refactor: share channel card selectors and layout

This commit is contained in:
Peter Steinberger
2026-03-24 21:43:42 -07:00
parent c00372e559
commit 23a4932997
13 changed files with 494 additions and 470 deletions

View File

@@ -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);
});

View File

@@ -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`
<div class="card">
<div class="card-title">Discord</div>
<div class="card-sub">Bot status and channel configuration.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${configured == null ? "n/a" : configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${discord?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${discord?.lastStartAt ? formatRelativeTimestamp(discord.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${discord?.lastProbeAt ? formatRelativeTimestamp(discord.lastProbeAt) : "n/a"}</span>
</div>
</div>
${
discord?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${discord.lastError}
</div>`
: nothing
}
${
discord?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${discord.probe.ok ? "ok" : "failed"} ·
${discord.probe.status ?? ""} ${discord.probe.error ?? ""}
</div>`
: nothing
}
${renderChannelConfigSection({ channelId: "discord", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>
</div>
`;
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`<div class="callout" style="margin-top: 12px;">
Probe ${discord.probe.ok ? "ok" : "failed"} ·
${discord.probe.status ?? ""} ${discord.probe.error ?? ""}
</div>`
: nothing,
configSection: renderChannelConfigSection({ channelId: "discord", props }),
footer: html`<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>`,
});
}

View File

