resource settings page

This commit is contained in:
2026-02-19 19:13:23 +00:00
parent 4733a972dd
commit 521ac24463
9 changed files with 462 additions and 61 deletions
@@ -2,7 +2,33 @@ import { useState } from 'react';
import { Box, Circle, Server, Wrench } from 'lucide-react';
import { useGlobal } from 'hooks/useGlobal';
import { Input } from '@/components/ui/input';
import { useResources, type Resource } from '@/state/useResources';
import { useResources, getResourceCategory, type Resource } from '@/state/useResources';
type ResourceItemProps = {
resource: Resource;
isActive: boolean;
onSelect: () => void;
};
const ResourceItem = ({ resource: r, isActive, onSelect }: ResourceItemProps) => (
<button
onClick={onSelect}
className={`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${isActive ? 'bg-duck-teal/10 text-duck-dark' : 'text-duck-dark/70 hover:bg-duck-dark/5 hover:text-duck-dark'}`}
>
{r.port ? (
<Server className="h-3.5 w-3.5 text-duck-teal shrink-0 mt-0.5" />
) : (
<Wrench className="h-3.5 w-3.5 text-duck-dark/40 shrink-0 mt-0.5" />
)}
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{r.name}</div>
<div className="text-xs text-duck-dark/40 truncate">{r.subtitle}</div>
</div>
<Circle
className={`h-2 w-2 shrink-0 mt-1.5 ${r.installed ? 'fill-green-500 text-green-500' : 'fill-duck-dark/20 text-duck-dark/20'}`}
/>
</button>
);
export const ResourceSidebar = () => {
const { resources, isLoading } = useResources();
@@ -17,6 +43,9 @@ export const ResourceSidebar = () => {
r.description.toLowerCase().includes(query),
);
const apiBased = filtered?.filter((r: Resource) => getResourceCategory(r) === 'api-based') ?? [];
const localCli = filtered?.filter((r: Resource) => getResourceCategory(r) === 'local-cli') ?? [];
return (
<div className="flex flex-col h-full overflow-y-auto">
<div className="p-3 pb-2">
@@ -26,35 +55,47 @@ export const ResourceSidebar = () => {
</div>
</div>
<div className="px-3 pb-2">
<Input placeholder="Search..." value={search} onChange={(ev) => setSearch(ev.target.value)} className="h-8 text-xs" />
<Input
placeholder="Search..."
value={search}
onChange={(ev) => setSearch(ev.target.value)}
className="h-8 text-xs"
/>
</div>
<div className="flex flex-col gap-0.5 px-3 overflow-y-auto flex-1">
{isLoading && <p className="text-xs text-duck-dark/50 px-3 py-2">Loading...</p>}
{filtered?.map((r: Resource) => {
const isActive = selectedId === r.id;
return (
<button
key={r.id}
onClick={() => setSelectedId(r.id)}
className={`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${isActive ? 'bg-duck-teal/10 text-duck-dark' : 'text-duck-dark/70 hover:bg-duck-dark/5 hover:text-duck-dark'
}`}
>
{r.port ? (
<Server className="h-3.5 w-3.5 text-duck-teal shrink-0 mt-0.5" />
) : (
<Wrench className="h-3.5 w-3.5 text-duck-dark/40 shrink-0 mt-0.5" />
)}
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{r.name}</div>
<div className="text-xs text-duck-dark/40 truncate">{r.subtitle}</div>
</div>
<Circle
className={`h-2 w-2 shrink-0 mt-1.5 ${r.installed ? 'fill-green-500 text-green-500' : 'fill-duck-dark/20 text-duck-dark/20'
}`}
{apiBased.length > 0 && (
<>
<div className="flex items-center gap-2 px-3 pt-3 pb-1">
<Server className="h-3 w-3 text-duck-teal" />
<span className="text-[11px] font-semibold uppercase tracking-wider text-duck-dark/40">API Based</span>
</div>
{apiBased.map((r: Resource) => (
<ResourceItem
key={r.id}
resource={r}
isActive={selectedId === r.id}
onSelect={() => setSelectedId(r.id)}
/>
</button>
);
})}
))}
</>
)}
{localCli.length > 0 && (
<>
<div className="flex items-center gap-2 px-3 pt-3 pb-1">
<Wrench className="h-3 w-3 text-duck-dark/40" />
<span className="text-[11px] font-semibold uppercase tracking-wider text-duck-dark/40">Local CLI</span>
</div>
{localCli.map((r: Resource) => (
<ResourceItem
key={r.id}
resource={r}
isActive={selectedId === r.id}
onSelect={() => setSelectedId(r.id)}
/>
))}
</>
)}
</div>
</div>
);
@@ -1,7 +1,9 @@
import { useState } from 'react';
import { Copy, Check, Server, Wrench } from 'lucide-react';
import { Copy, Check, Server, Wrench, Loader2 } from 'lucide-react';
import { useGlobal } from 'hooks/useGlobal';
import { useResources, type Resource } from '@/state/useResources';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useResources, getResourceCategory, type Resource, type PingResult } from '@/state/useResources';
const CopyCommand = ({ command }: { command: string }) => {
const [copied, setCopied] = useState(false);
@@ -33,6 +35,140 @@ const CommandRow = ({ label, command }: { label: string; command: string }) => (
</div>
);
type ConnectionSectionProps = {
resource: Resource;
};
const ConnectionSection = ({ resource }: ConnectionSectionProps) => {
const { saveConnectionConfig, pingResource } = useResources();
const [url, setUrl] = useState(resource.connectionConfig?.url ?? '');
const [apiKey, setApiKey] = useState(resource.connectionConfig?.credentials?.apiKey ?? '');
const [username, setUsername] = useState(resource.connectionConfig?.credentials?.username ?? '');
const [password, setPassword] = useState(resource.connectionConfig?.credentials?.password ?? '');
const [pinging, setPinging] = useState(false);
const [pingResult, setPingResult] = useState<PingResult | null>(null);
const [saving, setSaving] = useState(false);
const handlePing = async () => {
setPinging(true);
setPingResult(null);
try {
const result = await pingResource(resource.id, url);
setPingResult(result);
} catch {
setPingResult({ reachable: false, latencyMs: null });
} finally {
setPinging(false);
}
};
const handleSave = async () => {
setSaving(true);
try {
const credentials =
apiKey || username || password
? { apiKey: apiKey || undefined, username: username || undefined, password: password || undefined }
: undefined;
await saveConnectionConfig(resource.id, { url, credentials });
} finally {
setSaving(false);
}
};
const hasCredentials = !!(
resource.connectionConfig?.credentials?.apiKey || resource.connectionConfig?.credentials?.username
);
return (
<div className="mb-6">
<h3 className="text-sm font-semibold text-duck-dark mb-3">Connection</h3>
<div className="flex flex-col gap-3">
<div>
<label className="text-xs text-duck-dark/50 mb-1 block">Base URL</label>
<Input
value={url}
onChange={(ev) => setUrl(ev.target.value)}
placeholder="http://127.0.0.1:64202"
className="h-8 text-xs"
/>
</div>
{(hasCredentials || apiKey) && (
<div>
<label className="text-xs text-duck-dark/50 mb-1 block">API Key</label>
<Input
value={apiKey}
onChange={(ev) => setApiKey(ev.target.value)}
placeholder="Optional"
type="password"
className="h-8 text-xs"
/>
</div>
)}
{(hasCredentials || username || password) && (
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-duck-dark/50 mb-1 block">Username</label>
<Input
value={username}
onChange={(ev) => setUsername(ev.target.value)}
placeholder="Optional"
className="h-8 text-xs"
/>
</div>
<div className="flex-1">
<label className="text-xs text-duck-dark/50 mb-1 block">Password</label>
<Input
value={password}
onChange={(ev) => setPassword(ev.target.value)}
placeholder="Optional"
type="password"
className="h-8 text-xs"
/>
</div>
</div>
)}
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handlePing} disabled={pinging || !url} className="text-xs">
{pinging && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
Test Connection
</Button>
<Button size="sm" onClick={handleSave} disabled={saving || !url} className="text-xs">
{saving && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
Save
</Button>
{pingResult && (
<span className={`text-xs ${pingResult.reachable ? 'text-green-600' : 'text-red-500'}`}>
{pingResult.reachable ? `Reachable (${pingResult.latencyMs}ms)` : 'Unreachable'}
</span>
)}
</div>
</div>
</div>
);
};
const LocalAvailabilitySection = ({ resource }: { resource: Resource }) => (
<div>
<h3 className="text-sm font-semibold text-duck-dark mb-3">Local Availability</h3>
<div className="mb-3">
{resource.installed ? (
<span className="text-xs bg-green-100 text-green-700 rounded-full px-2 py-0.5">
Installed{resource.version ? ` (${resource.version})` : ''}
</span>
) : (
<span className="text-xs bg-duck-dark/5 text-duck-dark/40 rounded-full px-2 py-0.5">Not installed</span>
)}
</div>
<div className="flex flex-col gap-2">
{resource.installCommand && <CommandRow label="Install" command={resource.installCommand} />}
{resource.uninstallCommand && <CommandRow label="Uninstall" command={resource.uninstallCommand} />}
{resource.manageCommand && <CommandRow label="Manage" command={resource.manageCommand} />}
{resource.verifyCommand && <CommandRow label="Verify" command={resource.verifyCommand} />}
{resource.updateCommand && <CommandRow label="Update" command={resource.updateCommand} />}
</div>
</div>
);
export const Resources = () => {
const { resources } = useResources();
const [selectedId] = useGlobal<string | null>('RESOURCE_SELECTED', null);
@@ -47,6 +183,8 @@ export const Resources = () => {
);
}
const category = getResourceCategory(resource);
return (
<div className="h-full overflow-y-auto p-6">
<div className="flex items-center gap-2 mb-1">
@@ -61,27 +199,20 @@ export const Resources = () => {
<div className="flex items-center gap-2 mb-4">
<span className="text-xs bg-duck-dark/5 text-duck-dark/50 rounded-full px-2 py-0.5">{resource.type}</span>
<span
className={`text-xs rounded-full px-2 py-0.5 ${category === 'api-based' ? 'bg-duck-teal/10 text-duck-teal' : 'bg-duck-dark/5 text-duck-dark/50'}`}
>
{category === 'api-based' ? 'API Based' : 'Local CLI'}
</span>
{resource.port && (
<span className="text-xs bg-duck-teal/10 text-duck-teal rounded-full px-2 py-0.5">:{resource.port}</span>
)}
{resource.installed ? (
<span className="text-xs bg-green-100 text-green-700 rounded-full px-2 py-0.5">
{resource.version ?? 'Running'}
</span>
) : (
<span className="text-xs bg-duck-dark/5 text-duck-dark/40 rounded-full px-2 py-0.5">Not installed</span>
)}
</div>
<p className="text-sm text-duck-dark/70 mb-6">{resource.description}</p>
<div className="flex flex-col gap-2">
{resource.installCommand && <CommandRow label="Install" command={resource.installCommand} />}
{resource.uninstallCommand && <CommandRow label="Uninstall" command={resource.uninstallCommand} />}
{resource.manageCommand && <CommandRow label="Manage" command={resource.manageCommand} />}
{resource.verifyCommand && <CommandRow label="Verify" command={resource.verifyCommand} />}
{resource.updateCommand && <CommandRow label="Update" command={resource.updateCommand} />}
</div>
{category === 'api-based' && <ConnectionSection key={resource.id} resource={resource} />}
<LocalAvailabilitySection resource={resource} />
</div>
);
};
@@ -1,10 +1,12 @@
import { useState } from 'react';
import { Copy, Check } from 'lucide-react';
import { Copy, Check, Play } from 'lucide-react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useServerSettings } from '@/state/useServerSettings';
import { RUN_COMMAND_CHANNEL, type RunCommandState } from './run-command-channel';
type VersionInfo = { version: string | null; path: string | null; globalPath: string | null };
type ClaudeAuthInfo = { authenticated: boolean; loggedIn?: boolean; subscriptionType?: string };
@@ -85,15 +87,26 @@ export const AIHarnessesSection = () => {
setTimeout(() => setCopied(null), 1500);
};
const CopyCommand = ({ command }: { command: string }) => (
const [, setRunCommand] = usePanelChannel<RunCommandState>(RUN_COMMAND_CHANNEL, null);
const CopyCommand = ({ command, refetchKeys }: { command: string; refetchKeys: string[] }) => (
<div className="mt-2 text-xs text-amber-600">
Not globally accessible. Run:
<div className="flex items-center gap-1 mt-1">
<code className="flex-1 bg-duck-dark/5 rounded px-2 py-1 text-duck-dark/70">{command}</code>
<button
type="button"
onClick={() => setRunCommand({ command, refetchKeys })}
className="shrink-0 p-1 rounded hover:bg-duck-teal/10 cursor-pointer transition-colors"
title="Run in terminal"
>
<Play className="h-3.5 w-3.5 text-duck-teal" />
</button>
<button
type="button"
onClick={() => copyToClipboard(command)}
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
title="Copy command"
>
{copied === command ? (
<Check className="h-3.5 w-3.5 text-green-600" />
@@ -142,7 +155,7 @@ export const AIHarnessesSection = () => {
</div>
)}
{!opencodeVersion.globalPath && opencodeVersion.path && (
<CopyCommand command={`sudo ln -s ${opencodeVersion.path} /usr/local/bin/opencode`} />
<CopyCommand command={`sudo ln -s ${opencodeVersion.path} /usr/local/bin/opencode`} refetchKeys={['OPENCODE_VERSION']} />
)}
</>
) : (
@@ -194,7 +207,7 @@ export const AIHarnessesSection = () => {
</div>
)}
{!claudeVersion.globalPath && claudeVersion.path && (
<CopyCommand command={`sudo ln -s ${claudeVersion.path} /usr/local/bin/claude`} />
<CopyCommand command={`sudo ln -s ${claudeVersion.path} /usr/local/bin/claude`} refetchKeys={['CLAUDE_CODE_VERSION']} />
)}
</>
) : (
@@ -1,10 +1,14 @@
import { useMemo } from 'react';
import { Terminal, Server } from 'lucide-react';
import { Terminal, Server, X } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
import { WorkspaceLayout } from '@/components/Workspace';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { appRegistry } from '../../Workspaces/app-registry';
import { createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel';
import { AIHarnessesSection } from './AIHarnessesSection';
import { TerminalView } from 'apps/Terminal';
import { RUN_COMMAND_CHANNEL, type RunCommandState } from './run-command-channel';
const GLOBAL_KEY = 'SERVER_SETTINGS_SELECTED';
@@ -19,7 +23,7 @@ const { Sidebar, Content } = createSettingsPanelComponents({
sections,
});
const layout: LayoutNode = {
const baseLayout: LayoutNode = {
type: 'group',
id: 'server-root',
direction: 'horizontal',
@@ -29,11 +33,72 @@ const layout: LayoutNode = {
],
};
const splitLayout: LayoutNode = {
type: 'group',
id: 'server-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'server-left', appType: null }, size: 20 },
{
node: {
type: 'group',
id: 'server-right-group',
direction: 'vertical',
children: [
{ node: { type: 'panel', id: 'server-right', appType: null }, size: 50 },
{ node: { type: 'panel', id: 'server-terminal', appType: null }, size: 50 },
],
},
size: 80,
},
],
};
const SystemTerminalPanel = () => {
const queryClient = useQueryClient();
const [state, setState] = usePanelChannel<RunCommandState>(RUN_COMMAND_CHANNEL, null);
const close = () => setState(null);
const onExit = () => {
if (state) {
for (const key of state.refetchKeys) {
queryClient.invalidateQueries({ queryKey: [key] });
}
}
};
if (!state) return null;
return (
<div className="h-full flex flex-col">
<div className="shrink-0 px-4 py-1.5 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
<span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 flex-1">Run Command</span>
<button onClick={close} className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors">
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
</button>
</div>
<TerminalView
className="flex-1"
sandboxed={false}
command={state.command}
sessionId={`run-cmd-${Date.now()}`}
onExit={onExit}
/>
</div>
);
};
export const ServerSettings = () => {
const [runCommand] = usePanelChannel<RunCommandState>(RUN_COMMAND_CHANNEL, null);
const layout = useMemo(() => (runCommand ? splitLayout : baseLayout), [runCommand]);
const panelComponents: PanelComponents = useMemo(
() => ({
'server-left': Sidebar,
'server-right': Content,
'server-terminal': SystemTerminalPanel,
}),
[],
);
@@ -0,0 +1,6 @@
export type RunCommandState = {
command: string;
refetchKeys: string[];
} | null;
export const RUN_COMMAND_CHANNEL = 'server-settings:run-command';
+33 -2
View File
@@ -1,6 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
export type ResourceCredentials = {
apiKey?: string;
username?: string;
password?: string;
};
export type ResourceConnectionConfig = {
url: string;
credentials?: ResourceCredentials;
};
export type Resource = {
id: string;
name: string;
@@ -15,17 +26,37 @@ export type Resource = {
updateCommand: string | null;
installed: boolean;
version: string | null;
connectionConfig: ResourceConnectionConfig | null;
};
export type PingResult = {
reachable: boolean;
latencyMs: number | null;
};
export type ResourceCategory = 'api-based' | 'local-cli';
export const getResourceCategory = (r: Resource): ResourceCategory => (r.port ? 'api-based' : 'local-cli');
const RESOURCES_KEY = ['RESOURCES'];
export const useResources = () => {
const client = useClient();
const queryClient = useQueryClient();
const { data: resources, isLoading } = useQuery({
queryKey: RESOURCES_KEY,
queryFn: () => client.get<Resource[]>('/server-settings/resources'),
});
return { resources, isLoading };
const saveConnectionConfig = async (id: string, config: Partial<ResourceConnectionConfig>) => {
await client.patch(`/server-settings/resources/config/${id}`, config);
queryClient.invalidateQueries({ queryKey: RESOURCES_KEY });
};
const pingResource = async (id: string, url?: string) => {
return client.post<PingResult>(`/server-settings/resources/${id}/ping`, { url });
};
return { resources, isLoading, saveConnectionConfig, pingResource };
};