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

This commit is contained in:
2026-10-30 12:12:04 +08:00
committed by trae-solo-agent
parent 16dd04f746
commit 4a02370006
26 changed files with 2569 additions and 233 deletions

View File

@@ -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 "";

View File

@@ -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>