mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-15 02:16:27 +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 parent
c6d2e71ec2
commit
e9eab712b3
7 files changed
+542
-148
No files matched your search
@@ -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 { useQuery } from '@tanstack/react-query';
|
||||
import { withApiBase } from '@/lib/api-client';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ApiConflictError, withApiBase } from '@/lib/api-client';
|
||||
|
||||
export interface DroidBinaryDiagnostics {
|
||||
installed: boolean;
|
||||
@@ -69,6 +69,16 @@ export interface DroidRawSettings {
|
||||
parseError: string | null;
|
||||
}
|
||||
|
||||
interface SaveDroidRawSettingsInput {
|
||||
rawText: string;
|
||||
expectedMtime?: number;
|
||||
}
|
||||
|
||||
interface SaveDroidRawSettingsResponse {
|
||||
success: true;
|
||||
mtime: number;
|
||||
}
|
||||
|
||||
async function fetchDroidDiagnostics(): Promise<DroidDashboardDiagnostics> {
|
||||
const res = await fetch(withApiBase('/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();
|
||||
}
|
||||
|
||||
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() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const diagnosticsQuery = useQuery({
|
||||
queryKey: ['droid-diagnostics'],
|
||||
queryFn: fetchDroidDiagnostics,
|
||||
@@ -93,6 +122,14 @@ export function useDroid() {
|
||||
queryFn: fetchDroidRawSettings,
|
||||
});
|
||||
|
||||
const saveRawSettingsMutation = useMutation({
|
||||
mutationFn: saveDroidRawSettings,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['droid-diagnostics'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['droid-raw-settings'] });
|
||||
},
|
||||
});
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
diagnostics: diagnosticsQuery.data,
|
||||
@@ -104,6 +141,10 @@ export function useDroid() {
|
||||
rawSettingsLoading: rawSettingsQuery.isLoading,
|
||||
rawSettingsError: rawSettingsQuery.error,
|
||||
refetchRawSettings: rawSettingsQuery.refetch,
|
||||
|
||||
saveRawSettings: saveRawSettingsMutation.mutate,
|
||||
saveRawSettingsAsync: saveRawSettingsMutation.mutateAsync,
|
||||
isSavingRawSettings: saveRawSettingsMutation.isPending,
|
||||
}),
|
||||
[
|
||||
diagnosticsQuery.data,
|
||||
@@ -114,6 +155,9 @@ export function useDroid() {
|
||||
rawSettingsQuery.isLoading,
|
||||
rawSettingsQuery.error,
|
||||
rawSettingsQuery.refetch,
|
||||
saveRawSettingsMutation.mutate,
|
||||
saveRawSettingsMutation.mutateAsync,
|
||||
saveRawSettingsMutation.isPending,
|
||||
]
|
||||
);
|
||||
}
|
||||
+68
-74
@@ -4,24 +4,21 @@ 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 { isApiConflictError } from '@/lib/api-client';
|
||||
import { RawJsonSettingsEditorPanel } from '@/components/compatible-cli/raw-json-settings-editor-panel';
|
||||
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 {
|
||||
@@ -36,6 +33,18 @@ function formatBytes(value: number | null | undefined): string {
|
||||
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({
|
||||
label,
|
||||
value,
|
||||
@@ -62,27 +71,48 @@ export function DroidPage() {
|
||||
rawSettings,
|
||||
rawSettingsLoading,
|
||||
refetchRawSettings,
|
||||
saveRawSettingsAsync,
|
||||
isSavingRawSettings,
|
||||
} = 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 [rawDraftText, setRawDraftText] = useState<string | null>(null);
|
||||
const rawBaseText = rawSettings?.rawText ?? '{}';
|
||||
const rawEditorText = rawDraftText ?? rawBaseText;
|
||||
const rawConfigDirty = rawDraftText !== null && rawDraftText !== rawBaseText;
|
||||
|
||||
const refreshAll = async () => {
|
||||
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 providerRows = useMemo(
|
||||
() => Object.entries(diagnostics?.byok.providerBreakdown ?? {}).sort((a, b) => b[1] - a[1]),
|
||||
[diagnostics?.byok.providerBreakdown]
|
||||
);
|
||||
const rawEditorValidation = validateJsonObject(rawEditorText);
|
||||
|
||||
const renderOverview = () => {
|
||||
if (diagnosticsLoading) {
|
||||
@@ -313,66 +343,30 @@ export function DroidPage() {
|
||||
<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>
|
||||
<RawJsonSettingsEditorPanel
|
||||
title="Droid BYOK Settings"
|
||||
pathLabel={rawSettings?.path || '~/.factory/settings.json'}
|
||||
loading={rawSettingsLoading}
|
||||
parseWarning={rawSettings?.parseError}
|
||||
value={rawEditorText}
|
||||
dirty={rawConfigDirty}
|
||||
saving={isSavingRawSettings}
|
||||
saveDisabled={
|
||||
!rawConfigDirty ||
|
||||
isSavingRawSettings ||
|
||||
rawSettingsLoading ||
|
||||
!rawEditorValidation.valid
|
||||
}
|
||||
onChange={(next) => {
|
||||
if (next === rawBaseText) {
|
||||
setRawDraftText(null);
|
||||
return;
|
||||
}
|
||||
setRawDraftText(next);
|
||||
}}
|
||||
onSave={handleSaveRawSettings}
|
||||
onRefresh={refreshAll}
|
||||
/>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user