mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 12:19:35 +00:00
refactor(dashboard): reuse compatible CLI settings editor stack
- add shared backend JSON file probe/write utilities for compatible CLI settings - add shared frontend raw JSON editor panel and wire Droid page to it - support PUT save flow with validation and mtime conflict handling
This commit is contained in:
@@ -1,8 +1,11 @@
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import {
|
import {
|
||||||
|
DroidRawSettingsConflictError,
|
||||||
|
DroidRawSettingsValidationError,
|
||||||
getDroidDashboardDiagnostics,
|
getDroidDashboardDiagnostics,
|
||||||
getDroidRawSettings,
|
getDroidRawSettings,
|
||||||
|
saveDroidRawSettings,
|
||||||
} from '../services/droid-dashboard-service';
|
} from '../services/droid-dashboard-service';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -21,7 +24,7 @@ router.get('/diagnostics', (_req: Request, res: Response): void => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/droid/settings/raw
|
* GET /api/droid/settings/raw
|
||||||
* Raw ~/.factory/settings.json payload for read-only viewer.
|
* Raw ~/.factory/settings.json payload for editor.
|
||||||
*/
|
*/
|
||||||
router.get('/settings/raw', (_req: Request, res: Response): void => {
|
router.get('/settings/raw', (_req: Request, res: Response): void => {
|
||||||
try {
|
try {
|
||||||
@@ -31,4 +34,38 @@ router.get('/settings/raw', (_req: Request, res: Response): void => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/droid/settings/raw
|
||||||
|
* Save raw ~/.factory/settings.json payload from dashboard editor.
|
||||||
|
*/
|
||||||
|
router.put('/settings/raw', (req: Request, res: Response): void => {
|
||||||
|
try {
|
||||||
|
const { rawText, expectedMtime } = req.body ?? {};
|
||||||
|
|
||||||
|
if (typeof rawText !== 'string') {
|
||||||
|
res.status(400).json({ error: 'rawText must be a string.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
expectedMtime !== undefined &&
|
||||||
|
(typeof expectedMtime !== 'number' || !Number.isFinite(expectedMtime))
|
||||||
|
) {
|
||||||
|
res.status(400).json({ error: 'expectedMtime must be a finite number when provided.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(saveDroidRawSettings({ rawText, expectedMtime }));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DroidRawSettingsValidationError) {
|
||||||
|
res.status(400).json({ error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error instanceof DroidRawSettingsConflictError) {
|
||||||
|
res.status(409).json({ error: error.message, mtime: error.mtime });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
export interface JsonFileDiagnostics {
|
||||||
|
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 JsonFileProbe {
|
||||||
|
diagnostics: JsonFileDiagnostics;
|
||||||
|
json: Record<string, unknown> | null;
|
||||||
|
rawText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WriteJsonObjectFileInput {
|
||||||
|
filePath: string;
|
||||||
|
rawText: string;
|
||||||
|
expectedMtime?: number;
|
||||||
|
fileLabel?: string;
|
||||||
|
dirMode?: number;
|
||||||
|
fileMode?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WriteJsonObjectFileResult {
|
||||||
|
mtime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JsonFileValidationError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'JsonFileValidationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JsonFileConflictError extends Error {
|
||||||
|
readonly code = 'CONFLICT';
|
||||||
|
readonly mtime: number;
|
||||||
|
|
||||||
|
constructor(message: string, mtime: number) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'JsonFileConflictError';
|
||||||
|
this.mtime = mtime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function probeJsonObjectFile(
|
||||||
|
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: JsonFileDiagnostics = {
|
||||||
|
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 parseJsonObjectText(
|
||||||
|
rawText: string,
|
||||||
|
fieldName = 'rawText'
|
||||||
|
): Record<string, unknown> {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(rawText);
|
||||||
|
} catch (error) {
|
||||||
|
throw new JsonFileValidationError(`Invalid JSON in ${fieldName}: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isObject(parsed)) {
|
||||||
|
throw new JsonFileValidationError(`${fieldName} JSON root must be an object.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeJsonObjectFileAtomic(
|
||||||
|
input: WriteJsonObjectFileInput
|
||||||
|
): WriteJsonObjectFileResult {
|
||||||
|
const fileLabel = input.fileLabel || path.basename(input.filePath);
|
||||||
|
const parsed = parseJsonObjectText(input.rawText, fileLabel);
|
||||||
|
const targetPath = input.filePath;
|
||||||
|
const targetDir = path.dirname(targetPath);
|
||||||
|
const tempPath = targetPath + '.tmp';
|
||||||
|
const dirMode = input.dirMode ?? 0o700;
|
||||||
|
const fileMode = input.fileMode ?? 0o600;
|
||||||
|
|
||||||
|
fs.mkdirSync(targetDir, { recursive: true, mode: dirMode });
|
||||||
|
|
||||||
|
if (fs.existsSync(targetPath)) {
|
||||||
|
const stat = fs.lstatSync(targetPath);
|
||||||
|
if (stat.isSymbolicLink()) {
|
||||||
|
throw new Error(`Refusing to write: ${fileLabel} is a symlink.`);
|
||||||
|
}
|
||||||
|
if (!stat.isFile()) {
|
||||||
|
throw new Error(`Refusing to write: ${fileLabel} is not a regular file.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof input.expectedMtime !== 'number' || !Number.isFinite(input.expectedMtime)) {
|
||||||
|
throw new JsonFileConflictError('File metadata not loaded. Refresh and retry.', stat.mtimeMs);
|
||||||
|
}
|
||||||
|
if (Math.abs(stat.mtimeMs - input.expectedMtime) > 1000) {
|
||||||
|
throw new JsonFileConflictError('File modified externally.', stat.mtimeMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let wroteTemp = false;
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(tempPath)) {
|
||||||
|
const tempStat = fs.lstatSync(tempPath);
|
||||||
|
if (tempStat.isSymbolicLink()) {
|
||||||
|
throw new Error(`Refusing to write: ${fileLabel}.tmp is a symlink.`);
|
||||||
|
}
|
||||||
|
if (!tempStat.isFile()) {
|
||||||
|
throw new Error(`Refusing to write: ${fileLabel}.tmp is not a regular file.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(tempPath, JSON.stringify(parsed, null, 2) + '\n', { mode: fileMode });
|
||||||
|
wroteTemp = true;
|
||||||
|
|
||||||
|
const tempStat = fs.lstatSync(tempPath);
|
||||||
|
if (tempStat.isSymbolicLink()) {
|
||||||
|
throw new Error(`Refusing to write: ${fileLabel}.tmp is a symlink.`);
|
||||||
|
}
|
||||||
|
if (!tempStat.isFile()) {
|
||||||
|
throw new Error(`Refusing to write: ${fileLabel}.tmp is not a regular file.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.renameSync(tempPath, targetPath);
|
||||||
|
wroteTemp = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.chmodSync(targetPath, fileMode);
|
||||||
|
} catch {
|
||||||
|
// Best-effort permission hardening.
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = fs.statSync(targetPath);
|
||||||
|
return { mtime: stat.mtimeMs };
|
||||||
|
} finally {
|
||||||
|
if (wroteTemp && fs.existsSync(tempPath)) {
|
||||||
|
fs.unlinkSync(tempPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
import * as fs from 'fs';
|
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { execFileSync } from 'child_process';
|
import { execFileSync } from 'child_process';
|
||||||
import { detectDroidCli } from '../../targets/droid-detector';
|
import { detectDroidCli } from '../../targets/droid-detector';
|
||||||
import type {
|
import type {
|
||||||
DroidByokDiagnostics,
|
DroidByokDiagnostics,
|
||||||
DroidConfigFileDiagnostics,
|
|
||||||
DroidCustomModelDiagnostics,
|
DroidCustomModelDiagnostics,
|
||||||
DroidDashboardDiagnostics,
|
DroidDashboardDiagnostics,
|
||||||
DroidRawSettingsResponse,
|
DroidRawSettingsResponse,
|
||||||
} from './compatible-cli-types';
|
} from './compatible-cli-types';
|
||||||
|
import {
|
||||||
|
JsonFileConflictError,
|
||||||
|
JsonFileValidationError,
|
||||||
|
probeJsonObjectFile,
|
||||||
|
writeJsonObjectFileAtomic,
|
||||||
|
} from './compatible-cli-json-file-service';
|
||||||
|
|
||||||
interface DroidConfigPaths {
|
interface DroidConfigPaths {
|
||||||
settingsPath: string;
|
settingsPath: string;
|
||||||
@@ -18,12 +22,21 @@ interface DroidConfigPaths {
|
|||||||
legacyConfigDisplayPath: string;
|
legacyConfigDisplayPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface JsonFileProbe {
|
interface SaveDroidRawSettingsInput {
|
||||||
diagnostics: DroidConfigFileDiagnostics;
|
|
||||||
json: Record<string, unknown> | null;
|
|
||||||
rawText: string;
|
rawText: string;
|
||||||
|
expectedMtime?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SaveDroidRawSettingsResult {
|
||||||
|
success: true;
|
||||||
|
mtime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
JsonFileConflictError as DroidRawSettingsConflictError,
|
||||||
|
JsonFileValidationError as DroidRawSettingsValidationError,
|
||||||
|
};
|
||||||
|
|
||||||
function isObject(value: unknown): value is Record<string, unknown> {
|
function isObject(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
@@ -87,69 +100,6 @@ function getBinaryVersion(binaryPath: string): string | 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 {
|
export function summarizeDroidCustomModels(customModelsValue: unknown): DroidByokDiagnostics {
|
||||||
const rows: DroidCustomModelDiagnostics[] = [];
|
const rows: DroidCustomModelDiagnostics[] = [];
|
||||||
const providerBreakdown: Record<string, number> = {};
|
const providerBreakdown: Record<string, number> = {};
|
||||||
@@ -213,12 +163,12 @@ export function getDroidDashboardDiagnostics(): DroidDashboardDiagnostics {
|
|||||||
|
|
||||||
const source = process.env.CCS_DROID_PATH ? 'CCS_DROID_PATH' : binaryPath ? 'PATH' : 'missing';
|
const source = process.env.CCS_DROID_PATH ? 'CCS_DROID_PATH' : binaryPath ? 'PATH' : 'missing';
|
||||||
|
|
||||||
const settingsProbe = readJsonFileProbe(
|
const settingsProbe = probeJsonObjectFile(
|
||||||
paths.settingsPath,
|
paths.settingsPath,
|
||||||
'BYOK settings',
|
'BYOK settings',
|
||||||
paths.settingsDisplayPath
|
paths.settingsDisplayPath
|
||||||
);
|
);
|
||||||
const legacyConfigProbe = readJsonFileProbe(
|
const legacyConfigProbe = probeJsonObjectFile(
|
||||||
paths.legacyConfigPath,
|
paths.legacyConfigPath,
|
||||||
'Legacy config',
|
'Legacy config',
|
||||||
paths.legacyConfigDisplayPath
|
paths.legacyConfigDisplayPath
|
||||||
@@ -274,7 +224,7 @@ export function getDroidDashboardDiagnostics(): DroidDashboardDiagnostics {
|
|||||||
|
|
||||||
export function getDroidRawSettings(): DroidRawSettingsResponse {
|
export function getDroidRawSettings(): DroidRawSettingsResponse {
|
||||||
const paths = resolveDroidConfigPaths();
|
const paths = resolveDroidConfigPaths();
|
||||||
const settingsProbe = readJsonFileProbe(
|
const settingsProbe = probeJsonObjectFile(
|
||||||
paths.settingsPath,
|
paths.settingsPath,
|
||||||
'BYOK settings',
|
'BYOK settings',
|
||||||
paths.settingsDisplayPath
|
paths.settingsDisplayPath
|
||||||
@@ -290,3 +240,18 @@ export function getDroidRawSettings(): DroidRawSettingsResponse {
|
|||||||
parseError: settingsProbe.diagnostics.parseError,
|
parseError: settingsProbe.diagnostics.parseError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function saveDroidRawSettings(input: SaveDroidRawSettingsInput): SaveDroidRawSettingsResult {
|
||||||
|
const paths = resolveDroidConfigPaths();
|
||||||
|
if (typeof input.rawText !== 'string') {
|
||||||
|
throw new JsonFileValidationError('rawText must be a string.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = writeJsonObjectFileAtomic({
|
||||||
|
filePath: paths.settingsPath,
|
||||||
|
rawText: input.rawText,
|
||||||
|
expectedMtime: input.expectedMtime,
|
||||||
|
fileLabel: 'settings.json',
|
||||||
|
});
|
||||||
|
return { success: true, mtime: saved.mtime };
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import * as fs from 'fs';
|
|||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import {
|
import {
|
||||||
|
DroidRawSettingsConflictError,
|
||||||
|
DroidRawSettingsValidationError,
|
||||||
getDroidRawSettings,
|
getDroidRawSettings,
|
||||||
maskApiKeyPreview,
|
maskApiKeyPreview,
|
||||||
resolveDroidConfigPaths,
|
resolveDroidConfigPaths,
|
||||||
|
saveDroidRawSettings,
|
||||||
summarizeDroidCustomModels,
|
summarizeDroidCustomModels,
|
||||||
} from '../../../src/web-server/services/droid-dashboard-service';
|
} from '../../../src/web-server/services/droid-dashboard-service';
|
||||||
|
|
||||||
@@ -106,4 +109,40 @@ describe('droid-dashboard-service', () => {
|
|||||||
expect(raw.settings).toBeNull();
|
expect(raw.settings).toBeNull();
|
||||||
expect(raw.rawText).toContain('invalid-json');
|
expect(raw.rawText).toContain('invalid-json');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('saves valid raw settings content', () => {
|
||||||
|
const result = saveDroidRawSettings({
|
||||||
|
rawText: JSON.stringify({
|
||||||
|
model: 'custom:test-model',
|
||||||
|
customModels: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const settingsPath = path.join(testRoot, '.factory', 'settings.json');
|
||||||
|
const written = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.mtime).toBeGreaterThan(0);
|
||||||
|
expect(written.model).toBe('custom:test-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid JSON while saving raw settings', () => {
|
||||||
|
expect(() => saveDroidRawSettings({ rawText: '{ invalid-json' })).toThrow(
|
||||||
|
DroidRawSettingsValidationError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects stale writes with conflict error', () => {
|
||||||
|
const settingsDir = path.join(testRoot, '.factory');
|
||||||
|
fs.mkdirSync(settingsDir, { recursive: true });
|
||||||
|
const settingsPath = path.join(settingsDir, 'settings.json');
|
||||||
|
fs.writeFileSync(settingsPath, JSON.stringify({ customModels: [] }));
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
saveDroidRawSettings({
|
||||||
|
rawText: JSON.stringify({ model: 'custom:next', customModels: [] }),
|
||||||
|
expectedMtime: 1,
|
||||||
|
})
|
||||||
|
).toThrow(DroidRawSettingsConflictError);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Copy, FileCode2, Loader2, RefreshCw, Save } from 'lucide-react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { CodeEditor } from '@/components/shared/code-editor';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface RawJsonSettingsEditorPanelProps {
|
||||||
|
title: string;
|
||||||
|
pathLabel: string;
|
||||||
|
loading: boolean;
|
||||||
|
parseWarning: string | null | undefined;
|
||||||
|
value: string;
|
||||||
|
dirty: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
saveDisabled: boolean;
|
||||||
|
onChange: (nextValue: string) => void;
|
||||||
|
onSave: () => Promise<void> | void;
|
||||||
|
onRefresh: () => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RawJsonSettingsEditorPanel({
|
||||||
|
title,
|
||||||
|
pathLabel,
|
||||||
|
loading,
|
||||||
|
parseWarning,
|
||||||
|
value,
|
||||||
|
dirty,
|
||||||
|
saving,
|
||||||
|
saveDisabled,
|
||||||
|
onChange,
|
||||||
|
onSave,
|
||||||
|
onRefresh,
|
||||||
|
}: RawJsonSettingsEditorPanelProps) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (!value) return;
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
setCopied(true);
|
||||||
|
toast.success('Settings copied to clipboard');
|
||||||
|
window.setTimeout(() => setCopied(false), 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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" />
|
||||||
|
{title}
|
||||||
|
{dirty && (
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
Unsaved
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-muted-foreground font-mono truncate">{pathLabel}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" onClick={onSave} disabled={saveDisabled}>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-4 w-4 mr-1" />
|
||||||
|
)}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleCopy} disabled={!value}>
|
||||||
|
<Copy className="h-4 w-4 mr-1" />
|
||||||
|
{copied ? 'Copied' : 'Copy'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={onRefresh}>
|
||||||
|
<RefreshCw className={cn('h-4 w-4', loading ? 'animate-spin' : '')} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{loading ? (
|
||||||
|
<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">
|
||||||
|
{parseWarning && (
|
||||||
|
<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: {parseWarning}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 p-4 pt-3">
|
||||||
|
<div className="h-full rounded-md border overflow-hidden bg-background">
|
||||||
|
<CodeEditor value={value} onChange={onChange} language="json" minHeight="100%" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { withApiBase } from '@/lib/api-client';
|
import { ApiConflictError, withApiBase } from '@/lib/api-client';
|
||||||
|
|
||||||
export interface DroidBinaryDiagnostics {
|
export interface DroidBinaryDiagnostics {
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
@@ -69,6 +69,16 @@ export interface DroidRawSettings {
|
|||||||
parseError: string | null;
|
parseError: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SaveDroidRawSettingsInput {
|
||||||
|
rawText: string;
|
||||||
|
expectedMtime?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SaveDroidRawSettingsResponse {
|
||||||
|
success: true;
|
||||||
|
mtime: number;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchDroidDiagnostics(): Promise<DroidDashboardDiagnostics> {
|
async function fetchDroidDiagnostics(): Promise<DroidDashboardDiagnostics> {
|
||||||
const res = await fetch(withApiBase('/droid/diagnostics'));
|
const res = await fetch(withApiBase('/droid/diagnostics'));
|
||||||
if (!res.ok) throw new Error('Failed to fetch Droid diagnostics');
|
if (!res.ok) throw new Error('Failed to fetch Droid diagnostics');
|
||||||
@@ -81,7 +91,26 @@ async function fetchDroidRawSettings(): Promise<DroidRawSettings> {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveDroidRawSettings(
|
||||||
|
data: SaveDroidRawSettingsInput
|
||||||
|
): Promise<SaveDroidRawSettingsResponse> {
|
||||||
|
const res = await fetch(withApiBase('/droid/settings/raw'), {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (res.status === 409) throw new ApiConflictError('Droid raw settings changed externally');
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const payload = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||||
|
throw new Error(payload?.error || 'Failed to save Droid raw settings');
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
export function useDroid() {
|
export function useDroid() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const diagnosticsQuery = useQuery({
|
const diagnosticsQuery = useQuery({
|
||||||
queryKey: ['droid-diagnostics'],
|
queryKey: ['droid-diagnostics'],
|
||||||
queryFn: fetchDroidDiagnostics,
|
queryFn: fetchDroidDiagnostics,
|
||||||
@@ -93,6 +122,14 @@ export function useDroid() {
|
|||||||
queryFn: fetchDroidRawSettings,
|
queryFn: fetchDroidRawSettings,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const saveRawSettingsMutation = useMutation({
|
||||||
|
mutationFn: saveDroidRawSettings,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['droid-diagnostics'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['droid-raw-settings'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
diagnostics: diagnosticsQuery.data,
|
diagnostics: diagnosticsQuery.data,
|
||||||
@@ -104,6 +141,10 @@ export function useDroid() {
|
|||||||
rawSettingsLoading: rawSettingsQuery.isLoading,
|
rawSettingsLoading: rawSettingsQuery.isLoading,
|
||||||
rawSettingsError: rawSettingsQuery.error,
|
rawSettingsError: rawSettingsQuery.error,
|
||||||
refetchRawSettings: rawSettingsQuery.refetch,
|
refetchRawSettings: rawSettingsQuery.refetch,
|
||||||
|
|
||||||
|
saveRawSettings: saveRawSettingsMutation.mutate,
|
||||||
|
saveRawSettingsAsync: saveRawSettingsMutation.mutateAsync,
|
||||||
|
isSavingRawSettings: saveRawSettingsMutation.isPending,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
diagnosticsQuery.data,
|
diagnosticsQuery.data,
|
||||||
@@ -114,6 +155,9 @@ export function useDroid() {
|
|||||||
rawSettingsQuery.isLoading,
|
rawSettingsQuery.isLoading,
|
||||||
rawSettingsQuery.error,
|
rawSettingsQuery.error,
|
||||||
rawSettingsQuery.refetch,
|
rawSettingsQuery.refetch,
|
||||||
|
saveRawSettingsMutation.mutate,
|
||||||
|
saveRawSettingsMutation.mutateAsync,
|
||||||
|
saveRawSettingsMutation.isPending,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+68
-74
@@ -4,24 +4,21 @@ import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
|||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Copy,
|
|
||||||
FileCode2,
|
|
||||||
Folder,
|
Folder,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
Loader2,
|
Loader2,
|
||||||
RefreshCw,
|
|
||||||
Server,
|
Server,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
TerminalSquare,
|
TerminalSquare,
|
||||||
XCircle,
|
XCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useDroid } from '@/hooks/use-droid';
|
import { useDroid } from '@/hooks/use-droid';
|
||||||
import { Button } from '@/components/ui/button';
|
import { isApiConflictError } from '@/lib/api-client';
|
||||||
|
import { RawJsonSettingsEditorPanel } from '@/components/compatible-cli/raw-json-settings-editor-panel';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { CodeEditor } from '@/components/shared/code-editor';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
function formatTimestamp(value: number | null | undefined): string {
|
function formatTimestamp(value: number | null | undefined): string {
|
||||||
@@ -36,6 +33,18 @@ function formatBytes(value: number | null | undefined): string {
|
|||||||
return `${(value / (1024 * 1024)).toFixed(2)} MB`;
|
return `${(value / (1024 * 1024)).toFixed(2)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateJsonObject(text: string): { valid: true } | { valid: false; error: string } {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
return { valid: false, error: 'JSON root must be an object.' };
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { valid: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function DetailRow({
|
function DetailRow({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
@@ -62,27 +71,48 @@ export function DroidPage() {
|
|||||||
rawSettings,
|
rawSettings,
|
||||||
rawSettingsLoading,
|
rawSettingsLoading,
|
||||||
refetchRawSettings,
|
refetchRawSettings,
|
||||||
|
saveRawSettingsAsync,
|
||||||
|
isSavingRawSettings,
|
||||||
} = useDroid();
|
} = useDroid();
|
||||||
|
|
||||||
const [copied, setCopied] = useState(false);
|
const [rawDraftText, setRawDraftText] = useState<string | null>(null);
|
||||||
|
const rawBaseText = rawSettings?.rawText ?? '{}';
|
||||||
const copyRawSettings = async () => {
|
const rawEditorText = rawDraftText ?? rawBaseText;
|
||||||
if (!rawSettings?.rawText) return;
|
const rawConfigDirty = rawDraftText !== null && rawDraftText !== rawBaseText;
|
||||||
await navigator.clipboard.writeText(rawSettings.rawText);
|
|
||||||
setCopied(true);
|
|
||||||
toast.success('Droid settings copied to clipboard');
|
|
||||||
window.setTimeout(() => setCopied(false), 1500);
|
|
||||||
};
|
|
||||||
|
|
||||||
const refreshAll = async () => {
|
const refreshAll = async () => {
|
||||||
await Promise.all([refetchDiagnostics(), refetchRawSettings()]);
|
await Promise.all([refetchDiagnostics(), refetchRawSettings()]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveRawSettings = async () => {
|
||||||
|
if (!rawEditorValidation.valid) {
|
||||||
|
toast.error(`Invalid JSON: ${rawEditorValidation.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveRawSettingsAsync({
|
||||||
|
rawText: rawEditorText,
|
||||||
|
expectedMtime: rawSettings?.exists ? rawSettings.mtime : undefined,
|
||||||
|
});
|
||||||
|
setRawDraftText(null);
|
||||||
|
await Promise.all([refetchDiagnostics(), refetchRawSettings()]);
|
||||||
|
toast.success('Droid settings saved');
|
||||||
|
} catch (error) {
|
||||||
|
if (isApiConflictError(error)) {
|
||||||
|
toast.error('Droid settings changed externally. Refresh and retry.');
|
||||||
|
} else {
|
||||||
|
toast.error((error as Error).message || 'Failed to save Droid settings');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const customModels = diagnostics?.byok.customModels ?? [];
|
const customModels = diagnostics?.byok.customModels ?? [];
|
||||||
const providerRows = useMemo(
|
const providerRows = useMemo(
|
||||||
() => Object.entries(diagnostics?.byok.providerBreakdown ?? {}).sort((a, b) => b[1] - a[1]),
|
() => Object.entries(diagnostics?.byok.providerBreakdown ?? {}).sort((a, b) => b[1] - a[1]),
|
||||||
[diagnostics?.byok.providerBreakdown]
|
[diagnostics?.byok.providerBreakdown]
|
||||||
);
|
);
|
||||||
|
const rawEditorValidation = validateJsonObject(rawEditorText);
|
||||||
|
|
||||||
const renderOverview = () => {
|
const renderOverview = () => {
|
||||||
if (diagnosticsLoading) {
|
if (diagnosticsLoading) {
|
||||||
@@ -313,66 +343,30 @@ export function DroidPage() {
|
|||||||
<GripVertical className="w-3 h-3 text-muted-foreground group-hover:text-primary" />
|
<GripVertical className="w-3 h-3 text-muted-foreground group-hover:text-primary" />
|
||||||
</PanelResizeHandle>
|
</PanelResizeHandle>
|
||||||
<Panel defaultSize={55} minSize={35}>
|
<Panel defaultSize={55} minSize={35}>
|
||||||
<div className="h-full flex flex-col">
|
<RawJsonSettingsEditorPanel
|
||||||
<div className="p-4 border-b bg-background flex items-center justify-between gap-2">
|
title="Droid BYOK Settings"
|
||||||
<div className="min-w-0">
|
pathLabel={rawSettings?.path || '~/.factory/settings.json'}
|
||||||
<h2 className="font-semibold flex items-center gap-2">
|
loading={rawSettingsLoading}
|
||||||
<FileCode2 className="h-4 w-4 text-primary" />
|
parseWarning={rawSettings?.parseError}
|
||||||
Droid BYOK Settings
|
value={rawEditorText}
|
||||||
</h2>
|
dirty={rawConfigDirty}
|
||||||
<p className="text-xs text-muted-foreground font-mono truncate">
|
saving={isSavingRawSettings}
|
||||||
{rawSettings?.path || '~/.factory/settings.json'}
|
saveDisabled={
|
||||||
</p>
|
!rawConfigDirty ||
|
||||||
</div>
|
isSavingRawSettings ||
|
||||||
<div className="flex items-center gap-2">
|
rawSettingsLoading ||
|
||||||
<Button
|
!rawEditorValidation.valid
|
||||||
variant="outline"
|
}
|
||||||
size="sm"
|
onChange={(next) => {
|
||||||
onClick={copyRawSettings}
|
if (next === rawBaseText) {
|
||||||
disabled={!rawSettings?.rawText}
|
setRawDraftText(null);
|
||||||
>
|
return;
|
||||||
<Copy className="h-4 w-4 mr-1" />
|
}
|
||||||
{copied ? 'Copied' : 'Copy'}
|
setRawDraftText(next);
|
||||||
</Button>
|
}}
|
||||||
<Button variant="outline" size="sm" onClick={refreshAll}>
|
onSave={handleSaveRawSettings}
|
||||||
<RefreshCw
|
onRefresh={refreshAll}
|
||||||
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>
|
</Panel>
|
||||||
</PanelGroup>
|
</PanelGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user