260707_1_대시보드정리 1차
This commit is contained in:
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user