From 7c4831b3719760f7e8340e2879c91856d1129396 Mon Sep 17 00:00:00 2001 From: one-ea Date: Mon, 29 Jun 2026 19:39:05 +0000 Subject: [PATCH] feat(console): add operations dashboard with overview, conversations, deliveries views - Add /operations/overview: stat cards for messages, deliveries, conversations, AI drafts with color-coded health indicators - Add /operations/conversations: paginated conversation list with status filter (all/open/closed), sorted by last_message_at DESC - Add /operations/deliveries: failed delivery queue with manual retry button (failed only, permanent_failure not retryable) - Add POST /operations/deliveries/retry: resets next_retry_at to now - Add ConversationService.listConversations with JOIN to contacts and telegram_topics, pagination via LIMIT/OFFSET - Add DeliveryService.listFailedDeliveries and scheduleRetry methods - Extend AiDraftService.stats to include sent and discarded counts - Independent dashboard CSS namespace (ops- prefix) with header nav - 5 new test cases: ops auth redirect, overview HTML, conversation pagination/filter, failed delivery list + scheduleRetry Co-authored-by: monkeycode-ai --- src/domain/ai-drafts.ts | 6 +- src/domain/conversations.ts | 53 ++++++++ src/domain/deliveries.ts | 28 +++++ src/runtime/main.ts | 73 +++++++++++ src/runtime/web-console.ts | 240 ++++++++++++++++++++++++++++++++++++ test/core.test.ts | 153 +++++++++++++++++++++++ 6 files changed, 551 insertions(+), 2 deletions(-) diff --git a/src/domain/ai-drafts.ts b/src/domain/ai-drafts.ts index 72f9686..776eb67 100644 --- a/src/domain/ai-drafts.ts +++ b/src/domain/ai-drafts.ts @@ -187,15 +187,17 @@ export class AiDraftService { .run(nowIso(), draftId); } - stats(): { pending: number; ready: number; failed: number } { + stats(): { pending: number; ready: number; failed: number; sent: number; discarded: number } { const rows = this.db .prepare("SELECT status, COUNT(*) AS cnt FROM ai_drafts GROUP BY status") .all() as Array<{ status: string; cnt: number }>; - const result = { pending: 0, ready: 0, failed: 0 }; + const result = { pending: 0, ready: 0, failed: 0, sent: 0, discarded: 0 }; for (const row of rows) { if (row.status === "pending") result.pending = row.cnt; else if (row.status === "ready") result.ready = row.cnt; else if (row.status === "failed") result.failed = row.cnt; + else if (row.status === "sent") result.sent = row.cnt; + else if (row.status === "discarded") result.discarded = row.cnt; } return result; } diff --git a/src/domain/conversations.ts b/src/domain/conversations.ts index 4ee2deb..05144d6 100644 --- a/src/domain/conversations.ts +++ b/src/domain/conversations.ts @@ -13,6 +13,34 @@ export interface ConversationBundle { conversation: Conversation; } +export interface ConversationListItem { + id: number; + status: "open" | "closed"; + priority: "low" | "normal" | "high" | "urgent"; + assignedAdminId: string | null; + createdAt: string; + lastMessageAt: string | null; + contactDisplayName: string | null; + contactUsername: string | null; + topicName: string | null; + messageThreadId: number | null; +} + +function conversationListItemFromRow(row: Record): ConversationListItem { + return { + id: Number(row.id), + status: row.status as ConversationListItem["status"], + priority: row.priority as ConversationListItem["priority"], + assignedAdminId: row.assigned_admin_id === null ? null : String(row.assigned_admin_id), + createdAt: String(row.created_at), + lastMessageAt: row.last_message_at === null ? null : String(row.last_message_at), + contactDisplayName: row.display_name === null ? null : String(row.display_name), + contactUsername: row.username === null ? null : String(row.username), + topicName: row.topic_name === null ? null : String(row.topic_name), + messageThreadId: row.message_thread_id === null ? null : Number(row.message_thread_id), + }; +} + export interface AdminNote { id: number; conversationId: number; @@ -512,4 +540,29 @@ export class ConversationService { } return result; } + + listConversations(opts: { + status?: "open" | "closed"; + limit: number; + offset: number; + }): { items: ConversationListItem[]; total: number } { + const where = opts.status ? "WHERE c.status = ?" : ""; + const params = opts.status ? [opts.status] : []; + const rows = this.db + .prepare( + `SELECT c.id, c.status, c.priority, c.assigned_admin_id, c.created_at, c.last_message_at, + ct.display_name, ct.username, t.topic_name, t.message_thread_id + FROM conversations c + LEFT JOIN contacts ct ON c.contact_id = ct.id + LEFT JOIN telegram_topics t ON t.conversation_id = c.id + ${where} + ORDER BY c.last_message_at DESC NULLS LAST + LIMIT ? OFFSET ?`, + ) + .all(...params, opts.limit, opts.offset) as Array>; + const countRow = this.db + .prepare(`SELECT COUNT(*) AS cnt FROM conversations c ${where}`) + .get(...params) as { cnt: number }; + return { items: rows.map(conversationListItemFromRow), total: countRow.cnt }; + } } diff --git a/src/domain/deliveries.ts b/src/domain/deliveries.ts index 6a2d625..348bd9b 100644 --- a/src/domain/deliveries.ts +++ b/src/domain/deliveries.ts @@ -85,4 +85,32 @@ export class DeliveryService { } return result; } + + listFailedDeliveries(opts: { + limit: number; + offset: number; + }): { items: Delivery[]; total: number } { + const rows = this.db + .prepare( + `SELECT * FROM deliveries + WHERE status IN ('failed', 'permanent_failure') + ORDER BY created_at ASC + LIMIT ? OFFSET ?`, + ) + .all(opts.limit, opts.offset) as Array>; + const countRow = this.db + .prepare(`SELECT COUNT(*) AS cnt FROM deliveries WHERE status IN ('failed', 'permanent_failure')`) + .get() as { cnt: number }; + return { items: rows.map(deliveryFromRow), total: countRow.cnt }; + } + + scheduleRetry(deliveryId: number): void { + this.db + .prepare( + `UPDATE deliveries + SET next_retry_at = ?, updated_at = ? + WHERE id = ? AND status = 'failed'`, + ) + .run(nowIso(), nowIso(), deliveryId); + } } diff --git a/src/runtime/main.ts b/src/runtime/main.ts index 139a843..c805671 100644 --- a/src/runtime/main.ts +++ b/src/runtime/main.ts @@ -303,6 +303,79 @@ await startWebConsole({ timestamp: new Date().toISOString(), }; }, + collectOperationsOverview: () => { + const config = loadConfigFromSources(settings.all()); + const conversations = new ConversationService( + handle.db, + config.MESSAGE_RETENTION_DAYS, + config.DEFAULT_CONVERSATION_RETENTION_DAYS, + ); + const deliveries = new DeliveryService(handle.db); + const aiDrafts = new AiDraftService(handle.db, conversations, config); + const msgStats = conversations.messageStats(); + const convStats = conversations.conversationStats(); + const delStats = deliveries.stats(); + const draftStats = aiDrafts.stats(); + return { + messages: { inboundTotal: msgStats.inbound, outboundTotal: msgStats.outbound, internalTotal: msgStats.internal }, + deliveries: { pending: delStats.pending, sent: delStats.sent, failed: delStats.failed, permanentFailure: delStats.permanentFailure }, + conversations: { open: convStats.open, closed: convStats.closed }, + aiDrafts: { pending: draftStats.pending, ready: draftStats.ready, failed: draftStats.failed, sent: draftStats.sent, discarded: draftStats.discarded }, + uptimeSeconds: Math.round(process.uptime()), + }; + }, + listConversations: (opts) => { + const conversations = new ConversationService( + handle.db, + loadConfigFromSources(settings.all()).MESSAGE_RETENTION_DAYS, + loadConfigFromSources(settings.all()).DEFAULT_CONVERSATION_RETENTION_DAYS, + ); + const result = conversations.listConversations({ + status: opts.status === "open" || opts.status === "closed" ? opts.status : undefined, + limit: opts.pageSize, + offset: (opts.page - 1) * opts.pageSize, + }); + return { + items: result.items.map((c) => ({ + id: c.id, + status: c.status, + priority: c.priority, + assignedAdminId: c.assignedAdminId, + createdAt: c.createdAt, + lastMessageAt: c.lastMessageAt, + contactDisplayName: c.contactDisplayName, + contactUsername: c.contactUsername, + topicName: c.topicName, + messageThreadId: c.messageThreadId, + })), + total: result.total, + }; + }, + listFailedDeliveries: (opts) => { + const deliveries = new DeliveryService(handle.db); + const result = deliveries.listFailedDeliveries({ + limit: opts.pageSize, + offset: (opts.page - 1) * opts.pageSize, + }); + return { + items: result.items.map((d) => ({ + id: d.id, + sourceMessageId: d.sourceMessageId, + target: d.target, + status: d.status, + attemptCount: d.attemptCount, + lastError: d.lastError, + nextRetryAt: d.nextRetryAt, + createdAt: d.createdAt, + updatedAt: d.updatedAt, + })), + total: result.total, + }; + }, + scheduleRetry: async (deliveryId: number) => { + const deliveries = new DeliveryService(handle.db); + deliveries.scheduleRetry(deliveryId); + }, }); logger.info({ port: databaseConfig.WEB_CONSOLE_PORT }, "InboxBridge web console started."); await restartRuntime(); diff --git a/src/runtime/web-console.ts b/src/runtime/web-console.ts index 46ad870..1cbef36 100644 --- a/src/runtime/web-console.ts +++ b/src/runtime/web-console.ts @@ -195,6 +195,39 @@ export interface MetricsSnapshot { timestamp: string; } +export interface ConversationListItemView { + id: number; + status: "open" | "closed"; + priority: "low" | "normal" | "high" | "urgent"; + assignedAdminId: string | null; + createdAt: string; + lastMessageAt: string | null; + contactDisplayName: string | null; + contactUsername: string | null; + topicName: string | null; + messageThreadId: number | null; +} + +export interface DeliveryView { + id: number; + sourceMessageId: number | null; + target: string; + status: "pending" | "sent" | "failed" | "permanent_failure"; + attemptCount: number; + lastError: string | null; + nextRetryAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface OperationsOverview { + messages: { inboundTotal: number; outboundTotal: number; internalTotal: number }; + deliveries: { pending: number; sent: number; failed: number; permanentFailure: number }; + conversations: { open: number; closed: number }; + aiDrafts: { pending: number; ready: number; failed: number; sent: number; discarded: number }; + uptimeSeconds: number; +} + export interface WebConsoleOptions { settings: AppSettingsService; port: number; @@ -203,6 +236,10 @@ export interface WebConsoleOptions { telegramWebhook?: (req: IncomingMessage, res: ServerResponse) => Promise; dbHealthCheck: () => boolean; collectMetrics: () => MetricsSnapshot; + collectOperationsOverview: () => OperationsOverview; + listConversations: (opts: { page: number; status?: string; pageSize: number }) => { items: ConversationListItemView[]; total: number }; + listFailedDeliveries: (opts: { page: number; pageSize: number }) => { items: DeliveryView[]; total: number }; + scheduleRetry: (deliveryId: number) => Promise; } type SessionKind = "password" | "setup"; @@ -288,6 +325,37 @@ export async function startWebConsole(options: WebConsoleOptions): Promise 0) { + await options.scheduleRetry(deliveryId); + } + redirect(res, "/operations/deliveries?action=retryed"); + return; + } + if (url.pathname === "/" && req.method === "GET") { renderDashboard(res, options.settings, options.getStatus(), url.searchParams.get("saved") === "1"); return; @@ -694,6 +762,178 @@ function escapeHtml(value: string): string { return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); } +const opsBaseCss = ` +:root{color-scheme:light dark;--font-sans:"Fira Sans",ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--font-mono:"Fira Code",ui-monospace,SFMono-Regular,Menlo,monospace} +@media (prefers-color-scheme:light){:root{--color-bg:#f6f8fc;--color-surface:#ffffff;--color-surface-2:#f8fafc;--color-border:#e2e8f0;--color-text:#0f172a;--color-text-secondary:#475569;--color-text-muted:#94a3b8;--color-primary:#2563eb;--color-ok:#059669;--color-warn:#d97706;--color-danger:#dc2626;--shadow-md:0 8px 24px rgba(15,23,42,.08)}} +@media (prefers-color-scheme:dark){:root{--color-bg:#0b1220;--color-surface:#111a2e;--color-surface-2:#0e1626;--color-border:#1f2a40;--color-text:#f1f5f9;--color-text-secondary:#94a3b8;--color-text-muted:#64748b;--color-primary:#3b82f6;--color-ok:#10b981;--color-warn:#f59e0b;--color-danger:#ef4444;--shadow-md:0 8px 24px rgba(0,0,0,.3)}} +*{box-sizing:border-box} +body{margin:0;font-family:var(--font-sans);background:var(--color-bg);color:var(--color-text);line-height:1.6} +.ops-header{display:flex;gap:16px;align-items:center;padding:12px 24px;border-bottom:1px solid var(--color-border);background:var(--color-surface)} +.ops-header a{color:var(--color-text-secondary);text-decoration:none;font-size:14px;font-weight:500;padding:6px 12px;border-radius:8px;transition:all 160ms} +.ops-header a:hover{background:var(--color-surface-2);color:var(--color-text)} +.ops-header a.active{color:var(--color-primary);background:rgba(37,99,235,.08)} +.ops-main{max-width:1240px;margin:0 auto;padding:24px} +.ops-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:16px;margin-bottom:32px} +.ops-card{background:var(--color-surface);border:1px solid var(--color-border);border-radius:16px;padding:20px;box-shadow:var(--shadow-md)} +.ops-card h3{margin:0 0 12px;font-size:14px;color:var(--color-text-secondary);text-transform:uppercase;letter-spacing:.05em} +.ops-metric{display:flex;justify-content:space-between;align-items:center;padding:6px 0;font-size:15px} +.ops-metric .label{color:var(--color-text-secondary)} +.ops-metric .value{font-family:var(--font-mono);font-weight:600;font-size:18px} +.ops-metric .value.ok{color:var(--color-ok)} +.ops-metric .value.warn{color:var(--color-warn)} +.ops-metric .value.danger{color:var(--color-danger)} +.ops-banner{padding:12px 16px;border-radius:12px;margin-bottom:16px;font-size:14px} +.ops-banner.success{background:rgba(5,150,105,.1);color:var(--color-ok);border:1px solid rgba(5,150,105,.2)} +.ops-table{width:100%;border-collapse:collapse;font-size:14px} +.ops-table th{text-align:left;padding:10px 12px;border-bottom:2px solid var(--color-border);color:var(--color-text-secondary);font-weight:600;font-size:13px;text-transform:uppercase;letter-spacing:.03em} +.ops-table td{padding:10px 12px;border-bottom:1px solid var(--color-border)} +.ops-table tr:hover td{background:var(--color-surface-2)} +.ops-badge{display:inline-block;padding:2px 8px;border-radius:6px;font-size:12px;font-weight:600} +.ops-badge.open{background:rgba(5,150,105,.12);color:var(--color-ok)} +.ops-badge.closed{background:rgba(148,163,184,.12);color:var(--color-text-muted)} +.ops-badge.failed{background:rgba(217,119,6,.12);color:var(--color-warn)} +.ops-badge.permanent_failure{background:rgba(220,38,38,.12);color:var(--color-danger)} +.ops-btn{padding:4px 12px;border:1px solid var(--color-primary);background:transparent;color:var(--color-primary);border-radius:6px;cursor:pointer;font-size:13px;font-family:inherit} +.ops-btn:hover{background:rgba(37,99,235,.08)} +.ops-pager{display:flex;gap:8px;justify-content:center;margin-top:16px} +.ops-pager a{padding:6px 12px;border:1px solid var(--color-border);border-radius:8px;text-decoration:none;color:var(--color-text-secondary);font-size:14px} +.ops-pager a:hover{background:var(--color-surface-2)} +.ops-pager .current{background:var(--color-primary);color:#fff;border-color:var(--color-primary)} +.ops-empty{text-align:center;padding:40px;color:var(--color-text-muted);font-size:15px} +.ops-section-title{font-size:20px;font-weight:600;margin:0 0 16px} +`; + +function opsPage(title: string, body: string, activeNav: "overview" | "conversations" | "deliveries"): string { + return `${escapeHtml(title)} +
+配置 +概览 +会话 +投递 +
+
${body}
+`; +} + +function renderOperationsOverview(res: ServerResponse, data: OperationsOverview, showRetryBanner: boolean): void { + const banner = showRetryBanner ? `
投递已加入重试队列,将在下次扫描时重试。
` : ""; + const body = ` + ${banner} +
+

消息总量

+
入站${data.messages.inboundTotal}
+
出站${data.messages.outboundTotal}
+
内部${data.messages.internalTotal}
+
+

投递状态

+
pending${data.deliveries.pending}
+
sent${data.deliveries.sent}
+
failed${data.deliveries.failed}
+
permanent_failure${data.deliveries.permanentFailure}
+
+

会话

+
open${data.conversations.open}
+
closed${data.conversations.closed}
+
+

AI 草稿

+
pending${data.aiDrafts.pending}
+
ready${data.aiDrafts.ready}
+
failed${data.aiDrafts.failed}
+
sent${data.aiDrafts.sent}
+
discarded${data.aiDrafts.discarded}
+
+
+

进程运行时长:${Math.floor(data.uptimeSeconds / 3600)}h ${Math.floor((data.uptimeSeconds % 3600) / 60)}m

+ `; + send(res, 200, "text/html; charset=utf-8", opsPage("运维概览 - InboxBridge", body, "overview")); +} + +function renderConversations( + res: ServerResponse, + data: { items: ConversationListItemView[]; total: number }, + page: number, + status: string | undefined, +): void { + const pageSize = 50; + const totalPages = Math.max(1, Math.ceil(data.total / pageSize)); + const filterLinks = ["", "open", "closed"] + .map((s) => { + const label = s === "" ? "全部" : s === "open" ? "open" : "closed"; + const active = (status ?? "") === s ? "current" : ""; + const qs = s ? `?status=${s}` : ""; + return `${label}`; + }) + .join(""); + const rows = data.items.length + ? data.items + .map( + (c) => ` + ${c.id} + ${escapeHtml(c.topicName ?? "-")} + ${escapeHtml(c.contactDisplayName ?? "-")} + ${c.status} + ${escapeHtml(c.priority)} + ${escapeHtml(c.assignedAdminId ?? "-")} + ${c.lastMessageAt ?? "-"} + `, + ) + .join("") + : `没有符合条件的会话。`; + const pager = Array.from({ length: Math.min(totalPages, 10) }, (_, i) => i + 1) + .map( + (p) => + `${p}`, + ) + .join(""); + const body = ` +

会话列表 (${data.total})

+
${filterLinks}
+ + + ${rows} +
IDTopic联系人状态优先级负责人最后消息
+ ${totalPages > 1 ? `
${pager}
` : ""} + `; + send(res, 200, "text/html; charset=utf-8", opsPage("会话列表 - InboxBridge", body, "conversations")); +} + +function renderDeliveries( + res: ServerResponse, + data: { items: DeliveryView[]; total: number }, + page: number, +): void { + const pageSize = 50; + const totalPages = Math.max(1, Math.ceil(data.total / pageSize)); + const rows = data.items.length + ? data.items + .map( + (d) => ` + ${d.id} + ${escapeHtml(d.target)} + ${d.status} + ${d.attemptCount}/8 + ${escapeHtml(d.lastError ?? "-")} + ${d.createdAt} + ${d.nextRetryAt ?? "-"} + ${d.status === "failed" ? `
` : "-"} + `, + ) + .join("") + : `没有失败投递记录。`; + const pager = Array.from({ length: Math.min(totalPages, 10) }, (_, i) => i + 1) + .map((p) => `${p}`) + .join(""); + const body = ` +

失败投递队列 (${data.total})

+ + + ${rows} +
ID目标状态尝试最后错误创建时间下次重试操作
+ ${totalPages > 1 ? `
${pager}
` : ""} + `; + send(res, 200, "text/html; charset=utf-8", opsPage("投递队列 - InboxBridge", body, "deliveries")); +} + function redirect(res: ServerResponse, location: string): void { res.statusCode = 302; res.setHeader("location", location); diff --git a/test/core.test.ts b/test/core.test.ts index eb7d35a..1dc91d3 100644 --- a/test/core.test.ts +++ b/test/core.test.ts @@ -34,6 +34,16 @@ const stubMetrics = () => ({ uptime_seconds: 0, timestamp: new Date().toISOString(), }); +const stubOpsOverview = () => ({ + messages: { inboundTotal: 0, outboundTotal: 0, internalTotal: 0 }, + deliveries: { pending: 0, sent: 0, failed: 0, permanentFailure: 0 }, + conversations: { open: 0, closed: 0 }, + aiDrafts: { pending: 0, ready: 0, failed: 0, sent: 0, discarded: 0 }, + uptimeSeconds: 0, +}); +const stubListConversations = () => ({ items: [], total: 0 }); +const stubListFailedDeliveries = () => ({ items: [], total: 0 }); +const stubScheduleRetry = async () => {}; beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), "inboxbridge-")); @@ -168,6 +178,10 @@ describe("web console", () => { onConfigSaved: async () => {}, dbHealthCheck: noopDbHealthCheck, collectMetrics: stubMetrics, + collectOperationsOverview: stubOpsOverview, + listConversations: stubListConversations, + listFailedDeliveries: stubListFailedDeliveries, + scheduleRetry: stubScheduleRetry, }); const port = (server.address() as AddressInfo).port; @@ -208,6 +222,10 @@ describe("web console", () => { onConfigSaved: async () => {}, dbHealthCheck: noopDbHealthCheck, collectMetrics: stubMetrics, + collectOperationsOverview: stubOpsOverview, + listConversations: stubListConversations, + listFailedDeliveries: stubListFailedDeliveries, + scheduleRetry: stubScheduleRetry, }); const port = (server.address() as AddressInfo).port; @@ -233,6 +251,10 @@ describe("web console", () => { onConfigSaved: async () => {}, dbHealthCheck: noopDbHealthCheck, collectMetrics: stubMetrics, + collectOperationsOverview: stubOpsOverview, + listConversations: stubListConversations, + listFailedDeliveries: stubListFailedDeliveries, + scheduleRetry: stubScheduleRetry, telegramWebhook: async () => { throw new Error("webhook failed"); }, @@ -266,6 +288,10 @@ describe("web console", () => { onConfigSaved: async () => {}, dbHealthCheck: noopDbHealthCheck, collectMetrics: stubMetrics, + collectOperationsOverview: stubOpsOverview, + listConversations: stubListConversations, + listFailedDeliveries: stubListFailedDeliveries, + scheduleRetry: stubScheduleRetry, }), /EADDRINUSE/, ); @@ -284,6 +310,10 @@ describe("web console", () => { onConfigSaved: async () => {}, dbHealthCheck: () => true, collectMetrics: stubMetrics, + collectOperationsOverview: stubOpsOverview, + listConversations: stubListConversations, + listFailedDeliveries: stubListFailedDeliveries, + scheduleRetry: stubScheduleRetry, }); const port = (server.address() as AddressInfo).port; try { @@ -308,6 +338,10 @@ describe("web console", () => { onConfigSaved: async () => {}, dbHealthCheck: () => true, collectMetrics: stubMetrics, + collectOperationsOverview: stubOpsOverview, + listConversations: stubListConversations, + listFailedDeliveries: stubListFailedDeliveries, + scheduleRetry: stubScheduleRetry, }); const port = (server.address() as AddressInfo).port; try { @@ -330,6 +364,10 @@ describe("web console", () => { onConfigSaved: async () => {}, dbHealthCheck: noopDbHealthCheck, collectMetrics: stubMetrics, + collectOperationsOverview: stubOpsOverview, + listConversations: stubListConversations, + listFailedDeliveries: stubListFailedDeliveries, + scheduleRetry: stubScheduleRetry, }); const port = (server.address() as AddressInfo).port; try { @@ -358,6 +396,10 @@ describe("web console", () => { uptime_seconds: 42, timestamp: "2026-01-01T00:00:00.000Z", }), + collectOperationsOverview: stubOpsOverview, + listConversations: stubListConversations, + listFailedDeliveries: stubListFailedDeliveries, + scheduleRetry: stubScheduleRetry, }); const port = (server.address() as AddressInfo).port; try { @@ -379,6 +421,72 @@ describe("web console", () => { server.close(); } }); + + it("redirects unauthenticated /operations to /login", async () => { + const settings = new AppSettingsService(handle.db); + settings.setMany({ WEB_CONSOLE_PASSWORD_HASH: "bad:hash" }); + const server = await startWebConsole({ + settings, + port: 0, + getStatus: () => ({ bot: "running", issues: [] }), + onConfigSaved: async () => {}, + dbHealthCheck: noopDbHealthCheck, + collectMetrics: stubMetrics, + collectOperationsOverview: stubOpsOverview, + listConversations: stubListConversations, + listFailedDeliveries: stubListFailedDeliveries, + scheduleRetry: stubScheduleRetry, + }); + const port = (server.address() as AddressInfo).port; + try { + const res = await fetch(`http://127.0.0.1:${port}/operations`, { redirect: "manual" }); + assert.equal(res.status, 302); + assert.equal(res.headers.get("location"), "/login"); + } finally { + server.close(); + } + }); + + it("returns operations overview HTML after authentication", async () => { + const settings = new AppSettingsService(handle.db); + settings.setMany({ WEB_CONSOLE_PASSWORD_HASH: "bad:hash" }); + const server = await startWebConsole({ + settings, + port: 0, + getStatus: () => ({ bot: "running", issues: [] }), + onConfigSaved: async () => {}, + dbHealthCheck: noopDbHealthCheck, + collectMetrics: stubMetrics, + collectOperationsOverview: () => ({ + messages: { inboundTotal: 10, outboundTotal: 5, internalTotal: 2 }, + deliveries: { pending: 1, sent: 5, failed: 2, permanentFailure: 0 }, + conversations: { open: 3, closed: 1 }, + aiDrafts: { pending: 0, ready: 1, failed: 0, sent: 2, discarded: 1 }, + uptimeSeconds: 3600, + }), + listConversations: stubListConversations, + listFailedDeliveries: stubListFailedDeliveries, + scheduleRetry: stubScheduleRetry, + }); + const port = (server.address() as AddressInfo).port; + try { + const loginRes = await fetch(`http://127.0.0.1:${port}/login`, { + method: "POST", + body: new URLSearchParams({ setupToken: "setup-token" }), + redirect: "manual", + }); + const cookie = loginRes.headers.get("set-cookie")?.split(";")[0]; + assert.ok(cookie); + const res = await fetch(`http://127.0.0.1:${port}/operations`, { headers: { cookie } }); + assert.equal(res.status, 200); + const html = await res.text(); + assert.match(html, /消息总量/); + assert.match(html, /投递状态/); + assert.match(html, /10/); + } finally { + server.close(); + } + }); }); describe("conversation service", () => { @@ -557,6 +665,30 @@ describe("conversation service", () => { assert.equal(msgStats.inbound, 1); assert.equal(msgStats.outbound, 1); }); + + it("lists conversations with pagination and status filter", async () => { + const service = new ConversationService(handle.db, 30); + for (let i = 1; i <= 3; i++) { + const bundle = await service.getOrCreateConversation({ + platform: "telegram", + externalUserId: String(i), + displayName: `User${i}`, + }); + if (i === 3) await service.setConversationStatus(bundle.conversation.id, "closed"); + } + + const all = service.listConversations({ limit: 50, offset: 0 }); + assert.equal(all.total, 3); + assert.equal(all.items.length, 3); + + const openOnly = service.listConversations({ status: "open", limit: 50, offset: 0 }); + assert.equal(openOnly.total, 2); + assert.equal(openOnly.items.length, 2); + assert.ok(openOnly.items.every((c) => c.status === "open")); + + const paged = service.listConversations({ limit: 2, offset: 0 }); + assert.equal(paged.items.length, 2); + }); }); describe("permissions and rate limits", () => { @@ -847,4 +979,25 @@ describe("AI draft lifecycle", () => { assert.equal(stats.sent, 1); assert.equal(stats.permanentFailure, 1); }); + + it("lists failed deliveries and schedules retry", async () => { + const deliveries = new DeliveryService(handle.db); + const id1 = await deliveries.createPending(undefined, "telegram-user:1"); + const id2 = await deliveries.createPending(undefined, "telegram-user:2"); + const id3 = await deliveries.createPending(undefined, "telegram-user:3"); + await deliveries.markFailed(id1, "error", 1); + await deliveries.markFailed(id2, "error", 2); + await deliveries.markPermanentFailure(id3, "fatal"); + + const list = deliveries.listFailedDeliveries({ limit: 50, offset: 0 }); + assert.equal(list.total, 3); + assert.equal(list.items.length, 3); + + deliveries.scheduleRetry(id1); + const row = handle.db.prepare("SELECT next_retry_at FROM deliveries WHERE id = ?").get(id1) as { next_retry_at: string }; + assert.ok(row.next_retry_at); + + const beforePf = handle.db.prepare("SELECT next_retry_at FROM deliveries WHERE id = ?").get(id3) as { next_retry_at: string | null }; + assert.equal(beforePf.next_retry_at, null); + }); });