From 8ffa9b097a6500e58c748bc964951f2e6ce82315 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 8 Jul 2026 19:32:16 +0900 Subject: [PATCH] 260707_2 --- .agent/backend.md | 8 +- .agent/code_20260708_b01_dashboard.md | 41 ++++++++++ .agent/frontend.md | 11 ++- .claude/settings.json | 3 +- A00_Common/router.ts | 2 +- B01_Dashboard/B01_Dashboard_Api_Fetch.ts | 6 ++ B01_Dashboard/B01_Dashboard_Repository.py | 32 +++++++- B01_Dashboard/B01_Dashboard_Router.py | 8 ++ B01_Dashboard/B01_Dashboard_UI_Page.ts | 94 ++++++++++++++++++++--- B01_Dashboard/B01_Dashboard_UI_Style.css | 8 ++ common_util/common_util_auth.py | 3 +- scratch_inspect.py | 20 ++--- ui_template/ui_template_elements.ts | 48 +++++++++--- ui_template/ui_template_locale.ts | 4 +- 14 files changed, 238 insertions(+), 50 deletions(-) diff --git a/.agent/backend.md b/.agent/backend.md index b3e61d9..a6e5ed9 100644 --- a/.agent/backend.md +++ b/.agent/backend.md @@ -260,5 +260,11 @@ * **엔드포인트:** `GET /api/dashboard/admin/resources?days=30` (SYSTEM_ADMIN 전용, 범위 1~90일) * **응답:** - `current`: 요청 시점 실시간 계측값 (그래프 미저장) - - `history`: `system_resources` 테이블의 최근 N일 시계열 (그래프 렌더링용) + - `history`: `system_resources` 테이블의 최근 N일 시계열 (그래프 렌더링용, **다운샘플링 적용**) - `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 수준 diff --git a/.agent/code_20260708_b01_dashboard.md b/.agent/code_20260708_b01_dashboard.md index 521d447..7e5417f 100644 --- a/.agent/code_20260708_b01_dashboard.md +++ b/.agent/code_20260708_b01_dashboard.md @@ -157,10 +157,51 @@ - `npx tsc --noEmit` 통과, `ruff format`/`ruff check` 통과, `prettier --write` 변경 없음. - 계측/로그 트림 로직은 스크립트로 실측 확인 (샘플 기록·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 화면은 구현됐지만 실제 브라우저 검증은 수행하지 않았다. - 그래프는 `system_resources` 이력이 쌓여야 선이 그려진다 (앱 최초 구동 직후에는 데이터 2점 미만이면 `—` 빈 상태 표시, 2분 주기로 누적). +- 그래프 갱신은 페이지 로드/새로고침 시점 1회 (자동 폴링 없음). - 로그인/회사 상태별 실제 시나리오 검증은 별도 검증 AI 또는 사용자가 수행해야 한다. - `B01_Dashboard_UI_Page.ts`는 645줄로 700줄 제한 이내지만, 향후 기능 추가 시 컴포넌트 분리를 고려해야 한다. - 이전 import 검증 중 기존 B04 계열 Pydantic `model_*` 필드 경고가 출력됐으나, 이번 B01 구현에서 발생한 경고는 아니다. diff --git a/.agent/frontend.md b/.agent/frontend.md index 7a1faab..8253aa2 100644 --- a/.agent/frontend.md +++ b/.agent/frontend.md @@ -15,11 +15,14 @@ ### 2.1 공통 컴포넌트 목록 (`ui_template_elements.ts`) * **기본:** `createButton`, `createInputField`, `createCard`, `createTag`, `createWorkflowShell` * **오버레이/알림:** `showLoadingOverlay`, `hideLoadingOverlay`, `showToast` -* **라인 차트:** `createLineChart(opts)` — 외부 라이브러리 없이 인라인 SVG로 시계열 렌더링 - - 색상은 `--color-chart-0~3` 테마 변수 사용 (하드코딩 금지) - - `series[].values`에 `null` 포함 시 해당 구간 선 끊김 처리 +* **스플라인 차트:** `createLineChart(opts)` — 외부 라이브러리 없이 인라인 SVG로 시계열 렌더링 + - **곡선:** Catmull-Rom → 3차 베지어 변환으로 스플라인(부드러운 곡선) 렌더 + - **색상:** `--color-chart-0~3` 테마 변수 사용 (하드코딩 금지) + - **범례:** 그래프 영역 우상단에 반투명 오버레이 배치 (`opts.series[].name`) + - **X축:** `opts.xLabels` 전달 시 양끝 포함 5개 라벨 균등 표기 + - **결측:** `series[].values`에 `null` 포함 시 해당 구간 선 끊김 처리 - 데이터 2점 미만이면 빈 상태(`—`) 반환 - - 사용 예: B01_Dashboard 리소스 현황(CPU/메모리/디스크 30일 추이) + - 사용 예: B01_Dashboard 리소스 현황(CPU/메모리/디스크 30일 추이, X축=시간) --- diff --git a/.claude/settings.json b/.claude/settings.json index 4a3b67f..dd7d540 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -10,7 +10,8 @@ "Bash(./venv/Scripts/python.exe -c ' *)", "Bash(npx.cmd tsc *)", "Bash(./venv/Scripts/ruff.exe format *)", - "Bash(./venv/Scripts/ruff.exe check *)" + "Bash(./venv/Scripts/ruff.exe check *)", + "Bash(npx.cmd prettier *)" ] } } diff --git a/A00_Common/router.ts b/A00_Common/router.ts index 8d1267d..d58c7ca 100644 --- a/A00_Common/router.ts +++ b/A00_Common/router.ts @@ -122,7 +122,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise { navigateTo(ROUTES.A06_LOGIN); return; } - if (!user.company_id) { + if (!user.company_id && user.role !== "SYSTEM_ADMIN") { navigateTo(ROUTES.B01_ACCOUNT); return; } diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts index 14832f3..e09326a 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts @@ -207,6 +207,12 @@ export async function fetchCompanyProjects(): Promise { return data.projects; } +export async function fetchAllProjects(): Promise { + const data = await request<{ projects: ProjectItem[] }>("/dashboard/admin/projects-all"); + return data.projects; +} + + export async function fetchAllCompanies(): Promise { const data = await request<{ companies: CompanyInfo[] }>("/dashboard/admin/companies"); return data.companies; diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index b8a5737..f7e3b51 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -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()] +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: if company_id is None: return None @@ -399,10 +412,23 @@ async def get_system_resources(days: int = 30) -> dict[str, Any]: # 다운샘플링: 조회 구간을 최대 TARGET_POINTS개 시간버킷으로 나눠 평균. # 계측 간격(2분)보다 버킷이 작으면 원본 그대로(버킷=간격) 반환. target_points = 300 - bucket_seconds = max(120, (days * 86400) // target_points) await cursor.execute( - """SELECT - FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(timestamp) / %s) * %s) AS timestamp, + "SELECT COUNT(*) AS cnt FROM system_resources WHERE timestamp >= %s", + (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(memory_usage_percent), 2) AS memory_usage_percent, ROUND(AVG(disk_usage_percent), 2) AS disk_usage_percent, diff --git a/B01_Dashboard/B01_Dashboard_Router.py b/B01_Dashboard/B01_Dashboard_Router.py index f1988e0..1fd3823 100644 --- a/B01_Dashboard/B01_Dashboard_Router.py +++ b/B01_Dashboard/B01_Dashboard_Router.py @@ -16,6 +16,7 @@ from .B01_Dashboard_Repository import ( get_user_company, join_company, list_all_companies, + list_all_projects, list_all_users, list_audit_logs, list_company_members, @@ -236,3 +237,10 @@ async def system_resources( ): _ = session 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()} + diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 3672458..c82dbac 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -18,6 +18,7 @@ import { createCompany, fetchAllCompanies, fetchAllJoinRequests, + fetchAllProjects, fetchAllUsers, fetchAuditLogs, fetchCompanyJoinRequests, @@ -52,6 +53,7 @@ interface DashboardState { user: DashboardUser; userProjects: ProjectItem[]; companyProjects: ProjectItem[]; + allProjects: ProjectItem[]; company: CompanyInfo | null; members: Member[]; joinRequests: JoinRequest[]; @@ -70,6 +72,7 @@ export async function renderB01Dashboard(root: HTMLElement): Promise { user, userProjects: [], companyProjects: [], + allProjects: [], company: null, members: [], joinRequests: [], @@ -90,18 +93,20 @@ export async function renderB01Dashboard(root: HTMLElement): Promise { async function loadRoleData(state: DashboardState): Promise { 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(), fetchAllUsers(), fetchAllJoinRequests(), fetchAuditLogs(), - fetchSystemResources(), + fetchSystemResources(7), + fetchAllProjects(), ]); state.allCompanies = companies; state.allUsers = users; state.allJoinRequests = requests; state.auditLogs = logs; state.resources = resources; + state.allProjects = projects; return; } const [projects, company] = await Promise.all([fetchUserProjects(), fetchUserCompany()]); @@ -130,8 +135,9 @@ function buildPage(state: DashboardState): HTMLElement { if (state.user.role === "SYSTEM_ADMIN") { grid.append( section(L("B01_Dashboard_Resources"), buildResourcePanel(state.resources), true), - section(L("B01_Dashboard_Companies"), companyTable(state.allCompanies)), - section(L("B01_Dashboard_Users"), userTable(state.allUsers)), + section(L("B01_Dashboard_Companies"), companyTable(state.allCompanies), true), + 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_AuditLogs"), auditLogTable(state.auditLogs), true), ); @@ -415,32 +421,96 @@ function buildResourceMetrics(resources: ResourceData | null): HTMLElement { function formatChartTime(iso: string | null | undefined): string { if (!iso) return ""; - const d = new Date(iso); - if (Number.isNaN(d.getTime())) return ""; - const pad = (n: number) => String(n).padStart(2, "0"); - return `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; + try { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + 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 { 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({ ariaLabel: L("B01_Dashboard_Resources_ChartAria"), - xLabels: history.map((p) => formatChartTime(p.timestamp)), + xLabels, + width: 800, + height: 140, series: [ { name: L("B01_Dashboard_Metric_Cpu"), colorIndex: 0, - values: history.map((p) => p.cpu_usage_percent), + values: points.map((p) => p.cpu), }, { name: L("B01_Dashboard_Metric_Memory"), colorIndex: 1, - values: history.map((p) => p.memory_usage_percent), + values: points.map((p) => p.mem), }, { name: L("B01_Dashboard_Metric_Disk"), colorIndex: 2, - values: history.map((p) => p.disk_usage_percent), + values: points.map((p) => p.disk), }, ], }); diff --git a/B01_Dashboard/B01_Dashboard_UI_Style.css b/B01_Dashboard/B01_Dashboard_UI_Style.css index 07e61de..a8f7341 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style.css @@ -152,6 +152,14 @@ gap: var(--spacing-8); } +.b01-dashboard__chart .ui-chart__plot { + height: 140px; +} + +.b01-dashboard__chart .ui-chart__svg { + height: 100%; +} + .b01-dashboard__chart-caption { margin: 0; color: var(--color-text-secondary); diff --git a/common_util/common_util_auth.py b/common_util/common_util_auth.py index 70c701b..c3d79f2 100644 --- a/common_util/common_util_auth.py +++ b/common_util/common_util_auth.py @@ -142,6 +142,7 @@ async def require_system_admin( async def require_company( session: dict[str, Any] = Depends(verify_session), ) -> 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="회사 연결이 필요합니다.") return session + diff --git a/scratch_inspect.py b/scratch_inspect.py index 51292cf..24c1916 100644 --- a/scratch_inspect.py +++ b/scratch_inspect.py @@ -9,20 +9,12 @@ from config.config_db import init_db_pool, close_db_pool async def main(): await init_db_pool() from config.config_db import get_db_pool - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: - try: - await cursor.execute("SELECT * FROM users") - users = await cursor.fetchall() - print("Users count:", len(users)) - 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) + from B01_Dashboard.B01_Dashboard_Repository import get_system_resources + res = await get_system_resources(30) + print("Current:", res["current"]) + print("History count:", len(res["history"])) + for h in res["history"][:5]: + print(h) except Exception as e: print("Error:", e) await close_db_pool() diff --git a/ui_template/ui_template_elements.ts b/ui_template/ui_template_elements.ts index 62d7e92..912279d 100644 --- a/ui_template/ui_template_elements.ts +++ b/ui_template/ui_template_elements.ts @@ -311,12 +311,16 @@ export interface LineChartOptions { yUnit?: string; /** 접근성 설명 */ ariaLabel?: string; + /** 커스텀 가상 가로폭 (기본: CHART_W = 640) */ + width?: number; + /** 커스텀 가상 세로폭 (기본: CHART_H = 200) */ + height?: number; } const CHART_W = 640; const CHART_H = 200; 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 데이터를 만든다. */ 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 { + const w = opts.width ?? CHART_W; + const h = opts.height ?? CHART_H; const yMax = opts.yMax ?? 100; const yUnit = opts.yUnit ?? "%"; const wrap = el("div", { className: "ui-chart" }); @@ -354,16 +360,18 @@ export function createLineChart(opts: LineChartOptions): HTMLDivElement { return wrap; } - const plotW = CHART_W - CHART_PAD.left - CHART_PAD.right; - const plotH = CHART_H - CHART_PAD.top - CHART_PAD.bottom; + const plotW = w - CHART_PAD.left - CHART_PAD.right; + const plotH = h - CHART_PAD.top - CHART_PAD.bottom; 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 svgNs = "http://www.w3.org/2000/svg"; const svg = document.createElementNS(svgNs, "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("width", "100%"); + svg.setAttribute("height", "100%"); svg.setAttribute("preserveAspectRatio", "none"); 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"); line.setAttribute("class", "ui-chart__grid"); 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("y2", String(y)); svg.append(line); @@ -387,20 +395,18 @@ export function createLineChart(opts: LineChartOptions): HTMLDivElement { svg.append(tick); } - // x축 라벨 (양끝 포함 X_TICK_COUNT개를 균등 선택) + // x축 라벨 (데이터 포인트 개수만큼, X_TICK_STEP 간격으로 표기) if (opts.xLabels && opts.xLabels.length > 0) { const labels = opts.xLabels; const baseY = CHART_PAD.top + plotH; - for (let t = 0; t < X_TICK_COUNT; t += 1) { - const idx = Math.round(((pointCount - 1) * t) / (X_TICK_COUNT - 1)); + for (let idx = 0; idx < pointCount; idx += X_TICK_STEP) { const label = labels[idx]; - if (label === undefined) continue; - const anchor = t === 0 ? "start" : t === X_TICK_COUNT - 1 ? "end" : "middle"; + if (label === undefined || label === "") continue; const tick = document.createElementNS(svgNs, "text"); tick.setAttribute("class", "ui-chart__tick ui-chart__tick--x"); tick.setAttribute("x", String(xAt(idx))); tick.setAttribute("y", String(baseY + 16)); - tick.setAttribute("text-anchor", anchor); + tick.setAttribute("text-anchor", "middle"); tick.textContent = label; 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("d", d.trim()); 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--c2 { stroke: var(--color-chart-2); } .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 { position: absolute; top: var(--spacing-4); diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index 768cb88..f604155 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -439,8 +439,8 @@ export const ui_locales = { B01_Dashboard_Reject: ["거절", "Reject"], B01_Dashboard_Resources: ["리소스 현황", "Resources"], B01_Dashboard_Resources_ChartCaption: [ - "최근 30일 리소스 사용률 (2분 간격)", - "Resource usage over the last 30 days (2-min interval)", + "최근 7일 리소스 사용률 (2분 간격)", + "Resource usage over the last 7 days (2-min interval)", ], B01_Dashboard_Resources_ChartAria: [ "CPU, 메모리, 디스크 사용률 시계열 그래프",