260707_1_대시보드정리 1차

This commit is contained in:
2026-07-08 19:04:20 +09:00
parent 3485eeb096
commit 9c40dec070
26 changed files with 1264 additions and 305 deletions
+38 -12
View File
@@ -67,17 +67,29 @@ export interface AuditLog {
timestamp: string;
}
export interface ResourceSnapshot {
cpu_usage_percent: number | null;
memory_usage_percent: number | null;
disk_usage_percent: number | null;
active_user_count: number;
active_project_count: number;
total_storage_mb: number;
}
export interface ResourceHistoryPoint {
timestamp: string;
cpu_usage_percent: number | null;
memory_usage_percent: number | null;
disk_usage_percent: number | null;
active_user_count: number;
active_project_count: number;
total_storage_mb: number;
}
export interface ResourceData {
current: {
cpu_usage_percent: number | null;
memory_usage_percent: number | null;
disk_usage_percent: number | null;
active_user_count: number;
active_project_count: number;
total_storage_mb: number;
};
history: unknown[];
stats: ResourceData["current"];
current: ResourceSnapshot;
history: ResourceHistoryPoint[];
stats: ResourceSnapshot;
}
export interface UpdateUserRequest {
@@ -94,6 +106,13 @@ export interface CreateCompanyRequest {
business_owner?: string | null;
}
export interface ChangePasswordRequest {
current_password: string;
new_password: string;
new_password_confirm: string;
logout_all: boolean;
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
credentials: "include",
@@ -120,6 +139,13 @@ export async function updateUserProfile(payload: UpdateUserRequest): Promise<Das
return data.user;
}
export function changePassword(payload: ChangePasswordRequest): Promise<unknown> {
return request("/auth/password", {
method: "POST",
body: body(payload),
});
}
export async function fetchUserProjects(): Promise<ProjectItem[]> {
const data = await request<{ projects: ProjectItem[] }>("/dashboard/user/projects");
return data.projects;
@@ -219,6 +245,6 @@ export async function fetchAuditLogs(): Promise<AuditLog[]> {
return data.items;
}
export async function fetchSystemResources(period = "24h"): Promise<ResourceData> {
return request<ResourceData>(`/dashboard/admin/resources?period=${encodeURIComponent(period)}`);
export async function fetchSystemResources(days = 30): Promise<ResourceData> {
return request<ResourceData>(`/dashboard/admin/resources?days=${encodeURIComponent(days)}`);
}
+23 -7
View File
@@ -7,6 +7,7 @@ from datetime import datetime, timedelta
from typing import Any
import aiomysql
import psutil
from config.config_db import get_db_pool
@@ -372,9 +373,11 @@ async def list_audit_logs(limit: int, offset: int) -> dict[str, Any]:
return {"total": total, "items": list(await cursor.fetchall())}
async def get_system_resources() -> dict[str, Any]:
async def get_system_resources(days: int = 30) -> dict[str, Any]:
total, used, _free = shutil.disk_usage(".")
disk_percent = used / total * 100 if total else 0
cpu_percent = psutil.cpu_percent(interval=0.1)
memory_percent = psutil.virtual_memory().percent
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
@@ -385,18 +388,31 @@ async def get_system_resources() -> dict[str, Any]:
)
stats = await cursor.fetchone()
current = {
"cpu_usage_percent": None,
"memory_usage_percent": None,
"cpu_usage_percent": round(cpu_percent, 2),
"memory_usage_percent": round(memory_percent, 2),
"disk_usage_percent": round(disk_percent, 2),
"active_user_count": stats["active_user_count"],
"active_project_count": stats["active_project_count"],
"total_storage_mb": round(used / 1024 / 1024, 2),
}
since = datetime.utcnow() - timedelta(days=days)
# 다운샘플링: 조회 구간을 최대 TARGET_POINTS개 시간버킷으로 나눠 평균.
# 계측 간격(2분)보다 버킷이 작으면 원본 그대로(버킷=간격) 반환.
target_points = 300
bucket_seconds = max(120, (days * 86400) // target_points)
await cursor.execute(
"""SELECT timestamp, cpu_usage_percent, memory_usage_percent, disk_usage_percent,
active_user_count, active_project_count, total_storage_mb
"""SELECT
FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(timestamp) / %s) * %s) 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,
MAX(active_user_count) AS active_user_count,
MAX(active_project_count) AS active_project_count,
ROUND(AVG(total_storage_mb), 2) AS total_storage_mb
FROM system_resources
WHERE timestamp >= %s ORDER BY timestamp""",
(datetime.utcnow() - timedelta(hours=24),),
WHERE timestamp >= %s
GROUP BY FLOOR(UNIX_TIMESTAMP(timestamp) / %s)
ORDER BY timestamp""",
(bucket_seconds, bucket_seconds, since, bucket_seconds),
)
return {"current": current, "history": list(await cursor.fetchall()), "stats": current}
+2 -3
View File
@@ -231,9 +231,8 @@ async def system_audit_logs(
@router.get("/admin/resources")
async def system_resources(
period: str = Query("24h"),
days: int = Query(30, ge=1, le=90),
session: dict[str, Any] = Depends(require_system_admin),
):
_ = period
_ = session
return {"status": "success", **await get_system_resources()}
return {"status": "success", **await get_system_resources(days)}
+118 -2
View File
@@ -6,6 +6,7 @@ import {
createButton,
createCard,
createInputField,
createLineChart,
createTag,
hideLoadingOverlay,
showLoadingOverlay,
@@ -13,6 +14,7 @@ import {
} from "@ui/ui_template_elements";
import {
addCompanyMember,
changePassword,
createCompany,
fetchAllCompanies,
fetchAllJoinRequests,
@@ -40,6 +42,8 @@ import {
} from "./B01_Dashboard_Api_Fetch";
import "./B01_Dashboard_UI_Style.css";
const PASSWORD_MIN_LENGTH = 8;
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
@@ -125,7 +129,7 @@ function buildPage(state: DashboardState): HTMLElement {
if (state.user.role === "SYSTEM_ADMIN") {
grid.append(
section(L("B01_Dashboard_Resources"), buildResourceMetrics(state.resources), true),
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_JoinRequests"), joinRequestTable(state.allJoinRequests, true)),
@@ -159,7 +163,10 @@ function buildPage(state: DashboardState): HTMLElement {
);
}
}
grid.append(section(L("B01_Dashboard_Profile"), buildProfileForm(state.user), true));
grid.append(
section(L("B01_Dashboard_Profile"), buildProfileForm(state.user)),
section(L("B01_Account_Section_Security"), buildSecurityForm()),
);
page.append(grid);
return page;
}
@@ -373,6 +380,13 @@ function actionPair(approve: () => void, reject: () => void): HTMLElement {
return row;
}
function buildResourcePanel(resources: ResourceData | null): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b01-dashboard__resource-panel";
wrap.append(buildResourceMetrics(resources), buildResourceChart(resources));
return wrap;
}
function buildResourceMetrics(resources: ResourceData | null): HTMLElement {
const values = resources?.current;
const grid = document.createElement("div");
@@ -399,6 +413,46 @@ function buildResourceMetrics(resources: ResourceData | null): HTMLElement {
return grid;
}
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())}`;
}
function buildResourceChart(resources: ResourceData | null): HTMLElement {
const history = resources?.history ?? [];
const chart = createLineChart({
ariaLabel: L("B01_Dashboard_Resources_ChartAria"),
xLabels: history.map((p) => formatChartTime(p.timestamp)),
series: [
{
name: L("B01_Dashboard_Metric_Cpu"),
colorIndex: 0,
values: history.map((p) => p.cpu_usage_percent),
},
{
name: L("B01_Dashboard_Metric_Memory"),
colorIndex: 1,
values: history.map((p) => p.memory_usage_percent),
},
{
name: L("B01_Dashboard_Metric_Disk"),
colorIndex: 2,
values: history.map((p) => p.disk_usage_percent),
},
],
});
const caption = document.createElement("p");
caption.className = "b01-dashboard__chart-caption";
caption.textContent = L("B01_Dashboard_Resources_ChartCaption");
const box = document.createElement("div");
box.className = "b01-dashboard__chart";
box.append(caption, chart);
return box;
}
function companyTable(companies: CompanyInfo[]): HTMLElement {
return table(
[
@@ -478,6 +532,68 @@ function buildProfileForm(user: DashboardUser): HTMLElement {
return wrap;
}
function buildSecurityForm(): HTMLElement {
const currentPassword = createInputField({
label: L("B01_Account_Field_CurrentPw"),
type: "password",
required: true,
});
const newPassword = createInputField({
label: L("B01_Account_Field_NewPw"),
type: "password",
required: true,
});
const confirmPassword = createInputField({
label: L("B01_Account_Field_ConfirmPw"),
type: "password",
required: true,
});
const grid = document.createElement("div");
grid.className = "b01-dashboard__form-grid";
grid.append(currentPassword.root, newPassword.root, confirmPassword.root);
const save = createButton({
label: L("B01_Account_Save_Password"),
variant: "ghost",
onClick: async function onB01_Password_Save_Click() {
currentPassword.setError();
newPassword.setError();
confirmPassword.setError();
const currentValue = currentPassword.input.value;
const nextValue = newPassword.input.value;
const confirmValue = confirmPassword.input.value;
if (isBlank(currentValue) || isBlank(nextValue) || isBlank(confirmValue)) {
currentPassword.setError(isBlank(currentValue) ? L("Common_Msg_RequiredField") : undefined);
newPassword.setError(isBlank(nextValue) ? L("Common_Msg_RequiredField") : undefined);
confirmPassword.setError(isBlank(confirmValue) ? L("Common_Msg_RequiredField") : undefined);
return;
}
if (nextValue.length < PASSWORD_MIN_LENGTH) {
newPassword.setError(L("B01_Account_Error_PwLength"));
return;
}
if (nextValue !== confirmValue) {
confirmPassword.setError(L("B01_Account_Error_PwMismatch"));
return;
}
await runRequest(() =>
changePassword({
current_password: currentValue,
new_password: nextValue,
new_password_confirm: confirmValue,
logout_all: false,
}),
);
currentPassword.input.value = "";
newPassword.input.value = "";
confirmPassword.input.value = "";
},
});
const wrap = document.createElement("div");
wrap.append(grid, save);
return wrap;
}
function openCreateCompanyModal(): void {
const name = createInputField({ label: L("B01_Dashboard_Table_Company"), required: true });
const number = createInputField({
+35 -9
View File
@@ -105,30 +105,57 @@
.b01-dashboard__metric-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--spacing-16);
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--spacing-8);
}
.b01-dashboard__metric {
padding: var(--spacing-16);
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--spacing-8);
padding: var(--spacing-8) var(--spacing-16);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface);
}
.b01-dashboard__metric-label {
display: block;
color: var(--color-text-secondary);
font-size: var(--text-caption);
white-space: nowrap;
}
.b01-dashboard__metric-value {
display: block;
margin-top: var(--spacing-8);
color: var(--color-text);
font-size: var(--text-subheading);
font-size: var(--text-body);
font-family: var(--font-display);
line-height: 1;
white-space: nowrap;
}
@media (max-width: 900px) {
.b01-dashboard__metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.b01-dashboard__resource-panel {
display: flex;
flex-direction: column;
gap: var(--spacing-24);
}
.b01-dashboard__chart {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.b01-dashboard__chart-caption {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.b01-dashboard__form-grid {
@@ -174,8 +201,7 @@
margin-top: var(--spacing-24);
}
.b01-dashboard__form-grid,
.b01-dashboard__metric-grid {
.b01-dashboard__form-grid {
grid-template-columns: 1fr;
}
}