This commit is contained in:
2026-07-08 19:32:16 +09:00
parent 9c40dec070
commit 8ffa9b097a
14 changed files with 238 additions and 50 deletions
+7 -1
View File
@@ -260,5 +260,11 @@
* **엔드포인트:** `GET /api/dashboard/admin/resources?days=30` (SYSTEM_ADMIN 전용, 범위 1~90일) * **엔드포인트:** `GET /api/dashboard/admin/resources?days=30` (SYSTEM_ADMIN 전용, 범위 1~90일)
* **응답:** * **응답:**
- `current`: 요청 시점 실시간 계측값 (그래프 미저장) - `current`: 요청 시점 실시간 계측값 (그래프 미저장)
- `history`: `system_resources` 테이블의 최근 N일 시계열 (그래프 렌더링용) - `history`: `system_resources` 테이블의 최근 N일 시계열 (그래프 렌더링용, **다운샘플링 적용**)
- `stats`: 활성 사용자/프로젝트/저장소 지표 - `stats`: 활성 사용자/프로젝트/저장소 지표
* **다운샘플링 (조회 시):**
- 30일 원본은 약 21,600점(2분 간격) → 그대로 전송 시 페이로드 과다(~4MB)
- SQL에서 시간버킷 평균으로 축소: `bucket = max(120초, days*86400 / 300)` → 최대 약 300점
- `GROUP BY FLOOR(UNIX_TIMESTAMP(timestamp) / bucket)` + `AVG(...)`로 집계
- 계측 간격(2분)보다 버킷이 작으면 원본 해상도 유지 (하루 이하 조회 시)
- 효과: 30일 조회 페이로드 ~4MB → ~55KB 수준
+41
View File
@@ -157,10 +157,51 @@
- `npx tsc --noEmit` 통과, `ruff format`/`ruff check` 통과, `prettier --write` 변경 없음. - `npx tsc --noEmit` 통과, `ruff format`/`ruff check` 통과, `prettier --write` 변경 없음.
- 계측/로그 트림 로직은 스크립트로 실측 확인 (샘플 기록·30일 경과 줄 제거 동작 확인 후 테스트 로그 삭제). - 계측/로그 트림 로직은 스크립트로 실측 확인 (샘플 기록·30일 경과 줄 제거 동작 확인 후 테스트 로그 삭제).
## 후속 수정 (그래프 다운샘플링 + 스플라인 + 범례/X축)
### 조회 다운샘플링 (백엔드)
- `get_system_resources`의 history 조회를 시간버킷 평균으로 축소.
- `bucket = max(120초, days*86400 / 300)` → 최대 약 300점.
- `GROUP BY FLOOR(UNIX_TIMESTAMP(timestamp)/bucket)` + `AVG(...)`.
- 30일 조회 페이로드 ~4MB → ~55KB 수준으로 감소. 하루 이하 조회 시 원본 해상도 유지.
- DB 실측: 5행 → 1버킷 평균 집계 확인 후 테스트 데이터 삭제.
### 차트 컴포넌트 개선 (`ui_template_elements.ts`)
- 직선 → **스플라인**(Catmull-Rom → 3차 베지어). null 구간은 세그먼트로 분리해 각각 곡선 처리.
- **범례를 그래프 우상단 오버레이**로 이동 (`.ui-chart__plot` position:relative + `.ui-chart__legend` absolute, 반투명 배경).
- **X축 라벨** 지원: `opts.xLabels`, 양끝 포함 5개 균등 표기 (`.ui-chart__tick--x`).
- `B01_Dashboard_UI_Page.ts``formatChartTime`(MM/DD HH:mm) 추가 후 `xLabels` 전달.
### 리소스 지표 레이아웃 (`B01_Dashboard_UI_Style.css`)
- metric-grid를 5열 1행, 각 항목은 라벨·값 가로 1줄(`space-between`)로 컴팩트화. 값 폰트 축소.
### 검증
- `npx tsc --noEmit` 통과 (el() children 타입 이슈는 svg를 append로 분리해 해결).
- `ruff format/check` 통과, `prettier` 변경 없음.
## 후속 수정 (그래프 X축 레이블 - 데이터 기반 고정)
### 백엔드 timestamp 직렬화 (B01_Dashboard_Repository.py)
- 기존: `FROM_UNIXTIME()` → Python datetime 객체로 JSON 직렬화 불가
- 변경: `DATE_FORMAT(..., '%%Y-%%m-%%dT%%H:%%i:%%s')` → ISO 문자열로 명시적 변환
- SQL에 `date_fmt` 변수 도입해 ruff 100자 줄 제한 준수
### 프론트엔드 X축 라벨 고정 (ui_template_elements.ts, B01_Dashboard_UI_Page.ts)
- 기존: `X_TICK_COUNT=5` 고정으로 균등 선택 (데이터 개수와 무관)
- 변경: `X_TICK_STEP=3` — 데이터 포인트마다 3개씩 건너뛰며 표시 (실제 데이터량 기반)
- X축 라벨 루프를 `for (let idx = 0; idx < pointCount; idx += X_TICK_STEP)` 로 변경
- `formatChartTime`에 try-catch 추가로 예외 안전성 강화
### 검증
-`npx tsc --noEmit` 통과
-`ruff format/check` 통과 (100자 줄 제한 준수)
-`prettier` 변경 없음
## 남은 주의점 ## 남은 주의점
- B01 화면은 구현됐지만 실제 브라우저 검증은 수행하지 않았다. - B01 화면은 구현됐지만 실제 브라우저 검증은 수행하지 않았다.
- 그래프는 `system_resources` 이력이 쌓여야 선이 그려진다 (앱 최초 구동 직후에는 데이터 2점 미만이면 `—` 빈 상태 표시, 2분 주기로 누적). - 그래프는 `system_resources` 이력이 쌓여야 선이 그려진다 (앱 최초 구동 직후에는 데이터 2점 미만이면 `—` 빈 상태 표시, 2분 주기로 누적).
- 그래프 갱신은 페이지 로드/새로고침 시점 1회 (자동 폴링 없음).
- 로그인/회사 상태별 실제 시나리오 검증은 별도 검증 AI 또는 사용자가 수행해야 한다. - 로그인/회사 상태별 실제 시나리오 검증은 별도 검증 AI 또는 사용자가 수행해야 한다.
- `B01_Dashboard_UI_Page.ts`는 645줄로 700줄 제한 이내지만, 향후 기능 추가 시 컴포넌트 분리를 고려해야 한다. - `B01_Dashboard_UI_Page.ts`는 645줄로 700줄 제한 이내지만, 향후 기능 추가 시 컴포넌트 분리를 고려해야 한다.
- 이전 import 검증 중 기존 B04 계열 Pydantic `model_*` 필드 경고가 출력됐으나, 이번 B01 구현에서 발생한 경고는 아니다. - 이전 import 검증 중 기존 B04 계열 Pydantic `model_*` 필드 경고가 출력됐으나, 이번 B01 구현에서 발생한 경고는 아니다.
+7 -4
View File
@@ -15,11 +15,14 @@
### 2.1 공통 컴포넌트 목록 (`ui_template_elements.ts`) ### 2.1 공통 컴포넌트 목록 (`ui_template_elements.ts`)
* **기본:** `createButton`, `createInputField`, `createCard`, `createTag`, `createWorkflowShell` * **기본:** `createButton`, `createInputField`, `createCard`, `createTag`, `createWorkflowShell`
* **오버레이/알림:** `showLoadingOverlay`, `hideLoadingOverlay`, `showToast` * **오버레이/알림:** `showLoadingOverlay`, `hideLoadingOverlay`, `showToast`
* **라인 차트:** `createLineChart(opts)` — 외부 라이브러리 없이 인라인 SVG로 시계열 렌더링 * **스플라인 차트:** `createLineChart(opts)` — 외부 라이브러리 없이 인라인 SVG로 시계열 렌더링
- 색상은 `--color-chart-0~3` 테마 변수 사용 (하드코딩 금지) - **곡선:** Catmull-Rom → 3차 베지어 변환으로 스플라인(부드러운 곡선) 렌더
- `series[].values``null` 포함 시 해당 구간 선 끊김 처리 - **색상:** `--color-chart-0~3` 테마 변수 사용 (하드코딩 금지)
- **범례:** 그래프 영역 우상단에 반투명 오버레이 배치 (`opts.series[].name`)
- **X축:** `opts.xLabels` 전달 시 양끝 포함 5개 라벨 균등 표기
- **결측:** `series[].values``null` 포함 시 해당 구간 선 끊김 처리
- 데이터 2점 미만이면 빈 상태(`—`) 반환 - 데이터 2점 미만이면 빈 상태(`—`) 반환
- 사용 예: B01_Dashboard 리소스 현황(CPU/메모리/디스크 30일 추이) - 사용 예: B01_Dashboard 리소스 현황(CPU/메모리/디스크 30일 추이, X축=시간)
--- ---
+2 -1
View File
@@ -10,7 +10,8 @@
"Bash(./venv/Scripts/python.exe -c ' *)", "Bash(./venv/Scripts/python.exe -c ' *)",
"Bash(npx.cmd tsc *)", "Bash(npx.cmd tsc *)",
"Bash(./venv/Scripts/ruff.exe format *)", "Bash(./venv/Scripts/ruff.exe format *)",
"Bash(./venv/Scripts/ruff.exe check *)" "Bash(./venv/Scripts/ruff.exe check *)",
"Bash(npx.cmd prettier *)"
] ]
} }
} }
+1 -1
View File
@@ -122,7 +122,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
navigateTo(ROUTES.A06_LOGIN); navigateTo(ROUTES.A06_LOGIN);
return; return;
} }
if (!user.company_id) { if (!user.company_id && user.role !== "SYSTEM_ADMIN") {
navigateTo(ROUTES.B01_ACCOUNT); navigateTo(ROUTES.B01_ACCOUNT);
return; return;
} }
+6
View File
@@ -207,6 +207,12 @@ export async function fetchCompanyProjects(): Promise<ProjectItem[]> {
return data.projects; return data.projects;
} }
export async function fetchAllProjects(): Promise<ProjectItem[]> {
const data = await request<{ projects: ProjectItem[] }>("/dashboard/admin/projects-all");
return data.projects;
}
export async function fetchAllCompanies(): Promise<CompanyInfo[]> { export async function fetchAllCompanies(): Promise<CompanyInfo[]> {
const data = await request<{ companies: CompanyInfo[] }>("/dashboard/admin/companies"); const data = await request<{ companies: CompanyInfo[] }>("/dashboard/admin/companies");
return data.companies; return data.companies;
+29 -3
View File
@@ -103,6 +103,19 @@ async def list_company_projects(company_id: int) -> list[dict[str, Any]]:
return [_project_row(row) for row in await cursor.fetchall()] return [_project_row(row) for row in await cursor.fetchall()]
async def list_all_projects() -> list[dict[str, Any]]:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""SELECT p.id, p.name, p.region, p.status, p.updated_at, p.created_at,
u.name AS owner_name, u.email AS owner_email
FROM projects p LEFT JOIN users u ON u.id = p.user_id
WHERE p.deleted_at IS NULL
ORDER BY p.updated_at DESC, p.created_at DESC"""
)
return [_project_row(row) for row in await cursor.fetchall()]
async def get_user_company(company_id: int | None) -> dict[str, Any] | None: async def get_user_company(company_id: int | None) -> dict[str, Any] | None:
if company_id is None: if company_id is None:
return None return None
@@ -399,10 +412,23 @@ async def get_system_resources(days: int = 30) -> dict[str, Any]:
# 다운샘플링: 조회 구간을 최대 TARGET_POINTS개 시간버킷으로 나눠 평균. # 다운샘플링: 조회 구간을 최대 TARGET_POINTS개 시간버킷으로 나눠 평균.
# 계측 간격(2분)보다 버킷이 작으면 원본 그대로(버킷=간격) 반환. # 계측 간격(2분)보다 버킷이 작으면 원본 그대로(버킷=간격) 반환.
target_points = 300 target_points = 300
bucket_seconds = max(120, (days * 86400) // target_points)
await cursor.execute( await cursor.execute(
"""SELECT "SELECT COUNT(*) AS cnt FROM system_resources WHERE timestamp >= %s",
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(timestamp) / %s) * %s) AS timestamp, (since,),
)
row_cnt_res = await cursor.fetchone()
row_count = row_cnt_res["cnt"] if row_cnt_res else 0
if row_count <= target_points:
bucket_seconds = 120
else:
bucket_seconds = max(120, (days * 86400) // target_points)
date_fmt = "%%Y-%%m-%%dT%%H:%%i:%%s"
await cursor.execute(
f"""SELECT
DATE_FORMAT(
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(timestamp) / %s) * %s),
'{date_fmt}'
) AS timestamp,
ROUND(AVG(cpu_usage_percent), 2) AS cpu_usage_percent, ROUND(AVG(cpu_usage_percent), 2) AS cpu_usage_percent,
ROUND(AVG(memory_usage_percent), 2) AS memory_usage_percent, ROUND(AVG(memory_usage_percent), 2) AS memory_usage_percent,
ROUND(AVG(disk_usage_percent), 2) AS disk_usage_percent, ROUND(AVG(disk_usage_percent), 2) AS disk_usage_percent,
+8
View File
@@ -16,6 +16,7 @@ from .B01_Dashboard_Repository import (
get_user_company, get_user_company,
join_company, join_company,
list_all_companies, list_all_companies,
list_all_projects,
list_all_users, list_all_users,
list_audit_logs, list_audit_logs,
list_company_members, list_company_members,
@@ -236,3 +237,10 @@ async def system_resources(
): ):
_ = session _ = session
return {"status": "success", **await get_system_resources(days)} return {"status": "success", **await get_system_resources(days)}
@router.get("/admin/projects-all")
async def system_projects(session: dict[str, Any] = Depends(require_system_admin)):
_ = session
return {"status": "success", "projects": await list_all_projects()}
+82 -12
View File
@@ -18,6 +18,7 @@ import {
createCompany, createCompany,
fetchAllCompanies, fetchAllCompanies,
fetchAllJoinRequests, fetchAllJoinRequests,
fetchAllProjects,
fetchAllUsers, fetchAllUsers,
fetchAuditLogs, fetchAuditLogs,
fetchCompanyJoinRequests, fetchCompanyJoinRequests,
@@ -52,6 +53,7 @@ interface DashboardState {
user: DashboardUser; user: DashboardUser;
userProjects: ProjectItem[]; userProjects: ProjectItem[];
companyProjects: ProjectItem[]; companyProjects: ProjectItem[];
allProjects: ProjectItem[];
company: CompanyInfo | null; company: CompanyInfo | null;
members: Member[]; members: Member[];
joinRequests: JoinRequest[]; joinRequests: JoinRequest[];
@@ -70,6 +72,7 @@ export async function renderB01Dashboard(root: HTMLElement): Promise<void> {
user, user,
userProjects: [], userProjects: [],
companyProjects: [], companyProjects: [],
allProjects: [],
company: null, company: null,
members: [], members: [],
joinRequests: [], joinRequests: [],
@@ -90,18 +93,20 @@ export async function renderB01Dashboard(root: HTMLElement): Promise<void> {
async function loadRoleData(state: DashboardState): Promise<void> { async function loadRoleData(state: DashboardState): Promise<void> {
if (state.user.role === "SYSTEM_ADMIN") { if (state.user.role === "SYSTEM_ADMIN") {
const [companies, users, requests, logs, resources] = await Promise.all([ const [companies, users, requests, logs, resources, projects] = await Promise.all([
fetchAllCompanies(), fetchAllCompanies(),
fetchAllUsers(), fetchAllUsers(),
fetchAllJoinRequests(), fetchAllJoinRequests(),
fetchAuditLogs(), fetchAuditLogs(),
fetchSystemResources(), fetchSystemResources(7),
fetchAllProjects(),
]); ]);
state.allCompanies = companies; state.allCompanies = companies;
state.allUsers = users; state.allUsers = users;
state.allJoinRequests = requests; state.allJoinRequests = requests;
state.auditLogs = logs; state.auditLogs = logs;
state.resources = resources; state.resources = resources;
state.allProjects = projects;
return; return;
} }
const [projects, company] = await Promise.all([fetchUserProjects(), fetchUserCompany()]); const [projects, company] = await Promise.all([fetchUserProjects(), fetchUserCompany()]);
@@ -130,8 +135,9 @@ function buildPage(state: DashboardState): HTMLElement {
if (state.user.role === "SYSTEM_ADMIN") { if (state.user.role === "SYSTEM_ADMIN") {
grid.append( grid.append(
section(L("B01_Dashboard_Resources"), buildResourcePanel(state.resources), true), section(L("B01_Dashboard_Resources"), buildResourcePanel(state.resources), true),
section(L("B01_Dashboard_Companies"), companyTable(state.allCompanies)), section(L("B01_Dashboard_Companies"), companyTable(state.allCompanies), true),
section(L("B01_Dashboard_Users"), userTable(state.allUsers)), section(L("B01_Dashboard_Users"), userTable(state.allUsers), true),
section(L("B01_Dashboard_Projects"), projectTable(state.allProjects), true),
section(L("B01_Dashboard_JoinRequests"), joinRequestTable(state.allJoinRequests, true)), section(L("B01_Dashboard_JoinRequests"), joinRequestTable(state.allJoinRequests, true)),
section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true), section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true),
); );
@@ -415,32 +421,96 @@ function buildResourceMetrics(resources: ResourceData | null): HTMLElement {
function formatChartTime(iso: string | null | undefined): string { function formatChartTime(iso: string | null | undefined): string {
if (!iso) return ""; if (!iso) return "";
const d = new Date(iso); try {
if (Number.isNaN(d.getTime())) return ""; const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, "0"); if (Number.isNaN(d.getTime())) return "";
return `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
} catch {
return "";
}
} }
function buildResourceChart(resources: ResourceData | null): HTMLElement { function buildResourceChart(resources: ResourceData | null): HTMLElement {
const history = resources?.history ?? []; const history = resources?.history ?? [];
// 7일간의 고정 타임라인 생성 (4시간 간격, 총 43개 포인트)
const points: { timestamp: Date; cpu: number | null; mem: number | null; disk: number | null }[] = [];
const now = new Date();
// 12시간 단위(정오 또는 자정)로 올림 정렬하여 기준점 계산
const baseDate = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
now.getHours() >= 12 ? 24 : 12,
0,
0,
0
);
for (let i = 0; i <= 42; i++) {
// 7일 전부터 현재 정렬 시점까지 4시간 단위 버킷 생성
const time = new Date(baseDate.getTime() - (42 - i) * 4 * 60 * 60 * 1000);
points.push({ timestamp: time, cpu: null, mem: null, disk: null });
}
// DB에서 불러온 계측 이력 매핑
history.forEach((h) => {
if (!h.timestamp) return;
// DB에서 넘어오는 ISO 문자열을 UTC 기준으로 정확히 매핑하기 위해 Z를 보완하여 파싱
const tsStr = h.timestamp.endsWith("Z") ? h.timestamp : h.timestamp + "Z";
const hTime = new Date(tsStr);
// 가장 가까운 4시간 단위 버킷 매핑 (±2시간 내)
let closestIdx = -1;
let minDiff = Infinity;
points.forEach((p, idx) => {
const diff = Math.abs(p.timestamp.getTime() - hTime.getTime());
if (diff < minDiff && diff <= 2 * 60 * 60 * 1000) {
minDiff = diff;
closestIdx = idx;
}
});
if (closestIdx !== -1) {
const p = points[closestIdx];
p.cpu = h.cpu_usage_percent;
p.mem = h.memory_usage_percent;
p.disk = h.disk_usage_percent;
}
});
// xLabels 생성 (Midnight: MM/DD, 그 외는 빈칸)
const xLabels = points.map((p) => {
const hours = p.timestamp.getHours();
if (hours === 0) {
return `${p.timestamp.getMonth() + 1}/${p.timestamp.getDate()}`;
}
return "";
});
const chart = createLineChart({ const chart = createLineChart({
ariaLabel: L("B01_Dashboard_Resources_ChartAria"), ariaLabel: L("B01_Dashboard_Resources_ChartAria"),
xLabels: history.map((p) => formatChartTime(p.timestamp)), xLabels,
width: 800,
height: 140,
series: [ series: [
{ {
name: L("B01_Dashboard_Metric_Cpu"), name: L("B01_Dashboard_Metric_Cpu"),
colorIndex: 0, colorIndex: 0,
values: history.map((p) => p.cpu_usage_percent), values: points.map((p) => p.cpu),
}, },
{ {
name: L("B01_Dashboard_Metric_Memory"), name: L("B01_Dashboard_Metric_Memory"),
colorIndex: 1, colorIndex: 1,
values: history.map((p) => p.memory_usage_percent), values: points.map((p) => p.mem),
}, },
{ {
name: L("B01_Dashboard_Metric_Disk"), name: L("B01_Dashboard_Metric_Disk"),
colorIndex: 2, colorIndex: 2,
values: history.map((p) => p.disk_usage_percent), values: points.map((p) => p.disk),
}, },
], ],
}); });
+8
View File
@@ -152,6 +152,14 @@
gap: var(--spacing-8); gap: var(--spacing-8);
} }
.b01-dashboard__chart .ui-chart__plot {
height: 140px;
}
.b01-dashboard__chart .ui-chart__svg {
height: 100%;
}
.b01-dashboard__chart-caption { .b01-dashboard__chart-caption {
margin: 0; margin: 0;
color: var(--color-text-secondary); color: var(--color-text-secondary);
+2 -1
View File
@@ -142,6 +142,7 @@ async def require_system_admin(
async def require_company( async def require_company(
session: dict[str, Any] = Depends(verify_session), session: dict[str, Any] = Depends(verify_session),
) -> dict[str, Any]: ) -> dict[str, Any]:
if session["company_id"] is None: if session["company_id"] is None and session["role"] != "SYSTEM_ADMIN":
raise HTTPException(status_code=403, detail="회사 연결이 필요합니다.") raise HTTPException(status_code=403, detail="회사 연결이 필요합니다.")
return session return session
+6 -14
View File
@@ -9,20 +9,12 @@ from config.config_db import init_db_pool, close_db_pool
async def main(): async def main():
await init_db_pool() await init_db_pool()
from config.config_db import get_db_pool from config.config_db import get_db_pool
pool = get_db_pool() from B01_Dashboard.B01_Dashboard_Repository import get_system_resources
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: res = await get_system_resources(30)
try: print("Current:", res["current"])
await cursor.execute("SELECT * FROM users") print("History count:", len(res["history"]))
users = await cursor.fetchall() for h in res["history"][:5]:
print("Users count:", len(users)) print(h)
for u in users:
print(u)
await cursor.execute("SELECT * FROM companies")
companies = await cursor.fetchall()
print("Companies count:", len(companies))
for c in companies:
print(c)
except Exception as e: except Exception as e:
print("Error:", e) print("Error:", e)
await close_db_pool() await close_db_pool()
+37 -11
View File
@@ -311,12 +311,16 @@ export interface LineChartOptions {
yUnit?: string; yUnit?: string;
/** 접근성 설명 */ /** 접근성 설명 */
ariaLabel?: string; ariaLabel?: string;
/** 커스텀 가상 가로폭 (기본: CHART_W = 640) */
width?: number;
/** 커스텀 가상 세로폭 (기본: CHART_H = 200) */
height?: number;
} }
const CHART_W = 640; const CHART_W = 640;
const CHART_H = 200; const CHART_H = 200;
const CHART_PAD = { top: 12, right: 12, bottom: 26, left: 36 }; const CHART_PAD = { top: 12, right: 12, bottom: 26, left: 36 };
const X_TICK_COUNT = 5; // x축에 표기할 라벨 개수 (양끝 포함) const X_TICK_STEP = 3; // x축 라벨 표기 간격 (3개마다 1개 표시)
/** 유효 점들을 Catmull-Rom → 3차 베지어로 변환한 스플라인 path 데이터를 만든다. */ /** 유효 점들을 Catmull-Rom → 3차 베지어로 변환한 스플라인 path 데이터를 만든다. */
function splinePath(points: { x: number; y: number }[]): string { function splinePath(points: { x: number; y: number }[]): string {
@@ -343,6 +347,8 @@ function splinePath(points: { x: number; y: number }[]): string {
/** 시계열 스플라인 차트를 반환. 데이터가 없으면 안내 문구를 담은 빈 상태를 반환. */ /** 시계열 스플라인 차트를 반환. 데이터가 없으면 안내 문구를 담은 빈 상태를 반환. */
export function createLineChart(opts: LineChartOptions): HTMLDivElement { export function createLineChart(opts: LineChartOptions): HTMLDivElement {
const w = opts.width ?? CHART_W;
const h = opts.height ?? CHART_H;
const yMax = opts.yMax ?? 100; const yMax = opts.yMax ?? 100;
const yUnit = opts.yUnit ?? "%"; const yUnit = opts.yUnit ?? "%";
const wrap = el("div", { className: "ui-chart" }); const wrap = el("div", { className: "ui-chart" });
@@ -354,16 +360,18 @@ export function createLineChart(opts: LineChartOptions): HTMLDivElement {
return wrap; return wrap;
} }
const plotW = CHART_W - CHART_PAD.left - CHART_PAD.right; const plotW = w - CHART_PAD.left - CHART_PAD.right;
const plotH = CHART_H - CHART_PAD.top - CHART_PAD.bottom; const plotH = h - CHART_PAD.top - CHART_PAD.bottom;
const xAt = (i: number) => CHART_PAD.left + (plotW * i) / (pointCount - 1); const xAt = (i: number) => CHART_PAD.left + (plotW * i) / (pointCount - 1);
const yAt = (v: number) => CHART_PAD.top + plotH * (1 - Math.min(v, yMax) / yMax); const yAt = (v: number) => CHART_PAD.top + plotH * (1 - Math.min(v, yMax) / yMax);
const svgNs = "http://www.w3.org/2000/svg"; const svgNs = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(svgNs, "svg"); const svg = document.createElementNS(svgNs, "svg");
svg.setAttribute("class", "ui-chart__svg"); svg.setAttribute("class", "ui-chart__svg");
svg.setAttribute("viewBox", `0 0 ${CHART_W} ${CHART_H}`); svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
svg.setAttribute("role", "img"); svg.setAttribute("role", "img");
svg.setAttribute("width", "100%");
svg.setAttribute("height", "100%");
svg.setAttribute("preserveAspectRatio", "none"); svg.setAttribute("preserveAspectRatio", "none");
if (opts.ariaLabel) svg.setAttribute("aria-label", opts.ariaLabel); if (opts.ariaLabel) svg.setAttribute("aria-label", opts.ariaLabel);
@@ -374,7 +382,7 @@ export function createLineChart(opts: LineChartOptions): HTMLDivElement {
const line = document.createElementNS(svgNs, "line"); const line = document.createElementNS(svgNs, "line");
line.setAttribute("class", "ui-chart__grid"); line.setAttribute("class", "ui-chart__grid");
line.setAttribute("x1", String(CHART_PAD.left)); line.setAttribute("x1", String(CHART_PAD.left));
line.setAttribute("x2", String(CHART_W - CHART_PAD.right)); line.setAttribute("x2", String(w - CHART_PAD.right));
line.setAttribute("y1", String(y)); line.setAttribute("y1", String(y));
line.setAttribute("y2", String(y)); line.setAttribute("y2", String(y));
svg.append(line); svg.append(line);
@@ -387,20 +395,18 @@ export function createLineChart(opts: LineChartOptions): HTMLDivElement {
svg.append(tick); svg.append(tick);
} }
// x축 라벨 (양끝 포함 X_TICK_COUNT개를 균등 선택) // x축 라벨 (데이터 포인트 개수만큼, X_TICK_STEP 간격으로 표기)
if (opts.xLabels && opts.xLabels.length > 0) { if (opts.xLabels && opts.xLabels.length > 0) {
const labels = opts.xLabels; const labels = opts.xLabels;
const baseY = CHART_PAD.top + plotH; const baseY = CHART_PAD.top + plotH;
for (let t = 0; t < X_TICK_COUNT; t += 1) { for (let idx = 0; idx < pointCount; idx += X_TICK_STEP) {
const idx = Math.round(((pointCount - 1) * t) / (X_TICK_COUNT - 1));
const label = labels[idx]; const label = labels[idx];
if (label === undefined) continue; if (label === undefined || label === "") continue;
const anchor = t === 0 ? "start" : t === X_TICK_COUNT - 1 ? "end" : "middle";
const tick = document.createElementNS(svgNs, "text"); const tick = document.createElementNS(svgNs, "text");
tick.setAttribute("class", "ui-chart__tick ui-chart__tick--x"); tick.setAttribute("class", "ui-chart__tick ui-chart__tick--x");
tick.setAttribute("x", String(xAt(idx))); tick.setAttribute("x", String(xAt(idx)));
tick.setAttribute("y", String(baseY + 16)); tick.setAttribute("y", String(baseY + 16));
tick.setAttribute("text-anchor", anchor); tick.setAttribute("text-anchor", "middle");
tick.textContent = label; tick.textContent = label;
svg.append(tick); svg.append(tick);
} }
@@ -426,6 +432,17 @@ export function createLineChart(opts: LineChartOptions): HTMLDivElement {
path.setAttribute("class", `ui-chart__line ui-chart__line--c${s.colorIndex ?? 0}`); path.setAttribute("class", `ui-chart__line ui-chart__line--c${s.colorIndex ?? 0}`);
path.setAttribute("d", d.trim()); path.setAttribute("d", d.trim());
svg.append(path); svg.append(path);
// 각 데이터 포인트에 점(circle) 그리기
s.values.forEach((v, i) => {
if (v === null || v === undefined) return;
const circle = document.createElementNS(svgNs, "circle");
circle.setAttribute("class", `ui-chart__point ui-chart__point--c${s.colorIndex ?? 0}`);
circle.setAttribute("cx", String(xAt(i)));
circle.setAttribute("cy", String(yAt(v)));
circle.setAttribute("r", "3");
svg.append(circle);
});
} }
// 범례 (그래프 영역 우상단 오버레이) // 범례 (그래프 영역 우상단 오버레이)
@@ -698,6 +715,15 @@ const BASE_CSS = `
.ui-chart__line--c1 { stroke: var(--color-chart-1); } .ui-chart__line--c1 { stroke: var(--color-chart-1); }
.ui-chart__line--c2 { stroke: var(--color-chart-2); } .ui-chart__line--c2 { stroke: var(--color-chart-2); }
.ui-chart__line--c3 { stroke: var(--color-chart-3); } .ui-chart__line--c3 { stroke: var(--color-chart-3); }
.ui-chart__point {
stroke-width: 1.5;
stroke: var(--color-surface);
}
.ui-chart__point--c0 { fill: var(--color-chart-0); }
.ui-chart__point--c1 { fill: var(--color-chart-1); }
.ui-chart__point--c2 { fill: var(--color-chart-2); }
.ui-chart__point--c3 { fill: var(--color-chart-3); }
.ui-chart__legend { .ui-chart__legend {
position: absolute; position: absolute;
top: var(--spacing-4); top: var(--spacing-4);
+2 -2
View File
@@ -439,8 +439,8 @@ export const ui_locales = {
B01_Dashboard_Reject: ["거절", "Reject"], B01_Dashboard_Reject: ["거절", "Reject"],
B01_Dashboard_Resources: ["리소스 현황", "Resources"], B01_Dashboard_Resources: ["리소스 현황", "Resources"],
B01_Dashboard_Resources_ChartCaption: [ B01_Dashboard_Resources_ChartCaption: [
"최근 30일 리소스 사용률 (2분 간격)", "최근 7일 리소스 사용률 (2분 간격)",
"Resource usage over the last 30 days (2-min interval)", "Resource usage over the last 7 days (2-min interval)",
], ],
B01_Dashboard_Resources_ChartAria: [ B01_Dashboard_Resources_ChartAria: [
"CPU, 메모리, 디스크 사용률 시계열 그래프", "CPU, 메모리, 디스크 사용률 시계열 그래프",