/* ============================================================================= * B01_Dashboard_UI_View_SysOverview.ts * 시스템 관리 › 개요(549-2) — 숫자 카드(회사 · 사용자 · 활성 프로젝트 · 활성 사용자) + * CPU · 메모리 · 디스크 게이지(넘으면 주황 · 빨강) + 7 일 그래프(인라인 SVG). * 자료 = 리소스 API(7 일) · 회사 목록 · 전체 사용자 — 시스템 관리자만(서버도 막음). * ========================================================================== */ import { fetchAllCompanies, fetchAllUsers, fetchSystemResources, type ResourceData, } from "./B01_Dashboard_Api_Fetch"; import { pageFrame } from "./B01_Dashboard_UI_PageKit"; import { el as h, loadPage } from "./B01_Dashboard_UI_View_Parts"; import type { ViewCtx } from "./B01_Dashboard_UI_Views"; const SVG_NS = "http://www.w3.org/2000/svg"; const DAY_MS = 86_400_000; /** 게이지 색 바뀌는 선 — 이 % 이상 주황 · 빨강 */ export const GAUGE_WARN = 70; export const GAUGE_BAD = 90; export const gaugeTone = (percent: number | null | undefined): "ok" | "warn" | "bad" => percent == null || percent < GAUGE_WARN ? "ok" : percent < GAUGE_BAD ? "warn" : "bad"; function statCard(label: string, value: number | string): HTMLElement { const card = h("div", "b01-pg-card b01-sys-stat"); card.append( h("span", "b01-sys-stat__label", label), h("span", "b01-sys-stat__value", String(value)), ); return card; } function gauge(label: string, percent: number | null | undefined): HTMLElement { const card = h("div", `b01-pg-card b01-sys-gauge is-${gaugeTone(percent)}`); const top = h("div", "b01-sys-gauge__top"); top.append( h("span", "b01-sys-stat__label", label), h("span", "b01-sys-stat__value", percent == null ? "-" : `${Math.round(percent)}%`), ); const bar = h("div", "b01-sys-gauge__bar"); bar.setAttribute("role", "progressbar"); bar.setAttribute("aria-valuenow", String(Math.round(percent ?? 0))); const fill = h("div", "b01-sys-gauge__fill"); fill.style.width = `${Math.min(100, Math.max(0, percent ?? 0))}%`; bar.append(fill); card.append(top, bar); return card; } const svg = (tag: string, attrs: Record): SVGElement => { const e = document.createElementNS(SVG_NS, tag); for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, String(v)); return e; }; const SERIES = [ { name: "CPU", key: "cpu_usage_percent", color: "var(--b01-s1)" }, { name: "메모리", key: "memory_usage_percent", color: "var(--b01-s2)" }, { name: "디스크", key: "disk_usage_percent", color: "var(--b01-s3)" }, ] as const; /** 7 일 그래프 — 가로 = 시각 · 세로 0~100 % · 세 시간 넘게 비면 선을 끊음 */ export function buildWeekChart(data: ResourceData | null, now = Date.now()): HTMLElement { const W = 720; const H = 180; const pad = { l: 28, r: 8, t: 8, b: 20 }; const start = now - 7 * DAY_MS; const x = (t: number): number => pad.l + ((t - start) / (now - start)) * (W - pad.l - pad.r); const y = (v: number): number => pad.t + (1 - v / 100) * (H - pad.t - pad.b); const box = h("div", "b01-pg-card b01-sys-chart"); const legend = h("div", "b01-sys-chart__legend"); for (const s of SERIES) { const item = h("span", "", s.name); const dot = h("i"); dot.style.background = s.color; item.prepend(dot); legend.append(item); } const chart = svg("svg", { viewBox: `0 0 ${W} ${H}`, role: "img" }); chart.setAttribute("aria-label", "최근 7 일 CPU · 메모리 · 디스크 사용률"); for (const v of [0, 50, 100]) { chart.append(svg("line", { x1: pad.l, x2: W - pad.r, y1: y(v), y2: y(v) })); const label = svg("text", { x: pad.l - 4, y: y(v) + 3, "text-anchor": "end" }); label.textContent = `${v}`; chart.append(label); } // 날짜 눈금 — 자정마다 const midnight = new Date(start); midnight.setHours(24, 0, 0, 0); for (let t = midnight.getTime(); t <= now; t += DAY_MS) { const d = new Date(t); const label = svg("text", { x: x(t), y: H - 6, "text-anchor": "middle" }); label.textContent = `${d.getMonth() + 1}/${d.getDate()}`; chart.append(label); } const points = (data?.history ?? []) .filter((p) => p.timestamp) .map((p) => ({ t: new Date(p.timestamp.endsWith("Z") ? p.timestamp : `${p.timestamp}Z`).getTime(), p, })) .filter(({ t }) => t >= start && t <= now) .sort((a, b) => a.t - b.t); for (const s of SERIES) { let d = ""; let prev = -Infinity; for (const { t, p } of points) { const v = p[s.key]; if (v == null) continue; d += `${t - prev > 3 * 3_600_000 ? "M" : "L"}${x(t).toFixed(1)} ${y(v).toFixed(1)} `; prev = t; chart.append( svg("circle", { cx: x(t).toFixed(1), cy: y(v).toFixed(1), r: 1.5, fill: s.color }), ); } chart.append(svg("path", { d, fill: "none", stroke: s.color, "stroke-width": 1.5 })); } box.append(legend, chart); if (!points.length) box.append(h("p", "b01-pg-note", "7 일 안에 쌓인 기록이 없습니다.")); return box; } export function mountSysOverview(host: HTMLElement, _ctx: ViewCtx): void { loadPage(host, async (stage) => { const [res, companies, users] = await Promise.all([ fetchSystemResources(7), fetchAllCompanies().catch(() => []), fetchAllUsers(null).catch(() => []), ]); const now = res.current; const stats = h("div", "b01-sys-stats"); stats.append( statCard("회사", companies.length), statCard("사용자", users.length), statCard("활성 프로젝트", now?.active_project_count ?? 0), statCard("활성 사용자", now?.active_user_count ?? 0), ); const gauges = h("div", "b01-sys-stats"); gauges.append( gauge("CPU", now?.cpu_usage_percent), gauge("메모리", now?.memory_usage_percent), gauge("디스크", now?.disk_usage_percent), ); const page = pageFrame( stage, "개요", "서버 상태와 회사 · 사용자 · 프로젝트 수를 한눈에 봅니다.", ); page.body.append(stats, gauges, buildWeekChart(res)); }); }