Files
Aislo/B01_Dashboard/B01_Dashboard_UI_Resources.ts
T
eomsangdonandClaude Sonnet 5.5 2a2c4d0f62 feat(B01): 대시보드 항목별 탭 — 해시 탭 줄 · 개요 · 프로젝트 · 회사 · 내 계정 · 시스템 (92-1·92-2)
- 해시 ?tab= 탭 줄(B08 단추 꼴) · 고른 탭 자료만 받음 · 설정 카드는 탭 줄 위 · 회사 고르기 알약은 탭 줄 오른쪽 하나
- 회사 없음 진입 두 곳(router · M02 Layer)은 ?tab=company · 회사 관리자 프로젝트 목록 한 번만 받음
- 개요 숫자 카드(단계별 · 실패 · 재확인 · 가입 대기 · 보관함 · 리소스) · 프로젝트 표 칸 보강 · 찾기 · 거르기 · 쪽 넘김 · 진행 현황
- 회사 정보 칸(사업자번호 · 대표 · 상태) · 팀원 읽기 · 자산 표 · 내 계정 마지막 로그인 · 시스템 리소스 7·30·90일 · 활성 추이 · 전체 회사 · 사용자 · 로그 쪽 · 고객 문의
- 시험 test_b01_dashboard_tabs 신규 · test_b01_settings_cards 고침

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
2026-10-08 13:58:34 +09:00

204 lines
6.7 KiB
TypeScript

