fix(intelligence): Heartbeat 统一健康系统 — 6处断链修复 + 健康面板 + SaaS自动恢复
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

Rust 后端 (heartbeat.rs):
- 告警实时推送: OnceLock<AppHandle> + Tauri emit heartbeat:alert
- 动态间隔: tokio::select! + Notify 替代不可变 interval
- Config 持久化: update_config 写入 VikingStorage
- heartbeat_init 从 VikingStorage 恢复 config
- 移除 dead code (subscribe, HeartbeatCheckFn)
- Memory stats fallback 分层处理

新增 health_snapshot.rs:
- HealthSnapshot Tauri 命令 — 按需查询引擎/记忆状态
- 注册到 lib.rs invoke_handler

前端修复:
- HeartbeatConfig handleSave 同步到 Rust 后端
- App.tsx 读 localStorage 持久化配置 + heartbeat:alert 监听 + toast
- saasStore 降级后指数退避探测恢复 + saas-recovered 事件
- 新增 HealthPanel.tsx 只读健康面板 (4卡片 + 告警列表)
- SettingsLayout 添加 health 导航入口

清理:
- 删除 intelligence-client/ 目录版 (9文件 -1640行, 单文件版是活跃代码)
This commit is contained in:
iven
2026-04-15 23:19:24 +08:00
parent 043824c722
commit 215c079d29
19 changed files with 1184 additions and 1678 deletions

View File

@@ -84,6 +84,7 @@ export interface SaaSStateSlice {
_consecutiveFailures: number;
_heartbeatTimer?: ReturnType<typeof setInterval>;
_healthCheckTimer?: ReturnType<typeof setInterval>;
_recoveryProbeTimer?: ReturnType<typeof setInterval>;
// === Billing State ===
plans: BillingPlan[];
@@ -141,6 +142,67 @@ function resolveInitialMode(sessionMeta: { saasUrl: string; account: SaaSAccount
return sessionMeta ? 'saas' : 'tauri';
}
// === SaaS Recovery Probe ===
// When SaaS degrades to local mode, periodically probes SaaS reachability
// with exponential backoff (2min → 3min → 4.5min → 6.75min → 10min cap).
// On recovery, switches back to SaaS mode and notifies user via toast.
let _recoveryProbeInterval: ReturnType<typeof setInterval> | null = null;
let _recoveryBackoffMs = 2 * 60 * 1000; // Start at 2 minutes
const RECOVERY_BACKOFF_CAP_MS = 10 * 60 * 1000; // Max 10 minutes
const RECOVERY_BACKOFF_MULTIPLIER = 1.5;
function startRecoveryProbe() {
if (_recoveryProbeInterval) return; // Already probing
_recoveryBackoffMs = 2 * 60 * 1000; // Reset backoff
log.info('[SaaS Recovery] Starting recovery probe...');
const probe = async () => {
try {
await saasClient.deviceHeartbeat(DEVICE_ID);
// SaaS is reachable again — recover
log.info('[SaaS Recovery] SaaS reachable — switching back to SaaS mode');
useSaaSStore.setState({
saasReachable: true,
connectionMode: 'saas',
_consecutiveFailures: 0,
} as unknown as Partial<SaaSStore>);
saveConnectionMode('saas');
// Notify user via custom event (App.tsx listens)
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('saas-recovered'));
}
// Stop probing
stopRecoveryProbe();
} catch {
// Still unreachable — increase backoff
_recoveryBackoffMs = Math.min(
_recoveryBackoffMs * RECOVERY_BACKOFF_MULTIPLIER,
RECOVERY_BACKOFF_CAP_MS
);
log.debug(`[SaaS Recovery] Still unreachable, next probe in ${Math.round(_recoveryBackoffMs / 1000)}s`);
// Reschedule with new backoff
if (_recoveryProbeInterval) {
clearInterval(_recoveryProbeInterval);
}
_recoveryProbeInterval = setInterval(probe, _recoveryBackoffMs);
}
};
_recoveryProbeInterval = setInterval(probe, _recoveryBackoffMs);
}
function stopRecoveryProbe() {
if (_recoveryProbeInterval) {
clearInterval(_recoveryProbeInterval);
_recoveryProbeInterval = null;
}
}
// === Store Implementation ===
export const useSaaSStore = create<SaaSStore>((set, get) => {
@@ -698,6 +760,8 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
connectionMode: 'tauri',
} as unknown as Partial<SaaSStore>);
saveConnectionMode('tauri');
// Start recovery probe with exponential backoff
startRecoveryProbe();
}
}
}, 5 * 60 * 1000);