resources
This commit is contained in:
+44
-13
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user