Resources tuneup
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Server, Wrench, Loader2 } from 'lucide-react';
|
||||
import { Server, Wrench, Loader2, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { CommandBlock } from '@/components/CommandBlock';
|
||||
import { useResources, getResourceCategory, type Resource, type PingResult } from '@/state/useResources';
|
||||
import { RUN_COMMAND_CHANNEL, type RunCommandState } from './run-command-channel';
|
||||
|
||||
@@ -135,29 +135,133 @@ type LocalAvailabilitySectionProps = {
|
||||
onRun: (command: string) => void;
|
||||
};
|
||||
|
||||
const LocalAvailabilitySection = ({ resource, onRun }: LocalAvailabilitySectionProps) => (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-duck-dark mb-3">Local Availability</h3>
|
||||
<div className="mb-3">
|
||||
type ResourceAction = 'install' | 'uninstall' | 'verify' | 'update' | 'manage';
|
||||
|
||||
const LocalAvailabilitySection = ({ resource, onRun }: LocalAvailabilitySectionProps) => {
|
||||
const { runCommand } = useResources();
|
||||
const [runningAction, setRunningAction] = useState<ResourceAction | null>(null);
|
||||
|
||||
const isSudo = (cmd: string) => cmd.trimStart().startsWith('sudo');
|
||||
|
||||
const handleAction = async (action: ResourceAction, command: string) => {
|
||||
if (isSudo(command)) {
|
||||
onRun(command);
|
||||
return;
|
||||
}
|
||||
setRunningAction(action);
|
||||
try {
|
||||
const result = await runCommand(resource.id, action);
|
||||
if (result.exitCode === 0) {
|
||||
toast.success('Command completed successfully');
|
||||
} else {
|
||||
toast.error(result.output || `Command failed (exit code ${result.exitCode})`, { duration: 8000 });
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to run command');
|
||||
} finally {
|
||||
setRunningAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{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>
|
||||
<>
|
||||
<div>
|
||||
<span className="text-xs bg-green-100 text-green-700 rounded-full px-2 py-0.5">Installed</span>
|
||||
</div>
|
||||
{resource.version && (
|
||||
<div className="flex items-center gap-2">
|
||||
{resource.updateCommand && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs cursor-pointer"
|
||||
onClick={() => handleAction('update', resource.updateCommand!)}
|
||||
disabled={!!runningAction}
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 mr-1 ${runningAction === 'update' ? 'animate-spin' : ''}`} />
|
||||
Update
|
||||
</Button>
|
||||
)}
|
||||
<span className="text-xs text-duck-dark/50 dark:text-foreground/50">{resource.version}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!resource.version && resource.updateCommand && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs cursor-pointer"
|
||||
onClick={() => handleAction('update', resource.updateCommand!)}
|
||||
disabled={!!runningAction}
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 mr-1 ${runningAction === 'update' ? 'animate-spin' : ''}`} />
|
||||
Update
|
||||
</Button>
|
||||
)}
|
||||
{resource.verifyCommand && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs cursor-pointer"
|
||||
onClick={() => handleAction('verify', resource.verifyCommand!)}
|
||||
disabled={!!runningAction}
|
||||
>
|
||||
{runningAction === 'verify' && <Loader2 className="h-3 w-3 mr-1 animate-spin" />}
|
||||
Verify
|
||||
</Button>
|
||||
)}
|
||||
{resource.manageCommand && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs cursor-pointer"
|
||||
onClick={() => handleAction('manage', resource.manageCommand!)}
|
||||
disabled={!!runningAction}
|
||||
>
|
||||
{runningAction === 'manage' && <Loader2 className="h-3 w-3 mr-1 animate-spin" />}
|
||||
Manage
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{resource.uninstallCommand && (
|
||||
<div className="flex items-center mt-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs cursor-pointer text-red-500 hover:text-red-600 border-red-500/30 hover:border-red-500/50 hover:bg-red-500/5"
|
||||
onClick={() => handleAction('uninstall', resource.uninstallCommand!)}
|
||||
disabled={!!runningAction}
|
||||
>
|
||||
{runningAction === 'uninstall' ? (
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
Uninstall
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs bg-duck-dark/5 text-duck-dark/40 rounded-full px-2 py-0.5">Not installed</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs bg-duck-dark/5 text-duck-dark/40 rounded-full px-2 py-0.5">Not installed</span>
|
||||
{resource.installCommand && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-6 text-xs bg-duck-teal text-duck-yellow hover:bg-duck-teal/90 cursor-pointer"
|
||||
onClick={() => handleAction('install', resource.installCommand!)}
|
||||
disabled={runningAction === 'install'}
|
||||
>
|
||||
{runningAction === 'install' ? 'Installing...' : 'Install'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{resource.installCommand && <CommandBlock label="Install" command={resource.installCommand} onRun={onRun} />}
|
||||
{resource.uninstallCommand && (
|
||||
<CommandBlock label="Uninstall" command={resource.uninstallCommand} onRun={onRun} />
|
||||
)}
|
||||
{resource.manageCommand && <CommandBlock label="Manage" command={resource.manageCommand} onRun={onRun} />}
|
||||
{resource.verifyCommand && <CommandBlock label="Verify" command={resource.verifyCommand} onRun={onRun} />}
|
||||
{resource.updateCommand && <CommandBlock label="Update" command={resource.updateCommand} onRun={onRun} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
type CatalogCardProps = {
|
||||
resource: Resource;
|
||||
@@ -306,18 +410,12 @@ const ResourceDetail = ({ resource }: { resource: Resource }) => {
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Run with elevated privileges</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div>
|
||||
You are about to run a command with elevated privileges (sudo)
|
||||
<br />
|
||||
<span className="text-red-500">
|
||||
in the host machine.
|
||||
<br />
|
||||
ARE YOU SURE?
|
||||
</span>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<p className="text-sm text-duck-dark/70 dark:text-foreground/70">
|
||||
For this operation the script must be run with elevated privileges (sudo) on the host machine.
|
||||
<br />
|
||||
Not to worry, though, we wrote it and battle tested it ourselves.
|
||||
</p>
|
||||
<code className="text-sm font-mono bg-[#1a1a2e] text-[#e0e0e0] rounded-lg px-3 py-2 break-all">{confirmCommand}</code>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
|
||||
|
||||
@@ -58,5 +58,11 @@ export const useResources = () => {
|
||||
return client.post<PingResult>(`/server-settings/resources/${id}/ping`, { url });
|
||||
};
|
||||
|
||||
return { resources, isLoading, saveConnectionConfig, pingResource };
|
||||
const runCommand = async (id: string, action: string) => {
|
||||
const result = await client.post<{ exitCode: number; output: string }>(`/server-settings/resources/${id}/run`, { action });
|
||||
queryClient.invalidateQueries({ queryKey: RESOURCES_KEY });
|
||||
return result;
|
||||
};
|
||||
|
||||
return { resources, isLoading, saveConnectionConfig, pingResource, runCommand };
|
||||
};
|
||||
|
||||
@@ -198,20 +198,22 @@ function buildConnectionConfig(
|
||||
return { url: `http://127.0.0.1:${port ?? resource.port}` };
|
||||
}
|
||||
|
||||
async function loadResources(): Promise<Resource[]> {
|
||||
async function parseResources() {
|
||||
const dir = getResourcesDir();
|
||||
if (!existsSync(dir)) return [];
|
||||
const [files, config] = await Promise.all([readdir(dir), readConfig()]);
|
||||
const files = await readdir(dir);
|
||||
const serviceFiles = files.filter((f) => f.startsWith('SERVICE_') && f.endsWith('.md'));
|
||||
|
||||
const parsed = await Promise.all(
|
||||
return Promise.all(
|
||||
serviceFiles.map(async (filename) => {
|
||||
const content = await Bun.file(`${dir}/${filename}`).text();
|
||||
return parseResourceFile(filename, content);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const resources = await Promise.all(
|
||||
async function loadResources(): Promise<Resource[]> {
|
||||
const [parsed, config] = await Promise.all([parseResources(), readConfig()]);
|
||||
return Promise.all(
|
||||
parsed.map(async (r) => {
|
||||
const configUrl = config[r.id]?.url;
|
||||
const status = await checkResourceStatus({ resource: r, configUrl });
|
||||
@@ -219,8 +221,6 @@ async function loadResources(): Promise<Resource[]> {
|
||||
return { ...r, ...status, connectionConfig };
|
||||
}),
|
||||
);
|
||||
|
||||
return resources;
|
||||
}
|
||||
|
||||
export const resourcesRouter = createRouter();
|
||||
@@ -290,6 +290,42 @@ resourcesRouter.post('/error-log', async (ctx) => {
|
||||
return ctx.json({ filePath });
|
||||
});
|
||||
|
||||
resourcesRouter.post('/:id/run', async (ctx) => {
|
||||
const id = ctx.req.param('id');
|
||||
const { action } = await ctx.req.json<{ action: string }>();
|
||||
|
||||
const parsed = await parseResources();
|
||||
const resource = parsed.find((r) => r.id === id);
|
||||
if (!resource) return ctx.json({ error: 'Resource not found' }, 404);
|
||||
|
||||
const commands: Record<string, string | null> = {
|
||||
install: resource.installCommand,
|
||||
uninstall: resource.uninstallCommand,
|
||||
verify: resource.verifyCommand,
|
||||
update: resource.updateCommand,
|
||||
manage: resource.manageCommand,
|
||||
};
|
||||
|
||||
const command = commands[action];
|
||||
if (!command) return ctx.json({ error: `No ${action} command for this resource` }, 400);
|
||||
|
||||
if (command.trimStart().startsWith('sudo')) {
|
||||
return ctx.json({ error: 'Sudo commands must run in terminal' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn(['sh', '-c', command], { stdout: 'pipe', stderr: 'pipe' });
|
||||
const [stdout, stderr] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
]);
|
||||
await proc.exited;
|
||||
return ctx.json({ exitCode: proc.exitCode, output: (stdout + stderr).trim() });
|
||||
} catch {
|
||||
return ctx.json({ exitCode: 1, output: 'Failed to execute command' });
|
||||
}
|
||||
});
|
||||
|
||||
resourcesRouter.get('/:id', async (ctx) => {
|
||||
const resources = await loadResources();
|
||||
const resource = resources.find((r) => r.id === ctx.req.param('id'));
|
||||
|
||||
Reference in New Issue
Block a user