Files
Aislo/B01_Dashboard/B01_Dashboard_UI_View_SysOverview.ts
T
eomsangdonandClaude Sonnet 5.5 70786cef9e feat(B01): 시스템 관리 · 내 정보 세부 페이지 여섯 — 개요 · 회사 · 사용자 · 설계 기본값 · 로그 · 내 정보 (PLAN 549-2)
- 개요 = 숫자 카드 넷 + CPU · 메모리 · 디스크 게이지(70 % 주황 · 90 % 빨강) + 7 일 SVG 그래프
- 회사 = 목록(찾기 · ⋯) → 상세(정보 · 사람 · 프로젝트 탭) · 사용자 = 회사 · 역할 · 찾기 거르기 · ⋯
- 설계 기본값 = 설정 카드를 묶음 제목 + 줄로 · 시스템 로그 = 동작 · 기간 · 찾기 거르기 + 이전 기록 더 보기
- 내 정보 = Linear 꼴 설정 줄(기본정보 · 비밀번호 · 테마 · 내 설계 기본값)
- 옛 구역(리소스 패널 · 옛 내 정보 폼) 지움 · 지킴 시험 test_549 추가 · test_546 stub 맞춤

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 18:19:47 +09:00

156 lines
6.1 KiB
TypeScript

/* =============================================================================
* 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<string, string | number>): 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));
});
}