From 521ac24463331ce799124c6873729f74cbba161e Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Thu, 19 Feb 2026 19:13:23 +0000 Subject: [PATCH] resource settings page --- .../ResourceSettings/ResourceSidebar.tsx | 93 +++++++--- .../Settings/ResourceSettings/Resources.tsx | 163 ++++++++++++++++-- .../ServerSettings/AIHarnessesSection.tsx | 21 ++- .../Settings/ServerSettings/index.tsx | 69 +++++++- .../ServerSettings/run-command-channel.ts | 6 + src/apps/officer-web/state/useResources.ts | 35 +++- src/server.tsx | 4 +- src/servers/api/server-settings/resources.ts | 124 ++++++++++++- src/workspaces/apps/Terminal/Terminal.tsx | 8 +- 9 files changed, 462 insertions(+), 61 deletions(-) create mode 100644 src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/run-command-channel.ts diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/ResourceSidebar.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/ResourceSidebar.tsx index 2b2f1757..fcf0a6cf 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/ResourceSidebar.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/ResourceSidebar.tsx @@ -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) => ( + +); 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 (
@@ -26,35 +55,47 @@ export const ResourceSidebar = () => {
- setSearch(ev.target.value)} className="h-8 text-xs" /> + setSearch(ev.target.value)} + className="h-8 text-xs" + />
{isLoading &&

Loading...

} - {filtered?.map((r: Resource) => { - const isActive = selectedId === r.id; - return ( - - ); - })} + ))} + + )} + {localCli.length > 0 && ( + <> +
+ + Local CLI +
+ {localCli.map((r: Resource) => ( + setSelectedId(r.id)} + /> + ))} + + )}
); diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/Resources.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/Resources.tsx index 238cf99a..edee15f9 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/Resources.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/Resources.tsx @@ -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 }) => ( ); +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(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 ( +
+

Connection

+
+
+ + setUrl(ev.target.value)} + placeholder="http://127.0.0.1:64202" + className="h-8 text-xs" + /> +
+ {(hasCredentials || apiKey) && ( +
+ + setApiKey(ev.target.value)} + placeholder="Optional" + type="password" + className="h-8 text-xs" + /> +
+ )} + {(hasCredentials || username || password) && ( +
+
+ + setUsername(ev.target.value)} + placeholder="Optional" + className="h-8 text-xs" + /> +
+
+ + setPassword(ev.target.value)} + placeholder="Optional" + type="password" + className="h-8 text-xs" + /> +
+
+ )} +
+ + + {pingResult && ( + + {pingResult.reachable ? `Reachable (${pingResult.latencyMs}ms)` : 'Unreachable'} + + )} +
+
+
+ ); +}; + +const LocalAvailabilitySection = ({ resource }: { resource: Resource }) => ( +
+

Local Availability

+
+ {resource.installed ? ( + + Installed{resource.version ? ` (${resource.version})` : ''} + + ) : ( + Not installed + )} +
+
+ {resource.installCommand && } + {resource.uninstallCommand && } + {resource.manageCommand && } + {resource.verifyCommand && } + {resource.updateCommand && } +
+
+); + export const Resources = () => { const { resources } = useResources(); const [selectedId] = useGlobal('RESOURCE_SELECTED', null); @@ -47,6 +183,8 @@ export const Resources = () => { ); } + const category = getResourceCategory(resource); + return (
@@ -61,27 +199,20 @@ export const Resources = () => {
{resource.type} + + {category === 'api-based' ? 'API Based' : 'Local CLI'} + {resource.port && ( :{resource.port} )} - {resource.installed ? ( - - {resource.version ?? 'Running'} - - ) : ( - Not installed - )}

{resource.description}

-
- {resource.installCommand && } - {resource.uninstallCommand && } - {resource.manageCommand && } - {resource.verifyCommand && } - {resource.updateCommand && } -
+ {category === 'api-based' && } +
); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx index c78d9593..f215bbca 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx @@ -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(RUN_COMMAND_CHANNEL, null); + + const CopyCommand = ({ command, refetchKeys }: { command: string; refetchKeys: string[] }) => (
Not globally accessible. Run:
{command} +
)} {!opencodeVersion.globalPath && opencodeVersion.path && ( - + )} ) : ( @@ -194,7 +207,7 @@ export const AIHarnessesSection = () => {
)} {!claudeVersion.globalPath && claudeVersion.path && ( - + )} ) : ( diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/index.tsx index f59f348f..4579393d 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/index.tsx @@ -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(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 ( +
+
+ Run Command + +
+ +
+ ); +}; + export const ServerSettings = () => { + const [runCommand] = usePanelChannel(RUN_COMMAND_CHANNEL, null); + + const layout = useMemo(() => (runCommand ? splitLayout : baseLayout), [runCommand]); + const panelComponents: PanelComponents = useMemo( () => ({ 'server-left': Sidebar, 'server-right': Content, + 'server-terminal': SystemTerminalPanel, }), [], ); diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/run-command-channel.ts b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/run-command-channel.ts new file mode 100644 index 00000000..60db6033 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/run-command-channel.ts @@ -0,0 +1,6 @@ +export type RunCommandState = { + command: string; + refetchKeys: string[]; +} | null; + +export const RUN_COMMAND_CHANNEL = 'server-settings:run-command'; diff --git a/src/apps/officer-web/state/useResources.ts b/src/apps/officer-web/state/useResources.ts index f4674dfc..2fabde42 100644 --- a/src/apps/officer-web/state/useResources.ts +++ b/src/apps/officer-web/state/useResources.ts @@ -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('/server-settings/resources'), }); - return { resources, isLoading }; + const saveConnectionConfig = async (id: string, config: Partial) => { + await client.patch(`/server-settings/resources/config/${id}`, config); + queryClient.invalidateQueries({ queryKey: RESOURCES_KEY }); + }; + + const pingResource = async (id: string, url?: string) => { + return client.post(`/server-settings/resources/${id}/ping`, { url }); + }; + + return { resources, isLoading, saveConnectionConfig, pingResource }; }; diff --git a/src/server.tsx b/src/server.tsx index 72743125..04d60ef7 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -20,6 +20,7 @@ type WSData = { sandboxed: boolean; sessionId?: string; cwd?: string; + command?: string; }; const handlers: Record = { @@ -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 { diff --git a/src/servers/api/server-settings/resources.ts b/src/servers/api/server-settings/resources.ts index 441c7675..54781e17 100644 --- a/src/servers/api/server-settings/resources.ts +++ b/src/servers/api/server-settings/resources.ts @@ -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; + 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 { +function parseResourceFile( + filename: string, + content: string, +): Omit { const id = filename .replace(/^SERVICE_/, '') .replace(/\.md$/, '') @@ -60,6 +78,23 @@ function parseResourceFile(filename: string, content: string): Omit join(getResourcesDir(), CONFIG_FILENAME); + +async function readConfig(): Promise { + 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 { + 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 { @@ -107,14 +142,36 @@ function extractFirstPort(portStr: string): number | null { return match ? parseInt(match[1]!, 10) : null; } -async function checkResourceStatus( - resource: Omit, -): Promise<{ installed: boolean; version: string | null }> { +async function checkUrl(url: string): Promise { + 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; + 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, + 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 { 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 { 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>(); + 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')); diff --git a/src/workspaces/apps/Terminal/Terminal.tsx b/src/workspaces/apps/Terminal/Terminal.tsx index a04ae801..8de36422 100644 --- a/src/workspaces/apps/Terminal/Terminal.tsx +++ b/src/workspaces/apps/Terminal/Terminal.tsx @@ -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 = { 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,