resources

This commit is contained in:
pastilhas
2026-02-18 19:57:46 +00:00
parent 485ae4c3d7
commit 8b97f8a38a
8 changed files with 341 additions and 15 deletions
+8
View File
@@ -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
@@ -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<string | null>('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 (
<div className="flex flex-col h-full overflow-y-auto">
<div className="p-3 pb-0">
<div className="p-3 pb-2">
<div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal">
<Box className="h-4 w-4" />
Resources
</div>
</div>
<div className="flex flex-col gap-0.5 px-3 pt-3">
{sidebarItems.map((item) => {
const Icon = item.icon;
<div className="px-3 pb-2">
<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={item.id}
className="flex items-center gap-2.5 py-2 px-3 rounded-lg text-sm font-medium bg-duck-teal/10 text-duck-dark cursor-default"
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'
}`}
>
<Icon className="h-4 w-4 shrink-0" />
<span className="flex-1 text-left">{item.label}</span>
{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>
);
})}
@@ -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 (
<div className="flex items-center gap-1 mt-1">
<code className="flex-1 bg-duck-dark/5 rounded px-2 py-1 text-xs text-duck-dark/70">{command}</code>
<button
type="button"
onClick={copy}
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
>
{copied ? <Check className="h-3.5 w-3.5 text-green-600" /> : <Copy className="h-3.5 w-3.5 text-duck-dark/50" />}
</button>
</div>
);
};
const CommandRow = ({ label, command }: { label: string; command: string }) => (
<div className="text-xs text-duck-dark/50">
{label}:
<CopyCommand command={command} />
</div>
);
export const Resources = () => {
const { resources } = useResources();
const [selectedId] = useGlobal<string | null>('RESOURCE_SELECTED', null);
const resource = resources?.find((r: Resource) => r.id === selectedId);
if (!resource) {
return (
<div className="h-full flex items-center justify-center">
<p className="text-sm text-duck-dark/30">Select a resource to view details</p>
</div>
);
}
return (
<div className="h-full overflow-y-auto p-6">
<div className="flex items-center gap-2 mb-1">
{resource.port ? (
<Server className="h-4 w-4 text-duck-teal shrink-0" />
) : (
<Wrench className="h-4 w-4 text-duck-dark/40 shrink-0" />
)}
<h2 className="text-lg font-bold text-duck-dark">{resource.name}</h2>
<span className="text-sm text-duck-dark/40">{resource.subtitle}</span>
</div>
<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>
{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>
</div>
);
};
@@ -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,
}),
[],
);
@@ -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<Resource[]>('/server-settings/resources'),
});
return { resources, isLoading };
};
@@ -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<Resource, 'installed' | 'version'> {
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<boolean> {
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<never>((_, 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<Resource, 'installed' | 'version'>,
): 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<Resource[]> {
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);
});
@@ -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();
+2
View File
@@ -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');