366 lines
12 KiB
TypeScript
366 lines
12 KiB
TypeScript
/**
|
||
* 运维控制面板页面
|
||
*
|
||
* 功能:
|
||
* - 服务器系统资源概况(CPU / 内存 / 磁盘)
|
||
* - 各环境服务状态 + 启停重启按钮
|
||
* - 各环境 Git 状态 + pull / 同步依赖按钮
|
||
* - 各环境 .env 配置查看(敏感值脱敏)
|
||
*/
|
||
|
||
import React, { useEffect, useState, useCallback } from "react";
|
||
import {
|
||
Card,
|
||
Row,
|
||
Col,
|
||
Tag,
|
||
Button,
|
||
Space,
|
||
Statistic,
|
||
Progress,
|
||
Modal,
|
||
message,
|
||
Descriptions,
|
||
Spin,
|
||
Tooltip,
|
||
Typography,
|
||
Input,
|
||
} from "antd";
|
||
import {
|
||
PlayCircleOutlined,
|
||
PauseCircleOutlined,
|
||
ReloadOutlined,
|
||
CloudDownloadOutlined,
|
||
SyncOutlined,
|
||
FileTextOutlined,
|
||
CheckCircleOutlined,
|
||
CloseCircleOutlined,
|
||
ClockCircleOutlined,
|
||
DesktopOutlined,
|
||
} from "@ant-design/icons";
|
||
import type {
|
||
SystemInfo,
|
||
ServiceStatus,
|
||
GitInfo,
|
||
} from "../api/opsPanel";
|
||
import {
|
||
fetchSystemInfo,
|
||
fetchServicesStatus,
|
||
fetchGitInfo,
|
||
startService,
|
||
stopService,
|
||
restartService,
|
||
gitPull,
|
||
syncDeps,
|
||
fetchEnvFile,
|
||
} from "../api/opsPanel";
|
||
|
||
const { Text, Title } = Typography;
|
||
const { TextArea } = Input;
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* 工具函数 */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
/** 秒数格式化为 "Xd Xh Xm" */
|
||
function formatUptime(seconds: number | null): string {
|
||
if (seconds == null) return "-";
|
||
const d = Math.floor(seconds / 86400);
|
||
const h = Math.floor((seconds % 86400) / 3600);
|
||
const m = Math.floor((seconds % 3600) / 60);
|
||
const parts: string[] = [];
|
||
if (d > 0) parts.push(`${d}天`);
|
||
if (h > 0) parts.push(`${h}时`);
|
||
parts.push(`${m}分`);
|
||
return parts.join(" ");
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* 组件 */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
const OpsPanel: React.FC = () => {
|
||
const [system, setSystem] = useState<SystemInfo | null>(null);
|
||
const [services, setServices] = useState<ServiceStatus[]>([]);
|
||
const [gitInfos, setGitInfos] = useState<GitInfo[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
|
||
const [envModalOpen, setEnvModalOpen] = useState(false);
|
||
const [envModalContent, setEnvModalContent] = useState("");
|
||
const [envModalTitle, setEnvModalTitle] = useState("");
|
||
|
||
// ---- 数据加载 ----
|
||
|
||
const loadAll = useCallback(async () => {
|
||
try {
|
||
const [sys, svc, git] = await Promise.all([
|
||
fetchSystemInfo(),
|
||
fetchServicesStatus(),
|
||
fetchGitInfo(),
|
||
]);
|
||
setSystem(sys);
|
||
setServices(svc);
|
||
setGitInfos(git);
|
||
} catch {
|
||
message.error("加载运维数据失败");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadAll();
|
||
const timer = setInterval(loadAll, 15_000);
|
||
return () => clearInterval(timer);
|
||
}, [loadAll]);
|
||
|
||
// ---- 操作处理 ----
|
||
|
||
const withAction = async (key: string, fn: () => Promise<void>) => {
|
||
setActionLoading((prev) => ({ ...prev, [key]: true }));
|
||
try {
|
||
await fn();
|
||
} finally {
|
||
setActionLoading((prev) => ({ ...prev, [key]: false }));
|
||
}
|
||
};
|
||
|
||
const handleStart = (env: string) =>
|
||
withAction(`start-${env}`, async () => {
|
||
const r = await startService(env);
|
||
r.success ? message.success(r.message) : message.warning(r.message);
|
||
await loadAll();
|
||
});
|
||
|
||
const handleStop = (env: string) =>
|
||
withAction(`stop-${env}`, async () => {
|
||
const r = await stopService(env);
|
||
r.success ? message.success(r.message) : message.warning(r.message);
|
||
await loadAll();
|
||
});
|
||
|
||
const handleRestart = (env: string) =>
|
||
withAction(`restart-${env}`, async () => {
|
||
const r = await restartService(env);
|
||
r.success ? message.success(r.message) : message.warning(r.message);
|
||
await loadAll();
|
||
});
|
||
|
||
const handlePull = (env: string) =>
|
||
withAction(`pull-${env}`, async () => {
|
||
const r = await gitPull(env);
|
||
if (r.success) {
|
||
message.success("拉取成功");
|
||
Modal.info({ title: `Git Pull - ${env}`, content: <pre style={{ maxHeight: 300, overflow: "auto", fontSize: 12 }}>{r.output}</pre>, width: 600 });
|
||
} else {
|
||
message.error("拉取失败");
|
||
Modal.error({ title: `Git Pull 失败 - ${env}`, content: <pre style={{ maxHeight: 300, overflow: "auto", fontSize: 12 }}>{r.output}</pre>, width: 600 });
|
||
}
|
||
await loadAll();
|
||
});
|
||
|
||
const handleSyncDeps = (env: string) =>
|
||
withAction(`sync-${env}`, async () => {
|
||
const r = await syncDeps(env);
|
||
r.success ? message.success("依赖同步完成") : message.error(r.message);
|
||
});
|
||
|
||
const handleViewEnv = async (env: string, label: string) => {
|
||
try {
|
||
const r = await fetchEnvFile(env);
|
||
setEnvModalTitle(`${label} .env 配置`);
|
||
setEnvModalContent(r.content);
|
||
setEnvModalOpen(true);
|
||
} catch {
|
||
message.error("读取配置文件失败");
|
||
}
|
||
};
|
||
|
||
// ---- 渲染 ----
|
||
|
||
if (loading) {
|
||
return <Spin size="large" style={{ display: "flex", justifyContent: "center", marginTop: 120 }} />;
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<Title level={4} style={{ marginBottom: 16 }}>
|
||
<DesktopOutlined style={{ marginRight: 8 }} />
|
||
运维控制面板
|
||
</Title>
|
||
|
||
{/* ---- 系统资源 ---- */}
|
||
{system && (
|
||
<Card size="small" title="服务器资源" style={{ marginBottom: 16 }}>
|
||
<Row gutter={24}>
|
||
<Col span={8}>
|
||
<Statistic title="CPU 使用率" value={system.cpu_percent} suffix="%" />
|
||
<Progress percent={system.cpu_percent} size="small" status={system.cpu_percent > 80 ? "exception" : "normal"} showInfo={false} />
|
||
</Col>
|
||
<Col span={8}>
|
||
<Statistic title="内存" value={system.memory_used_gb} suffix={`/ ${system.memory_total_gb} GB`} precision={1} />
|
||
<Progress percent={system.memory_percent} size="small" status={system.memory_percent > 85 ? "exception" : "normal"} showInfo={false} />
|
||
</Col>
|
||
<Col span={8}>
|
||
<Statistic title="磁盘" value={system.disk_used_gb} suffix={`/ ${system.disk_total_gb} GB`} precision={1} />
|
||
<Progress percent={system.disk_percent} size="small" status={system.disk_percent > 90 ? "exception" : "normal"} showInfo={false} />
|
||
</Col>
|
||
</Row>
|
||
<Text type="secondary" style={{ fontSize: 12, marginTop: 8, display: "block" }}>
|
||
开机时间:{new Date(system.boot_time).toLocaleString()}
|
||
</Text>
|
||
</Card>
|
||
)}
|
||
|
||
{/* ---- 服务状态 ---- */}
|
||
<Card size="small" title="服务状态" style={{ marginBottom: 16 }}>
|
||
<Row gutter={16}>
|
||
{services.map((svc) => (
|
||
<Col span={12} key={svc.env}>
|
||
<Card
|
||
size="small"
|
||
type="inner"
|
||
title={
|
||
<Space>
|
||
{svc.running
|
||
? <CheckCircleOutlined style={{ color: "#52c41a" }} />
|
||
: <CloseCircleOutlined style={{ color: "#ff4d4f" }} />}
|
||
{svc.label}
|
||
<Tag color={svc.running ? "success" : "error"}>
|
||
{svc.running ? "运行中" : "已停止"}
|
||
</Tag>
|
||
</Space>
|
||
}
|
||
extra={<Tag>:{svc.port}</Tag>}
|
||
>
|
||
{svc.running && (
|
||
<Descriptions size="small" column={3} style={{ marginBottom: 12 }}>
|
||
<Descriptions.Item label="PID">{svc.pid}</Descriptions.Item>
|
||
<Descriptions.Item label="运行时长">
|
||
<ClockCircleOutlined style={{ marginRight: 4 }} />
|
||
{formatUptime(svc.uptime_seconds)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="内存">{svc.memory_mb ?? "-"} MB</Descriptions.Item>
|
||
</Descriptions>
|
||
)}
|
||
<Space>
|
||
{!svc.running && (
|
||
<Button
|
||
type="primary"
|
||
size="small"
|
||
icon={<PlayCircleOutlined />}
|
||
loading={actionLoading[`start-${svc.env}`]}
|
||
onClick={() => handleStart(svc.env)}
|
||
>
|
||
启动
|
||
</Button>
|
||
)}
|
||
{svc.running && (
|
||
<>
|
||
<Button
|
||
danger
|
||
size="small"
|
||
icon={<PauseCircleOutlined />}
|
||
loading={actionLoading[`stop-${svc.env}`]}
|
||
onClick={() => handleStop(svc.env)}
|
||
>
|
||
停止
|
||
</Button>
|
||
<Button
|
||
size="small"
|
||
icon={<ReloadOutlined />}
|
||
loading={actionLoading[`restart-${svc.env}`]}
|
||
onClick={() => handleRestart(svc.env)}
|
||
>
|
||
重启
|
||
</Button>
|
||
</>
|
||
)}
|
||
</Space>
|
||
</Card>
|
||
</Col>
|
||
))}
|
||
</Row>
|
||
</Card>
|
||
|
||
{/* ---- Git 状态 & 配置 ---- */}
|
||
<Card size="small" title="代码与配置" style={{ marginBottom: 16 }}>
|
||
<Row gutter={16}>
|
||
{gitInfos.map((git) => {
|
||
const envCfg = services.find((s) => s.env === git.env);
|
||
const label = envCfg?.label ?? git.env;
|
||
return (
|
||
<Col span={12} key={git.env}>
|
||
<Card size="small" type="inner" title={label}>
|
||
<Descriptions size="small" column={1} style={{ marginBottom: 12 }}>
|
||
<Descriptions.Item label="分支">
|
||
<Tag color="blue">{git.branch}</Tag>
|
||
{git.has_local_changes && (
|
||
<Tooltip title="工作区有未提交的变更">
|
||
<Tag color="warning">有变更</Tag>
|
||
</Tooltip>
|
||
)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="最新提交">
|
||
<Text code style={{ fontSize: 12 }}>{git.last_commit_hash}</Text>
|
||
<Text type="secondary" style={{ marginLeft: 8, fontSize: 12 }}>
|
||
{git.last_commit_message}
|
||
</Text>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="提交时间">
|
||
<Text type="secondary" style={{ fontSize: 12 }}>{git.last_commit_time}</Text>
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
<Space>
|
||
<Button
|
||
size="small"
|
||
icon={<CloudDownloadOutlined />}
|
||
loading={actionLoading[`pull-${git.env}`]}
|
||
onClick={() => handlePull(git.env)}
|
||
>
|
||
Git Pull
|
||
</Button>
|
||
<Button
|
||
size="small"
|
||
icon={<SyncOutlined />}
|
||
loading={actionLoading[`sync-${git.env}`]}
|
||
onClick={() => handleSyncDeps(git.env)}
|
||
>
|
||
同步依赖
|
||
</Button>
|
||
<Button
|
||
size="small"
|
||
icon={<FileTextOutlined />}
|
||
onClick={() => handleViewEnv(git.env, label)}
|
||
>
|
||
查看配置
|
||
</Button>
|
||
</Space>
|
||
</Card>
|
||
</Col>
|
||
);
|
||
})}
|
||
</Row>
|
||
</Card>
|
||
|
||
{/* ---- 配置查看弹窗 ---- */}
|
||
<Modal
|
||
title={envModalTitle}
|
||
open={envModalOpen}
|
||
onCancel={() => setEnvModalOpen(false)}
|
||
footer={null}
|
||
width={700}
|
||
>
|
||
<TextArea
|
||
value={envModalContent}
|
||
readOnly
|
||
autoSize={{ minRows: 10, maxRows: 30 }}
|
||
style={{ fontFamily: "monospace", fontSize: 12 }}
|
||
/>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default OpsPanel;
|