Fix telemetry shutdown noise and improve token usage diagnostics (#444)
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
01adc828b5
commit
995a7f070d
@@ -30,7 +30,9 @@ Standard dev commands are in `package.json` scripts and `README.md`. Key ones:
|
||||
- **Gateway startup**: When running `pnpm dev`, the OpenClaw Gateway process starts automatically on port 18789. It takes ~10-30 seconds to become ready. Gateway readiness is not required for UI development—the app functions without it (shows "connecting" state).
|
||||
- **No database**: The app uses `electron-store` (JSON files) and OS keychain. No database setup is needed.
|
||||
- **AI Provider keys**: Actual AI chat requires at least one provider API key configured via Settings > AI Providers. The app is fully navigable and testable without keys.
|
||||
- **Token usage history implementation**: Dashboard token usage history is not parsed from console logs. It reads OpenClaw session transcript `.jsonl` files under the local OpenClaw config directory, extracts assistant messages with `message.usage`, and aggregates fields such as input/output/cache/total tokens and cost from those structured records.
|
||||
- **Token usage history implementation**: Dashboard token usage history is not parsed from console logs. It reads OpenClaw session transcript `.jsonl` files under the local OpenClaw config directory, scans both configured agents and any runtime agent directories found on disk, and treats normal, `.deleted.jsonl`, and `.jsonl.reset.*` transcripts as valid history sources. It extracts assistant/tool usage records with `message.usage` and aggregates fields such as input/output/cache/total tokens and cost from those structured records.
|
||||
- **Models page aggregation**: The 7-day/30-day filters are relative rolling windows, not calendar-month buckets. When grouped by time, the chart should keep all day buckets in the selected window; only model grouping is intentionally capped to the top entries.
|
||||
- **OpenClaw Doctor in UI**: In Settings > Advanced > Developer, the app exposes both `Run Doctor` (`openclaw doctor --json`) and `Run Doctor Fix` (`openclaw doctor --fix --yes --non-interactive`) through the host-api. Renderer code should call the host route, not spawn CLI processes directly.
|
||||
- **Renderer/Main API boundary (important)**:
|
||||
- Renderer must use `src/lib/host-api.ts` and `src/lib/api-client.ts` as the single entry for backend calls.
|
||||
- Do not add new direct `window.electron.ipcRenderer.invoke(...)` calls in pages/components; expose them through host-api/api-client instead.
|
||||
|
||||
@@ -187,6 +187,7 @@ ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネ
|
||||
- 高度なプロキシフィールドが空の場合、ClawXは`プロキシサーバー`にフォールバックします。
|
||||
- プロキシ設定を保存すると、Electronのネットワーク設定が即座に再適用され、ゲートウェイが自動的に再起動されます。
|
||||
- ClawXはTelegramが有効な場合、プロキシをOpenClawのTelegramチャネル設定にも同期します。
|
||||
- **設定 → 詳細 → 開発者** では **OpenClaw Doctor** を実行でき、`openclaw doctor --json` の診断出力をアプリ内で確認できます。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -191,6 +191,7 @@ Notes:
|
||||
- If advanced proxy fields are left empty, ClawX falls back to `Proxy Server`.
|
||||
- Saving proxy settings reapplies Electron networking immediately and restarts the Gateway automatically.
|
||||
- ClawX also syncs the proxy to OpenClaw's Telegram channel config when Telegram is enabled.
|
||||
- In **Settings → Advanced → Developer**, you can run **OpenClaw Doctor** to execute `openclaw doctor --json` and inspect the diagnostic output without leaving the app.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -191,6 +191,7 @@ ClawX 内置了代理设置,适用于需要通过本地代理客户端访问
|
||||
- 高级代理项留空时,会自动回退到“代理服务器”。
|
||||
- 保存代理设置后,Electron 网络层会立即重新应用代理,并自动重启 Gateway。
|
||||
- 如果启用了 Telegram,ClawX 还会把代理同步到 OpenClaw 的 Telegram 频道配置中。
|
||||
- 在 **设置 → 高级 → 开发者** 中,可以直接运行 **OpenClaw Doctor**,执行 `openclaw doctor --json` 并在应用内查看诊断输出。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { setCorsHeaders, sendNoContent } from '../route-utils';
|
||||
import { parseJsonBody } from '../route-utils';
|
||||
import { setCorsHeaders, sendJson, sendNoContent } from '../route-utils';
|
||||
import { runOpenClawDoctor, runOpenClawDoctorFix } from '../../utils/openclaw-doctor';
|
||||
|
||||
export async function handleAppRoutes(
|
||||
req: IncomingMessage,
|
||||
@@ -23,6 +25,13 @@ export async function handleAppRoutes(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/app/openclaw-doctor' && req.method === 'POST') {
|
||||
const body = await parseJsonBody<{ mode?: 'diagnose' | 'fix' }>(req);
|
||||
const mode = body.mode === 'fix' ? 'fix' : 'diagnose';
|
||||
sendJson(res, 200, mode === 'fix' ? await runOpenClawDoctorFix() : await runOpenClawDoctor());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
sendNoContent(res);
|
||||
return true;
|
||||
|
||||
@@ -13,7 +13,7 @@ import { createMenu } from './menu';
|
||||
import { appUpdater, registerUpdateHandlers } from './updater';
|
||||
import { logger } from '../utils/logger';
|
||||
import { warmupNetworkOptimization } from '../utils/uv-env';
|
||||
import { initTelemetry, shutdownTelemetry } from '../utils/telemetry';
|
||||
import { initTelemetry } from '../utils/telemetry';
|
||||
|
||||
import { ClawHubService } from '../gateway/clawhub';
|
||||
import { ensureClawXContext, repairClawXOnlyBootstrapFiles } from '../utils/openclaw-workspace';
|
||||
@@ -392,10 +392,6 @@ app.on('before-quit', () => {
|
||||
setQuitting();
|
||||
hostEventBus.closeAll();
|
||||
hostApiServer?.close();
|
||||
// Flush telemetry data
|
||||
void shutdownTelemetry().catch((err) => {
|
||||
logger.warn('Failed to shutdown telemetry:', err);
|
||||
});
|
||||
// Fire-and-forget: do not await gatewayManager.stop() here.
|
||||
// Awaiting inside before-quit can stall Electron's quit sequence.
|
||||
void gatewayManager.stop().catch((err) => {
|
||||
|
||||
158
electron/utils/openclaw-doctor.ts
Normal file
158
electron/utils/openclaw-doctor.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { app, utilityProcess } from 'electron';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { getOpenClawDir, getOpenClawEntryPath } from './paths';
|
||||
import { logger } from './logger';
|
||||
import { getUvMirrorEnv } from './uv-env';
|
||||
|
||||
const OPENCLAW_DOCTOR_TIMEOUT_MS = 60_000;
|
||||
const OPENCLAW_DOCTOR_ARGS = ['doctor', '--json'];
|
||||
const OPENCLAW_DOCTOR_FIX_ARGS = ['doctor', '--fix', '--yes', '--non-interactive'];
|
||||
|
||||
export type OpenClawDoctorMode = 'diagnose' | 'fix';
|
||||
|
||||
export interface OpenClawDoctorResult {
|
||||
mode: OpenClawDoctorMode;
|
||||
success: boolean;
|
||||
exitCode: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
command: string;
|
||||
cwd: string;
|
||||
durationMs: number;
|
||||
timedOut?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function getBundledBinPath(): string {
|
||||
const target = `${process.platform}-${process.arch}`;
|
||||
return app.isPackaged
|
||||
? path.join(process.resourcesPath, 'bin')
|
||||
: path.join(process.cwd(), 'resources', 'bin', target);
|
||||
}
|
||||
|
||||
async function runDoctorCommand(mode: OpenClawDoctorMode): Promise<OpenClawDoctorResult> {
|
||||
const openclawDir = getOpenClawDir();
|
||||
const entryScript = getOpenClawEntryPath();
|
||||
const args = mode === 'fix' ? OPENCLAW_DOCTOR_FIX_ARGS : OPENCLAW_DOCTOR_ARGS;
|
||||
const command = `openclaw ${args.join(' ')}`;
|
||||
const startedAt = Date.now();
|
||||
|
||||
if (!existsSync(entryScript)) {
|
||||
const error = `OpenClaw entry script not found at ${entryScript}`;
|
||||
logger.error(`Cannot run OpenClaw doctor: ${error}`);
|
||||
return {
|
||||
mode,
|
||||
success: false,
|
||||
exitCode: null,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
command,
|
||||
cwd: openclawDir,
|
||||
durationMs: Date.now() - startedAt,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
const binPath = getBundledBinPath();
|
||||
const binPathExists = existsSync(binPath);
|
||||
const finalPath = binPathExists
|
||||
? `${binPath}${path.delimiter}${process.env.PATH || ''}`
|
||||
: process.env.PATH || '';
|
||||
const uvEnv = await getUvMirrorEnv();
|
||||
|
||||
logger.info(
|
||||
`Running OpenClaw doctor (mode=${mode}, entry="${entryScript}", args="${args.join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'})`,
|
||||
);
|
||||
|
||||
return await new Promise<OpenClawDoctorResult>((resolve) => {
|
||||
const child = utilityProcess.fork(entryScript, args, {
|
||||
cwd: openclawDir,
|
||||
stdio: 'pipe',
|
||||
env: {
|
||||
...process.env,
|
||||
...uvEnv,
|
||||
PATH: finalPath,
|
||||
OPENCLAW_NO_RESPAWN: '1',
|
||||
} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
|
||||
const finish = (result: Omit<OpenClawDoctorResult, 'durationMs'>) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve({
|
||||
...result,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
logger.error(`OpenClaw doctor timed out after ${OPENCLAW_DOCTOR_TIMEOUT_MS}ms`);
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
finish({
|
||||
mode,
|
||||
success: false,
|
||||
exitCode: null,
|
||||
stdout,
|
||||
stderr,
|
||||
command,
|
||||
cwd: openclawDir,
|
||||
timedOut: true,
|
||||
error: `Timed out after ${OPENCLAW_DOCTOR_TIMEOUT_MS}ms`,
|
||||
});
|
||||
}, OPENCLAW_DOCTOR_TIMEOUT_MS);
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timeout);
|
||||
logger.error('Failed to spawn OpenClaw doctor process:', error);
|
||||
finish({
|
||||
mode,
|
||||
success: false,
|
||||
exitCode: null,
|
||||
stdout,
|
||||
stderr,
|
||||
command,
|
||||
cwd: openclawDir,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
|
||||
child.on('exit', (code) => {
|
||||
clearTimeout(timeout);
|
||||
logger.info(`OpenClaw doctor exited with code ${code ?? 'null'}`);
|
||||
finish({
|
||||
mode,
|
||||
success: code === 0,
|
||||
exitCode: code,
|
||||
stdout,
|
||||
stderr,
|
||||
command,
|
||||
cwd: openclawDir,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function runOpenClawDoctor(): Promise<OpenClawDoctorResult> {
|
||||
return await runDoctorCommand('diagnose');
|
||||
}
|
||||
|
||||
export async function runOpenClawDoctorFix(): Promise<OpenClawDoctorResult> {
|
||||
return await runDoctorCommand('fix');
|
||||
}
|
||||
@@ -6,10 +6,32 @@ import { logger } from './logger';
|
||||
|
||||
const POSTHOG_API_KEY = 'phc_aGNegeJQP5FzNiF2rEoKqQbkuCpiiETMttplibXpB0n';
|
||||
const POSTHOG_HOST = 'https://us.i.posthog.com';
|
||||
const TELEMETRY_SHUTDOWN_TIMEOUT_MS = 1500;
|
||||
|
||||
let posthogClient: PostHog | null = null;
|
||||
let distinctId: string = '';
|
||||
|
||||
function isIgnorablePostHogShutdownError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = `${error.name} ${error.message}`.toLowerCase();
|
||||
if (
|
||||
message.includes('posthogfetchnetworkerror') ||
|
||||
message.includes('network error while fetching posthog') ||
|
||||
message.includes('timeouterror') ||
|
||||
message.includes('aborted due to timeout') ||
|
||||
message.includes('fetch failed')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return 'cause' in error && error.cause !== error
|
||||
? isIgnorablePostHogShutdownError(error.cause)
|
||||
: false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize PostHog telemetry
|
||||
*/
|
||||
@@ -65,15 +87,51 @@ export async function initTelemetry(): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure PostHog flushes all pending events before shutting down
|
||||
* Best-effort telemetry shutdown that never blocks app exit on network issues.
|
||||
*/
|
||||
export async function shutdownTelemetry(): Promise<void> {
|
||||
if (posthogClient) {
|
||||
try {
|
||||
await posthogClient.shutdown();
|
||||
logger.debug('Flushed telemetry events on shutdown');
|
||||
} catch (error) {
|
||||
logger.error('Error shutting down telemetry:', error);
|
||||
const client = posthogClient;
|
||||
posthogClient = null;
|
||||
distinctId = '';
|
||||
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
|
||||
let didTimeout = false;
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
||||
const shutdownPromise = client.shutdown().catch((error) => {
|
||||
if (isIgnorablePostHogShutdownError(error)) {
|
||||
logger.debug('Ignored telemetry shutdown network error:', error);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
shutdownPromise,
|
||||
new Promise<void>((resolve) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
didTimeout = true;
|
||||
resolve();
|
||||
}, TELEMETRY_SHUTDOWN_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
|
||||
if (didTimeout) {
|
||||
logger.debug(`Skipped waiting for telemetry shutdown after ${TELEMETRY_SHUTDOWN_TIMEOUT_MS}ms`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('Flushed telemetry events on shutdown');
|
||||
} catch (error) {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
logger.error('Error shutting down telemetry:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface TokenUsageHistoryEntry {
|
||||
export function extractSessionIdFromTranscriptFileName(fileName: string): string | undefined {
|
||||
if (!fileName.endsWith('.jsonl') && !fileName.includes('.jsonl.reset.')) return undefined;
|
||||
return fileName
|
||||
.replace(/\.jsonl\.reset\..+$/, '')
|
||||
.replace(/\.reset\..+$/, '')
|
||||
.replace(/\.deleted\.jsonl$/, '')
|
||||
.replace(/\.jsonl$/, '');
|
||||
}
|
||||
|
||||
@@ -15,12 +15,45 @@ export {
|
||||
type TokenUsageHistoryEntry,
|
||||
} from './token-usage-core';
|
||||
|
||||
async function listAgentIdsWithSessionDirs(): Promise<string[]> {
|
||||
const openclawDir = getOpenClawConfigDir();
|
||||
const agentsDir = join(openclawDir, 'agents');
|
||||
const agentIds = new Set<string>();
|
||||
|
||||
try {
|
||||
for (const agentId of await listConfiguredAgentIds()) {
|
||||
const normalized = agentId.trim();
|
||||
if (normalized) {
|
||||
agentIds.add(normalized);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore config discovery failures and fall back to disk scan.
|
||||
}
|
||||
|
||||
try {
|
||||
const agentEntries = await readdir(agentsDir, { withFileTypes: true });
|
||||
for (const entry of agentEntries) {
|
||||
if (entry.isDirectory()) {
|
||||
const normalized = entry.name.trim();
|
||||
if (normalized) {
|
||||
agentIds.add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore disk discovery failures and return whatever we already found.
|
||||
}
|
||||
|
||||
return [...agentIds];
|
||||
}
|
||||
|
||||
async function listRecentSessionFiles(): Promise<Array<{ filePath: string; sessionId: string; agentId: string; mtimeMs: number }>> {
|
||||
const openclawDir = getOpenClawConfigDir();
|
||||
const agentsDir = join(openclawDir, 'agents');
|
||||
|
||||
try {
|
||||
const agentEntries = await listConfiguredAgentIds();
|
||||
const agentEntries = await listAgentIdsWithSessionDirs();
|
||||
const files: Array<{ filePath: string; sessionId: string; agentId: string; mtimeMs: number }> = [];
|
||||
|
||||
for (const agentId of agentEntries) {
|
||||
|
||||
@@ -219,6 +219,29 @@
|
||||
"cliPowershell": "PowerShell command.",
|
||||
"cmdUnavailable": "Command unavailable",
|
||||
"cmdCopied": "CLI command copied",
|
||||
"doctor": "OpenClaw Doctor",
|
||||
"doctorDesc": "Run `openclaw doctor --json` and inspect the raw diagnostic output.",
|
||||
"runDoctor": "Run Doctor",
|
||||
"runDoctorFix": "Run Doctor Fix",
|
||||
"doctorSucceeded": "OpenClaw doctor completed",
|
||||
"doctorFailed": "OpenClaw doctor reported issues",
|
||||
"doctorRunFailed": "Failed to run OpenClaw doctor",
|
||||
"doctorFixSucceeded": "OpenClaw doctor fix completed",
|
||||
"doctorFixFailed": "OpenClaw doctor fix reported issues",
|
||||
"doctorFixRunFailed": "Failed to run OpenClaw doctor fix",
|
||||
"doctorCopied": "Doctor output copied",
|
||||
"doctorOk": "Healthy",
|
||||
"doctorIssue": "Issues Found",
|
||||
"doctorFixOk": "Fixed",
|
||||
"doctorFixIssue": "Fix Failed",
|
||||
"doctorExitCode": "Exit code",
|
||||
"doctorDuration": "Duration",
|
||||
"doctorCommand": "Command",
|
||||
"doctorWorkingDir": "Working dir",
|
||||
"doctorError": "Error",
|
||||
"doctorStdout": "Stdout",
|
||||
"doctorStderr": "Stderr",
|
||||
"doctorOutputEmpty": "(empty)",
|
||||
"wsDiagnostic": "WS Diagnostic Mode",
|
||||
"wsDiagnosticDesc": "Temporarily enable WS/HTTP fallback chain for gateway RPC debugging.",
|
||||
"wsDiagnosticEnabled": "WS diagnostic mode enabled",
|
||||
|
||||
@@ -216,6 +216,29 @@
|
||||
"cliPowershell": "PowerShell コマンド。",
|
||||
"cmdUnavailable": "コマンドが利用できません",
|
||||
"cmdCopied": "CLI コマンドをコピーしました",
|
||||
"doctor": "OpenClaw Doctor",
|
||||
"doctorDesc": "`openclaw doctor --json` を実行して診断の生出力を確認します。",
|
||||
"runDoctor": "Doctor を実行",
|
||||
"runDoctorFix": "Doctor 修復を実行",
|
||||
"doctorSucceeded": "OpenClaw doctor が完了しました",
|
||||
"doctorFailed": "OpenClaw doctor が問題を報告しました",
|
||||
"doctorRunFailed": "OpenClaw doctor の実行に失敗しました",
|
||||
"doctorFixSucceeded": "OpenClaw doctor 修復が完了しました",
|
||||
"doctorFixFailed": "OpenClaw doctor 修復後も問題が残っています",
|
||||
"doctorFixRunFailed": "OpenClaw doctor 修復の実行に失敗しました",
|
||||
"doctorCopied": "診断出力をコピーしました",
|
||||
"doctorOk": "正常",
|
||||
"doctorIssue": "問題あり",
|
||||
"doctorFixOk": "修復済み",
|
||||
"doctorFixIssue": "修復失敗",
|
||||
"doctorExitCode": "終了コード",
|
||||
"doctorDuration": "所要時間",
|
||||
"doctorCommand": "コマンド",
|
||||
"doctorWorkingDir": "作業ディレクトリ",
|
||||
"doctorError": "エラー",
|
||||
"doctorStdout": "標準出力",
|
||||
"doctorStderr": "標準エラー",
|
||||
"doctorOutputEmpty": "(空)",
|
||||
"wsDiagnostic": "WS 診断モード",
|
||||
"wsDiagnosticDesc": "Gateway RPC デバッグのため一時的に WS/HTTP フォールバックを有効化します。",
|
||||
"wsDiagnosticEnabled": "WS 診断モードを有効化しました",
|
||||
|
||||
@@ -219,6 +219,29 @@
|
||||
"cliPowershell": "PowerShell 命令。",
|
||||
"cmdUnavailable": "命令不可用",
|
||||
"cmdCopied": "CLI 命令已复制",
|
||||
"doctor": "OpenClaw Doctor 诊断",
|
||||
"doctorDesc": "运行 `openclaw doctor --json` 并查看原始诊断输出。",
|
||||
"runDoctor": "运行 Doctor",
|
||||
"runDoctorFix": "运行 Doctor 并修复",
|
||||
"doctorSucceeded": "OpenClaw doctor 已完成",
|
||||
"doctorFailed": "OpenClaw doctor 检测到问题",
|
||||
"doctorRunFailed": "运行 OpenClaw doctor 失败",
|
||||
"doctorFixSucceeded": "OpenClaw doctor 修复已完成",
|
||||
"doctorFixFailed": "OpenClaw doctor 修复后仍有问题",
|
||||
"doctorFixRunFailed": "运行 OpenClaw doctor 修复失败",
|
||||
"doctorCopied": "诊断输出已复制",
|
||||
"doctorOk": "正常",
|
||||
"doctorIssue": "发现问题",
|
||||
"doctorFixOk": "已修复",
|
||||
"doctorFixIssue": "修复失败",
|
||||
"doctorExitCode": "退出码",
|
||||
"doctorDuration": "耗时",
|
||||
"doctorCommand": "命令",
|
||||
"doctorWorkingDir": "工作目录",
|
||||
"doctorError": "错误",
|
||||
"doctorStdout": "标准输出",
|
||||
"doctorStderr": "标准错误",
|
||||
"doctorOutputEmpty": "(空)",
|
||||
"wsDiagnostic": "WS 诊断模式",
|
||||
"wsDiagnosticDesc": "临时启用 WS/HTTP 回退链,用于网关 RPC 调试。",
|
||||
"wsDiagnosticEnabled": "已启用 WS 诊断模式",
|
||||
|
||||
@@ -12,25 +12,15 @@ import { hostApiFetch } from '@/lib/host-api';
|
||||
import { trackUiEvent } from '@/lib/telemetry';
|
||||
import { ProvidersSettings } from '@/components/settings/ProvidersSettings';
|
||||
import { FeedbackState } from '@/components/common/FeedbackState';
|
||||
|
||||
type UsageHistoryEntry = {
|
||||
timestamp: string;
|
||||
sessionId: string;
|
||||
agentId: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
content?: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
totalTokens: number;
|
||||
costUsd?: number;
|
||||
};
|
||||
|
||||
type UsageWindow = '7d' | '30d' | 'all';
|
||||
type UsageGroupBy = 'model' | 'day';
|
||||
const USAGE_FETCH_MAX_ATTEMPTS = 6;
|
||||
import {
|
||||
filterUsageHistoryByWindow,
|
||||
groupUsageHistory,
|
||||
type UsageGroupBy,
|
||||
type UsageHistoryEntry,
|
||||
type UsageWindow,
|
||||
} from './usage-history';
|
||||
const DEFAULT_USAGE_FETCH_MAX_ATTEMPTS = 6;
|
||||
const WINDOWS_USAGE_FETCH_MAX_ATTEMPTS = 10;
|
||||
const USAGE_FETCH_RETRY_DELAY_MS = 1500;
|
||||
|
||||
export function Models() {
|
||||
@@ -38,6 +28,9 @@ export function Models() {
|
||||
const gatewayStatus = useGatewayStore((state) => state.status);
|
||||
const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked);
|
||||
const isGatewayRunning = gatewayStatus.state === 'running';
|
||||
const usageFetchMaxAttempts = window.electron.platform === 'win32'
|
||||
? WINDOWS_USAGE_FETCH_MAX_ATTEMPTS
|
||||
: DEFAULT_USAGE_FETCH_MAX_ATTEMPTS;
|
||||
|
||||
const [usageHistory, setUsageHistory] = useState<UsageHistoryEntry[]>([]);
|
||||
const [usageGroupBy, setUsageGroupBy] = useState<UsageGroupBy>('model');
|
||||
@@ -87,7 +80,7 @@ export function Models() {
|
||||
restartMarker,
|
||||
});
|
||||
|
||||
if (normalized.length === 0 && attempt < USAGE_FETCH_MAX_ATTEMPTS) {
|
||||
if (normalized.length === 0 && attempt < usageFetchMaxAttempts) {
|
||||
trackUiEvent('models.token_usage_fetch_retry_scheduled', {
|
||||
generation,
|
||||
attempt,
|
||||
@@ -113,7 +106,7 @@ export function Models() {
|
||||
restartMarker,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
if (attempt < USAGE_FETCH_MAX_ATTEMPTS) {
|
||||
if (attempt < usageFetchMaxAttempts) {
|
||||
trackUiEvent('models.token_usage_fetch_retry_scheduled', {
|
||||
generation,
|
||||
attempt,
|
||||
@@ -143,7 +136,7 @@ export function Models() {
|
||||
usageFetchTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isGatewayRunning, gatewayStatus.connectedAt, gatewayStatus.pid]);
|
||||
}, [isGatewayRunning, gatewayStatus.connectedAt, gatewayStatus.pid, usageFetchMaxAttempts]);
|
||||
|
||||
const visibleUsageHistory = isGatewayRunning ? usageHistory : [];
|
||||
const filteredUsageHistory = filterUsageHistoryByWindow(visibleUsageHistory, usageWindow);
|
||||
@@ -383,84 +376,6 @@ function formatUsageTimestamp(timestamp: string): string {
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function groupUsageHistory(
|
||||
entries: UsageHistoryEntry[],
|
||||
groupBy: UsageGroupBy,
|
||||
): Array<{
|
||||
label: string;
|
||||
totalTokens: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheTokens: number;
|
||||
sortKey: number | string;
|
||||
}> {
|
||||
const grouped = new Map<string, {
|
||||
label: string;
|
||||
totalTokens: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheTokens: number;
|
||||
sortKey: number | string;
|
||||
}>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const label = groupBy === 'model'
|
||||
? (entry.model || 'Unknown')
|
||||
: formatUsageDay(entry.timestamp);
|
||||
const current = grouped.get(label) ?? {
|
||||
label,
|
||||
totalTokens: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheTokens: 0,
|
||||
sortKey: groupBy === 'day' ? getUsageDaySortKey(entry.timestamp) : label.toLowerCase(),
|
||||
};
|
||||
current.totalTokens += entry.totalTokens;
|
||||
current.inputTokens += entry.inputTokens;
|
||||
current.outputTokens += entry.outputTokens;
|
||||
current.cacheTokens += entry.cacheReadTokens + entry.cacheWriteTokens;
|
||||
grouped.set(label, current);
|
||||
}
|
||||
|
||||
return Array.from(grouped.values())
|
||||
.sort((a, b) => {
|
||||
if (groupBy === 'day') {
|
||||
return Number(a.sortKey) - Number(b.sortKey);
|
||||
}
|
||||
return b.totalTokens - a.totalTokens;
|
||||
})
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
function formatUsageDay(timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) return timestamp;
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function getUsageDaySortKey(timestamp: string): number {
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) return 0;
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
function filterUsageHistoryByWindow(entries: UsageHistoryEntry[], window: UsageWindow): UsageHistoryEntry[] {
|
||||
if (window === 'all') return entries;
|
||||
|
||||
const now = Date.now();
|
||||
const days = window === '7d' ? 7 : 30;
|
||||
const cutoff = now - days * 24 * 60 * 60 * 1000;
|
||||
|
||||
return entries.filter((entry) => {
|
||||
const timestamp = Date.parse(entry.timestamp);
|
||||
return Number.isFinite(timestamp) && timestamp >= cutoff;
|
||||
});
|
||||
}
|
||||
|
||||
function UsageBarChart({
|
||||
groups,
|
||||
emptyLabel,
|
||||
|
||||
93
src/pages/Models/usage-history.ts
Normal file
93
src/pages/Models/usage-history.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
export type UsageHistoryEntry = {
|
||||
timestamp: string;
|
||||
sessionId: string;
|
||||
agentId: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
content?: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
totalTokens: number;
|
||||
costUsd?: number;
|
||||
};
|
||||
|
||||
export type UsageWindow = '7d' | '30d' | 'all';
|
||||
export type UsageGroupBy = 'model' | 'day';
|
||||
|
||||
export type UsageGroup = {
|
||||
label: string;
|
||||
totalTokens: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheTokens: number;
|
||||
sortKey: number | string;
|
||||
};
|
||||
|
||||
export function formatUsageDay(timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) return timestamp;
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function getUsageDaySortKey(timestamp: string): number {
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) return 0;
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
export function groupUsageHistory(
|
||||
entries: UsageHistoryEntry[],
|
||||
groupBy: UsageGroupBy,
|
||||
): UsageGroup[] {
|
||||
const grouped = new Map<string, UsageGroup>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const label = groupBy === 'model'
|
||||
? (entry.model || 'Unknown')
|
||||
: formatUsageDay(entry.timestamp);
|
||||
const current = grouped.get(label) ?? {
|
||||
label,
|
||||
totalTokens: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheTokens: 0,
|
||||
sortKey: groupBy === 'day' ? getUsageDaySortKey(entry.timestamp) : label.toLowerCase(),
|
||||
};
|
||||
current.totalTokens += entry.totalTokens;
|
||||
current.inputTokens += entry.inputTokens;
|
||||
current.outputTokens += entry.outputTokens;
|
||||
current.cacheTokens += entry.cacheReadTokens + entry.cacheWriteTokens;
|
||||
grouped.set(label, current);
|
||||
}
|
||||
|
||||
const sorted = Array.from(grouped.values()).sort((a, b) => {
|
||||
if (groupBy === 'day') {
|
||||
return Number(a.sortKey) - Number(b.sortKey);
|
||||
}
|
||||
return b.totalTokens - a.totalTokens;
|
||||
});
|
||||
|
||||
return groupBy === 'model' ? sorted.slice(0, 8) : sorted;
|
||||
}
|
||||
|
||||
export function filterUsageHistoryByWindow(
|
||||
entries: UsageHistoryEntry[],
|
||||
window: UsageWindow,
|
||||
now = Date.now(),
|
||||
): UsageHistoryEntry[] {
|
||||
if (window === 'all') return entries;
|
||||
|
||||
const days = window === '7d' ? 7 : 30;
|
||||
const cutoff = now - days * 24 * 60 * 60 * 1000;
|
||||
|
||||
return entries.filter((entry) => {
|
||||
const timestamp = Date.parse(entry.timestamp);
|
||||
return Number.isFinite(timestamp) && timestamp >= cutoff;
|
||||
});
|
||||
}
|
||||
@@ -100,6 +100,19 @@ export function Settings() {
|
||||
const showCliTools = true;
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
const [logContent, setLogContent] = useState('');
|
||||
const [doctorRunningMode, setDoctorRunningMode] = useState<'diagnose' | 'fix' | null>(null);
|
||||
const [doctorResult, setDoctorResult] = useState<{
|
||||
mode: 'diagnose' | 'fix';
|
||||
success: boolean;
|
||||
exitCode: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
command: string;
|
||||
cwd: string;
|
||||
durationMs: number;
|
||||
timedOut?: boolean;
|
||||
error?: string;
|
||||
} | null>(null);
|
||||
|
||||
const handleShowLogs = async () => {
|
||||
try {
|
||||
@@ -123,6 +136,72 @@ export function Settings() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunOpenClawDoctor = async (mode: 'diagnose' | 'fix') => {
|
||||
setDoctorRunningMode(mode);
|
||||
try {
|
||||
const result = await hostApiFetch<{
|
||||
mode: 'diagnose' | 'fix';
|
||||
success: boolean;
|
||||
exitCode: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
command: string;
|
||||
cwd: string;
|
||||
durationMs: number;
|
||||
timedOut?: boolean;
|
||||
error?: string;
|
||||
}>('/api/app/openclaw-doctor', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mode }),
|
||||
});
|
||||
setDoctorResult(result);
|
||||
if (result.success) {
|
||||
toast.success(mode === 'fix' ? t('developer.doctorFixSucceeded') : t('developer.doctorSucceeded'));
|
||||
} else {
|
||||
toast.error(result.error || (mode === 'fix' ? t('developer.doctorFixFailed') : t('developer.doctorFailed')));
|
||||
}
|
||||
} catch (error) {
|
||||
const message = toUserMessage(error) || (mode === 'fix' ? t('developer.doctorFixRunFailed') : t('developer.doctorRunFailed'));
|
||||
toast.error(message);
|
||||
setDoctorResult({
|
||||
mode,
|
||||
success: false,
|
||||
exitCode: null,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
command: 'openclaw doctor --json',
|
||||
cwd: '',
|
||||
durationMs: 0,
|
||||
error: message,
|
||||
});
|
||||
} finally {
|
||||
setDoctorRunningMode(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyDoctorOutput = async () => {
|
||||
if (!doctorResult) return;
|
||||
const payload = [
|
||||
`command: ${doctorResult.command}`,
|
||||
`cwd: ${doctorResult.cwd}`,
|
||||
`exitCode: ${doctorResult.exitCode ?? 'null'}`,
|
||||
`durationMs: ${doctorResult.durationMs}`,
|
||||
'',
|
||||
'[stdout]',
|
||||
doctorResult.stdout.trim() || '(empty)',
|
||||
'',
|
||||
'[stderr]',
|
||||
doctorResult.stderr.trim() || '(empty)',
|
||||
].join('\n');
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(payload);
|
||||
toast.success(t('developer.doctorCopied'));
|
||||
} catch (error) {
|
||||
toast.error(`Failed to copy doctor output: ${String(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const refreshControlUiInfo = async () => {
|
||||
@@ -737,6 +816,86 @@ export function Settings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<Label className="text-[14px] font-medium text-foreground">{t('developer.doctor')}</Label>
|
||||
<p className="text-[13px] text-muted-foreground mt-1">
|
||||
{t('developer.doctorDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void handleRunOpenClawDoctor('diagnose')}
|
||||
disabled={doctorRunningMode !== null}
|
||||
className="rounded-xl h-10 px-4 bg-transparent border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2${doctorRunningMode === 'diagnose' ? ' animate-spin' : ''}`} />
|
||||
{doctorRunningMode === 'diagnose' ? t('common:status.running') : t('developer.runDoctor')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void handleRunOpenClawDoctor('fix')}
|
||||
disabled={doctorRunningMode !== null}
|
||||
className="rounded-xl h-10 px-4 bg-transparent border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2${doctorRunningMode === 'fix' ? ' animate-spin' : ''}`} />
|
||||
{doctorRunningMode === 'fix' ? t('common:status.running') : t('developer.runDoctorFix')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCopyDoctorOutput}
|
||||
disabled={!doctorResult}
|
||||
className="rounded-xl h-10 px-4 bg-transparent border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
{t('common:actions.copy')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{doctorResult && (
|
||||
<div className="space-y-3 rounded-2xl border border-black/10 dark:border-white/10 p-5 bg-black/5 dark:bg-white/5">
|
||||
<div className="flex flex-wrap gap-2 text-[12px]">
|
||||
<Badge variant={doctorResult.success ? 'secondary' : 'destructive'} className="rounded-full px-3 py-1">
|
||||
{doctorResult.mode === 'fix'
|
||||
? (doctorResult.success ? t('developer.doctorFixOk') : t('developer.doctorFixIssue'))
|
||||
: (doctorResult.success ? t('developer.doctorOk') : t('developer.doctorIssue'))}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="rounded-full px-3 py-1">
|
||||
{t('developer.doctorExitCode')}: {doctorResult.exitCode ?? 'null'}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="rounded-full px-3 py-1">
|
||||
{t('developer.doctorDuration')}: {Math.round(doctorResult.durationMs)}ms
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-1 text-[12px] text-muted-foreground font-mono break-all">
|
||||
<p>{t('developer.doctorCommand')}: {doctorResult.command}</p>
|
||||
<p>{t('developer.doctorWorkingDir')}: {doctorResult.cwd || '-'}</p>
|
||||
{doctorResult.error && <p>{t('developer.doctorError')}: {doctorResult.error}</p>}
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<p className="text-[12px] font-semibold text-foreground/80">{t('developer.doctorStdout')}</p>
|
||||
<pre className="max-h-72 overflow-auto rounded-xl border border-black/10 dark:border-white/10 bg-white dark:bg-card p-3 text-[11px] font-mono whitespace-pre-wrap break-words">
|
||||
{doctorResult.stdout.trim() || t('developer.doctorOutputEmpty')}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-[12px] font-semibold text-foreground/80">{t('developer.doctorStderr')}</p>
|
||||
<pre className="max-h-72 overflow-auto rounded-xl border border-black/10 dark:border-white/10 bg-white dark:bg-card p-3 text-[11px] font-mono whitespace-pre-wrap break-words">
|
||||
{doctorResult.stderr.trim() || t('developer.doctorOutputEmpty')}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-2xl border border-black/10 dark:border-white/10 p-5 bg-transparent">
|
||||
<div>
|
||||
|
||||
59
tests/unit/app-routes.test.ts
Normal file
59
tests/unit/app-routes.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
|
||||
const runOpenClawDoctorMock = vi.fn();
|
||||
const runOpenClawDoctorFixMock = vi.fn();
|
||||
const sendJsonMock = vi.fn();
|
||||
const sendNoContentMock = vi.fn();
|
||||
|
||||
vi.mock('@electron/utils/openclaw-doctor', () => ({
|
||||
runOpenClawDoctor: (...args: unknown[]) => runOpenClawDoctorMock(...args),
|
||||
runOpenClawDoctorFix: (...args: unknown[]) => runOpenClawDoctorFixMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@electron/api/route-utils', () => ({
|
||||
setCorsHeaders: vi.fn(),
|
||||
parseJsonBody: vi.fn().mockResolvedValue({}),
|
||||
sendJson: (...args: unknown[]) => sendJsonMock(...args),
|
||||
sendNoContent: (...args: unknown[]) => sendNoContentMock(...args),
|
||||
}));
|
||||
|
||||
describe('handleAppRoutes', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('runs openclaw doctor through the host api', async () => {
|
||||
runOpenClawDoctorMock.mockResolvedValueOnce({ success: true, exitCode: 0 });
|
||||
const { handleAppRoutes } = await import('@electron/api/routes/app');
|
||||
|
||||
const handled = await handleAppRoutes(
|
||||
{ method: 'POST' } as IncomingMessage,
|
||||
{} as ServerResponse,
|
||||
new URL('http://127.0.0.1:3210/api/app/openclaw-doctor'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(runOpenClawDoctorMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendJsonMock).toHaveBeenCalledWith(expect.anything(), 200, { success: true, exitCode: 0 });
|
||||
});
|
||||
|
||||
it('runs openclaw doctor fix when requested', async () => {
|
||||
const { parseJsonBody } = await import('@electron/api/route-utils');
|
||||
vi.mocked(parseJsonBody).mockResolvedValueOnce({ mode: 'fix' });
|
||||
runOpenClawDoctorFixMock.mockResolvedValueOnce({ success: false, exitCode: 1 });
|
||||
const { handleAppRoutes } = await import('@electron/api/routes/app');
|
||||
|
||||
const handled = await handleAppRoutes(
|
||||
{ method: 'POST' } as IncomingMessage,
|
||||
{} as ServerResponse,
|
||||
new URL('http://127.0.0.1:3210/api/app/openclaw-doctor'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(runOpenClawDoctorFixMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendJsonMock).toHaveBeenCalledWith(expect.anything(), 200, { success: false, exitCode: 1 });
|
||||
});
|
||||
});
|
||||
68
tests/unit/models-usage-history.test.ts
Normal file
68
tests/unit/models-usage-history.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
filterUsageHistoryByWindow,
|
||||
groupUsageHistory,
|
||||
type UsageHistoryEntry,
|
||||
} from '@/pages/Models/usage-history';
|
||||
|
||||
function createEntry(day: number, totalTokens: number): UsageHistoryEntry {
|
||||
return {
|
||||
timestamp: `2026-03-${String(day).padStart(2, '0')}T12:00:00.000Z`,
|
||||
sessionId: `session-${day}`,
|
||||
agentId: 'main',
|
||||
model: 'gpt-5',
|
||||
inputTokens: totalTokens,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalTokens,
|
||||
};
|
||||
}
|
||||
|
||||
describe('models usage history helpers', () => {
|
||||
it('keeps all day buckets instead of truncating to the first eight', () => {
|
||||
const entries = Array.from({ length: 12 }, (_, index) => createEntry(index + 1, index + 1));
|
||||
|
||||
const groups = groupUsageHistory(entries, 'day');
|
||||
|
||||
expect(groups).toHaveLength(12);
|
||||
expect(groups[0]?.totalTokens).toBe(1);
|
||||
expect(groups[11]?.totalTokens).toBe(12);
|
||||
});
|
||||
|
||||
it('limits model buckets to the top eight by total tokens', () => {
|
||||
const entries = Array.from({ length: 10 }, (_, index) => ({
|
||||
...createEntry(index + 1, index + 1),
|
||||
model: `model-${index + 1}`,
|
||||
}));
|
||||
|
||||
const groups = groupUsageHistory(entries, 'model');
|
||||
|
||||
expect(groups).toHaveLength(8);
|
||||
expect(groups[0]?.label).toBe('model-10');
|
||||
expect(groups[7]?.label).toBe('model-3');
|
||||
});
|
||||
|
||||
it('filters the last 30 days relative to now instead of calendar month boundaries', () => {
|
||||
const now = Date.parse('2026-03-12T12:00:00.000Z');
|
||||
const entries = [
|
||||
{
|
||||
...createEntry(12, 12),
|
||||
timestamp: '2026-03-12T12:00:00.000Z',
|
||||
},
|
||||
{
|
||||
...createEntry(11, 11),
|
||||
timestamp: '2026-02-11T12:00:00.000Z',
|
||||
},
|
||||
{
|
||||
...createEntry(10, 10),
|
||||
timestamp: '2026-02-10T11:59:59.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const filtered = filterUsageHistoryByWindow(entries, '30d', now);
|
||||
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered.map((entry) => entry.totalTokens)).toEqual([12, 11]);
|
||||
});
|
||||
});
|
||||
92
tests/unit/telemetry.test.ts
Normal file
92
tests/unit/telemetry.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
shutdownMock,
|
||||
captureMock,
|
||||
getSettingMock,
|
||||
setSettingMock,
|
||||
loggerDebugMock,
|
||||
loggerErrorMock,
|
||||
} = vi.hoisted(() => ({
|
||||
shutdownMock: vi.fn(),
|
||||
captureMock: vi.fn(),
|
||||
getSettingMock: vi.fn(),
|
||||
setSettingMock: vi.fn(),
|
||||
loggerDebugMock: vi.fn(),
|
||||
loggerErrorMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('posthog-node', () => ({
|
||||
PostHog: vi.fn(function PostHogMock() {
|
||||
return {
|
||||
capture: captureMock,
|
||||
shutdown: shutdownMock,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@electron/utils/store', () => ({
|
||||
getSetting: getSettingMock,
|
||||
setSetting: setSettingMock,
|
||||
}));
|
||||
|
||||
vi.mock('@electron/utils/logger', () => ({
|
||||
logger: {
|
||||
debug: loggerDebugMock,
|
||||
error: loggerErrorMock,
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getVersion: () => '0.2.1',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node-machine-id', () => ({
|
||||
machineIdSync: () => 'machine-id-1',
|
||||
}));
|
||||
|
||||
describe('main telemetry shutdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
getSettingMock.mockImplementation(async (key: string) => {
|
||||
switch (key) {
|
||||
case 'telemetryEnabled':
|
||||
return true;
|
||||
case 'machineId':
|
||||
return 'existing-machine-id';
|
||||
case 'hasReportedInstall':
|
||||
return true;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
setSettingMock.mockResolvedValue(undefined);
|
||||
captureMock.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
it('ignores PostHog network timeout errors during shutdown', async () => {
|
||||
shutdownMock.mockRejectedValueOnce(
|
||||
Object.assign(new Error('Network error while fetching PostHog'), {
|
||||
name: 'PostHogFetchNetworkError',
|
||||
cause: Object.assign(new Error('The operation was aborted due to timeout'), {
|
||||
name: 'TimeoutError',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { initTelemetry, shutdownTelemetry } = await import('@electron/utils/telemetry');
|
||||
await initTelemetry();
|
||||
await shutdownTelemetry();
|
||||
|
||||
expect(loggerErrorMock).not.toHaveBeenCalled();
|
||||
expect(loggerDebugMock).toHaveBeenCalledWith(
|
||||
'Ignored telemetry shutdown network error:',
|
||||
expect.objectContaining({ name: 'PostHogFetchNetworkError' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,10 @@ describe('extractSessionIdFromTranscriptFileName', () => {
|
||||
expect(extractSessionIdFromTranscriptFileName('abc-123.jsonl.reset.2026-03-09T03-01-29.968Z')).toBe('abc-123');
|
||||
});
|
||||
|
||||
it('parses deleted reset transcript names', () => {
|
||||
expect(extractSessionIdFromTranscriptFileName('abc-123.deleted.jsonl.reset.2026-03-09T03-01-29.968Z')).toBe('abc-123');
|
||||
});
|
||||
|
||||
it('returns undefined for non-transcript files', () => {
|
||||
expect(extractSessionIdFromTranscriptFileName('sessions.json')).toBeUndefined();
|
||||
expect(extractSessionIdFromTranscriptFileName('abc-123.log')).toBeUndefined();
|
||||
|
||||
89
tests/unit/token-usage-scan.test.ts
Normal file
89
tests/unit/token-usage-scan.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { mkdir, rm, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { testHome, testUserData } = vi.hoisted(() => {
|
||||
const suffix = Math.random().toString(36).slice(2);
|
||||
return {
|
||||
testHome: `/tmp/clawx-token-usage-${suffix}`,
|
||||
testUserData: `/tmp/clawx-token-usage-user-data-${suffix}`,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('os', async () => {
|
||||
const actual = await vi.importActual<typeof import('os')>('os');
|
||||
const mocked = {
|
||||
...actual,
|
||||
homedir: () => testHome,
|
||||
};
|
||||
return {
|
||||
...mocked,
|
||||
default: mocked,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getPath: () => testUserData,
|
||||
getVersion: () => '0.0.0-test',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('token usage session scan', () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
await rm(testHome, { recursive: true, force: true });
|
||||
await rm(testUserData, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('includes transcripts from agent directories that exist on disk but are not configured', async () => {
|
||||
const openclawDir = join(testHome, '.openclaw');
|
||||
await mkdir(openclawDir, { recursive: true });
|
||||
await writeFile(join(openclawDir, 'openclaw.json'), JSON.stringify({
|
||||
agents: {
|
||||
list: [
|
||||
{ id: 'main', name: 'Main', default: true },
|
||||
],
|
||||
},
|
||||
}, null, 2), 'utf8');
|
||||
|
||||
const diskOnlySessionsDir = join(openclawDir, 'agents', 'custom-custom25', 'sessions');
|
||||
await mkdir(diskOnlySessionsDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(diskOnlySessionsDir, 'f8e66f77-0125-4e2f-b750-9c4de01e8f5a.jsonl'),
|
||||
[
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
timestamp: '2026-03-12T12:19:00.000Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
model: 'gpt-5.2-2025-12-11',
|
||||
provider: 'openai',
|
||||
usage: {
|
||||
input: 17649,
|
||||
output: 107,
|
||||
total: 17756,
|
||||
},
|
||||
},
|
||||
}),
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const { getRecentTokenUsageHistory } = await import('@electron/utils/token-usage');
|
||||
const entries = await getRecentTokenUsageHistory();
|
||||
|
||||
expect(entries).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
agentId: 'custom-custom25',
|
||||
sessionId: 'f8e66f77-0125-4e2f-b750-9c4de01e8f5a',
|
||||
model: 'gpt-5.2-2025-12-11',
|
||||
totalTokens: 17756,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user