260707_2
This commit is contained in:
+7
-1
@@ -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 수준
|
||||
|
||||
@@ -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 구현에서 발생한 경고는 아니다.
|
||||
|
||||
+7
-4
@@ -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축=시간)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
|
||||
navigateTo(ROUTES.A06_LOGIN);
|
||||
return;
|
||||
}
|
||||
if (!user.company_id) {
|
||||
if (!user.company_id && user.role !== "SYSTEM_ADMIN") {
|
||||
navigateTo(ROUTES.B01_ACCOUNT);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -207,6 +207,12 @@ export async function fetchCompanyProjects(): Promise<ProjectItem[]> {
|
||||
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[]> {
|
||||
const data = await request<{ companies: CompanyInfo[] }>("/dashboard/admin/companies");
|
||||
return data.companies;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
user,
|
||||
userProjects: [],
|
||||
companyProjects: [],
|
||||
allProjects: [],
|
||||
company: null,
|
||||
members: [],
|
||||
joinRequests: [],
|
||||
@@ -90,18 +93,20 @@ export async function renderB01Dashboard(root: HTMLElement): Promise<void> {
|
||||
|
||||
async function loadRoleData(state: DashboardState): Promise<void> {
|
||||
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 "";
|
||||
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),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+6
-14
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, 메모리, 디스크 사용률 시계열 그래프",
|
||||
|
||||
Reference in New Issue
Block a user