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 { Box, Circle, Server, Wrench } from 'lucide-react';
import { useGlobal } from 'hooks/useGlobal'; import { useGlobal } from 'hooks/useGlobal';
import { Input } from '@/components/ui/input'; 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 = () => { export const ResourceSidebar = () => {
const { resources, isLoading } = useResources(); const { resources, isLoading } = useResources();
@@ -17,6 +43,9 @@ export const ResourceSidebar = () => {
r.description.toLowerCase().includes(query), 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 ( return (
<div className="flex flex-col h-full overflow-y-auto"> <div className="flex flex-col h-full overflow-y-auto">
<div className="p-3 pb-2"> <div className="p-3 pb-2">
@@ -26,35 +55,47 @@ export const ResourceSidebar = () => {
</div> </div>
</div> </div>
<div className="px-3 pb-2"> <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>
<div className="flex flex-col gap-0.5 px-3 overflow-y-auto flex-1"> <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>} {isLoading && <p className="text-xs text-duck-dark/50 px-3 py-2">Loading...</p>}
{filtered?.map((r: Resource) => { {apiBased.length > 0 && (
const isActive = selectedId === r.id; <>
return ( <div className="flex items-center gap-2 px-3 pt-3 pb-1">
<button <Server className="h-3 w-3 text-duck-teal" />
key={r.id} <span className="text-[11px] font-semibold uppercase tracking-wider text-duck-dark/40">API Based</span>
onClick={() => setSelectedId(r.id)} </div>
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' {apiBased.map((r: Resource) => (
}`} <ResourceItem
> key={r.id}
{r.port ? ( resource={r}
<Server className="h-3.5 w-3.5 text-duck-teal shrink-0 mt-0.5" /> isActive={selectedId === r.id}
) : ( onSelect={() => setSelectedId(r.id)}
<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> ))}
); </>
})} )}
{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>
</div> </div>
); );
@@ -1,7 +1,9 @@
import { useState } from 'react'; 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 { 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 CopyCommand = ({ command }: { command: string }) => {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -33,6 +35,140 @@ const CommandRow = ({ label, command }: { label: string; command: string }) => (
</div> </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 = () => { export const Resources = () => {
const { resources } = useResources(); const { resources } = useResources();
const [selectedId] = useGlobal<string | null>('RESOURCE_SELECTED', null); const [selectedId] = useGlobal<string | null>('RESOURCE_SELECTED', null);
@@ -47,6 +183,8 @@ export const Resources = () => {
); );
} }
const category = getResourceCategory(resource);
return ( return (
<div className="h-full overflow-y-auto p-6"> <div className="h-full overflow-y-auto p-6">
<div className="flex items-center gap-2 mb-1"> <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"> <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 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 && ( {resource.port && (
<span className="text-xs bg-duck-teal/10 text-duck-teal rounded-full px-2 py-0.5">:{resource.port}</span> <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> </div>
<p className="text-sm text-duck-dark/70 mb-6">{resource.description}</p> <p className="text-sm text-duck-dark/70 mb-6">{resource.description}</p>
<div className="flex flex-col gap-2"> {category === 'api-based' && <ConnectionSection key={resource.id} resource={resource} />}
{resource.installCommand && <CommandRow label="Install" command={resource.installCommand} />} <LocalAvailabilitySection resource={resource} />
{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> </div>
); );
}; };
@@ -1,10 +1,12 @@
import { useState } from 'react'; 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 { useQuery, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useServerSettings } from '@/state/useServerSettings'; 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 VersionInfo = { version: string | null; path: string | null; globalPath: string | null };
type ClaudeAuthInfo = { authenticated: boolean; loggedIn?: boolean; subscriptionType?: string }; type ClaudeAuthInfo = { authenticated: boolean; loggedIn?: boolean; subscriptionType?: string };
@@ -85,15 +87,26 @@ export const AIHarnessesSection = () => {
setTimeout(() => setCopied(null), 1500); 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"> <div className="mt-2 text-xs text-amber-600">
Not globally accessible. Run: Not globally accessible. Run:
<div className="flex items-center gap-1 mt-1"> <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> <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 <button
type="button" type="button"
onClick={() => copyToClipboard(command)} onClick={() => copyToClipboard(command)}
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors" className="shrink-0 p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
title="Copy command"
> >
{copied === command ? ( {copied === command ? (
<Check className="h-3.5 w-3.5 text-green-600" /> <Check className="h-3.5 w-3.5 text-green-600" />
@@ -142,7 +155,7 @@ export const AIHarnessesSection = () => {
</div> </div>
)} )}
{!opencodeVersion.globalPath && opencodeVersion.path && ( {!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> </div>
)} )}
{!claudeVersion.globalPath && claudeVersion.path && ( {!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 { 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 type { LayoutNode, PanelComponents } from '@/components/Workspace';
import { WorkspaceLayout } from '@/components/Workspace'; import { WorkspaceLayout } from '@/components/Workspace';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { appRegistry } from '../../Workspaces/app-registry'; import { appRegistry } from '../../Workspaces/app-registry';
import { createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel'; import { createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel';
import { AIHarnessesSection } from './AIHarnessesSection'; 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'; const GLOBAL_KEY = 'SERVER_SETTINGS_SELECTED';
@@ -19,7 +23,7 @@ const { Sidebar, Content } = createSettingsPanelComponents({
sections, sections,
}); });
const layout: LayoutNode = { const baseLayout: LayoutNode = {
type: 'group', type: 'group',
id: 'server-root', id: 'server-root',
direction: 'horizontal', 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 = () => { export const ServerSettings = () => {
const [runCommand] = usePanelChannel<RunCommandState>(RUN_COMMAND_CHANNEL, null);
const layout = useMemo(() => (runCommand ? splitLayout : baseLayout), [runCommand]);
const panelComponents: PanelComponents = useMemo( const panelComponents: PanelComponents = useMemo(
() => ({ () => ({
'server-left': Sidebar, 'server-left': Sidebar,
'server-right': Content, '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'; import { useClient } from 'hooks/useClient';
export type ResourceCredentials = {
apiKey?: string;
username?: string;
password?: string;
};
export type ResourceConnectionConfig = {
url: string;
credentials?: ResourceCredentials;
};
export type Resource = { export type Resource = {
id: string; id: string;
name: string; name: string;
@@ -15,17 +26,37 @@ export type Resource = {
updateCommand: string | null; updateCommand: string | null;
installed: boolean; installed: boolean;
version: string | null; 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']; const RESOURCES_KEY = ['RESOURCES'];
export const useResources = () => { export const useResources = () => {
const client = useClient(); const client = useClient();
const queryClient = useQueryClient();
const { data: resources, isLoading } = useQuery({ const { data: resources, isLoading } = useQuery({
queryKey: RESOURCES_KEY, queryKey: RESOURCES_KEY,
queryFn: () => client.get<Resource[]>('/server-settings/resources'), 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 };
}; };
+3 -1
View File
@@ -20,6 +20,7 @@ type WSData = {
sandboxed: boolean; sandboxed: boolean;
sessionId?: string; sessionId?: string;
cwd?: string; cwd?: string;
command?: string;
}; };
const handlers: Record<string, typeof claudeWebsocket> = { const handlers: Record<string, typeof claudeWebsocket> = {
@@ -47,8 +48,9 @@ async function upgradeWs(req: Request, server: any, provider: 'claude' | 'openco
const sessionId = url.searchParams.get('sessionId') ?? undefined; const sessionId = url.searchParams.get('sessionId') ?? undefined;
const sandboxed = url.searchParams.get('sandboxed') !== 'false'; const sandboxed = url.searchParams.get('sandboxed') !== 'false';
const cwd = url.searchParams.get('cwd') ?? undefined; const cwd = url.searchParams.get('cwd') ?? undefined;
const command = url.searchParams.get('command') ?? undefined;
const ok = server.upgrade(req, { const ok = server.upgrade(req, {
data: { userId: user.id, email: user.email, role: user.role, provider, sandboxed, sessionId, cwd }, data: { userId: user.id, email: user.email, role: user.role, provider, sandboxed, sessionId, cwd, command },
}); });
if (!ok) return new Response('Upgrade failed', { status: 500 }); if (!ok) return new Response('Upgrade failed', { status: 500 });
} catch { } catch {
+116 -8
View File
@@ -1,8 +1,22 @@
import { readdir } from 'node:fs/promises'; import { readdir, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { createRouter } from '../../create-router'; import { createRouter } from '../../create-router';
import { getResourcesDir } from '../../data-path'; import { getResourcesDir } from '../../data-path';
type ResourceCredentials = {
apiKey?: string;
username?: string;
password?: string;
};
type ResourceConnectionConfig = {
url: string;
credentials?: ResourceCredentials;
};
type ResourcesConfig = Record<string, ResourceConnectionConfig>;
type Resource = { type Resource = {
id: string; id: string;
name: string; name: string;
@@ -18,11 +32,15 @@ type Resource = {
updateCommand: string | null; updateCommand: string | null;
installed: boolean; installed: boolean;
version: string | null; version: string | null;
connectionConfig: ResourceConnectionConfig | null;
}; };
const stripBackticks = (value: string) => value.replace(/^`(.+)`$/, '$1'); const stripBackticks = (value: string) => value.replace(/^`(.+)`$/, '$1');
function parseResourceFile(filename: string, content: string): Omit<Resource, 'installed' | 'version'> { function parseResourceFile(
filename: string,
content: string,
): Omit<Resource, 'installed' | 'version' | 'connectionConfig'> {
const id = filename const id = filename
.replace(/^SERVICE_/, '') .replace(/^SERVICE_/, '')
.replace(/\.md$/, '') .replace(/\.md$/, '')
@@ -60,6 +78,23 @@ function parseResourceFile(filename: string, content: string): Omit<Resource, 'i
}; };
} }
const CONFIG_FILENAME = 'resources-config.json';
const getConfigPath = () => join(getResourcesDir(), CONFIG_FILENAME);
async function readConfig(): Promise<ResourcesConfig> {
const path = getConfigPath();
if (!existsSync(path)) return {};
const text = await Bun.file(path).text();
return JSON.parse(text) as ResourcesConfig;
}
async function writeConfig(config: ResourcesConfig): Promise<void> {
const dir = getResourcesDir();
if (!existsSync(dir)) await mkdir(dir, { recursive: true });
await Bun.write(getConfigPath(), JSON.stringify(config, null, 2));
}
const CHECK_TIMEOUT_MS = 3_000; const CHECK_TIMEOUT_MS = 3_000;
async function checkPort(port: number): Promise<boolean> { async function checkPort(port: number): Promise<boolean> {
@@ -107,14 +142,36 @@ function extractFirstPort(portStr: string): number | null {
return match ? parseInt(match[1]!, 10) : null; return match ? parseInt(match[1]!, 10) : null;
} }
async function checkResourceStatus( async function checkUrl(url: string): Promise<boolean> {
resource: Omit<Resource, 'installed' | 'version'>, try {
): Promise<{ installed: boolean; version: string | null }> { const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS);
await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
return true;
} catch {
return false;
}
}
type CheckResourceStatusParams = {
resource: Omit<Resource, 'installed' | 'version' | 'connectionConfig'>;
configUrl?: string;
};
async function checkResourceStatus({
resource,
configUrl,
}: CheckResourceStatusParams): Promise<{ installed: boolean; version: string | null }> {
if (resource.path) { if (resource.path) {
const fullPath = `${getResourcesDir()}/${resource.path}`; const fullPath = `${getResourcesDir()}/${resource.path}`;
return { installed: existsSync(fullPath), version: null }; return { installed: existsSync(fullPath), version: null };
} }
if (resource.port) { if (resource.port) {
if (configUrl) {
const reachable = await checkUrl(configUrl);
if (reachable) return { installed: true, version: null };
}
const port = extractFirstPort(resource.port); const port = extractFirstPort(resource.port);
if (port) { if (port) {
const reachable = await checkPort(port); const reachable = await checkPort(port);
@@ -127,10 +184,20 @@ async function checkResourceStatus(
return { installed: false, version: null }; return { installed: false, version: null };
} }
function buildConnectionConfig(
resource: Omit<Resource, 'installed' | 'version' | 'connectionConfig'>,
config: ResourcesConfig,
): ResourceConnectionConfig | null {
if (!resource.port) return null;
if (config[resource.id]) return config[resource.id]!;
const port = extractFirstPort(resource.port);
return { url: `http://127.0.0.1:${port ?? resource.port}` };
}
async function loadResources(): Promise<Resource[]> { async function loadResources(): Promise<Resource[]> {
const dir = getResourcesDir(); const dir = getResourcesDir();
if (!existsSync(dir)) return []; if (!existsSync(dir)) return [];
const files = await readdir(dir); const [files, config] = await Promise.all([readdir(dir), readConfig()]);
const serviceFiles = files.filter((f) => f.startsWith('SERVICE_') && f.endsWith('.md')); const serviceFiles = files.filter((f) => f.startsWith('SERVICE_') && f.endsWith('.md'));
const parsed = await Promise.all( const parsed = await Promise.all(
@@ -142,8 +209,10 @@ async function loadResources(): Promise<Resource[]> {
const resources = await Promise.all( const resources = await Promise.all(
parsed.map(async (r) => { parsed.map(async (r) => {
const status = await checkResourceStatus(r); const configUrl = config[r.id]?.url;
return { ...r, ...status }; const status = await checkResourceStatus({ resource: r, configUrl });
const connectionConfig = buildConnectionConfig(r, config);
return { ...r, ...status, connectionConfig };
}), }),
); );
@@ -157,6 +226,45 @@ resourcesRouter.get('/', async (ctx) => {
return ctx.json(resources); return ctx.json(resources);
}); });
resourcesRouter.get('/config', async (ctx) => {
const config = await readConfig();
return ctx.json(config);
});
resourcesRouter.patch('/config/:id', async (ctx) => {
const id = ctx.req.param('id');
const body = await ctx.req.json<Partial<ResourceConnectionConfig>>();
const config = await readConfig();
const existing = config[id] ?? { url: '' };
config[id] = { ...existing, ...body };
await writeConfig(config);
return ctx.json(config[id]);
});
resourcesRouter.post('/:id/ping', async (ctx) => {
const id = ctx.req.param('id');
const body = await ctx.req.json<{ url?: string }>().catch((): { url?: string } => ({}));
const config = await readConfig();
const resources = await loadResources();
const resource = resources.find((r) => r.id === id);
if (!resource) return ctx.json({ error: 'Resource not found' }, 404);
const url = body.url ?? config[id]?.url ?? resource.connectionConfig?.url;
if (!url) return ctx.json({ error: 'No URL configured' }, 400);
try {
const start = performance.now();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS);
await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
const latencyMs = Math.round(performance.now() - start);
return ctx.json({ reachable: true, latencyMs });
} catch {
return ctx.json({ reachable: false, latencyMs: null });
}
});
resourcesRouter.get('/:id', async (ctx) => { resourcesRouter.get('/:id', async (ctx) => {
const resources = await loadResources(); const resources = await loadResources();
const resource = resources.find((r) => r.id === ctx.req.param('id')); const resource = resources.find((r) => r.id === ctx.req.param('id'));
+6 -2
View File
@@ -19,6 +19,7 @@ export type TerminalViewProps = {
sessionId?: string; sessionId?: string;
sandboxed?: boolean; sandboxed?: boolean;
cwd?: string; cwd?: string;
command?: string;
fontSize?: number; fontSize?: number;
fontFamily?: string; fontFamily?: string;
theme?: TerminalTheme; theme?: TerminalTheme;
@@ -35,7 +36,7 @@ const DEFAULT_THEME: Required<TerminalTheme> = {
selectionBackground: '#3a3a5e', selectionBackground: '#3a3a5e',
}; };
const buildWsUrl = (wsPath: string, sessionId?: string, sandboxed?: boolean, cwd?: string) => { const buildWsUrl = (wsPath: string, sessionId?: string, sandboxed?: boolean, cwd?: string, command?: string) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = localStorage.getItem('BEARER_TOKEN') ?? ''; const token = localStorage.getItem('BEARER_TOKEN') ?? '';
const separator = wsPath.includes('?') ? '&' : '?'; const separator = wsPath.includes('?') ? '&' : '?';
@@ -43,6 +44,7 @@ const buildWsUrl = (wsPath: string, sessionId?: string, sandboxed?: boolean, cwd
if (sessionId) url += `&sessionId=${encodeURIComponent(sessionId)}`; if (sessionId) url += `&sessionId=${encodeURIComponent(sessionId)}`;
if (sandboxed === false) url += '&sandboxed=false'; if (sandboxed === false) url += '&sandboxed=false';
if (cwd) url += `&cwd=${encodeURIComponent(cwd)}`; if (cwd) url += `&cwd=${encodeURIComponent(cwd)}`;
if (command) url += `&command=${encodeURIComponent(command)}`;
return url; return url;
}; };
@@ -53,6 +55,7 @@ export const TerminalView = ({
sessionId, sessionId,
sandboxed = true, sandboxed = true,
cwd, cwd,
command,
fontSize = 14, fontSize = 14,
fontFamily = 'Menlo, Monaco, "Courier New", monospace', fontFamily = 'Menlo, Monaco, "Courier New", monospace',
theme, theme,
@@ -116,7 +119,7 @@ export const TerminalView = ({
fitAddonRef.current = fitAddon; fitAddonRef.current = fitAddon;
onReadyRef.current?.(term); onReadyRef.current?.(term);
const ws = new WebSocket(buildWsUrl(wsPath, sessionId, sandboxed, cwd)); const ws = new WebSocket(buildWsUrl(wsPath, sessionId, sandboxed, cwd, command));
wsRef.current = ws; wsRef.current = ws;
const handleOpen = () => { const handleOpen = () => {
@@ -191,6 +194,7 @@ export const TerminalView = ({
sessionId, sessionId,
sandboxed, sandboxed,
cwd, cwd,
command,
fontSize, fontSize,
fontFamily, fontFamily,
background, background,