163 lines
6.5 KiB
TypeScript
163 lines
6.5 KiB
TypeScript
import { useState } from 'react';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { RefreshCw, Download, Circle, Copy, Check } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { Button } from '@/components/ui/button';
|
|
import { useClient } from 'hooks/useClient';
|
|
|
|
type AppStatus = {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
installed: boolean;
|
|
version: string | null;
|
|
running: boolean | null;
|
|
hasInstall: boolean;
|
|
hasUpdate: boolean;
|
|
manualInstallCommand: string | null;
|
|
manualUpdateCommand: string | null;
|
|
};
|
|
|
|
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 dark:bg-foreground/5 rounded px-2 py-1 text-xs text-duck-dark/70 dark:text-foreground/70">{command}</code>
|
|
<button
|
|
type="button"
|
|
onClick={copy}
|
|
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/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 dark:text-foreground/50" />}
|
|
</button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const Applications = () => {
|
|
const client = useClient();
|
|
const queryClient = useQueryClient();
|
|
const [actionInProgress, setActionInProgress] = useState<string | null>(null);
|
|
|
|
const { data: apps, isLoading } = useQuery({
|
|
queryKey: ['APPLICATIONS'],
|
|
queryFn: () => client.get<AppStatus[]>('/server-settings/applications'),
|
|
});
|
|
|
|
const runAction = async (id: string, action: 'install' | 'update') => {
|
|
setActionInProgress(id);
|
|
try {
|
|
await client.post<AppStatus>(`/server-settings/applications/${id}/${action}`);
|
|
await queryClient.invalidateQueries({ queryKey: ['APPLICATIONS'] });
|
|
toast.success(`${action === 'install' ? 'Installed' : 'Updated'} successfully`);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : `${action} failed`;
|
|
toast.error(message);
|
|
} finally {
|
|
setActionInProgress(null);
|
|
}
|
|
};
|
|
|
|
const getManualCommand = (app: AppStatus): string | null => {
|
|
if (!app.installed) return app.manualInstallCommand;
|
|
return app.manualUpdateCommand ?? app.manualInstallCommand;
|
|
};
|
|
|
|
const hasAutoAction = (app: AppStatus): boolean => {
|
|
if (!app.installed) return app.hasInstall && !app.manualInstallCommand;
|
|
return app.hasUpdate && !(app.manualUpdateCommand ?? app.manualInstallCommand);
|
|
};
|
|
|
|
return (
|
|
<div className="h-full overflow-y-auto p-6">
|
|
<h2 className="text-lg font-bold text-duck-dark dark:text-foreground mb-4" title="System tools and dependencies used by Officer.dev">Applications</h2>
|
|
|
|
{isLoading && <p className="text-sm text-duck-dark/50 dark:text-foreground/50">Checking applications...</p>}
|
|
|
|
{apps && (
|
|
<div className="flex flex-col gap-3">
|
|
{apps.map((app: AppStatus) => {
|
|
const manualCmd = getManualCommand(app);
|
|
const canAutoRun = hasAutoAction(app);
|
|
|
|
return (
|
|
<div key={app.id} className="flex flex-col rounded-lg border border-duck-dark/10 dark:border-foreground/10 px-4 py-3">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-semibold text-duck-dark dark:text-foreground">{app.name}</span>
|
|
{app.installed && (
|
|
<span className="text-xs bg-green-100 text-green-700 rounded-full px-2 py-0.5">
|
|
{app.version}
|
|
</span>
|
|
)}
|
|
{!app.installed && (
|
|
<span className="text-xs bg-duck-dark/5 dark:bg-foreground/5 text-duck-dark/40 dark:text-foreground/40 rounded-full px-2 py-0.5">
|
|
Not installed
|
|
</span>
|
|
)}
|
|
{app.running !== null && (
|
|
<Circle
|
|
className={`h-2.5 w-2.5 ${app.running ? 'fill-green-500 text-green-500' : 'fill-duck-dark/20 text-duck-dark/20'}`}
|
|
/>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 mt-0.5">{app.description}</p>
|
|
</div>
|
|
|
|
<div className="shrink-0">
|
|
{canAutoRun && !app.installed && (
|
|
<Button
|
|
size="sm"
|
|
className="bg-duck-teal text-duck-yellow hover:bg-duck-teal/90"
|
|
disabled={actionInProgress === app.id}
|
|
onClick={() => runAction(app.id, 'install')}
|
|
>
|
|
{actionInProgress === app.id ? (
|
|
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<Download className="h-3.5 w-3.5" />
|
|
)}
|
|
{actionInProgress === app.id ? 'Installing...' : 'Install'}
|
|
</Button>
|
|
)}
|
|
{canAutoRun && app.installed && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={actionInProgress === app.id}
|
|
onClick={() => runAction(app.id, 'update')}
|
|
>
|
|
{actionInProgress === app.id ? (
|
|
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="h-3.5 w-3.5" />
|
|
)}
|
|
{actionInProgress === app.id ? 'Updating...' : 'Update'}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{manualCmd && (
|
|
<div className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
|
|
{app.installed ? 'Update' : 'Install'} manually:
|
|
<CopyCommand command={manualCmd} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|