@@ -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`
<div class="card">
<div class="card-title">Google Chat</div>
<div class="card-sub">Chat API webhook status and channel configuration.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${configured == null ? "n/a" : configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${googleChat ? (googleChat.running ? "Yes" : "No") : "n/a"}</span>
</div>
<div>
<span class="label">Credential</span>
<span>${googleChat?.credentialSource ?? "n/a"}</span>
</div>
<div>
<span class="label">Audience</span>
<span>
${
googleChat?.audienceType
? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}`
: "n/a"
}
</span>
</div>
<div>
<span class="label">Last start</span>
<span>${googleChat?.lastStartAt ? formatRelativeTimestamp(googleChat.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${googleChat?.lastProbeAt ? formatRelativeTimestamp(googleChat.lastProbeAt) : "n/a"}</span>
</div>
</div>
${
googleChat?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${googleChat.lastError}
</div>`
: nothing
}
${
googleChat?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${googleChat.probe.ok ? "ok" : "failed"} ·
${googleChat.probe.status ?? ""} ${googleChat.probe.error ?? ""}
</div>`
: nothing
}
${renderChannelConfigSection({ channelId: "googlechat", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>
</div>
`;
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`<div class="callout" style="margin-top: 12px;">
Probe ${googleChat.probe.ok ? "ok" : "failed"} ·
${googleChat.probe.status ?? ""} ${googleChat.probe.error ?? ""}
</div>`
: nothing,
configSection: renderChannelConfigSection({ channelId: "googlechat", props }),
footer: html`<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>`,
});
}

View File

@@ -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`
<div class="card">
<div class="card-title">iMessage</div>
<div class="card-sub">macOS bridge status and channel configuration.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${configured == null ? "n/a" : configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${imessage?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${imessage?.lastStartAt ? formatRelativeTimestamp(imessage.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${imessage?.lastProbeAt ? formatRelativeTimestamp(imessage.lastProbeAt) : "n/a"}</span>
</div>
</div>
${
imessage?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${imessage.lastError}
</div>`
: nothing
}
${
imessage?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${imessage.probe.ok ? "ok" : "failed"} ·
${imessage.probe.error ?? ""}
</div>`
: nothing
}
${renderChannelConfigSection({ channelId: "imessage", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>
</div>
`;
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`<div class="callout" style="margin-top: 12px;">
Probe ${imessage.probe.ok ? "ok" : "failed"} ·
${imessage.probe.error ?? ""}
</div>`
: nothing,
configSection: renderChannelConfigSection({ channelId: "imessage", props }),
footer: html`<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>`,
});
}

View File

@@ -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<string, unknown> | null;
if (!snapshot || !channels) {
return false;
}
const channelStatus = channels[key] as Record<string, unknown> | 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<string, unknown> | undefined;
};
type ChannelStatusRow = {
label: string;
value: unknown;
};
function resolveChannelStatus(
key: ChannelKey,
props: ChannelsProps,
): Record<string, unknown> | undefined {
const channels = props.snapshot?.channels as Record<string, unknown> | null;
return channels?.[key] as Record<string, unknown> | 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<string, unknown> | null;
const channelStatus = channels?.[key] as Record<string, unknown> | 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`
<div class="card">
<div class="card-title">${params.title}</div>
<div class="card-sub">${params.subtitle}</div>
${params.accountCountLabel}
if (typeof defaultAccount?.configured === "boolean") {
return defaultAccount.configured;
}
<div class="status-list" style="margin-top: 16px;">
${params.statusRows.map(
(row) => html`
<div>
<span class="label">${row.label}</span>
<span>${row.value}</span>
</div>
`,
)}
</div>
return null;
${
params.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${params.lastError}
</div>`
: nothing
}
${params.secondaryCallout ?? nothing}
${params.extraContent ?? nothing}
${params.configSection}
${params.footer ?? nothing}
</div>
`;
}
export function getChannelAccountCount(

View File

@@ -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`
<div class="card">
<div class="card-title">Signal</div>
<div class="card-sub">signal-cli status and channel configuration.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${configured == null ? "n/a" : configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${signal?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Base URL</span>
<span>${signal?.baseUrl ?? "n/a"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${signal?.lastStartAt ? formatRelativeTimestamp(signal.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${signal?.lastProbeAt ? formatRelativeTimestamp(signal.lastProbeAt) : "n/a"}</span>
</div>
</div>
${
signal?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${signal.lastError}
</div>`
: nothing
}
${
signal?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${signal.probe.ok ? "ok" : "failed"} ·
${signal.probe.status ?? ""} ${signal.probe.error ?? ""}
</div>`
: nothing
}
${renderChannelConfigSection({ channelId: "signal", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>
</div>
`;
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`<div class="callout" style="margin-top: 12px;">
Probe ${signal.probe.ok ? "ok" : "failed"} ·
${signal.probe.status ?? ""} ${signal.probe.error ?? ""}
</div>`
: nothing,
configSection: renderChannelConfigSection({ channelId: "signal", props }),
footer: html`<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>`,
});
}

View File

@@ -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`
<div class="card">
<div class="card-title">Slack</div>
<div class="card-sub">Socket mode status and channel configuration.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${configured == null ? "n/a" : configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${slack?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${slack?.lastStartAt ? formatRelativeTimestamp(slack.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${slack?.lastProbeAt ? formatRelativeTimestamp(slack.lastProbeAt) : "n/a"}</span>
</div>
</div>
${
slack?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${slack.lastError}
</div>`
: nothing
}
${
slack?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${slack.probe.ok ? "ok" : "failed"} ·
${slack.probe.status ?? ""} ${slack.probe.error ?? ""}
</div>`
: nothing
}
${renderChannelConfigSection({ channelId: "slack", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>
</div>
`;
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`<div class="callout" style="margin-top: 12px;">
Probe ${slack.probe.ok ? "ok" : "failed"} ·
${slack.probe.status ?? ""} ${slack.probe.error ?? ""}
</div>`
: nothing,
configSection: renderChannelConfigSection({ channelId: "slack", props }),
footer: html`<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>`,
});
}

View File

@@ -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`
<div class="card">
<div class="card-title">Telegram</div>
<div class="card-sub">Bot status and channel configuration.</div>
${accountCountLabel}
if (hasMultipleAccounts) {
return html`
<div class="card">
<div class="card-title">Telegram</div>
<div class="card-sub">Bot status and channel configuration.</div>
${accountCountLabel}
${
hasMultipleAccounts
? html`
<div class="account-card-list">
${telegramAccounts.map((account) => renderAccountCard(account))}
</div>
`
: html`
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${configured == null ? "n/a" : configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${telegram?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Mode</span>
<span>${telegram?.mode ?? "n/a"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${telegram?.lastStartAt ? formatRelativeTimestamp(telegram.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${telegram?.lastProbeAt ? formatRelativeTimestamp(telegram.lastProbeAt) : "n/a"}</span>
</div>
</div>
`
}
<div class="account-card-list">
${telegramAccounts.map((account) => renderAccountCard(account))}
</div>
${
telegram?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${telegram.lastError}
</div>`
: nothing
}
${
telegram?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${telegram.lastError}
</div>`
: nothing
}
${
telegram?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${telegram.probe.ok ? "ok" : "failed"} ·
${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""}
</div>`
: nothing
}
${
telegram?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${telegram.probe.ok ? "ok" : "failed"} ·
${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""}
</div>`
: nothing
}
${renderChannelConfigSection({ channelId: "telegram", props })}
${renderChannelConfigSection({ channelId: "telegram", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>
</div>
</div>
`;
`;
}
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`<div class="callout" style="margin-top: 12px;">
Probe ${telegram.probe.ok ? "ok" : "failed"} ·
${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""}
</div>`
: nothing,
configSection: renderChannelConfigSection({ channelId: "telegram", props }),
footer: html`<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>`,
});
}

View File

@@ -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);
});
});

View File

@@ -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<string, ChannelAccountSnapshot[]>,
) {
const label = resolveChannelLabel(props.snapshot, key);
const status = props.snapshot?.channels?.[key] as Record<string, unknown> | 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(
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${configured == null ? "n/a" : configured ? "Yes" : "No"}</span>
<span>${formatNullableBoolean(displayState.configured)}</span>
</div>
<div>
<span class="label">Running</span>
<span>${running == null ? "n/a" : running ? "Yes" : "No"}</span>
<span>${formatNullableBoolean(displayState.running)}</span>
</div>
<div>
<span class="label">Connected</span>
<span>${connected == null ? "n/a" : connected ? "Yes" : "No"}</span>
<span>${formatNullableBoolean(displayState.connected)}</span>
</div>
</div>
`

View File

@@ -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`
<div class="card">
<div class="card-title">WhatsApp</div>
<div class="card-sub">Link WhatsApp Web and monitor connection health.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${configured == null ? "n/a" : configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Linked</span>
<span>${whatsapp?.linked ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${whatsapp?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Connected</span>
<span>${whatsapp?.connected ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Last connect</span>
<span>
${whatsapp?.lastConnectedAt ? formatRelativeTimestamp(whatsapp.lastConnectedAt) : "n/a"}
</span>
</div>
<div>
<span class="label">Last message</span>
<span>
${whatsapp?.lastMessageAt ? formatRelativeTimestamp(whatsapp.lastMessageAt) : "n/a"}
</span>
</div>
<div>
<span class="label">Auth age</span>
<span>
${whatsapp?.authAgeMs != null ? formatDurationHuman(whatsapp.authAgeMs) : "n/a"}
</span>
</div>
</div>
${
whatsapp?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${whatsapp.lastError}
</div>`
: 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`<div class="callout" style="margin-top: 12px;">
${props.whatsappMessage}
</div>`
${props.whatsappMessage}
</div>`
: nothing
}
${
props.whatsappQrDataUrl
? html`<div class="qr-wrap">
<img src=${props.whatsappQrDataUrl} alt="WhatsApp QR" />
</div>`
<img src=${props.whatsappQrDataUrl} alt="WhatsApp QR" />
</div>`
: nothing
}
<div class="row" style="margin-top: 14px; flex-wrap: wrap;">
<button
class="btn primary"
?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppStart(false)}
>
${props.whatsappBusy ? "Working…" : "Show QR"}
</button>
<button
class="btn"
?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppStart(true)}
>
Relink
</button>
<button
class="btn"
?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppWait()}
>
Wait for scan
</button>
<button
class="btn danger"
?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppLogout()}
>
Logout
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>
Refresh
</button>
</div>
${renderChannelConfigSection({ channelId: "whatsapp", props })}
</div>
`;
`,
configSection: renderChannelConfigSection({ channelId: "whatsapp", props }),
footer: html`<div class="row" style="margin-top: 14px; flex-wrap: wrap;">
<button
class="btn primary"
?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppStart(false)}
>
${props.whatsappBusy ? "Working…" : "Show QR"}
</button>
<button
class="btn"
?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppStart(true)}
>
Relink
</button>
<button
class="btn"
?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppWait()}
>
Wait for scan
</button>
<button
class="btn danger"
?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppLogout()}
>
Logout
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>
Refresh
</button>
</div>`,
});
}

View File

@@ -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",

View File

@@ -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",