diff --git a/src/db.ts b/src/db.ts index 198b76f..fa77ece 100644 --- a/src/db.ts +++ b/src/db.ts @@ -77,6 +77,27 @@ function migrate(db: Database.Database): void { change_pct_low REAL NOT NULL, UNIQUE(prediction_id, date) ); + + CREATE TABLE IF NOT EXISTS run_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + label TEXT NOT NULL, + started_at TEXT NOT NULL, + ended_at TEXT, + status TEXT NOT NULL DEFAULT 'running', + posts_found INTEGER NOT NULL DEFAULT 0, + posts_new INTEGER NOT NULL DEFAULT 0, + error_message TEXT, + summary TEXT + ); + + CREATE TABLE IF NOT EXISTS notification_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id INTEGER REFERENCES run_logs(id), + channel TEXT NOT NULL, + status TEXT NOT NULL, + error_message TEXT, + sent_at TEXT NOT NULL + ); `); } diff --git a/src/index.ts b/src/index.ts index 237e179..385eb63 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import { filterNewPosts as filterNew } from './seen.js'; import { withRetry } from './retry.js'; import { createTranscriber, transcribeVideoPosts, type TranscriberType } from './transcribe.js'; import { recordPredictions, updateTracking } from './tracker.js'; +import { startRun, endRun, logNotification } from './run-logger.js'; import { getDb } from './db.js'; import { getConfig, initConfigTable, type ConfigKey } from './config-store.js'; import { startWebServer } from './web.js'; @@ -81,17 +82,32 @@ interface RunOptions { async function run(opts: RunOptions) { if (running) { console.log(`[${opts.label}] 上一次還在跑,跳過本次排程`); + const skipId = startRun(opts.label); + endRun(skipId, 'skip', { summary: '上一次還在跑,跳過' }); return; } running = true; + const runId = startRun(opts.label); try { - await runInner(opts); + await runInner(opts, runId); + endRun(runId, 'ok', { + postsFound: (opts as any)._postsFound ?? 0, + postsNew: (opts as any)._postsNew ?? 0, + summary: (opts as any)._summary ?? null, + }); + } catch (err) { + endRun(runId, 'fail', { + postsFound: (opts as any)._postsFound ?? 0, + postsNew: (opts as any)._postsNew ?? 0, + error: err instanceof Error ? err.message : String(err), + }); + throw err; } finally { running = false; } } -async function runInner(opts: RunOptions) { +async function runInner(opts: RunOptions, runId: number) { const now = new Date().toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' }); console.log(`\n=== 巴逆逆反指標追蹤器 [${opts.label}] ${now} ===\n`); @@ -120,16 +136,20 @@ async function runInner(opts: RunOptions) { console.error(`[Facebook] 抓取失敗: ${err instanceof Error ? err.message : err}`); } + (opts as any)._postsFound = allPosts.length; if (allPosts.length === 0) { console.log('沒有抓到任何貼文,結束'); + (opts as any)._summary = '沒有抓到貼文'; return; } // 3. 去重(查 SQLite posts 表) const newPosts = filterNew(allPosts); + (opts as any)._postsNew = newPosts.length; if (newPosts.length === 0) { console.log('沒有新貼文,結束'); + (opts as any)._summary = '沒有新貼文'; return; } @@ -254,6 +274,7 @@ async function runInner(opts: RunOptions) { console.log(' 巴逆逆反指標分析報告'); console.log('========================================\n'); console.log(`摘要: ${analysis.summary}`); + (opts as any)._summary = analysis.summary; if (analysis.hasInvestmentContent) { if (analysis.mentionedTargets?.length) { @@ -300,8 +321,11 @@ async function runInner(opts: RunOptions) { const r = results[i]; if (r.status === 'fulfilled') { console.log(`[${notifiers[i].name}] 通知已發送`); + logNotification(runId, notifiers[i].name, 'sent'); } else { - console.error(`[${notifiers[i].name}] 發送失敗(已重試 3 次): ${r.reason instanceof Error ? r.reason.message : r.reason}`); + const errMsg = r.reason instanceof Error ? r.reason.message : String(r.reason); + console.error(`[${notifiers[i].name}] 發送失敗(已重試 3 次): ${errMsg}`); + logNotification(runId, notifiers[i].name, 'failed', errMsg); } } } else { diff --git a/src/run-logger.ts b/src/run-logger.ts new file mode 100644 index 0000000..f5a458c --- /dev/null +++ b/src/run-logger.ts @@ -0,0 +1,89 @@ +/** + * Run & notification logging — writes to SQLite for Web UI display. + */ +import { getDb } from './db.js'; + +export interface RunLog { + id: number; + label: string; + started_at: string; + ended_at: string | null; + status: 'running' | 'ok' | 'fail' | 'skip'; + posts_found: number; + posts_new: number; + error_message: string | null; + summary: string | null; +} + +export function startRun(label: string): number { + const db = getDb(); + const now = new Date().toISOString(); + const result = db.prepare( + 'INSERT INTO run_logs (label, started_at, status) VALUES (?, ?, ?)', + ).run(label, now, 'running'); + return Number(result.lastInsertRowid); +} + +export function endRun( + runId: number, + status: 'ok' | 'fail' | 'skip', + details: { postsFound?: number; postsNew?: number; error?: string; summary?: string } = {}, +): void { + const db = getDb(); + const now = new Date().toISOString(); + db.prepare(` + UPDATE run_logs SET + ended_at = ?, status = ?, + posts_found = ?, posts_new = ?, + error_message = ?, summary = ? + WHERE id = ? + `).run( + now, status, + details.postsFound ?? 0, details.postsNew ?? 0, + details.error ?? null, details.summary ?? null, + runId, + ); +} + +export function logNotification( + runId: number, + channel: string, + status: 'sent' | 'failed', + error?: string, +): void { + const db = getDb(); + const now = new Date().toISOString(); + db.prepare( + 'INSERT INTO notification_logs (run_id, channel, status, error_message, sent_at) VALUES (?, ?, ?, ?, ?)', + ).run(runId, channel, status, error ?? null, now); +} + +export function getRecentRuns(limit = 50): RunLog[] { + const db = getDb(); + return db.prepare( + 'SELECT * FROM run_logs ORDER BY id DESC LIMIT ?', + ).all(limit) as RunLog[]; +} + +export interface NotificationLog { + id: number; + run_id: number; + channel: string; + status: string; + error_message: string | null; + sent_at: string; +} + +export function getRecentNotifications(limit = 100): NotificationLog[] { + const db = getDb(); + return db.prepare( + 'SELECT * FROM notification_logs ORDER BY id DESC LIMIT ?', + ).all(limit) as NotificationLog[]; +} + +export function getNotificationsForRun(runId: number): NotificationLog[] { + const db = getDb(); + return db.prepare( + 'SELECT * FROM notification_logs WHERE run_id = ? ORDER BY id', + ).all(runId) as NotificationLog[]; +} diff --git a/src/web.ts b/src/web.ts index 8e4e5a7..9d5358b 100644 --- a/src/web.ts +++ b/src/web.ts @@ -10,6 +10,7 @@ import { getCookie, setCookie, deleteCookie } from 'hono/cookie'; import { isInitialized, setupAdmin, login, logout, validateSession } from './auth.js'; import { getAllConfig, setConfigs, getConfigKeys, getConfig, type ConfigKey } from './config-store.js'; import { fetchFacebookPosts } from './facebook.js'; +import { getRecentRuns, getRecentNotifications } from './run-logger.js'; import { analyzePosts, DEFAULT_SYSTEM_PROMPT } from './analyze.js'; import { createNotifiers, type ReportData, type PostSummary } from './notifiers/index.js'; @@ -93,6 +94,16 @@ app.put('/api/config', requireAuth, async (c) => { return c.json({ ok: true }); }); +app.get('/api/runs', requireAuth, (c) => { + const runs = getRecentRuns(50); + return c.json({ runs }); +}); + +app.get('/api/notifications', requireAuth, (c) => { + const notifications = getRecentNotifications(100); + return c.json({ notifications }); +}); + app.get('/api/default-prompt', requireAuth, (c) => { return c.json({ prompt: DEFAULT_SYSTEM_PROMPT }); }); @@ -290,6 +301,38 @@ const FRONTEND_HTML = /* html */ ` font-family: var(--mono); font-size: 0.8rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.1em; } + .tab-group { display: flex; gap: 0.2rem; } + .tab-btn { + font-family: var(--mono); font-size: 0.8rem; font-weight: 600; + color: var(--text-muted); background: transparent; border: none; + padding: 0.3rem 0.7rem; border-radius: 6px; cursor: pointer; + transition: all var(--transition); + } + .tab-btn:hover { color: var(--text-secondary); background: var(--surface-alt); } + .tab-btn.active { color: var(--accent); background: var(--accent-subtle); } + .run-row { + padding: 0.5rem 2rem; border-bottom: 1px solid var(--border); + display: flex; align-items: center; gap: 0.75rem; + font-size: 0.85rem; transition: background var(--transition); + } + .run-row:hover { background: rgba(0,0,0,0.02); } + .run-status { + width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; + } + .run-status.ok { background: var(--accent); } + .run-status.fail { background: var(--red); } + .run-status.skip { background: var(--text-muted); } + .run-status.running { background: var(--amber); } + .run-label { font-weight: 600; color: var(--text); min-width: 3.5rem; } + .run-meta { color: var(--text-muted); font-family: var(--mono); font-size: 0.8rem; } + .run-summary { color: var(--text-secondary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .notif-row { + padding: 0.5rem 2rem; border-bottom: 1px solid var(--border); + display: flex; align-items: center; gap: 0.75rem; + font-size: 0.85rem; transition: background var(--transition); + } + .notif-row:hover { background: rgba(0,0,0,0.02); } + .notif-channel { font-weight: 600; color: var(--text); min-width: 5rem; } .log-actions { display: flex; gap: 0.4rem; } .log-body { flex: 1; overflow-y: auto; padding: 0.75rem 0; @@ -496,15 +539,25 @@ const FRONTEND_HTML = /* html */ `
- Log +
+ + + +
- - + +
等待操作
+ +
@@ -677,6 +730,78 @@ function toast(id, text, isErr) { setTimeout(() => { el.textContent = ''; el.className = ''; }, 4000); } +let currentTab = 'log'; +function switchTab(tab) { + currentTab = tab; + document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === tab)); + $('log-body').style.display = tab === 'log' ? '' : 'none'; + $('runs-body').style.display = tab === 'runs' ? '' : 'none'; + $('notifs-body').style.display = tab === 'notifs' ? '' : 'none'; + // Update action buttons + const a1 = $('tab-action-1'), a2 = $('tab-action-2'); + if (tab === 'log') { + a1.textContent = '清除'; a1.onclick = clearLog; + a2.textContent = '複製'; a2.onclick = copyLog; + a1.style.display = ''; a2.style.display = ''; + } else if (tab === 'runs') { + a1.textContent = '重新整理'; a1.onclick = loadRuns; + a2.style.display = 'none'; + loadRuns(); + } else { + a1.textContent = '重新整理'; a1.onclick = loadNotifs; + a2.style.display = 'none'; + loadNotifs(); + } +} + +function fmtTime(iso) { + if (!iso) return '-'; + return new Date(iso).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei', hour12: false, month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit' }); +} + +async function loadRuns() { + const body = $('runs-body'); + body.innerHTML = '
載入中...
'; + try { + const res = await fetch('/api/runs'); + const { runs } = await res.json(); + if (runs.length === 0) { body.innerHTML = '
尚無排程紀錄
'; return; } + body.innerHTML = ''; + for (const r of runs) { + const el = document.createElement('div'); + el.className = 'run-row'; + const dur = r.ended_at ? ((new Date(r.ended_at) - new Date(r.started_at)) / 1000).toFixed(0) + 's' : '...'; + el.innerHTML = '
' + + '' + esc(r.label) + '' + + '' + fmtTime(r.started_at) + ' (' + dur + ')' + + '' + esc(r.summary || r.error_message || (r.posts_new > 0 ? r.posts_new + ' 篇新貼文' : '')) + '' + + '' + r.posts_found + '/' + r.posts_new + ''; + body.appendChild(el); + } + } catch { body.innerHTML = '
載入失敗
'; } +} + +async function loadNotifs() { + const body = $('notifs-body'); + body.innerHTML = '
載入中...
'; + try { + const res = await fetch('/api/notifications'); + const { notifications } = await res.json(); + if (notifications.length === 0) { body.innerHTML = '
尚無推送紀錄
'; return; } + body.innerHTML = ''; + for (const n of notifications) { + const el = document.createElement('div'); + el.className = 'notif-row'; + const statusCls = n.status === 'sent' ? 'log-ok' : 'log-fail'; + el.innerHTML = '
' + + '' + esc(n.channel) + '' + + '' + fmtTime(n.sent_at) + '' + + '' + esc(n.status === 'sent' ? '成功' : n.error_message || '失敗') + ''; + body.appendChild(el); + } + } catch { body.innerHTML = '
載入失敗
'; } +} + init();