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>
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
/** 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})`);
|
||||
});
|
||||
Reference in New Issue
Block a user