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>
236 lines
7.4 KiB
TypeScript
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})`);
|
|
});
|