/** Read-only dashboard API + static file server. * * The single source of truth is the reports directory the us2kr daemon archives into * (reports/YYYY-MM-DD.{md,json} + errors.log). Nothing here touches the SQLite DB, so the * dashboard container never contends with the daemon's WAL over virtiofs. */ import { createServer, type ServerResponse } from "node:http"; import { readFile, readdir, stat } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { BriefingResult, BriefingSummary, ErrorLine, HealthResponse, SeriesResponse, } from "./shared/types"; const REPORTS_DIR = process.env.US2KR_REPORTS_DIR ?? "/data/reports"; const PORT = Number(process.env.PORT ?? 8090); const PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "public"); const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; const JSON_FILE_RE = /^\d{4}-\d{2}-\d{2}\.json$/; // ------------------------------------------------------------------ report loading /** mtime+size keyed cache so repeated requests do not re-parse unchanged files. */ const cache = new Map(); async function loadBriefing(krxDate: string): Promise { const file = path.join(REPORTS_DIR, `${krxDate}.json`); let st; try { st = await stat(file); } catch { return null; } const cached = cache.get(file); if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) return cached.data; try { const data = JSON.parse(await readFile(file, "utf8")) as BriefingResult; cache.set(file, { mtimeMs: st.mtimeMs, size: st.size, data }); return data; } catch (err) { console.error(`unparseable report ${file}:`, err); return null; } } async function listDates(): Promise { let names: string[]; try { names = await readdir(REPORTS_DIR); } catch { return []; // directory not mounted yet / no briefing produced yet } return names .filter((n) => JSON_FILE_RE.test(n)) .map((n) => n.slice(0, 10)) .sort() .reverse(); } function summarize(krxDate: string, r: BriefingResult): BriefingSummary { return { krx_date: krxDate, session_date: r.payload.session_date, generated_at: r.payload.generated_at, krx_open_today: r.payload.krx_open_today, model_used: r.model_used, degraded: r.degraded, error: r.error, headline: r.report?.headline ?? null, quote_count: r.payload.quotes.length, news_count: r.payload.news.length, data_gaps: r.payload.data_gaps, }; } async function allBriefings(): Promise> { const out: Array<{ krxDate: string; result: BriefingResult }> = []; for (const krxDate of await listDates()) { const result = await loadBriefing(krxDate); if (result) out.push({ krxDate, result }); } return out; // newest first (listDates is descending) } async function buildSeries(): Promise { const briefings = (await allBriefings()).reverse(); // ascending for time series // One column per US session; a session may appear once only (krx_date↔session is 1:1). const sessions: string[] = []; const series: SeriesResponse["series"] = {}; for (const { result } of briefings) { const session = result.payload.session_date; if (sessions.includes(session)) continue; sessions.push(session); const idx = sessions.length - 1; for (const q of result.payload.quotes) { let entry = series[q.symbol]; if (!entry) { entry = { name: q.name, group: q.group, closes: [], change_pct: [] }; series[q.symbol] = entry; } entry.closes[idx] = q.close; entry.change_pct[idx] = q.change_pct; } } // pad ragged arrays so every series aligns with `sessions` for (const entry of Object.values(series)) { for (let i = 0; i < sessions.length; i++) { if (entry.closes[i] === undefined) entry.closes[i] = null; if (entry.change_pct[i] === undefined) entry.change_pct[i] = null; } } return { sessions, series }; } async function readErrors(limit = 100): Promise { let text: string; try { text = await readFile(path.join(REPORTS_DIR, "errors.log"), "utf8"); } catch { return []; } const lines = text.split("\n").filter((l) => l.trim().length > 0); return lines .slice(-limit) .reverse() .map((line) => { const [ts = "", subject = "", ...rest] = line.split("\t"); return { ts, subject, detail: rest.join("\t") }; }); } // ------------------------------------------------------------------ http plumbing function sendJson(res: ServerResponse, status: number, body: unknown): void { const payload = JSON.stringify(body); res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-cache", }); res.end(payload); } const STATIC_TYPES: Record = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", }; async function sendStatic(res: ServerResponse, urlPath: string): Promise { const rel = urlPath === "/" ? "index.html" : urlPath.replace(/^\/+/, ""); const file = path.resolve(PUBLIC_DIR, rel); if (!file.startsWith(PUBLIC_DIR + path.sep) && file !== path.join(PUBLIC_DIR, "index.html")) { sendJson(res, 404, { error: "not found" }); return; } try { const body = await readFile(file); res.writeHead(200, { "content-type": STATIC_TYPES[path.extname(file)] ?? "application/octet-stream", }); res.end(body); } catch { sendJson(res, 404, { error: "not found" }); } } const server = createServer(async (req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); const p = url.pathname; try { if (req.method !== "GET" && req.method !== "HEAD") { sendJson(res, 405, { error: "method not allowed" }); return; } if (p === "/api/health") { const dates = await listDates(); const body: HealthResponse = { ok: true, report_count: dates.length, reports_dir: REPORTS_DIR, latest_krx_date: dates[0] ?? null, }; sendJson(res, 200, body); return; } if (p === "/api/briefings") { const briefings = await allBriefings(); sendJson(res, 200, briefings.map(({ krxDate, result }) => summarize(krxDate, result))); return; } const detail = p.match(/^\/api\/briefings\/(\d{4}-\d{2}-\d{2})$/); if (detail) { const krxDate = detail[1]!; if (!DATE_RE.test(krxDate)) { sendJson(res, 400, { error: "bad date" }); return; } const result = await loadBriefing(krxDate); if (!result) { sendJson(res, 404, { error: `no briefing for ${krxDate}` }); return; } sendJson(res, 200, result); return; } if (p === "/api/series") { sendJson(res, 200, await buildSeries()); return; } if (p === "/api/errors") { sendJson(res, 200, await readErrors()); return; } if (p.startsWith("/api/")) { sendJson(res, 404, { error: "not found" }); return; } await sendStatic(res, p); } catch (err) { console.error(`request ${p} failed:`, err); sendJson(res, 500, { error: "internal error" }); } }); server.listen(PORT, "0.0.0.0", () => { console.log(`us2kr dashboard listening on :${PORT} (reports: ${REPORTS_DIR})`); });