c1adb94abb
CI has been running `prettier --check` against a tree that was never formatted, so the check reported 63 files and failed every run. Nothing here is a behaviour change: `pnpm lint` and the four typecheck builds pass exactly as before. conduit-bringup-artifacts is added to .prettierignore instead. Those files are captured bring-up reports, not maintained sources; reflowing them would only churn a record of what happened.
270 lines
9.2 KiB
TypeScript
270 lines
9.2 KiB
TypeScript
import { useState } from 'react';
|
|
import { useParams } from 'react-router';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import {
|
|
HardDrive,
|
|
Plus,
|
|
Download,
|
|
Trash2,
|
|
Lock,
|
|
Unlock,
|
|
RotateCcw,
|
|
CheckCircle2,
|
|
Clock,
|
|
} from 'lucide-react';
|
|
import { api } from '@/lib/api';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from '@/components/ui/dialog';
|
|
|
|
interface Backup {
|
|
id: string;
|
|
name: string;
|
|
sizeBytes: number | null;
|
|
cdnPath: string | null;
|
|
checksum: string | null;
|
|
isLocked: boolean;
|
|
completedAt: string | null;
|
|
createdAt: string;
|
|
}
|
|
|
|
function formatBytes(bytes: number | null): string {
|
|
if (bytes === null || bytes === 0) return '—';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i] ?? 'B'}`;
|
|
}
|
|
|
|
export function BackupsPage() {
|
|
const { orgId, serverId } = useParams();
|
|
const queryClient = useQueryClient();
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
const [confirmRestore, setConfirmRestore] = useState<string | null>(null);
|
|
|
|
const { data } = useQuery({
|
|
queryKey: ['backups', orgId, serverId],
|
|
queryFn: () =>
|
|
api.get<{ backups: Backup[] }>(`/organizations/${orgId}/servers/${serverId}/backups`),
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (backupId: string) =>
|
|
api.delete(`/organizations/${orgId}/servers/${serverId}/backups/${backupId}`),
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['backups', orgId, serverId] }),
|
|
});
|
|
|
|
const restoreMutation = useMutation({
|
|
mutationFn: (backupId: string) =>
|
|
api.post(`/organizations/${orgId}/servers/${serverId}/backups/${backupId}/restore`, {}),
|
|
onSuccess: () => setConfirmRestore(null),
|
|
});
|
|
|
|
const lockMutation = useMutation({
|
|
mutationFn: (backupId: string) =>
|
|
api.patch(`/organizations/${orgId}/servers/${serverId}/backups/${backupId}/lock`, {}),
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['backups', orgId, serverId] }),
|
|
});
|
|
|
|
const backupList = data?.backups ?? [];
|
|
|
|
const totalSize = backupList.reduce((sum, b) => sum + (b.sizeBytes ?? 0), 0);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-lg font-semibold">Backups</h2>
|
|
<p className="text-xs text-muted-foreground">
|
|
{backupList.length} backup{backupList.length !== 1 ? 's' : ''} —{' '}
|
|
{formatBytes(totalSize)} total
|
|
</p>
|
|
</div>
|
|
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
|
<DialogTrigger asChild>
|
|
<Button size="sm">
|
|
<Plus className="mr-1.5 h-4 w-4" />
|
|
Create Backup
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Create Backup</DialogTitle>
|
|
</DialogHeader>
|
|
<CreateBackupForm
|
|
orgId={orgId!}
|
|
serverId={serverId!}
|
|
onClose={() => setShowCreate(false)}
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
|
|
{backupList.length === 0 ? (
|
|
<Card>
|
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
<HardDrive className="mb-4 h-12 w-12 text-muted-foreground/50" />
|
|
<p className="text-muted-foreground">No backups yet</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
Create a backup to save the current state of your server
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{backupList.map((backup) => (
|
|
<Card key={backup.id}>
|
|
<CardContent className="flex items-center justify-between py-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
|
{backup.completedAt ? (
|
|
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
|
) : (
|
|
<Clock className="h-5 w-5 text-yellow-500 animate-pulse" />
|
|
)}
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<p className="font-medium">{backup.name}</p>
|
|
{backup.isLocked && (
|
|
<Badge variant="outline">
|
|
<Lock className="mr-1 h-3 w-3" />
|
|
Locked
|
|
</Badge>
|
|
)}
|
|
{!backup.completedAt && (
|
|
<Badge variant="outline" className="text-yellow-500">
|
|
In Progress
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="mt-0.5 flex items-center gap-3 text-xs text-muted-foreground">
|
|
<span>{formatBytes(backup.sizeBytes)}</span>
|
|
<span>{new Date(backup.createdAt).toLocaleString()}</span>
|
|
{backup.checksum && (
|
|
<span className="font-mono">{backup.checksum.slice(0, 12)}...</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1">
|
|
{backup.completedAt && (
|
|
<>
|
|
{confirmRestore === backup.id ? (
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-xs text-destructive mr-1">Confirm?</span>
|
|
<Button
|
|
size="sm"
|
|
variant="destructive"
|
|
onClick={() => restoreMutation.mutate(backup.id)}
|
|
disabled={restoreMutation.isPending}
|
|
>
|
|
Yes
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => setConfirmRestore(null)}
|
|
>
|
|
No
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setConfirmRestore(backup.id)}
|
|
title="Restore"
|
|
>
|
|
<RotateCcw className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => lockMutation.mutate(backup.id)}
|
|
title={backup.isLocked ? 'Unlock' : 'Lock'}
|
|
>
|
|
{backup.isLocked ? (
|
|
<Unlock className="h-4 w-4" />
|
|
) : (
|
|
<Lock className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
{!backup.isLocked && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-destructive hover:text-destructive"
|
|
onClick={() => deleteMutation.mutate(backup.id)}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CreateBackupForm({
|
|
orgId,
|
|
serverId,
|
|
onClose,
|
|
}: {
|
|
orgId: string;
|
|
serverId: string;
|
|
onClose: () => void;
|
|
}) {
|
|
const queryClient = useQueryClient();
|
|
const [name, setName] = useState(`backup-${new Date().toISOString().slice(0, 10)}`);
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: (data: { name: string }) =>
|
|
api.post(`/organizations/${orgId}/servers/${serverId}/backups`, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['backups', orgId, serverId] });
|
|
onClose();
|
|
},
|
|
});
|
|
|
|
return (
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
createMutation.mutate({ name });
|
|
}}
|
|
className="space-y-4"
|
|
>
|
|
<div className="grid gap-1.5">
|
|
<Label>Backup Name</Label>
|
|
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
|
</div>
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<Button type="button" variant="outline" onClick={onClose}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" disabled={createMutation.isPending}>
|
|
{createMutation.isPending ? 'Creating...' : 'Create Backup'}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|