import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createLineChart } from "@ui/ui_template_elements";
import type { ResourceData, ResourceHistoryPoint } from "./B01_Dashboard_Api_Fetch";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
function percent(value?: number | null): string {
return value == null ? "-" : `${Math.round(value)}%`;
}
export function buildResourcePanel(resources: ResourceData | null, days = 7): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b01-dashboard__resource-panel";
wrap.append(buildResourceMetrics(resources), buildResourceChart(resources, days));
return wrap;
}
/** 활성 사용자 · 활성 프로젝트 추이(92-2) — 같은 기간 선그래프 */
export function buildActivityChart(resources: ResourceData | null, days = 7): HTMLElement {
return buildTrendChart(resources, days, {
caption: L("B01_Sy_TrendCaption"),
ariaLabel: L("B01_Sy_ActiveTrend"),
count: true,
series: [
{
name: L("B01_Dashboard_Metric_ActiveUsers"),
colorIndex: 0,
pick: (h) => h.active_user_count,
},
{
name: L("B01_Dashboard_Metric_ActiveProjects"),
colorIndex: 1,
pick: (h) => h.active_project_count,
},
],
});
}
function buildResourceMetrics(resources: ResourceData | null): HTMLElement {
const values = resources?.current;
const grid = document.createElement("div");
grid.className = "b01-dashboard__metric-grid";
const items = [
[L("B01_Dashboard_Metric_Cpu"), percent(values?.cpu_usage_percent)],
[L("B01_Dashboard_Metric_Memory"), percent(values?.memory_usage_percent)],
[L("B01_Dashboard_Metric_Disk"), percent(values?.disk_usage_percent)],
[L("B01_Dashboard_Metric_ActiveUsers"), values?.active_user_count ?? 0],
[L("B01_Dashboard_Metric_ActiveProjects"), values?.active_project_count ?? 0],
];
for (const [label, value] of items) {
const box = document.createElement("div");
box.className = "b01-dashboard__metric";
const labelEl = document.createElement("span");
labelEl.className = "b01-dashboard__metric-label";
labelEl.textContent = String(label);
const valueEl = document.createElement("span");
valueEl.className = "b01-dashboard__metric-value";
valueEl.textContent = String(value);
box.append(labelEl, valueEl);
grid.append(box);
}
return grid;
}
function buildResourceChart(resources: ResourceData | null, days: number): HTMLElement {
return buildTrendChart(resources, days, {
caption: L("B01_Dashboard_Resources_ChartCaption"),
ariaLabel: L("B01_Dashboard_Resources_ChartAria"),
series: [
{ name: L("B01_Dashboard_Metric_Cpu"), colorIndex: 0, pick: (h) => h.cpu_usage_percent },
{
name: L("B01_Dashboard_Metric_Memory"),
colorIndex: 1,
pick: (h) => h.memory_usage_percent,
},
{ name: L("B01_Dashboard_Metric_Disk"), colorIndex: 2, pick: (h) => h.disk_usage_percent },
],
});
}
interface TrendOptions {
caption: string;
ariaLabel: string;
/** true = 사람 · 프로젝트 수(개수) 그래프 */
count?: boolean;
series: {
name: string;
colorIndex: 0 | 1 | 2 | 3;
pick: (point: ResourceHistoryPoint) => number | null;
}[];
}
/** 기간(일)에 맞춘 시간 칸 — 7일은 1시간, 30일은 3시간, 90일은 12시간 */
function trendBucketHours(days: number): number {
return days <= 7 ? 1 : days <= 30 ? 3 : 12;
}
function buildTrendChart(
resources: ResourceData | null,
days: number,
options: TrendOptions,
): HTMLElement {
const box = document.createElement("div");
box.className = "b01-dashboard__chart";
const caption = document.createElement("p");
caption.className = "b01-dashboard__chart-caption";
caption.textContent = options.caption;
box.append(caption);
const chartWrap = document.createElement("div");
chartWrap.className = "b01-dashboard__chart-content";
box.append(chartWrap);
const history = resources?.history ?? [];
const bucketHours = trendBucketHours(days);
const bucketMs = bucketHours * 60 * 60 * 1000;
const now = new Date();
const baseDate = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
now.getHours() >= 12 ? 24 : 12,
0,
0,
0,
);
// 기간을 시간 칸으로 나눔. 칸이 넓으면 짧은 가동 구간이 한 칸에 뭉쳐 점 하나(선 없음)가
// 되므로 7일은 1시간 단위를 쓴다.
const count = (days * 24) / bucketHours;
const times: Date[] = [];
const values: (number | null)[][] = options.series.map(() => []);
for (let i = 0; i <= count; i++) {
times.push(new Date(baseDate.getTime() - (count - i) * bucketMs));
for (const column of values) column.push(null);
}
history.forEach((h) => {
if (!h.timestamp) return;
const tsStr = h.timestamp.endsWith("Z") ? h.timestamp : h.timestamp + "Z";
const hTime = new Date(tsStr).getTime();
let closestIdx = -1;
let minDiff = Infinity;
times.forEach((time, idx) => {
const diff = Math.abs(time.getTime() - hTime);
if (diff < minDiff && diff <= Math.max(30 * 60 * 1000, bucketMs / 2)) {
minDiff = diff;
closestIdx = idx;
}
});
if (closestIdx === -1) return;
options.series.forEach((item, s) => {
values[s][closestIdx] = item.pick(h);
});
});
// 날짜 이름표는 하루 간격(7일) · 3일(30일) · 10일(90일)
const dayStep = days <= 7 ? 1 : days <= 30 ? 3 : 10;
const xLabels = times.map((time, i) =>
time.getHours() === 0 && Math.floor((i * bucketHours) / 24) % dayStep === 0
? `${time.getMonth() + 1}/${time.getDate()}`
: "",
);
const peak = Math.max(0, ...values.flat().map((v) => v ?? 0));
// 백분율 그래프는 0~100% 고정 · 개수 그래프는 최댓값에 맞춰 4칸으로 나눔
const yMax = options.count ? Math.max(4, Math.ceil((peak * 1.2) / 4) * 4) : 100;
let resizeTimeout: number | null = null;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const width = Math.floor(entry.contentRect.width);
if (width <= 0) continue;
if (resizeTimeout) {
window.cancelAnimationFrame(resizeTimeout);
}
resizeTimeout = window.requestAnimationFrame(() => {
chartWrap.innerHTML = "";
const chart = createLineChart({
ariaLabel: options.ariaLabel,
xLabels,
width: width,
height: 140,
yMax,
yUnit: options.count ? "" : "%",
series: options.series.map((item, s) => ({
name: item.name,
colorIndex: item.colorIndex,
values: values[s],
})),
});
chartWrap.append(chart);
});
}
});
observer.observe(box);
return box;
}