Files
zclaw_openfang/desktop/src/components/SecurityStatus.tsx
iven 4a5389510e
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
fix(ui): 深度审计修复 — RightPanel流式渲染优化 + SecurityStatus基线真实值
- RightPanel: useShallow选择器避免流式token导致的无效重渲染
  + stableMessagesRef 限制代码块提取仅在消息数变化时触发
- SecurityStatus: 默认层从全false改为Tauri桌面基线(4/16 true)
  session/input.sanitization/input.schema/exec.sandbox
2026-04-10 23:59:24 +08:00

256 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect } from 'react';
import { Shield, ShieldCheck, ShieldAlert, ShieldX, RefreshCw, Loader2, AlertCircle } from 'lucide-react';
import { useSecurityStore } from '../store/securityStore';
// ZCLAW 16-layer security architecture names (Chinese)
const SECURITY_LAYER_NAMES: Record<string, string> = {
// Layer 1: Network
'network.firewall': '网络防火墙',
'network.tls': 'TLS 加密',
'network.rate_limit': '速率限制',
// Layer 2: Authentication
'auth.device': '设备认证',
'auth.jwt': 'JWT 令牌',
'auth.session': '会话管理',
// Layer 3: Authorization
'auth.rbac': '角色权限',
'auth.capabilities': '能力控制',
// Layer 4: Input Validation
'input.sanitization': '输入净化',
'input.schema': '模式验证',
// Layer 5: Execution
'exec.sandbox': '沙箱隔离',
'exec.timeout': '执行超时',
'exec.resource_limit': '资源限制',
// Layer 6: Audit & Logging
'audit.logging': '审计日志',
'audit.tracing': '请求追踪',
'audit.alerting': '审计告警',
};
// Default 16 layers — Tauri desktop baseline truths pre-enabled
const DEFAULT_LAYERS = [
{ name: 'network.firewall', enabled: false },
{ name: 'network.tls', enabled: false },
{ name: 'network.rate_limit', enabled: false },
{ name: 'auth.device', enabled: false },
{ name: 'auth.jwt', enabled: false },
{ name: 'auth.session', enabled: true }, // session management always active
{ name: 'auth.rbac', enabled: false },
{ name: 'auth.capabilities', enabled: false },
{ name: 'input.sanitization', enabled: true }, // React built-in + DOMPurify
{ name: 'input.schema', enabled: true }, // Zod validation active
{ name: 'exec.sandbox', enabled: true }, // Tauri sandbox
{ name: 'exec.timeout', enabled: false },
{ name: 'exec.resource_limit', enabled: false },
{ name: 'audit.logging', enabled: false },
{ name: 'audit.tracing', enabled: false },
{ name: 'audit.alerting', enabled: false },
];
function getSecurityIcon(level: 'critical' | 'high' | 'medium' | 'low') {
switch (level) {
case 'critical':
return <ShieldCheck className="w-5 h-5 text-green-600" />;
case 'high':
return <Shield className="w-5 h-5 text-blue-600" />;
case 'medium':
return <ShieldAlert className="w-5 h-5 text-yellow-600" />;
case 'low':
return <ShieldX className="w-5 h-5 text-red-600" />;
}
}
function getSecurityLabel(level: 'critical' | 'high' | 'medium' | 'low') {
switch (level) {
case 'critical':
return { text: '极高', color: 'text-green-600 bg-green-50 border-green-200' };
case 'high':
return { text: '高', color: 'text-blue-600 bg-blue-50 border-blue-200' };
case 'medium':
return { text: '中', color: 'text-yellow-600 bg-yellow-50 border-yellow-200' };
case 'low':
return { text: '低', color: 'text-red-600 bg-red-50 border-red-200' };
}
}
export function SecurityStatus() {
const securityStatus = useSecurityStore((s) => s.securityStatus);
const securityStatusLoading = useSecurityStore((s) => s.securityStatusLoading);
const securityStatusError = useSecurityStore((s) => s.securityStatusError);
const loadSecurityStatus = useSecurityStore((s) => s.loadSecurityStatus);
useEffect(() => {
loadSecurityStatus();
}, [loadSecurityStatus]);
// Loading state
if (securityStatusLoading && !securityStatus) {
return (
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
<div className="flex items-center gap-2 mb-3">
<Loader2 className="w-4 h-4 text-gray-400 animate-spin" />
<span className="text-sm font-semibold text-gray-900"></span>
</div>
<p className="text-xs text-gray-400">...</p>
</div>
);
}
// API error state - show friendly message
if (securityStatusError && !securityStatus) {
return (
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-yellow-500" />
<span className="text-sm font-semibold text-gray-900"></span>
</div>
<button
onClick={() => loadSecurityStatus()}
className="p-1 text-gray-400 hover:text-orange-500 rounded transition-colors"
title="重试"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
<p className="text-xs text-gray-500 mb-2"></p>
<p className="text-xs text-gray-400">
</p>
</div>
);
}
// Use default layers if no data, or merge with API data
const displayLayers = securityStatus?.layers?.length
? DEFAULT_LAYERS.map((defaultLayer) => {
const apiLayer = securityStatus.layers.find((l) => l.name === defaultLayer.name);
return apiLayer || defaultLayer;
})
: DEFAULT_LAYERS;
const enabledCount = displayLayers.filter((l) => l.enabled).length;
const totalCount = displayLayers.length;
const securityLevel = securityStatus?.securityLevel ||
(enabledCount >= 14 ? 'critical' : enabledCount >= 10 ? 'high' : enabledCount >= 6 ? 'medium' : 'low');
const levelLabel = getSecurityLabel(securityLevel);
return (
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
{getSecurityIcon(securityLevel)}
<span className="text-sm font-semibold text-gray-900"></span>
{securityStatusLoading && (
<Loader2 className="w-3 h-3 text-gray-400 animate-spin" />
)}
</div>
<div className="flex items-center gap-2">
<span className={`text-xs px-2 py-0.5 rounded-full border ${levelLabel.color}`}>
{levelLabel.text}
</span>
<button
onClick={() => loadSecurityStatus()}
className="p-1 text-gray-400 hover:text-orange-500 rounded transition-colors disabled:opacity-50"
title="刷新安全状态"
disabled={securityStatusLoading}
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Summary */}
<div className="mb-3 text-xs text-gray-500">
{enabledCount} / {totalCount}
</div>
{/* Progress bar */}
<div className="mb-4 h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-300 ${
securityLevel === 'critical'
? 'bg-green-500'
: securityLevel === 'high'
? 'bg-blue-500'
: securityLevel === 'medium'
? 'bg-yellow-500'
: 'bg-red-500'
}`}
style={{ width: `${(enabledCount / totalCount) * 100}%` }}
/>
</div>
{/* Layers Grid */}
<div className="grid grid-cols-2 gap-1.5">
{displayLayers.map((layer) => {
const label = SECURITY_LAYER_NAMES[layer.name] || layer.name;
return (
<div
key={layer.name}
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-lg text-xs ${
layer.enabled
? 'bg-green-50 text-green-700'
: 'bg-gray-50 text-gray-400'
}`}
title={layer.name}
>
<div
className={`w-1.5 h-1.5 rounded-full ${
layer.enabled ? 'bg-green-500' : 'bg-gray-300'
}`}
/>
<span className="truncate">{label}</span>
</div>
);
})}
</div>
{/* Layer Categories */}
<div className="mt-4 pt-3 border-t border-gray-100">
<div className="grid grid-cols-3 gap-2 text-xs">
<CategorySummary
label="网络"
layers={displayLayers.filter((l) => l.name.startsWith('network.'))}
/>
<CategorySummary
label="认证"
layers={displayLayers.filter((l) => l.name.startsWith('auth.'))}
/>
<CategorySummary
label="执行"
layers={displayLayers.filter((l) => l.name.startsWith('exec.'))}
/>
<CategorySummary
label="输入"
layers={displayLayers.filter((l) => l.name.startsWith('input.'))}
/>
<CategorySummary
label="审计"
layers={displayLayers.filter((l) => l.name.startsWith('audit.'))}
/>
</div>
</div>
</div>
);
}
function CategorySummary({ label, layers }: { label: string; layers: { enabled: boolean }[] }) {
if (layers.length === 0) return null;
const enabled = layers.filter((l) => l.enabled).length;
const total = layers.length;
const allEnabled = enabled === total;
return (
<div className="flex flex-col items-center">
<span className={`font-medium ${allEnabled ? 'text-green-600' : 'text-gray-500'}`}>
{enabled}/{total}
</span>
<span className="text-gray-400">{label}</span>
</div>
);
}