Files
US2KR/dashboard/src/server.ts
T
jsahnandClaude Fable 5 f7b8d895c3 Add TypeScript web dashboard for browsing archived briefings
Zero-runtime-dependency Node 24 server + esbuild-bundled client.
Reads reports/*.json only (never the daemon's SQLite/WAL): summary
card, dual Seoul/New York clocks, holiday-gap rows in the briefing
list, per-symbol close chart with table view, error log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 00:58:51 +09:00

236 lines
7.4 KiB
TypeScript

/** 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<string, { mtimeMs: number; size: number; data: BriefingResult }>();
async function loadBriefing(krxDate: string): Promise<BriefingResult | null> {
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<string[]> {
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<Array<{ krxDate: string; result: BriefingResult }>> {
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<SeriesResponse> {
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<ErrorLine[]> {
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<string, string> = {
".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<void> {
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})`);
});