- 해시 ?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
204 lines
6.7 KiB
TypeScript
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;
|
|
}
|