first commit

This commit is contained in:
jsahn
2026-09-02 14:57:29 +09:00
commit c0b140d27f
60 changed files with 7520 additions and 0 deletions
+16
View File
@@ -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 SEBRIEF_REPORTS_DIR=/data/reports PORT=8091
COPY --from=build /app/dist ./dist
EXPOSE 8091
USER node
CMD ["node", "dist/server.js"]
+565
View File
@@ -0,0 +1,565 @@
{
"name": "sebrief-dashboard",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sebrief-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"
}
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "sebrief-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"
}
}
+172
View File
@@ -0,0 +1,172 @@
/** 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 {
label: string;
value: number;
}
function svgEl<K extends keyof SVGElementTagNameMap>(
tag: K,
attrs: Record<string, string | number> = {},
): 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 = Number.isInteger(v) ? 0 : Math.abs(v) >= 10 ? 1 : 2;
return v.toLocaleString("ko-KR", { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
}
/** Render the chart into `host` (cleared first). Null values break the line.
* `labels` are ISO weeks; the axis shows their short form ("W36"). */
export function renderLineChart(
host: HTMLElement,
labels: string[],
values: (number | null)[],
seriesLabel: string,
): void {
host.textContent = "";
const points: (Point | null)[] = labels.map((label, i) => {
const v = values[i];
return v === null || v === undefined ? null : { label, 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(0, ...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;
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 + (labels.length === 1 ? innerW / 2 : (i / (labels.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(labels.length / maxLabels));
labels.forEach((d, i) => {
if (i % stride !== 0 && i !== labels.length - 1) return;
const label = svgEl("text", { class: "axis-label", x: x(i), y: H - 8, "text-anchor": "middle" });
label.textContent = d.slice(5); // "2026-W36" -> "W36"
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 labelEl = document.createElement("div");
labelEl.textContent = pt.label;
const valueEl = document.createElement("div");
valueEl.className = "tt-value";
valueEl.textContent = `${formatValue(pt.value)}편`;
tooltip.append(labelEl, valueEl);
tooltip.hidden = false;
tooltip.style.left = `${(cx / W) * rect.width}px`;
tooltip.style.top = `${(cy / H) * rect.height}px`;
});
}
+359
View File
@@ -0,0 +1,359 @@
import DOMPurify from "dompurify";
import { marked } from "marked";
import type {
DigestResult,
DigestSummary,
ErrorLine,
HealthResponse,
StatsResponse,
} from "../shared/types";
import { formatValue, renderLineChart } from "./chart";
function el<K extends keyof HTMLElementTagNameMap>(
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<T extends HTMLElement>(id: string): T {
const node = document.getElementById(id);
if (!node) throw new Error(`missing #${id}`);
return node as T;
}
async function getJson<T>(url: string): Promise<T> {
const res = await fetch(url);
if (!res.ok) throw new Error(`${url} -> ${res.status}`);
return (await res.json()) as T;
}
// ------------------------------------------------------------------ ISO week helpers
const WEEK_RE = /^(\d{4})-W(\d{2})$/;
/** Monday (UTC midnight) of an ISO week id. */
function weekToMonday(week: string): Date {
const m = WEEK_RE.exec(week);
if (!m) throw new Error(`bad week ${week}`);
const year = Number(m[1]);
const wk = Number(m[2]);
const jan4 = new Date(Date.UTC(year, 0, 4)); // Jan 4 is always in ISO week 1
const week1Monday = new Date(jan4);
week1Monday.setUTCDate(jan4.getUTCDate() - ((jan4.getUTCDay() + 6) % 7));
const monday = new Date(week1Monday);
monday.setUTCDate(week1Monday.getUTCDate() + (wk - 1) * 7);
return monday;
}
/** ISO week id of a UTC date. */
function dateToWeek(d: Date): string {
const thu = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
thu.setUTCDate(thu.getUTCDate() - ((thu.getUTCDay() + 6) % 7) + 3); // Thursday decides the ISO year
const isoYear = thu.getUTCFullYear();
const jan4 = new Date(Date.UTC(isoYear, 0, 4));
const week1Monday = new Date(jan4);
week1Monday.setUTCDate(jan4.getUTCDate() - ((jan4.getUTCDay() + 6) % 7));
const wk = Math.floor((thu.getTime() - week1Monday.getTime()) / (7 * 864e5)) + 1;
return `${isoYear}-W${String(wk).padStart(2, "0")}`;
}
function addWeeks(week: string, delta: number): string {
const monday = weekToMonday(week);
monday.setUTCDate(monday.getUTCDate() + delta * 7);
return dateToWeek(monday);
}
/** Weeks are lexicographically ordered only within a year; compare via their Mondays. */
function weekLt(a: string, b: string): boolean {
return weekToMonday(a).getTime() < weekToMonday(b).getTime();
}
/** The ISO week containing today (Seoul calendar date). */
function kstCurrentWeek(): string {
const today = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Seoul" }).format(new Date());
return dateToWeek(new Date(`${today}T00:00:00Z`));
}
function fmtDateTime(iso: string): string {
return new Date(iso).toLocaleString("ko-KR", { dateStyle: "medium", timeStyle: "short" });
}
/** Live Seoul clock in the top bar. */
function startClock(): void {
const host = byId<HTMLElement>("clocks");
const seoul = el("span", "clock");
host.append(seoul);
const tick = () => {
seoul.textContent = `서울 ${new Intl.DateTimeFormat("ko-KR", {
timeZone: "Asia/Seoul",
month: "2-digit",
day: "2-digit",
weekday: "short",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(new Date())}`;
};
tick();
setInterval(tick, 1000);
}
// ------------------------------------------------------------------ stat tiles
function verificationBadge(result: DigestResult): { text: string; cls: string } {
const v = result.verification;
if (result.degraded) return { text: "요약 없음", cls: "badge-warn" };
if (!v) return { text: "검증 안 함", cls: "" };
if (v.ok) return { text: v.issues.length > 0 ? `통과 (minor ${v.issues.length})` : "통과", cls: "badge-ok" };
return { text: `이슈 ${v.issues.length}`, cls: "badge-error" };
}
function renderTiles(result: DigestResult): void {
const host = byId<HTMLElement>("tiles");
host.textContent = "";
const venues = new Set(result.payload.papers.map((p) => p.venue || "(미상)"));
const tiles: Array<[string, string]> = [
["논문", `${result.payload.papers.length}편`],
["저널", `${venues.size}곳`],
["하이라이트", `${result.digest?.highlights.length ?? 0}편`],
["주제 그룹", `${result.digest?.by_topic.length ?? 0}개`],
];
for (const [label, value] of tiles) {
const tile = el("div", "tile");
tile.append(el("div", "tile-label", label));
tile.append(el("div", "tile-value", value));
host.append(tile);
}
const tile = el("div", "tile");
tile.append(el("div", "tile-label", "검증"));
const { text, cls } = verificationBadge(result);
const value = el("div", "tile-value tile-value-small", text);
if (cls) value.classList.add(cls.replace("badge-", "tone-"));
tile.append(value);
host.append(tile);
}
// ------------------------------------------------------------------ digest list + body
function badgeFor(s: DigestSummary): 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", "정상");
}
function missingRow(week: string, currentWeek: string): HTMLElement {
const label = week === currentWeek ? "이번 주 · 다음 주 월요일 생성 예정" : "다이제스트 없음";
const item = el("li", "week-missing");
item.append(el("span", "digest-week", week), el("span", undefined, label));
return item;
}
function renderList(
summaries: DigestSummary[],
selected: string,
onSelect: (week: string) => void,
): void {
const list = byId<HTMLUListElement>("digest-list");
list.textContent = "";
const byWeek = new Map(summaries.map((s) => [s.week, s]));
const current = kstCurrentWeek();
const newest = summaries[0]?.week ?? current;
const oldest = summaries[summaries.length - 1]?.week ?? current;
const start = weekLt(newest, current) ? current : newest;
// walk week by week so gaps (missed runs) are visible, not silent
for (let w = start; !weekLt(w, oldest); w = addWeeks(w, -1)) {
const s = byWeek.get(w);
if (!s) {
list.append(missingRow(w, current));
continue;
}
const item = el("li");
const button = el("button");
button.type = "button";
button.setAttribute("aria-current", String(s.week === selected));
button.append(el("span", "digest-week", s.week));
const line = el("span", "digest-line");
line.append(`${s.week_start} ~ ${s.week_end}`, badgeFor(s));
line.append(el("span", "badge", `${s.paper_count}편`));
if (s.data_gaps.length > 0) line.append(el("span", "badge badge-warn", `결측 ${s.data_gaps.length}`));
button.append(line);
if (s.overview) button.append(el("span", "digest-line digest-overview", s.overview));
button.addEventListener("click", () => onSelect(s.week));
item.append(button);
list.append(item);
}
}
function metaChips(result: DigestResult): HTMLElement[] {
const p = result.payload;
const chips: HTMLElement[] = [
el("span", undefined, `${p.week} (${p.week_start} ~ ${p.week_end})`),
el("span", undefined, `생성 ${fmtDateTime(p.generated_at)}`),
];
if (result.model_used) chips.push(el("span", undefined, `모델 ${result.model_used}`));
if (result.degraded) chips.push(el("span", "badge badge-warn", "요약 없음"));
const { text, cls } = verificationBadge(result);
if (result.verification) chips.push(el("span", `badge ${cls}`.trim(), `검증 ${text}`));
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: overview + worth-reading picks, above the full markdown. */
function renderSummaryCard(result: DigestResult): void {
const host = byId<HTMLElement>("digest-summary");
host.textContent = "";
const digest = result.digest;
if (!digest) return;
const card = el("section", "summary-card");
card.setAttribute("aria-label", "핵심 요약");
if (digest.overview) card.append(el("p", "summary-overview", digest.overview));
if (digest.worth_reading.length > 0) {
const block = el("div", "summary-block");
block.append(el("h3", undefined, "읽어볼 논문"));
const list = el("ul");
const papers = new Map(result.payload.papers.map((p) => [p.doi, p]));
// 제목 우선 매칭: LLM이 DOI를 뒤섞어도 링크는 (독자가 보는) 제목을 따라간다 (렌더러와 동일 규칙)
const normTitle = (s: string) => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
const byTitle = new Map<string, (typeof result.payload.papers)[number] | null>();
for (const p of result.payload.papers) {
const key = normTitle(p.title);
byTitle.set(key, byTitle.has(key) ? null : p);
}
for (const pick of digest.worth_reading) {
const item = el("li");
const paper = byTitle.get(normTitle(pick.title)) ?? papers.get(pick.doi);
const url = paper?.oa_url ?? `https://doi.org/${pick.doi}`;
const link = el("a", undefined, pick.title || pick.doi);
link.setAttribute("href", url);
link.setAttribute("target", "_blank");
link.setAttribute("rel", "noopener noreferrer");
item.append(link);
if (pick.reason) item.append(` — ${pick.reason}`);
list.append(item);
}
block.append(list);
card.append(block);
}
host.append(card);
}
async function showDigest(week: string): Promise<void> {
const result = await getJson<DigestResult>(`/api/digests/${week}`);
renderTiles(result);
const meta = byId<HTMLElement>("digest-meta");
meta.textContent = "";
meta.append(...metaChips(result));
renderSummaryCard(result);
const body = byId<HTMLElement>("digest-body");
// Paper titles/abstract prose come from external sources - sanitize the rendered HTML.
body.innerHTML = DOMPurify.sanitize(marked.parse(result.markdown, { async: false }));
for (const link of body.querySelectorAll("a")) {
link.setAttribute("target", "_blank");
link.setAttribute("rel", "noopener noreferrer");
}
}
// ------------------------------------------------------------------ chart section
function setupChart(stats: StatsResponse): void {
const chartHost = byId<HTMLElement>("chart");
const tableHost = byId<HTMLElement>("chart-table");
const toggle = byId<HTMLButtonElement>("table-toggle");
if (stats.weeks.length === 0) {
chartHost.append(el("p", "chart-empty", "다이제스트가 쌓이면 주차별 논문 수가 표시됩니다."));
toggle.disabled = true;
return;
}
renderLineChart(chartHost, stats.weeks, stats.paper_counts, "주차별 수집 논문 수");
const table = el("table", "data-table");
const head = el("tr");
head.append(el("th", undefined, "주차"), el("th", undefined, "논문 수"));
table.append(head);
for (let i = stats.weeks.length - 1; i >= 0; i--) {
const row = el("tr");
const v = stats.paper_counts[i];
row.append(
el("td", undefined, stats.weeks[i] ?? ""),
el("td", undefined, v === null || v === undefined ? "—" : formatValue(v)),
);
table.append(row);
}
tableHost.append(table);
toggle.addEventListener("click", () => {
const showTable = tableHost.hidden;
tableHost.hidden = !showTable;
toggle.setAttribute("aria-pressed", String(showTable));
toggle.textContent = showTable ? "표 숨기기" : "표로 보기";
});
}
// ------------------------------------------------------------------ errors + health
function renderErrors(errors: ErrorLine[]): void {
if (errors.length === 0) return;
const panel = byId<HTMLElement>("errors-panel");
panel.hidden = false;
byId<HTMLElement>("errors-count").textContent = String(errors.length);
const list = byId<HTMLUListElement>("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<void> {
startClock();
const health = await getJson<HealthResponse>("/api/health");
byId<HTMLElement>("health").textContent =
health.digest_count > 0
? `다이제스트 ${health.digest_count}건 · 최신 ${health.latest_week}`
: "아직 쌓인 다이제스트가 없습니다";
const [summaries, stats, errors] = await Promise.all([
getJson<DigestSummary[]>("/api/digests"),
getJson<StatsResponse>("/api/stats"),
getJson<ErrorLine[]>("/api/errors"),
]);
renderErrors(errors);
setupChart(stats);
if (summaries.length === 0) {
byId<HTMLElement>("digest-body").append(
el("p", "chart-empty", "다이제스트가 아직 없습니다. 다음 주 월요일 아침 첫 다이제스트가 생성됩니다."),
);
return;
}
let selected = summaries[0]!.week;
const select = async (week: string) => {
selected = week;
renderList(summaries, selected, (w) => void select(w));
await showDigest(week);
};
await select(selected);
}
void main().catch((err) => {
console.error(err);
byId<HTMLElement>("health").textContent = "대시보드 로드 실패 — 서버 로그를 확인하세요";
});
+219
View File
@@ -0,0 +1,219 @@
/** Read-only dashboard API + static file server.
*
* The single source of truth is the reports directory the sebrief daemon archives into
* (reports/YYYY-Wnn.{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 {
DigestResult,
DigestSummary,
ErrorLine,
HealthResponse,
StatsResponse,
} from "./shared/types";
const REPORTS_DIR = process.env.SEBRIEF_REPORTS_DIR ?? "/data/reports";
const PORT = Number(process.env.PORT ?? 8091);
const PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "public");
const WEEK_RE = /^\d{4}-W\d{2}$/;
const JSON_FILE_RE = /^\d{4}-W\d{2}\.json$/;
// ------------------------------------------------------------------ digest loading
/** mtime+size keyed cache so repeated requests do not re-parse unchanged files. */
const cache = new Map<string, { mtimeMs: number; size: number; data: DigestResult }>();
async function loadDigest(week: string): Promise<DigestResult | null> {
const file = path.join(REPORTS_DIR, `${week}.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 DigestResult;
cache.set(file, { mtimeMs: st.mtimeMs, size: st.size, data });
return data;
} catch (err) {
console.error(`unparseable digest ${file}:`, err);
return null;
}
}
async function listWeeks(): Promise<string[]> {
let names: string[];
try {
names = await readdir(REPORTS_DIR);
} catch {
return []; // directory not mounted yet / no digest produced yet
}
return names
.filter((n) => JSON_FILE_RE.test(n))
.map((n) => n.slice(0, 8))
.sort()
.reverse();
}
function summarize(week: string, r: DigestResult): DigestSummary {
return {
week,
week_start: r.payload.week_start,
week_end: r.payload.week_end,
generated_at: r.payload.generated_at,
model_used: r.model_used,
degraded: r.degraded,
error: r.error,
overview: r.digest?.overview ?? null,
paper_count: r.payload.papers.length,
highlight_count: r.digest?.highlights.length ?? 0,
topic_count: r.digest?.by_topic.length ?? 0,
verification_ok: r.verification?.ok ?? null,
data_gaps: r.payload.data_gaps,
};
}
async function allDigests(): Promise<Array<{ week: string; result: DigestResult }>> {
const out: Array<{ week: string; result: DigestResult }> = [];
for (const week of await listWeeks()) {
const result = await loadDigest(week);
if (result) out.push({ week, result });
}
return out; // newest first (listWeeks is descending)
}
async function buildStats(): Promise<StatsResponse> {
const digests = (await allDigests()).reverse(); // ascending for the time series
const weeks: string[] = [];
const paper_counts: (number | null)[] = [];
for (const { week, result } of digests) {
if (weeks.includes(week)) continue;
weeks.push(week);
paper_counts.push(result.payload.papers.length);
}
return { weeks, paper_counts };
}
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 weeks = await listWeeks();
const body: HealthResponse = {
ok: true,
digest_count: weeks.length,
reports_dir: REPORTS_DIR,
latest_week: weeks[0] ?? null,
};
sendJson(res, 200, body);
return;
}
if (p === "/api/digests") {
const digests = await allDigests();
sendJson(res, 200, digests.map(({ week, result }) => summarize(week, result)));
return;
}
const detail = p.match(/^\/api\/digests\/(\d{4}-W\d{2})$/);
if (detail) {
const week = detail[1]!;
if (!WEEK_RE.test(week)) {
sendJson(res, 400, { error: "bad week" });
return;
}
const result = await loadDigest(week);
if (!result) {
sendJson(res, 404, { error: `no digest for ${week}` });
return;
}
sendJson(res, 200, result);
return;
}
if (p === "/api/stats") {
sendJson(res, 200, await buildStats());
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(`sebrief dashboard listening on :${PORT} (reports: ${REPORTS_DIR})`);
});
+128
View File
@@ -0,0 +1,128 @@
/** Mirrors sebrief/models.py (pydantic model_dump_json → snake_case). */
export interface PaperSummary {
tldr: string;
problem: string;
approach: string;
findings: string;
implication: string;
}
export interface Paper {
doi: string;
title: string;
abstract: string;
authors: string[];
venue: string;
published_date: string | null;
oa_url: string | null;
pdf_url: string | null;
topics: string[];
source: string;
/** Absent/null until the per-paper summarisation step has run for this DOI. */
summary?: PaperSummary | null;
}
export interface WeekPayload {
week: string;
week_start: string;
week_end: string;
generated_at: string;
papers: Paper[];
data_gaps: string[];
}
export interface Highlight {
doi: string;
title: string;
problem: string;
approach: string;
findings: string;
implication: string;
}
export interface TopicPaper {
doi: string;
title: string;
note: string;
}
export interface TopicGroup {
topic: string;
papers: TopicPaper[];
}
export interface Pick {
doi: string;
title: string;
reason: string;
}
export interface Digest {
overview: string;
highlights: Highlight[];
by_topic: TopicGroup[];
worth_reading: Pick[];
data_gaps: string[];
}
export interface VerificationIssue {
claim: string;
problem: string;
severity: "minor" | "major";
}
export interface Verification {
ok: boolean;
issues: VerificationIssue[];
}
export interface DigestResult {
payload: WeekPayload;
digest: Digest | null;
markdown: string;
model_used: string | null;
degraded: boolean;
error: string | null;
/** Absent/null: verification not run (disabled, degraded, or the verifier failed). */
verification?: Verification | null;
}
// ---------------------------------------------------------------- API shapes
export interface DigestSummary {
week: string;
week_start: string;
week_end: string;
generated_at: string;
model_used: string | null;
degraded: boolean;
error: string | null;
overview: string | null;
paper_count: number;
highlight_count: number;
topic_count: number;
/** null: verification not run. */
verification_ok: boolean | null;
data_gaps: string[];
}
export interface StatsResponse {
/** ISO weeks, ascending. */
weeks: string[];
/** papers per week, aligned with `weeks`. */
paper_counts: (number | null)[];
}
export interface ErrorLine {
ts: string;
subject: string;
detail: string;
}
export interface HealthResponse {
ok: boolean;
digest_count: number;
reports_dir: string;
latest_week: string | null;
}
+16
View File
@@ -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"]
}
+53
View File
@@ -0,0 +1,53 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SE-Brief 대시보드</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<header class="topbar">
<h1>SE-Brief <span class="topbar-sub">OA 소프트웨어공학 논문 주간 다이제스트</span></h1>
<div class="topbar-right">
<div id="clocks" class="clocks"></div>
<div id="health" class="health" role="status"></div>
</div>
</header>
<main class="layout">
<section id="tiles" class="tiles" aria-label="선택 주차 요약"></section>
<section class="panel chart-panel" aria-label="주차별 논문 수">
<div class="panel-head">
<h2 id="chart-title">주차별 수집 논문 수</h2>
<div class="chart-controls">
<button id="table-toggle" type="button" aria-pressed="false">표로 보기</button>
</div>
</div>
<div id="chart" class="chart"></div>
<div id="chart-table" class="chart-table" hidden></div>
</section>
<div class="columns">
<aside class="panel list-panel" aria-label="다이제스트 목록">
<h2>주간 다이제스트</h2>
<ul id="digest-list" class="digest-list"></ul>
</aside>
<article class="panel report-panel">
<div id="digest-meta" class="digest-meta"></div>
<div id="digest-summary"></div>
<div id="digest-body" class="markdown"></div>
</article>
</div>
<section id="errors-panel" class="panel errors-panel" hidden>
<h2>오류 로그 <span id="errors-count" class="badge badge-error"></span></h2>
<ul id="errors-list" class="errors-list"></ul>
</section>
</main>
<script src="/app.js" defer></script>
</body>
</html>
+241
View File
@@ -0,0 +1,241 @@
/* 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: #4a7c59;
--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: #6faf82;
--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); }
.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-value-small { font-size: 15px; }
.tile-value.tone-ok { color: var(--badge-ok-ink); }
.tile-value.tone-warn { color: var(--badge-warn-ink); }
.tile-value.tone-error { color: var(--badge-err-ink); }
/* ------------------------------------------------------------------ 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: 270px 1fr;
gap: 16px;
align-items: start;
}
@media (max-width: 820px) {
.columns { grid-template-columns: 1fr; }
}
.digest-list { list-style: none; margin: 10px 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.digest-list button {
width: 100%;
text-align: left;
display: flex;
flex-direction: column;
gap: 2px;
border-color: var(--border);
}
.digest-list button[aria-current="true"] { border-color: var(--series-1); background: var(--grid); }
.digest-week { font-weight: 650; }
.week-missing {
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;
}
.week-missing .digest-week { font-weight: 500; white-space: nowrap; }
.digest-line { font-size: 12px; color: var(--ink-2); display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.digest-overview {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.badge {
display: inline-block;
font-size: 11px;
border-radius: 20px;
padding: 1px 8px;
font-weight: 600;
background: var(--grid);
color: var(--ink-2);
}
.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-overview { margin: 0 0 10px; font-size: 15px; }
.summary-block h3 { font-size: 12.5px; margin: 0 0 4px; color: var(--muted); font-weight: 650; }
.summary-block ul { margin: 0; padding-left: 18px; }
.summary-block li { margin: 2px 0; }
/* ------------------------------------------------------------------ digest body */
.digest-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: 80ch; }
.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 em { color: var(--muted); font-style: normal; font-size: 12.5px; }
.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: left; }
.markdown th { background: var(--page); color: var(--ink-2); font-weight: 600; }
.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; }