From f7b8d895c3f05d9c3aaf5ed0535866e74e908368 Mon Sep 17 00:00:00 2001 From: jsahn Date: Tue, 1 Sep 2026 00:58:51 +0900 Subject: [PATCH] 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 --- .gitignore | 2 + dashboard/.dockerignore | 2 + dashboard/Dockerfile | 16 + dashboard/package-lock.json | 565 ++++++++++++++++++++++++++++++++++ dashboard/package.json | 21 ++ dashboard/src/client/chart.ts | 171 ++++++++++ dashboard/src/client/main.ts | 378 +++++++++++++++++++++++ dashboard/src/server.ts | 235 ++++++++++++++ dashboard/src/shared/types.ts | 122 ++++++++ dashboard/tsconfig.json | 16 + dashboard/web/index.html | 55 ++++ dashboard/web/styles.css | 254 +++++++++++++++ 12 files changed, 1837 insertions(+) create mode 100644 dashboard/.dockerignore create mode 100644 dashboard/Dockerfile create mode 100644 dashboard/package-lock.json create mode 100644 dashboard/package.json create mode 100644 dashboard/src/client/chart.ts create mode 100644 dashboard/src/client/main.ts create mode 100644 dashboard/src/server.ts create mode 100644 dashboard/src/shared/types.ts create mode 100644 dashboard/tsconfig.json create mode 100644 dashboard/web/index.html create mode 100644 dashboard/web/styles.css diff --git a/.gitignore b/.gitignore index 4500ecb..e1b5e45 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ reports/ logs/ .pytest_cache/ .ruff_cache/ +node_modules/ +dashboard/dist/ diff --git a/dashboard/.dockerignore b/dashboard/.dockerignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/dashboard/.dockerignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile new file mode 100644 index 0000000..baa2ec3 --- /dev/null +++ b/dashboard/Dockerfile @@ -0,0 +1,16 @@ +FROM node:24-slim AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY tsconfig.json ./ +COPY src ./src +COPY web ./web +RUN npm run build + +FROM node:24-slim +WORKDIR /app +ENV NODE_ENV=production US2KR_REPORTS_DIR=/data/reports PORT=8090 +COPY --from=build /app/dist ./dist +EXPOSE 8090 +USER node +CMD ["node", "dist/server.js"] diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json new file mode 100644 index 0000000..f22db04 --- /dev/null +++ b/dashboard/package-lock.json @@ -0,0 +1,565 @@ +{ + "name": "us2kr-dashboard", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "us2kr-dashboard", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^24.3.0", + "dompurify": "^3.2.4", + "esbuild": "^0.25.0", + "marked": "^16.0.0", + "typescript": "^5.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/dompurify": { + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", + "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", + "dev": true, + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/dashboard/package.json b/dashboard/package.json new file mode 100644 index 0000000..d845c02 --- /dev/null +++ b/dashboard/package.json @@ -0,0 +1,21 @@ +{ + "name": "us2kr-dashboard", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "build:server": "esbuild src/server.ts --bundle --platform=node --target=node24 --format=esm --outfile=dist/server.js", + "build:client": "esbuild src/client/main.ts --bundle --minify --target=es2022 --outfile=dist/public/app.js", + "build:static": "mkdir -p dist/public && cp web/index.html web/styles.css dist/public/", + "build": "npm run typecheck && npm run build:server && npm run build:client && npm run build:static", + "start": "node dist/server.js" + }, + "devDependencies": { + "@types/node": "^24.3.0", + "dompurify": "^3.2.4", + "esbuild": "^0.25.0", + "marked": "^16.0.0", + "typescript": "^5.9.0" + } +} diff --git a/dashboard/src/client/chart.ts b/dashboard/src/client/chart.ts new file mode 100644 index 0000000..0c01148 --- /dev/null +++ b/dashboard/src/client/chart.ts @@ -0,0 +1,171 @@ +/** Hand-rolled single-series SVG line chart with crosshair + tooltip hover. */ + +const SVG_NS = "http://www.w3.org/2000/svg"; +const W = 920; +const H = 260; +const PAD = { top: 14, right: 16, bottom: 26, left: 58 }; + +interface Point { + date: string; + value: number; +} + +function svgEl( + tag: K, + attrs: Record = {}, +): SVGElementTagNameMap[K] { + const el = document.createElementNS(SVG_NS, tag); + for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v)); + return el; +} + +function niceTicks(min: number, max: number, count = 4): number[] { + if (min === max) { + const pad = Math.abs(min) * 0.01 || 1; + min -= pad; + max += pad; + } + const span = max - min; + const step = 10 ** Math.floor(Math.log10(span / count)); + const err = span / count / step; + const mult = err >= 7.5 ? 10 : err >= 3.5 ? 5 : err >= 1.5 ? 2 : 1; + const s = step * mult; + const first = Math.ceil(min / s) * s; + const ticks: number[] = []; + for (let v = first; v <= max + s * 1e-9; v += s) ticks.push(Number(v.toFixed(10))); + return ticks; +} + +export function formatValue(v: number): string { + const decimals = Math.abs(v) >= 1000 ? 0 : Math.abs(v) >= 10 ? 2 : Math.abs(v) >= 1 ? 3 : 4; + return v.toLocaleString("ko-KR", { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); +} + +/** Render the chart into `host` (cleared first). Null values break the line. */ +export function renderLineChart( + host: HTMLElement, + dates: string[], + values: (number | null)[], + seriesLabel: string, +): void { + host.textContent = ""; + const points: (Point | null)[] = dates.map((date, i) => { + const v = values[i]; + return v === null || v === undefined ? null : { date, value: v }; + }); + const present = points.filter((p): p is Point => p !== null); + if (present.length === 0) { + const empty = document.createElement("p"); + empty.className = "chart-empty"; + empty.textContent = "표시할 데이터가 없습니다."; + host.append(empty); + return; + } + + const lo = Math.min(...present.map((p) => p.value)); + const hi = Math.max(...present.map((p) => p.value)); + const span = hi - lo || Math.abs(hi) * 0.02 || 1; + const yMin = lo - span * 0.08; + const yMax = hi + span * 0.08; + + const innerW = W - PAD.left - PAD.right; + const innerH = H - PAD.top - PAD.bottom; + const x = (i: number) => PAD.left + (dates.length === 1 ? innerW / 2 : (i / (dates.length - 1)) * innerW); + const y = (v: number) => PAD.top + (1 - (v - yMin) / (yMax - yMin)) * innerH; + + const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, role: "img" }); + const title = svgEl("title"); + title.textContent = `${seriesLabel} 종가 추이`; + svg.append(title); + + for (const tick of niceTicks(yMin, yMax)) { + svg.append(svgEl("line", { class: "gridline", x1: PAD.left, x2: W - PAD.right, y1: y(tick), y2: y(tick) })); + const label = svgEl("text", { class: "axis-label", x: PAD.left - 8, y: y(tick) + 4, "text-anchor": "end" }); + label.textContent = formatValue(tick); + svg.append(label); + } + svg.append( + svgEl("line", { class: "baseline", x1: PAD.left, x2: W - PAD.right, y1: H - PAD.bottom, y2: H - PAD.bottom }), + ); + + const maxLabels = Math.max(2, Math.floor(innerW / 90)); + const stride = Math.max(1, Math.ceil(dates.length / maxLabels)); + dates.forEach((d, i) => { + if (i % stride !== 0 && i !== dates.length - 1) return; + const label = svgEl("text", { class: "axis-label", x: x(i), y: H - 8, "text-anchor": "middle" }); + label.textContent = d.slice(5); // MM-DD + svg.append(label); + }); + + // polyline per contiguous run so nulls read as gaps, not interpolation + let run: string[] = []; + const flush = () => { + if (run.length >= 2) svg.append(svgEl("polyline", { class: "series-line", points: run.join(" ") })); + else if (run.length === 1) { + const [px = "", py = ""] = (run[0] ?? "").split(","); + svg.append(svgEl("circle", { class: "hover-dot", cx: px, cy: py, r: 3 })); + } + run = []; + }; + points.forEach((pt, i) => { + if (pt === null) flush(); + else run.push(`${x(i)},${y(pt.value)}`); + }); + flush(); + + // hover layer: crosshair + dot + tooltip on the nearest present point + const crosshair = svgEl("line", { + class: "crosshair", + y1: PAD.top, + y2: H - PAD.bottom, + x1: -10, + x2: -10, + visibility: "hidden", + }); + const dot = svgEl("circle", { class: "hover-dot", r: 4.5, visibility: "hidden" }); + svg.append(crosshair, dot); + + const tooltip = document.createElement("div"); + tooltip.className = "chart-tooltip"; + tooltip.hidden = true; + host.append(svg, tooltip); + + const hide = () => { + crosshair.setAttribute("visibility", "hidden"); + dot.setAttribute("visibility", "hidden"); + tooltip.hidden = true; + }; + svg.addEventListener("pointerleave", hide); + svg.addEventListener("pointermove", (ev) => { + const rect = svg.getBoundingClientRect(); + const relX = ((ev.clientX - rect.left) / rect.width) * W; + let best: { i: number; pt: Point } | null = null; + points.forEach((pt, i) => { + if (pt === null) return; + if (best === null || Math.abs(x(i) - relX) < Math.abs(x(best.i) - relX)) best = { i, pt }; + }); + if (best === null) { + hide(); + return; + } + const { i, pt } = best as { i: number; pt: Point }; + const cx = x(i); + const cy = y(pt.value); + crosshair.setAttribute("x1", String(cx)); + crosshair.setAttribute("x2", String(cx)); + crosshair.setAttribute("visibility", "visible"); + dot.setAttribute("cx", String(cx)); + dot.setAttribute("cy", String(cy)); + dot.setAttribute("visibility", "visible"); + tooltip.innerHTML = ""; + const dateEl = document.createElement("div"); + dateEl.textContent = pt.date; + const valueEl = document.createElement("div"); + valueEl.className = "tt-value"; + valueEl.textContent = formatValue(pt.value); + tooltip.append(dateEl, valueEl); + tooltip.hidden = false; + tooltip.style.left = `${(cx / W) * rect.width}px`; + tooltip.style.top = `${(cy / H) * rect.height}px`; + }); +} diff --git a/dashboard/src/client/main.ts b/dashboard/src/client/main.ts new file mode 100644 index 0000000..1eaee6e --- /dev/null +++ b/dashboard/src/client/main.ts @@ -0,0 +1,378 @@ +import DOMPurify from "dompurify"; +import { marked } from "marked"; + +import type { + BriefingResult, + BriefingSummary, + ErrorLine, + HealthResponse, + Quote, + SeriesResponse, +} from "../shared/types"; +import { formatValue, renderLineChart } from "./chart"; + +/** Symbols shown as stat tiles, in order (subset of what the daemon tracks). */ +const TILE_SYMBOLS = ["^GSPC", "^IXIC", "^SOX", "^VIX", "KRW=X", "^TNX"]; +const GROUP_LABELS: Record = { + indices: "지수", + futures: "선물", + rates: "금리", + fx: "환율/달러", + commodities: "원자재/크립토", + sectors: "섹터 ETF", + semis: "반도체", + megacap: "빅테크", + korea_linked: "한국 연관", +}; + +function el( + tag: K, + className?: string, + text?: string, +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +function byId(id: string): T { + const node = document.getElementById(id); + if (!node) throw new Error(`missing #${id}`); + return node as T; +} + +async function getJson(url: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`${url} -> ${res.status}`); + return (await res.json()) as T; +} + +// ------------------------------------------------------------------ dates & clocks + +/** Today's calendar date in Seoul, as YYYY-MM-DD. */ +function kstToday(): string { + return new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Seoul" }).format(new Date()); +} + +function addDays(iso: string, delta: number): string { + const d = new Date(`${iso}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + delta); + return d.toISOString().slice(0, 10); +} + +/** Weekday of a pure calendar date (0=Sun..6=Sat). */ +function weekday(iso: string): number { + return new Date(`${iso}T00:00:00Z`).getUTCDay(); +} + +function fmtInZone(dt: Date, tz: string, withSeconds = false): string { + return new Intl.DateTimeFormat("ko-KR", { + timeZone: tz, + month: "2-digit", + day: "2-digit", + weekday: "short", + hour: "2-digit", + minute: "2-digit", + ...(withSeconds ? { second: "2-digit" } : {}), + hour12: false, + }).format(dt); +} + +/** Live Seoul + New York clocks in the top bar. */ +function startClocks(): void { + const host = byId("clocks"); + const seoul = el("span", "clock"); + const ny = el("span", "clock"); + host.append(seoul, ny); + const tick = () => { + const now = new Date(); + seoul.textContent = `서울 ${fmtInZone(now, "Asia/Seoul", true)}`; + ny.textContent = `뉴욕 ${fmtInZone(now, "America/New_York", true)}`; + }; + tick(); + setInterval(tick, 1000); +} + +// ------------------------------------------------------------------ stat tiles + +function deltaText(q: Quote): { text: string; cls: string } { + const isRates = q.group === "rates"; + const v = isRates ? q.change_abs : q.change_pct; + if (v === null || v === undefined) return { text: "—", cls: "" }; + const arrow = v > 0 ? "▲" : v < 0 ? "▼" : "="; + const cls = v > 0 ? "up" : v < 0 ? "down" : ""; + const body = isRates ? `${(v * 100).toFixed(1)}bp` : `${Math.abs(v).toFixed(2)}%`; + return { text: `${arrow} ${body}`, cls }; +} + +function renderTiles(result: BriefingResult): void { + const host = byId("tiles"); + host.textContent = ""; + const quotes = new Map(result.payload.quotes.map((q) => [q.symbol, q])); + for (const sym of TILE_SYMBOLS) { + const q = quotes.get(sym); + if (!q) continue; + const tile = el("div", "tile"); + tile.append(el("div", "tile-label", q.name || sym)); + tile.append(el("div", "tile-value", q.close === null ? "—" : formatValue(q.close))); + const { text, cls } = deltaText(q); + tile.append(el("div", `tile-delta ${cls}`.trim(), text)); + host.append(tile); + } +} + +// ------------------------------------------------------------------ briefing list + body + +function badgeFor(s: BriefingSummary): HTMLElement { + if (s.error) return el("span", "badge badge-error", "오류"); + if (s.degraded) return el("span", "badge badge-warn", "축소"); + return el("span", "badge badge-ok", "정상"); +} + +/** Label for a calendar day with no briefing. The US session feeding KRX date D is D-1: + * a weekend session day means "no US market"; today with a weekday session is just "not yet". */ +function closedRow(krxDate: string, today: string): HTMLElement { + const sessionDay = weekday(addDays(krxDate, -1)); + const isWeekend = sessionDay === 0 || sessionDay === 6; + const label = isWeekend ? "주말 · 미국 휴장" : krxDate === today ? "오늘 · 미국 마감 후 생성 예정" : "미국 휴장"; + const item = el("li", "day-closed"); + item.append(el("span", "briefing-date", krxDate), el("span", undefined, label)); + return item; +} + +function renderList( + summaries: BriefingSummary[], + selected: string, + onSelect: (krxDate: string) => void, +): void { + const list = byId("briefing-list"); + list.textContent = ""; + const byDate = new Map(summaries.map((s) => [s.krx_date, s])); + const today = kstToday(); + const newest = summaries[0]?.krx_date ?? today; + const oldest = summaries[summaries.length - 1]?.krx_date ?? today; + const start = today > newest ? today : newest; + // walk the calendar day by day so gaps (weekends, US holidays) are visible, not silent + for (let d = start; d >= oldest; d = addDays(d, -1)) { + const s = byDate.get(d); + if (!s) { + list.append(closedRow(d, today)); + continue; + } + const item = el("li"); + const button = el("button"); + button.type = "button"; + button.setAttribute("aria-current", String(s.krx_date === selected)); + button.append(el("span", "briefing-date", `${s.krx_date} (KRX)`)); + const line = el("span", "briefing-line"); + line.append(`세션 ${s.session_date}`, badgeFor(s)); + if (s.data_gaps.length > 0) line.append(el("span", "badge badge-warn", `결측 ${s.data_gaps.length}`)); + button.append(line); + if (s.headline) button.append(el("span", "briefing-line", s.headline)); + button.addEventListener("click", () => onSelect(s.krx_date)); + item.append(button); + list.append(item); + } +} + +function metaChips(result: BriefingResult): HTMLElement[] { + const p = result.payload; + const close = new Date(p.us_close_time); + const chips: HTMLElement[] = [ + el("span", undefined, `미국 세션 ${p.session_date} → KRX ${p.krx_date}`), + el( + "span", + undefined, + `마감 뉴욕 ${fmtInZone(close, "America/New_York")} · 서울 ${fmtInZone(close, "Asia/Seoul")}`, + ), + el("span", undefined, `생성 ${new Date(p.generated_at).toLocaleString("ko-KR")}`), + ]; + if (!p.krx_open_today) chips.push(el("span", "badge badge-warn", "한국 휴장")); + chips.push( + result.model_used + ? el("span", "badge badge-ok", result.model_used) + : el("span", "badge badge-warn", "LLM 미사용"), + ); + for (const gap of p.data_gaps) chips.push(el("span", "badge badge-warn", gap)); + if (result.error) chips.push(el("span", "badge badge-error", result.error)); + return chips; +} + +/** Compact "핵심 요약" card: the LLM report's actionable parts, above the full markdown. */ +function renderSummaryCard(result: BriefingResult): void { + const host = byId("report-summary"); + host.textContent = ""; + const report = result.report; + if (!report) return; + const card = el("section", "summary-card"); + card.setAttribute("aria-label", "핵심 요약"); + if (report.headline) card.append(el("p", "summary-headline", report.headline)); + if (report.market_overview) card.append(el("p", "summary-overview", report.market_overview)); + const blocks: Array<[string, string[], string]> = [ + ["한국 장 시사점", report.korea_implications, ""], + ["오늘 볼 것", report.watchlist_today, ""], + ["리스크", report.risk_flags, "risk"], + ]; + const grid = el("div", "summary-grid"); + for (const [title, items, kind] of blocks) { + if (!items || items.length === 0) continue; + const block = el("div", `summary-block ${kind}`.trim()); + block.append(el("h3", undefined, title)); + const list = el("ul"); + for (const item of items) list.append(el("li", undefined, item)); + block.append(list); + grid.append(block); + } + if (grid.childElementCount > 0) card.append(grid); + host.append(card); +} + +async function showBriefing(krxDate: string): Promise { + const result = await getJson(`/api/briefings/${krxDate}`); + renderTiles(result); + const meta = byId("report-meta"); + meta.textContent = ""; + meta.append(...metaChips(result)); + renderSummaryCard(result); + const body = byId("report-body"); + // News titles inside the markdown come from external feeds - sanitize the rendered HTML. + body.innerHTML = DOMPurify.sanitize(marked.parse(result.markdown, { async: false })); +} + +// ------------------------------------------------------------------ chart section + +function seriesLabel(sym: string, data: SeriesResponse): string { + const entry = data.series[sym]; + return entry && entry.name && entry.name !== sym ? `${entry.name} (${sym})` : sym; +} + +function renderChartTable(host: HTMLElement, dates: string[], values: (number | null)[]): void { + host.textContent = ""; + const table = el("table", "data-table"); + const head = el("tr"); + head.append(el("th", undefined, "세션"), el("th", undefined, "종가")); + table.append(head); + for (let i = dates.length - 1; i >= 0; i--) { + const row = el("tr"); + const v = values[i]; + row.append( + el("td", undefined, dates[i] ?? ""), + el("td", undefined, v === null || v === undefined ? "—" : formatValue(v)), + ); + table.append(row); + } + host.append(table); +} + +function setupChart(data: SeriesResponse): void { + const select = byId("symbol-select"); + const chartHost = byId("chart"); + const tableHost = byId("chart-table"); + const toggle = byId("table-toggle"); + const chartTitle = byId("chart-title"); + + const symbols = Object.keys(data.series); + if (symbols.length === 0) { + chartHost.append(el("p", "chart-empty", "브리핑이 쌓이면 시세 추이가 표시됩니다.")); + select.disabled = true; + toggle.disabled = true; + return; + } + + const byGroup = new Map(); + for (const sym of symbols) { + const group = data.series[sym]?.group ?? "etc"; + const bucket = byGroup.get(group) ?? []; + bucket.push(sym); + byGroup.set(group, bucket); + } + for (const [group, syms] of byGroup) { + const optgroup = document.createElement("optgroup"); + optgroup.label = GROUP_LABELS[group] ?? group; + for (const sym of syms.sort()) { + const option = document.createElement("option"); + option.value = sym; + option.textContent = seriesLabel(sym, data); + optgroup.append(option); + } + select.append(optgroup); + } + + const draw = () => { + const sym = select.value; + const entry = data.series[sym]; + if (!entry) return; + chartTitle.textContent = `시세 추이 — ${seriesLabel(sym, data)}`; + renderLineChart(chartHost, data.sessions, entry.closes, seriesLabel(sym, data)); + renderChartTable(tableHost, data.sessions, entry.closes); + }; + select.value = symbols.includes("^GSPC") ? "^GSPC" : (symbols[0] ?? ""); + select.addEventListener("change", draw); + toggle.addEventListener("click", () => { + const showTable = tableHost.hidden; + tableHost.hidden = !showTable; + toggle.setAttribute("aria-pressed", String(showTable)); + toggle.textContent = showTable ? "표 숨기기" : "표로 보기"; + }); + draw(); +} + +// ------------------------------------------------------------------ errors + health + +function renderErrors(errors: ErrorLine[]): void { + if (errors.length === 0) return; + const panel = byId("errors-panel"); + panel.hidden = false; + byId("errors-count").textContent = String(errors.length); + const list = byId("errors-list"); + for (const e of errors.slice(0, 30)) { + const item = el("li"); + item.append( + el("span", "err-ts", e.ts), + el("span", "err-subject", e.subject), + el("span", "err-detail", e.detail), + ); + list.append(item); + } +} + +// ------------------------------------------------------------------ boot + +async function main(): Promise { + startClocks(); + const health = await getJson("/api/health"); + byId("health").textContent = + health.report_count > 0 + ? `브리핑 ${health.report_count}건 · 최신 ${health.latest_krx_date}` + : "아직 쌓인 브리핑이 없습니다"; + + const [summaries, series, errors] = await Promise.all([ + getJson("/api/briefings"), + getJson("/api/series"), + getJson("/api/errors"), + ]); + + renderErrors(errors); + setupChart(series); + + if (summaries.length === 0) { + byId("report-body").append( + el("p", "chart-empty", "브리핑이 아직 없습니다. 다음 미국 장 마감 후 첫 브리핑이 생성됩니다."), + ); + return; + } + + let selected = summaries[0]!.krx_date; + const select = async (krxDate: string) => { + selected = krxDate; + renderList(summaries, selected, (d) => void select(d)); + await showBriefing(krxDate); + }; + await select(selected); +} + +void main().catch((err) => { + console.error(err); + byId("health").textContent = "대시보드 로드 실패 — 서버 로그를 확인하세요"; +}); diff --git a/dashboard/src/server.ts b/dashboard/src/server.ts new file mode 100644 index 0000000..15c34f4 --- /dev/null +++ b/dashboard/src/server.ts @@ -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(); + +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})`); +}); diff --git a/dashboard/src/shared/types.ts b/dashboard/src/shared/types.ts new file mode 100644 index 0000000..8a25465 --- /dev/null +++ b/dashboard/src/shared/types.ts @@ -0,0 +1,122 @@ +/** Mirrors us2kr/models.py (pydantic model_dump_json → snake_case). */ + +export interface Quote { + symbol: string; + name: string; + group: string; + close: number | null; + prev_close: number | null; + change_pct: number | null; + change_abs: number | null; + day_high: number | null; + day_low: number | null; + volume: number | null; + avg_volume_10d: number | null; + after_hours_price: number | null; + after_hours_change_pct: number | null; + as_of: string | null; + source: string; +} + +export interface NewsItem { + title: string; + summary: string; + link: string; + source: string; + published: string; +} + +export interface EconEvent { + name: string; + time: string | null; + country: string; + actual: string | null; + forecast: string | null; + previous: string | null; + importance: "high" | "medium" | "low"; + kind: "released" | "upcoming"; +} + +export interface EarningsItem { + symbol: string; + name: string; + report_date: string; + timing: "bmo" | "amc" | "unknown"; + eps_actual: number | null; + eps_estimate: number | null; + revenue_actual: number | null; + revenue_estimate: number | null; +} + +export interface MarketPayload { + session_date: string; + us_close_time: string; + generated_at: string; + krx_date: string; + krx_open_today: boolean; + quotes: Quote[]; + news: NewsItem[]; + events: EconEvent[]; + earnings: EarningsItem[]; + history_notes: string[]; + data_gaps: string[]; +} + +export interface Report { + headline: string; + market_overview: string; + key_drivers: string[]; + rates_fx_commodities: string; + korea_implications: string[]; + watchlist_today: string[]; + risk_flags: string[]; + data_gaps: string[]; +} + +export interface BriefingResult { + payload: MarketPayload; + report: Report | null; + markdown: string; + model_used: string | null; + degraded: boolean; + error: string | null; +} + +// ---------------------------------------------------------------- API shapes + +export interface BriefingSummary { + krx_date: string; + session_date: string; + generated_at: string; + krx_open_today: boolean; + model_used: string | null; + degraded: boolean; + error: string | null; + headline: string | null; + quote_count: number; + news_count: number; + data_gaps: string[]; +} + +export interface SeriesResponse { + /** US session dates, ascending. */ + sessions: string[]; + /** symbol → aligned arrays (null where the session had no value). */ + series: Record< + string, + { name: string; group: string; closes: (number | null)[]; change_pct: (number | null)[] } + >; +} + +export interface ErrorLine { + ts: string; + subject: string; + detail: string; +} + +export interface HealthResponse { + ok: boolean; + report_count: number; + reports_dir: string; + latest_krx_date: string | null; +} diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json new file mode 100644 index 0000000..cb9ca01 --- /dev/null +++ b/dashboard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/dashboard/web/index.html b/dashboard/web/index.html new file mode 100644 index 0000000..d9bfef5 --- /dev/null +++ b/dashboard/web/index.html @@ -0,0 +1,55 @@ + + + + + + US2KR 대시보드 + + + +
+

US2KR 미국 마감 → 한국 개장 브리핑

+
+
+
+
+
+ +
+
+ +
+
+

시세 추이

+
+ + + +
+
+
+ +
+ +
+ + +
+
+
+
+
+
+ + +
+ + + + diff --git a/dashboard/web/styles.css b/dashboard/web/styles.css new file mode 100644 index 0000000..3c3d9f2 --- /dev/null +++ b/dashboard/web/styles.css @@ -0,0 +1,254 @@ +/* Design tokens - light is the base, dark overrides via prefers-color-scheme. */ +:root { + color-scheme: light; + --page: #f9f9f7; + --surface: #fcfcfb; + --ink: #0b0b0b; + --ink-2: #52514e; + --muted: #898781; + --grid: #e1e0d9; + --baseline: #c3c2b7; + --border: rgba(11, 11, 11, 0.1); + --series-1: #2a78d6; + /* Korean market convention: up = red, down = blue (diverging pair poles). */ + --delta-up: #d03b3b; + --delta-down: #256abf; + --badge-warn-bg: #fdf3d7; + --badge-warn-ink: #7a5600; + --badge-err-bg: #fbe3e3; + --badge-err-ink: #8f2020; + --badge-ok-bg: #e2f2e2; + --badge-ok-ink: #106310; +} +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --page: #0d0d0d; + --surface: #1a1a19; + --ink: #ffffff; + --ink-2: #c3c2b7; + --muted: #898781; + --grid: #2c2c2a; + --baseline: #383835; + --border: rgba(255, 255, 255, 0.1); + --series-1: #3987e5; + --delta-up: #e66767; + --delta-down: #3987e5; + --badge-warn-bg: #3a2f10; + --badge-warn-ink: #fab219; + --badge-err-bg: #3c1a1a; + --badge-err-ink: #e66767; + --badge-ok-bg: #143214; + --badge-ok-ink: #4fc24f; + } +} + +* { box-sizing: border-box; } +body { + margin: 0; + background: var(--page); + color: var(--ink); + font: 14px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; +} +h1, h2 { margin: 0; font-weight: 650; } +h1 { font-size: 17px; } +h2 { font-size: 14px; color: var(--ink-2); } + +.visually-hidden { + position: absolute; + width: 1px; height: 1px; + margin: -1px; padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; border: 0; +} + +.topbar { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + padding: 14px 20px; + border-bottom: 1px solid var(--border); + background: var(--surface); +} +.topbar-sub { font-size: 13px; font-weight: 400; color: var(--muted); margin-left: 6px; } +.topbar-right { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; } +.clocks { display: flex; gap: 14px; font-size: 12.5px; color: var(--ink-2); font-variant-numeric: tabular-nums; } +.clock::before { content: "●"; font-size: 7px; vertical-align: 2px; margin-right: 5px; color: var(--series-1); } +.health { font-size: 12px; color: var(--muted); } + +.layout { + max-width: 1180px; + margin: 0 auto; + padding: 16px 20px 48px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 14px 16px; +} +.panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 6px; +} +.chart-controls { display: flex; gap: 8px; align-items: center; } +select, button { + font: inherit; + color: var(--ink); + background: var(--surface); + border: 1px solid var(--baseline); + border-radius: 7px; + padding: 5px 9px; +} +button { cursor: pointer; } +button[aria-pressed="true"] { background: var(--grid); } + +/* ------------------------------------------------------------------ stat tiles */ +.tiles { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 10px; +} +.tile { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 13px; +} +.tile-label { font-size: 12px; color: var(--ink-2); } +.tile-value { font-size: 21px; font-weight: 650; margin-top: 2px; } +.tile-delta { font-size: 12.5px; margin-top: 1px; color: var(--ink-2); } +.tile-delta.up { color: var(--delta-up); } +.tile-delta.down { color: var(--delta-down); } + +/* ------------------------------------------------------------------ chart */ +.chart { position: relative; } +.chart svg { display: block; width: 100%; height: auto; } +.chart .axis-label { font-size: 11px; fill: var(--muted); } +.chart .gridline { stroke: var(--grid); stroke-width: 1; } +.chart .baseline { stroke: var(--baseline); stroke-width: 1; } +.chart .series-line { stroke: var(--series-1); stroke-width: 2; fill: none; } +.chart .hover-dot { fill: var(--series-1); stroke: var(--surface); stroke-width: 2; } +.chart .crosshair { stroke: var(--baseline); stroke-width: 1; stroke-dasharray: 3 3; } +.chart-tooltip { + position: absolute; + pointer-events: none; + background: var(--surface); + border: 1px solid var(--baseline); + border-radius: 7px; + padding: 5px 9px; + font-size: 12px; + color: var(--ink); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + white-space: nowrap; + transform: translate(-50%, -110%); +} +.chart-tooltip .tt-value { font-weight: 650; } +.chart-empty { color: var(--muted); padding: 24px 4px; } + +.chart-table { overflow-x: auto; margin-top: 8px; } + +/* ------------------------------------------------------------------ columns */ +.columns { + display: grid; + grid-template-columns: 250px 1fr; + gap: 16px; + align-items: start; +} +@media (max-width: 820px) { + .columns { grid-template-columns: 1fr; } +} + +.briefing-list { list-style: none; margin: 10px 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; } +.briefing-list button { + width: 100%; + text-align: left; + display: flex; + flex-direction: column; + gap: 2px; + border-color: var(--border); +} +.briefing-list button[aria-current="true"] { border-color: var(--series-1); background: var(--grid); } +.briefing-date { font-weight: 650; } +.day-closed { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: baseline; + column-gap: 8px; + padding: 4px 10px; + font-size: 12px; + color: var(--muted); + border: 1px dashed var(--grid); + border-radius: 7px; +} +.day-closed .briefing-date { font-weight: 500; white-space: nowrap; } +.briefing-line { font-size: 12px; color: var(--ink-2); display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } + +.badge { + display: inline-block; + font-size: 11px; + border-radius: 20px; + padding: 1px 8px; + font-weight: 600; +} +.badge-ok { background: var(--badge-ok-bg); color: var(--badge-ok-ink); } +.badge-warn { background: var(--badge-warn-bg); color: var(--badge-warn-ink); } +.badge-error { background: var(--badge-err-bg); color: var(--badge-err-ink); } + +/* ------------------------------------------------------------------ summary card */ +.summary-card { + border: 1px solid var(--border); + border-left: 3px solid var(--series-1); + border-radius: 10px; + background: var(--page); + padding: 12px 16px; + margin: 10px 0 14px; +} +.summary-headline { font-size: 16px; font-weight: 700; margin: 0 0 6px; } +.summary-overview { margin: 0 0 10px; color: var(--ink-2); } +.summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; +} +.summary-block h3 { font-size: 12.5px; margin: 0 0 4px; color: var(--muted); font-weight: 650; } +.summary-block.risk h3 { color: var(--badge-err-ink); } +.summary-block ul { margin: 0; padding-left: 18px; } +.summary-block li { margin: 2px 0; } + +/* ------------------------------------------------------------------ report */ +.report-meta { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 12.5px; color: var(--ink-2); margin-bottom: 4px; } +.markdown { max-width: 76ch; } +.markdown h1 { font-size: 19px; margin: 10px 0 6px; } +.markdown h2 { font-size: 15px; color: var(--ink); margin: 18px 0 6px; } +.markdown h3 { font-size: 14px; margin: 14px 0 4px; } +.markdown p, .markdown li { color: var(--ink); } +.markdown a { color: var(--series-1); } +.markdown table { border-collapse: collapse; font-size: 13px; margin: 8px 0; display: block; overflow-x: auto; } +.markdown th, .markdown td { border: 1px solid var(--grid); padding: 4px 9px; text-align: right; } +.markdown th:first-child, .markdown td:first-child { text-align: left; } +.markdown th { background: var(--page); color: var(--ink-2); font-weight: 600; } +.markdown td { font-variant-numeric: tabular-nums; } +.markdown blockquote { margin: 8px 0; padding: 2px 12px; border-left: 3px solid var(--baseline); color: var(--ink-2); } +.markdown code { background: var(--page); border: 1px solid var(--grid); border-radius: 4px; padding: 0 4px; font-size: 12.5px; } + +table.data-table { border-collapse: collapse; font-size: 13px; } +table.data-table th, table.data-table td { border: 1px solid var(--grid); padding: 4px 10px; text-align: right; font-variant-numeric: tabular-nums; } +table.data-table th:first-child, table.data-table td:first-child { text-align: left; } +table.data-table th { background: var(--page); color: var(--ink-2); font-weight: 600; } + +.errors-list { list-style: none; margin: 8px 0 0; padding: 0; font-size: 12.5px; display: flex; flex-direction: column; gap: 6px; } +.errors-list .err-ts { color: var(--muted); margin-right: 8px; } +.errors-list .err-subject { font-weight: 650; color: var(--badge-err-ink); margin-right: 8px; } +.errors-list .err-detail { color: var(--ink-2); overflow-wrap: anywhere; }