mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 08:17:11 +00:00
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
This commit is contained in:
@@ -64,6 +64,7 @@ The dashboard provides visual management for all account types:
|
|||||||
- **Claude Accounts**: Create isolated instances (work, personal, client)
|
- **Claude Accounts**: Create isolated instances (work, personal, client)
|
||||||
- **OAuth Providers**: One-click auth for Gemini, Codex, Antigravity, Kiro, Copilot
|
- **OAuth Providers**: One-click auth for Gemini, Codex, Antigravity, Kiro, Copilot
|
||||||
- **API Profiles**: Configure GLM, Kimi with your keys
|
- **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)
|
- **Updates Center**: Track support rollouts (Droid target, CLIProxy provider changes, WebSearch integrations)
|
||||||
- **Health Monitor**: Real-time status across all profiles
|
- **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:<alias>`). This avoids passing `-m` argv in interactive mode,
|
(`model: custom:<alias>`). This avoids passing `-m` argv in interactive mode,
|
||||||
which Droid treats as queued prompt text.
|
which Droid treats as queued prompt text.
|
||||||
|
|
||||||
|
Dashboard parity: `ccs config` -> `Factory Droid`
|
||||||
|
|
||||||
### Per-Profile Target Defaults
|
### Per-Profile Target Defaults
|
||||||
|
|
||||||
You can pin a default target (`claude` or `droid`) per profile:
|
You can pin a default target (`claude` or `droid`) per profile:
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -21,6 +21,7 @@ import cliproxyStatsRoutes from './cliproxy-stats-routes';
|
|||||||
import cliproxySyncRoutes from './cliproxy-sync-routes';
|
import cliproxySyncRoutes from './cliproxy-sync-routes';
|
||||||
import copilotRoutes from './copilot-routes';
|
import copilotRoutes from './copilot-routes';
|
||||||
import cursorRoutes from './cursor-routes';
|
import cursorRoutes from './cursor-routes';
|
||||||
|
import droidRoutes from './droid-routes';
|
||||||
import miscRoutes from './misc-routes';
|
import miscRoutes from './misc-routes';
|
||||||
import cliproxyServerRoutes from './proxy-routes';
|
import cliproxyServerRoutes from './proxy-routes';
|
||||||
import authRoutes from './auth-routes';
|
import authRoutes from './auth-routes';
|
||||||
@@ -67,6 +68,9 @@ apiRoutes.use('/copilot', copilotRoutes);
|
|||||||
// ==================== Cursor ====================
|
// ==================== Cursor ====================
|
||||||
apiRoutes.use('/cursor', cursorRoutes);
|
apiRoutes.use('/cursor', cursorRoutes);
|
||||||
|
|
||||||
|
// ==================== Droid ====================
|
||||||
|
apiRoutes.use('/droid', droidRoutes);
|
||||||
|
|
||||||
// ==================== CLIProxy Server Settings ====================
|
// ==================== CLIProxy Server Settings ====================
|
||||||
apiRoutes.use('/cliproxy-server', cliproxyServerRoutes);
|
apiRoutes.use('/cliproxy-server', cliproxyServerRoutes);
|
||||||
|
|
||||||
|
|||||||
@@ -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<string, number>;
|
||||||
|
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<string, unknown> | null;
|
||||||
|
parseError: string | null;
|
||||||
|
}
|
||||||
@@ -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<string, unknown> | null;
|
||||||
|
rawText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
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<string, number> = {};
|
||||||
|
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:<alias>)',
|
||||||
|
'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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,6 +27,7 @@ const CliproxyControlPanelPage = lazy(() =>
|
|||||||
);
|
);
|
||||||
const CopilotPage = lazy(() => import('@/pages/copilot').then((m) => ({ default: m.CopilotPage })));
|
const CopilotPage = lazy(() => import('@/pages/copilot').then((m) => ({ default: m.CopilotPage })));
|
||||||
const CursorPage = lazy(() => import('@/pages/cursor').then((m) => ({ default: m.CursorPage })));
|
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(() =>
|
const AccountsPage = lazy(() =>
|
||||||
import('@/pages/accounts').then((m) => ({ default: m.AccountsPage }))
|
import('@/pages/accounts').then((m) => ({ default: m.AccountsPage }))
|
||||||
);
|
);
|
||||||
@@ -117,6 +118,14 @@ export default function App() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/droid"
|
||||||
|
element={
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<DroidPage />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/accounts"
|
path="/accounts"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
BarChart3,
|
BarChart3,
|
||||||
Gauge,
|
Gauge,
|
||||||
Github,
|
Github,
|
||||||
|
TerminalSquare,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@@ -103,6 +104,10 @@ const navGroups: SidebarGroupDef[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Compatible CLIs',
|
||||||
|
items: [{ path: '/droid', icon: TerminalSquare, label: 'Factory Droid' }],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'System',
|
title: 'System',
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { withApiBase } from '@/lib/api-client';
|
||||||
|
|
||||||
|
export interface DroidBinaryDiagnostics {
|
||||||
|
installed: boolean;
|
||||||
|
path: string | null;
|
||||||
|
installDir: string | null;
|
||||||
|
source: 'CCS_DROID_PATH' | 'PATH' | 'missing';
|
||||||
|
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 DroidDashboardDiagnostics {
|
||||||
|
binary: DroidBinaryDiagnostics;
|
||||||
|
files: {
|
||||||
|
settings: DroidConfigFileDiagnostics;
|
||||||
|
globalConfig: DroidConfigFileDiagnostics;
|
||||||
|
};
|
||||||
|
byok: {
|
||||||
|
activeModelSelector: string | null;
|
||||||
|
customModelCount: number;
|
||||||
|
ccsManagedCount: number;
|
||||||
|
userManagedCount: number;
|
||||||
|
invalidModelEntryCount: number;
|
||||||
|
providerBreakdown: Record<string, number>;
|
||||||
|
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<string, unknown> | null;
|
||||||
|
parseError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDroidDiagnostics(): Promise<DroidDashboardDiagnostics> {
|
||||||
|
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<DroidRawSettings> {
|
||||||
|
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,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex items-start justify-between gap-3 text-sm">
|
||||||
|
<span className="text-muted-foreground shrink-0">{label}</span>
|
||||||
|
<span className={cn('text-right break-all', mono && 'font-mono text-xs')}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin mr-2" />
|
||||||
|
Loading Droid diagnostics...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diagnosticsError || !diagnostics) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-destructive px-6 text-center">
|
||||||
|
Failed to load Droid diagnostics.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<TerminalSquare className="h-4 w-4" />
|
||||||
|
Runtime & Installation
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">Status</span>
|
||||||
|
<Badge variant={diagnostics.binary.installed ? 'default' : 'secondary'}>
|
||||||
|
{diagnostics.binary.installed ? 'Detected' : 'Not Found'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<DetailRow label="Detection source" value={diagnostics.binary.source} mono />
|
||||||
|
<DetailRow
|
||||||
|
label="Binary path"
|
||||||
|
value={diagnostics.binary.path || 'Not detected'}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="Install directory"
|
||||||
|
value={diagnostics.binary.installDir || 'N/A'}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<DetailRow label="Version" value={diagnostics.binary.version || 'Unknown'} mono />
|
||||||
|
<DetailRow
|
||||||
|
label="Override (CCS_DROID_PATH)"
|
||||||
|
value={diagnostics.binary.overridePath || 'Not set'}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Folder className="h-4 w-4" />
|
||||||
|
Config Files
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{[diagnostics.files.settings, diagnostics.files.globalConfig].map((file) => (
|
||||||
|
<div key={file.label} className="rounded-md border p-3 space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="font-medium text-sm">{file.label}</span>
|
||||||
|
{file.exists ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DetailRow label="Path" value={file.path} mono />
|
||||||
|
<DetailRow label="Resolved" value={file.resolvedPath} mono />
|
||||||
|
<DetailRow label="Size" value={formatBytes(file.sizeBytes)} />
|
||||||
|
<DetailRow label="Last modified" value={formatTimestamp(file.mtimeMs)} />
|
||||||
|
{file.parseError && (
|
||||||
|
<p className="text-xs text-amber-600">Parse warning: {file.parseError}</p>
|
||||||
|
)}
|
||||||
|
{file.readError && (
|
||||||
|
<p className="text-xs text-destructive">Read warning: {file.readError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Server className="h-4 w-4" />
|
||||||
|
BYOK Summary
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<DetailRow
|
||||||
|
label="Active model selector"
|
||||||
|
value={diagnostics.byok.activeModelSelector || 'Not set'}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<DetailRow label="Custom models" value={String(diagnostics.byok.customModelCount)} />
|
||||||
|
<DetailRow label="CCS-managed" value={String(diagnostics.byok.ccsManagedCount)} />
|
||||||
|
<DetailRow label="User-managed" value={String(diagnostics.byok.userManagedCount)} />
|
||||||
|
<DetailRow
|
||||||
|
label="Malformed entries"
|
||||||
|
value={String(diagnostics.byok.invalidModelEntryCount)}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs text-muted-foreground">Providers</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{providerRows.length === 0 && (
|
||||||
|
<Badge variant="secondary" className="font-mono">
|
||||||
|
none
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{providerRows.map(([provider, count]) => (
|
||||||
|
<Badge key={provider} variant="outline" className="font-mono text-xs">
|
||||||
|
{provider}: {count}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<ShieldCheck className="h-4 w-4" />
|
||||||
|
Docs-Aligned Notes
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 text-sm">
|
||||||
|
{diagnostics.docsReference.notes.map((note) => (
|
||||||
|
<p key={note} className="text-muted-foreground">
|
||||||
|
- {note}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
<Separator />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Provider values: {diagnostics.docsReference.providerValues.join(', ')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Settings hierarchy: {diagnostics.docsReference.settingsHierarchy.join(' -> ')}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base">Custom Models</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-md border overflow-hidden">
|
||||||
|
<div className="grid grid-cols-[2fr_1fr_2fr] bg-muted/40 px-3 py-2 text-xs font-medium">
|
||||||
|
<span>Name / Model</span>
|
||||||
|
<span>Provider</span>
|
||||||
|
<span>Base URL</span>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="h-52">
|
||||||
|
<div className="divide-y">
|
||||||
|
{customModels.length === 0 && (
|
||||||
|
<div className="px-3 py-4 text-xs text-muted-foreground">
|
||||||
|
No custom models
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{customModels.map((model) => (
|
||||||
|
<div
|
||||||
|
key={`${model.displayName}-${model.model}-${model.baseUrl}`}
|
||||||
|
className="grid grid-cols-[2fr_1fr_2fr] gap-2 px-3 py-2 text-xs"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium truncate">{model.displayName}</p>
|
||||||
|
<p className="text-muted-foreground font-mono truncate">{model.model}</p>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate">{model.provider}</p>
|
||||||
|
<p className="text-muted-foreground">{model.apiKeyPreview || 'no-key'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate" title={model.baseUrl}>
|
||||||
|
{model.host || model.baseUrl}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground font-mono truncate">
|
||||||
|
{model.baseUrl}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{diagnostics.warnings.length > 0 && (
|
||||||
|
<Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-amber-600" />
|
||||||
|
Warnings
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-1.5">
|
||||||
|
{diagnostics.warnings.map((warning) => (
|
||||||
|
<p key={warning} className="text-sm text-amber-800 dark:text-amber-300">
|
||||||
|
- {warning}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-[calc(100vh-100px)] overflow-hidden">
|
||||||
|
<PanelGroup direction="horizontal" className="h-full">
|
||||||
|
<Panel defaultSize={45} minSize={35}>
|
||||||
|
<div className="h-full border-r bg-muted/20">{renderOverview()}</div>
|
||||||
|
</Panel>
|
||||||
|
<PanelResizeHandle className="w-2 bg-border hover:bg-primary/20 transition-colors cursor-col-resize flex items-center justify-center group">
|
||||||
|
<GripVertical className="w-3 h-3 text-muted-foreground group-hover:text-primary" />
|
||||||
|
</PanelResizeHandle>
|
||||||
|
<Panel defaultSize={55} minSize={35}>
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="p-4 border-b bg-background flex items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="font-semibold flex items-center gap-2">
|
||||||
|
<FileCode2 className="h-4 w-4 text-primary" />
|
||||||
|
Droid BYOK Settings
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-muted-foreground font-mono truncate">
|
||||||
|
{rawSettings?.path || '~/.factory/settings.json'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={copyRawSettings}
|
||||||
|
disabled={!rawSettings?.rawText}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4 mr-1" />
|
||||||
|
{copied ? 'Copied' : 'Copy'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={refreshAll}>
|
||||||
|
<RefreshCw
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4',
|
||||||
|
diagnosticsLoading || rawSettingsLoading ? 'animate-spin' : ''
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{rawSettingsLoading ? (
|
||||||
|
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin mr-2" />
|
||||||
|
Loading settings.json...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
{rawSettings?.parseError && (
|
||||||
|
<div className="mx-4 mt-4 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:bg-amber-950/20 dark:text-amber-300">
|
||||||
|
Parse warning: {rawSettings.parseError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 p-4 pt-3">
|
||||||
|
<div className="h-full rounded-md border overflow-hidden bg-background">
|
||||||
|
<CodeEditor
|
||||||
|
value={rawSettings?.rawText || '{}'}
|
||||||
|
onChange={() => {}}
|
||||||
|
language="json"
|
||||||
|
readonly
|
||||||
|
minHeight="100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</PanelGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,3 +17,5 @@ export { AnalyticsPage } from './analytics';
|
|||||||
export { CursorPage } from './cursor';
|
export { CursorPage } from './cursor';
|
||||||
|
|
||||||
export { UpdatesPage } from './updates';
|
export { UpdatesPage } from './updates';
|
||||||
|
|
||||||
|
export { DroidPage } from './droid';
|
||||||
|
|||||||
Reference in New Issue
Block a user