Sfoglia il codice sorgente

资源监视功能

liuq 1 mese fa
parent
commit
1ba3851d72

+ 95 - 10
backend/app/api/v1/endpoints/system.py

@@ -1,8 +1,12 @@
 import os
 import shutil
+import time
+import platform
 from typing import Annotated
 
+import psutil
 from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
+
 from app.api.v1 import deps
 from app.models.user import User, UserRole
 
@@ -12,6 +16,7 @@ CERTS_DIR = "/app/certs"
 CRT_FILENAME = "server.crt"
 KEY_FILENAME = "server.key"
 
+
 @router.post("/ssl/config", summary="更新SSL证书")
 async def update_ssl_config(
     crt_file: Annotated[UploadFile, File(description="证书文件 (.crt/.pem)")],
@@ -28,28 +33,108 @@ async def update_ssl_config(
 
     # 确保目录存在
     os.makedirs(CERTS_DIR, exist_ok=True)
-    
+
     crt_path = os.path.join(CERTS_DIR, CRT_FILENAME)
     key_path = os.path.join(CERTS_DIR, KEY_FILENAME)
-    
+
     # 简单的文件扩展名检查 (可以根据需要增强)
-    if not crt_file.filename.endswith(('.crt', '.pem', '.cer')):
-        raise HTTPException(status_code=400, detail="证书文件格式不正确,请上传 .crt, .pem 或 .cer 文件")
-    
-    if not key_file.filename.endswith(('.key', '.pem')):
-         raise HTTPException(status_code=400, detail="私钥文件格式不正确,请上传 .key 或 .pem 文件")
+    if not crt_file.filename.endswith((".crt", ".pem", ".cer")):
+        raise HTTPException(
+            status_code=400,
+            detail="证书文件格式不正确,请上传 .crt, .pem 或 .cer 文件",
+        )
+
+    if not key_file.filename.endswith((".key", ".pem")):
+        raise HTTPException(
+            status_code=400,
+            detail="私钥文件格式不正确,请上传 .key 或 .pem 文件",
+        )
 
     try:
         # 保存证书文件
         with open(crt_path, "wb") as buffer:
             shutil.copyfileobj(crt_file.file, buffer)
-            
+
         # 保存私钥文件
         with open(key_path, "wb") as buffer:
             shutil.copyfileobj(key_file.file, buffer)
-            
+
     except Exception as e:
         raise HTTPException(status_code=500, detail=f"保存证书文件失败: {str(e)}")
-        
+
     return {"message": "SSL证书已更新,Nginx将自动重新加载配置。"}
 
+
+@router.get("/status", summary="获取服务器资源状态")
+async def get_system_status(
+    current_user: User = Depends(deps.get_current_active_user),
+):
+    """
+    返回当前服务器的 CPU / 内存 / 磁盘 / 系统信息。
+    仅超级管理员可用。
+    """
+    if current_user.role != UserRole.SUPER_ADMIN:
+        raise HTTPException(status_code=403, detail="权限不足")
+
+    # CPU
+    cpu_percent = psutil.cpu_percent(interval=0.5)
+    cpu_count = psutil.cpu_count(logical=True)
+
+    try:
+        load1, load5, load15 = os.getloadavg()
+        cpu_load = {"load1": load1, "load5": load5, "load15": load15}
+    except (AttributeError, OSError):
+        cpu_load = None
+
+    # 内存
+    mem = psutil.virtual_memory()
+    memory = {
+        "total": mem.total,
+        "used": mem.used,
+        "available": mem.available,
+        "percent": mem.percent,
+    }
+
+    # 磁盘(只列出主要分区)
+    disk_partitions = psutil.disk_partitions(all=False)
+    disks = []
+    for part in disk_partitions:
+        try:
+            usage = psutil.disk_usage(part.mountpoint)
+        except PermissionError:
+            continue
+
+        disks.append(
+            {
+                "mountpoint": part.mountpoint,
+                "fstype": part.fstype,
+                "total": usage.total,
+                "used": usage.used,
+                "free": usage.free,
+                "percent": usage.percent,
+            }
+        )
+
+    # 系统信息
+    boot_time = psutil.boot_time()
+    now = time.time()
+    system_info = {
+        "hostname": platform.node(),
+        "system": platform.system(),
+        "release": platform.release(),
+        "version": platform.version(),
+        "machine": platform.machine(),
+        "boot_time": boot_time,
+        "uptime_seconds": int(now - boot_time),
+    }
+
+    return {
+        "cpu": {
+            "percent": cpu_percent,
+            "count": cpu_count,
+            "load": cpu_load,
+        },
+        "memory": memory,
+        "disks": disks,
+        "system": system_info,
+    }

+ 1 - 0
backend/requirements.txt

@@ -29,3 +29,4 @@ alibabacloud_tea_util>=0.3.0
 apscheduler>=3.10.0
 xpinyin>=0.7.6
 minio>=7.1.0
+psutil>=5.9.0

+ 52 - 0
frontend/src/api/system.ts

@@ -0,0 +1,52 @@
+import request from '../utils/request'
+
+export interface CpuInfo {
+  percent: number
+  count: number
+  load?: {
+    load1: number
+    load5: number
+    load15: number
+  } | null
+}
+
+export interface MemoryInfo {
+  total: number
+  used: number
+  available: number
+  percent: number
+}
+
+export interface DiskInfo {
+  mountpoint: string
+  fstype: string
+  total: number
+  used: number
+  free: number
+  percent: number
+}
+
+export interface SystemBasicInfo {
+  hostname: string
+  system: string
+  release: string
+  version: string
+  machine: string
+  boot_time: number
+  uptime_seconds: number
+}
+
+export interface SystemStatus {
+  cpu: CpuInfo
+  memory: MemoryInfo
+  disks: DiskInfo[]
+  system: SystemBasicInfo
+}
+
+export const getSystemStatus = () => {
+  return request<SystemStatus>({
+    url: '/system/status',
+    method: 'get',
+  })
+}
+

+ 6 - 0
frontend/src/router/index.ts

@@ -99,6 +99,12 @@ const routes: Array<RouteRecordRaw> = [
         component: () => import('../views/admin/maintenance/SystemLogs.vue'),
         meta: { requiresAdmin: true }
       },
+      {
+        path: 'system-status',
+        name: 'SystemStatus',
+        component: () => import('../views/admin/maintenance/SystemStatus.vue'),
+        meta: { requiresAdmin: true }
+      },
       {
         path: 'login-logs',
         name: 'LoginLogs',

+ 4 - 0
frontend/src/views/Dashboard.vue

@@ -48,6 +48,10 @@
               <el-icon><Document /></el-icon>
               <span>后台日志</span>
             </el-menu-item>
+            <el-menu-item index="/dashboard/system-status">
+              <el-icon><Monitor /></el-icon>
+              <span>服务器资源监控</span>
+            </el-menu-item>
             <el-menu-item index="/dashboard/login-logs">
               <el-icon><List /></el-icon>
               <span>登录日志</span>

+ 219 - 0
frontend/src/views/admin/maintenance/SystemStatus.vue

@@ -0,0 +1,219 @@
+<template>
+  <div class="app-container">
+    <el-card>
+      <template #header>
+        <div class="card-header">
+          <span>服务器资源监控</span>
+          <div class="actions">
+            <el-button size="small" @click="refresh" :loading="loading">刷新</el-button>
+            <el-switch
+              v-model="autoRefresh"
+              active-text="自动刷新"
+              inactive-text="手动刷新"
+            />
+            <span v-if="lastUpdate" class="last-update">上次更新时间:{{ lastUpdate }}</span>
+          </div>
+        </div>
+      </template>
+
+      <el-row :gutter="20">
+        <el-col :span="8">
+          <el-card shadow="never" class="stat-card">
+            <template #header>CPU 使用率</template>
+            <div class="stat-body">
+              <el-progress type="dashboard" :percentage="status?.cpu.percent || 0" />
+              <div class="stat-text">核心数:{{ status?.cpu.count ?? '-' }}</div>
+              <div class="stat-text" v-if="status?.cpu.load">
+                Load(1/5/15):{{ status.cpu.load.load1.toFixed(2) }} /
+                {{ status.cpu.load.load5.toFixed(2) }} /
+                {{ status.cpu.load.load15.toFixed(2) }}
+              </div>
+            </div>
+          </el-card>
+        </el-col>
+
+        <el-col :span="8">
+          <el-card shadow="never" class="stat-card">
+            <template #header>内存使用情况</template>
+            <div class="stat-body">
+              <el-progress type="circle" :percentage="status?.memory.percent || 0" />
+              <div class="stat-text">总内存:{{ formatBytes(status?.memory.total) }}</div>
+              <div class="stat-text">已使用:{{ formatBytes(status?.memory.used) }}</div>
+              <div class="stat-text">可用:{{ formatBytes(status?.memory.available) }}</div>
+            </div>
+          </el-card>
+        </el-col>
+
+        <el-col :span="8">
+          <el-card shadow="never" class="stat-card">
+            <template #header>系统信息</template>
+            <div class="stat-body">
+              <div class="stat-text">主机名:{{ status?.system.hostname || '-' }}</div>
+              <div class="stat-text">
+                系统:{{ status?.system.system }} {{ status?.system.release }}
+              </div>
+              <div class="stat-text">版本:{{ status?.system.version }}</div>
+              <div class="stat-text">机器类型:{{ status?.system.machine }}</div>
+              <div class="stat-text">启动时间:{{ formatTimestamp(status?.system.boot_time) }}</div>
+              <div class="stat-text">运行时长:{{ formatDuration(status?.system.uptime_seconds) }}</div>
+            </div>
+          </el-card>
+        </el-col>
+      </el-row>
+
+      <el-card shadow="never" class="disk-card" style="margin-top: 20px">
+        <template #header>磁盘使用情况</template>
+        <el-table :data="status?.disks || []" v-loading="loading" style="width: 100%">
+          <el-table-column prop="mountpoint" label="挂载点" width="150" />
+          <el-table-column prop="fstype" label="文件系统" width="120" />
+          <el-table-column label="总空间" width="160">
+            <template #default="scope">
+              {{ formatBytes(scope.row.total) }}
+            </template>
+          </el-table-column>
+          <el-table-column label="已使用" width="160">
+            <template #default="scope">
+              {{ formatBytes(scope.row.used) }}
+            </template>
+          </el-table-column>
+          <el-table-column label="可用空间" width="160">
+            <template #default="scope">
+              {{ formatBytes(scope.row.free) }}
+            </template>
+          </el-table-column>
+          <el-table-column label="使用率">
+            <template #default="scope">
+              <el-progress :percentage="scope.row.percent" :stroke-width="14" />
+            </template>
+          </el-table-column>
+        </el-table>
+      </el-card>
+    </el-card>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, onUnmounted, watch } from 'vue'
+import { ElMessage } from 'element-plus'
+import { getSystemStatus, type SystemStatus } from '../../../api/system'
+
+const status = ref<SystemStatus | null>(null)
+const loading = ref(false)
+const autoRefresh = ref(true)
+const lastUpdate = ref<string | null>(null)
+let timer: number | null = null
+
+const fetchStatus = async () => {
+  loading.value = true
+  try {
+    const res = await getSystemStatus()
+    status.value = res.data
+    lastUpdate.value = new Date().toLocaleString()
+  } catch (e) {
+    ElMessage.error('获取系统状态失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+const refresh = () => {
+  fetchStatus()
+}
+
+const startAutoRefresh = () => {
+  stopAutoRefresh()
+  if (autoRefresh.value) {
+    timer = window.setInterval(fetchStatus, 5000)
+  }
+}
+
+const stopAutoRefresh = () => {
+  if (timer !== null) {
+    clearInterval(timer)
+    timer = null
+  }
+}
+
+onMounted(() => {
+  fetchStatus()
+  startAutoRefresh()
+})
+
+onUnmounted(() => {
+  stopAutoRefresh()
+})
+
+watch(autoRefresh, () => {
+  startAutoRefresh()
+})
+
+const formatBytes = (value?: number) => {
+  if (value === undefined || value === null) return '-'
+  if (value === 0) return '0 B'
+  const k = 1024
+  const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
+  const i = Math.floor(Math.log(value) / Math.log(k))
+  return `${(value / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`
+}
+
+const formatTimestamp = (ts?: number) => {
+  if (!ts) return '-'
+  return new Date(ts * 1000).toLocaleString()
+}
+
+const formatDuration = (sec?: number) => {
+  if (sec === undefined || sec === null) return '-'
+  const s = sec
+  const days = Math.floor(s / 86400)
+  const hours = Math.floor((s % 86400) / 3600)
+  const minutes = Math.floor((s % 3600) / 60)
+  const seconds = s % 60
+  const parts: string[] = []
+  if (days) parts.push(`${days}天`)
+  if (hours) parts.push(`${hours}小时`)
+  if (minutes) parts.push(`${minutes}分`)
+  if (seconds || parts.length === 0) parts.push(`${seconds}秒`)
+  return parts.join('')
+}
+</script>
+
+<style scoped>
+.app-container {
+  padding: 20px;
+}
+
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.actions {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.last-update {
+  font-size: 12px;
+  color: #999;
+}
+
+.stat-card {
+  margin-bottom: 20px;
+}
+
+.stat-body {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 8px;
+}
+
+.stat-text {
+  font-size: 13px;
+  color: #666;
+  width: 100%;
+}
+</style>
+