diff --git a/ui/package.json b/ui/package.json index dc6f69063..681da580b 100644 --- a/ui/package.json +++ b/ui/package.json @@ -19,8 +19,5 @@ "playwright": "^1.57.0", "typescript": "^5.9.3", "vitest": "4.0.17" - }, - "pnpm": { - "minimumReleaseAge": 2880 } } diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts index aed9b29d6..cc0cc76ad 100644 --- a/ui/src/ui/controllers/config.ts +++ b/ui/src/ui/controllers/config.ts @@ -17,6 +17,12 @@ import { type SignalForm, type TelegramForm, } from "../ui-types"; +import { + cloneConfigObject, + removePathValue, + serializeConfigForm, + setPathValue, +} from "./config/form-utils"; export type ConfigState = { client: GatewayBrowserClient | null; @@ -477,84 +483,3 @@ export function removeConfigFormValue( state.configRaw = serializeConfigForm(base); } } - -function cloneConfigObject(value: T): T { - if (typeof structuredClone === "function") { - return structuredClone(value); - } - return JSON.parse(JSON.stringify(value)) as T; -} - -function serializeConfigForm(form: Record): string { - return `${JSON.stringify(form, null, 2).trimEnd()}\n`; -} - -function setPathValue( - obj: Record | unknown[], - path: Array, - value: unknown, -) { - if (path.length === 0) return; - let current: Record | unknown[] = obj; - for (let i = 0; i < path.length - 1; i += 1) { - const key = path[i]; - const nextKey = path[i + 1]; - if (typeof key === "number") { - if (!Array.isArray(current)) return; - if (current[key] == null) { - current[key] = - typeof nextKey === "number" ? [] : ({} as Record); - } - current = current[key] as Record | unknown[]; - } else { - if (typeof current !== "object" || current == null) return; - const record = current as Record; - if (record[key] == null) { - record[key] = - typeof nextKey === "number" ? [] : ({} as Record); - } - current = record[key] as Record | unknown[]; - } - } - const lastKey = path[path.length - 1]; - if (typeof lastKey === "number") { - if (Array.isArray(current)) { - current[lastKey] = value; - } - return; - } - if (typeof current === "object" && current != null) { - (current as Record)[lastKey] = value; - } -} - -function removePathValue( - obj: Record | unknown[], - path: Array, -) { - if (path.length === 0) return; - let current: Record | unknown[] = obj; - for (let i = 0; i < path.length - 1; i += 1) { - const key = path[i]; - if (typeof key === "number") { - if (!Array.isArray(current)) return; - current = current[key] as Record | unknown[]; - } else { - if (typeof current !== "object" || current == null) return; - current = (current as Record)[key] as - | Record - | unknown[]; - } - if (current == null) return; - } - const lastKey = path[path.length - 1]; - if (typeof lastKey === "number") { - if (Array.isArray(current)) { - current.splice(lastKey, 1); - } - return; - } - if (typeof current === "object" && current != null) { - delete (current as Record)[lastKey]; - } -} diff --git a/ui/src/ui/controllers/config/form-utils.ts b/ui/src/ui/controllers/config/form-utils.ts new file mode 100644 index 000000000..dea4d7f61 --- /dev/null +++ b/ui/src/ui/controllers/config/form-utils.ts @@ -0,0 +1,77 @@ +export function cloneConfigObject(value: T): T { + if (typeof structuredClone === "function") { + return structuredClone(value); + } + return JSON.parse(JSON.stringify(value)) as T; +} + +export function serializeConfigForm(form: Record): string { + return `${JSON.stringify(form, null, 2).trimEnd()}\n`; +} + +export function setPathValue( + obj: Record | unknown[], + path: Array, + value: unknown, +) { + if (path.length === 0) return; + let current: Record | unknown[] = obj; + for (let i = 0; i < path.length - 1; i += 1) { + const key = path[i]; + const nextKey = path[i + 1]; + if (typeof key === "number") { + if (!Array.isArray(current)) return; + if (current[key] == null) { + current[key] = + typeof nextKey === "number" ? [] : ({} as Record); + } + current = current[key] as Record | unknown[]; + } else { + if (typeof current !== "object" || current == null) return; + const record = current as Record; + if (record[key] == null) { + record[key] = + typeof nextKey === "number" ? [] : ({} as Record); + } + current = record[key] as Record | unknown[]; + } + } + const lastKey = path[path.length - 1]; + if (typeof lastKey === "number") { + if (Array.isArray(current)) current[lastKey] = value; + return; + } + if (typeof current === "object" && current != null) { + (current as Record)[lastKey] = value; + } +} + +export function removePathValue( + obj: Record | unknown[], + path: Array, +) { + if (path.length === 0) return; + let current: Record | unknown[] = obj; + for (let i = 0; i < path.length - 1; i += 1) { + const key = path[i]; + if (typeof key === "number") { + if (!Array.isArray(current)) return; + current = current[key] as Record | unknown[]; + } else { + if (typeof current !== "object" || current == null) return; + current = (current as Record)[key] as + | Record + | unknown[]; + } + if (current == null) return; + } + const lastKey = path[path.length - 1]; + if (typeof lastKey === "number") { + if (Array.isArray(current)) current.splice(lastKey, 1); + return; + } + if (typeof current === "object" && current != null) { + delete (current as Record)[lastKey]; + } +} + diff --git a/ui/src/ui/views/connections.action-options.ts b/ui/src/ui/views/connections.action-options.ts new file mode 100644 index 000000000..dd68e12ea --- /dev/null +++ b/ui/src/ui/views/connections.action-options.ts @@ -0,0 +1,28 @@ +import type { DiscordActionForm, SlackActionForm } from "../ui-types"; + +export const discordActionOptions = [ + { key: "reactions", label: "Reactions" }, + { key: "stickers", label: "Stickers" }, + { key: "polls", label: "Polls" }, + { key: "permissions", label: "Permissions" }, + { key: "messages", label: "Messages" }, + { key: "threads", label: "Threads" }, + { key: "pins", label: "Pins" }, + { key: "search", label: "Search" }, + { key: "memberInfo", label: "Member info" }, + { key: "roleInfo", label: "Role info" }, + { key: "channelInfo", label: "Channel info" }, + { key: "voiceStatus", label: "Voice status" }, + { key: "events", label: "Events" }, + { key: "roles", label: "Role changes" }, + { key: "moderation", label: "Moderation" }, +] satisfies Array<{ key: keyof DiscordActionForm; label: string }>; + +export const slackActionOptions = [ + { key: "reactions", label: "Reactions" }, + { key: "messages", label: "Messages" }, + { key: "pins", label: "Pins" }, + { key: "memberInfo", label: "Member info" }, + { key: "emojiList", label: "Emoji list" }, +] satisfies Array<{ key: keyof SlackActionForm; label: string }>; + diff --git a/ui/src/ui/views/connections.shared.ts b/ui/src/ui/views/connections.shared.ts new file mode 100644 index 000000000..bf50337ce --- /dev/null +++ b/ui/src/ui/views/connections.shared.ts @@ -0,0 +1,71 @@ +import { html, nothing } from "lit"; + +import type { + DiscordStatus, + IMessageStatus, + SignalStatus, + SlackStatus, + TelegramStatus, + WhatsAppStatus, +} from "../types"; +import type { ChannelAccountSnapshot } from "../types"; +import type { ChannelKey, ConnectionsProps } from "./connections.types"; + +export function formatDuration(ms?: number | null) { + if (!ms && ms !== 0) return "n/a"; + const sec = Math.round(ms / 1000); + if (sec < 60) return `${sec}s`; + const min = Math.round(sec / 60); + if (min < 60) return `${min}m`; + const hr = Math.round(min / 60); + return `${hr}h`; +} + +export function channelEnabled(key: ChannelKey, props: ConnectionsProps) { + const snapshot = props.snapshot; + const channels = snapshot?.channels as Record | null; + if (!snapshot || !channels) return false; + const whatsapp = channels.whatsapp as WhatsAppStatus | undefined; + const telegram = channels.telegram as TelegramStatus | undefined; + const discord = (channels.discord ?? null) as DiscordStatus | null; + const slack = (channels.slack ?? null) as SlackStatus | null; + const signal = (channels.signal ?? null) as SignalStatus | null; + const imessage = (channels.imessage ?? null) as IMessageStatus | null; + switch (key) { + case "whatsapp": + return ( + Boolean(whatsapp?.configured) || + Boolean(whatsapp?.linked) || + Boolean(whatsapp?.running) + ); + case "telegram": + return Boolean(telegram?.configured) || Boolean(telegram?.running); + case "discord": + return Boolean(discord?.configured || discord?.running); + case "slack": + return Boolean(slack?.configured || slack?.running); + case "signal": + return Boolean(signal?.configured || signal?.running); + case "imessage": + return Boolean(imessage?.configured || imessage?.running); + default: + return false; + } +} + +export function getChannelAccountCount( + key: ChannelKey, + channelAccounts?: Record | null, +): number { + return channelAccounts?.[key]?.length ?? 0; +} + +export function renderChannelAccountCount( + key: ChannelKey, + channelAccounts?: Record | null, +) { + const count = getChannelAccountCount(key, channelAccounts); + if (count < 2) return nothing; + return html``; +} + diff --git a/ui/src/ui/views/connections.telegram.ts b/ui/src/ui/views/connections.telegram.ts new file mode 100644 index 000000000..8c11cdf5a --- /dev/null +++ b/ui/src/ui/views/connections.telegram.ts @@ -0,0 +1,248 @@ +import { html, nothing } from "lit"; + +import { formatAgo } from "../format"; +import type { ChannelAccountSnapshot, TelegramStatus } from "../types"; +import type { ConnectionsProps } from "./connections.types"; + +export function renderTelegramCard(params: { + props: ConnectionsProps; + telegram?: TelegramStatus; + telegramAccounts: ChannelAccountSnapshot[]; + accountCountLabel: unknown; +}) { + const { props, telegram, telegramAccounts, accountCountLabel } = params; + const hasMultipleAccounts = telegramAccounts.length > 1; + + const renderAccountCard = (account: ChannelAccountSnapshot) => { + const probe = account.probe as { bot?: { username?: string } } | undefined; + const botUsername = probe?.bot?.username; + const label = account.name || account.accountId; + return html` + + `; + }; + + return html` +
+
Telegram
+
Bot token and delivery options.
+ ${accountCountLabel} + + ${hasMultipleAccounts + ? html` + + ` + : html` +
+
+ Configured + ${telegram?.configured ? "Yes" : "No"} +
+
+ Running + ${telegram?.running ? "Yes" : "No"} +
+
+ Mode + ${telegram?.mode ?? "n/a"} +
+
+ Last start + ${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"} +
+
+ Last probe + ${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"} +
+
+ `} + + ${telegram?.lastError + ? html`
+ ${telegram.lastError} +
` + : nothing} + + ${telegram?.probe + ? html`
+ Probe ${telegram.probe.ok ? "ok" : "failed"} · + ${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""} +
` + : nothing} + +
+ + + + + + + + +
+ +
+ Allow from supports numeric user IDs (recommended) or @usernames. DM the bot + to get your ID, or run /whoami. +
+ + ${props.telegramTokenLocked + ? html`
+ TELEGRAM_BOT_TOKEN is set in the environment. Config edits will not override it. +
` + : nothing} + + ${props.telegramForm.groupsWildcardEnabled + ? html`
+ This writes telegram.groups["*"] and allows all groups. Remove it + if you only want specific groups. +
+ +
+
` + : nothing} + + ${props.telegramStatus + ? html`
+ ${props.telegramStatus} +
` + : nothing} + +
+ + +
+
+ `; +} + diff --git a/ui/src/ui/views/connections.ts b/ui/src/ui/views/connections.ts index dd276caec..42bbaf602 100644 --- a/ui/src/ui/views/connections.ts +++ b/ui/src/ui/views/connections.ts @@ -12,85 +12,21 @@ import type { WhatsAppStatus, } from "../types"; import type { - DiscordActionForm, DiscordForm, IMessageForm, - SlackActionForm, SlackForm, SignalForm, TelegramForm, } from "../ui-types"; - -const discordActionOptions = [ - { key: "reactions", label: "Reactions" }, - { key: "stickers", label: "Stickers" }, - { key: "polls", label: "Polls" }, - { key: "permissions", label: "Permissions" }, - { key: "messages", label: "Messages" }, - { key: "threads", label: "Threads" }, - { key: "pins", label: "Pins" }, - { key: "search", label: "Search" }, - { key: "memberInfo", label: "Member info" }, - { key: "roleInfo", label: "Role info" }, - { key: "channelInfo", label: "Channel info" }, - { key: "voiceStatus", label: "Voice status" }, - { key: "events", label: "Events" }, - { key: "roles", label: "Role changes" }, - { key: "moderation", label: "Moderation" }, -] satisfies Array<{ key: keyof DiscordActionForm; label: string }>; - -const slackActionOptions = [ - { key: "reactions", label: "Reactions" }, - { key: "messages", label: "Messages" }, - { key: "pins", label: "Pins" }, - { key: "memberInfo", label: "Member info" }, - { key: "emojiList", label: "Emoji list" }, -] satisfies Array<{ key: keyof SlackActionForm; label: string }>; - -export type ConnectionsProps = { - connected: boolean; - loading: boolean; - snapshot: ChannelsStatusSnapshot | null; - lastError: string | null; - lastSuccessAt: number | null; - whatsappMessage: string | null; - whatsappQrDataUrl: string | null; - whatsappConnected: boolean | null; - whatsappBusy: boolean; - telegramForm: TelegramForm; - telegramTokenLocked: boolean; - telegramSaving: boolean; - telegramStatus: string | null; - discordForm: DiscordForm; - discordTokenLocked: boolean; - discordSaving: boolean; - discordStatus: string | null; - slackForm: SlackForm; - slackTokenLocked: boolean; - slackAppTokenLocked: boolean; - slackSaving: boolean; - slackStatus: string | null; - signalForm: SignalForm; - signalSaving: boolean; - signalStatus: string | null; - imessageForm: IMessageForm; - imessageSaving: boolean; - imessageStatus: string | null; - onRefresh: (probe: boolean) => void; - onWhatsAppStart: (force: boolean) => void; - onWhatsAppWait: () => void; - onWhatsAppLogout: () => void; - onTelegramChange: (patch: Partial) => void; - onTelegramSave: () => void; - onDiscordChange: (patch: Partial) => void; - onDiscordSave: () => void; - onSlackChange: (patch: Partial) => void; - onSlackSave: () => void; - onSignalChange: (patch: Partial) => void; - onSignalSave: () => void; - onIMessageChange: (patch: Partial) => void; - onIMessageSave: () => void; -}; +import type { + ChannelKey, + ConnectionsChannelData, + ConnectionsProps, +} from "./connections.types"; +import { channelEnabled, formatDuration, renderChannelAccountCount } from "./connections.shared"; +import { discordActionOptions, slackActionOptions } from "./connections.action-options"; +import { renderTelegramCard } from "./connections.telegram"; +import { renderWhatsAppCard } from "./connections.whatsapp"; export function renderConnections(props: ConnectionsProps) { const channels = props.snapshot?.channels as Record | null; @@ -158,426 +94,29 @@ ${props.snapshot ? JSON.stringify(props.snapshot, null, 2) : "No snapshot yet."} `; } -function formatDuration(ms?: number | null) { - if (!ms && ms !== 0) return "n/a"; - const sec = Math.round(ms / 1000); - if (sec < 60) return `${sec}s`; - const min = Math.round(sec / 60); - if (min < 60) return `${min}m`; - const hr = Math.round(min / 60); - return `${hr}h`; -} - -type ChannelKey = - | "whatsapp" - | "telegram" - | "discord" - | "slack" - | "signal" - | "imessage"; - -function channelEnabled(key: ChannelKey, props: ConnectionsProps) { - const snapshot = props.snapshot; - const channels = snapshot?.channels as Record | null; - if (!snapshot || !channels) return false; - const whatsapp = channels.whatsapp as WhatsAppStatus | undefined; - const telegram = channels.telegram as TelegramStatus | undefined; - const discord = (channels.discord ?? null) as DiscordStatus | null; - const slack = (channels.slack ?? null) as SlackStatus | null; - const signal = (channels.signal ?? null) as SignalStatus | null; - const imessage = (channels.imessage ?? null) as IMessageStatus | null; - switch (key) { - case "whatsapp": - return ( - Boolean(whatsapp?.configured) || - Boolean(whatsapp?.linked) || - Boolean(whatsapp?.running) - ); - case "telegram": - return Boolean(telegram?.configured) || Boolean(telegram?.running); - case "discord": - return Boolean(discord?.configured || discord?.running); - case "slack": - return Boolean(slack?.configured || slack?.running); - case "signal": - return Boolean(signal?.configured || signal?.running); - case "imessage": - return Boolean(imessage?.configured || imessage?.running); - default: - return false; - } -} - -function getChannelAccountCount( - key: ChannelKey, - channelAccounts?: Record | null, -): number { - return channelAccounts?.[key]?.length ?? 0; -} - -function renderChannelAccountCount( - key: ChannelKey, - channelAccounts?: Record | null, -) { - const count = getChannelAccountCount(key, channelAccounts); - if (count < 2) return nothing; - return html``; -} - function renderChannel( key: ChannelKey, props: ConnectionsProps, - data: { - whatsapp?: WhatsAppStatus; - telegram?: TelegramStatus; - discord?: DiscordStatus | null; - slack?: SlackStatus | null; - signal?: SignalStatus | null; - imessage?: IMessageStatus | null; - channelAccounts?: Record | null; - }, + data: ConnectionsChannelData, ) { const accountCountLabel = renderChannelAccountCount( key, data.channelAccounts, ); switch (key) { - case "whatsapp": { - const whatsapp = data.whatsapp; - return html` -
-
WhatsApp
-
Link WhatsApp Web and monitor connection health.
- ${accountCountLabel} - -
-
- Configured - ${whatsapp?.configured ? "Yes" : "No"} -
-
- Linked - ${whatsapp?.linked ? "Yes" : "No"} -
-
- Running - ${whatsapp?.running ? "Yes" : "No"} -
-
- Connected - ${whatsapp?.connected ? "Yes" : "No"} -
-
- Last connect - - ${whatsapp?.lastConnectedAt - ? formatAgo(whatsapp.lastConnectedAt) - : "n/a"} - -
-
- Last message - - ${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"} - -
-
- Auth age - - ${whatsapp?.authAgeMs != null ? formatDuration(whatsapp.authAgeMs) : "n/a"} - -
-
- - ${whatsapp?.lastError - ? html`
- ${whatsapp.lastError} -
` - : nothing} - - ${props.whatsappMessage - ? html`
- ${props.whatsappMessage} -
` - : nothing} - - ${props.whatsappQrDataUrl - ? html`
- WhatsApp QR -
` - : nothing} - -
- - - - - -
-
- `; - } - case "telegram": { - const telegram = data.telegram; - const telegramAccounts = data.channelAccounts?.telegram ?? []; - const hasMultipleAccounts = telegramAccounts.length > 1; - - const renderAccountCard = (account: ChannelAccountSnapshot) => { - const probe = account.probe as { bot?: { username?: string } } | undefined; - const botUsername = probe?.bot?.username; - const label = account.name || account.accountId; - return html` - - `; - }; - - return html` -
-
Telegram
-
Bot token and delivery options.
- ${accountCountLabel} - - ${hasMultipleAccounts ? html` - - ` : html` -
-
- Configured - ${telegram?.configured ? "Yes" : "No"} -
-
- Running - ${telegram?.running ? "Yes" : "No"} -
-
- Mode - ${telegram?.mode ?? "n/a"} -
-
- Last start - ${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"} -
-
- Last probe - ${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"} -
-
- `} - - ${telegram?.lastError - ? html`
- ${telegram.lastError} -
` - : nothing} - - ${telegram?.probe - ? html`
- Probe ${telegram.probe.ok ? "ok" : "failed"} · - ${telegram.probe.status ?? ""} - ${telegram.probe.error ?? ""} -
` - : nothing} - -
- - - - - - - - -
- -
- Allow from supports numeric user IDs (recommended) or @usernames. DM the bot - to get your ID, or run /whoami. -
- - ${props.telegramTokenLocked - ? html`
- TELEGRAM_BOT_TOKEN is set in the environment. Config edits will not override it. -
` - : nothing} - - ${props.telegramForm.groupsWildcardEnabled - ? html`
- This writes telegram.groups["*"] and allows all groups. Remove it - if you only want specific groups. -
- -
-
` - : nothing} - - ${props.telegramStatus - ? html`
- ${props.telegramStatus} -
` - : nothing} - -
- - -
-
- `; - } + case "whatsapp": + return renderWhatsAppCard({ + props, + whatsapp: data.whatsapp, + accountCountLabel, + }); + case "telegram": + return renderTelegramCard({ + props, + telegram: data.telegram, + telegramAccounts: data.channelAccounts?.telegram ?? [], + accountCountLabel, + }); case "discord": { const discord = data.discord; const botName = discord?.probe?.bot?.username; diff --git a/ui/src/ui/views/connections.types.ts b/ui/src/ui/views/connections.types.ts new file mode 100644 index 000000000..b7b750b15 --- /dev/null +++ b/ui/src/ui/views/connections.types.ts @@ -0,0 +1,81 @@ +import type { + ChannelAccountSnapshot, + ChannelsStatusSnapshot, + DiscordStatus, + IMessageStatus, + SignalStatus, + SlackStatus, + TelegramStatus, + WhatsAppStatus, +} from "../types"; +import type { + DiscordForm, + IMessageForm, + SignalForm, + SlackForm, + TelegramForm, +} from "../ui-types"; + +export type ChannelKey = + | "whatsapp" + | "telegram" + | "discord" + | "slack" + | "signal" + | "imessage"; + +export type ConnectionsProps = { + connected: boolean; + loading: boolean; + snapshot: ChannelsStatusSnapshot | null; + lastError: string | null; + lastSuccessAt: number | null; + whatsappMessage: string | null; + whatsappQrDataUrl: string | null; + whatsappConnected: boolean | null; + whatsappBusy: boolean; + telegramForm: TelegramForm; + telegramTokenLocked: boolean; + telegramSaving: boolean; + telegramStatus: string | null; + discordForm: DiscordForm; + discordTokenLocked: boolean; + discordSaving: boolean; + discordStatus: string | null; + slackForm: SlackForm; + slackTokenLocked: boolean; + slackAppTokenLocked: boolean; + slackSaving: boolean; + slackStatus: string | null; + signalForm: SignalForm; + signalSaving: boolean; + signalStatus: string | null; + imessageForm: IMessageForm; + imessageSaving: boolean; + imessageStatus: string | null; + onRefresh: (probe: boolean) => void; + onWhatsAppStart: (force: boolean) => void; + onWhatsAppWait: () => void; + onWhatsAppLogout: () => void; + onTelegramChange: (patch: Partial) => void; + onTelegramSave: () => void; + onDiscordChange: (patch: Partial) => void; + onDiscordSave: () => void; + onSlackChange: (patch: Partial) => void; + onSlackSave: () => void; + onSignalChange: (patch: Partial) => void; + onSignalSave: () => void; + onIMessageChange: (patch: Partial) => void; + onIMessageSave: () => void; +}; + +export type ConnectionsChannelData = { + whatsapp?: WhatsAppStatus; + telegram?: TelegramStatus; + discord?: DiscordStatus | null; + slack?: SlackStatus | null; + signal?: SignalStatus | null; + imessage?: IMessageStatus | null; + channelAccounts?: Record | null; +}; + diff --git a/ui/src/ui/views/connections.whatsapp.ts b/ui/src/ui/views/connections.whatsapp.ts new file mode 100644 index 000000000..ea3290a47 --- /dev/null +++ b/ui/src/ui/views/connections.whatsapp.ts @@ -0,0 +1,116 @@ +import { html, nothing } from "lit"; + +import { formatAgo } from "../format"; +import type { WhatsAppStatus } from "../types"; +import type { ConnectionsProps } from "./connections.types"; +import { formatDuration } from "./connections.shared"; + +export function renderWhatsAppCard(params: { + props: ConnectionsProps; + whatsapp?: WhatsAppStatus; + accountCountLabel: unknown; +}) { + const { props, whatsapp, accountCountLabel } = params; + + return html` +
+
WhatsApp
+
Link WhatsApp Web and monitor connection health.
+ ${accountCountLabel} + +
+
+ Configured + ${whatsapp?.configured ? "Yes" : "No"} +
+
+ Linked + ${whatsapp?.linked ? "Yes" : "No"} +
+
+ Running + ${whatsapp?.running ? "Yes" : "No"} +
+
+ Connected + ${whatsapp?.connected ? "Yes" : "No"} +
+
+ Last connect + + ${whatsapp?.lastConnectedAt + ? formatAgo(whatsapp.lastConnectedAt) + : "n/a"} + +
+
+ Last message + + ${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"} + +
+
+ Auth age + + ${whatsapp?.authAgeMs != null + ? formatDuration(whatsapp.authAgeMs) + : "n/a"} + +
+
+ + ${whatsapp?.lastError + ? html`
+ ${whatsapp.lastError} +
` + : nothing} + + ${props.whatsappMessage + ? html`
+ ${props.whatsappMessage} +
` + : nothing} + + ${props.whatsappQrDataUrl + ? html`
+ WhatsApp QR +
` + : nothing} + +
+ + + + + +
+
+ `; +} +