resource settings page
This commit is contained in:
+66
-25
@@ -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>
|
||||
{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>
|
||||
<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.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>
|
||||
);
|
||||
};
|
||||
|
||||
+17
-4
@@ -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';
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
+3
-1
@@ -20,6 +20,7 @@ type WSData = {
|
||||
sandboxed: boolean;
|
||||
sessionId?: string;
|
||||
cwd?: string;
|
||||
command?: string;
|
||||
};
|
||||
|
||||
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 sandboxed = url.searchParams.get('sandboxed') !== 'false';
|
||||
const cwd = url.searchParams.get('cwd') ?? undefined;
|
||||
const command = url.searchParams.get('command') ?? undefined;
|
||||
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 });
|
||||
} catch {
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { readdir, mkdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { createRouter } from '../../create-router';
|
||||
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 = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -18,11 +32,15 @@ type Resource = {
|
||||
updateCommand: string | null;
|
||||
installed: boolean;
|
||||
version: string | null;
|
||||
connectionConfig: ResourceConnectionConfig | null;
|
||||
};
|
||||
|
||||
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
|
||||
.replace(/^SERVICE_/, '')
|
||||
.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;
|
||||
|
||||
async function checkPort(port: number): Promise<boolean> {
|
||||
@@ -107,14 +142,36 @@ function extractFirstPort(portStr: string): number | null {
|
||||
return match ? parseInt(match[1]!, 10) : null;
|
||||
}
|
||||
|
||||
async function checkResourceStatus(
|
||||
resource: Omit<Resource, 'installed' | 'version'>,
|
||||
): Promise<{ installed: boolean; version: string | null }> {
|
||||
async function checkUrl(url: string): Promise<boolean> {
|
||||
try {
|
||||
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) {
|
||||
const fullPath = `${getResourcesDir()}/${resource.path}`;
|
||||
return { installed: existsSync(fullPath), version: null };
|
||||
}
|
||||
if (resource.port) {
|
||||
if (configUrl) {
|
||||
const reachable = await checkUrl(configUrl);
|
||||
if (reachable) return { installed: true, version: null };
|
||||
}
|
||||
const port = extractFirstPort(resource.port);
|
||||
if (port) {
|
||||
const reachable = await checkPort(port);
|
||||
@@ -127,10 +184,20 @@ async function checkResourceStatus(
|
||||
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[]> {
|
||||
const dir = getResourcesDir();
|
||||
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 parsed = await Promise.all(
|
||||
@@ -142,8 +209,10 @@ async function loadResources(): Promise<Resource[]> {
|
||||
|
||||
const resources = await Promise.all(
|
||||
parsed.map(async (r) => {
|
||||
const status = await checkResourceStatus(r);
|
||||
return { ...r, ...status };
|
||||
const configUrl = config[r.id]?.url;
|
||||
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);
|
||||
});
|
||||
|
||||
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) => {
|
||||
const resources = await loadResources();
|
||||
const resource = resources.find((r) => r.id === ctx.req.param('id'));
|
||||
|
||||
@@ -19,6 +19,7 @@ export type TerminalViewProps = {
|
||||
sessionId?: string;
|
||||
sandboxed?: boolean;
|
||||
cwd?: string;
|
||||
command?: string;
|
||||
fontSize?: number;
|
||||
fontFamily?: string;
|
||||
theme?: TerminalTheme;
|
||||
@@ -35,7 +36,7 @@ const DEFAULT_THEME: Required<TerminalTheme> = {
|
||||
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 token = localStorage.getItem('BEARER_TOKEN') ?? '';
|
||||
const separator = wsPath.includes('?') ? '&' : '?';
|
||||
@@ -43,6 +44,7 @@ const buildWsUrl = (wsPath: string, sessionId?: string, sandboxed?: boolean, cwd
|
||||
if (sessionId) url += `&sessionId=${encodeURIComponent(sessionId)}`;
|
||||
if (sandboxed === false) url += '&sandboxed=false';
|
||||
if (cwd) url += `&cwd=${encodeURIComponent(cwd)}`;
|
||||
if (command) url += `&command=${encodeURIComponent(command)}`;
|
||||
return url;
|
||||
};
|
||||
|
||||
@@ -53,6 +55,7 @@ export const TerminalView = ({
|
||||
sessionId,
|
||||
sandboxed = true,
|
||||
cwd,
|
||||
command,
|
||||
fontSize = 14,
|
||||
fontFamily = 'Menlo, Monaco, "Courier New", monospace',
|
||||
theme,
|
||||
@@ -116,7 +119,7 @@ export const TerminalView = ({
|
||||
fitAddonRef.current = fitAddon;
|
||||
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;
|
||||
|
||||
const handleOpen = () => {
|
||||
@@ -191,6 +194,7 @@ export const TerminalView = ({
|
||||
sessionId,
|
||||
sandboxed,
|
||||
cwd,
|
||||
command,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
background,
|
||||
|
||||
Reference in New Issue
Block a user