feat: add monitoring settings tab, workbench view, and inspection alert improvements
Some checks failed
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
Some checks failed
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
This commit is contained in:
@@ -80,8 +80,8 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
|
||||
}, [monitoringTitle, reportProductName]);
|
||||
|
||||
// 远程 HTTP Server 状态
|
||||
const [remoteStatus, setRemoteStatus] = useState<{ running: boolean; port: number; token: string | null }>({
|
||||
running: false, port: 8848, token: null,
|
||||
const [remoteStatus, setRemoteStatus] = useState<{ running: boolean; port: number; token: string | null; url: string | null }>({
|
||||
running: false, port: 8848, token: null, url: null,
|
||||
});
|
||||
const [remoteLoading, setRemoteLoading] = useState(false);
|
||||
const [remotePortDraft, setRemotePortDraft] = useState("8848");
|
||||
@@ -90,7 +90,12 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
|
||||
try {
|
||||
const s = await (window as any).netcatty?.remoteHttp?.getStatus?.();
|
||||
if (s) {
|
||||
setRemoteStatus({ running: !!s.running, port: s.port || 8848, token: s.token || null });
|
||||
setRemoteStatus({
|
||||
running: !!s.running,
|
||||
port: s.port || 8848,
|
||||
token: s.token || null,
|
||||
url: s.url || null,
|
||||
});
|
||||
setRemotePortDraft(String(s.port || 8848));
|
||||
}
|
||||
} catch {}
|
||||
@@ -172,6 +177,7 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
|
||||
running: !!checked,
|
||||
port: res.port || port,
|
||||
token: res.token || remoteStatus.token,
|
||||
url: res.url || null,
|
||||
});
|
||||
setRemotePortDraft(String(res.port || port));
|
||||
if (checked) toast.success("远程访问已启动");
|
||||
@@ -189,10 +195,9 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
|
||||
};
|
||||
|
||||
const handleCopyUrl = async () => {
|
||||
if (!remoteStatus.token) { toast.error("请先启用远程访问"); return; }
|
||||
const url = `http://localhost:${remoteStatus.port}/dashboard?token=${remoteStatus.token}`;
|
||||
if (!remoteStatus.url) { toast.error("请先启用远程访问"); return; }
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
await navigator.clipboard.writeText(remoteStatus.url);
|
||||
toast.success("访问链接已复制");
|
||||
} catch {
|
||||
toast.error("复制失败");
|
||||
@@ -308,11 +313,11 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
|
||||
<span className="text-xs text-muted-foreground ml-2">(默认 8848,修改后需重启远程访问生效)</span>
|
||||
</div>
|
||||
|
||||
{remoteStatus.running && remoteStatus.token && (
|
||||
{remoteStatus.running && remoteStatus.url && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={14} className="text-muted-foreground shrink-0" />
|
||||
<code className="flex-1 px-2 py-1 rounded-md bg-muted text-xs text-foreground truncate select-all">
|
||||
http://localhost:{remoteStatus.port}/dashboard?token={remoteStatus.token}
|
||||
{remoteStatus.url}
|
||||
</code>
|
||||
<Button variant="outline" size="sm" onClick={handleCopyUrl} className="shrink-0">
|
||||
<Copy size={14} className="mr-1" /> 复制
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Settings Page - Standalone settings window content
|
||||
* This component is rendered in a separate Electron window
|
||||
*/
|
||||
import { AppWindow, FileType, Keyboard, Palette, Puzzle, Sparkles, TerminalSquare, X } from "lucide-react";
|
||||
import { AppWindow, FileType, Keyboard, Monitor, Palette, Puzzle, Sparkles, TerminalSquare, X } from "lucide-react";
|
||||
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAISettingsState } from "../application/state/useAISettingsState";
|
||||
import { useAvailableFonts } from "../application/state/fontStore";
|
||||
@@ -33,6 +33,7 @@ const LazySettingsAITab = lazy(() => import("./settings/tabs/SettingsAITab"));
|
||||
const LazySettingsSystemTab = lazy(() => import("./settings/tabs/SettingsSystemTab"));
|
||||
const LazySettingsTerminalTab = lazy(() => import("./settings/tabs/SettingsTerminalTab"));
|
||||
const LazySettingsPluginsTab = lazy(() => import("./settings/tabs/SettingsPluginsTab"));
|
||||
const LazySettingsMonitoringTab = lazy(() => import("./settings/tabs/SettingsMonitoringTab"));
|
||||
|
||||
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
|
||||
@@ -418,6 +419,13 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
|
||||
<Sparkles size={14} className={settingsTabIconClassName} />
|
||||
<span className={settingsTabLabelClassName}>{t("settings.tab.ai")}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="monitoring"
|
||||
className={settingsTabTriggerClassName}
|
||||
>
|
||||
<Monitor size={14} className={settingsTabIconClassName} />
|
||||
<span className={settingsTabLabelClassName}>监控大屏</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
@@ -536,6 +544,11 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
|
||||
<SettingsAITabContainer />
|
||||
</SettingsLazyTab>
|
||||
)}
|
||||
{mountedTabs.has("monitoring") && (
|
||||
<SettingsLazyTab value="monitoring">
|
||||
<LazySettingsMonitoringTab />
|
||||
</SettingsLazyTab>
|
||||
)}
|
||||
{mountedTabs.has("plugins") && pluginRuntimeAvailable && (
|
||||
<SettingsLazyTab value="plugins">
|
||||
<LazySettingsPluginsTab />
|
||||
|
||||
@@ -88,7 +88,7 @@ const STATUS_COLORS: Record<AlertRecordStatus, string> = {
|
||||
closed: "text-muted-foreground",
|
||||
};
|
||||
|
||||
export function AlertCenterPage() {
|
||||
export function AlertCenterPage({ initialAlertId }: { initialAlertId?: string }) {
|
||||
const [alerts, setAlerts] = useState<AlertRecord[]>([]);
|
||||
const [stats, setStats] = useState<AlertStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -97,6 +97,19 @@ export function AlertCenterPage() {
|
||||
const [noteDialogOpen, setNoteDialogOpen] = useState(false);
|
||||
const [noteText, setNoteText] = useState("");
|
||||
|
||||
// 从工作台跳转时自动打开指定告警的详情弹窗
|
||||
const autoOpenedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initialAlertId || autoOpenedRef.current) return;
|
||||
if (alerts.length === 0) return; // 等数据加载完
|
||||
const target = alerts.find((a) => a.id === initialAlertId);
|
||||
if (target) {
|
||||
autoOpenedRef.current = true;
|
||||
setSelectedAlert(target);
|
||||
setDetailOpen(true);
|
||||
}
|
||||
}, [initialAlertId, alerts]);
|
||||
|
||||
// 防御:清洗告警里可能残留的 [object Object](老数据修复前写入的坏值)
|
||||
const _clean = (v: any): string => {
|
||||
if (v === null || v === undefined) return "";
|
||||
|
||||
@@ -80,35 +80,55 @@ function formatDuration(ms: number) {
|
||||
/** 从 vault 解密 + device extension 拿到所有设备的 SSH target
|
||||
* enabledDeviceIds 为空/undefined = 全部设备;有值则只返回列表内的
|
||||
* 返回值第二个元素是完整 host 列表(用于 SchedulePanel 渲染设备多选)
|
||||
*
|
||||
* 远程浏览器模式下:从 HTTP /api/vault/hosts 读 hosts(已脱敏),
|
||||
* 从 /api/devices 读 catalog_id。targets 不带真实凭据(远程跑不了 SSH)。
|
||||
*/
|
||||
async function buildAllDeviceTargets(enabledDeviceIds?: string[]): Promise<{
|
||||
targets: ReportDeviceTarget[];
|
||||
allHosts: Array<{ id: string; hostname: string; catalog_id?: string; label: string }>;
|
||||
}> {
|
||||
const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
|
||||
const REMOTE_TOKEN = (window as any).__REMOTE_TOKEN__ || "";
|
||||
|
||||
const targets: ReportDeviceTarget[] = [];
|
||||
const allHosts: Array<{ id: string; hostname: string; catalog_id?: string; label: string }> = [];
|
||||
|
||||
let hosts: HostAny[] = [];
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_HOSTS);
|
||||
if (raw) hosts = await decryptHosts(JSON.parse(raw));
|
||||
} catch (_) {}
|
||||
|
||||
let keys: KeyAny[] = [];
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_KEYS);
|
||||
if (raw) keys = await decryptKeys(JSON.parse(raw));
|
||||
} catch (_) {}
|
||||
|
||||
let hosts: any[] = [];
|
||||
let extensions: Record<string, any> = {};
|
||||
try {
|
||||
const raw = localStorage.getItem("netcatty_inspection_device_extension_v1");
|
||||
if (raw) extensions = JSON.parse(raw);
|
||||
} catch (_) {}
|
||||
|
||||
if (IS_REMOTE) {
|
||||
// ── 远程浏览器:从 HTTP API 读 ──
|
||||
try {
|
||||
const url = `/api/vault/hosts?token=${encodeURIComponent(REMOTE_TOKEN)}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
hosts = Array.isArray(data?.data) ? data.data : [];
|
||||
} catch (_) {}
|
||||
// catalog_id 从 /api/devices 读
|
||||
try {
|
||||
const url = `/api/devices?token=${encodeURIComponent(REMOTE_TOKEN)}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
for (const d of (data?.devices || [])) {
|
||||
if (d?.host_id) extensions[d.host_id] = { catalog_id: d.catalog_id || d.catalog_type };
|
||||
}
|
||||
} catch (_) {}
|
||||
} else {
|
||||
// ── Electron 本地:从 localStorage 读 ──
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_HOSTS);
|
||||
if (raw) hosts = await decryptHosts(JSON.parse(raw));
|
||||
} catch (_) {}
|
||||
try {
|
||||
const raw = localStorage.getItem("netcatty_inspection_device_extension_v1");
|
||||
if (raw) extensions = JSON.parse(raw);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
for (const host of hosts) {
|
||||
const ext = extensions[host.id];
|
||||
const catalogId = ext?.catalog_id || undefined;
|
||||
const catalogId = ext?.catalog_id || host.catalog_id || undefined;
|
||||
const label = host.label || host.hostname;
|
||||
|
||||
allHosts.push({ id: host.id, hostname: host.hostname, catalog_id: catalogId, label });
|
||||
@@ -116,18 +136,14 @@ async function buildAllDeviceTargets(enabledDeviceIds?: string[]): Promise<{
|
||||
// 过滤:enabledDeviceIds 有值且当前不在列表里 → 跳过
|
||||
if (enabledDeviceIds && enabledDeviceIds.length > 0 && !enabledDeviceIds.includes(host.id)) continue;
|
||||
|
||||
const key = host.identityFileId
|
||||
? keys.find((k: KeyAny) => k.id === host.identityFileId)
|
||||
: undefined;
|
||||
|
||||
targets.push({
|
||||
device_id: host.id,
|
||||
hostname: host.hostname,
|
||||
port: host.port || 22,
|
||||
username: host.username,
|
||||
username: host.username || "",
|
||||
password: host.password,
|
||||
privateKeyContent: key?.privateKeyContent,
|
||||
passphrase: key?.passphrase,
|
||||
privateKeyContent: host.privateKeyContent || undefined,
|
||||
passphrase: host.passphrase,
|
||||
catalog_id: catalogId,
|
||||
});
|
||||
}
|
||||
@@ -1237,23 +1253,35 @@ function SchedulePanel({ schedule, allHosts, onSave, onToggleScheduler }: {
|
||||
}) {
|
||||
const [local, setLocal] = useState<ReportSchedule>(schedule);
|
||||
const [saving, setSaving] = useState(false);
|
||||
// dirty 保护:用户在面板里编辑过 → 不要被外部 props.schedule 变化静默覆盖
|
||||
// (比如主进程广播 schedule 变更、其他组件触发 loadSchedule 等)
|
||||
const dirtyRef = useRef(false);
|
||||
|
||||
useEffect(() => { setLocal(schedule); }, [schedule]);
|
||||
useEffect(() => {
|
||||
if (!dirtyRef.current) setLocal(schedule);
|
||||
}, [schedule]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
await onSave(local);
|
||||
dirtyRef.current = false;
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
/** 编辑点统一入口:改 local + 标记 dirty,防止外部 props 覆盖未保存的本地编辑 */
|
||||
const edit = useCallback((updater: ReportSchedule | ((p: ReportSchedule) => ReportSchedule)) => {
|
||||
dirtyRef.current = true;
|
||||
setLocal(updater);
|
||||
}, []);
|
||||
|
||||
// 设备多选 helpers
|
||||
// enabledDeviceIds 约定:null/undefined = 全部,[] = 一个都不选,[id1,id2] = 指定
|
||||
const selectedAll = !local.enabledDeviceIds; // null / undefined → 全部
|
||||
const toggleAllDevices = (selectAll: boolean) => {
|
||||
setLocal(p => ({ ...p, enabledDeviceIds: selectAll ? null : [] }));
|
||||
edit(p => ({ ...p, enabledDeviceIds: selectAll ? null : [] }));
|
||||
};
|
||||
const toggleDevice = (deviceId: string) => {
|
||||
setLocal(p => {
|
||||
edit(p => {
|
||||
const cur = p.enabledDeviceIds;
|
||||
if (!cur) {
|
||||
// 当前是"全部" —— 取消这一台变成"除了这台"
|
||||
@@ -1291,7 +1319,11 @@ function SchedulePanel({ schedule, allHosts, onSave, onToggleScheduler }: {
|
||||
<div className="text-xs text-muted-foreground">按周期自动巡检</div>
|
||||
</div>
|
||||
<Switch checked={local.enabled}
|
||||
onCheckedChange={(v) => { setLocal(p => ({ ...p, enabled: v })); onToggleScheduler(v); }} />
|
||||
onCheckedChange={(v) => {
|
||||
edit(p => ({ ...p, enabled: v }));
|
||||
// onToggleScheduler 直接写主进程,成功后清 dirty(async)
|
||||
Promise.resolve(onToggleScheduler(v)).then(() => { dirtyRef.current = false; });
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* 巡检周期 */}
|
||||
@@ -1300,7 +1332,7 @@ function SchedulePanel({ schedule, allHosts, onSave, onToggleScheduler }: {
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="number" min={1} max={72} step={1} className="w-20 rounded border border-border/60 bg-background px-2 py-1 text-sm"
|
||||
value={local.defaultIntervalHours}
|
||||
onChange={(e) => setLocal(p => ({ ...p, defaultIntervalHours: Number(e.target.value) || 1 }))} />
|
||||
onChange={(e) => edit(p => ({ ...p, defaultIntervalHours: Number(e.target.value) || 1 }))} />
|
||||
<span className="text-xs text-muted-foreground">小时</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1310,7 +1342,7 @@ function SchedulePanel({ schedule, allHosts, onSave, onToggleScheduler }: {
|
||||
<div className="mb-1 text-sm font-medium">日报告触发时间</div>
|
||||
<input type="time" className="w-full rounded border border-border/60 bg-background px-2 py-1 text-sm"
|
||||
value={local.dailyReportTime}
|
||||
onChange={(e) => setLocal(p => ({ ...p, dailyReportTime: e.target.value }))} />
|
||||
onChange={(e) => edit(p => ({ ...p, dailyReportTime: e.target.value }))} />
|
||||
</div>
|
||||
|
||||
{/* AI 汇总 */}
|
||||
@@ -1320,7 +1352,7 @@ function SchedulePanel({ schedule, allHosts, onSave, onToggleScheduler }: {
|
||||
<div className="text-xs text-muted-foreground">手动触发快照时生成 AI 四段式汇总</div>
|
||||
</div>
|
||||
<Switch checked={local.aiSummaryEnabled}
|
||||
onCheckedChange={(v) => setLocal(p => ({ ...p, aiSummaryEnabled: v }))} />
|
||||
onCheckedChange={(v) => edit(p => ({ ...p, aiSummaryEnabled: v }))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* 运行监控 - 3D 拓扑大屏
|
||||
*
|
||||
* 移植自 ITYUNWEI TopologyView.vue
|
||||
@@ -503,47 +503,32 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
|
||||
fetchJSON(`/api/dashboard-title?token=${encodeURIComponent(token)}`)
|
||||
.then(json => { if (json?.ok && json.title) setMonitoringTitle(json.title); });
|
||||
|
||||
// 2. 拓扑位置
|
||||
fetchJSON(`/api/topology-positions?token=${encodeURIComponent(token)}`)
|
||||
.then(json => {
|
||||
if (json?.ok && json.positions && Object.keys(json.positions).length > 0) {
|
||||
setSavedPositions(json.positions);
|
||||
}
|
||||
});
|
||||
// 2. 拓扑位置(layoutDevices 内部已经同步 XHR 加载了,这里不需要)
|
||||
|
||||
// 3. 连接线
|
||||
fetchJSON(`/api/topology-links?token=${encodeURIComponent(token)}`)
|
||||
.then(json => { if (json?.ok && Array.isArray(json.links)) setCustomLinks(json.links); });
|
||||
}, []);
|
||||
|
||||
// Electron 环境:监听 localStorage 变化 + mount 时同步 hosts/title 给主进程
|
||||
// Electron 环境:mount 时同步 hosts/title/拓扑位置/连线给主进程持久化
|
||||
useEffect(() => {
|
||||
const nc = (window as any).netcatty;
|
||||
console.log('[MonitoringTopologyView sync] nc exists:', !!nc, 'nc.inspection exists:', !!nc?.inspection, 'hosts.length:', hosts?.length);
|
||||
if (!nc?.inspection) {
|
||||
console.log('[MonitoringTopologyView sync] skip — not Electron env');
|
||||
return;
|
||||
}
|
||||
if (!nc?.inspection) return; // 不是 Electron 环境
|
||||
|
||||
// mount 时一次性同步 vault hosts + 标题 + 拓扑位置 + 拓扑连线
|
||||
if (hosts?.length > 0) {
|
||||
console.log('[MonitoringTopologyView sync] saving vault hosts:', hosts.length);
|
||||
nc.inspection.saveVaultHosts(hosts).then((r: any) => console.log('saveVaultHosts result:', r)).catch((e: any) => console.warn('saveVaultHosts error:', e));
|
||||
nc.inspection.saveVaultHosts(hosts).catch(() => {});
|
||||
}
|
||||
const savedTitle = localStorage.getItem(STORAGE_KEY_MONITORING_DASHBOARD_TITLE);
|
||||
console.log('[MonitoringTopologyView sync] savedTitle:', savedTitle);
|
||||
if (savedTitle) nc.inspection.saveDashboardTitle(savedTitle).catch(() => {});
|
||||
const savedPos = localStorage.getItem('monitoring-topology-positions');
|
||||
if (savedPos) nc.inspection.saveTopologyPositions(JSON.parse(savedPos)).catch(() => {});
|
||||
const savedLinks = localStorage.getItem('monitoring-topology-links');
|
||||
console.log('[MonitoringTopologyView sync] savedLinks exists:', !!savedLinks);
|
||||
if (savedLinks) {
|
||||
try { nc.inspection.saveTopologyLinks(JSON.parse(savedLinks)).catch(() => {}); } catch {}
|
||||
}
|
||||
if (!savedLinks && nc.inspection.getTopologyLinks) {
|
||||
nc.inspection.getTopologyLinks().then((linksFromMain: any) => {
|
||||
if (Array.isArray(linksFromMain) && linksFromMain.length > 0) {
|
||||
console.info('[MonitoringTopologyView] restore links from main:', linksFromMain.length);
|
||||
localStorage.setItem('monitoring-topology-links', JSON.stringify(linksFromMain));
|
||||
try { setCustomLinks(linksFromMain); } catch {}
|
||||
}
|
||||
@@ -605,6 +590,44 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
|
||||
// 相机参数 —— 正常模式(45°俯视)和大屏模式(正俯视)
|
||||
const normalCameraRef = useRef({ pos: new THREE.Vector3(0, 55, 115), target: new THREE.Vector3(-20, -25, 20), minDist: 30, maxDist: 150, autoRotate: true, autoRotateSpeed: 0.4, fov: 60 });
|
||||
|
||||
|
||||
// ===== 外部相机配置(Settings 页面驱动)=====
|
||||
const CAMERA_CONFIG_KEY = "monitoring-camera-config";
|
||||
const CAMERA_TARGET = new THREE.Vector3(-20, -25, 20); // OrbitControls 的旋转中心(与 init scene 一致)
|
||||
|
||||
interface ICameraConfig {
|
||||
elevation: number; azimuth: number; distance: number;
|
||||
fov: number; autoRotate: boolean; autoRotateSpeed: number;
|
||||
}
|
||||
// 从球坐标(elevation 仰角、azimuth 方位角、distance 距离)算 camera.position(y 向上的右手系)
|
||||
const computePosFromCfg = (cfg: ICameraConfig, target: THREE.Vector3 = CAMERA_TARGET): THREE.Vector3 => {
|
||||
const el = (cfg.elevation * Math.PI) / 180;
|
||||
const az = (cfg.azimuth * Math.PI) / 180;
|
||||
const d = cfg.distance;
|
||||
const offset = new THREE.Vector3(
|
||||
d * Math.cos(el) * Math.cos(az),
|
||||
d * Math.sin(el),
|
||||
d * Math.cos(el) * Math.sin(az),
|
||||
);
|
||||
return offset.add(target);
|
||||
};
|
||||
const readCameraCfg = (): ICameraConfig | null => {
|
||||
try {
|
||||
const raw = localStorage.getItem(CAMERA_CONFIG_KEY);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw);
|
||||
} catch { return null; }
|
||||
};
|
||||
// 外部相机配置的过渡锁:避免用户手动拖拽相机时被外部事件反写
|
||||
const externalCameraCfgAppliedRef = useRef(false);
|
||||
|
||||
// 地图底图热替换用的 ref(init scene useEffect 外也能访问 → dispose / rebuild)
|
||||
const mapPlaneRef = useRef<THREE.Mesh | null>(null);
|
||||
const glowPlaneRef = useRef<THREE.Mesh | null>(null);
|
||||
const mapTextureRef = useRef<THREE.Texture | null>(null);
|
||||
const gridOnMapRef = useRef<THREE.GridHelper | null>(null);
|
||||
const gridOutsideRef = useRef<THREE.GridHelper | null>(null);
|
||||
|
||||
// 相机平滑过渡动画(Vector3.lerp 逐帧插值)
|
||||
interface CameraAnim {
|
||||
fromPos: THREE.Vector3;
|
||||
@@ -1382,13 +1405,19 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
|
||||
scene.fog = new THREE.Fog(0xcfe2f3, 120, 280);
|
||||
sceneRef.current = scene;
|
||||
|
||||
// 【根治跳帧 Bug】Three.js 初始化**第一帧**就读 Settings 里配置的相机参数
|
||||
// 这样相机一出生就是对的位置,不会先渲染硬编码位置再被 useEffect 异步改掉
|
||||
const bootCfg = readCameraCfg();
|
||||
const bootFov = bootCfg?.fov ?? 60;
|
||||
const bootPos = bootCfg ? computePosFromCfg(bootCfg) : new THREE.Vector3(0, 55, 115);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
60,
|
||||
bootFov,
|
||||
container.clientWidth / container.clientHeight,
|
||||
0.1,
|
||||
500
|
||||
);
|
||||
camera.position.set(0, 55, 115);
|
||||
camera.position.copy(bootPos);
|
||||
cameraRef.current = camera;
|
||||
|
||||
// 复用全局单例 WebGLRenderer —— 整个应用只创建一个 WebGL context
|
||||
@@ -1485,12 +1514,12 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
|
||||
controls.enableZoom = true;
|
||||
controls.target.set(-20, -25, 20); // 往上抬 target,让顶部露出更多蓝天
|
||||
controls.update();
|
||||
controls.minDistance = 30;
|
||||
controls.maxDistance = 150;
|
||||
controls.minDistance = bootCfg ? Math.max(20, bootCfg.distance - 80) : 30;
|
||||
controls.maxDistance = bootCfg ? Math.min(350, bootCfg.distance + 80) : 150;
|
||||
controls.maxPolarAngle = Math.PI / 2 - 0.1;
|
||||
controls.minPolarAngle = 0;
|
||||
controls.autoRotate = true;
|
||||
controls.autoRotateSpeed = 0.4;
|
||||
controls.autoRotate = bootCfg?.autoRotate ?? true;
|
||||
controls.autoRotateSpeed = bootCfg?.autoRotateSpeed ?? 0.4;
|
||||
controlsRef.current = controls;
|
||||
|
||||
// TransformControls 拖拽
|
||||
@@ -1541,12 +1570,13 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
|
||||
new THREE.Plane(new THREE.Vector3(0, 0, -1), h / 2),
|
||||
];
|
||||
|
||||
// 地图外网格(深蓝)
|
||||
// 地图外网格(深蓝)——先按默认正方形创建,地图纹理加载完再按真实 aspect 更新裁剪平面
|
||||
const gridOutside = new THREE.GridHelper(gridSize, gridDiv, 0x3d6aa0, 0x1e3a60);
|
||||
gridOutside.position.y = -0.45;
|
||||
const outsidePlanes = makeClipPlanes(mapW, mapH).map((p) => p.clone().negate());
|
||||
(gridOutside.material as THREE.Material).clippingPlanes = outsidePlanes;
|
||||
scene.add(gridOutside);
|
||||
gridOutsideRef.current = gridOutside;
|
||||
|
||||
// 地图上网格(极浅半透明)
|
||||
const gridOnMap = new THREE.GridHelper(gridSize, gridDiv, 0x0d1f30, 0x060f1a);
|
||||
@@ -1555,6 +1585,7 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
|
||||
onMapMat.transparent = true;
|
||||
onMapMat.opacity = 0.18;
|
||||
scene.add(gridOnMap);
|
||||
gridOnMapRef.current = gridOnMap;
|
||||
|
||||
// Glow ring
|
||||
const ringGeo = new THREE.RingGeometry(25, 30, 64);
|
||||
@@ -1623,100 +1654,161 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
|
||||
sky.name = 'SkyDome';
|
||||
scene.add(sky);
|
||||
|
||||
// ===== ditu.png 地图纹理 + 边缘发光晕 =====
|
||||
const texLoader = new THREE.TextureLoader();
|
||||
texLoader.setCrossOrigin('anonymous');
|
||||
texLoader.load(
|
||||
'/ditu.png',
|
||||
(texture) => {
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
const mapAspect = texture.image.width / texture.image.height;
|
||||
mapW = gridSize;
|
||||
mapH = gridSize / mapAspect;
|
||||
// ===== 地图纹理(用户上传 → data URL,或默认 /ditu.png)=====
|
||||
// rebuildMapFromTexture:dispose 旧 mesh/material/texture → 重建地图层级
|
||||
// 初始化调一次,热替换时再调一次(从外部事件监听调用)
|
||||
const _sceneForHot = scene; // 闭包内缓存,给外部 hot-rebuild 用
|
||||
const _gridSizeForHot = gridSize;
|
||||
|
||||
// 地图发光底层(比地图稍大,形成发光边缘)
|
||||
const glowW = mapW + 6;
|
||||
const glowH = mapH + 6;
|
||||
const glowGeo = new THREE.PlaneGeometry(glowW, glowH);
|
||||
const glowMat = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uMap: { value: texture },
|
||||
uColor: { value: new THREE.Color('#38bdf8') },
|
||||
uFade: { value: 0.12 },
|
||||
},
|
||||
vertexShader: `
|
||||
varying vec2 vUv;
|
||||
void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }
|
||||
`,
|
||||
fragmentShader: `
|
||||
varying vec2 vUv;
|
||||
uniform sampler2D uMap;
|
||||
uniform vec3 uColor;
|
||||
uniform float uFade;
|
||||
void main(){
|
||||
vec4 c = texture2D(uMap, vUv);
|
||||
float dx = min(vUv.x, 1.0 - vUv.x) * 2.0;
|
||||
float dy = min(vUv.y, 1.0 - vUv.y) * 2.0;
|
||||
float edge = min(dx, dy);
|
||||
float glow = 1.0 - smoothstep(0.0, uFade, edge);
|
||||
gl_FragColor = vec4(uColor, glow * 0.5);
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
});
|
||||
const glowPlane = new THREE.Mesh(glowGeo, glowMat);
|
||||
glowPlane.rotation.x = -Math.PI / 2;
|
||||
glowPlane.position.y = -0.58;
|
||||
scene.add(glowPlane);
|
||||
|
||||
const mapGeo = new THREE.PlaneGeometry(mapW, mapH);
|
||||
const mapMat = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uMap: { value: texture },
|
||||
uOpacity: { value: 0.85 },
|
||||
uFade: { value: 0.08 },
|
||||
},
|
||||
vertexShader: `
|
||||
varying vec2 vUv;
|
||||
void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }
|
||||
`,
|
||||
fragmentShader: `
|
||||
varying vec2 vUv;
|
||||
uniform sampler2D uMap;
|
||||
uniform float uOpacity;
|
||||
uniform float uFade;
|
||||
void main(){
|
||||
vec4 c = texture2D(uMap, vUv);
|
||||
float dx = min(vUv.x, 1.0 - vUv.x) * 2.0;
|
||||
float dy = min(vUv.y, 1.0 - vUv.y) * 2.0;
|
||||
float edge = min(dx, dy);
|
||||
float a = smoothstep(0.0, uFade, edge);
|
||||
gl_FragColor = vec4(c.rgb, c.a * a * uOpacity);
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
});
|
||||
const mapPlane = new THREE.Mesh(mapGeo, mapMat);
|
||||
mapPlane.rotation.x = -Math.PI / 2;
|
||||
mapPlane.position.y = -0.6;
|
||||
scene.add(mapPlane);
|
||||
|
||||
// 地图加载完更新网格裁剪平面
|
||||
const newOnMapPlanes = makeClipPlanes(mapW, mapH);
|
||||
;(gridOnMap.material as THREE.Material).clippingPlanes = newOnMapPlanes;
|
||||
const newOutsidePlanes = makeClipPlanes(mapW, mapH).map((p) => p.clone().negate());
|
||||
;(gridOutside.material as THREE.Material).clippingPlanes = newOutsidePlanes;
|
||||
},
|
||||
undefined,
|
||||
() => {
|
||||
// 地图加载失败,静默跳过
|
||||
console.warn('[Monitoring] ditu.png load failed, skipping map texture');
|
||||
function rebuildMapFromTexture(loadedTexture: THREE.Texture) {
|
||||
// dispose 旧的
|
||||
if (mapPlaneRef.current) {
|
||||
_sceneForHot.remove(mapPlaneRef.current);
|
||||
mapPlaneRef.current.geometry.dispose();
|
||||
disposeMatTextures(mapPlaneRef.current.material);
|
||||
mapPlaneRef.current = null;
|
||||
}
|
||||
);
|
||||
if (glowPlaneRef.current) {
|
||||
_sceneForHot.remove(glowPlaneRef.current);
|
||||
glowPlaneRef.current.geometry.dispose();
|
||||
disposeMatTextures(glowPlaneRef.current.material);
|
||||
glowPlaneRef.current = null;
|
||||
}
|
||||
if (mapTextureRef.current) {
|
||||
mapTextureRef.current.dispose();
|
||||
mapTextureRef.current = null;
|
||||
}
|
||||
|
||||
loadedTexture.colorSpace = THREE.SRGBColorSpace;
|
||||
mapTextureRef.current = loadedTexture;
|
||||
|
||||
const img: any = loadedTexture.image ?? { width: 1, height: 1 };
|
||||
const mapAspect = (img.width || 1) / Math.max(1, img.height || 1);
|
||||
mapW = _gridSizeForHot;
|
||||
mapH = _gridSizeForHot / Math.max(0.01, mapAspect);
|
||||
|
||||
// 发光底层
|
||||
const glowW = mapW + 6;
|
||||
const glowH = mapH + 6;
|
||||
const glowGeo = new THREE.PlaneGeometry(glowW, glowH);
|
||||
const glowMat = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uMap: { value: loadedTexture },
|
||||
uColor: { value: new THREE.Color('#38bdf8') },
|
||||
uFade: { value: 0.12 },
|
||||
},
|
||||
vertexShader: `
|
||||
varying vec2 vUv;
|
||||
void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }
|
||||
`,
|
||||
fragmentShader: `
|
||||
varying vec2 vUv;
|
||||
uniform sampler2D uMap;
|
||||
uniform vec3 uColor;
|
||||
uniform float uFade;
|
||||
void main(){
|
||||
vec4 c = texture2D(uMap, vUv);
|
||||
float dx = min(vUv.x, 1.0 - vUv.x) * 2.0;
|
||||
float dy = min(vUv.y, 1.0 - vUv.y) * 2.0;
|
||||
float edge = min(dx, dy);
|
||||
float glow = 1.0 - smoothstep(0.0, uFade, edge);
|
||||
gl_FragColor = vec4(uColor, glow * 0.5);
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
});
|
||||
const glowPlane = new THREE.Mesh(glowGeo, glowMat);
|
||||
glowPlane.rotation.x = -Math.PI / 2;
|
||||
glowPlane.position.y = -0.58;
|
||||
_sceneForHot.add(glowPlane);
|
||||
glowPlaneRef.current = glowPlane;
|
||||
|
||||
const mapGeo = new THREE.PlaneGeometry(mapW, mapH);
|
||||
const mapMat = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uMap: { value: loadedTexture },
|
||||
uOpacity: { value: 0.85 },
|
||||
uFade: { value: 0.08 },
|
||||
},
|
||||
vertexShader: `
|
||||
varying vec2 vUv;
|
||||
void main(){ vUv=uv; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0); }
|
||||
`,
|
||||
fragmentShader: `
|
||||
varying vec2 vUv;
|
||||
uniform sampler2D uMap;
|
||||
uniform float uOpacity;
|
||||
uniform float uFade;
|
||||
void main(){
|
||||
vec4 c = texture2D(uMap, vUv);
|
||||
float dx = min(vUv.x, 1.0 - vUv.x) * 2.0;
|
||||
float dy = min(vUv.y, 1.0 - vUv.y) * 2.0;
|
||||
float edge = min(dx, dy);
|
||||
float a = smoothstep(0.0, uFade, edge);
|
||||
gl_FragColor = vec4(c.rgb, c.a * a * uOpacity);
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
});
|
||||
const mapPlane = new THREE.Mesh(mapGeo, mapMat);
|
||||
mapPlane.rotation.x = -Math.PI / 2;
|
||||
mapPlane.position.y = -0.6;
|
||||
_sceneForHot.add(mapPlane);
|
||||
mapPlaneRef.current = mapPlane;
|
||||
|
||||
// 更新网格裁剪平面(按真实 aspect)
|
||||
const newOnMapPlanes = makeClipPlanes(mapW, mapH);
|
||||
if (gridOnMapRef.current) {
|
||||
(gridOnMapRef.current.material as THREE.Material).clippingPlanes = newOnMapPlanes;
|
||||
}
|
||||
const newOutsidePlanes = makeClipPlanes(mapW, mapH).map((p) => p.clone().negate());
|
||||
if (gridOutsideRef.current) {
|
||||
(gridOutsideRef.current.material as THREE.Material).clippingPlanes = newOutsidePlanes;
|
||||
}
|
||||
}
|
||||
|
||||
function loadMapByUrl(url: string) {
|
||||
const loader = new THREE.TextureLoader();
|
||||
loader.setCrossOrigin('anonymous');
|
||||
loader.load(
|
||||
url,
|
||||
(texture) => rebuildMapFromTexture(texture),
|
||||
undefined,
|
||||
() => {
|
||||
if (url !== '/ditu.png') {
|
||||
console.warn('[Monitoring] 用户地图加载失败,fallback /ditu.png');
|
||||
loader.load('/ditu.png', rebuildMapFromTexture);
|
||||
} else {
|
||||
console.warn('[Monitoring] ditu.png load failed, skipping map texture');
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 初始化:优先用户上传底图 → fallback /ditu.png
|
||||
void (async () => {
|
||||
let targetUrl = '/ditu.png';
|
||||
try {
|
||||
const nc = (window as any).netcatty;
|
||||
if (nc?.inspection?.getMapBgData) {
|
||||
const res = await nc.inspection.getMapBgData();
|
||||
if (res?.ok && res.dataUrl) {
|
||||
targetUrl = res.dataUrl;
|
||||
console.info('[Monitoring] 使用用户上传底图:', res.filename);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Monitoring] getMapBgData 失败,使用默认 ditu.png:', e);
|
||||
}
|
||||
loadMapByUrl(targetUrl);
|
||||
})();
|
||||
|
||||
// 热替换暴露给外部(useEffect 里监听事件时调用)
|
||||
(scene as any).__rebuildMapByUrl = loadMapByUrl;
|
||||
|
||||
// 设备/连线创建已移到下方独立的 useEffect(依赖 [devices, links])
|
||||
|
||||
@@ -2232,6 +2324,149 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // 只在 mount/unmount 时跑,renderer 全局唯一
|
||||
|
||||
// ===== 外部相机配置监听(Settings 页面驱动)=====
|
||||
// 主路径:主进程 IPC 广播(inspection:camera-config-updated),最可靠
|
||||
// 兜底:storage 事件(浏览器远程访问模式)
|
||||
useEffect(() => {
|
||||
console.info("[MonitoringTopologyView] camera cfg listener mounted");
|
||||
|
||||
let lastAppliedTs = 0; // debounce:连续触发合并
|
||||
let ipcUnsubscribe: (() => void) | null = null;
|
||||
|
||||
const applyCameraCfg = (cfg: ICameraConfig) => {
|
||||
const camera = cameraRef.current;
|
||||
const controls = controlsRef.current;
|
||||
if (!camera || !controls) {
|
||||
console.warn("[MonitoringTopologyView] applyCameraCfg: camera/controls not ready yet");
|
||||
return;
|
||||
}
|
||||
|
||||
// debounce:至少隔 80ms
|
||||
const now = performance.now();
|
||||
if (now - lastAppliedTs < 80) return;
|
||||
lastAppliedTs = now;
|
||||
|
||||
// 先更新 OrbitControls 的距离/FOV/自动旋转
|
||||
const newMinDist = Math.max(20, cfg.distance - 80);
|
||||
const newMaxDist = Math.min(350, cfg.distance + 80);
|
||||
controls.minDistance = newMinDist;
|
||||
controls.maxDistance = newMaxDist;
|
||||
controls.autoRotate = !!cfg.autoRotate;
|
||||
controls.autoRotateSpeed = cfg.autoRotateSpeed ?? 0.4;
|
||||
|
||||
const target = CAMERA_TARGET.clone();
|
||||
const newPos = computePosFromCfg(cfg, target);
|
||||
const newFov = cfg.fov ?? 60;
|
||||
|
||||
console.info("[MonitoringTopologyView] applyCameraCfg", {
|
||||
elevation: cfg.elevation, azimuth: cfg.azimuth, distance: cfg.distance,
|
||||
fov: newFov, autoRotate: cfg.autoRotate,
|
||||
newPos: newPos.toArray().map((v) => Number(v.toFixed(2))),
|
||||
curPos: camera.position.toArray().map((v) => Number(v.toFixed(2))),
|
||||
});
|
||||
|
||||
cameraAnimRef.current = {
|
||||
fromPos: camera.position.clone(),
|
||||
toPos: newPos,
|
||||
fromTarget: controls.target.clone(),
|
||||
toTarget: target,
|
||||
fromFov: camera.fov,
|
||||
toFov: newFov,
|
||||
startMs: performance.now(),
|
||||
durationMs: 400,
|
||||
};
|
||||
normalCameraRef.current.pos.copy(newPos);
|
||||
normalCameraRef.current.target.copy(target);
|
||||
normalCameraRef.current.fov = newFov;
|
||||
normalCameraRef.current.autoRotate = !!cfg.autoRotate;
|
||||
normalCameraRef.current.autoRotateSpeed = cfg.autoRotateSpeed ?? 0.4;
|
||||
externalCameraCfgAppliedRef.current = true;
|
||||
};
|
||||
|
||||
// mount 时应用一次初始配置(用户可能在 Settings 调了,然后才打开监控大屏)
|
||||
const initialCfg = readCameraCfg();
|
||||
if (initialCfg) {
|
||||
console.info("[MonitoringTopologyView] mount: found initial camera cfg");
|
||||
const t = setTimeout(() => applyCameraCfg(initialCfg), 800);
|
||||
// cleanup 里会清 timeout,但先记一下让 ipcUnsubscribe 覆盖
|
||||
} else {
|
||||
console.info("[MonitoringTopologyView] mount: no initial camera cfg (use Three.js defaults)");
|
||||
}
|
||||
|
||||
// 主路径:preload 暴露的 IPC 事件(Electron 环境)
|
||||
try {
|
||||
const nc = (window as any).netcatty;
|
||||
if (nc?.inspection?.onCameraConfigUpdated) {
|
||||
ipcUnsubscribe = nc.inspection.onCameraConfigUpdated((cfg: ICameraConfig) => {
|
||||
console.info("[MonitoringTopologyView] IPC camera-config-updated", cfg);
|
||||
if (cfg) applyCameraCfg(cfg);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[MonitoringTopologyView] IPC listener unavailable:", e);
|
||||
}
|
||||
|
||||
// 兜底:storage 事件(跨窗口/跨标签)+ 同窗口 CustomEvent(Settings 页面触发)
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === CAMERA_CONFIG_KEY) {
|
||||
const cfg = e.newValue ? JSON.parse(e.newValue) : null;
|
||||
if (cfg) applyCameraCfg(cfg);
|
||||
}
|
||||
};
|
||||
const onCustomEvent = (e: Event) => {
|
||||
try {
|
||||
const cfg = localStorage.getItem(CAMERA_CONFIG_KEY);
|
||||
if (cfg) applyCameraCfg(JSON.parse(cfg));
|
||||
} catch {}
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
window.addEventListener("monitoring-camera-changed", onCustomEvent);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("storage", onStorage);
|
||||
window.removeEventListener("monitoring-camera-changed", onCustomEvent);
|
||||
if (ipcUnsubscribe) ipcUnsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ===== 地图底图热替换:Settings 里改了地图 → monitoring 端实时重建 =====
|
||||
useEffect(() => {
|
||||
const scene: any = sceneRef.current;
|
||||
if (!scene) return;
|
||||
|
||||
const applyMapBg = async () => {
|
||||
let url = '/ditu.png';
|
||||
try {
|
||||
const nc = (window as any).netcatty;
|
||||
if (nc?.inspection?.getMapBgData) {
|
||||
const res = await nc.inspection.getMapBgData();
|
||||
if (res?.ok && res.dataUrl) {
|
||||
url = res.dataUrl;
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore — 浏览器远程环境无此 API */ }
|
||||
const rebuilder = scene.__rebuildMapByUrl;
|
||||
if (typeof rebuilder === 'function') rebuilder(url);
|
||||
};
|
||||
|
||||
const onCustomEvent = () => { void applyMapBg(); };
|
||||
const onStorageEvent = (e: StorageEvent) => {
|
||||
if (e.key && /map_bg|monitoring.*bg/i.test(e.key)) {
|
||||
void applyMapBg();
|
||||
}
|
||||
};
|
||||
|
||||
// SettingsMonitoringTab 切换后 dispatchEvent 触发(同 window)
|
||||
window.addEventListener('monitoring-map-bg-changed', onCustomEvent);
|
||||
// localStorage storage 事件(跨 window 同步;Settings 独立 window 时监听)
|
||||
window.addEventListener('storage', onStorageEvent);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('monitoring-map-bg-changed', onCustomEvent);
|
||||
window.removeEventListener('storage', onStorageEvent);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ===== 设备/连线创建:每次 devices/links 变化重建 scene 内容,不重建 renderer =====
|
||||
useEffect(() => {
|
||||
const scene = sceneRef.current;
|
||||
@@ -2546,12 +2781,36 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
|
||||
}, [typeDistribution, monitoring.alarmTrend]);
|
||||
|
||||
const resetView = useCallback(() => {
|
||||
if (cameraRef.current && controlsRef.current) {
|
||||
cameraRef.current.position.set(0, 80, 125);
|
||||
controlsRef.current.target.set(-20, -40, 20);
|
||||
controlsRef.current.autoRotate = true;
|
||||
controlsRef.current.update();
|
||||
if (!cameraRef.current || !controlsRef.current) return;
|
||||
const camera = cameraRef.current;
|
||||
const controls = controlsRef.current;
|
||||
|
||||
// 优先用 Settings 里配置好的相机参数重置;没有才 fallback 硬编码默认
|
||||
const cfg = readCameraCfg();
|
||||
if (cfg) {
|
||||
const target = CAMERA_TARGET.clone();
|
||||
const newPos = computePosFromCfg(cfg, target);
|
||||
camera.position.copy(newPos);
|
||||
controls.target.copy(target);
|
||||
const newMin = Math.max(20, cfg.distance - 80);
|
||||
const newMax = Math.min(350, cfg.distance + 80);
|
||||
controls.minDistance = newMin;
|
||||
controls.maxDistance = newMax;
|
||||
controls.autoRotate = !!cfg.autoRotate;
|
||||
controls.autoRotateSpeed = cfg.autoRotateSpeed ?? 0.4;
|
||||
camera.fov = cfg.fov ?? 60;
|
||||
camera.updateProjectionMatrix();
|
||||
normalCameraRef.current.pos.copy(newPos);
|
||||
normalCameraRef.current.target.copy(target);
|
||||
normalCameraRef.current.fov = cfg.fov ?? 60;
|
||||
normalCameraRef.current.autoRotate = !!cfg.autoRotate;
|
||||
normalCameraRef.current.autoRotateSpeed = cfg.autoRotateSpeed ?? 0.4;
|
||||
} else {
|
||||
camera.position.set(0, 80, 125);
|
||||
controls.target.set(-20, -40, 20);
|
||||
controls.autoRotate = true;
|
||||
}
|
||||
controls.update();
|
||||
}, []);
|
||||
|
||||
const refreshData = useCallback(() => {
|
||||
@@ -2769,6 +3028,8 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
|
||||
background: 'linear-gradient(to right, transparent 0%, rgba(74,155,255,0.5) 30%, #4A9BFF 100%)',
|
||||
marginRight: '12px',
|
||||
boxShadow: '0 0 6px rgba(74,155,255,0.5)',
|
||||
position: 'relative',
|
||||
top: '14px',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
@@ -2779,6 +3040,8 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
|
||||
borderBottom: '1px solid #4A9BFF',
|
||||
background: 'linear-gradient(180deg, rgba(58,123,213,0.22) 0%, rgba(58,123,213,0.06) 100%)',
|
||||
boxShadow: '0 0 16px rgba(74,155,255,0.3), inset 0 0 12px rgba(74,155,255,0.15)',
|
||||
position: 'relative',
|
||||
top: '14px',
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
@@ -2802,6 +3065,8 @@ export function MonitoringTopologyView({ hosts, keys }: Props) {
|
||||
background: 'linear-gradient(to left, transparent 0%, rgba(74,155,255,0.5) 30%, #4A9BFF 100%)',
|
||||
marginLeft: '12px',
|
||||
boxShadow: '0 0 6px rgba(74,155,255,0.5)',
|
||||
position: 'relative',
|
||||
top: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
474
components/settings/tabs/SettingsMonitoringTab.tsx
Normal file
474
components/settings/tabs/SettingsMonitoringTab.tsx
Normal file
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* Settings Monitoring Tab —— 监控大屏地图底图 + 相机参数配置
|
||||
*
|
||||
* 底图:上传 png/jpg/webp 到 userData/monitoring/maps/,拓扑图热替换。
|
||||
* 相机:倾斜角 / 距离 / FOV / 方位角 / 自动旋转 写 localStorage,
|
||||
* MonitoringTopologyView 通过 storage 事件监听 + cameraAnimRef 平滑过渡。
|
||||
*/
|
||||
|
||||
import { Image, Upload, Trash2, Check, RotateCcw, Camera, Maximize2, ZoomIn, AlertTriangle } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button } from "../../ui/button";
|
||||
import { ScrollArea } from "../../ui/scroll-area";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { toast } from "../../ui/toast";
|
||||
import { SectionHeader, SettingCard, SettingsTabContent } from "../settings-ui";
|
||||
|
||||
const ALLOWED_EXTS = [".png", ".jpg", ".jpeg", ".webp"];
|
||||
const ALLOWED_ACCEPT = "image/png,image/jpeg,image/jpg,image/webp";
|
||||
const MAX_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
const BG_CHANGED_EVENT = "monitoring-map-bg-changed";
|
||||
const CAMERA_CONFIG_KEY = "monitoring-camera-config";
|
||||
|
||||
interface MapBgEntry {
|
||||
filename: string;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
interface CameraConfig {
|
||||
elevation: number;
|
||||
azimuth: number;
|
||||
distance: number;
|
||||
fov: number;
|
||||
autoRotate: boolean;
|
||||
autoRotateSpeed: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CAMERA: CameraConfig = {
|
||||
elevation: 40,
|
||||
azimuth: 45,
|
||||
distance: 130,
|
||||
fov: 60,
|
||||
autoRotate: true,
|
||||
autoRotateSpeed: 0.4,
|
||||
};
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (!bytes || bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
function formatDate(ms: number): string {
|
||||
if (!ms) return "";
|
||||
try { return new Date(ms).toLocaleString(); } catch { return ""; }
|
||||
}
|
||||
|
||||
function emitBgChanged() {
|
||||
try { window.dispatchEvent(new Event(BG_CHANGED_EVENT)); } catch {}
|
||||
}
|
||||
function emitCameraChanged() {
|
||||
try { window.dispatchEvent(new Event("monitoring-camera-changed")); } catch {}
|
||||
}
|
||||
|
||||
function getInspectionApi() {
|
||||
const nc = typeof window !== "undefined" ? (window as any).netcatty : null;
|
||||
return nc?.inspection ?? null;
|
||||
}
|
||||
|
||||
function readCameraConfig(): CameraConfig {
|
||||
try {
|
||||
const raw = localStorage.getItem(CAMERA_CONFIG_KEY);
|
||||
if (raw) {
|
||||
const v = JSON.parse(raw);
|
||||
return { ...DEFAULT_CAMERA, ...v };
|
||||
}
|
||||
} catch {}
|
||||
return { ...DEFAULT_CAMERA };
|
||||
}
|
||||
function writeCameraConfig(cfg: CameraConfig) {
|
||||
try {
|
||||
localStorage.setItem(CAMERA_CONFIG_KEY, JSON.stringify(cfg));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function SliderRow({
|
||||
label, value, min, max, step, unit, onChange,
|
||||
}: {
|
||||
label: React.ReactNode; value: number; min: number; max: number; step?: number; unit?: string;
|
||||
onChange: (v: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-mono text-foreground/80">
|
||||
{value}{unit || ""}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step ?? 1}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="w-full accent-primary"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsMonitoringTab() {
|
||||
const api = getInspectionApi();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [entries, setEntries] = useState<MapBgEntry[]>([]);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const [camera, setCamera] = useState<CameraConfig>(() => readCameraConfig());
|
||||
|
||||
const isAvailable = !!api;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!api) return;
|
||||
let list: MapBgEntry[] = [];
|
||||
let previewDataUrl: string | null = null;
|
||||
let previewFilename: string | null = null;
|
||||
|
||||
try {
|
||||
const raw = (await api.listMapBgs()) as MapBgEntry[];
|
||||
list = Array.isArray(raw) ? raw : [];
|
||||
} catch (e) {
|
||||
console.warn("[SettingsMonitoringTab] listMapBgs failed:", e);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.getMapBgData();
|
||||
if (res?.ok && typeof res.dataUrl === "string" && res.dataUrl.length > 0) {
|
||||
previewDataUrl = res.dataUrl;
|
||||
previewFilename = res.filename || null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[SettingsMonitoringTab] getMapBgData failed:", e);
|
||||
}
|
||||
|
||||
setEntries(list);
|
||||
setSelected(previewFilename);
|
||||
setPreviewUrl(previewDataUrl);
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => { void refresh(); }, [refresh]);
|
||||
|
||||
// 相机参数任何变化 → 写 localStorage + debounced 走主进程 IPC 广播
|
||||
useEffect(() => {
|
||||
writeCameraConfig(camera);
|
||||
emitCameraChanged();
|
||||
|
||||
// debounce 后调 IPC:Slider 拖动很频繁,主进程广播需要节流
|
||||
if (!api?.setCameraConfig) return;
|
||||
const t = setTimeout(() => {
|
||||
api.setCameraConfig(camera).catch(() => {});
|
||||
}, 150);
|
||||
return () => clearTimeout(t);
|
||||
}, [camera, api]);
|
||||
|
||||
const handleFiles = useCallback(async (files: FileList | null) => {
|
||||
if (!api || !files || files.length === 0) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const ext = "." + file.name.split(".").pop()?.toLowerCase();
|
||||
if (!ALLOWED_EXTS.includes(ext)) {
|
||||
toast.warning(`跳过不支持的文件 ${file.name}`);
|
||||
continue;
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
toast.warning(`${file.name} 超过 20MB 上限`);
|
||||
continue;
|
||||
}
|
||||
const dataUrl: string = await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(new Error("FileReader failed"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
const base64 = dataUrl.split(",", 2)[1] || "";
|
||||
const res = await api.uploadMapBg(file.name, base64);
|
||||
if (res?.ok) {
|
||||
toast.success("上传成功");
|
||||
const sel = await api.selectMapBg(res.filename);
|
||||
if (sel?.ok) emitBgChanged();
|
||||
} else {
|
||||
toast.error(`上传失败: ${res?.error || "未知"}`);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error("[SettingsMonitoringTab] upload error:", e);
|
||||
toast.error(`上传失败: ${e?.message || String(e)}`);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
void refresh();
|
||||
}
|
||||
}, [api, refresh]);
|
||||
|
||||
const handleSelect = useCallback(async (filename: string) => {
|
||||
if (!api) return;
|
||||
const res = await api.selectMapBg(filename);
|
||||
if (res?.ok) { emitBgChanged(); await refresh(); }
|
||||
else toast.error(`切换失败: ${res?.error || ""}`);
|
||||
}, [api, refresh]);
|
||||
|
||||
const handleDelete = useCallback(async (filename: string) => {
|
||||
if (!api) return;
|
||||
const res = await api.deleteMapBg(filename);
|
||||
if (res?.ok) { emitBgChanged(); await refresh(); }
|
||||
else toast.error(`删除失败: ${res?.error || ""}`);
|
||||
}, [api, refresh]);
|
||||
|
||||
const handleReset = useCallback(async () => {
|
||||
if (!api) return;
|
||||
const res = await api.resetMapBg();
|
||||
if (res?.ok) {
|
||||
emitBgChanged();
|
||||
toast.success("已恢复默认地图");
|
||||
setSelected(null);
|
||||
setPreviewUrl(null);
|
||||
await refresh();
|
||||
} else toast.error(`恢复默认失败: ${res?.error || ""}`);
|
||||
}, [api, refresh]);
|
||||
|
||||
const handleResetCamera = useCallback(() => {
|
||||
setCamera({ ...DEFAULT_CAMERA });
|
||||
toast.success("已恢复默认相机参数");
|
||||
}, []);
|
||||
|
||||
const sortedEntries = useMemo(
|
||||
() => [...entries].sort((a, b) => b.mtimeMs - a.mtimeMs),
|
||||
[entries],
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsTabContent value="monitoring">
|
||||
<SectionHeader title="监控大屏" />
|
||||
|
||||
{!isAvailable && (
|
||||
<SettingCard padded>
|
||||
<div className="flex items-start gap-2 text-amber-500">
|
||||
<AlertTriangle size={18} className="mt-0.5" />
|
||||
<div className="text-sm text-muted-foreground">
|
||||
底图上传仅在 Electron 本机可用。远程浏览器版本暂不支持。
|
||||
</div>
|
||||
</div>
|
||||
</SettingCard>
|
||||
)}
|
||||
|
||||
{isAvailable && (
|
||||
<>
|
||||
{/* 当前底图 + 紧凑上传 */}
|
||||
<SettingCard padded>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Image size={14} className="text-muted-foreground" />
|
||||
<span className="text-sm font-medium">底图</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({selected ? "自定义" : "内置 /ditu.png"})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ALLOWED_ACCEPT}
|
||||
className="hidden"
|
||||
onChange={(e) => void handleFiles(e.target.files)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={uploading}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload size={12} className="mr-1" />
|
||||
{uploading ? "上传中..." : "上传新底图"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={!selected}
|
||||
>
|
||||
<RotateCcw size={12} className="mr-1" /> 恢复默认
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-muted/30 overflow-hidden">
|
||||
{previewUrl ? (
|
||||
<div className="relative bg-black">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="current map bg"
|
||||
className="w-full max-h-36 object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
<div className="absolute bottom-1 right-2 rounded bg-black/60 px-2 py-0.5 text-[11px] text-white/90">
|
||||
{selected}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-20 items-center justify-center text-xs text-muted-foreground">
|
||||
使用内置 /ditu.png 默认底图
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
{/* 已上传底图列表 */}
|
||||
<SettingCard padded>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">已上传底图</span>
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
|
||||
{sortedEntries.length}
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => void refresh()}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="max-h-56 rounded-md border border-border">
|
||||
{sortedEntries.length === 0 ? (
|
||||
<div className="flex h-20 items-center justify-center text-xs text-muted-foreground">
|
||||
尚未上传自定义底图
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{sortedEntries.map((entry) => {
|
||||
const isActive = selected === entry.filename;
|
||||
return (
|
||||
<li
|
||||
key={entry.filename}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-1.5 text-sm",
|
||||
isActive && "bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className={cn(
|
||||
"truncate",
|
||||
isActive ? "font-medium text-foreground" : "text-foreground/90",
|
||||
)}
|
||||
title={entry.filename}
|
||||
>
|
||||
{entry.filename}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{formatSize(entry.size)} · {formatDate(entry.mtimeMs)}
|
||||
</div>
|
||||
</div>
|
||||
{isActive ? (
|
||||
<span className="flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[11px] text-primary">
|
||||
<Check size={10} /> 当前
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void handleSelect(entry.filename)}
|
||||
>
|
||||
应用
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void handleDelete(entry.filename)}
|
||||
title="删除"
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</SettingCard>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 相机视角配置(任何环境都渲染 —— 不依赖 inspection API) */}
|
||||
<SettingCard padded className="space-y-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Camera size={14} className="text-muted-foreground" />
|
||||
<span className="text-sm font-medium">拓扑相机视角</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={handleResetCamera}>
|
||||
<RotateCcw size={12} className="mr-1" /> 默认
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<SliderRow
|
||||
label={<span className="flex items-center gap-1.5"><Maximize2 size={12} className="text-muted-foreground" />倾斜角(俯视 → 侧视)</span>}
|
||||
value={camera.elevation}
|
||||
min={15}
|
||||
max={85}
|
||||
onChange={(v) => setCamera((c) => ({ ...c, elevation: v }))}
|
||||
unit="°"
|
||||
/>
|
||||
<SliderRow
|
||||
label={<span className="flex items-center gap-1.5"><ZoomIn size={12} className="text-muted-foreground" />相机距离(近 → 远)</span>}
|
||||
value={camera.distance}
|
||||
min={30}
|
||||
max={220}
|
||||
onChange={(v) => setCamera((c) => ({ ...c, distance: v }))}
|
||||
/>
|
||||
<SliderRow
|
||||
label="方位角(相机水平旋转)"
|
||||
value={camera.azimuth}
|
||||
min={0}
|
||||
max={359}
|
||||
onChange={(v) => setCamera((c) => ({ ...c, azimuth: v }))}
|
||||
unit="°"
|
||||
/>
|
||||
<SliderRow
|
||||
label="视野角 FOV(广角 → 长焦)"
|
||||
value={camera.fov}
|
||||
min={25}
|
||||
max={95}
|
||||
onChange={(v) => setCamera((c) => ({ ...c, fov: v }))}
|
||||
unit="°"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={camera.autoRotate}
|
||||
onChange={(e) => setCamera((c) => ({ ...c, autoRotate: e.target.checked }))}
|
||||
className="accent-primary"
|
||||
/>
|
||||
自动旋转
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>旋转速度</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0.05}
|
||||
max={2}
|
||||
step={0.05}
|
||||
value={camera.autoRotateSpeed}
|
||||
onChange={(e) => setCamera((c) => ({ ...c, autoRotateSpeed: Number(e.target.value) }))}
|
||||
className="w-28 accent-primary"
|
||||
/>
|
||||
<span className="font-mono w-10">{camera.autoRotateSpeed.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SettingCard>
|
||||
</SettingsTabContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsMonitoringTab;
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React, { useCallback, lazy, Suspense, useState, useEffect } from "react";
|
||||
import { AlertTriangle, Bell, Activity, Network, FileText } from "lucide-react";
|
||||
import { AlertTriangle, Bell, Activity, Network, FileText, BarChart3 } from "lucide-react";
|
||||
import { WorkbenchView } from "./WorkbenchView";
|
||||
import { deleteVaultKey } from "../../application/defaultKeyPassphrases";
|
||||
import { usePluginImporterCommit } from "../../application/state/usePluginImporterCommit";
|
||||
import { preserveConcurrentHostLineTimestampUpdate } from "../../domain/host";
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
} from "./VaultPageHeader";
|
||||
import { useConnectionLogsStore } from "../../application/state/connectionLogsStore";
|
||||
import { useNotesStore } from "../../application/state/notesStore";
|
||||
import { useSettingsState } from "../../application/state/useSettingsState";
|
||||
import { LazyLoadBoundary } from "../ui/lazy-load-boundary";
|
||||
import { toast } from "../ui/toast";
|
||||
import { AppWordmark } from "../AppWordmark";
|
||||
@@ -35,6 +37,8 @@ const InspectionCenterPage = lazy(() => import("../inspection/InspectionCenterPa
|
||||
const MonitoringTopologyView = lazy(() => import("../monitoring/MonitoringTopologyView"));
|
||||
// 巡检报告
|
||||
const ReportPage = lazy(() => import("../inspection/ReportPage"));
|
||||
// 设置页面(远程访问降级用)
|
||||
const SettingsPage = lazy(() => import("../SettingsPage"));
|
||||
|
||||
type VaultViewLayoutContext = Record<string, any>;
|
||||
|
||||
@@ -111,10 +115,12 @@ function VaultConnectionLogsSection({
|
||||
ConnectionLogsManager,
|
||||
hosts,
|
||||
onOpenLogView,
|
||||
initialLogId,
|
||||
}: {
|
||||
ConnectionLogsManager: React.ComponentType<any>;
|
||||
hosts: any[];
|
||||
onOpenLogView: (log: any) => void;
|
||||
initialLogId?: string;
|
||||
}) {
|
||||
const {
|
||||
connectionLogs,
|
||||
@@ -122,6 +128,14 @@ function VaultConnectionLogsSection({
|
||||
deleteConnectionLog,
|
||||
clearUnsavedConnectionLogs,
|
||||
} = useConnectionLogsStore();
|
||||
|
||||
// 从工作台跳转时自动打开指定 log 的详情
|
||||
React.useEffect(() => {
|
||||
if (!initialLogId) return;
|
||||
const log = connectionLogs.find((l: any) => l.id === initialLogId);
|
||||
if (log) onOpenLogView(log);
|
||||
}, [initialLogId, connectionLogs, onOpenLogView]);
|
||||
|
||||
return (
|
||||
<ConnectionLogsManager
|
||||
logs={connectionLogs}
|
||||
@@ -137,6 +151,34 @@ function VaultConnectionLogsSection({
|
||||
export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
const IS_REMOTE = typeof window !== "undefined" && !!(window as any).__REMOTE_TOKEN__;
|
||||
|
||||
// ─── 设置权威源同步(远程浏览器) ───
|
||||
// 1. HTML 注入脚本里的同步 XHR 已在 React 启动前把权威设置填到 localStorage
|
||||
// 2. 这里注册写后钩子:每次 localStorageAdapter 写都 POST 回权威源
|
||||
React.useEffect(() => {
|
||||
if (!IS_REMOTE) return;
|
||||
const TOKEN = (window as any).__REMOTE_TOKEN__;
|
||||
const API_BASE = `${window.location.origin}`;
|
||||
|
||||
(globalThis as any).__NETCATTY_SETTINGS_SYNC__ = (key: string, value: string | null) => {
|
||||
fetch(`${API_BASE}/api/settings?token=${encodeURIComponent(TOKEN)}`, {
|
||||
method: value === null ? "DELETE" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: value === null ? JSON.stringify({ keys: [key] }) : JSON.stringify({ [key]: value }),
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
return () => {
|
||||
if ((globalThis as any).__NETCATTY_SETTINGS_SYNC__) {
|
||||
(globalThis as any).__NETCATTY_SETTINGS_SYNC__ = null;
|
||||
}
|
||||
};
|
||||
}, [IS_REMOTE]);
|
||||
|
||||
const remoteSettings = useSettingsState();
|
||||
|
||||
// 工作台跳转时带过来的预选中项(alertId / logId / hostId)
|
||||
const pendingSelectionRef = React.useRef<{ alertId?: string; logId?: string; hostId?: string } | null>(null);
|
||||
|
||||
// 产品名称 - 直接读取 localStorage,监听 storage 事件(来自其他标签页)和自定义事件
|
||||
const [productName, setProductName] = useState(() => localStorageAdapter.readString(STORAGE_KEY_PRODUCT_NAME) || 'NetMesh');
|
||||
|
||||
@@ -444,6 +486,16 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
),
|
||||
[displayedGroups, viewMode, visibleTreeGroupPaths],
|
||||
);
|
||||
// 当切到 hosts section 且带了 hostId,自动选中
|
||||
React.useEffect(() => {
|
||||
if (currentSection !== "hosts") return;
|
||||
const pending = pendingSelectionRef.current;
|
||||
if (pending?.hostId) {
|
||||
setSelectedHostIds(new Set([pending.hostId]));
|
||||
pendingSelectionRef.current = null; // 消费掉
|
||||
}
|
||||
}, [currentSection, setSelectedHostIds]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedGroupPaths((current: Set<string>) => {
|
||||
const next = retainVisibleVaultGroupSelection(current, visibleSelectableGroupPaths);
|
||||
@@ -457,6 +509,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
resizeAriaLabel: t("vault.panel.resizeWidth"),
|
||||
};
|
||||
const [isSidebarResizing, setIsSidebarResizing] = React.useState(false);
|
||||
const [showRemoteSettings, setShowRemoteSettings] = React.useState(false);
|
||||
const keyListRef = React.useRef(keys);
|
||||
keyListRef.current = keys;
|
||||
const newHostActionsRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -600,6 +653,30 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
<div
|
||||
className={cn("space-y-1", sidebarCollapsed ? "px-1.5" : "px-2.5")}
|
||||
>
|
||||
{/* 工作台 */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
variant={currentSection === "workbench" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
sidebarCollapsed
|
||||
? "justify-center p-0"
|
||||
: "justify-start gap-3",
|
||||
currentSection === "workbench" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => setCurrentSection("workbench")}
|
||||
>
|
||||
<BarChart3 size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && "工作台"}
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && (
|
||||
<TooltipContent side="right">工作台</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
@@ -948,7 +1025,13 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
? "justify-center p-0"
|
||||
: "justify-start gap-3",
|
||||
)}
|
||||
onClick={onOpenSettings}
|
||||
onClick={() => {
|
||||
if (IS_REMOTE) {
|
||||
setShowRemoteSettings(true);
|
||||
} else {
|
||||
onOpenSettings();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Settings size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("common.settings")}
|
||||
@@ -1383,6 +1466,27 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 工作台 */}
|
||||
{currentSection === "workbench" && (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<WorkbenchView
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
proxies={[]}
|
||||
snippetCount={snippets?.length ?? 0}
|
||||
activePortForwardCount={0}
|
||||
onNavigate={(section: string, opts?: any) => {
|
||||
if (opts) pendingSelectionRef.current = opts;
|
||||
setCurrentSection(section);
|
||||
}}
|
||||
onConnectHost={(hostId: string) => {
|
||||
const h = hosts?.find((x: any) => x.id === hostId);
|
||||
if (h) handleHostConnect(h);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentSection === "snippets" && (
|
||||
<LazyLoadBoundary name="Snippets" resetKey="snippets">
|
||||
<Suspense fallback={<VaultSectionLoading />}>
|
||||
@@ -1556,6 +1660,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
ConnectionLogsManager={LazyConnectionLogsManager}
|
||||
hosts={hosts}
|
||||
onOpenLogView={onOpenLogView}
|
||||
initialLogId={pendingSelectionRef.current?.logId ?? undefined}
|
||||
/>
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
@@ -1580,7 +1685,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
<Suspense fallback={<VaultSectionLoading />}>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<InspectionErrorBoundary>
|
||||
<AlertCenterPage />
|
||||
<AlertCenterPage initialAlertId={pendingSelectionRef.current?.alertId ?? undefined} />
|
||||
</InspectionErrorBoundary>
|
||||
</div>
|
||||
</Suspense>
|
||||
@@ -2028,6 +2133,25 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
onUpdateHosts([...hosts, host]);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Remote Settings Dialog (远程访问设置面板) */}
|
||||
{IS_REMOTE && (
|
||||
<Dialog open={showRemoteSettings} onOpenChange={setShowRemoteSettings}>
|
||||
<DialogContent className="max-w-5xl max-h-[92vh] overflow-auto p-0 gap-0">
|
||||
<DialogHeader className="px-6 pt-5 pb-3 border-b border-border">
|
||||
<DialogTitle>{t("common.settings")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Suspense fallback={<div className="p-8 text-center text-muted-foreground">加载设置中...</div>}>
|
||||
<SettingsPage
|
||||
settings={remoteSettings as any}
|
||||
appLock={{ enabled: false, locked: false } as any}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
526
components/vault/WorkbenchView.tsx
Normal file
526
components/vault/WorkbenchView.tsx
Normal file
@@ -0,0 +1,526 @@
|
||||
/**
|
||||
* Workbench (工作台) —— NetMesh 概览 Dashboard v2
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import * as echarts from "echarts";
|
||||
import {
|
||||
Server, KeyRound, GitBranch, FileCode, Network, AlertTriangle, Activity,
|
||||
Zap, Loader2, Terminal, ArrowRight, ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
import { ScrollArea } from "../ui/scroll-area";
|
||||
import { useConnectionLogsStore } from "../../application/state/connectionLogsStore";
|
||||
import {
|
||||
inspectionDeviceApi, inspectionCatalogApi, inspectionResultApi,
|
||||
inspectionAlertApi, inspectionReportApi, inspectionDailyReportApi,
|
||||
type DeviceExtension, type DeviceCatalog, type DailyReport,
|
||||
type AlertRecord, type ReportSnapshot,
|
||||
} from "../../infrastructure/inspection/inspectionApi";
|
||||
|
||||
/* =================== 类型 =================== */
|
||||
interface WorkbenchHost { id?: string; label?: string; hostname?: string; tags?: string[]; group?: string; deviceType?: string; }
|
||||
interface WorkbenchKey { id?: string; label?: string; }
|
||||
interface WorkbenchProxy { id?: string; label?: string; }
|
||||
interface WorkbenchSectionProps {
|
||||
hosts: WorkbenchHost[]; keys: WorkbenchKey[]; proxies: WorkbenchProxy[];
|
||||
snippetCount?: number; activePortForwardCount?: number;
|
||||
onNavigate: (section: string, opts?: { alertId?: string; logId?: string; hostId?: string }) => void;
|
||||
onConnectHost?: (hostId: string) => void;
|
||||
}
|
||||
interface InspectResult { deviceId?: string; hostId?: string; device_id?: string;
|
||||
metrics?: { cpu?: number; memory?: number; online?: boolean };
|
||||
timestamp?: number; collectedAt?: number | string; status?: string; online?: boolean; }
|
||||
|
||||
function getInspectionApi(): any { try { return (window as any).netcatty?.inspection ?? null; } catch { return null; } }
|
||||
|
||||
/* =================== 设备类别定义(单一数据源) =================== */
|
||||
const DEVICE_CATEGORY_META: Record<string, { label: string; color: string }> = {
|
||||
server: { label: "服务器", color: "#3b82f6" },
|
||||
switch: { label: "交换机", color: "#10b981" },
|
||||
router: { label: "路由器", color: "#f59e0b" },
|
||||
firewall: { label: "防火墙", color: "#ef4444" },
|
||||
storage: { label: "存储设备", color: "#8b5cf6" },
|
||||
workstation:{ label: "工作站", color: "#06b6d4" },
|
||||
other: { label: "未分类", color: "#64748b" },
|
||||
};
|
||||
const DEVICE_CATEGORY_KEYS = Object.keys(DEVICE_CATEGORY_META);
|
||||
function deviceTypeLabel(t?: string): string {
|
||||
const meta = DEVICE_CATEGORY_META[(t || "").toLowerCase()];
|
||||
return meta ? meta.label : "未分类";
|
||||
}
|
||||
function deviceTypeColor(key: string): string {
|
||||
return DEVICE_CATEGORY_META[key]?.color ?? "#64748b";
|
||||
}
|
||||
const CATALOG_TYPE_TO_DISPLAY: Record<string, string> = {
|
||||
linux: "server", windows: "server", huawei_h3c_switch: "switch", cisco_switch: "switch",
|
||||
huawei_firewall: "firewall", fortinet_firewall: "firewall",
|
||||
};
|
||||
function mapCatalogTypeKeyToCategory(tk: string, name: string, vendor: string): string {
|
||||
const key = tk.toLowerCase();
|
||||
if (CATALOG_TYPE_TO_DISPLAY[key]) return CATALOG_TYPE_TO_DISPLAY[key];
|
||||
if (DEVICE_CATEGORY_KEYS.includes(key)) return key;
|
||||
const blob = `${tk} ${name} ${vendor}`.toLowerCase();
|
||||
if (/h3c|huawei|vrp|comware|cisco|switch|交换机/.test(blob)) return "switch";
|
||||
if (/router|路由/.test(blob)) return "router";
|
||||
if (/firewall|防火墙|\bfw\b/.test(blob)) return "firewall";
|
||||
if (/storage|存储|nas|san/.test(blob)) return "storage";
|
||||
if (/workstation|\bpc\b|桌面/.test(blob)) return "workstation";
|
||||
if (/linux|windows|server|服务器/.test(blob)) return "server";
|
||||
return "other";
|
||||
}
|
||||
function classifyHostType(host: WorkbenchHost, ext?: DeviceExtension, catalogs?: DeviceCatalog[]): string {
|
||||
if (ext?.catalog_id && catalogs && catalogs.length > 0) {
|
||||
const cat = catalogs.find((c) => c.id === ext!.catalog_id);
|
||||
if (cat) return mapCatalogTypeKeyToCategory(cat.type_key || "", cat.name || "", cat.vendor || "");
|
||||
}
|
||||
const directType = (host as any).type;
|
||||
if (directType && DEVICE_CATEGORY_KEYS.includes(directType)) return directType;
|
||||
const distro = ((host as any).distro || (host as any).catalog || (host as any).catalog_id || "").toLowerCase();
|
||||
if (distro) return mapCatalogTypeKeyToCategory(distro, "", "");
|
||||
const label = (host.label || host.hostname || "").toLowerCase();
|
||||
if (/h3c|huawei|vrp|comware|cisco|switch|交换机/.test(label)) return "switch";
|
||||
if (/router|路由/.test(label)) return "router";
|
||||
if (/firewall|防火墙/.test(label)) return "firewall";
|
||||
if (/storage|存储|nas|san/.test(label)) return "storage";
|
||||
if (/workstation|\bpc\b|桌面/.test(label)) return "workstation";
|
||||
if (/linux|windows|server|服务器/.test(label)) return "server";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function formatTimeAgo(input: number | string | undefined): string {
|
||||
if (!input) return "-";
|
||||
const t = typeof input === "number" ? input : new Date(input).getTime();
|
||||
const diff = Date.now() - t;
|
||||
if (diff < 60_000) return "刚刚";
|
||||
if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前`;
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3600_000)} 小时前`;
|
||||
return `${Math.floor(diff / 86_400_000)} 天前`;
|
||||
}
|
||||
|
||||
/* =================== KPI 卡 =================== */
|
||||
function KpiCard({ icon: Icon, label, value, sub, accent = "bg-primary/10 text-primary" }:
|
||||
{ icon: React.ElementType; label: string; value: React.ReactNode; sub?: string; accent?: string; }) {
|
||||
return (
|
||||
<div className="group relative overflow-hidden rounded-xl border border-border/50 bg-gradient-to-br from-card to-card/80 p-3 shadow-sm transition-all hover:-translate-y-0.5 hover:border-primary/25 hover:shadow-md">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className={cn("flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg shadow-sm ring-1 ring-inset ring-current/10 transition-transform group-hover:scale-110 group-hover:rotate-3", accent)}>
|
||||
<Icon size={15} strokeWidth={2.2} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<div className="text-xl font-bold tabular-nums leading-none tracking-tight text-foreground">{value}</div>
|
||||
{sub && <span className="text-[10px] font-medium text-muted-foreground">{sub}</span>}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] font-medium tracking-wide text-muted-foreground/75">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function SectionTitle({ title, action }: { title: string; action?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-2.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-4 w-1 rounded-full bg-primary/70" />
|
||||
<h3 className="text-sm font-semibold tracking-wide text-foreground/90">{title}</h3>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* =================== 通用 ECharts hook =================== */
|
||||
function useChart(optionFn: () => echarts.EChartsOption | null, deps: React.DependencyList) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<echarts.ECharts | null>(null);
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
if (!chartRef.current) {
|
||||
chartRef.current = echarts.init(ref.current);
|
||||
const onResize = () => chartRef.current?.resize();
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => { window.removeEventListener("resize", onResize); chartRef.current?.dispose(); chartRef.current = null; };
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!chartRef.current) return;
|
||||
const opt = optionFn();
|
||||
if (opt) chartRef.current.setOption(opt, true); else chartRef.current.clear();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
return ref;
|
||||
}
|
||||
|
||||
/* =================== 近一周正常/离线堆叠柱状图 =================== */
|
||||
function WeeklyOnlineOfflineBarChart({ results }: { results: InspectResult[] }) {
|
||||
const ref = useChart(() => {
|
||||
const days: string[] = [], online: number[] = [], offline: number[] = [];
|
||||
const now = new Date();
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const d = new Date(now); d.setDate(d.getDate() - i);
|
||||
days.push(`${d.getMonth() + 1}/${d.getDate()}`);
|
||||
const start = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
const end = start + 86_400_000;
|
||||
const latestByDevice = new Map<string, InspectResult>();
|
||||
for (const r of results) {
|
||||
const t = typeof (r as any).timestamp === "number" ? (r as any).timestamp : new Date((r as any).collectedAt || 0).getTime();
|
||||
if (t < start || t >= end) continue;
|
||||
const did = (r as any).device_id || r.deviceId || r.hostId; if (!did) continue;
|
||||
const exist = latestByDevice.get(did); const eTs = exist ? ((exist as any).timestamp || 0) : 0;
|
||||
if (!exist || t > eTs) latestByDevice.set(did, r);
|
||||
}
|
||||
let on = 0, off = 0;
|
||||
for (const r of latestByDevice.values()) { if (r.online === false || r.metrics?.online === false) off++; else on++; }
|
||||
online.push(on); offline.push(off);
|
||||
}
|
||||
const totalOn = online.reduce((a, b) => a + b, 0), totalOff = offline.reduce((a, b) => a + b, 0);
|
||||
return {
|
||||
grid: { left: 32, right: 12, top: 28, bottom: 28 },
|
||||
tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
|
||||
legend: { top: 4, right: 12, textStyle: { fontSize: 11, color: "var(--muted-foreground)" }, itemWidth: 10, itemHeight: 6 },
|
||||
title: { text: `${totalOn + totalOff} 台次 · ${totalOn} 在线 / ${totalOff} 离线`,
|
||||
textStyle: { fontSize: 11, color: "var(--muted-foreground)", fontWeight: 400 }, left: 12, top: 4 },
|
||||
xAxis: { type: "category", data: days, axisLabel: { fontSize: 10, color: "var(--muted-foreground)" },
|
||||
axisLine: { lineStyle: { color: "var(--border)" } }, axisTick: { show: false } },
|
||||
yAxis: { type: "value", splitLine: { lineStyle: { color: "var(--border)", type: "dashed" } },
|
||||
axisLabel: { fontSize: 10, color: "var(--muted-foreground)" } },
|
||||
series: [
|
||||
{ name: "在线", type: "bar", stack: "status", barWidth: "48%", emphasis: { focus: "series" }, data: online,
|
||||
itemStyle: { borderRadius: [0, 0, 0, 0], color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [{ offset: 0, color: "#10b981" }, { offset: 1, color: "#059669" }] } } },
|
||||
{ name: "离线", type: "bar", stack: "status", emphasis: { focus: "series" }, data: offline,
|
||||
itemStyle: { borderRadius: [4, 4, 0, 0], color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [{ offset: 0, color: "#ef4444" }, { offset: 1, color: "#b91c1c" }] } } },
|
||||
],
|
||||
} as echarts.EChartsOption;
|
||||
}, [results]);
|
||||
return <div ref={ref} className="h-[170px] w-full" />;
|
||||
}
|
||||
|
||||
/* =================== 设备类型分布圆环图 =================== */
|
||||
function DeviceTypeDonutChart({ hosts, extensions, catalogs }:
|
||||
{ hosts: WorkbenchHost[]; extensions?: Record<string, DeviceExtension>; catalogs?: DeviceCatalog[] }) {
|
||||
const ref = useChart(() => {
|
||||
// 从 DEVICE_CATEGORY_META 动态构建 counts,彻底消除硬编码
|
||||
const counts: Record<string, number> = {};
|
||||
for (const key of DEVICE_CATEGORY_KEYS) counts[key] = 0;
|
||||
for (const h of hosts) {
|
||||
const ext = extensions && h.id ? extensions[h.id] : undefined;
|
||||
const cat = classifyHostType(h, ext, catalogs);
|
||||
if (counts[cat] !== undefined) counts[cat]++;
|
||||
else counts["other"]++; // 兜底:未知类别归入 other
|
||||
}
|
||||
const data = DEVICE_CATEGORY_KEYS
|
||||
.map((k) => ({ name: deviceTypeLabel(k), value: counts[k], color: deviceTypeColor(k) }))
|
||||
.filter((d) => d.value > 0);
|
||||
const total = data.reduce((s, d) => s + d.value, 0);
|
||||
if (total === 0) return null;
|
||||
return {
|
||||
tooltip: { trigger: "item", formatter: "{b}: {c} 台 ({d}%)" },
|
||||
title: { text: `${total}`, subtext: "台设备", left: "34%", top: "center",
|
||||
textStyle: { fontSize: 20, fontWeight: 700, color: "var(--foreground)" },
|
||||
subtextStyle: { fontSize: 10, color: "var(--muted-foreground)" } },
|
||||
series: [{ type: "pie", radius: ["48%", "72%"], center: ["34%", "50%"], avoidLabelOverlap: true,
|
||||
itemStyle: { borderRadius: 4, borderColor: "var(--card)", borderWidth: 2 }, label: { show: false }, labelLine: { show: false },
|
||||
data: data.map((d) => ({ name: d.name, value: d.value, itemStyle: { color: d.color } })) }],
|
||||
// graphic 直接用 data[i].color,不再靠 label 反查 index
|
||||
graphic: data.map((d, i) => ({
|
||||
type: "text", left: "68%", top: `${18 + i * 16}%`,
|
||||
style: {
|
||||
text: `{dot|} {lbl|${d.name}} {val|${d.value}} {pct|${((d.value / total) * 100).toFixed(1)}%}`,
|
||||
rich: {
|
||||
dot: { width: 8, height: 8, backgroundColor: d.color, borderRadius: 4, padding: [0, 6, 0, 0] },
|
||||
lbl: { color: "var(--foreground)", fontSize: 11 },
|
||||
val: { color: "var(--foreground)", fontSize: 12, fontWeight: 600, padding: [0, 5, 0, 0] },
|
||||
pct: { color: "var(--muted-foreground)", fontSize: 11 },
|
||||
},
|
||||
},
|
||||
})),
|
||||
} as echarts.EChartsOption;
|
||||
}, [hosts, extensions, catalogs]);
|
||||
return <div ref={ref} className="h-[170px] w-full" />;
|
||||
}
|
||||
|
||||
/* =================== 滚动行组件 =================== */
|
||||
function AlertScrollRow({ alert, onClick }: { alert: AlertRecord; onClick?: () => void }) {
|
||||
const levelStyles: Record<string, string> = { critical: "bg-red-500/15 text-red-500", warning: "bg-amber-500/15 text-amber-500", info: "bg-sky-500/15 text-sky-500" };
|
||||
const levelLabel: Record<string, string> = { critical: "严重", warning: "警告", info: "提示" };
|
||||
return (
|
||||
<button type="button" onClick={onClick} className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted/40">
|
||||
<span className={cn("flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-[10px] font-semibold", levelStyles[alert.level] || levelStyles.warning)}>
|
||||
{(levelLabel[alert.level] || "警告")[0]}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-foreground/90">{alert.message || "未知告警"}</div>
|
||||
<div className="truncate text-[10px] text-muted-foreground">{alert.device_name || alert.device_id || "-"} · {alert.metric || "—"} = {alert.metric_value ?? "?"}</div>
|
||||
</div>
|
||||
<span className="flex-shrink-0 text-[10px] text-muted-foreground">{formatTimeAgo(alert.triggered_at)}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
function ReportScrollRow({ report, onClick }: { report: ReportSnapshot; onClick?: () => void }) {
|
||||
const dateLabel = new Date(report.timestamp).toLocaleDateString();
|
||||
return (
|
||||
<button type="button" onClick={onClick} className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted/40">
|
||||
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-indigo-500/15 text-indigo-500"><Activity size={10} /></div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-foreground/90">巡检快照 · {dateLabel}</div>
|
||||
<div className="truncate text-[10px] text-muted-foreground">{report.devices?.length ?? 0} 台设备 · {(report.duration_ms / 1000).toFixed(1)}s · {report.trigger}</div>
|
||||
</div>
|
||||
<span className="flex-shrink-0 text-[10px] text-muted-foreground">{formatTimeAgo(report.timestamp)}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
function DailyReportScrollRow({ report, onClick }: { report: DailyReport; onClick?: () => void }) {
|
||||
const modeLabel = report.mode === "finalized" ? "已发布" : "待确认";
|
||||
const statusColor = report.mode === "finalized" ? "bg-emerald-500/15 text-emerald-500" : "bg-amber-500/15 text-amber-500";
|
||||
return (
|
||||
<button type="button" onClick={onClick} className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted/40">
|
||||
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-sky-500/15 text-sky-500"><Activity size={10} /></div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-foreground/90">日巡检报告 {report.date || "-"}
|
||||
<span className={cn("ml-1.5 rounded px-1 py-px text-[9px] font-medium", statusColor)}>{modeLabel}</span>
|
||||
</div>
|
||||
<div className="truncate text-[10px] text-muted-foreground">{report.summary ? `${report.summary.deviceCount ?? 0} 台 · ${(report.summary.failRate * 100).toFixed(1)}% 失败` : ""}</div>
|
||||
</div>
|
||||
<span className="flex-shrink-0 text-[10px] text-muted-foreground">{formatTimeAgo(report.timestamp)}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
function QuickConnectRow({ host, onConnect }: { host: WorkbenchHost; onConnect?: (host: WorkbenchHost) => void }) {
|
||||
const label = host.label || host.hostname || "未命名";
|
||||
return (
|
||||
<button type="button" onClick={() => onConnect?.(host)}
|
||||
className="group flex w-full items-center gap-2 rounded-lg border border-border/50 bg-card/60 px-2.5 py-2 text-left text-xs transition-colors hover:border-primary/30 hover:bg-primary/5">
|
||||
<div className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-primary/10 text-primary"><Terminal size={11} /></div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-foreground/90">{label}</div>
|
||||
<div className="truncate text-[10px] text-muted-foreground">{host.hostname || host.group || "—"}</div>
|
||||
</div>
|
||||
<ChevronRight size={10} className="flex-shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
function RecentSessionRow({ log, hosts, onClick }: { log: any; hosts: WorkbenchHost[]; onClick?: () => void }) {
|
||||
const host = hosts.find((h) => h.id === log.hostId);
|
||||
const label = host?.label || log.hostLabel || log.hostname || "未知";
|
||||
const isError = (log.exitCode ?? null) !== null && (log.exitCode ?? null) !== undefined;
|
||||
return (
|
||||
<button type="button" onClick={onClick} className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-left text-xs transition-colors hover:bg-muted/40">
|
||||
<span className={cn("flex h-5 w-5 flex-shrink-0 items-center justify-center rounded", isError ? "bg-red-500/10 text-red-500" : "bg-emerald-500/10 text-emerald-500")}>
|
||||
<Terminal size={10} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-foreground/90">{label}</div>
|
||||
<div className="truncate text-[10px] text-muted-foreground">{log.username ? `${log.username}@` : ""}{log.hostname || ""}</div>
|
||||
</div>
|
||||
<span className="flex-shrink-0 text-[10px] text-muted-foreground">{formatTimeAgo(log.startTime || log.startedAt)}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* =================== 主组件 =================== */
|
||||
export function WorkbenchView({ hosts, keys, proxies, snippetCount = 0, activePortForwardCount = 0, onNavigate, onConnectHost }: WorkbenchSectionProps) {
|
||||
const inspectionApi = getInspectionApi();
|
||||
const [results, setResults] = useState<InspectResult[]>([]);
|
||||
const [alerts, setAlerts] = useState<AlertRecord[]>([]);
|
||||
const [reports, setReports] = useState<ReportSnapshot[]>([]);
|
||||
const [dailyReports, setDailyReports] = useState<DailyReport[]>([]);
|
||||
const [extensions, setExtensions] = useState<Record<string, DeviceExtension>>({});
|
||||
const [catalogs, setCatalogs] = useState<DeviceCatalog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastUpdate, setLastUpdate] = useState<number>(0);
|
||||
const { connectionLogs } = useConnectionLogsStore();
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let historyResults: InspectResult[] = []; let latestResults: InspectResult[] = [];
|
||||
// 优先用按时间范围查(过去 8 天完整历史),避免 getRecent(N) 被今天新结果挤出昨天数据
|
||||
try {
|
||||
const now = Date.now();
|
||||
const eightDaysAgo = now - 9 * 86_400_000; // 多一天保险
|
||||
historyResults = ((await inspectionResultApi.getByTimeRange(eightDaysAgo, now)) as any) || [];
|
||||
} catch {}
|
||||
if (historyResults.length === 0) {
|
||||
try { historyResults = ((await inspectionResultApi.getRecent(1000)) as any) || []; } catch {}
|
||||
}
|
||||
try { latestResults = (await inspectionApi?.getLatestAll?.()) || []; } catch {}
|
||||
setResults(historyResults.length > 0 ? historyResults : latestResults);
|
||||
|
||||
// 用 inspectionAlertApi(AlertRecord 类型,snake_case 字段)
|
||||
let alertList: AlertRecord[] = [];
|
||||
try { alertList = await inspectionAlertApi.getActive(); } catch {}
|
||||
if (alertList.length === 0) {
|
||||
try { alertList = (await inspectionApi?.listAlerts?.()) || (await inspectionApi?.getActiveAlerts?.()) || []; } catch {}
|
||||
}
|
||||
setAlerts(alertList);
|
||||
|
||||
try { setReports(await inspectionReportApi.listSnapshots(15)); } catch {}
|
||||
try { const daily = await inspectionDailyReportApi.list(15); setDailyReports(Array.isArray(daily) ? daily : []); } catch {}
|
||||
try {
|
||||
const [exts, cats] = await Promise.all([
|
||||
inspectionDeviceApi.getAll().catch(() => ({})), inspectionCatalogApi.list().catch(() => []),
|
||||
]);
|
||||
if (exts && typeof exts === "object") setExtensions(exts as Record<string, DeviceExtension>);
|
||||
if (Array.isArray(cats)) setCatalogs(cats);
|
||||
} catch {}
|
||||
setLastUpdate(Date.now());
|
||||
} catch (e: any) { console.warn("[Workbench] fetch failed:", e?.message || e); }
|
||||
finally { setLoading(false); }
|
||||
}, [inspectionApi]);
|
||||
|
||||
useEffect(() => { void fetchData(); const t = setInterval(() => void fetchData(), 60_000); return () => clearInterval(t); }, [fetchData]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const latestByDevice = new Map<string, InspectResult>();
|
||||
for (const r of results) {
|
||||
const id = (r as any).device_id || r.deviceId || r.hostId; if (!id) continue;
|
||||
const exist = latestByDevice.get(id);
|
||||
const rTs = (r as any).timestamp || 0; const eTs = exist ? ((exist as any).timestamp || 0) : 0;
|
||||
if (!exist || rTs > eTs) latestByDevice.set(id, r);
|
||||
}
|
||||
let online = 0, offline = 0;
|
||||
for (const r of latestByDevice.values()) { if (r.online === false || r.metrics?.online === false) offline++; else online++; }
|
||||
return { online, offline };
|
||||
}, [results]);
|
||||
|
||||
const recentLogs = useMemo(() =>
|
||||
[...(connectionLogs || [])].sort((a: any, b: any) => (b.startTime || b.startedAt || 0) - (a.startTime || a.startedAt || 0)).slice(0, 15),
|
||||
[connectionLogs]);
|
||||
|
||||
const quickConnectHosts = useMemo(() => hosts, [hosts]);
|
||||
|
||||
const handleQuickConnect = useCallback((host: WorkbenchHost) => {
|
||||
// 优先开终端 → 没有 hostId 时 fallback 到跳 hosts section 选中
|
||||
if (onConnectHost && host.id) { onConnectHost(host.id); return; }
|
||||
if (host.id) onNavigate("hosts", { hostId: host.id });
|
||||
else onNavigate("hosts");
|
||||
}, [onConnectHost, onNavigate]);
|
||||
|
||||
const alertList = alerts.slice(0, 30), reportList = reports.slice(0, 20), dailyReportList = dailyReports.slice(0, 20);
|
||||
const navigate = useCallback((section: string, opts?: any) => onNavigate(section, opts), [onNavigate]);
|
||||
|
||||
// === 复用已有页面的预览/详情 ===
|
||||
const handlePreviewSnapshot = useCallback(async (snap: ReportSnapshot) => {
|
||||
try { await inspectionReportApi.previewSnapshotPdf(snap.id); } catch { navigate("reports"); }
|
||||
}, [navigate]);
|
||||
const handlePreviewDaily = useCallback(async (r: DailyReport) => {
|
||||
try { await inspectionDailyReportApi.previewPdf(r.id); } catch { navigate("reports"); }
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-3.5 p-5">
|
||||
{/* 顶部标题条 */}
|
||||
<div className="flex flex-shrink-0 items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight text-foreground">工作台</h1>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground/80">
|
||||
{loading ? "加载中..." : lastUpdate ? `最后更新 ${formatTimeAgo(lastUpdate)}` : "暂无巡检数据"}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => void fetchData()} disabled={loading} className="h-8 border-border/60 bg-background/60 text-xs font-medium hover:bg-background">
|
||||
{loading ? <Loader2 size={12} className="mr-1 animate-spin" /> : <Zap size={12} className="mr-1" />}刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ============ 8 KPI 卡 ============ */}
|
||||
<div className="grid flex-shrink-0 grid-cols-2 gap-2.5 sm:grid-cols-4 lg:grid-cols-8">
|
||||
<KpiCard icon={Server} label="主机总数" value={hosts.length} accent="bg-sky-500/10 text-sky-500" />
|
||||
<KpiCard icon={KeyRound} label="密钥" value={keys.length} accent="bg-emerald-500/10 text-emerald-500" />
|
||||
<KpiCard icon={GitBranch} label="代理" value={proxies.length} accent="bg-violet-500/10 text-violet-500" />
|
||||
<KpiCard icon={FileCode} label="代码片段" value={snippetCount} accent="bg-amber-500/10 text-amber-500" />
|
||||
<KpiCard icon={Network} label="端口转发" value={activePortForwardCount} accent="bg-cyan-500/10 text-cyan-500" />
|
||||
<KpiCard icon={AlertTriangle} label="活跃告警" value={alerts.length}
|
||||
accent={alerts.length ? "bg-red-500/10 text-red-500" : "bg-emerald-500/10 text-emerald-500"}
|
||||
sub={alerts.length ? "需关注" : "全部正常"} />
|
||||
<KpiCard icon={Activity} label="已巡检" value={results.length} accent="bg-blue-500/10 text-blue-500" />
|
||||
<KpiCard icon={Server} label="在线主机" value={stats.online}
|
||||
sub={hosts.length ? `${((stats.online / hosts.length) * 100).toFixed(0)}%` : "—"} accent="bg-green-500/10 text-green-500" />
|
||||
</div>
|
||||
|
||||
{/* ============ 主体:外层 3 列 grid,左 2 列 + 右 1 列各自独立 ============ */}
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 lg:grid-cols-3">
|
||||
{/* ===== 左侧 2 列:flex-col 独立容器 ===== */}
|
||||
<div className="flex min-h-0 flex-col gap-3 lg:col-span-2">
|
||||
{/* 左侧 Row A:快速连接 + 最近会话 */}
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="flex min-h-0 flex-col rounded-xl border border-border/50 bg-card p-4">
|
||||
<SectionTitle title="快速连接" action={<Button variant="ghost" size="sm" onClick={() => navigate("hosts")} className="h-7 text-xs">全部 <ArrowRight size={10} className="ml-1" /></Button>} />
|
||||
{quickConnectHosts.length === 0 ? <div className="flex flex-1 items-center justify-center text-xs text-muted-foreground">还没有主机</div>
|
||||
: <ScrollArea className="min-h-0 flex-1"><div className="space-y-1.5 pr-1">{quickConnectHosts.map((h) => <QuickConnectRow key={h.id} host={h} onConnect={handleQuickConnect} />)}</div></ScrollArea>}
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-col rounded-xl border border-border/50 bg-card p-4">
|
||||
<SectionTitle title="最近会话" action={<Button variant="ghost" size="sm" onClick={() => navigate("logs")} className="h-7 text-xs">全部 <ArrowRight size={10} className="ml-1" /></Button>} />
|
||||
{recentLogs.length === 0 ? <div className="flex flex-1 items-center justify-center text-xs text-muted-foreground">暂无记录</div>
|
||||
: <ScrollArea className="min-h-0 flex-1"><div className="space-y-0.5 pr-1">{recentLogs.map((log) => <RecentSessionRow key={log.id || log.sessionId} log={log} hosts={hosts} onClick={() => navigate("logs", { logId: log.id || log.sessionId })} />)}</div></ScrollArea>}
|
||||
</div>
|
||||
</div>
|
||||
{/* 左侧 Row B:巡检快照 + 巡检日报 */}
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="flex min-h-0 flex-col rounded-xl border border-border/50 bg-card p-4">
|
||||
<SectionTitle title="巡检快照" action={<Button variant="ghost" size="sm" onClick={() => navigate("reports")} className="h-7 text-xs">全部 <ArrowRight size={10} className="ml-1" /></Button>} />
|
||||
{reportList.length === 0 ? <div className="flex flex-1 items-center justify-center text-xs text-muted-foreground">暂无快照</div>
|
||||
: <ScrollArea className="min-h-0 flex-1"><div className="space-y-0.5 pr-1">{reportList.map((r) => <ReportScrollRow key={r.id} report={r} onClick={() => handlePreviewSnapshot(r)} />)}</div></ScrollArea>}
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-col rounded-xl border border-border/50 bg-card p-4">
|
||||
<SectionTitle title="巡检日报" action={<Button variant="ghost" size="sm" onClick={() => navigate("reports")} className="h-7 text-xs">全部 <ArrowRight size={10} className="ml-1" /></Button>} />
|
||||
{dailyReportList.length === 0 ? <div className="flex flex-1 items-center justify-center text-xs text-muted-foreground">暂无日报</div>
|
||||
: <ScrollArea className="min-h-0 flex-1"><div className="space-y-0.5 pr-1">{dailyReportList.map((r) => <DailyReportScrollRow key={r.id} report={r} onClick={() => handlePreviewDaily(r)} />)}</div></ScrollArea>}
|
||||
</div>
|
||||
</div>
|
||||
{/* 左侧 Row C:快捷操作 */}
|
||||
<div className="flex-shrink-0 rounded-xl border border-border/50 bg-card p-4">
|
||||
<SectionTitle title="快捷操作" />
|
||||
<div className="mt-3 grid grid-cols-5 gap-3">
|
||||
{[
|
||||
{ icon: KeyRound, label: "密钥管理", sub: "管理 SSH 密钥", section: "keys", accent: "from-amber-500/20 to-amber-500/5 text-amber-600 border-amber-500/30 hover:from-amber-500/30 hover:to-amber-500/10" },
|
||||
{ icon: Network, label: "端口转发", sub: "管理端口映射", section: "port", accent: "from-blue-500/20 to-blue-500/5 text-blue-600 border-blue-500/30 hover:from-blue-500/30 hover:to-blue-500/10" },
|
||||
{ icon: FileCode, label: "代码片段", sub: "常用代码块", section: "snippets", accent: "from-violet-500/20 to-violet-500/5 text-violet-600 border-violet-500/30 hover:from-violet-500/30 hover:to-violet-500/10" },
|
||||
{ icon: Zap, label: "告警规则", sub: "配置告警策略", section: "alertRules", accent: "from-rose-500/20 to-rose-500/5 text-rose-600 border-rose-500/30 hover:from-rose-500/30 hover:to-rose-500/10" },
|
||||
{ icon: Server, label: "监控大屏", sub: "拓扑可视化", section: "monitoring", accent: "from-emerald-500/20 to-emerald-500/5 text-emerald-600 border-emerald-500/30 hover:from-emerald-500/30 hover:to-emerald-500/10" },
|
||||
].map(({ icon: Icon, label, sub, section, accent }) => (
|
||||
<button key={section} type="button" onClick={() => navigate(section)}
|
||||
className={`group flex h-12 items-center gap-2.5 rounded-lg border bg-gradient-to-br px-3 text-left transition-all hover:-translate-y-0.5 hover:shadow-sm ${accent}`}>
|
||||
<span className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md bg-background/40 ring-1 ring-inset ring-current/15">
|
||||
<Icon size={15} strokeWidth={2.2} className="transition-transform group-hover:scale-110" />
|
||||
</span>
|
||||
<div className="min-w-0 leading-tight">
|
||||
<div className="text-[12px] font-semibold truncate">{label}</div>
|
||||
<div className="text-[10px] text-muted-foreground/70 truncate">{sub}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== 右侧 1 列:flex-col 独立容器 ===== */}
|
||||
<div className="flex min-h-0 flex-col gap-3">
|
||||
{/* 右侧 A:设备类型分布 — 自然矮 */}
|
||||
<div className="flex flex-shrink-0 flex-col rounded-xl border border-border/50 bg-card p-4">
|
||||
<SectionTitle title="设备类型分布" />
|
||||
{hosts.length === 0 ? <div className="flex items-center justify-center text-xs text-muted-foreground">暂无主机数据</div>
|
||||
: <div className="flex items-center"><DeviceTypeDonutChart hosts={hosts} extensions={extensions} catalogs={catalogs} /></div>}
|
||||
</div>
|
||||
{/* 右侧 B:近一周巡检状态 — 自然矮 */}
|
||||
<div className="flex flex-shrink-0 flex-col rounded-xl border border-border/50 bg-card p-4">
|
||||
<SectionTitle title="近一周巡检状态" action={<Button variant="ghost" size="sm" onClick={() => navigate("inspection")} className="h-7 text-xs">巡检中心 <ArrowRight size={10} className="ml-1" /></Button>} />
|
||||
{results.length === 0 ? <div className="flex items-center justify-center text-xs text-muted-foreground">暂无巡检数据</div>
|
||||
: <div className="flex items-end"><WeeklyOnlineOfflineBarChart results={results} /></div>}
|
||||
</div>
|
||||
{/* 右侧 C:活跃告警 — flex-1 填满剩余空间 */}
|
||||
<div className="flex min-h-0 flex-1 flex-col rounded-xl border border-border/50 bg-card p-4">
|
||||
<SectionTitle title="活跃告警" action={<Button variant="ghost" size="sm" onClick={() => navigate("alerts")} className="h-7 text-xs">全部 <ArrowRight size={10} className="ml-1" /></Button>} />
|
||||
{alertList.length === 0 ? <div className="flex flex-1 items-center justify-center gap-2 text-xs text-muted-foreground"><Activity size={14} className="text-emerald-500/60" /> 全部正常</div>
|
||||
: <ScrollArea className="min-h-0 flex-1"><div className="space-y-0.5 pr-1">{alertList.map((a) => <AlertScrollRow key={a.id} alert={a} onClick={() => navigate("alerts", { alertId: a.id })} />)}</div></ScrollArea>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkbenchView;
|
||||
Reference in New Issue
Block a user