diff --git "a/\\" "b/\\" new file mode 100644 index 00000000..20fae769 --- /dev/null +++ "b/\\" @@ -0,0 +1,8 @@ +PORT=9000 +JWT_SECRET="officer" +POSTGRES_URL="postgres://postgres:password@localhost:5432/officer" +MAIL_TRANSPORT="smtp://localhost:1025" +PUBLIC_URL=http://omega:9000 +DATA_PATH=/home/pastilhas/production/officer.dev/data +HOME_DIR=/home/pastilhas +OPENCODE_PORT=4096 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 b1b6b3bc..d10ac430 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/ResourceSidebar.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/ResourceSidebar.tsx @@ -1,28 +1,59 @@ -import { Box, AppWindow } from 'lucide-react'; - -const sidebarItems = [ - { id: 'applications', label: 'Applications', icon: AppWindow }, -] as const; +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'; export const ResourceSidebar = () => { + const { resources, isLoading } = useResources(); + const [selectedId, setSelectedId] = useGlobal('RESOURCE_SELECTED', null); + const [search, setSearch] = useState(''); + + const query = search.toLowerCase(); + const filtered = resources?.filter( + (r: Resource) => + r.name.toLowerCase().includes(query) || + r.subtitle.toLowerCase().includes(query) || + r.description.toLowerCase().includes(query), + ); + return (
-
+
Resources
-
- {sidebarItems.map((item) => { - const Icon = item.icon; +
+ setSearch(ev.target.value)} className="h-8 text-xs" /> +
+
+ {isLoading &&

Loading...

} + {filtered?.map((r: Resource) => { + const isActive = selectedId === r.id; return ( ); })} diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/Resources.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/Resources.tsx new file mode 100644 index 00000000..238cf99a --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/Resources.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react'; +import { Copy, Check, Server, Wrench } from 'lucide-react'; +import { useGlobal } from 'hooks/useGlobal'; +import { useResources, type Resource } from '@/state/useResources'; + +const CopyCommand = ({ command }: { command: string }) => { + const [copied, setCopied] = useState(false); + + const copy = () => { + navigator.clipboard.writeText(command); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + return ( +
+ {command} + +
+ ); +}; + +const CommandRow = ({ label, command }: { label: string; command: string }) => ( +
+ {label}: + +
+); + +export const Resources = () => { + const { resources } = useResources(); + const [selectedId] = useGlobal('RESOURCE_SELECTED', null); + + const resource = resources?.find((r: Resource) => r.id === selectedId); + + if (!resource) { + return ( +
+

Select a resource to view details

+
+ ); + } + + return ( +
+
+ {resource.port ? ( + + ) : ( + + )} +

{resource.name}

+ {resource.subtitle} +
+ +
+ {resource.type} + {resource.port && ( + :{resource.port} + )} + {resource.installed ? ( + + {resource.version ?? 'Running'} + + ) : ( + Not installed + )} +
+ +

{resource.description}

+ +
+ {resource.installCommand && } + {resource.uninstallCommand && } + {resource.manageCommand && } + {resource.verifyCommand && } + {resource.updateCommand && } +
+
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/index.tsx index 77c5664b..45a47cdd 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ResourceSettings/index.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import type { LayoutNode, PanelComponents } from '@/components/Workspace'; import { WorkspaceLayout } from '@/components/Workspace'; import { appRegistry } from '../../Workspaces/app-registry'; -import { Applications } from './Applications'; +import { Resources } from './Resources'; import { ResourceSidebar } from './ResourceSidebar'; const layout: LayoutNode = { @@ -19,7 +19,7 @@ export const ResourceSettings = () => { const panelComponents: PanelComponents = useMemo( () => ({ 'resources-left': ResourceSidebar, - 'resources-right': Applications, + 'resources-right': Resources, }), [], ); diff --git a/src/apps/officer-web/state/useResources.ts b/src/apps/officer-web/state/useResources.ts new file mode 100644 index 00000000..f4674dfc --- /dev/null +++ b/src/apps/officer-web/state/useResources.ts @@ -0,0 +1,31 @@ +import { useQuery } from '@tanstack/react-query'; +import { useClient } from 'hooks/useClient'; + +export type Resource = { + id: string; + name: string; + subtitle: string; + type: string; + port: string | null; + description: string; + installCommand: string | null; + uninstallCommand: string | null; + manageCommand: string | null; + verifyCommand: string | null; + updateCommand: string | null; + installed: boolean; + version: string | null; +}; + +const RESOURCES_KEY = ['RESOURCES']; + +export const useResources = () => { + const client = useClient(); + + const { data: resources, isLoading } = useQuery({ + queryKey: RESOURCES_KEY, + queryFn: () => client.get('/server-settings/resources'), + }); + + return { resources, isLoading }; +}; diff --git a/src/servers/api/server-settings/resources.ts b/src/servers/api/server-settings/resources.ts new file mode 100644 index 00000000..441c7675 --- /dev/null +++ b/src/servers/api/server-settings/resources.ts @@ -0,0 +1,165 @@ +import { readdir } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { createRouter } from '../../create-router'; +import { getResourcesDir } from '../../data-path'; + +type Resource = { + id: string; + name: string; + subtitle: string; + type: string; + port: string | null; + path: string | null; + description: string; + installCommand: string | null; + uninstallCommand: string | null; + manageCommand: string | null; + verifyCommand: string | null; + updateCommand: string | null; + installed: boolean; + version: string | null; +}; + +const stripBackticks = (value: string) => value.replace(/^`(.+)`$/, '$1'); + +function parseResourceFile(filename: string, content: string): Omit { + const id = filename + .replace(/^SERVICE_/, '') + .replace(/\.md$/, '') + .toLowerCase() + .replace(/_/g, '-'); + + const headingMatch = content.match(/^#\s+(.+?)\s+—\s+(.+)$/m); + const name = headingMatch?.[1] ?? id; + const subtitle = headingMatch?.[2] ?? ''; + + const field = (key: string): string | null => { + const match = content.match(new RegExp(`^-\\s+\\*\\*${key}:\\*\\*\\s+(.+)$`, 'm')); + return match?.[1]?.trim() ?? null; + }; + + const rawType = field('Type') ?? 'native'; + const rawPort = field('Port'); + const port = rawPort && !rawPort.startsWith('none') ? rawPort : null; + + const rawPath = field('Path'); + + return { + id, + name, + subtitle, + type: rawType, + port, + path: rawPath ? stripBackticks(rawPath) : null, + description: field('Description') ?? '', + installCommand: field('Install') ? stripBackticks(field('Install')!) : null, + uninstallCommand: field('Uninstall') ? stripBackticks(field('Uninstall')!) : null, + manageCommand: field('Manage') ? stripBackticks(field('Manage')!) : null, + verifyCommand: field('Verify') ? stripBackticks(field('Verify')!) : null, + updateCommand: field('Update') ? stripBackticks(field('Update')!) : null, + }; +} + +const CHECK_TIMEOUT_MS = 3_000; + +async function checkPort(port: number): Promise { + try { + const socket = await Bun.connect({ + hostname: '127.0.0.1', + port, + socket: { + data() {}, + open(s) { + s.end(); + }, + error() {}, + }, + }); + socket.end(); + return true; + } catch { + return false; + } +} + +async function checkVerifyCommand(command: string): Promise<{ installed: boolean; version: string | null }> { + try { + const proc = Bun.spawn(['sh', '-c', command], { stdout: 'pipe', stderr: 'pipe' }); + const timeout = new Promise((_, reject) => + setTimeout(() => { + proc.kill(); + reject(new Error('timeout')); + }, CHECK_TIMEOUT_MS), + ); + const result = Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]); + const [stdout, stderr] = await Promise.race([result, timeout]); + if (proc.exitCode !== 0) return { installed: false, version: null }; + const output = (stdout + stderr).trim(); + const versionMatch = output.match(/(\d+\.\d+[\w.-]*)/); + return { installed: true, version: versionMatch?.[1] ?? null }; + } catch { + return { installed: false, version: null }; + } +} + +function extractFirstPort(portStr: string): number | null { + const match = portStr.match(/(\d+)/); + return match ? parseInt(match[1]!, 10) : null; +} + +async function checkResourceStatus( + resource: Omit, +): Promise<{ installed: boolean; version: string | null }> { + if (resource.path) { + const fullPath = `${getResourcesDir()}/${resource.path}`; + return { installed: existsSync(fullPath), version: null }; + } + if (resource.port) { + const port = extractFirstPort(resource.port); + if (port) { + const reachable = await checkPort(port); + return { installed: reachable, version: null }; + } + } + if (resource.verifyCommand) { + return checkVerifyCommand(resource.verifyCommand); + } + return { installed: false, version: null }; +} + +async function loadResources(): Promise { + const dir = getResourcesDir(); + if (!existsSync(dir)) return []; + const files = await readdir(dir); + const serviceFiles = files.filter((f) => f.startsWith('SERVICE_') && f.endsWith('.md')); + + const parsed = await Promise.all( + serviceFiles.map(async (filename) => { + const content = await Bun.file(`${dir}/${filename}`).text(); + return parseResourceFile(filename, content); + }), + ); + + const resources = await Promise.all( + parsed.map(async (r) => { + const status = await checkResourceStatus(r); + return { ...r, ...status }; + }), + ); + + return resources; +} + +export const resourcesRouter = createRouter(); + +resourcesRouter.get('/', async (ctx) => { + const resources = await loadResources(); + return ctx.json(resources); +}); + +resourcesRouter.get('/:id', async (ctx) => { + const resources = await loadResources(); + const resource = resources.find((r) => r.id === ctx.req.param('id')); + if (!resource) return ctx.json({ error: 'Resource not found' }, 404); + return ctx.json(resource); +}); diff --git a/src/servers/api/server-settings/server-settings.ts b/src/servers/api/server-settings/server-settings.ts index 001026d6..c67d9074 100644 --- a/src/servers/api/server-settings/server-settings.ts +++ b/src/servers/api/server-settings/server-settings.ts @@ -7,6 +7,7 @@ import { officerdb, count, Users } from 'officerdb'; import { claudeCodeRouter } from './claude-code'; import { opencodeRouter } from './opencode'; import { applicationsRouter } from './applications'; +import { resourcesRouter } from './resources'; const configDir = `${homedir()}/.config/officer.dev`; export const settingsPath = `${configDir}/server-settings.json`; @@ -22,6 +23,7 @@ export const serverSettingsRouter = createRouter(); serverSettingsRouter.route('/claude-code', claudeCodeRouter); serverSettingsRouter.route('/opencode', opencodeRouter); serverSettingsRouter.route('/applications', applicationsRouter); +serverSettingsRouter.route('/resources', resourcesRouter); serverSettingsRouter.get('/settings', async (ctx) => { const settings = await Bun.file(settingsPath).json(); diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts index bb1868d6..9ccb7727 100644 --- a/src/servers/data-path.ts +++ b/src/servers/data-path.ts @@ -43,6 +43,8 @@ export const getGlobalProcessesDir = () => join(DATA_PATH, 'processes'); export const getUserProcessesDir = (email: string) => join(DATA_PATH, email, 'processes'); +export const getResourcesDir = () => join(DATA_PATH, 'resources'); + export const getTaskLogsDir = (email: string) => join(DATA_PATH, email, 'logs', 'tasks'); export const getTmpAttachmentsDir = (email: string) => join(DATA_PATH, email, 'chat_sessions', 'tmp_attachments');