From 8ba80d2dac230709d8cc080f46a68073d5daa684 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 14 Jan 2026 05:40:14 +0000 Subject: [PATCH] refactor(ui): split render + connections --- ...rm-renderer-flags-unsupported-unions-1.png | Bin 0 -> 2081 bytes ...er-renders-inputs-and-patches-values-1.png | Bin 0 -> 2081 bytes ...ers-union-literals-as-select-options-1.png | Bin 0 -> 2081 bytes ...s-chat-history-to-the-latest-message-1.png | Bin 0 -> 35014 bytes ui/src/ui/app-events.ts | 6 + ui/src/ui/app-render.helpers.ts | 223 +++ ui/src/ui/app-render.ts | 348 +---- ui/src/ui/app-view-state.ts | 197 +++ ui/src/ui/app.ts | 7 +- .../controllers/connections.save-discord.ts | 179 +++ .../controllers/connections.save-imessage.ts | 68 + .../ui/controllers/connections.save-signal.ts | 89 ++ .../ui/controllers/connections.save-slack.ts | 143 ++ ui/src/ui/controllers/connections.ts | 507 +------ ui/src/ui/controllers/connections.types.ts | 43 + ui/src/ui/views/config-form.analyze.ts | 121 ++ ui/src/ui/views/config-form.node.ts | 338 +++++ ui/src/ui/views/config-form.render.ts | 49 + ui/src/ui/views/config-form.shared.ts | 92 ++ ui/src/ui/views/config-form.ts | 716 +-------- .../ui/views/connections.discord.actions.ts | 31 + ui/src/ui/views/connections.discord.guilds.ts | 262 ++++ ui/src/ui/views/connections.discord.ts | 261 ++++ ui/src/ui/views/connections.imessage.ts | 184 +++ ui/src/ui/views/connections.signal.ts | 237 +++ ui/src/ui/views/connections.slack.ts | 391 +++++ ui/src/ui/views/connections.ts | 1352 +---------------- ui/src/ui/views/debug.ts | 8 +- 28 files changed, 2961 insertions(+), 2891 deletions(-) create mode 100644 ui/src/ui/__screenshots__/config-form.browser.test.ts/config-form-renderer-flags-unsupported-unions-1.png create mode 100644 ui/src/ui/__screenshots__/config-form.browser.test.ts/config-form-renderer-renders-inputs-and-patches-values-1.png create mode 100644 ui/src/ui/__screenshots__/config-form.browser.test.ts/config-form-renderer-renders-union-literals-as-select-options-1.png create mode 100644 ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png create mode 100644 ui/src/ui/app-events.ts create mode 100644 ui/src/ui/app-render.helpers.ts create mode 100644 ui/src/ui/app-view-state.ts create mode 100644 ui/src/ui/controllers/connections.save-discord.ts create mode 100644 ui/src/ui/controllers/connections.save-imessage.ts create mode 100644 ui/src/ui/controllers/connections.save-signal.ts create mode 100644 ui/src/ui/controllers/connections.save-slack.ts create mode 100644 ui/src/ui/controllers/connections.types.ts create mode 100644 ui/src/ui/views/config-form.analyze.ts create mode 100644 ui/src/ui/views/config-form.node.ts create mode 100644 ui/src/ui/views/config-form.render.ts create mode 100644 ui/src/ui/views/config-form.shared.ts create mode 100644 ui/src/ui/views/connections.discord.actions.ts create mode 100644 ui/src/ui/views/connections.discord.guilds.ts create mode 100644 ui/src/ui/views/connections.discord.ts create mode 100644 ui/src/ui/views/connections.imessage.ts create mode 100644 ui/src/ui/views/connections.signal.ts create mode 100644 ui/src/ui/views/connections.slack.ts diff --git a/ui/src/ui/__screenshots__/config-form.browser.test.ts/config-form-renderer-flags-unsupported-unions-1.png b/ui/src/ui/__screenshots__/config-form.browser.test.ts/config-form-renderer-flags-unsupported-unions-1.png new file mode 100644 index 0000000000000000000000000000000000000000..850d5b364ecb7abd932e8634bc40b802f9c6729f GIT binary patch literal 2081 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 literal 0 HcmV?d00001 diff --git a/ui/src/ui/app-events.ts b/ui/src/ui/app-events.ts new file mode 100644 index 000000000..c058cf73e --- /dev/null +++ b/ui/src/ui/app-events.ts @@ -0,0 +1,6 @@ +export type EventLogEntry = { + ts: number; + event: string; + payload?: unknown; +}; + diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts new file mode 100644 index 000000000..ad1d6c24a --- /dev/null +++ b/ui/src/ui/app-render.helpers.ts @@ -0,0 +1,223 @@ +import { html } from "lit"; + +import type { AppViewState } from "./app-view-state"; +import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation"; +import { loadChatHistory } from "./controllers/chat"; +import type { SessionsListResult } from "./types"; +import type { ThemeMode } from "./theme"; +import type { ThemeTransitionContext } from "./theme-transition"; + +export function renderTab(state: AppViewState, tab: Tab) { + const href = pathForTab(tab, state.basePath); + return html` + { + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) { + return; + } + event.preventDefault(); + state.setTab(tab); + }} + title=${titleForTab(tab)} + > + + ${titleForTab(tab)} + + `; +} + +export function renderChatControls(state: AppViewState) { + const sessionOptions = resolveSessionOptions(state.sessionKey, state.sessionsResult); + // Icon for list view (legacy) + const listIcon = html``; + // Icon for grouped view + const groupIcon = html``; + // Refresh icon + const refreshIcon = html``; + const focusIcon = html``; + return html` +
+ + + | + + +
+ `; +} + +function resolveSessionOptions(sessionKey: string, sessions: SessionsListResult | null) { + const seen = new Set(); + const options: Array<{ key: string; displayName?: string }> = []; + + // Add current session key first + seen.add(sessionKey); + options.push({ key: sessionKey }); + + // Add sessions from the result + if (sessions?.sessions) { + for (const s of sessions.sessions) { + if (!seen.has(s.key)) { + seen.add(s.key); + options.push({ key: s.key, displayName: s.displayName }); + } + } + } + + return options; +} + +const THEME_ORDER: ThemeMode[] = ["system", "light", "dark"]; + +export function renderThemeToggle(state: AppViewState) { + const index = Math.max(0, THEME_ORDER.indexOf(state.theme)); + const applyTheme = (next: ThemeMode) => (event: MouseEvent) => { + const element = event.currentTarget as HTMLElement; + const context: ThemeTransitionContext = { element }; + if (event.clientX || event.clientY) { + context.pointerClientX = event.clientX; + context.pointerClientY = event.clientY; + } + state.setTheme(next, context); + }; + + return html` +
+
+ + + + +
+
+ `; +} + +function renderSunIcon() { + return html` + + `; +} + +function renderMoonIcon() { + return html` + + `; +} + +function renderMonitorIcon() { + return html` + + `; +} diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 7face4b37..77e7a71d6 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -1,6 +1,7 @@ import { html, nothing } from "lit"; import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway"; +import type { AppViewState } from "./app-view-state"; import { TAB_GROUPS, iconForTab, @@ -46,6 +47,7 @@ import { renderNodes } from "./views/nodes"; import { renderOverview } from "./views/overview"; import { renderSessions } from "./views/sessions"; import { renderSkills } from "./views/skills"; +import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers"; import { loadChannels, updateDiscordForm, @@ -77,137 +79,6 @@ import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } fr import { loadDebug, callDebugMethod } from "./controllers/debug"; import { loadLogs } from "./controllers/logs"; -export type EventLogEntry = { - ts: number; - event: string; - payload?: unknown; -}; - -export type AppViewState = { - settings: UiSettings; - password: string; - tab: Tab; - basePath: string; - connected: boolean; - theme: ThemeMode; - themeResolved: "light" | "dark"; - hello: GatewayHelloOk | null; - lastError: string | null; - eventLog: EventLogEntry[]; - sessionKey: string; - chatLoading: boolean; - chatSending: boolean; - chatMessage: string; - chatMessages: unknown[]; - chatToolMessages: unknown[]; - chatStream: string | null; - chatRunId: string | null; - chatThinkingLevel: string | null; - chatQueue: ChatQueueItem[]; - nodesLoading: boolean; - nodes: Array>; - configLoading: boolean; - configRaw: string; - configValid: boolean | null; - configIssues: unknown[]; - configSaving: boolean; - configApplying: boolean; - updateRunning: boolean; - configSnapshot: ConfigSnapshot | null; - configSchema: unknown | null; - configSchemaLoading: boolean; - configUiHints: Record; - configForm: Record | null; - configFormMode: "form" | "raw"; - channelsLoading: boolean; - channelsSnapshot: ChannelsStatusSnapshot | null; - channelsError: string | null; - channelsLastSuccess: number | null; - whatsappLoginMessage: string | null; - whatsappLoginQrDataUrl: string | null; - whatsappLoginConnected: boolean | null; - whatsappBusy: boolean; - telegramForm: TelegramForm; - telegramSaving: boolean; - telegramTokenLocked: boolean; - telegramConfigStatus: string | null; - discordForm: DiscordForm; - discordSaving: boolean; - discordTokenLocked: boolean; - discordConfigStatus: string | null; - slackForm: SlackForm; - slackSaving: boolean; - slackTokenLocked: boolean; - slackAppTokenLocked: boolean; - slackConfigStatus: string | null; - signalForm: SignalForm; - signalSaving: boolean; - signalConfigStatus: string | null; - imessageForm: IMessageForm; - imessageSaving: boolean; - imessageConfigStatus: string | null; - presenceLoading: boolean; - presenceEntries: PresenceEntry[]; - presenceError: string | null; - presenceStatus: string | null; - sessionsLoading: boolean; - sessionsResult: SessionsListResult | null; - sessionsError: string | null; - sessionsFilterActive: string; - sessionsFilterLimit: string; - sessionsIncludeGlobal: boolean; - sessionsIncludeUnknown: boolean; - cronLoading: boolean; - cronJobs: CronJob[]; - cronStatus: CronStatus | null; - cronError: string | null; - cronForm: CronFormState; - cronRunsJobId: string | null; - cronRuns: CronRunLogEntry[]; - cronBusy: boolean; - skillsLoading: boolean; - skillsReport: SkillStatusReport | null; - skillsError: string | null; - skillsFilter: string; - skillEdits: Record; - skillMessages: Record; - skillsBusyKey: string | null; - debugLoading: boolean; - debugStatus: StatusSummary | null; - debugHealth: HealthSnapshot | null; - debugModels: unknown[]; - debugHeartbeat: unknown | null; - debugCallMethod: string; - debugCallParams: string; - debugCallResult: string | null; - debugCallError: string | null; - logsLoading: boolean; - logsError: string | null; - logsFile: string | null; - logsEntries: LogEntry[]; - logsFilterText: string; - logsLevelFilters: Record; - logsAutoFollow: boolean; - logsTruncated: boolean; - client: GatewayBrowserClient | null; - connect: () => void; - setTab: (tab: Tab) => void; - setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void; - applySettings: (next: UiSettings) => void; - loadOverview: () => Promise; - loadCron: () => Promise; - handleWhatsAppStart: (force: boolean) => Promise; - handleWhatsAppWait: () => Promise; - handleWhatsAppLogout: () => Promise; - handleTelegramSave: () => Promise; - handleSendChat: (messageOverride?: string, opts?: { restoreDraft?: boolean }) => Promise; - handleAbortChat: () => Promise; - removeQueuedMessage: (id: string) => void; - resetToolStream: () => void; - handleLogsScroll: (event: Event) => void; - exportLogs: (lines: string[], label: string) => void; -}; - export function renderApp(state: AppViewState) { const presenceCount = state.presenceEntries.length; const sessionsCount = state.sessionsResult?.count ?? null; @@ -589,218 +460,3 @@ export function renderApp(state: AppViewState) { `; } - -function renderTab(state: AppViewState, tab: Tab) { - const href = pathForTab(tab, state.basePath); - return html` - { - if ( - event.defaultPrevented || - event.button !== 0 || - event.metaKey || - event.ctrlKey || - event.shiftKey || - event.altKey - ) { - return; - } - event.preventDefault(); - state.setTab(tab); - }} - title=${titleForTab(tab)} - > - - ${titleForTab(tab)} - - `; -} - -function renderChatControls(state: AppViewState) { - const sessionOptions = resolveSessionOptions(state.sessionKey, state.sessionsResult); - // Icon for list view (legacy) - const listIcon = html``; - // Icon for grouped view - const groupIcon = html``; - // Refresh icon - const refreshIcon = html``; - const focusIcon = html``; - return html` -
- - - | - - -
- `; -} - -function resolveSessionOptions(sessionKey: string, sessions: SessionsListResult | null) { - const seen = new Set(); - const options: Array<{ key: string; displayName?: string }> = []; - - // Add current session key first - seen.add(sessionKey); - options.push({ key: sessionKey }); - - // Add sessions from the result - if (sessions?.sessions) { - for (const s of sessions.sessions) { - if (!seen.has(s.key)) { - seen.add(s.key); - options.push({ key: s.key, displayName: s.displayName }); - } - } - } - - return options; -} - -const THEME_ORDER: ThemeMode[] = ["system", "light", "dark"]; - -function renderThemeToggle(state: AppViewState) { - const index = Math.max(0, THEME_ORDER.indexOf(state.theme)); - const applyTheme = (next: ThemeMode) => (event: MouseEvent) => { - const element = event.currentTarget as HTMLElement; - const context: ThemeTransitionContext = { element }; - if (event.clientX || event.clientY) { - context.pointerClientX = event.clientX; - context.pointerClientY = event.clientY; - } - state.setTheme(next, context); - }; - - return html` -
-
- - - - -
-
- `; -} - -function renderSunIcon() { - return html` - - `; -} - -function renderMoonIcon() { - return html` - - `; -} - -function renderMonitorIcon() { - return html` - - `; -} diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts new file mode 100644 index 000000000..4d4750793 --- /dev/null +++ b/ui/src/ui/app-view-state.ts @@ -0,0 +1,197 @@ +import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway"; +import type { Tab } from "./navigation"; +import type { UiSettings } from "./storage"; +import type { ThemeMode } from "./theme"; +import type { ThemeTransitionContext } from "./theme-transition"; +import type { + ChannelsStatusSnapshot, + ConfigSnapshot, + CronJob, + CronRunLogEntry, + CronStatus, + HealthSnapshot, + LogEntry, + LogLevel, + PresenceEntry, + SessionsListResult, + SkillStatusReport, + StatusSummary, +} from "./types"; +import type { + ChatQueueItem, + CronFormState, + DiscordForm, + IMessageForm, + SlackForm, + SignalForm, + TelegramForm, +} from "./ui-types"; +import type { EventLogEntry } from "./app-events"; +import type { SkillMessage } from "./controllers/skills"; + +export type AppViewState = { + settings: UiSettings; + password: string; + tab: Tab; + basePath: string; + connected: boolean; + theme: ThemeMode; + themeResolved: "light" | "dark"; + hello: GatewayHelloOk | null; + lastError: string | null; + eventLog: EventLogEntry[]; + sessionKey: string; + chatLoading: boolean; + chatSending: boolean; + chatMessage: string; + chatMessages: unknown[]; + chatToolMessages: unknown[]; + chatStream: string | null; + chatRunId: string | null; + chatThinkingLevel: string | null; + chatQueue: ChatQueueItem[]; + nodesLoading: boolean; + nodes: Array>; + configLoading: boolean; + configRaw: string; + configValid: boolean | null; + configIssues: unknown[]; + configSaving: boolean; + configApplying: boolean; + updateRunning: boolean; + configSnapshot: ConfigSnapshot | null; + configSchema: unknown | null; + configSchemaLoading: boolean; + configUiHints: Record; + configForm: Record | null; + configFormMode: "form" | "raw"; + channelsLoading: boolean; + channelsSnapshot: ChannelsStatusSnapshot | null; + channelsError: string | null; + channelsLastSuccess: number | null; + whatsappLoginMessage: string | null; + whatsappLoginQrDataUrl: string | null; + whatsappLoginConnected: boolean | null; + whatsappBusy: boolean; + telegramForm: TelegramForm; + telegramSaving: boolean; + telegramTokenLocked: boolean; + telegramConfigStatus: string | null; + discordForm: DiscordForm; + discordSaving: boolean; + discordTokenLocked: boolean; + discordConfigStatus: string | null; + slackForm: SlackForm; + slackSaving: boolean; + slackTokenLocked: boolean; + slackAppTokenLocked: boolean; + slackConfigStatus: string | null; + signalForm: SignalForm; + signalSaving: boolean; + signalConfigStatus: string | null; + imessageForm: IMessageForm; + imessageSaving: boolean; + imessageConfigStatus: string | null; + presenceLoading: boolean; + presenceEntries: PresenceEntry[]; + presenceError: string | null; + presenceStatus: string | null; + sessionsLoading: boolean; + sessionsResult: SessionsListResult | null; + sessionsError: string | null; + sessionsFilterActive: string; + sessionsFilterLimit: string; + sessionsIncludeGlobal: boolean; + sessionsIncludeUnknown: boolean; + cronLoading: boolean; + cronJobs: CronJob[]; + cronStatus: CronStatus | null; + cronError: string | null; + cronForm: CronFormState; + cronRunsJobId: string | null; + cronRuns: CronRunLogEntry[]; + cronBusy: boolean; + skillsLoading: boolean; + skillsReport: SkillStatusReport | null; + skillsError: string | null; + skillsFilter: string; + skillEdits: Record; + skillMessages: Record; + skillsBusyKey: string | null; + debugLoading: boolean; + debugStatus: StatusSummary | null; + debugHealth: HealthSnapshot | null; + debugModels: unknown[]; + debugHeartbeat: unknown | null; + debugCallMethod: string; + debugCallParams: string; + debugCallResult: string | null; + debugCallError: string | null; + logsLoading: boolean; + logsError: string | null; + logsFile: string | null; + logsEntries: LogEntry[]; + logsFilterText: string; + logsLevelFilters: Record; + logsAutoFollow: boolean; + logsTruncated: boolean; + client: GatewayBrowserClient | null; + connect: () => void; + setTab: (tab: Tab) => void; + setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void; + applySettings: (next: UiSettings) => void; + loadOverview: () => Promise; + loadCron: () => Promise; + handleWhatsAppStart: (force: boolean) => Promise; + handleWhatsAppWait: () => Promise; + handleWhatsAppLogout: () => Promise; + handleTelegramSave: () => Promise; + handleDiscordSave: () => Promise; + handleSlackSave: () => Promise; + handleSignalSave: () => Promise; + handleIMessageSave: () => Promise; + handleConfigLoad: () => Promise; + handleConfigSave: () => Promise; + handleConfigApply: () => Promise; + handleConfigFormUpdate: (path: string, value: unknown) => void; + handleConfigFormModeChange: (mode: "form" | "raw") => void; + handleConfigRawChange: (raw: string) => void; + handleInstallSkill: (key: string) => Promise; + handleUpdateSkill: (key: string) => Promise; + handleToggleSkillEnabled: (key: string, enabled: boolean) => Promise; + handleUpdateSkillEdit: (key: string, value: string) => void; + handleSaveSkillApiKey: (key: string, apiKey: string) => Promise; + handleCronToggle: (jobId: string, enabled: boolean) => Promise; + handleCronRun: (jobId: string) => Promise; + handleCronRemove: (jobId: string) => Promise; + handleCronAdd: () => Promise; + handleCronRunsLoad: (jobId: string) => Promise; + handleCronFormUpdate: (path: string, value: unknown) => void; + handleSessionsLoad: () => Promise; + handleSessionsPatch: (key: string, patch: unknown) => Promise; + handleLoadNodes: () => Promise; + handleLoadPresence: () => Promise; + handleLoadSkills: () => Promise; + handleLoadDebug: () => Promise; + handleLoadLogs: () => Promise; + handleDebugCall: () => Promise; + handleRunUpdate: () => Promise; + setPassword: (next: string) => void; + setSessionKey: (next: string) => void; + setChatMessage: (next: string) => void; + handleChatSend: () => Promise; + handleChatAbort: () => Promise; + handleChatSelectQueueItem: (id: string) => void; + handleChatDropQueueItem: (id: string) => void; + handleChatClearQueue: () => void; + handleLogsFilterChange: (next: string) => void; + handleLogsLevelFilterToggle: (level: LogLevel) => void; + handleLogsAutoFollowToggle: (next: boolean) => void; + handleCallDebugMethod: (method: string, params: string) => Promise; + handleUpdateDiscordForm: (path: string, value: unknown) => void; + handleUpdateSlackForm: (path: string, value: unknown) => void; + handleUpdateSignalForm: (path: string, value: unknown) => void; + handleUpdateTelegramForm: (path: string, value: unknown) => void; + handleUpdateIMessageForm: (path: string, value: unknown) => void; +}; + diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 02b7ed6a5..42d879c82 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -85,12 +85,7 @@ import { } from "./controllers/skills"; import { loadDebug } from "./controllers/debug"; import { loadLogs } from "./controllers/logs"; - -type EventLogEntry = { - ts: number; - event: string; - payload?: unknown; -}; +import type { EventLogEntry } from "./app-events"; const TOOL_STREAM_LIMIT = 50; const TOOL_STREAM_THROTTLE_MS = 80; diff --git a/ui/src/ui/controllers/connections.save-discord.ts b/ui/src/ui/controllers/connections.save-discord.ts new file mode 100644 index 000000000..f00981698 --- /dev/null +++ b/ui/src/ui/controllers/connections.save-discord.ts @@ -0,0 +1,179 @@ +import { parseList } from "../format"; +import { + defaultDiscordActions, + type DiscordActionForm, + type DiscordGuildChannelForm, + type DiscordGuildForm, +} from "../ui-types"; +import type { ConnectionsState } from "./connections.types"; + +export async function saveDiscordConfig(state: ConnectionsState) { + if (!state.client || !state.connected) return; + if (state.discordSaving) return; + state.discordSaving = true; + state.discordConfigStatus = null; + try { + const base = state.configSnapshot?.config ?? {}; + const config = { ...base } as Record; + const discord = { ...(config.discord ?? {}) } as Record; + const form = state.discordForm; + + if (form.enabled) { + delete discord.enabled; + } else { + discord.enabled = false; + } + + if (!state.discordTokenLocked) { + const token = form.token.trim(); + if (token) discord.token = token; + else delete discord.token; + } + + const allowFrom = parseList(form.allowFrom); + const groupChannels = parseList(form.groupChannels); + const dm = { ...(discord.dm ?? {}) } as Record; + if (form.dmEnabled) delete dm.enabled; + else dm.enabled = false; + if (allowFrom.length > 0) dm.allowFrom = allowFrom; + else delete dm.allowFrom; + if (form.groupEnabled) dm.groupEnabled = true; + else delete dm.groupEnabled; + if (groupChannels.length > 0) dm.groupChannels = groupChannels; + else delete dm.groupChannels; + if (Object.keys(dm).length > 0) discord.dm = dm; + else delete discord.dm; + + const mediaMaxMb = Number(form.mediaMaxMb); + if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { + discord.mediaMaxMb = mediaMaxMb; + } else { + delete discord.mediaMaxMb; + } + + const historyLimitRaw = form.historyLimit.trim(); + if (historyLimitRaw.length === 0) { + delete discord.historyLimit; + } else { + const historyLimit = Number(historyLimitRaw); + if (Number.isFinite(historyLimit) && historyLimit >= 0) { + discord.historyLimit = historyLimit; + } else { + delete discord.historyLimit; + } + } + + const chunkLimitRaw = form.textChunkLimit.trim(); + if (chunkLimitRaw.length === 0) { + delete discord.textChunkLimit; + } else { + const chunkLimit = Number(chunkLimitRaw); + if (Number.isFinite(chunkLimit) && chunkLimit > 0) { + discord.textChunkLimit = chunkLimit; + } else { + delete discord.textChunkLimit; + } + } + + if (form.replyToMode === "off") { + delete discord.replyToMode; + } else { + discord.replyToMode = form.replyToMode; + } + + const guildsForm = Array.isArray(form.guilds) ? form.guilds : []; + const guilds: Record = {}; + guildsForm.forEach((guild: DiscordGuildForm) => { + const key = String(guild.key ?? "").trim(); + if (!key) return; + const entry: Record = {}; + const slug = String(guild.slug ?? "").trim(); + if (slug) entry.slug = slug; + if (guild.requireMention) entry.requireMention = true; + if ( + guild.reactionNotifications === "off" || + guild.reactionNotifications === "all" || + guild.reactionNotifications === "own" || + guild.reactionNotifications === "allowlist" + ) { + entry.reactionNotifications = guild.reactionNotifications; + } + const users = parseList(guild.users); + if (users.length > 0) entry.users = users; + const channels: Record = {}; + const channelForms = Array.isArray(guild.channels) ? guild.channels : []; + channelForms.forEach((channel: DiscordGuildChannelForm) => { + const channelKey = String(channel.key ?? "").trim(); + if (!channelKey) return; + const channelEntry: Record = {}; + if (channel.allow === false) channelEntry.allow = false; + if (channel.requireMention) channelEntry.requireMention = true; + channels[channelKey] = channelEntry; + }); + if (Object.keys(channels).length > 0) entry.channels = channels; + guilds[key] = entry; + }); + if (Object.keys(guilds).length > 0) discord.guilds = guilds; + else delete discord.guilds; + + const actions: Partial = {}; + const applyAction = (key: keyof DiscordActionForm) => { + const value = form.actions[key]; + if (value !== defaultDiscordActions[key]) actions[key] = value; + }; + applyAction("reactions"); + applyAction("stickers"); + applyAction("polls"); + applyAction("permissions"); + applyAction("messages"); + applyAction("threads"); + applyAction("pins"); + applyAction("search"); + applyAction("memberInfo"); + applyAction("roleInfo"); + applyAction("channelInfo"); + applyAction("voiceStatus"); + applyAction("events"); + applyAction("roles"); + applyAction("moderation"); + if (Object.keys(actions).length > 0) { + discord.actions = actions; + } else { + delete discord.actions; + } + + const slash = { ...(discord.slashCommand ?? {}) } as Record; + if (form.slashEnabled) { + slash.enabled = true; + } else { + delete slash.enabled; + } + if (form.slashName.trim()) slash.name = form.slashName.trim(); + else delete slash.name; + if (form.slashSessionPrefix.trim()) + slash.sessionPrefix = form.slashSessionPrefix.trim(); + else delete slash.sessionPrefix; + if (form.slashEphemeral) { + delete slash.ephemeral; + } else { + slash.ephemeral = false; + } + if (Object.keys(slash).length > 0) discord.slashCommand = slash; + else delete discord.slashCommand; + + if (Object.keys(discord).length > 0) { + config.discord = discord; + } else { + delete config.discord; + } + + const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`; + await state.client.request("config.set", { raw }); + state.discordConfigStatus = "Saved. Restart gateway if needed."; + } catch (err) { + state.discordConfigStatus = String(err); + } finally { + state.discordSaving = false; + } +} + diff --git a/ui/src/ui/controllers/connections.save-imessage.ts b/ui/src/ui/controllers/connections.save-imessage.ts new file mode 100644 index 000000000..70eed860b --- /dev/null +++ b/ui/src/ui/controllers/connections.save-imessage.ts @@ -0,0 +1,68 @@ +import { parseList } from "../format"; +import type { ConnectionsState } from "./connections.types"; + +export async function saveIMessageConfig(state: ConnectionsState) { + if (!state.client || !state.connected) return; + if (state.imessageSaving) return; + state.imessageSaving = true; + state.imessageConfigStatus = null; + try { + const base = state.configSnapshot?.config ?? {}; + const config = { ...base } as Record; + const imessage = { ...(config.imessage ?? {}) } as Record; + const form = state.imessageForm; + + if (form.enabled) { + delete imessage.enabled; + } else { + imessage.enabled = false; + } + + const cliPath = form.cliPath.trim(); + if (cliPath) imessage.cliPath = cliPath; + else delete imessage.cliPath; + + const dbPath = form.dbPath.trim(); + if (dbPath) imessage.dbPath = dbPath; + else delete imessage.dbPath; + + if (form.service === "auto") { + delete imessage.service; + } else { + imessage.service = form.service; + } + + const region = form.region.trim(); + if (region) imessage.region = region; + else delete imessage.region; + + const allowFrom = parseList(form.allowFrom); + if (allowFrom.length > 0) imessage.allowFrom = allowFrom; + else delete imessage.allowFrom; + + if (form.includeAttachments) imessage.includeAttachments = true; + else delete imessage.includeAttachments; + + const mediaMaxMb = Number(form.mediaMaxMb); + if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { + imessage.mediaMaxMb = mediaMaxMb; + } else { + delete imessage.mediaMaxMb; + } + + if (Object.keys(imessage).length > 0) { + config.imessage = imessage; + } else { + delete config.imessage; + } + + const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`; + await state.client.request("config.set", { raw }); + state.imessageConfigStatus = "Saved. Restart gateway if needed."; + } catch (err) { + state.imessageConfigStatus = String(err); + } finally { + state.imessageSaving = false; + } +} + diff --git a/ui/src/ui/controllers/connections.save-signal.ts b/ui/src/ui/controllers/connections.save-signal.ts new file mode 100644 index 000000000..e07a71f99 --- /dev/null +++ b/ui/src/ui/controllers/connections.save-signal.ts @@ -0,0 +1,89 @@ +import { parseList } from "../format"; +import type { ConnectionsState } from "./connections.types"; + +export async function saveSignalConfig(state: ConnectionsState) { + if (!state.client || !state.connected) return; + if (state.signalSaving) return; + state.signalSaving = true; + state.signalConfigStatus = null; + try { + const base = state.configSnapshot?.config ?? {}; + const config = { ...base } as Record; + const signal = { ...(config.signal ?? {}) } as Record; + const form = state.signalForm; + + if (form.enabled) { + delete signal.enabled; + } else { + signal.enabled = false; + } + + const account = form.account.trim(); + if (account) signal.account = account; + else delete signal.account; + + const httpUrl = form.httpUrl.trim(); + if (httpUrl) signal.httpUrl = httpUrl; + else delete signal.httpUrl; + + const httpHost = form.httpHost.trim(); + if (httpHost) signal.httpHost = httpHost; + else delete signal.httpHost; + + const httpPort = Number(form.httpPort); + if (Number.isFinite(httpPort) && httpPort > 0) { + signal.httpPort = httpPort; + } else { + delete signal.httpPort; + } + + const cliPath = form.cliPath.trim(); + if (cliPath) signal.cliPath = cliPath; + else delete signal.cliPath; + + if (form.autoStart) { + delete signal.autoStart; + } else { + signal.autoStart = false; + } + + if (form.receiveMode === "on-start" || form.receiveMode === "manual") { + signal.receiveMode = form.receiveMode; + } else { + delete signal.receiveMode; + } + + if (form.ignoreAttachments) signal.ignoreAttachments = true; + else delete signal.ignoreAttachments; + if (form.ignoreStories) signal.ignoreStories = true; + else delete signal.ignoreStories; + if (form.sendReadReceipts) signal.sendReadReceipts = true; + else delete signal.sendReadReceipts; + + const allowFrom = parseList(form.allowFrom); + if (allowFrom.length > 0) signal.allowFrom = allowFrom; + else delete signal.allowFrom; + + const mediaMaxMb = Number(form.mediaMaxMb); + if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { + signal.mediaMaxMb = mediaMaxMb; + } else { + delete signal.mediaMaxMb; + } + + if (Object.keys(signal).length > 0) { + config.signal = signal; + } else { + delete config.signal; + } + + const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`; + await state.client.request("config.set", { raw }); + state.signalConfigStatus = "Saved. Restart gateway if needed."; + } catch (err) { + state.signalConfigStatus = String(err); + } finally { + state.signalSaving = false; + } +} + diff --git a/ui/src/ui/controllers/connections.save-slack.ts b/ui/src/ui/controllers/connections.save-slack.ts new file mode 100644 index 000000000..19345505e --- /dev/null +++ b/ui/src/ui/controllers/connections.save-slack.ts @@ -0,0 +1,143 @@ +import { parseList } from "../format"; +import { defaultSlackActions, type SlackActionForm } from "../ui-types"; +import type { ConnectionsState } from "./connections.types"; + +export async function saveSlackConfig(state: ConnectionsState) { + if (!state.client || !state.connected) return; + if (state.slackSaving) return; + state.slackSaving = true; + state.slackConfigStatus = null; + try { + const base = state.configSnapshot?.config ?? {}; + const config = { ...base } as Record; + const slack = { ...(config.slack ?? {}) } as Record; + const form = state.slackForm; + + if (form.enabled) { + delete slack.enabled; + } else { + slack.enabled = false; + } + + if (!state.slackTokenLocked) { + const token = form.botToken.trim(); + if (token) slack.botToken = token; + else delete slack.botToken; + } + if (!state.slackAppTokenLocked) { + const token = form.appToken.trim(); + if (token) slack.appToken = token; + else delete slack.appToken; + } + + const dm = { ...(slack.dm ?? {}) } as Record; + dm.enabled = form.dmEnabled; + const allowFrom = parseList(form.allowFrom); + if (allowFrom.length > 0) dm.allowFrom = allowFrom; + else delete dm.allowFrom; + if (form.groupEnabled) { + dm.groupEnabled = true; + } else { + delete dm.groupEnabled; + } + const groupChannels = parseList(form.groupChannels); + if (groupChannels.length > 0) dm.groupChannels = groupChannels; + else delete dm.groupChannels; + if (Object.keys(dm).length > 0) slack.dm = dm; + else delete slack.dm; + + const mediaMaxMb = Number.parseFloat(form.mediaMaxMb); + if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { + slack.mediaMaxMb = mediaMaxMb; + } else { + delete slack.mediaMaxMb; + } + + const textChunkLimit = Number.parseInt(form.textChunkLimit, 10); + if (Number.isFinite(textChunkLimit) && textChunkLimit > 0) { + slack.textChunkLimit = textChunkLimit; + } else { + delete slack.textChunkLimit; + } + + if (form.reactionNotifications === "own") { + delete slack.reactionNotifications; + } else { + slack.reactionNotifications = form.reactionNotifications; + } + const reactionAllowlist = parseList(form.reactionAllowlist); + if (reactionAllowlist.length > 0) { + slack.reactionAllowlist = reactionAllowlist; + } else { + delete slack.reactionAllowlist; + } + + const slash = { ...(slack.slashCommand ?? {}) } as Record; + if (form.slashEnabled) { + slash.enabled = true; + } else { + delete slash.enabled; + } + if (form.slashName.trim()) slash.name = form.slashName.trim(); + else delete slash.name; + if (form.slashSessionPrefix.trim()) + slash.sessionPrefix = form.slashSessionPrefix.trim(); + else delete slash.sessionPrefix; + if (form.slashEphemeral) { + delete slash.ephemeral; + } else { + slash.ephemeral = false; + } + if (Object.keys(slash).length > 0) slack.slashCommand = slash; + else delete slack.slashCommand; + + const actions: Partial = {}; + const applyAction = (key: keyof SlackActionForm) => { + const value = form.actions[key]; + if (value !== defaultSlackActions[key]) actions[key] = value; + }; + applyAction("reactions"); + applyAction("messages"); + applyAction("pins"); + applyAction("memberInfo"); + applyAction("emojiList"); + if (Object.keys(actions).length > 0) { + slack.actions = actions; + } else { + delete slack.actions; + } + + const channels = form.channels + .map((entry): [string, Record] | null => { + const key = entry.key.trim(); + if (!key) return null; + const record: Record = { + allow: entry.allow, + requireMention: entry.requireMention, + }; + return [key, record]; + }) + .filter((value): value is [string, Record] => + Boolean(value), + ); + if (channels.length > 0) { + slack.channels = Object.fromEntries(channels); + } else { + delete slack.channels; + } + + if (Object.keys(slack).length > 0) { + config.slack = slack; + } else { + delete config.slack; + } + + const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`; + await state.client.request("config.set", { raw }); + state.slackConfigStatus = "Saved. Restart gateway if needed."; + } catch (err) { + state.slackConfigStatus = String(err); + } finally { + state.slackSaving = false; + } +} diff --git a/ui/src/ui/controllers/connections.ts b/ui/src/ui/controllers/connections.ts index 2bff0b017..50e2472b4 100644 --- a/ui/src/ui/controllers/connections.ts +++ b/ui/src/ui/controllers/connections.ts @@ -1,52 +1,20 @@ -import type { GatewayBrowserClient } from "../gateway"; import { parseList } from "../format"; -import type { ChannelsStatusSnapshot, ConfigSnapshot } from "../types"; +import type { ChannelsStatusSnapshot } from "../types"; import { - defaultDiscordActions, - defaultSlackActions, - type DiscordActionForm, type DiscordForm, - type DiscordGuildChannelForm, - type DiscordGuildForm, type IMessageForm, - type SlackActionForm, type SlackForm, type SignalForm, type TelegramForm, } from "../ui-types"; +import type { ConnectionsState } from "./connections.types"; -export type ConnectionsState = { - client: GatewayBrowserClient | null; - connected: boolean; - channelsLoading: boolean; - channelsSnapshot: ChannelsStatusSnapshot | null; - channelsError: string | null; - channelsLastSuccess: number | null; - whatsappLoginMessage: string | null; - whatsappLoginQrDataUrl: string | null; - whatsappLoginConnected: boolean | null; - whatsappBusy: boolean; - telegramForm: TelegramForm; - telegramSaving: boolean; - telegramTokenLocked: boolean; - telegramConfigStatus: string | null; - discordForm: DiscordForm; - discordSaving: boolean; - discordTokenLocked: boolean; - discordConfigStatus: string | null; - slackForm: SlackForm; - slackSaving: boolean; - slackTokenLocked: boolean; - slackAppTokenLocked: boolean; - slackConfigStatus: string | null; - signalForm: SignalForm; - signalSaving: boolean; - signalConfigStatus: string | null; - imessageForm: IMessageForm; - imessageSaving: boolean; - imessageConfigStatus: string | null; - configSnapshot: ConfigSnapshot | null; -}; +export { saveDiscordConfig } from "./connections.save-discord"; +export { saveIMessageConfig } from "./connections.save-imessage"; +export { saveSlackConfig } from "./connections.save-slack"; +export { saveSignalConfig } from "./connections.save-signal"; + +export type { ConnectionsState }; export async function loadChannels(state: ConnectionsState, probe: boolean) { if (!state.client || !state.connected) return; @@ -254,462 +222,3 @@ export async function saveTelegramConfig(state: ConnectionsState) { state.telegramSaving = false; } } - -export async function saveDiscordConfig(state: ConnectionsState) { - if (!state.client || !state.connected) return; - if (state.discordSaving) return; - state.discordSaving = true; - state.discordConfigStatus = null; - try { - const base = state.configSnapshot?.config ?? {}; - const config = { ...base } as Record; - const discord = { ...(config.discord ?? {}) } as Record; - const form = state.discordForm; - - if (form.enabled) { - delete discord.enabled; - } else { - discord.enabled = false; - } - - if (!state.discordTokenLocked) { - const token = form.token.trim(); - if (token) discord.token = token; - else delete discord.token; - } - - const allowFrom = parseList(form.allowFrom); - const groupChannels = parseList(form.groupChannels); - const dm = { ...(discord.dm ?? {}) } as Record; - if (form.dmEnabled) delete dm.enabled; - else dm.enabled = false; - if (allowFrom.length > 0) dm.allowFrom = allowFrom; - else delete dm.allowFrom; - if (form.groupEnabled) dm.groupEnabled = true; - else delete dm.groupEnabled; - if (groupChannels.length > 0) dm.groupChannels = groupChannels; - else delete dm.groupChannels; - if (Object.keys(dm).length > 0) discord.dm = dm; - else delete discord.dm; - - const mediaMaxMb = Number(form.mediaMaxMb); - if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { - discord.mediaMaxMb = mediaMaxMb; - } else { - delete discord.mediaMaxMb; - } - - const historyLimitRaw = form.historyLimit.trim(); - if (historyLimitRaw.length === 0) { - delete discord.historyLimit; - } else { - const historyLimit = Number(historyLimitRaw); - if (Number.isFinite(historyLimit) && historyLimit >= 0) { - discord.historyLimit = historyLimit; - } else { - delete discord.historyLimit; - } - } - - const chunkLimitRaw = form.textChunkLimit.trim(); - if (chunkLimitRaw.length === 0) { - delete discord.textChunkLimit; - } else { - const chunkLimit = Number(chunkLimitRaw); - if (Number.isFinite(chunkLimit) && chunkLimit > 0) { - discord.textChunkLimit = chunkLimit; - } else { - delete discord.textChunkLimit; - } - } - - if (form.replyToMode === "off") { - delete discord.replyToMode; - } else { - discord.replyToMode = form.replyToMode; - } - - const guildsForm = Array.isArray(form.guilds) ? form.guilds : []; - const guilds: Record = {}; - guildsForm.forEach((guild: DiscordGuildForm) => { - const key = String(guild.key ?? "").trim(); - if (!key) return; - const entry: Record = {}; - const slug = String(guild.slug ?? "").trim(); - if (slug) entry.slug = slug; - if (guild.requireMention) entry.requireMention = true; - if ( - guild.reactionNotifications === "off" || - guild.reactionNotifications === "all" || - guild.reactionNotifications === "own" || - guild.reactionNotifications === "allowlist" - ) { - entry.reactionNotifications = guild.reactionNotifications; - } - const users = parseList(guild.users); - if (users.length > 0) entry.users = users; - const channels: Record = {}; - const channelForms = Array.isArray(guild.channels) ? guild.channels : []; - channelForms.forEach((channel: DiscordGuildChannelForm) => { - const channelKey = String(channel.key ?? "").trim(); - if (!channelKey) return; - const channelEntry: Record = {}; - if (channel.allow === false) channelEntry.allow = false; - if (channel.requireMention) channelEntry.requireMention = true; - channels[channelKey] = channelEntry; - }); - if (Object.keys(channels).length > 0) entry.channels = channels; - guilds[key] = entry; - }); - if (Object.keys(guilds).length > 0) discord.guilds = guilds; - else delete discord.guilds; - - const actions: Partial = {}; - const applyAction = (key: keyof DiscordActionForm) => { - const value = form.actions[key]; - if (value !== defaultDiscordActions[key]) actions[key] = value; - }; - applyAction("reactions"); - applyAction("stickers"); - applyAction("polls"); - applyAction("permissions"); - applyAction("messages"); - applyAction("threads"); - applyAction("pins"); - applyAction("search"); - applyAction("memberInfo"); - applyAction("roleInfo"); - applyAction("channelInfo"); - applyAction("voiceStatus"); - applyAction("events"); - applyAction("roles"); - applyAction("moderation"); - if (Object.keys(actions).length > 0) { - discord.actions = actions; - } else { - delete discord.actions; - } - - const slash = { ...(discord.slashCommand ?? {}) } as Record; - if (form.slashEnabled) { - slash.enabled = true; - } else { - delete slash.enabled; - } - if (form.slashName.trim()) slash.name = form.slashName.trim(); - else delete slash.name; - if (form.slashSessionPrefix.trim()) - slash.sessionPrefix = form.slashSessionPrefix.trim(); - else delete slash.sessionPrefix; - if (form.slashEphemeral) { - delete slash.ephemeral; - } else { - slash.ephemeral = false; - } - if (Object.keys(slash).length > 0) discord.slashCommand = slash; - else delete discord.slashCommand; - - if (Object.keys(discord).length > 0) { - config.discord = discord; - } else { - delete config.discord; - } - - const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`; - await state.client.request("config.set", { raw }); - state.discordConfigStatus = "Saved. Restart gateway if needed."; - } catch (err) { - state.discordConfigStatus = String(err); - } finally { - state.discordSaving = false; - } -} - -export async function saveSlackConfig(state: ConnectionsState) { - if (!state.client || !state.connected) return; - if (state.slackSaving) return; - state.slackSaving = true; - state.slackConfigStatus = null; - try { - const base = state.configSnapshot?.config ?? {}; - const config = { ...base } as Record; - const slack = { ...(config.slack ?? {}) } as Record; - const form = state.slackForm; - - if (form.enabled) { - delete slack.enabled; - } else { - slack.enabled = false; - } - - if (!state.slackTokenLocked) { - const token = form.botToken.trim(); - if (token) slack.botToken = token; - else delete slack.botToken; - } - if (!state.slackAppTokenLocked) { - const token = form.appToken.trim(); - if (token) slack.appToken = token; - else delete slack.appToken; - } - - const dm = { ...(slack.dm ?? {}) } as Record; - dm.enabled = form.dmEnabled; - const allowFrom = parseList(form.allowFrom); - if (allowFrom.length > 0) dm.allowFrom = allowFrom; - else delete dm.allowFrom; - if (form.groupEnabled) { - dm.groupEnabled = true; - } else { - delete dm.groupEnabled; - } - const groupChannels = parseList(form.groupChannels); - if (groupChannels.length > 0) dm.groupChannels = groupChannels; - else delete dm.groupChannels; - if (Object.keys(dm).length > 0) slack.dm = dm; - else delete slack.dm; - - const mediaMaxMb = Number.parseFloat(form.mediaMaxMb); - if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { - slack.mediaMaxMb = mediaMaxMb; - } else { - delete slack.mediaMaxMb; - } - - const textChunkLimit = Number.parseInt(form.textChunkLimit, 10); - if (Number.isFinite(textChunkLimit) && textChunkLimit > 0) { - slack.textChunkLimit = textChunkLimit; - } else { - delete slack.textChunkLimit; - } - - if (form.reactionNotifications === "own") { - delete slack.reactionNotifications; - } else { - slack.reactionNotifications = form.reactionNotifications; - } - const reactionAllowlist = parseList(form.reactionAllowlist); - if (reactionAllowlist.length > 0) { - slack.reactionAllowlist = reactionAllowlist; - } else { - delete slack.reactionAllowlist; - } - - const slash = { ...(slack.slashCommand ?? {}) } as Record; - if (form.slashEnabled) { - slash.enabled = true; - } else { - delete slash.enabled; - } - if (form.slashName.trim()) slash.name = form.slashName.trim(); - else delete slash.name; - if (form.slashSessionPrefix.trim()) - slash.sessionPrefix = form.slashSessionPrefix.trim(); - else delete slash.sessionPrefix; - if (form.slashEphemeral) { - delete slash.ephemeral; - } else { - slash.ephemeral = false; - } - if (Object.keys(slash).length > 0) slack.slashCommand = slash; - else delete slack.slashCommand; - - const actions: Partial = {}; - const applyAction = (key: keyof SlackActionForm) => { - const value = form.actions[key]; - if (value !== defaultSlackActions[key]) actions[key] = value; - }; - applyAction("reactions"); - applyAction("messages"); - applyAction("pins"); - applyAction("memberInfo"); - applyAction("emojiList"); - if (Object.keys(actions).length > 0) { - slack.actions = actions; - } else { - delete slack.actions; - } - - const channels = form.channels - .map((entry): [string, Record] | null => { - const key = entry.key.trim(); - if (!key) return null; - const record: Record = { - allow: entry.allow, - requireMention: entry.requireMention, - }; - return [key, record]; - }) - .filter((value): value is [string, Record] => Boolean(value)); - if (channels.length > 0) { - slack.channels = Object.fromEntries(channels); - } else { - delete slack.channels; - } - - if (Object.keys(slack).length > 0) { - config.slack = slack; - } else { - delete config.slack; - } - - const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`; - await state.client.request("config.set", { raw }); - state.slackConfigStatus = "Saved. Restart gateway if needed."; - } catch (err) { - state.slackConfigStatus = String(err); - } finally { - state.slackSaving = false; - } -} - -export async function saveSignalConfig(state: ConnectionsState) { - if (!state.client || !state.connected) return; - if (state.signalSaving) return; - state.signalSaving = true; - state.signalConfigStatus = null; - try { - const base = state.configSnapshot?.config ?? {}; - const config = { ...base } as Record; - const signal = { ...(config.signal ?? {}) } as Record; - const form = state.signalForm; - - if (form.enabled) { - delete signal.enabled; - } else { - signal.enabled = false; - } - - const account = form.account.trim(); - if (account) signal.account = account; - else delete signal.account; - - const httpUrl = form.httpUrl.trim(); - if (httpUrl) signal.httpUrl = httpUrl; - else delete signal.httpUrl; - - const httpHost = form.httpHost.trim(); - if (httpHost) signal.httpHost = httpHost; - else delete signal.httpHost; - - const httpPort = Number(form.httpPort); - if (Number.isFinite(httpPort) && httpPort > 0) { - signal.httpPort = httpPort; - } else { - delete signal.httpPort; - } - - const cliPath = form.cliPath.trim(); - if (cliPath) signal.cliPath = cliPath; - else delete signal.cliPath; - - if (form.autoStart) { - delete signal.autoStart; - } else { - signal.autoStart = false; - } - - if (form.receiveMode === "on-start" || form.receiveMode === "manual") { - signal.receiveMode = form.receiveMode; - } else { - delete signal.receiveMode; - } - - if (form.ignoreAttachments) signal.ignoreAttachments = true; - else delete signal.ignoreAttachments; - if (form.ignoreStories) signal.ignoreStories = true; - else delete signal.ignoreStories; - if (form.sendReadReceipts) signal.sendReadReceipts = true; - else delete signal.sendReadReceipts; - - const allowFrom = parseList(form.allowFrom); - if (allowFrom.length > 0) signal.allowFrom = allowFrom; - else delete signal.allowFrom; - - const mediaMaxMb = Number(form.mediaMaxMb); - if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { - signal.mediaMaxMb = mediaMaxMb; - } else { - delete signal.mediaMaxMb; - } - - if (Object.keys(signal).length > 0) { - config.signal = signal; - } else { - delete config.signal; - } - - const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`; - await state.client.request("config.set", { raw }); - state.signalConfigStatus = "Saved. Restart gateway if needed."; - } catch (err) { - state.signalConfigStatus = String(err); - } finally { - state.signalSaving = false; - } -} - -export async function saveIMessageConfig(state: ConnectionsState) { - if (!state.client || !state.connected) return; - if (state.imessageSaving) return; - state.imessageSaving = true; - state.imessageConfigStatus = null; - try { - const base = state.configSnapshot?.config ?? {}; - const config = { ...base } as Record; - const imessage = { ...(config.imessage ?? {}) } as Record; - const form = state.imessageForm; - - if (form.enabled) { - delete imessage.enabled; - } else { - imessage.enabled = false; - } - - const cliPath = form.cliPath.trim(); - if (cliPath) imessage.cliPath = cliPath; - else delete imessage.cliPath; - - const dbPath = form.dbPath.trim(); - if (dbPath) imessage.dbPath = dbPath; - else delete imessage.dbPath; - - if (form.service === "auto") { - delete imessage.service; - } else { - imessage.service = form.service; - } - - const region = form.region.trim(); - if (region) imessage.region = region; - else delete imessage.region; - - const allowFrom = parseList(form.allowFrom); - if (allowFrom.length > 0) imessage.allowFrom = allowFrom; - else delete imessage.allowFrom; - - if (form.includeAttachments) imessage.includeAttachments = true; - else delete imessage.includeAttachments; - - const mediaMaxMb = Number(form.mediaMaxMb); - if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { - imessage.mediaMaxMb = mediaMaxMb; - } else { - delete imessage.mediaMaxMb; - } - - if (Object.keys(imessage).length > 0) { - config.imessage = imessage; - } else { - delete config.imessage; - } - - const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`; - await state.client.request("config.set", { raw }); - state.imessageConfigStatus = "Saved. Restart gateway if needed."; - } catch (err) { - state.imessageConfigStatus = String(err); - } finally { - state.imessageSaving = false; - } -} diff --git a/ui/src/ui/controllers/connections.types.ts b/ui/src/ui/controllers/connections.types.ts new file mode 100644 index 000000000..d01bb428c --- /dev/null +++ b/ui/src/ui/controllers/connections.types.ts @@ -0,0 +1,43 @@ +import type { GatewayBrowserClient } from "../gateway"; +import type { ChannelsStatusSnapshot, ConfigSnapshot } from "../types"; +import type { + DiscordForm, + IMessageForm, + SlackForm, + SignalForm, + TelegramForm, +} from "../ui-types"; + +export type ConnectionsState = { + client: GatewayBrowserClient | null; + connected: boolean; + channelsLoading: boolean; + channelsSnapshot: ChannelsStatusSnapshot | null; + channelsError: string | null; + channelsLastSuccess: number | null; + whatsappLoginMessage: string | null; + whatsappLoginQrDataUrl: string | null; + whatsappLoginConnected: boolean | null; + whatsappBusy: boolean; + telegramForm: TelegramForm; + telegramSaving: boolean; + telegramTokenLocked: boolean; + telegramConfigStatus: string | null; + discordForm: DiscordForm; + discordSaving: boolean; + discordTokenLocked: boolean; + discordConfigStatus: string | null; + slackForm: SlackForm; + slackSaving: boolean; + slackTokenLocked: boolean; + slackAppTokenLocked: boolean; + slackConfigStatus: string | null; + signalForm: SignalForm; + signalSaving: boolean; + signalConfigStatus: string | null; + imessageForm: IMessageForm; + imessageSaving: boolean; + imessageConfigStatus: string | null; + configSnapshot: ConfigSnapshot | null; +}; + diff --git a/ui/src/ui/views/config-form.analyze.ts b/ui/src/ui/views/config-form.analyze.ts new file mode 100644 index 000000000..4153fe058 --- /dev/null +++ b/ui/src/ui/views/config-form.analyze.ts @@ -0,0 +1,121 @@ +import { pathKey, schemaType, type JsonSchema } from "./config-form.shared"; + +export type ConfigSchemaAnalysis = { + schema: JsonSchema | null; + unsupportedPaths: string[]; +}; + +export function analyzeConfigSchema(raw: unknown): ConfigSchemaAnalysis { + if (!raw || typeof raw !== "object") { + return { schema: null, unsupportedPaths: [""] }; + } + return normalizeSchemaNode(raw as JsonSchema, []); +} + +function normalizeSchemaNode( + schema: JsonSchema, + path: Array, +): ConfigSchemaAnalysis { + const unsupportedPaths: string[] = []; + const normalized: JsonSchema = { ...schema }; + const pathLabel = pathKey(path) || ""; + + if (schema.anyOf || schema.oneOf || schema.allOf) { + const union = normalizeUnion(schema, path); + if (union) return union; + unsupportedPaths.push(pathLabel); + return { schema, unsupportedPaths }; + } + + const nullable = Array.isArray(schema.type) && schema.type.includes("null"); + const type = + schemaType(schema) ?? + (schema.properties || schema.additionalProperties ? "object" : undefined); + normalized.type = type ?? schema.type; + + if (nullable && !normalized.nullable) { + normalized.nullable = true; + } + + if (type === "object") { + const properties = schema.properties ?? {}; + const normalizedProps: Record = {}; + for (const [key, value] of Object.entries(properties)) { + const res = normalizeSchemaNode(value, [...path, key]); + normalizedProps[key] = res.schema ?? value; + unsupportedPaths.push(...res.unsupportedPaths); + } + normalized.properties = normalizedProps; + + if ( + schema.additionalProperties && + typeof schema.additionalProperties === "object" + ) { + const res = normalizeSchemaNode( + schema.additionalProperties as JsonSchema, + [...path, "*"], + ); + normalized.additionalProperties = + res.schema ?? schema.additionalProperties; + unsupportedPaths.push(...res.unsupportedPaths); + } + } + + if (type === "array" && schema.items && !Array.isArray(schema.items)) { + const res = normalizeSchemaNode(schema.items, [...path, 0]); + normalized.items = res.schema ?? schema.items; + unsupportedPaths.push(...res.unsupportedPaths); + } + + return { schema: normalized, unsupportedPaths }; +} + +function normalizeUnion( + schema: JsonSchema, + path: Array, +): ConfigSchemaAnalysis | null { + const union = schema.anyOf ?? schema.oneOf ?? schema.allOf ?? []; + const pathLabel = pathKey(path) || ""; + if (union.length === 0) return null; + + const nonNull = union.filter( + (v) => + !( + v.type === "null" || + (Array.isArray(v.type) && v.type.includes("null")) + ), + ); + + if (nonNull.length === 1) { + const res = normalizeSchemaNode(nonNull[0], path); + return { + schema: { ...(res.schema ?? nonNull[0]), nullable: true }, + unsupportedPaths: res.unsupportedPaths, + }; + } + + const literals = nonNull + .map((v) => { + if (v.const !== undefined) return v.const; + if (v.enum && v.enum.length === 1) return v.enum[0]; + return undefined; + }) + .filter((v) => v !== undefined); + + if (literals.length === nonNull.length) { + return { + schema: { + ...schema, + anyOf: undefined, + oneOf: undefined, + allOf: undefined, + type: "string", + enum: literals as unknown[], + }, + unsupportedPaths: [], + }; + } + + return { schema, unsupportedPaths: [pathLabel] }; +} + diff --git a/ui/src/ui/views/config-form.node.ts b/ui/src/ui/views/config-form.node.ts new file mode 100644 index 000000000..f2563827c --- /dev/null +++ b/ui/src/ui/views/config-form.node.ts @@ -0,0 +1,338 @@ +import { html, nothing, type TemplateResult } from "lit"; +import type { ConfigUiHints } from "../types"; +import { + defaultValue, + hintForPath, + humanize, + isSensitivePath, + pathKey, + schemaType, + type JsonSchema, +} from "./config-form.shared"; + +export function renderNode(params: { + schema: JsonSchema; + value: unknown; + path: Array; + hints: ConfigUiHints; + unsupported: Set; + disabled: boolean; + showLabel?: boolean; + onPatch: (path: Array, value: unknown) => void; +}): TemplateResult | typeof nothing { + const { schema, value, path, hints, unsupported, disabled, onPatch } = params; + const showLabel = params.showLabel ?? true; + const type = schemaType(schema); + const hint = hintForPath(path, hints); + const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1))); + const help = hint?.help ?? schema.description; + const key = pathKey(path); + + if (unsupported.has(key)) { + return html`
+ ${label}: unsupported schema node. Use Raw. +
`; + } + + if (schema.anyOf || schema.oneOf) { + const variants = schema.anyOf ?? schema.oneOf ?? []; + const nonNull = variants.filter( + (v) => + !( + v.type === "null" || + (Array.isArray(v.type) && v.type.includes("null")) + ), + ); + + if (nonNull.length === 1) { + return renderNode({ ...params, schema: nonNull[0] }); + } + + const extractLiteral = (v: JsonSchema): unknown | undefined => { + if (v.const !== undefined) return v.const; + if (v.enum && v.enum.length === 1) return v.enum[0]; + return undefined; + }; + const literals = nonNull.map(extractLiteral); + const allLiterals = literals.every((v) => v !== undefined); + + if (allLiterals && literals.length > 0) { + const currentIndex = literals.findIndex( + (lit) => lit === value || String(lit) === String(value), + ); + return html` + + `; + } + } + + if (type === "object") { + const obj = (value ?? {}) as Record; + const props = schema.properties ?? {}; + const entries = Object.entries(props); + const sorted = entries.sort((a, b) => { + const orderA = hintForPath([...path, a[0]], hints)?.order ?? 0; + const orderB = hintForPath([...path, b[0]], hints)?.order ?? 0; + if (orderA !== orderB) return orderA - orderB; + return a[0].localeCompare(b[0]); + }); + const reserved = new Set(Object.keys(props)); + const additional = schema.additionalProperties; + const allowExtra = Boolean(additional) && typeof additional === "object"; + + return html` +
+ ${showLabel ? html`
${label}
` : nothing} + ${help ? html`
${help}
` : nothing} + + ${sorted.map(([propKey, node]) => + renderNode({ + schema: node, + value: obj[propKey], + path: [...path, propKey], + hints, + unsupported, + disabled, + onPatch, + }), + )} + + ${allowExtra + ? renderMapField({ + schema: additional as JsonSchema, + value: obj, + path, + hints, + unsupported, + disabled, + reservedKeys: reserved, + onPatch, + }) + : nothing} +
+ `; + } + + if (type === "array") { + const itemsSchema = Array.isArray(schema.items) + ? schema.items[0] + : schema.items; + if (!itemsSchema) { + return html`
+ ${showLabel ? html`${label}` : nothing} +
Unsupported array schema. Use Raw.
+
`; + } + const arr = Array.isArray(value) ? value : []; + return html` +
+ ${showLabel ? html`${label}` : nothing} + ${help ? html`
${help}
` : nothing} +
+ ${arr.map((item, idx) => { + const itemPath = [...path, idx]; + return html`
+
+ ${renderNode({ + schema: itemsSchema, + value: item, + path: itemPath, + hints, + unsupported, + disabled, + showLabel: false, + onPatch, + })} +
+ +
`; + })} + +
+
+ `; + } + + if (type === "boolean") { + return html` + + `; + } + + if (type === "number" || type === "integer") { + return html` + + `; + } + + if (type === "string") { + const isSensitive = hint?.sensitive ?? isSensitivePath(path); + const placeholder = hint?.placeholder ?? (isSensitive ? "••••" : ""); + return html` + + `; + } + + return html`
+ ${showLabel ? html`${label}` : nothing} +
Unsupported type. Use Raw.
+
`; +} + +function renderMapField(params: { + schema: JsonSchema; + value: Record; + path: Array; + hints: ConfigUiHints; + unsupported: Set; + disabled: boolean; + reservedKeys: Set; + onPatch: (path: Array, value: unknown) => void; +}): TemplateResult { + const { schema, value, path, hints, unsupported, disabled, reservedKeys, onPatch } = + params; + const entries = Object.entries(value ?? {}).filter( + ([key]) => !reservedKeys.has(key), + ); + return html` +
+
+ Extra entries + +
+ ${entries.length === 0 + ? html`
No entries yet.
` + : entries.map(([key, entryValue]) => { + const valuePath = [...path, key]; + return html`
+ { + const nextKey = (e.target as HTMLInputElement).value.trim(); + if (!nextKey || nextKey === key) return; + const next = { ...(value ?? {}) }; + if (nextKey in next) return; + next[nextKey] = next[key]; + delete next[key]; + onPatch(path, next); + }} + /> +
+ ${renderNode({ + schema, + value: entryValue, + path: valuePath, + hints, + unsupported, + disabled, + showLabel: false, + onPatch, + })} +
+ +
`; + })} +
+ `; +} + diff --git a/ui/src/ui/views/config-form.render.ts b/ui/src/ui/views/config-form.render.ts new file mode 100644 index 000000000..c21c276fc --- /dev/null +++ b/ui/src/ui/views/config-form.render.ts @@ -0,0 +1,49 @@ +import { html } from "lit"; +import type { ConfigUiHints } from "../types"; +import { hintForPath, schemaType, type JsonSchema } from "./config-form.shared"; +import { renderNode } from "./config-form.node"; + +export type ConfigFormProps = { + schema: JsonSchema | null; + uiHints: ConfigUiHints; + value: Record | null; + disabled?: boolean; + unsupportedPaths?: string[]; + onPatch: (path: Array, value: unknown) => void; +}; + +export function renderConfigForm(props: ConfigFormProps) { + if (!props.schema) { + return html`
Schema unavailable.
`; + } + const schema = props.schema; + const value = props.value ?? {}; + if (schemaType(schema) !== "object" || !schema.properties) { + return html`
Unsupported schema. Use Raw.
`; + } + const unsupported = new Set(props.unsupportedPaths ?? []); + const entries = Object.entries(schema.properties); + const sorted = entries.sort((a, b) => { + const orderA = hintForPath([a[0]], props.uiHints)?.order ?? 0; + const orderB = hintForPath([b[0]], props.uiHints)?.order ?? 0; + if (orderA !== orderB) return orderA - orderB; + return a[0].localeCompare(b[0]); + }); + + return html` +
+ ${sorted.map(([key, node]) => + renderNode({ + schema: node, + value: (value as Record)[key], + path: [key], + hints: props.uiHints, + unsupported, + disabled: props.disabled ?? false, + onPatch: props.onPatch, + }), + )} +
+ `; +} + diff --git a/ui/src/ui/views/config-form.shared.ts b/ui/src/ui/views/config-form.shared.ts new file mode 100644 index 000000000..b37969a93 --- /dev/null +++ b/ui/src/ui/views/config-form.shared.ts @@ -0,0 +1,92 @@ +import type { ConfigUiHints } from "../types"; + +export type JsonSchema = { + type?: string | string[]; + title?: string; + description?: string; + properties?: Record; + items?: JsonSchema | JsonSchema[]; + additionalProperties?: JsonSchema | boolean; + enum?: unknown[]; + const?: unknown; + default?: unknown; + anyOf?: JsonSchema[]; + oneOf?: JsonSchema[]; + allOf?: JsonSchema[]; + nullable?: boolean; +}; + +export function schemaType(schema: JsonSchema): string | undefined { + if (!schema) return undefined; + if (Array.isArray(schema.type)) { + const filtered = schema.type.filter((t) => t !== "null"); + return filtered[0] ?? schema.type[0]; + } + return schema.type; +} + +export function defaultValue(schema?: JsonSchema): unknown { + if (!schema) return ""; + if (schema.default !== undefined) return schema.default; + const type = schemaType(schema); + switch (type) { + case "object": + return {}; + case "array": + return []; + case "boolean": + return false; + case "number": + case "integer": + return 0; + case "string": + return ""; + default: + return ""; + } +} + +export function pathKey(path: Array): string { + return path.filter((segment) => typeof segment === "string").join("."); +} + +export function hintForPath(path: Array, hints: ConfigUiHints) { + const key = pathKey(path); + const direct = hints[key]; + if (direct) return direct; + const segments = key.split("."); + for (const [hintKey, hint] of Object.entries(hints)) { + if (!hintKey.includes("*")) continue; + const hintSegments = hintKey.split("."); + if (hintSegments.length !== segments.length) continue; + let match = true; + for (let i = 0; i < segments.length; i += 1) { + if (hintSegments[i] !== "*" && hintSegments[i] !== segments[i]) { + match = false; + break; + } + } + if (match) return hint; + } + return undefined; +} + +export function humanize(raw: string) { + return raw + .replace(/_/g, " ") + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/\s+/g, " ") + .replace(/^./, (m) => m.toUpperCase()); +} + +export function isSensitivePath(path: Array): boolean { + const key = pathKey(path).toLowerCase(); + return ( + key.includes("token") || + key.includes("password") || + key.includes("secret") || + key.includes("apikey") || + key.endsWith("key") + ); +} + diff --git a/ui/src/ui/views/config-form.ts b/ui/src/ui/views/config-form.ts index 09d3f82c3..d62222b20 100644 --- a/ui/src/ui/views/config-form.ts +++ b/ui/src/ui/views/config-form.ts @@ -1,711 +1,7 @@ -import { html, nothing, type TemplateResult } from "lit"; -import type { ConfigUiHint, ConfigUiHints } from "../types"; +export { renderConfigForm, type ConfigFormProps } from "./config-form.render"; +export { + analyzeConfigSchema, + type ConfigSchemaAnalysis, +} from "./config-form.analyze"; +export type { JsonSchema } from "./config-form.shared"; -export type ConfigFormProps = { - schema: JsonSchema | null; - uiHints: ConfigUiHints; - value: Record | null; - disabled?: boolean; - unsupportedPaths?: string[]; - onPatch: (path: Array, value: unknown) => void; -}; - -type JsonSchema = { - type?: string | string[]; - title?: string; - description?: string; - properties?: Record; - items?: JsonSchema | JsonSchema[]; - additionalProperties?: JsonSchema | boolean; - enum?: unknown[]; - const?: unknown; - default?: unknown; - anyOf?: JsonSchema[]; - oneOf?: JsonSchema[]; - allOf?: JsonSchema[]; - nullable?: boolean; -}; - -export function renderConfigForm(props: ConfigFormProps) { - if (!props.schema) { - return html`
Schema unavailable.
`; - } - const schema = props.schema; - const value = props.value ?? {}; - if (schemaType(schema) !== "object" || !schema.properties) { - return html`
Unsupported schema. Use Raw.
`; - } - const unsupported = new Set(props.unsupportedPaths ?? []); - const entries = Object.entries(schema.properties); - const sorted = entries.sort((a, b) => { - const orderA = hintForPath([a[0]], props.uiHints)?.order ?? 0; - const orderB = hintForPath([b[0]], props.uiHints)?.order ?? 0; - if (orderA !== orderB) return orderA - orderB; - return a[0].localeCompare(b[0]); - }); - - return html` -
- ${sorted.map(([key, node]) => - renderNode({ - schema: node, - value: (value as Record)[key], - path: [key], - hints: props.uiHints, - unsupported, - disabled: props.disabled ?? false, - onPatch: props.onPatch, - }), - )} -
- `; -} - -function renderNode(params: { - schema: JsonSchema; - value: unknown; - path: Array; - hints: ConfigUiHints; - unsupported: Set; - disabled: boolean; - showLabel?: boolean; - onPatch: (path: Array, value: unknown) => void; -}): TemplateResult | typeof nothing { - const { schema, value, path, hints, unsupported, disabled, onPatch } = params; - const showLabel = params.showLabel ?? true; - const type = schemaType(schema); - const hint = hintForPath(path, hints); - const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1))); - const help = hint?.help ?? schema.description; - const key = pathKey(path); - - if (unsupported.has(key)) { - return html`
- ${label}: unsupported schema node. Use Raw. -
`; - } - - if (schema.anyOf || schema.oneOf) { - const variants = schema.anyOf ?? schema.oneOf ?? []; - const nonNull = variants.filter( - (v) => !(v.type === "null" || (Array.isArray(v.type) && v.type.includes("null"))), - ); - - if (nonNull.length === 1) { - return renderNode({ ...params, schema: nonNull[0] }); - } - - const extractLiteral = (v: JsonSchema): unknown | undefined => { - if (v.const !== undefined) return v.const; - if (v.enum && v.enum.length === 1) return v.enum[0]; - return undefined; - }; - const literals = nonNull.map(extractLiteral); - const allLiterals = literals.every((v) => v !== undefined); - - if (allLiterals && literals.length > 0) { - const currentIndex = literals.findIndex( - (lit) => lit === value || String(lit) === String(value), - ); - return html` - - `; - } - - const primitiveTypes = ["string", "number", "integer", "boolean"]; - const allPrimitive = nonNull.every((v) => v.type && primitiveTypes.includes(String(v.type))); - if (allPrimitive) { - const typeHint = nonNull.map((v) => v.type).join(" | "); - const hasBoolean = nonNull.some((v) => v.type === "boolean"); - const hasNumber = nonNull.some((v) => v.type === "number" || v.type === "integer"); - const isInteger = nonNull.every((v) => v.type !== "number"); - return html` - - `; - } - - return html`
- ${label}: unsupported schema node. Use Raw. -
`; - } - - if (schema.allOf) { - return html`
- ${label}: unsupported schema node. Use Raw. -
`; - } - - if (type === "object") { - const props = schema.properties ?? {}; - const entries = Object.entries(props); - const hasMap = - schema.additionalProperties && - typeof schema.additionalProperties === "object"; - if (entries.length === 0 && !hasMap) return nothing; - const reservedKeys = new Set(entries.map(([key]) => key)); - return html` -
- ${label} - ${help ? html`
${help}
` : nothing} - ${entries.map(([key, node]) => - renderNode({ - schema: node, - value: value && typeof value === "object" ? (value as any)[key] : undefined, - path: [...path, key], - hints, - unsupported, - onPatch, - disabled, - }), - )} - ${hasMap - ? renderMapField({ - schema: schema.additionalProperties as JsonSchema, - value: value && typeof value === "object" ? (value as any) : {}, - path, - hints, - unsupported, - disabled, - reservedKeys, - onPatch, - }) - : nothing} -
- `; - } - - if (type === "array") { - const itemSchema = Array.isArray(schema.items) - ? schema.items[0] - : schema.items; - const arr = Array.isArray(value) ? value : []; - return html` -
-
- ${showLabel ? html`${label}` : nothing} - -
- ${help ? html`
${help}
` : nothing} - ${arr.map((entry, index) => - html`
- ${itemSchema - ? renderNode({ - schema: itemSchema, - value: entry, - path: [...path, index], - hints, - unsupported, - disabled, - onPatch, - }) - : nothing} - -
`, - )} -
- `; - } - - if (schema.enum) { - const enumValues = schema.enum; - const currentIndex = enumValues.findIndex( - (v) => v === value || String(v) === String(value), - ); - const unsetValue = "__unset__"; - return html` - - `; - } - - if (type === "boolean") { - return html` - - `; - } - - if (type === "number" || type === "integer") { - return html` - - `; - } - - if (type === "string") { - const isSensitive = hint?.sensitive ?? isSensitivePath(path); - const placeholder = hint?.placeholder ?? (isSensitive ? "••••" : ""); - return html` - - `; - } - - return html`
- ${showLabel ? html`${label}` : nothing} -
Unsupported type. Use Raw.
-
`; -} - -function schemaType(schema: JsonSchema): string | undefined { - if (!schema) return undefined; - if (Array.isArray(schema.type)) { - const filtered = schema.type.filter((t) => t !== "null"); - return filtered[0] ?? schema.type[0]; - } - return schema.type; -} - -function defaultValue(schema?: JsonSchema): unknown { - if (!schema) return ""; - if (schema.default !== undefined) return schema.default; - const type = schemaType(schema); - switch (type) { - case "object": - return {}; - case "array": - return []; - case "boolean": - return false; - case "number": - case "integer": - return 0; - case "string": - return ""; - default: - return ""; - } -} - -function hintForPath(path: Array, hints: ConfigUiHints) { - const key = pathKey(path); - const direct = hints[key]; - if (direct) return direct; - const segments = key.split("."); - for (const [hintKey, hint] of Object.entries(hints)) { - if (!hintKey.includes("*")) continue; - const hintSegments = hintKey.split("."); - if (hintSegments.length !== segments.length) continue; - let match = true; - for (let i = 0; i < segments.length; i += 1) { - if (hintSegments[i] !== "*" && hintSegments[i] !== segments[i]) { - match = false; - break; - } - } - if (match) return hint; - } - return undefined; -} - -function pathKey(path: Array): string { - return path.filter((segment) => typeof segment === "string").join("."); -} - -function humanize(raw: string) { - return raw - .replace(/_/g, " ") - .replace(/([a-z0-9])([A-Z])/g, "$1 $2") - .replace(/\s+/g, " ") - .replace(/^./, (m) => m.toUpperCase()); -} - -function isSensitivePath(path: Array): boolean { - const key = pathKey(path).toLowerCase(); - return ( - key.includes("token") || - key.includes("password") || - key.includes("secret") || - key.includes("apikey") || - key.endsWith("key") - ); -} - -function renderMapField(params: { - schema: JsonSchema; - value: Record; - path: Array; - hints: ConfigUiHints; - unsupported: Set; - disabled: boolean; - reservedKeys: Set; - onPatch: (path: Array, value: unknown) => void; -}): TemplateResult { - const { - schema, - value, - path, - hints, - unsupported, - disabled, - reservedKeys, - onPatch, - } = params; - const entries = Object.entries(value ?? {}).filter( - ([key]) => !reservedKeys.has(key), - ); - return html` -
-
- Extra entries - -
- ${entries.length === 0 - ? html`
No entries yet.
` - : entries.map(([key, entryValue]) => { - const valuePath = [...path, key]; - return html`
- { - const nextKey = (e.target as HTMLInputElement).value.trim(); - if (!nextKey || nextKey === key) return; - const next = { ...(value ?? {}) }; - if (nextKey in next) return; - next[nextKey] = next[key]; - delete next[key]; - onPatch(path, next); - }} - /> -
- ${renderNode({ - schema, - value: entryValue, - path: valuePath, - hints, - unsupported, - disabled, - showLabel: false, - onPatch, - })} -
- -
`; - })} -
- `; -} - -export type ConfigSchemaAnalysis = { - schema: JsonSchema | null; - unsupportedPaths: string[]; -}; - -export function analyzeConfigSchema(raw: unknown): ConfigSchemaAnalysis { - if (!raw || typeof raw !== "object") { - return { schema: null, unsupportedPaths: [""] }; - } - const result = normalizeSchemaNode(raw as JsonSchema, []); - return result; -} - -function normalizeSchemaNode( - schema: JsonSchema, - path: Array, -): ConfigSchemaAnalysis { - const unsupportedPaths: string[] = []; - const normalized = { ...schema }; - const pathLabel = pathKey(path) || ""; - - if (schema.anyOf || schema.oneOf || schema.allOf) { - const union = normalizeUnion(schema, path); - if (union) return union; - unsupportedPaths.push(pathLabel); - return { schema, unsupportedPaths }; - } - - const nullable = - Array.isArray(schema.type) && schema.type.includes("null"); - const type = - schemaType(schema) ?? - (schema.properties || schema.additionalProperties ? "object" : undefined); - normalized.type = type ?? schema.type; - normalized.nullable = nullable || schema.nullable; - - if (normalized.enum) { - const { enumValues, nullable: enumNullable } = normalizeEnumValues( - normalized.enum, - ); - normalized.enum = enumValues; - if (enumNullable) normalized.nullable = true; - if (enumValues.length === 0) { - unsupportedPaths.push(pathLabel); - } - } - - if (type === "object") { - const props = schema.properties ?? {}; - const normalizedProps: Record = {}; - for (const [key, child] of Object.entries(props)) { - const result = normalizeSchemaNode(child, [...path, key]); - if (result.schema) normalizedProps[key] = result.schema; - unsupportedPaths.push(...result.unsupportedPaths); - } - normalized.properties = normalizedProps; - - if (schema.additionalProperties === true) { - unsupportedPaths.push(pathLabel); - } else if (schema.additionalProperties === false) { - normalized.additionalProperties = false; - } else if (schema.additionalProperties) { - const result = normalizeSchemaNode( - schema.additionalProperties, - [...path, "*"], - ); - normalized.additionalProperties = result.schema ?? schema.additionalProperties; - if (result.unsupportedPaths.length > 0) { - unsupportedPaths.push(pathLabel); - } - } - } else if (type === "array") { - const itemSchema = Array.isArray(schema.items) - ? schema.items[0] - : schema.items; - if (!itemSchema) { - unsupportedPaths.push(pathLabel); - } else { - const result = normalizeSchemaNode(itemSchema, [...path, "*"]); - normalized.items = result.schema ?? itemSchema; - if (result.unsupportedPaths.length > 0) { - unsupportedPaths.push(pathLabel); - } - } - } else if ( - type === "string" || - type === "number" || - type === "integer" || - type === "boolean" - ) { - // ok - } else if (!normalized.enum) { - unsupportedPaths.push(pathLabel); - } - - return { - schema: normalized, - unsupportedPaths: Array.from(new Set(unsupportedPaths)), - }; -} - -function normalizeUnion( - schema: JsonSchema, - path: Array, -): ConfigSchemaAnalysis | null { - if (schema.allOf) return null; - const variants = schema.anyOf ?? schema.oneOf; - if (!variants) return null; - const values: unknown[] = []; - const nonLiteral: JsonSchema[] = []; - let nullable = false; - for (const variant of variants) { - if (!variant || typeof variant !== "object") return null; - if (Array.isArray(variant.enum)) { - const { enumValues, nullable: enumNullable } = normalizeEnumValues( - variant.enum, - ); - values.push(...enumValues); - if (enumNullable) nullable = true; - continue; - } - if ("const" in variant) { - if (variant.const === null || variant.const === undefined) { - nullable = true; - continue; - } - values.push(variant.const); - continue; - } - if (schemaType(variant) === "null") { - nullable = true; - continue; - } - nonLiteral.push(variant); - } - - if (values.length > 0 && nonLiteral.length === 0) { - const unique: unknown[] = []; - for (const value of values) { - if (!unique.some((entry) => Object.is(entry, value))) unique.push(value); - } - return { - schema: { - ...schema, - enum: unique, - nullable, - anyOf: undefined, - oneOf: undefined, - allOf: undefined, - }, - unsupportedPaths: [], - }; - } - - if (nonLiteral.length === 1) { - const result = normalizeSchemaNode(nonLiteral[0], path); - if (result.schema) { - result.schema.nullable = nullable || result.schema.nullable; - } - return result; - } - - const primitiveTypes = ["string", "number", "integer", "boolean"]; - const allPrimitive = nonLiteral.every( - (v) => v.type && primitiveTypes.includes(String(v.type)), - ); - if (allPrimitive && nonLiteral.length > 0 && values.length === 0) { - return { - schema: { ...schema, nullable }, - unsupportedPaths: [], - }; - } - - return null; -} - -function normalizeEnumValues(values: unknown[]) { - const filtered = values.filter((value) => value !== null && value !== undefined); - const nullable = filtered.length !== values.length; - const unique: unknown[] = []; - for (const value of filtered) { - if (!unique.some((entry) => Object.is(entry, value))) unique.push(value); - } - return { enumValues: unique, nullable }; -} diff --git a/ui/src/ui/views/connections.discord.actions.ts b/ui/src/ui/views/connections.discord.actions.ts new file mode 100644 index 000000000..283f3ff19 --- /dev/null +++ b/ui/src/ui/views/connections.discord.actions.ts @@ -0,0 +1,31 @@ +import { html } from "lit"; + +import type { ConnectionsProps } from "./connections.types"; +import { discordActionOptions } from "./connections.action-options"; + +export function renderDiscordActionsSection(props: ConnectionsProps) { + return html` +
Tool actions
+
+ ${discordActionOptions.map( + (action) => html``, + )} +
+ `; +} + diff --git a/ui/src/ui/views/connections.discord.guilds.ts b/ui/src/ui/views/connections.discord.guilds.ts new file mode 100644 index 000000000..2d4fd32d1 --- /dev/null +++ b/ui/src/ui/views/connections.discord.guilds.ts @@ -0,0 +1,262 @@ +import { html, nothing } from "lit"; + +import type { ConnectionsProps } from "./connections.types"; + +export function renderDiscordGuildsEditor(props: ConnectionsProps) { + return html` +
+ Guilds +
+ Add each guild (id or slug) and optional channel rules. Empty channel + entries still allow that channel. +
+
+ ${props.discordForm.guilds.map( + (guild, guildIndex) => html` +
+
+
+ + + + + +
+ ${guild.channels.length + ? html` +
+ ${guild.channels.map( + (channel, channelIndex) => html` + + + + + `, + )} +
+ ` + : nothing} +
+
+ Channels + + +
+
+ `, + )} +
+ +
+ `; +} + diff --git a/ui/src/ui/views/connections.discord.ts b/ui/src/ui/views/connections.discord.ts new file mode 100644 index 000000000..54a84e316 --- /dev/null +++ b/ui/src/ui/views/connections.discord.ts @@ -0,0 +1,261 @@ +import { html, nothing } from "lit"; + +import { formatAgo } from "../format"; +import type { DiscordStatus } from "../types"; +import type { ConnectionsProps } from "./connections.types"; +import { renderDiscordActionsSection } from "./connections.discord.actions"; +import { renderDiscordGuildsEditor } from "./connections.discord.guilds"; + +export function renderDiscordCard(params: { + props: ConnectionsProps; + discord: DiscordStatus | null; + accountCountLabel: unknown; +}) { + const { props, discord, accountCountLabel } = params; + const botName = discord?.probe?.bot?.username; + + return html` +
+
Discord
+
Bot connection and probe status.
+ ${accountCountLabel} + +
+
+ Configured + ${discord?.configured ? "Yes" : "No"} +
+
+ Running + ${discord?.running ? "Yes" : "No"} +
+
+ Bot + ${botName ? `@${botName}` : "n/a"} +
+
+ Last start + ${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"} +
+
+ Last probe + ${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"} +
+
+ + ${discord?.lastError + ? html`
+ ${discord.lastError} +
` + : nothing} + + ${discord?.probe + ? html`
+ Probe ${discord.probe.ok ? "ok" : "failed"} · + ${discord.probe.status ?? ""} ${discord.probe.error ?? ""} +
` + : nothing} + +
+ + + + + + + + + + + ${renderDiscordGuildsEditor(props)} + + + + +
+ + ${renderDiscordActionsSection(props)} + + ${props.discordTokenLocked + ? html`
+ DISCORD_BOT_TOKEN is set in the environment. Config edits will not + override it. +
` + : nothing} + + ${props.discordStatus + ? html`
+ ${props.discordStatus} +
` + : nothing} + +
+ + +
+
+ `; +} diff --git a/ui/src/ui/views/connections.imessage.ts b/ui/src/ui/views/connections.imessage.ts new file mode 100644 index 000000000..fcdbf0b12 --- /dev/null +++ b/ui/src/ui/views/connections.imessage.ts @@ -0,0 +1,184 @@ +import { html, nothing } from "lit"; + +import { formatAgo } from "../format"; +import type { IMessageStatus } from "../types"; +import type { ConnectionsProps } from "./connections.types"; + +export function renderIMessageCard(params: { + props: ConnectionsProps; + imessage: IMessageStatus | null; + accountCountLabel: unknown; +}) { + const { props, imessage, accountCountLabel } = params; + + return html` +
+
iMessage
+
imsg CLI and database availability.
+ ${accountCountLabel} + +
+
+ Configured + ${imessage?.configured ? "Yes" : "No"} +
+
+ Running + ${imessage?.running ? "Yes" : "No"} +
+
+ CLI + ${imessage?.cliPath ?? "n/a"} +
+
+ DB + ${imessage?.dbPath ?? "n/a"} +
+
+ Last start + + ${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"} + +
+
+ Last probe + + ${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"} + +
+
+ + ${imessage?.lastError + ? html`
+ ${imessage.lastError} +
` + : nothing} + + ${imessage?.probe && !imessage.probe.ok + ? html`
+ Probe failed · ${imessage.probe.error ?? "unknown error"} +
` + : nothing} + +
+ + + + + + + + +
+ + ${props.imessageStatus + ? html`
+ ${props.imessageStatus} +
` + : nothing} + +
+ + +
+
+ `; +} + diff --git a/ui/src/ui/views/connections.signal.ts b/ui/src/ui/views/connections.signal.ts new file mode 100644 index 000000000..ca04d6bc9 --- /dev/null +++ b/ui/src/ui/views/connections.signal.ts @@ -0,0 +1,237 @@ +import { html, nothing } from "lit"; + +import { formatAgo } from "../format"; +import type { SignalStatus } from "../types"; +import type { ConnectionsProps } from "./connections.types"; + +export function renderSignalCard(params: { + props: ConnectionsProps; + signal: SignalStatus | null; + accountCountLabel: unknown; +}) { + const { props, signal, accountCountLabel } = params; + + return html` +
+
Signal
+
REST daemon status and probe details.
+ ${accountCountLabel} + +
+
+ Configured + ${signal?.configured ? "Yes" : "No"} +
+
+ Running + ${signal?.running ? "Yes" : "No"} +
+
+ Base URL + ${signal?.baseUrl ?? "n/a"} +
+
+ Last start + ${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"} +
+
+ Last probe + ${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"} +
+
+ + ${signal?.lastError + ? html`
+ ${signal.lastError} +
` + : nothing} + + ${signal?.probe + ? html`
+ Probe ${signal.probe.ok ? "ok" : "failed"} · + ${signal.probe.status ?? ""} ${signal.probe.error ?? ""} +
` + : nothing} + +
+ + + + + + + + + + + + + +
+ + ${props.signalStatus + ? html`
+ ${props.signalStatus} +
` + : nothing} + +
+ + +
+
+ `; +} + diff --git a/ui/src/ui/views/connections.slack.ts b/ui/src/ui/views/connections.slack.ts new file mode 100644 index 000000000..4116c649d --- /dev/null +++ b/ui/src/ui/views/connections.slack.ts @@ -0,0 +1,391 @@ +import { html, nothing } from "lit"; + +import { formatAgo } from "../format"; +import type { SlackStatus } from "../types"; +import type { ConnectionsProps } from "./connections.types"; +import { slackActionOptions } from "./connections.action-options"; + +export function renderSlackCard(params: { + props: ConnectionsProps; + slack: SlackStatus | null; + accountCountLabel: unknown; +}) { + const { props, slack, accountCountLabel } = params; + const botName = slack?.probe?.bot?.name; + const teamName = slack?.probe?.team?.name; + + return html` +
+
Slack
+
Socket mode status and bot details.
+ ${accountCountLabel} + +
+
+ Configured + ${slack?.configured ? "Yes" : "No"} +
+
+ Running + ${slack?.running ? "Yes" : "No"} +
+
+ Bot + ${botName ? botName : "n/a"} +
+
+ Team + ${teamName ? teamName : "n/a"} +
+
+ Last start + ${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"} +
+
+ Last probe + ${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"} +
+
+ + ${slack?.lastError + ? html`
+ ${slack.lastError} +
` + : nothing} + + ${slack?.probe + ? html`
+ Probe ${slack.probe.ok ? "ok" : "failed"} · ${slack.probe.status ?? ""} + ${slack.probe.error ?? ""} +
` + : nothing} + +
+ + + + + + + + + + + +
+ +
Slash command
+
+ + + + +
+ +
Channels
+
Add channel ids or #names and optionally require mentions.
+
+ ${props.slackForm.channels.map( + (channel, channelIndex) => html` +
+
+
+ + + + +
+
+
+ `, + )} +
+ + +
Tool actions
+
+ ${slackActionOptions.map( + (action) => html``, + )} +
+ + ${props.slackTokenLocked || props.slackAppTokenLocked + ? html`
+ ${props.slackTokenLocked ? "SLACK_BOT_TOKEN " : ""} + ${props.slackAppTokenLocked ? "SLACK_APP_TOKEN " : ""} is set in the + environment. Config edits will not override it. +
` + : nothing} + + ${props.slackStatus + ? html`
+ ${props.slackStatus} +
` + : nothing} + +
+ + +
+
+ `; +} + diff --git a/ui/src/ui/views/connections.ts b/ui/src/ui/views/connections.ts index 42bbaf602..2c7aef25e 100644 --- a/ui/src/ui/views/connections.ts +++ b/ui/src/ui/views/connections.ts @@ -2,8 +2,6 @@ import { html, nothing } from "lit"; import { formatAgo } from "../format"; import type { - ChannelAccountSnapshot, - ChannelsStatusSnapshot, DiscordStatus, IMessageStatus, SignalStatus, @@ -11,20 +9,16 @@ import type { TelegramStatus, WhatsAppStatus, } from "../types"; -import type { - DiscordForm, - IMessageForm, - SlackForm, - SignalForm, - TelegramForm, -} from "../ui-types"; import type { ChannelKey, ConnectionsChannelData, ConnectionsProps, } from "./connections.types"; -import { channelEnabled, formatDuration, renderChannelAccountCount } from "./connections.shared"; -import { discordActionOptions, slackActionOptions } from "./connections.action-options"; +import { channelEnabled, renderChannelAccountCount } from "./connections.shared"; +import { renderDiscordCard } from "./connections.discord"; +import { renderIMessageCard } from "./connections.imessage"; +import { renderSignalCard } from "./connections.signal"; +import { renderSlackCard } from "./connections.slack"; import { renderTelegramCard } from "./connections.telegram"; import { renderWhatsAppCard } from "./connections.whatsapp"; @@ -117,1318 +111,30 @@ function renderChannel( telegramAccounts: data.channelAccounts?.telegram ?? [], accountCountLabel, }); - case "discord": { - const discord = data.discord; - const botName = discord?.probe?.bot?.username; - return html` -
-
Discord
-
Bot connection and probe status.
- ${accountCountLabel} - -
-
- Configured - ${discord?.configured ? "Yes" : "No"} -
-
- Running - ${discord?.running ? "Yes" : "No"} -
-
- Bot - ${botName ? `@${botName}` : "n/a"} -
-
- Last start - ${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"} -
-
- Last probe - ${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"} -
-
- - ${discord?.lastError - ? html`
- ${discord.lastError} -
` - : nothing} - - ${discord?.probe - ? html`
- Probe ${discord.probe.ok ? "ok" : "failed"} · - ${discord.probe.status ?? ""} - ${discord.probe.error ?? ""} -
` - : nothing} - -
- - - - - - - - - - -
- Guilds -
- Add each guild (id or slug) and optional channel rules. Empty channel - entries still allow that channel. -
-
- ${props.discordForm.guilds.map( - (guild, guildIndex) => html` -
-
-
- - - - - -
- ${guild.channels.length - ? html` -
- ${guild.channels.map( - (channel, channelIndex) => html` - - - - - `, - )} -
- ` - : nothing} -
-
- Channels - - -
-
- `, - )} -
- -
- - - - -
- -
Tool actions
-
- ${discordActionOptions.map( - (action) => html``, - )} -
- - ${props.discordTokenLocked - ? html`
- DISCORD_BOT_TOKEN is set in the environment. Config edits will not override it. -
` - : nothing} - - ${props.discordStatus - ? html`
- ${props.discordStatus} -
` - : nothing} - -
- - -
-
- `; - } - case "slack": { - const slack = data.slack; - const botName = slack?.probe?.bot?.name; - const teamName = slack?.probe?.team?.name; - return html` -
-
Slack
-
Socket mode status and bot details.
- ${accountCountLabel} - -
-
- Configured - ${slack?.configured ? "Yes" : "No"} -
-
- Running - ${slack?.running ? "Yes" : "No"} -
-
- Bot - ${botName ? botName : "n/a"} -
-
- Team - ${teamName ? teamName : "n/a"} -
-
- Last start - ${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"} -
-
- Last probe - ${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"} -
-
- - ${slack?.lastError - ? html`
- ${slack.lastError} -
` - : nothing} - - ${slack?.probe - ? html`
- Probe ${slack.probe.ok ? "ok" : "failed"} · - ${slack.probe.status ?? ""} - ${slack.probe.error ?? ""} -
` - : nothing} - -
- - - - - - - - - - - -
- -
Slash command
-
- - - - -
- -
Channels
-
- Add channel ids or #names and optionally require mentions. -
-
- ${props.slackForm.channels.map( - (channel, channelIndex) => html` -
-
-
- - - - -
-
-
- `, - )} -
- - -
Tool actions
-
- ${slackActionOptions.map( - (action) => html``, - )} -
- - ${props.slackTokenLocked || props.slackAppTokenLocked - ? html`
- ${props.slackTokenLocked ? "SLACK_BOT_TOKEN " : ""} - ${props.slackAppTokenLocked ? "SLACK_APP_TOKEN " : ""} - is set in the environment. Config edits will not override it. -
` - : nothing} - - ${props.slackStatus - ? html`
- ${props.slackStatus} -
` - : nothing} - -
- - -
-
- `; - } - case "signal": { - const signal = data.signal; - return html` -
-
Signal
-
REST daemon status and probe details.
- ${accountCountLabel} - -
-
- Configured - ${signal?.configured ? "Yes" : "No"} -
-
- Running - ${signal?.running ? "Yes" : "No"} -
-
- Base URL - ${signal?.baseUrl ?? "n/a"} -
-
- Last start - ${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"} -
-
- Last probe - ${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"} -
-
- - ${signal?.lastError - ? html`
- ${signal.lastError} -
` - : nothing} - - ${signal?.probe - ? html`
- Probe ${signal.probe.ok ? "ok" : "failed"} · - ${signal.probe.status ?? ""} - ${signal.probe.error ?? ""} -
` - : nothing} - -
- - - - - - - - - - - - - -
- - ${props.signalStatus - ? html`
- ${props.signalStatus} -
` - : nothing} - -
- - -
-
- `; - } - case "imessage": { - const imessage = data.imessage; - return html` -
-
iMessage
-
imsg CLI and database availability.
- ${accountCountLabel} - -
-
- Configured - ${imessage?.configured ? "Yes" : "No"} -
-
- Running - ${imessage?.running ? "Yes" : "No"} -
-
- CLI - ${imessage?.cliPath ?? "n/a"} -
-
- DB - ${imessage?.dbPath ?? "n/a"} -
-
- Last start - - ${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"} - -
-
- Last probe - - ${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"} - -
-
- - ${imessage?.lastError - ? html`
- ${imessage.lastError} -
` - : nothing} - - ${imessage?.probe && !imessage.probe.ok - ? html`
- Probe failed · ${imessage.probe.error ?? "unknown error"} -
` - : nothing} - -
- - - - - - - - -
- - ${props.imessageStatus - ? html`
- ${props.imessageStatus} -
` - : nothing} - -
- - -
-
- `; - } + case "discord": + return renderDiscordCard({ + props, + discord: data.discord, + accountCountLabel, + }); + case "slack": + return renderSlackCard({ + props, + slack: data.slack, + accountCountLabel, + }); + case "signal": + return renderSignalCard({ + props, + signal: data.signal, + accountCountLabel, + }); + case "imessage": + return renderIMessageCard({ + props, + imessage: data.imessage, + accountCountLabel, + }); default: return nothing; } diff --git a/ui/src/ui/views/debug.ts b/ui/src/ui/views/debug.ts index f1284145c..35e2e1af2 100644 --- a/ui/src/ui/views/debug.ts +++ b/ui/src/ui/views/debug.ts @@ -1,12 +1,7 @@ import { html, nothing } from "lit"; import { formatEventPayload } from "../presenter"; - -type EventLogEntry = { - ts: number; - event: string; - payload?: unknown; -}; +import type { EventLogEntry } from "../app-events"; export type DebugProps = { loading: boolean; @@ -126,4 +121,3 @@ export function renderDebug(props: DebugProps) { `; } -