From bc079bc88656435da1d5b4b2329f89c90c910d1b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 25 Feb 2026 22:59:15 +0700 Subject: [PATCH] feat(dashboard): add dedicated Factory Droid diagnostics page - add /droid route under a new Compatible CLIs sidebar section - expose /api/droid/diagnostics and /api/droid/settings/raw for install and BYOK visibility - include read-only settings.json viewer plus model/provider breakdown - add unit coverage for droid dashboard service parsing and path logic --- README.md | 3 + src/web-server/routes/droid-routes.ts | 34 ++ src/web-server/routes/index.ts | 4 + .../services/compatible-cli-types.ts | 70 ++++ .../services/droid-dashboard-service.ts | 298 ++++++++++++++ .../droid-dashboard-service.test.ts | 114 ++++++ ui/src/App.tsx | 9 + ui/src/components/layout/app-sidebar.tsx | 5 + ui/src/hooks/use-droid.ts | 119 ++++++ ui/src/pages/droid.tsx | 380 ++++++++++++++++++ ui/src/pages/index.tsx | 2 + 11 files changed, 1038 insertions(+) create mode 100644 src/web-server/routes/droid-routes.ts create mode 100644 src/web-server/services/compatible-cli-types.ts create mode 100644 src/web-server/services/droid-dashboard-service.ts create mode 100644 tests/unit/web-server/droid-dashboard-service.test.ts create mode 100644 ui/src/hooks/use-droid.ts create mode 100644 ui/src/pages/droid.tsx diff --git a/README.md b/README.md index 0245c4a9..cca63796 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ The dashboard provides visual management for all account types: - **Claude Accounts**: Create isolated instances (work, personal, client) - **OAuth Providers**: One-click auth for Gemini, Codex, Antigravity, Kiro, Copilot - **API Profiles**: Configure GLM, Kimi with your keys +- **Factory Droid**: Track Droid install location and BYOK settings health - **Updates Center**: Track support rollouts (Droid target, CLIProxy provider changes, WebSearch integrations) - **Health Monitor**: Real-time status across all profiles @@ -166,6 +167,8 @@ CCS also persists Droid's active model selector in `~/.factory/settings.json` (`model: custom:`). This avoids passing `-m` argv in interactive mode, which Droid treats as queued prompt text. +Dashboard parity: `ccs config` -> `Factory Droid` + ### Per-Profile Target Defaults You can pin a default target (`claude` or `droid`) per profile: diff --git a/src/web-server/routes/droid-routes.ts b/src/web-server/routes/droid-routes.ts new file mode 100644 index 00000000..0bbb19e5 --- /dev/null +++ b/src/web-server/routes/droid-routes.ts @@ -0,0 +1,34 @@ +import type { Request, Response } from 'express'; +import { Router } from 'express'; +import { + getDroidDashboardDiagnostics, + getDroidRawSettings, +} from '../services/droid-dashboard-service'; + +const router = Router(); + +/** + * GET /api/droid/diagnostics + * Dashboard-ready Droid installation + BYOK configuration diagnostics. + */ +router.get('/diagnostics', (_req: Request, res: Response): void => { + try { + res.json(getDroidDashboardDiagnostics()); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/droid/settings/raw + * Raw ~/.factory/settings.json payload for read-only viewer. + */ +router.get('/settings/raw', (_req: Request, res: Response): void => { + try { + res.json(getDroidRawSettings()); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +export default router; diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index f9517635..09097156 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -21,6 +21,7 @@ import cliproxyStatsRoutes from './cliproxy-stats-routes'; import cliproxySyncRoutes from './cliproxy-sync-routes'; import copilotRoutes from './copilot-routes'; import cursorRoutes from './cursor-routes'; +import droidRoutes from './droid-routes'; import miscRoutes from './misc-routes'; import cliproxyServerRoutes from './proxy-routes'; import authRoutes from './auth-routes'; @@ -67,6 +68,9 @@ apiRoutes.use('/copilot', copilotRoutes); // ==================== Cursor ==================== apiRoutes.use('/cursor', cursorRoutes); +// ==================== Droid ==================== +apiRoutes.use('/droid', droidRoutes); + // ==================== CLIProxy Server Settings ==================== apiRoutes.use('/cliproxy-server', cliproxyServerRoutes); diff --git a/src/web-server/services/compatible-cli-types.ts b/src/web-server/services/compatible-cli-types.ts new file mode 100644 index 00000000..6493f783 --- /dev/null +++ b/src/web-server/services/compatible-cli-types.ts @@ -0,0 +1,70 @@ +export type DroidBinarySource = 'CCS_DROID_PATH' | 'PATH' | 'missing'; + +export interface DroidBinaryDiagnostics { + installed: boolean; + path: string | null; + installDir: string | null; + source: DroidBinarySource; + version: string | null; + overridePath: string | null; +} + +export interface DroidConfigFileDiagnostics { + label: string; + path: string; + resolvedPath: string; + exists: boolean; + isSymlink: boolean; + isRegularFile: boolean; + sizeBytes: number | null; + mtimeMs: number | null; + parseError: string | null; + readError: string | null; +} + +export interface DroidCustomModelDiagnostics { + displayName: string; + model: string; + provider: string; + baseUrl: string; + host: string | null; + maxOutputTokens: number | null; + isCcsManaged: boolean; + apiKeyState: 'set' | 'missing'; + apiKeyPreview: string | null; +} + +export interface DroidByokDiagnostics { + activeModelSelector: string | null; + customModelCount: number; + ccsManagedCount: number; + userManagedCount: number; + invalidModelEntryCount: number; + providerBreakdown: Record; + customModels: DroidCustomModelDiagnostics[]; +} + +export interface DroidDashboardDiagnostics { + binary: DroidBinaryDiagnostics; + files: { + settings: DroidConfigFileDiagnostics; + globalConfig: DroidConfigFileDiagnostics; + }; + byok: DroidByokDiagnostics; + warnings: string[]; + docsReference: { + providerValues: string[]; + settingsHierarchy: string[]; + notes: string[]; + }; +} + +export interface DroidRawSettingsResponse { + path: string; + resolvedPath: string; + exists: boolean; + mtime: number; + rawText: string; + settings: Record | null; + parseError: string | null; +} diff --git a/src/web-server/services/droid-dashboard-service.ts b/src/web-server/services/droid-dashboard-service.ts new file mode 100644 index 00000000..050bffd9 --- /dev/null +++ b/src/web-server/services/droid-dashboard-service.ts @@ -0,0 +1,298 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { execFileSync } from 'child_process'; +import { detectDroidCli } from '../../targets/droid-detector'; +import type { + DroidByokDiagnostics, + DroidConfigFileDiagnostics, + DroidCustomModelDiagnostics, + DroidDashboardDiagnostics, + DroidRawSettingsResponse, +} from './compatible-cli-types'; + +interface DroidConfigPaths { + settingsPath: string; + settingsDisplayPath: string; + globalConfigPath: string; + globalConfigDisplayPath: string; +} + +interface JsonFileProbe { + diagnostics: DroidConfigFileDiagnostics; + json: Record | null; + rawText: string; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function parseHost(value: string): string | null { + try { + return new URL(value).host || null; + } catch { + return null; + } +} + +export function maskApiKeyPreview(value: string): string { + if (!value) return ''; + const suffix = value.slice(-4); + return `***${suffix}`; +} + +function isCcsManagedDisplayName(displayName: string): boolean { + return displayName.startsWith('CCS ') || displayName.startsWith('ccs-'); +} + +export function resolveDroidConfigPaths( + options: { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + homeDir?: string; + } = {} +): DroidConfigPaths { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const homeDir = options.homeDir ?? os.homedir(); + + const byokBase = env.CCS_HOME || homeDir; + const settingsPath = path.join(byokBase, '.factory', 'settings.json'); + + const globalConfigRoot = + platform === 'win32' + ? env.APPDATA || path.join(homeDir, 'AppData', 'Roaming') + : env.XDG_CONFIG_HOME || path.join(homeDir, '.config'); + const globalConfigPath = path.join(globalConfigRoot, 'factory', 'config.json'); + + return { + settingsPath, + settingsDisplayPath: '~/.factory/settings.json', + globalConfigPath, + globalConfigDisplayPath: + platform === 'win32' ? '%APPDATA%/factory/config.json' : '~/.config/factory/config.json', + }; +} + +function getBinaryVersion(binaryPath: string): string | null { + try { + return execFileSync(binaryPath, ['--version'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000, + }) + .trim() + .split('\n')[0] + .trim(); + } catch { + return null; + } +} + +function readJsonFileProbe(filePath: string, label: string, displayPath: string): JsonFileProbe { + if (!fs.existsSync(filePath)) { + return { + diagnostics: { + label, + path: displayPath, + resolvedPath: filePath, + exists: false, + isSymlink: false, + isRegularFile: false, + sizeBytes: null, + mtimeMs: null, + parseError: null, + readError: null, + }, + json: null, + rawText: '{}', + }; + } + + const stat = fs.lstatSync(filePath); + const diagnostics: DroidConfigFileDiagnostics = { + label, + path: displayPath, + resolvedPath: filePath, + exists: true, + isSymlink: stat.isSymbolicLink(), + isRegularFile: stat.isFile(), + sizeBytes: stat.size, + mtimeMs: stat.mtimeMs, + parseError: null, + readError: null, + }; + + if (diagnostics.isSymlink) { + diagnostics.readError = 'Refusing symlink file for safety.'; + return { diagnostics, json: null, rawText: '{}' }; + } + + if (!diagnostics.isRegularFile) { + diagnostics.readError = 'Target is not a regular file.'; + return { diagnostics, json: null, rawText: '{}' }; + } + + try { + const rawText = fs.readFileSync(filePath, 'utf8'); + try { + const parsed = JSON.parse(rawText); + if (!isObject(parsed)) { + diagnostics.parseError = 'JSON root must be an object.'; + return { diagnostics, json: null, rawText }; + } + return { diagnostics, json: parsed, rawText }; + } catch (error) { + diagnostics.parseError = (error as Error).message; + return { diagnostics, json: null, rawText }; + } + } catch (error) { + diagnostics.readError = (error as Error).message; + return { diagnostics, json: null, rawText: '{}' }; + } +} + +export function summarizeDroidCustomModels(customModelsValue: unknown): DroidByokDiagnostics { + const rows: DroidCustomModelDiagnostics[] = []; + const providerBreakdown: Record = {}; + let invalidModelEntryCount = 0; + + const source = Array.isArray(customModelsValue) + ? customModelsValue + : isObject(customModelsValue) + ? Object.values(customModelsValue) + : []; + + for (const item of source) { + if (!isObject(item)) { + invalidModelEntryCount += 1; + continue; + } + + const displayName = asString(item.displayName); + const model = asString(item.model); + const baseUrl = asString(item.baseUrl); + const providerRaw = asString(item.provider); + const apiKey = asString(item.apiKey); + + if (!displayName || !model || !baseUrl || !providerRaw) { + invalidModelEntryCount += 1; + continue; + } + + const provider = providerRaw.toLowerCase(); + providerBreakdown[provider] = (providerBreakdown[provider] ?? 0) + 1; + + rows.push({ + displayName, + model, + provider, + baseUrl, + host: parseHost(baseUrl), + maxOutputTokens: typeof item.maxOutputTokens === 'number' ? item.maxOutputTokens : null, + isCcsManaged: isCcsManagedDisplayName(displayName), + apiKeyState: apiKey ? 'set' : 'missing', + apiKeyPreview: apiKey ? maskApiKeyPreview(apiKey) : null, + }); + } + + const ccsManagedCount = rows.filter((row) => row.isCcsManaged).length; + + return { + activeModelSelector: null, + customModelCount: rows.length, + ccsManagedCount, + userManagedCount: rows.length - ccsManagedCount, + invalidModelEntryCount, + providerBreakdown, + customModels: rows, + }; +} + +export function getDroidDashboardDiagnostics(): DroidDashboardDiagnostics { + const paths = resolveDroidConfigPaths(); + const binaryPath = detectDroidCli(); + + const source = process.env.CCS_DROID_PATH ? 'CCS_DROID_PATH' : binaryPath ? 'PATH' : 'missing'; + + const settingsProbe = readJsonFileProbe( + paths.settingsPath, + 'BYOK settings', + paths.settingsDisplayPath + ); + const globalConfigProbe = readJsonFileProbe( + paths.globalConfigPath, + 'Global config', + paths.globalConfigDisplayPath + ); + + const byok = summarizeDroidCustomModels(settingsProbe.json?.customModels); + byok.activeModelSelector = asString(settingsProbe.json?.model); + + const warnings: string[] = []; + if (!binaryPath) warnings.push('Droid binary is not detected in PATH or CCS_DROID_PATH.'); + if (settingsProbe.diagnostics.parseError) { + warnings.push('~/.factory/settings.json contains invalid JSON.'); + } + if (byok.invalidModelEntryCount > 0) { + warnings.push(`${byok.invalidModelEntryCount} customModels entries are malformed.`); + } + if (globalConfigProbe.diagnostics.parseError) { + warnings.push('Global Droid config JSON is invalid.'); + } + + return { + binary: { + installed: !!binaryPath, + path: binaryPath, + installDir: binaryPath ? path.dirname(binaryPath) : null, + source, + version: binaryPath ? getBinaryVersion(binaryPath) : null, + overridePath: process.env.CCS_DROID_PATH || null, + }, + files: { + settings: settingsProbe.diagnostics, + globalConfig: globalConfigProbe.diagnostics, + }, + byok, + warnings, + docsReference: { + providerValues: ['anthropic', 'openai', 'generic-chat-completion-api'], + settingsHierarchy: [ + 'project-level config', + 'user-level config', + 'home-level config', + 'CLI flags and env vars', + ], + notes: [ + 'BYOK custom models are read from ~/.factory/settings.json customModels[]', + 'Interactive model selection uses settings.model (custom:)', + 'droid exec supports --model for one-off execution mode', + ], + }, + }; +} + +export function getDroidRawSettings(): DroidRawSettingsResponse { + const paths = resolveDroidConfigPaths(); + const settingsProbe = readJsonFileProbe( + paths.settingsPath, + 'BYOK settings', + paths.settingsDisplayPath + ); + + return { + path: paths.settingsDisplayPath, + resolvedPath: paths.settingsPath, + exists: settingsProbe.diagnostics.exists, + mtime: settingsProbe.diagnostics.mtimeMs ?? Date.now(), + rawText: settingsProbe.rawText, + settings: settingsProbe.json, + parseError: settingsProbe.diagnostics.parseError, + }; +} diff --git a/tests/unit/web-server/droid-dashboard-service.test.ts b/tests/unit/web-server/droid-dashboard-service.test.ts new file mode 100644 index 00000000..23249fe3 --- /dev/null +++ b/tests/unit/web-server/droid-dashboard-service.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + getDroidRawSettings, + maskApiKeyPreview, + resolveDroidConfigPaths, + summarizeDroidCustomModels, +} from '../../../src/web-server/services/droid-dashboard-service'; + +const testRoot = path.join(os.tmpdir(), `ccs-droid-dashboard-test-${Date.now()}`); + +beforeEach(() => { + fs.mkdirSync(testRoot, { recursive: true }); + process.env.CCS_HOME = testRoot; +}); + +afterEach(() => { + delete process.env.CCS_HOME; + if (fs.existsSync(testRoot)) { + fs.rmSync(testRoot, { recursive: true, force: true }); + } +}); + +describe('droid-dashboard-service', () => { + it('resolves droid config paths on unix-like platforms', () => { + const resolved = resolveDroidConfigPaths({ + platform: 'darwin', + env: { + CCS_HOME: '/tmp/ccs-home', + XDG_CONFIG_HOME: '/tmp/xdg', + } as NodeJS.ProcessEnv, + homeDir: '/Users/tester', + }); + + expect(resolved.settingsPath).toBe('/tmp/ccs-home/.factory/settings.json'); + expect(resolved.globalConfigPath).toBe('/tmp/xdg/factory/config.json'); + expect(resolved.settingsDisplayPath).toBe('~/.factory/settings.json'); + expect(resolved.globalConfigDisplayPath).toBe('~/.config/factory/config.json'); + }); + + it('resolves droid config paths on windows platforms', () => { + const resolved = resolveDroidConfigPaths({ + platform: 'win32', + env: { + APPDATA: 'C:/Users/test/AppData/Roaming', + } as NodeJS.ProcessEnv, + homeDir: 'C:/Users/test', + }); + + expect(resolved.settingsPath).toBe(path.join('C:/Users/test', '.factory', 'settings.json')); + expect(resolved.globalConfigPath).toBe( + path.join('C:/Users/test/AppData/Roaming', 'factory', 'config.json') + ); + expect(resolved.globalConfigDisplayPath).toBe('%APPDATA%/factory/config.json'); + }); + + it('masks api key preview with only suffix', () => { + expect(maskApiKeyPreview('sk-abcdefghijklmnop')).toBe('***mnop'); + }); + + it('summarizes custom model entries with provider breakdown and ownership', () => { + const summary = summarizeDroidCustomModels([ + { + displayName: 'CCS codex', + model: 'gpt-5-codex', + baseUrl: 'http://127.0.0.1:8317/v1', + apiKey: 'secret-token-1234', + provider: 'openai', + }, + { + displayName: 'Factory team profile', + model: 'claude-sonnet-4-5', + baseUrl: 'https://api.anthropic.com', + apiKey: 'another-token-9999', + provider: 'anthropic', + }, + { + displayName: 'bad entry', + }, + ]); + + expect(summary.customModelCount).toBe(2); + expect(summary.ccsManagedCount).toBe(1); + expect(summary.userManagedCount).toBe(1); + expect(summary.invalidModelEntryCount).toBe(1); + expect(summary.providerBreakdown.openai).toBe(1); + expect(summary.providerBreakdown.anthropic).toBe(1); + expect(summary.customModels[0].apiKeyPreview).toBe('***1234'); + }); + + it('returns raw settings payload for missing settings file', () => { + const raw = getDroidRawSettings(); + + expect(raw.exists).toBe(false); + expect(raw.path).toBe('~/.factory/settings.json'); + expect(raw.rawText).toBe('{}'); + expect(raw.settings).toBeNull(); + }); + + it('returns parseError when settings.json is invalid JSON', () => { + const settingsDir = path.join(testRoot, '.factory'); + fs.mkdirSync(settingsDir, { recursive: true }); + fs.writeFileSync(path.join(settingsDir, 'settings.json'), '{ invalid-json'); + + const raw = getDroidRawSettings(); + + expect(raw.exists).toBe(true); + expect(raw.parseError).toBeString(); + expect(raw.settings).toBeNull(); + expect(raw.rawText).toContain('invalid-json'); + }); +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 4a823ae4..62ecb304 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -27,6 +27,7 @@ const CliproxyControlPanelPage = lazy(() => ); const CopilotPage = lazy(() => import('@/pages/copilot').then((m) => ({ default: m.CopilotPage }))); const CursorPage = lazy(() => import('@/pages/cursor').then((m) => ({ default: m.CursorPage }))); +const DroidPage = lazy(() => import('@/pages/droid').then((m) => ({ default: m.DroidPage }))); const AccountsPage = lazy(() => import('@/pages/accounts').then((m) => ({ default: m.AccountsPage })) ); @@ -117,6 +118,14 @@ export default function App() { } /> + }> + + + } + /> ; + customModels: DroidCustomModelDiagnostics[]; + }; + warnings: string[]; + docsReference: { + providerValues: string[]; + settingsHierarchy: string[]; + notes: string[]; + }; +} + +export interface DroidRawSettings { + path: string; + resolvedPath: string; + exists: boolean; + mtime: number; + rawText: string; + settings: Record | null; + parseError: string | null; +} + +async function fetchDroidDiagnostics(): Promise { + const res = await fetch(withApiBase('/droid/diagnostics')); + if (!res.ok) throw new Error('Failed to fetch Droid diagnostics'); + return res.json(); +} + +async function fetchDroidRawSettings(): Promise { + const res = await fetch(withApiBase('/droid/settings/raw')); + if (!res.ok) throw new Error('Failed to fetch Droid raw settings'); + return res.json(); +} + +export function useDroid() { + const diagnosticsQuery = useQuery({ + queryKey: ['droid-diagnostics'], + queryFn: fetchDroidDiagnostics, + refetchInterval: 10000, + }); + + const rawSettingsQuery = useQuery({ + queryKey: ['droid-raw-settings'], + queryFn: fetchDroidRawSettings, + }); + + return useMemo( + () => ({ + diagnostics: diagnosticsQuery.data, + diagnosticsLoading: diagnosticsQuery.isLoading, + diagnosticsError: diagnosticsQuery.error, + refetchDiagnostics: diagnosticsQuery.refetch, + + rawSettings: rawSettingsQuery.data, + rawSettingsLoading: rawSettingsQuery.isLoading, + rawSettingsError: rawSettingsQuery.error, + refetchRawSettings: rawSettingsQuery.refetch, + }), + [ + diagnosticsQuery.data, + diagnosticsQuery.isLoading, + diagnosticsQuery.error, + diagnosticsQuery.refetch, + rawSettingsQuery.data, + rawSettingsQuery.isLoading, + rawSettingsQuery.error, + rawSettingsQuery.refetch, + ] + ); +} diff --git a/ui/src/pages/droid.tsx b/ui/src/pages/droid.tsx new file mode 100644 index 00000000..5792ef09 --- /dev/null +++ b/ui/src/pages/droid.tsx @@ -0,0 +1,380 @@ +import { useMemo, useState } from 'react'; +import { toast } from 'sonner'; +import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; +import { + AlertTriangle, + CheckCircle2, + Copy, + FileCode2, + Folder, + GripVertical, + Loader2, + RefreshCw, + Server, + ShieldCheck, + TerminalSquare, + XCircle, +} from 'lucide-react'; +import { useDroid } from '@/hooks/use-droid'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { CodeEditor } from '@/components/shared/code-editor'; +import { cn } from '@/lib/utils'; + +function formatTimestamp(value: number | null | undefined): string { + if (!value || !Number.isFinite(value)) return 'N/A'; + return new Date(value).toLocaleString(); +} + +function formatBytes(value: number | null | undefined): string { + if (!value || value <= 0) return '0 B'; + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; + return `${(value / (1024 * 1024)).toFixed(2)} MB`; +} + +function DetailRow({ + label, + value, + mono = false, +}: { + label: string; + value: string; + mono?: boolean; +}) { + return ( +
+ {label} + {value} +
+ ); +} + +export function DroidPage() { + const { + diagnostics, + diagnosticsLoading, + diagnosticsError, + refetchDiagnostics, + rawSettings, + rawSettingsLoading, + refetchRawSettings, + } = useDroid(); + + const [copied, setCopied] = useState(false); + + const copyRawSettings = async () => { + if (!rawSettings?.rawText) return; + await navigator.clipboard.writeText(rawSettings.rawText); + setCopied(true); + toast.success('Droid settings copied to clipboard'); + window.setTimeout(() => setCopied(false), 1500); + }; + + const refreshAll = async () => { + await Promise.all([refetchDiagnostics(), refetchRawSettings()]); + }; + + const customModels = diagnostics?.byok.customModels ?? []; + const providerRows = useMemo( + () => Object.entries(diagnostics?.byok.providerBreakdown ?? {}).sort((a, b) => b[1] - a[1]), + [diagnostics?.byok.providerBreakdown] + ); + + const renderOverview = () => { + if (diagnosticsLoading) { + return ( +
+ + Loading Droid diagnostics... +
+ ); + } + + if (diagnosticsError || !diagnostics) { + return ( +
+ Failed to load Droid diagnostics. +
+ ); + } + + return ( + +
+ + + + + Runtime & Installation + + + +
+ Status + + {diagnostics.binary.installed ? 'Detected' : 'Not Found'} + +
+ + + + + +
+
+ + + + + + Config Files + + + + {[diagnostics.files.settings, diagnostics.files.globalConfig].map((file) => ( +
+
+ {file.label} + {file.exists ? ( + + ) : ( + + )} +
+ + + + + {file.parseError && ( +

Parse warning: {file.parseError}

+ )} + {file.readError && ( +

Read warning: {file.readError}

+ )} +
+ ))} +
+
+ + + + + + BYOK Summary + + + + + + + + + +
+

Providers

+
+ {providerRows.length === 0 && ( + + none + + )} + {providerRows.map(([provider, count]) => ( + + {provider}: {count} + + ))} +
+
+
+
+ + + + + + Docs-Aligned Notes + + + + {diagnostics.docsReference.notes.map((note) => ( +

+ - {note} +

+ ))} + +

+ Provider values: {diagnostics.docsReference.providerValues.join(', ')} +

+

+ Settings hierarchy: {diagnostics.docsReference.settingsHierarchy.join(' -> ')} +

+
+
+ + + + Custom Models + + +
+
+ Name / Model + Provider + Base URL +
+ +
+ {customModels.length === 0 && ( +
+ No custom models +
+ )} + {customModels.map((model) => ( +
+
+

{model.displayName}

+

{model.model}

+
+
+

{model.provider}

+

{model.apiKeyPreview || 'no-key'}

+
+
+

+ {model.host || model.baseUrl} +

+

+ {model.baseUrl} +

+
+
+ ))} +
+
+
+
+
+ + {diagnostics.warnings.length > 0 && ( + + + + + Warnings + + + + {diagnostics.warnings.map((warning) => ( +

+ - {warning} +

+ ))} +
+
+ )} +
+
+ ); + }; + + return ( +
+ + +
{renderOverview()}
+
+ + + + +
+
+
+

+ + Droid BYOK Settings +

+

+ {rawSettings?.path || '~/.factory/settings.json'} +

+
+
+ + +
+
+ +
+ {rawSettingsLoading ? ( +
+ + Loading settings.json... +
+ ) : ( +
+ {rawSettings?.parseError && ( +
+ Parse warning: {rawSettings.parseError} +
+ )} +
+
+ {}} + language="json" + readonly + minHeight="100%" + /> +
+
+
+ )} +
+
+
+
+
+ ); +} diff --git a/ui/src/pages/index.tsx b/ui/src/pages/index.tsx index dda9ae12..b4ef665f 100644 --- a/ui/src/pages/index.tsx +++ b/ui/src/pages/index.tsx @@ -17,3 +17,5 @@ export { AnalyticsPage } from './analytics'; export { CursorPage } from './cursor'; export { UpdatesPage } from './updates'; + +export { DroidPage } from './droid';