From 0c601934a0b64b73a959ab06e83155e8319d8235 Mon Sep 17 00:00:00 2001 From: wangjiming Date: Mon, 3 Aug 2026 16:20:21 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=89=8D=E7=AB=AF=E7=9C=8B?= =?UTF-8?q?=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 39 +++++- backend/_check_sessions.py | 10 ++ backend/app/api/v1/endpoints/platform.py | 37 +++-- backend/app/db/platform_store.py | 6 +- frontend/src/App.vue | 4 +- frontend/src/api/modules/project.ts | 3 + frontend/src/api/modules/system.ts | 4 + frontend/src/api/modules/tenant.ts | 5 +- frontend/src/api/request.ts | 2 +- frontend/src/components/AclDialog.vue | 17 ++- frontend/src/components/AppSidebar.vue | 4 +- frontend/src/router/index.ts | 2 +- frontend/src/stores/auth.ts | 27 ++-- frontend/src/types/index.ts | 1 + .../src/views/dashboard/DashboardView.vue | 117 +++++++++------- .../src/views/projects/ProjectListView.vue | 50 +++++-- .../src/views/tenants/TenantDetailView.vue | 85 +++++++----- frontend/src/views/tenants/TenantListView.vue | 130 +++++++++++++----- 18 files changed, 356 insertions(+), 187 deletions(-) create mode 100644 backend/_check_sessions.py diff --git a/README.md b/README.md index 0df0649..1e02c54 100644 --- a/README.md +++ b/README.md @@ -134,12 +134,45 @@ npm run dev ## 算力服务启动 +算力服务是一个 FastAPI 应用,同时承载 Compute API(模型训练/推理/GPU 管理)和 File Gateway(文件上传下载)路由。Docker 部署时对外暴露两个端口(19100 和 19101)均指向同一服务,方便应用平台分别配置 `api_base_url` 和 `file_gateway_url`。本地开发只需启动一个进程。 + +### 方式一:Docker 启动(推荐) + ```bash -cd compute -uvicorn api.main:app --reload --port 19100 +cd docker/compute +cp .env.example .env +docker compose up -d ``` -默认 `COMPUTE_MODE=real`。真实 GPU 接入时,在每台算力服务器上部署 Compute API、Agent、File Gateway 和 LLaMA-Factory,应用平台通过 `compute_nodes.api_base_url` 和 `compute_nodes.file_gateway_url` 主动轮询。仅在隔离联调环境可显式设置 `COMPUTE_MODE=simulator` 或 `COMPUTE_EXECUTION_MODE=simulator`。 +### 方式二:本地开发启动 + +**Windows (cmd):** + +```cmd +cd /d E:\yg_ft\compute +set PYTHONPATH=E:\yg_ft +.\.venv\Scripts\python.exe -m uvicorn api.main:app --reload --port 19100 +``` + +> `PYTHONPATH=E:\yg_ft` 是必需的,因为代码使用 `from compute.agent...` 绝对导入。 + +**Linux / macOS:** + +```bash +cd compute +PYTHONPATH=.. uvicorn api.main:app --reload --port 19100 +``` + +### 环境变量说明 + +| 变量 | 默认值 | 说明 | +|---|---|---| +| `COMPUTE_MODE` | `real` | `real` / `simulator`,仅隔离联调用 simulator | +| `COMPUTE_EXECUTION_MODE` | `real` | 训练执行模式 | +| `COMPUTE_SERVICE_TOKEN` | `change_me` | 服务间认证 token | +| `MODELTF_ROUTE_PREFIX` | `/modelTF` | API 路由前缀 | + +应用平台通过数据库 `compute_nodes` 表中的 `api_base_url` 和 `file_gateway_url` 主动轮询算力节点状态。 ## 日志 diff --git a/backend/_check_sessions.py b/backend/_check_sessions.py new file mode 100644 index 0000000..bcc292b --- /dev/null +++ b/backend/_check_sessions.py @@ -0,0 +1,10 @@ +from app.db.platform_store import get_platform_store + +store = get_platform_store() +with store.connect() as conn: + rows = conn.execute( + "SELECT id, user_id, login_at, logout_at, duration_seconds FROM sessions ORDER BY login_at DESC LIMIT 10" + ).fetchall() + print(f"sessions count: {len(rows)}") + for r in rows: + print(f" user={r['user_id'][:25]}... login={r['login_at']} logout={r['logout_at']} dur={r['duration_seconds']}") \ No newline at end of file diff --git a/backend/app/api/v1/endpoints/platform.py b/backend/app/api/v1/endpoints/platform.py index 40b0e9e..88ae3fa 100644 --- a/backend/app/api/v1/endpoints/platform.py +++ b/backend/app/api/v1/endpoints/platform.py @@ -255,10 +255,21 @@ async def _fine_tune_preflight_with_job_payload( @router.post("/login") async def login(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: - user = get_platform_store().login(payload.get("username", ""), payload.get("password", "")) + store = get_platform_store() + user = store.login(payload.get("username", ""), payload.get("password", "")) if not user: raise fail(401, "invalid username or password") - return ok({"token": f"platform-token-{user['id']}", "user": user}) + sess = store.create_session(user["id"]) + return ok({"token": f"platform-token-{user['id']}", "user": user, "session_id": sess["session_id"]}) + + +@router.post("/logout") +async def logout(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: + store = get_platform_store() + session_id = payload.get("session_id", "") + if session_id: + store.finish_session(session_id) + return ok(None) @router.get("/me") @@ -388,37 +399,23 @@ async def dashboard_stats() -> dict[str, Any]: for t in tasks[:8] ] - # 用户操作分布:统计平台全部操作(含治理模块) + # 用户操作分布:仅统计 模型推理 / 模型训练 / 模型评测 / 数据处理 四类 MODULE_LABELS = [ ("data-process", "数据处理"), ("data_process", "数据处理"), - ("dataset", "数据集管理"), + ("dataset", "数据处理"), ("fine-tune", "模型训练"), ("fine_tune", "模型训练"), ("model-eval", "模型评测"), ("eval", "模型评测"), ("model-inference", "模型推理"), ("inference", "模型推理"), - ("model-manage", "模型管理"), - ("model", "模型管理"), - ("trained", "模型管理"), - # 治理模块操作 - ("tenant", "租户与项目"), - ("project", "租户与项目"), - ("approval", "租户与项目"), - ("acl", "租户与项目"), - ("user", "用户管理"), - ("role", "用户管理"), ] OP_ORDER = [ - "数据集管理", "数据处理", "模型训练", "模型评测", "模型推理", - "模型管理", - "租户与项目", - "用户管理", ] def _op_module(action: str) -> str | None: @@ -451,8 +448,8 @@ async def dashboard_stats() -> dict[str, Any]: for u in recent ] - # 登录时长排行(本月) - login_duration_rank = store.login_duration_rank() + # 登录时长排行(本月),只取 top 5 + login_duration_rank = store.login_duration_rank(limit=5) return ok( { diff --git a/backend/app/db/platform_store.py b/backend/app/db/platform_store.py index 1b21f67..a9f67ca 100644 --- a/backend/app/db/platform_store.py +++ b/backend/app/db/platform_store.py @@ -297,9 +297,9 @@ class PlatformStore: # TCP keepalive 让操作系统持续保活连接,抵抗远程库空闲静默断连。 pool_kwargs = { "keepalives": 1, - "keepalives_idle": 30, - "keepalives_interval": 10, - "keepalives_count": 5, + "keepalives_idle": 10, + "keepalives_interval": 5, + "keepalives_count": 3, } self._pool = ConnectionPool( conninfo=self.database_url, diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 21e861a..4926023 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -18,12 +18,12 @@ const auth = useAuthStore() */ let hiddenAt = 0 -function handleVisibility() { +async function handleVisibility() { if (document.hidden) { hiddenAt = Date.now() } else { if (hiddenAt > 0 && Date.now() - hiddenAt >= SESSION_TIMEOUT) { - auth.logout() + await auth.logout() ElMessage.warning('登录已过期,请重新登录') router.push('/login') } diff --git a/frontend/src/api/modules/project.ts b/frontend/src/api/modules/project.ts index 503e068..1137ff0 100644 --- a/frontend/src/api/modules/project.ts +++ b/frontend/src/api/modules/project.ts @@ -53,3 +53,6 @@ export const updateProjectMember = (id: string, userId: string, role: string) => /** 移除成员 */ export const removeProjectMember = (id: string, userId: string) => del(`/projects/${id}/members/${userId}`) + +/** 删除项目 */ +export const deleteProject = (id: string) => del(`/projects/${id}`) diff --git a/frontend/src/api/modules/system.ts b/frontend/src/api/modules/system.ts index 0342a6a..f0f355c 100644 --- a/frontend/src/api/modules/system.ts +++ b/frontend/src/api/modules/system.ts @@ -18,6 +18,10 @@ export const getHealth = () => get('/health') export const login = (username: string, password: string) => post('/login', { username, password }) +/** 登出 */ +export const logout = (sessionId?: string) => + post('/logout', { session_id: sessionId || '' }) + /** 用户列表 */ export const getUsers = () => get('/users') diff --git a/frontend/src/api/modules/tenant.ts b/frontend/src/api/modules/tenant.ts index 637d302..630cc61 100644 --- a/frontend/src/api/modules/tenant.ts +++ b/frontend/src/api/modules/tenant.ts @@ -1,4 +1,4 @@ -import { get, post, put } from '../request' +import { del, get, post, put } from '../request' export interface Tenant { id: string @@ -25,6 +25,9 @@ export const createTenant = (payload: Partial) => export const updateTenant = (id: string, payload: Partial) => put(`/tenants/${id}`, payload) +/** 删除租户 */ +export const deleteTenant = (id: string) => del(`/tenants/${id}`) + /** 设置租户配额 */ export const setTenantQuota = (id: string, quota: Record) => put(`/tenants/${id}/quota`, { quota }) diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts index e778400..dcd54d6 100644 --- a/frontend/src/api/request.ts +++ b/frontend/src/api/request.ts @@ -14,7 +14,7 @@ export interface ApiResult { const service: AxiosInstance = axios.create({ // Use a relative path; Vite proxies /modelTF to http://localhost:17861 in local development. baseURL: '/modelTF', - timeout: 30000, + timeout: 120000, }) /** diff --git a/frontend/src/components/AclDialog.vue b/frontend/src/components/AclDialog.vue index 5d83286..f3880c0 100644 --- a/frontend/src/components/AclDialog.vue +++ b/frontend/src/components/AclDialog.vue @@ -2,6 +2,7 @@ import { computed, ref, watch } from 'vue' import { ElMessage } from 'element-plus' import { getAcl, setAcl, type AclEntry } from '@/api/modules/acl' +import { getUsers, type SystemUser } from '@/api/modules/system' const props = defineProps<{ modelValue: boolean @@ -15,13 +16,20 @@ const visible = computed({ set: (v) => emit('update:modelValue', v), }) const entries = ref([]) +const users = ref([]) const loading = ref(false) const ALL_PERMS = ['read', 'write', 'execute', 'download', 'delete', 'share'] +const PROJECT_ROLES = ['member', 'admin', 'viewer'] async function load() { loading.value = true try { - entries.value = await getAcl(props.resourceType, props.resourceId) + const [acl, us] = await Promise.all([ + getAcl(props.resourceType, props.resourceId), + getUsers().catch(() => [] as SystemUser[]), + ]) + entries.value = acl + users.value = us } finally { loading.value = false } @@ -53,7 +61,12 @@ async function save() { - + + + + + + {{ p }} diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue index 45e7a96..4e74f23 100644 --- a/frontend/src/components/AppSidebar.vue +++ b/frontend/src/components/AppSidebar.vue @@ -132,8 +132,8 @@ async function handleSelect(key: string) { } } -function handleLogout() { - auth.logout() +async function handleLogout() { + await auth.logout() router.push('/login') } diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 2cd82d7..eaea4de 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -377,7 +377,7 @@ router.beforeEach((to, _from, next) => { } if (!auth.isLoggedIn) { - auth.logout() + auth.logout() // fire-and-forget,无需阻塞跳转 next({ name: 'login' }) return } diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 3fcd9bf..8d89cf9 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -1,9 +1,10 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' -import { login as loginApi } from '@/api/modules/system' +import { login as loginApi, logout as logoutApi } from '@/api/modules/system' import type { PermissionCode, SystemUser } from '@/types' const USER_STORAGE_KEY = 'currentUser' +const SESSION_STORAGE_KEY = 'sessionId' const allPermissions: PermissionCode[] = [ 'dashboard', @@ -29,20 +30,6 @@ function restoreUser(): SystemUser | null { localStorage.removeItem(USER_STORAGE_KEY) } } - - // 兼容改造前已经登录的 admin 会话。 - if (localStorage.getItem('username') === 'admin') { - return { - id: 'USR-0001', - username: 'admin', - display_name: '系统管理员', - role: 'admin', - status: 'active', - permissions: allPermissions, - create_time: '2026-01-01T08:00:00+08:00', - protected: true, - } - } return null } @@ -69,6 +56,9 @@ export const useAuthStore = defineStore('auth', () => { currentUser.value = response.user localStorage.setItem('username', response.user.username) localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(response.user)) + if (response.session_id) { + localStorage.setItem(SESSION_STORAGE_KEY, response.session_id) + } } /** 检查当前账号是否拥有指定模块权限。 */ @@ -78,10 +68,15 @@ export const useAuthStore = defineStore('auth', () => { } /** 退出 */ - function logout() { + async function logout() { + const sessionId = localStorage.getItem(SESSION_STORAGE_KEY) + if (sessionId) { + try { await logoutApi(sessionId) } catch { /* 静默 */ } + } currentUser.value = null localStorage.removeItem('username') localStorage.removeItem(USER_STORAGE_KEY) + localStorage.removeItem(SESSION_STORAGE_KEY) } return { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 816ce84..9e1f446 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -446,6 +446,7 @@ export interface SystemUser { export interface LoginResponse { token: string user: SystemUser + session_id?: string } export interface CreateUserPayload { diff --git a/frontend/src/views/dashboard/DashboardView.vue b/frontend/src/views/dashboard/DashboardView.vue index 76a660b..dfdbf3c 100644 --- a/frontend/src/views/dashboard/DashboardView.vue +++ b/frontend/src/views/dashboard/DashboardView.vue @@ -179,20 +179,30 @@ const chartOption = computed(() => ({ ], })) -// 模块固定配色,保证每个模块颜色不同 -const OPERATION_COLORS = ['#4f46e5', '#10b981', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6', '#ef4444', '#14b8a6'] +// 模块固定配色,按顺序循环分配颜色(与后端 OP_ORDER 一致:数据处理/模型训练/模型评测/模型推理) +const OPERATION_COLORS = ['#4f46e5', '#10b981', '#f59e0b', '#3b82f6'] const operationChartOption = computed(() => { const items = operationDistribution.value const total = items.reduce((s, d) => s + (d.value || 0), 0) - // 完全没有操作数据时,用等分灰色占位扇区,保证 6 个模块都可见 - const data = - total > 0 - ? items.map((d) => ({ value: d.value || 0, name: d.name })) - : items.map((d) => ({ value: 1, name: d.name, itemStyle: { color: '#e2e8f0' } })) + // 按数据项顺序显式分配颜色,避免依赖 name 匹配或全局 color 数组; + // value=0 的项给一个极小值(0.001)让扇区可见,从而显示各自颜色, + // 但占比几乎为 0 不影响有数据项的百分比展示。 + const data = items.map((d, idx) => { + const raw = d.value || 0 + return { + value: total > 0 ? (raw > 0 ? raw : 0.001) : 1, + name: d.name, + itemStyle: { + color: OPERATION_COLORS[idx % OPERATION_COLORS.length] || '#94a3b8', + borderRadius: 6, + borderColor: '#fff', + borderWidth: 2, + }, + } + }) return { animationDuration: 500, tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' }, - color: OPERATION_COLORS, legend: { type: 'scroll', bottom: 0, @@ -207,11 +217,6 @@ const operationChartOption = computed(() => { radius: ['38%', '60%'], center: ['50%', '42%'], avoidLabelOverlap: true, - itemStyle: { - borderRadius: 6, - borderColor: '#fff', - borderWidth: 2, - }, label: { show: true, position: 'outside', @@ -235,43 +240,54 @@ const operationChartOption = computed(() => { } }) -const loginDurationChartOption = computed(() => ({ - animationDuration: 500, - grid: { top: 8, right: 12, bottom: 6, left: 8, containLabel: true }, - tooltip: { - trigger: 'axis', - axisPointer: { type: 'shadow' }, - valueFormatter: (value) => `${value} 小时`, - }, - xAxis: { - type: 'value', - max: Math.max(10, Math.ceil(Math.max(...loginDurationStats.value.map((user) => user.duration), 0) * 1.15 / 10) * 10), - splitNumber: 4, - axisLabel: { color: '#94a3b8', fontSize: 11, formatter: '{value}h' }, - axisLine: { show: false }, - axisTick: { show: false }, - splitLine: { lineStyle: { color: '#eef2f7' } }, - }, - yAxis: { - type: 'category', - inverse: true, - data: loginDurationStats.value.map((user) => user.username), - axisLabel: { color: '#475569', fontSize: 12 }, - axisLine: { show: false }, - axisTick: { show: false }, - }, - series: [ - { - name: '登录时长', - type: 'bar', - data: loginDurationStats.value.map((user) => user.duration), - barMaxWidth: 18, - barCategoryGap: '34%', - itemStyle: { color: '#4f46e5', borderRadius: [0, 4, 4, 0] }, - label: { show: true, position: 'insideRight', distance: 6, color: '#ffffff', fontSize: 11, formatter: '{c} 小时' }, +const loginDurationChartOption = computed(() => { + const stats = loginDurationStats.value + const data = stats.map((u) => ({ name: u.username, value: u.duration })) + const maxVal = data.length + ? Math.max(10, Math.ceil(Math.max(...data.map((d) => d.value), 0) * 1.15 / 10) * 10) + : 10 + return { + animationDuration: 500, + grid: { top: 8, right: 12, bottom: 6, left: 8, containLabel: true }, + tooltip: { + trigger: 'axis', + axisPointer: { type: 'shadow' }, + valueFormatter: (value: number) => `${value} 小时`, }, - ], -})) + xAxis: { + type: 'value', + max: maxVal, + splitNumber: 4, + axisLabel: { color: '#94a3b8', fontSize: 11, formatter: '{value}h' }, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { lineStyle: { color: '#eef2f7' } }, + }, + yAxis: { + type: 'category', + inverse: true, + data: data.map((d) => d.name), + axisLabel: { + color: '#1f2937', + fontSize: 14, + fontFamily: '"PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif', + margin: 12, + }, + axisLine: { show: false }, + axisTick: { show: false }, + }, + series: [ + { + name: '登录时长', + type: 'bar', + data: data.map((d) => d.value), + barMaxWidth: 18, + barCategoryGap: '34%', + itemStyle: { color: '#4f46e5', borderRadius: [0, 4, 4, 0] }, + }, + ], + } +}) const roleTagType: Record = { '超级管理员': 'danger', @@ -405,10 +421,9 @@ function viewTask(task: DashboardTask) {

登录时长排行 (本月)

-
+
-
暂无数据
diff --git a/frontend/src/views/projects/ProjectListView.vue b/frontend/src/views/projects/ProjectListView.vue index 5f9447b..1be7298 100644 --- a/frontend/src/views/projects/ProjectListView.vue +++ b/frontend/src/views/projects/ProjectListView.vue @@ -1,10 +1,10 @@