diff --git a/test/vitest-unit-paths.test.ts b/test/vitest-unit-paths.test.ts index e8cbe961990..e9e6b360258 100644 --- a/test/vitest-unit-paths.test.ts +++ b/test/vitest-unit-paths.test.ts @@ -5,6 +5,7 @@ describe("isUnitConfigTestFile", () => { it("accepts unit-config src, test, and whitelisted ui tests", () => { expect(isUnitConfigTestFile("src/infra/git-commit.test.ts")).toBe(true); expect(isUnitConfigTestFile("test/format-error.test.ts")).toBe(true); + expect(isUnitConfigTestFile("ui/src/ui/views/channels.test.ts")).toBe(true); expect(isUnitConfigTestFile("ui/src/ui/views/chat.test.ts")).toBe(true); }); diff --git a/ui/src/ui/views/channels.discord.ts b/ui/src/ui/views/channels.discord.ts index bc921f06888..df5f7d33524 100644 --- a/ui/src/ui/views/channels.discord.ts +++ b/ui/src/ui/views/channels.discord.ts @@ -2,7 +2,11 @@ import { html, nothing } from "lit"; import { formatRelativeTimestamp } from "../format.ts"; import type { DiscordStatus } from "../types.ts"; import { renderChannelConfigSection } from "./channels.config.ts"; -import { resolveChannelConfigured } from "./channels.shared.ts"; +import { + formatNullableBoolean, + renderSingleAccountChannelCard, + resolveChannelConfigured, +} from "./channels.shared.ts"; import type { ChannelsProps } from "./channels.types.ts"; export function renderDiscordCard(params: { @@ -13,55 +17,34 @@ export function renderDiscordCard(params: { const { props, discord, accountCountLabel } = params; const configured = resolveChannelConfigured("discord", props); - return html` -
-
Discord
-
Bot status and channel configuration.
- ${accountCountLabel} - -
-
- Configured - ${configured == null ? "n/a" : configured ? "Yes" : "No"} -
-
- Running - ${discord?.running ? "Yes" : "No"} -
-
- Last start - ${discord?.lastStartAt ? formatRelativeTimestamp(discord.lastStartAt) : "n/a"} -
-
- Last probe - ${discord?.lastProbeAt ? formatRelativeTimestamp(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 - } - - ${renderChannelConfigSection({ channelId: "discord", props })} - -
- -
-
- `; + return renderSingleAccountChannelCard({ + title: "Discord", + subtitle: "Bot status and channel configuration.", + accountCountLabel, + statusRows: [ + { label: "Configured", value: formatNullableBoolean(configured) }, + { label: "Running", value: discord?.running ? "Yes" : "No" }, + { + label: "Last start", + value: discord?.lastStartAt ? formatRelativeTimestamp(discord.lastStartAt) : "n/a", + }, + { + label: "Last probe", + value: discord?.lastProbeAt ? formatRelativeTimestamp(discord.lastProbeAt) : "n/a", + }, + ], + lastError: discord?.lastError, + secondaryCallout: discord?.probe + ? html`
+ Probe ${discord.probe.ok ? "ok" : "failed"} · + ${discord.probe.status ?? ""} ${discord.probe.error ?? ""} +
` + : nothing, + configSection: renderChannelConfigSection({ channelId: "discord", props }), + footer: html`
+ +
`, + }); } diff --git a/ui/src/ui/views/channels.googlechat.ts b/ui/src/ui/views/channels.googlechat.ts index 6a867af449e..af4c3d2c696 100644 --- a/ui/src/ui/views/channels.googlechat.ts +++ b/ui/src/ui/views/channels.googlechat.ts @@ -2,7 +2,11 @@ import { html, nothing } from "lit"; import { formatRelativeTimestamp } from "../format.ts"; import type { GoogleChatStatus } from "../types.ts"; import { renderChannelConfigSection } from "./channels.config.ts"; -import { resolveChannelConfigured } from "./channels.shared.ts"; +import { + formatNullableBoolean, + renderSingleAccountChannelCard, + resolveChannelConfigured, +} from "./channels.shared.ts"; import type { ChannelsProps } from "./channels.types.ts"; export function renderGoogleChatCard(params: { @@ -13,69 +17,44 @@ export function renderGoogleChatCard(params: { const { props, googleChat, accountCountLabel } = params; const configured = resolveChannelConfigured("googlechat", props); - return html` -
-
Google Chat
-
Chat API webhook status and channel configuration.
- ${accountCountLabel} - -
-
- Configured - ${configured == null ? "n/a" : configured ? "Yes" : "No"} -
-
- Running - ${googleChat ? (googleChat.running ? "Yes" : "No") : "n/a"} -
-
- Credential - ${googleChat?.credentialSource ?? "n/a"} -
-
- Audience - - ${ - googleChat?.audienceType - ? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}` - : "n/a" - } - -
-
- Last start - ${googleChat?.lastStartAt ? formatRelativeTimestamp(googleChat.lastStartAt) : "n/a"} -
-
- Last probe - ${googleChat?.lastProbeAt ? formatRelativeTimestamp(googleChat.lastProbeAt) : "n/a"} -
-
- - ${ - googleChat?.lastError - ? html`
- ${googleChat.lastError} -
` - : nothing - } - - ${ - googleChat?.probe - ? html`
- Probe ${googleChat.probe.ok ? "ok" : "failed"} · - ${googleChat.probe.status ?? ""} ${googleChat.probe.error ?? ""} -
` - : nothing - } - - ${renderChannelConfigSection({ channelId: "googlechat", props })} - -
- -
-
- `; + return renderSingleAccountChannelCard({ + title: "Google Chat", + subtitle: "Chat API webhook status and channel configuration.", + accountCountLabel, + statusRows: [ + { label: "Configured", value: formatNullableBoolean(configured) }, + { + label: "Running", + value: googleChat ? (googleChat.running ? "Yes" : "No") : "n/a", + }, + { label: "Credential", value: googleChat?.credentialSource ?? "n/a" }, + { + label: "Audience", + value: googleChat?.audienceType + ? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}` + : "n/a", + }, + { + label: "Last start", + value: googleChat?.lastStartAt ? formatRelativeTimestamp(googleChat.lastStartAt) : "n/a", + }, + { + label: "Last probe", + value: googleChat?.lastProbeAt ? formatRelativeTimestamp(googleChat.lastProbeAt) : "n/a", + }, + ], + lastError: googleChat?.lastError, + secondaryCallout: googleChat?.probe + ? html`
+ Probe ${googleChat.probe.ok ? "ok" : "failed"} · + ${googleChat.probe.status ?? ""} ${googleChat.probe.error ?? ""} +
` + : nothing, + configSection: renderChannelConfigSection({ channelId: "googlechat", props }), + footer: html`
+ +
`, + }); } diff --git a/ui/src/ui/views/channels.imessage.ts b/ui/src/ui/views/channels.imessage.ts index 18f84a8b7b4..cc823cc0f46 100644 --- a/ui/src/ui/views/channels.imessage.ts +++ b/ui/src/ui/views/channels.imessage.ts @@ -2,7 +2,11 @@ import { html, nothing } from "lit"; import { formatRelativeTimestamp } from "../format.ts"; import type { IMessageStatus } from "../types.ts"; import { renderChannelConfigSection } from "./channels.config.ts"; -import { resolveChannelConfigured } from "./channels.shared.ts"; +import { + formatNullableBoolean, + renderSingleAccountChannelCard, + resolveChannelConfigured, +} from "./channels.shared.ts"; import type { ChannelsProps } from "./channels.types.ts"; export function renderIMessageCard(params: { @@ -13,55 +17,34 @@ export function renderIMessageCard(params: { const { props, imessage, accountCountLabel } = params; const configured = resolveChannelConfigured("imessage", props); - return html` -
-
iMessage
-
macOS bridge status and channel configuration.
- ${accountCountLabel} - -
-
- Configured - ${configured == null ? "n/a" : configured ? "Yes" : "No"} -
-
- Running - ${imessage?.running ? "Yes" : "No"} -
-
- Last start - ${imessage?.lastStartAt ? formatRelativeTimestamp(imessage.lastStartAt) : "n/a"} -
-
- Last probe - ${imessage?.lastProbeAt ? formatRelativeTimestamp(imessage.lastProbeAt) : "n/a"} -
-
- - ${ - imessage?.lastError - ? html`
- ${imessage.lastError} -
` - : nothing - } - - ${ - imessage?.probe - ? html`
- Probe ${imessage.probe.ok ? "ok" : "failed"} · - ${imessage.probe.error ?? ""} -
` - : nothing - } - - ${renderChannelConfigSection({ channelId: "imessage", props })} - -
- -
-
- `; + return renderSingleAccountChannelCard({ + title: "iMessage", + subtitle: "macOS bridge status and channel configuration.", + accountCountLabel, + statusRows: [ + { label: "Configured", value: formatNullableBoolean(configured) }, + { label: "Running", value: imessage?.running ? "Yes" : "No" }, + { + label: "Last start", + value: imessage?.lastStartAt ? formatRelativeTimestamp(imessage.lastStartAt) : "n/a", + }, + { + label: "Last probe", + value: imessage?.lastProbeAt ? formatRelativeTimestamp(imessage.lastProbeAt) : "n/a", + }, + ], + lastError: imessage?.lastError, + secondaryCallout: imessage?.probe + ? html`
+ Probe ${imessage.probe.ok ? "ok" : "failed"} · + ${imessage.probe.error ?? ""} +
` + : nothing, + configSection: renderChannelConfigSection({ channelId: "imessage", props }), + footer: html`
+ +
`, + }); } diff --git a/ui/src/ui/views/channels.shared.ts b/ui/src/ui/views/channels.shared.ts index 7838d3966a9..0d764ac4def 100644 --- a/ui/src/ui/views/channels.shared.ts +++ b/ui/src/ui/views/channels.shared.ts @@ -2,42 +2,138 @@ import { html, nothing } from "lit"; import type { ChannelAccountSnapshot } from "../types.ts"; import type { ChannelKey, ChannelsProps } from "./channels.types.ts"; -export function channelEnabled(key: ChannelKey, props: ChannelsProps) { - const snapshot = props.snapshot; - const channels = snapshot?.channels as Record | null; - if (!snapshot || !channels) { - return false; - } - const channelStatus = channels[key] as Record | undefined; - const configured = typeof channelStatus?.configured === "boolean" && channelStatus.configured; - const running = typeof channelStatus?.running === "boolean" && channelStatus.running; - const connected = typeof channelStatus?.connected === "boolean" && channelStatus.connected; - const accounts = snapshot.channelAccounts?.[key] ?? []; - const accountActive = accounts.some( +type ChannelDisplayState = { + configured: boolean | null; + running: boolean | null; + connected: boolean | null; + defaultAccount: ChannelAccountSnapshot | null; + hasAnyActiveAccount: boolean; + status: Record | undefined; +}; + +type ChannelStatusRow = { + label: string; + value: unknown; +}; + +function resolveChannelStatus( + key: ChannelKey, + props: ChannelsProps, +): Record | undefined { + const channels = props.snapshot?.channels as Record | null; + return channels?.[key] as Record | undefined; +} + +export function resolveDefaultChannelAccount( + key: ChannelKey, + props: ChannelsProps, +): ChannelAccountSnapshot | null { + const accounts = props.snapshot?.channelAccounts?.[key] ?? []; + const defaultAccountId = props.snapshot?.channelDefaultAccountId?.[key]; + return ( + (defaultAccountId + ? accounts.find((account) => account.accountId === defaultAccountId) + : undefined) ?? + accounts[0] ?? + null + ); +} + +export function resolveChannelDisplayState( + key: ChannelKey, + props: ChannelsProps, +): ChannelDisplayState { + const status = resolveChannelStatus(key, props); + const accounts = props.snapshot?.channelAccounts?.[key] ?? []; + const defaultAccount = resolveDefaultChannelAccount(key, props); + const configured = + typeof status?.configured === "boolean" + ? status.configured + : typeof defaultAccount?.configured === "boolean" + ? defaultAccount.configured + : null; + const running = typeof status?.running === "boolean" ? status.running : null; + const connected = typeof status?.connected === "boolean" ? status.connected : null; + const hasAnyActiveAccount = accounts.some( (account) => account.configured || account.running || account.connected, ); - return configured || running || connected || accountActive; + + return { + configured, + running, + connected, + defaultAccount, + hasAnyActiveAccount, + status, + }; +} + +export function channelEnabled(key: ChannelKey, props: ChannelsProps) { + if (!props.snapshot) { + return false; + } + const displayState = resolveChannelDisplayState(key, props); + return ( + displayState.configured === true || + displayState.running === true || + displayState.connected === true || + displayState.hasAnyActiveAccount + ); } export function resolveChannelConfigured(key: ChannelKey, props: ChannelsProps): boolean | null { - const snapshot = props.snapshot; - const channels = snapshot?.channels as Record | null; - const channelStatus = channels?.[key] as Record | undefined; - if (typeof channelStatus?.configured === "boolean") { - return channelStatus.configured; + return resolveChannelDisplayState(key, props).configured; +} + +export function formatNullableBoolean(value: boolean | null): string { + if (value == null) { + return "n/a"; } + return value ? "Yes" : "No"; +} - const accounts = snapshot?.channelAccounts?.[key] ?? []; - const defaultAccountId = snapshot?.channelDefaultAccountId?.[key]; - const defaultAccount = defaultAccountId - ? accounts.find((account) => account.accountId === defaultAccountId) - : accounts[0]; +export function renderSingleAccountChannelCard(params: { + title: string; + subtitle: string; + accountCountLabel: unknown; + statusRows: readonly ChannelStatusRow[]; + lastError?: string | null; + secondaryCallout?: unknown; + extraContent?: unknown; + configSection: unknown; + footer?: unknown; +}) { + return html` +
+
${params.title}
+
${params.subtitle}
+ ${params.accountCountLabel} - if (typeof defaultAccount?.configured === "boolean") { - return defaultAccount.configured; - } +
+ ${params.statusRows.map( + (row) => html` +
+ ${row.label} + ${row.value} +
+ `, + )} +
- return null; + ${ + params.lastError + ? html`
+ ${params.lastError} +
` + : nothing + } + + ${params.secondaryCallout ?? nothing} + ${params.extraContent ?? nothing} + ${params.configSection} + ${params.footer ?? nothing} +
+ `; } export function getChannelAccountCount( diff --git a/ui/src/ui/views/channels.signal.ts b/ui/src/ui/views/channels.signal.ts index 06417277223..d4e45a6bd6e 100644 --- a/ui/src/ui/views/channels.signal.ts +++ b/ui/src/ui/views/channels.signal.ts @@ -2,7 +2,11 @@ import { html, nothing } from "lit"; import { formatRelativeTimestamp } from "../format.ts"; import type { SignalStatus } from "../types.ts"; import { renderChannelConfigSection } from "./channels.config.ts"; -import { resolveChannelConfigured } from "./channels.shared.ts"; +import { + formatNullableBoolean, + renderSingleAccountChannelCard, + resolveChannelConfigured, +} from "./channels.shared.ts"; import type { ChannelsProps } from "./channels.types.ts"; export function renderSignalCard(params: { @@ -13,59 +17,35 @@ export function renderSignalCard(params: { const { props, signal, accountCountLabel } = params; const configured = resolveChannelConfigured("signal", props); - return html` -
-
Signal
-
signal-cli status and channel configuration.
- ${accountCountLabel} - -
-
- Configured - ${configured == null ? "n/a" : configured ? "Yes" : "No"} -
-
- Running - ${signal?.running ? "Yes" : "No"} -
-
- Base URL - ${signal?.baseUrl ?? "n/a"} -
-
- Last start - ${signal?.lastStartAt ? formatRelativeTimestamp(signal.lastStartAt) : "n/a"} -
-
- Last probe - ${signal?.lastProbeAt ? formatRelativeTimestamp(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 - } - - ${renderChannelConfigSection({ channelId: "signal", props })} - -
- -
-
- `; + return renderSingleAccountChannelCard({ + title: "Signal", + subtitle: "signal-cli status and channel configuration.", + accountCountLabel, + statusRows: [ + { label: "Configured", value: formatNullableBoolean(configured) }, + { label: "Running", value: signal?.running ? "Yes" : "No" }, + { label: "Base URL", value: signal?.baseUrl ?? "n/a" }, + { + label: "Last start", + value: signal?.lastStartAt ? formatRelativeTimestamp(signal.lastStartAt) : "n/a", + }, + { + label: "Last probe", + value: signal?.lastProbeAt ? formatRelativeTimestamp(signal.lastProbeAt) : "n/a", + }, + ], + lastError: signal?.lastError, + secondaryCallout: signal?.probe + ? html`
+ Probe ${signal.probe.ok ? "ok" : "failed"} · + ${signal.probe.status ?? ""} ${signal.probe.error ?? ""} +
` + : nothing, + configSection: renderChannelConfigSection({ channelId: "signal", props }), + footer: html`
+ +
`, + }); } diff --git a/ui/src/ui/views/channels.slack.ts b/ui/src/ui/views/channels.slack.ts index 1cf8c2a3d81..a7d313224c4 100644 --- a/ui/src/ui/views/channels.slack.ts +++ b/ui/src/ui/views/channels.slack.ts @@ -2,7 +2,11 @@ import { html, nothing } from "lit"; import { formatRelativeTimestamp } from "../format.ts"; import type { SlackStatus } from "../types.ts"; import { renderChannelConfigSection } from "./channels.config.ts"; -import { resolveChannelConfigured } from "./channels.shared.ts"; +import { + formatNullableBoolean, + renderSingleAccountChannelCard, + resolveChannelConfigured, +} from "./channels.shared.ts"; import type { ChannelsProps } from "./channels.types.ts"; export function renderSlackCard(params: { @@ -13,55 +17,34 @@ export function renderSlackCard(params: { const { props, slack, accountCountLabel } = params; const configured = resolveChannelConfigured("slack", props); - return html` -
-
Slack
-
Socket mode status and channel configuration.
- ${accountCountLabel} - -
-
- Configured - ${configured == null ? "n/a" : configured ? "Yes" : "No"} -
-
- Running - ${slack?.running ? "Yes" : "No"} -
-
- Last start - ${slack?.lastStartAt ? formatRelativeTimestamp(slack.lastStartAt) : "n/a"} -
-
- Last probe - ${slack?.lastProbeAt ? formatRelativeTimestamp(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 - } - - ${renderChannelConfigSection({ channelId: "slack", props })} - -
- -
-
- `; + return renderSingleAccountChannelCard({ + title: "Slack", + subtitle: "Socket mode status and channel configuration.", + accountCountLabel, + statusRows: [ + { label: "Configured", value: formatNullableBoolean(configured) }, + { label: "Running", value: slack?.running ? "Yes" : "No" }, + { + label: "Last start", + value: slack?.lastStartAt ? formatRelativeTimestamp(slack.lastStartAt) : "n/a", + }, + { + label: "Last probe", + value: slack?.lastProbeAt ? formatRelativeTimestamp(slack.lastProbeAt) : "n/a", + }, + ], + lastError: slack?.lastError, + secondaryCallout: slack?.probe + ? html`
+ Probe ${slack.probe.ok ? "ok" : "failed"} · + ${slack.probe.status ?? ""} ${slack.probe.error ?? ""} +
` + : nothing, + configSection: renderChannelConfigSection({ channelId: "slack", props }), + footer: html`
+ +
`, + }); } diff --git a/ui/src/ui/views/channels.telegram.ts b/ui/src/ui/views/channels.telegram.ts index 9c63f44dd54..00cbe18cf82 100644 --- a/ui/src/ui/views/channels.telegram.ts +++ b/ui/src/ui/views/channels.telegram.ts @@ -2,7 +2,11 @@ import { html, nothing } from "lit"; import { formatRelativeTimestamp } from "../format.ts"; import type { ChannelAccountSnapshot, TelegramStatus } from "../types.ts"; import { renderChannelConfigSection } from "./channels.config.ts"; -import { resolveChannelConfigured } from "./channels.shared.ts"; +import { + formatNullableBoolean, + renderSingleAccountChannelCard, + resolveChannelConfigured, +} from "./channels.shared.ts"; import type { ChannelsProps } from "./channels.types.ts"; export function renderTelegramCard(params: { @@ -54,69 +58,74 @@ export function renderTelegramCard(params: { `; }; - return html` -
-
Telegram
-
Bot status and channel configuration.
- ${accountCountLabel} + if (hasMultipleAccounts) { + return html` +
+
Telegram
+
Bot status and channel configuration.
+ ${accountCountLabel} - ${ - hasMultipleAccounts - ? html` - - ` - : html` -
-
- Configured - ${configured == null ? "n/a" : configured ? "Yes" : "No"} -
-
- Running - ${telegram?.running ? "Yes" : "No"} -
-
- Mode - ${telegram?.mode ?? "n/a"} -
-
- Last start - ${telegram?.lastStartAt ? formatRelativeTimestamp(telegram.lastStartAt) : "n/a"} -
-
- Last probe - ${telegram?.lastProbeAt ? formatRelativeTimestamp(telegram.lastProbeAt) : "n/a"} -
-
- ` - } + - ${ - telegram?.lastError - ? html`
- ${telegram.lastError} -
` - : nothing - } + ${ + telegram?.lastError + ? html`
+ ${telegram.lastError} +
` + : nothing + } - ${ - telegram?.probe - ? html`
- Probe ${telegram.probe.ok ? "ok" : "failed"} · - ${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""} -
` - : nothing - } + ${ + telegram?.probe + ? html`
+ Probe ${telegram.probe.ok ? "ok" : "failed"} · + ${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""} +
` + : nothing + } - ${renderChannelConfigSection({ channelId: "telegram", props })} + ${renderChannelConfigSection({ channelId: "telegram", props })} -
- +
+ +
-
- `; + `; + } + + return renderSingleAccountChannelCard({ + title: "Telegram", + subtitle: "Bot status and channel configuration.", + accountCountLabel, + statusRows: [ + { label: "Configured", value: formatNullableBoolean(configured) }, + { label: "Running", value: telegram?.running ? "Yes" : "No" }, + { label: "Mode", value: telegram?.mode ?? "n/a" }, + { + label: "Last start", + value: telegram?.lastStartAt ? formatRelativeTimestamp(telegram.lastStartAt) : "n/a", + }, + { + label: "Last probe", + value: telegram?.lastProbeAt ? formatRelativeTimestamp(telegram.lastProbeAt) : "n/a", + }, + ], + lastError: telegram?.lastError, + secondaryCallout: telegram?.probe + ? html`
+ Probe ${telegram.probe.ok ? "ok" : "failed"} · + ${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""} +
` + : nothing, + configSection: renderChannelConfigSection({ channelId: "telegram", props }), + footer: html`
+ +
`, + }); } diff --git a/ui/src/ui/views/channels.shared.test.ts b/ui/src/ui/views/channels.test.ts similarity index 66% rename from ui/src/ui/views/channels.shared.test.ts rename to ui/src/ui/views/channels.test.ts index ce879c147b3..c60cf989dbf 100644 --- a/ui/src/ui/views/channels.shared.test.ts +++ b/ui/src/ui/views/channels.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { resolveChannelConfigured } from "./channels.shared.ts"; +import { + channelEnabled, + resolveChannelConfigured, + resolveChannelDisplayState, +} from "./channels.shared.ts"; import type { ChannelsProps } from "./channels.types.ts"; function createProps(snapshot: ChannelsProps["snapshot"]): ChannelsProps { @@ -37,7 +41,7 @@ function createProps(snapshot: ChannelsProps["snapshot"]): ChannelsProps { }; } -describe("resolveChannelConfigured", () => { +describe("channel display selectors", () => { it("returns the channel summary configured flag when present", () => { const props = createProps({ ts: Date.now(), @@ -51,6 +55,7 @@ describe("resolveChannelConfigured", () => { }); expect(resolveChannelConfigured("discord", props)).toBe(false); + expect(resolveChannelDisplayState("discord", props).configured).toBe(false); }); it("falls back to the default account when the channel summary omits configured", () => { @@ -68,7 +73,11 @@ describe("resolveChannelConfigured", () => { channelDefaultAccountId: { discord: "discord-main" }, }); + const displayState = resolveChannelDisplayState("discord", props); + expect(resolveChannelConfigured("discord", props)).toBe(true); + expect(displayState.defaultAccount?.accountId).toBe("discord-main"); + expect(channelEnabled("discord", props)).toBe(true); }); it("falls back to the first account when no default account id is available", () => { @@ -83,6 +92,29 @@ describe("resolveChannelConfigured", () => { channelDefaultAccountId: {}, }); + const displayState = resolveChannelDisplayState("slack", props); + expect(resolveChannelConfigured("slack", props)).toBe(true); + expect(displayState.defaultAccount?.accountId).toBe("workspace-a"); + }); + + it("keeps disabled channels hidden when neither summary nor accounts are active", () => { + const props = createProps({ + ts: Date.now(), + channelOrder: ["signal"], + channelLabels: { signal: "Signal" }, + channels: { signal: {} }, + channelAccounts: { + signal: [{ accountId: "default", configured: false, running: false, connected: false }], + }, + channelDefaultAccountId: { signal: "default" }, + }); + + const displayState = resolveChannelDisplayState("signal", props); + + expect(displayState.configured).toBe(false); + expect(displayState.running).toBeNull(); + expect(displayState.connected).toBeNull(); + expect(channelEnabled("signal", props)).toBe(false); }); }); diff --git a/ui/src/ui/views/channels.ts b/ui/src/ui/views/channels.ts index 8b6ee7cdd9d..f45bc9e9486 100644 --- a/ui/src/ui/views/channels.ts +++ b/ui/src/ui/views/channels.ts @@ -21,8 +21,9 @@ import { renderIMessageCard } from "./channels.imessage.ts"; import { renderNostrCard } from "./channels.nostr.ts"; import { channelEnabled, + formatNullableBoolean, renderChannelAccountCount, - resolveChannelConfigured, + resolveChannelDisplayState, } from "./channels.shared.ts"; import { renderSignalCard } from "./channels.signal.ts"; import { renderSlackCard } from "./channels.slack.ts"; @@ -187,11 +188,9 @@ function renderGenericChannelCard( channelAccounts: Record, ) { const label = resolveChannelLabel(props.snapshot, key); - const status = props.snapshot?.channels?.[key] as Record | undefined; - const configured = resolveChannelConfigured(key, props); - const running = typeof status?.running === "boolean" ? status.running : undefined; - const connected = typeof status?.connected === "boolean" ? status.connected : undefined; - const lastError = typeof status?.lastError === "string" ? status.lastError : undefined; + const displayState = resolveChannelDisplayState(key, props); + const lastError = + typeof displayState.status?.lastError === "string" ? displayState.status.lastError : undefined; const accounts = channelAccounts[key] ?? []; const accountCountLabel = renderChannelAccountCount(key, channelAccounts); @@ -212,15 +211,15 @@ function renderGenericChannelCard(
Configured - ${configured == null ? "n/a" : configured ? "Yes" : "No"} + ${formatNullableBoolean(displayState.configured)}
Running - ${running == null ? "n/a" : running ? "Yes" : "No"} + ${formatNullableBoolean(displayState.running)}
Connected - ${connected == null ? "n/a" : connected ? "Yes" : "No"} + ${formatNullableBoolean(displayState.connected)}
` diff --git a/ui/src/ui/views/channels.whatsapp.ts b/ui/src/ui/views/channels.whatsapp.ts index d6e0a90e1e8..06c2b4d8484 100644 --- a/ui/src/ui/views/channels.whatsapp.ts +++ b/ui/src/ui/views/channels.whatsapp.ts @@ -2,7 +2,11 @@ import { html, nothing } from "lit"; import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts"; import type { WhatsAppStatus } from "../types.ts"; import { renderChannelConfigSection } from "./channels.config.ts"; -import { resolveChannelConfigured } from "./channels.shared.ts"; +import { + formatNullableBoolean, + renderSingleAccountChannelCard, + resolveChannelConfigured, +} from "./channels.shared.ts"; import type { ChannelsProps } from "./channels.types.ts"; export function renderWhatsAppCard(params: { @@ -13,108 +17,81 @@ export function renderWhatsAppCard(params: { const { props, whatsapp, accountCountLabel } = params; const configured = resolveChannelConfigured("whatsapp", props); - return html` -
-
WhatsApp
-
Link WhatsApp Web and monitor connection health.
- ${accountCountLabel} - -
-
- Configured - ${configured == null ? "n/a" : configured ? "Yes" : "No"} -
-
- Linked - ${whatsapp?.linked ? "Yes" : "No"} -
-
- Running - ${whatsapp?.running ? "Yes" : "No"} -
-
- Connected - ${whatsapp?.connected ? "Yes" : "No"} -
-
- Last connect - - ${whatsapp?.lastConnectedAt ? formatRelativeTimestamp(whatsapp.lastConnectedAt) : "n/a"} - -
-
- Last message - - ${whatsapp?.lastMessageAt ? formatRelativeTimestamp(whatsapp.lastMessageAt) : "n/a"} - -
-
- Auth age - - ${whatsapp?.authAgeMs != null ? formatDurationHuman(whatsapp.authAgeMs) : "n/a"} - -
-
- - ${ - whatsapp?.lastError - ? html`
- ${whatsapp.lastError} -
` - : nothing - } - + return renderSingleAccountChannelCard({ + title: "WhatsApp", + subtitle: "Link WhatsApp Web and monitor connection health.", + accountCountLabel, + statusRows: [ + { label: "Configured", value: formatNullableBoolean(configured) }, + { label: "Linked", value: whatsapp?.linked ? "Yes" : "No" }, + { label: "Running", value: whatsapp?.running ? "Yes" : "No" }, + { label: "Connected", value: whatsapp?.connected ? "Yes" : "No" }, + { + label: "Last connect", + value: whatsapp?.lastConnectedAt + ? formatRelativeTimestamp(whatsapp.lastConnectedAt) + : "n/a", + }, + { + label: "Last message", + value: whatsapp?.lastMessageAt ? formatRelativeTimestamp(whatsapp.lastMessageAt) : "n/a", + }, + { + label: "Auth age", + value: whatsapp?.authAgeMs != null ? formatDurationHuman(whatsapp.authAgeMs) : "n/a", + }, + ], + lastError: whatsapp?.lastError, + extraContent: html` ${ props.whatsappMessage ? html`
- ${props.whatsappMessage} -
` + ${props.whatsappMessage} +
` : nothing } ${ props.whatsappQrDataUrl ? html`
- WhatsApp QR -
` + WhatsApp QR +
` : nothing } - -
- - - - - -
- - ${renderChannelConfigSection({ channelId: "whatsapp", props })} - - `; + `, + configSection: renderChannelConfigSection({ channelId: "whatsapp", props }), + footer: html`
+ + + + + +
`, + }); } diff --git a/vitest.config.ts b/vitest.config.ts index 558b588a3a9..1f96ef339ff 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -70,6 +70,7 @@ export default defineConfig({ "test/**/*.test.ts", "ui/src/ui/app-chat.test.ts", "ui/src/ui/views/agents-utils.test.ts", + "ui/src/ui/views/channels.test.ts", "ui/src/ui/views/chat.test.ts", "ui/src/ui/views/nodes.devices.test.ts", "ui/src/ui/views/usage-render-details.test.ts", diff --git a/vitest.unit-paths.mjs b/vitest.unit-paths.mjs index c0becc4d048..a3a3888c935 100644 --- a/vitest.unit-paths.mjs +++ b/vitest.unit-paths.mjs @@ -5,6 +5,7 @@ export const unitTestIncludePatterns = [ "test/**/*.test.ts", "ui/src/ui/app-chat.test.ts", "ui/src/ui/views/agents-utils.test.ts", + "ui/src/ui/views/channels.test.ts", "ui/src/ui/views/chat.test.ts", "ui/src/ui/views/usage-render-details.test.ts", "ui/src/ui/controllers/agents.test.ts",