Files
fleetforge/apps/web/src/app/deployments/page.tsx
T

377 lines
14 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import DashboardLayout from "@/components/DashboardLayout";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger,
} from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Rocket, Check, Plus, AlertTriangle } from "lucide-react";
interface Deployment {
id: string;
name: string;
description: string | null;
jobType: string;
status: string;
createdByName: string | null;
totalTargets: number;
completedTargets: number;
failedTargets: number;
createdAt: string;
completedAt: string | null;
}
interface Device {
id: string;
name: string;
status: string;
}
const statusVariant: Record<string, "info" | "warning" | "success" | "destructive" | "secondary" | "outline"> = {
draft: "outline",
pending_approval: "warning",
approved: "info",
running: "warning",
completed: "success",
partially_completed: "warning",
failed: "destructive",
cancelled: "secondary",
};
const JOB_TYPES = [
{ value: "install_package", label: "Install Package", paramKey: "package_name", paramLabel: "Package Name" },
{ value: "update_package", label: "Update Package", paramKey: "package_name", paramLabel: "Package Name" },
{ value: "remove_package", label: "Remove Package", paramKey: "package_name", paramLabel: "Package Name" },
{ value: "start_service", label: "Start Service", paramKey: "service_name", paramLabel: "Service Name" },
{ value: "stop_service", label: "Stop Service", paramKey: "service_name", paramLabel: "Service Name" },
{ value: "restart_service", label: "Restart Service", paramKey: "service_name", paramLabel: "Service Name" },
{ value: "check_service_status", label: "Check Service Status", paramKey: "service_name", paramLabel: "Service Name" },
{ value: "reboot_system", label: "Reboot System", paramKey: null, paramLabel: null },
];
export default function DeploymentsPage() {
const [deployments, setDeployments] = useState<Deployment[]>([]);
const [total, setTotal] = useState(0);
const [devices, setDevices] = useState<Device[]>([]);
const [showCreate, setShowCreate] = useState(false);
const [error, setError] = useState("");
const [form, setForm] = useState({
name: "",
description: "",
jobType: "install_package",
packageName: "",
serviceName: "",
autoApprove: false,
});
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
const loadDeployments = () => {
api.get<Deployment[]>("/v1/deployments").then((res) => {
if (res.success && res.data) {
setDeployments(res.data);
setTotal(res.meta?.total || 0);
}
});
};
const loadDevices = () => {
api.get<Device[]>("/v1/devices").then((res) => {
if (res.success && res.data) setDevices(res.data);
});
};
useEffect(() => { loadDeployments(); loadDevices(); }, []);
const selectedJobType = JOB_TYPES.find((j) => j.value === form.jobType);
const needsPackageName = selectedJobType?.paramKey === "package_name";
const needsServiceName = selectedJobType?.paramKey === "service_name";
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (selectedDevices.length === 0) {
setError("Select at least one target device.");
return;
}
const parameters: Record<string, string> = {};
if (needsPackageName) {
if (!form.packageName.trim()) {
setError("Package name is required.");
return;
}
parameters.package_name = form.packageName.trim();
}
if (needsServiceName) {
if (!form.serviceName.trim()) {
setError("Service name is required.");
return;
}
parameters.service_name = form.serviceName.trim();
}
const res = await api.post<Deployment>("/v1/deployments", {
name: form.name.trim(),
description: form.description.trim() || undefined,
jobType: form.jobType,
parameters,
deviceIds: selectedDevices,
autoApprove: form.autoApprove,
});
if (res.success) {
setShowCreate(false);
setForm({ name: "", description: "", jobType: "install_package", packageName: "", serviceName: "", autoApprove: false });
setSelectedDevices([]);
loadDeployments();
} else {
setError(res.error?.message || "Failed to create deployment");
}
};
const handleApprove = async (id: string) => {
await api.patch(`/v1/deployments/${id}/approve`);
loadDeployments();
};
const handleCancel = async (id: string) => {
await api.patch(`/v1/deployments/${id}/cancel`);
loadDeployments();
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this deployment?")) return;
await api.delete(`/v1/deployments/${id}`);
loadDeployments();
};
const toggleDevice = (id: string) => {
setSelectedDevices((prev) =>
prev.includes(id) ? prev.filter((d) => d !== id) : [...prev, id]
);
};
return (
<DashboardLayout>
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold tracking-tight">Deployments</h1>
<p className="text-muted-foreground">{total} deployment(s)</p>
</div>
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
New Deployment
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<form onSubmit={handleCreate}>
<DialogHeader>
<DialogTitle>Create Deployment</DialogTitle>
<DialogDescription>Deploy packages, services, or commands to selected devices.</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-4">
{error && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label>Name *</Label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="e.g. Install nginx on web servers"
required
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Input
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="Optional description"
/>
</div>
<div className="space-y-2">
<Label>Job Type *</Label>
<Select value={form.jobType} onValueChange={(v) => setForm({ ...form, jobType: v })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{JOB_TYPES.map((jt) => (
<SelectItem key={jt.value} value={jt.value}>{jt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{needsPackageName && (
<div className="space-y-2">
<Label>Package Name *</Label>
<Input
value={form.packageName}
onChange={(e) => setForm({ ...form, packageName: e.target.value })}
placeholder="e.g. nginx"
required
/>
</div>
)}
{needsServiceName && (
<div className="space-y-2">
<Label>Service Name *</Label>
<Input
value={form.serviceName}
onChange={(e) => setForm({ ...form, serviceName: e.target.value })}
placeholder="e.g. nginx"
required
/>
</div>
)}
<div className="space-y-2">
<Label>Target Devices *</Label>
<div className="border rounded-md max-h-48 overflow-y-auto">
{devices.length === 0 ? (
<p className="text-sm text-muted-foreground p-4 text-center">No devices enrolled yet.</p>
) : (
devices.map((d) => (
<label
key={d.id}
className="flex items-center gap-3 px-3 py-2 hover:bg-muted/50 cursor-pointer border-b last:border-b-0"
>
<input
type="checkbox"
checked={selectedDevices.includes(d.id)}
onChange={() => toggleDevice(d.id)}
className="rounded"
/>
<span className="text-sm font-medium">{d.name}</span>
<Badge variant={d.status === "online" ? "success" : "secondary"} className="ml-auto text-xs">
{d.status}
</Badge>
</label>
))
)}
</div>
<p className="text-xs text-muted-foreground">{selectedDevices.length} device(s) selected</p>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.autoApprove}
onChange={(e) => setForm({ ...form, autoApprove: e.target.checked })}
className="rounded"
/>
Auto-approve and deploy immediately
</label>
</div>
<DialogFooter className="mt-6">
<Button type="button" variant="outline" onClick={() => setShowCreate(false)}>Cancel</Button>
<Button type="submit">Create Deployment</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
{deployments.length === 0 ? (
<Card>
<CardContent className="p-12 text-center">
<Rocket className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">No deployments created yet. Click "New Deployment" to get started.</p>
</CardContent>
</Card>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Job Type</TableHead>
<TableHead>Status</TableHead>
<TableHead>Progress</TableHead>
<TableHead>Created By</TableHead>
<TableHead>Created</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{deployments.map((d) => (
<TableRow key={d.id}>
<TableCell>
<div className="font-medium text-sm">{d.name}</div>
{d.description && (
<p className="text-xs text-muted-foreground mt-0.5">{d.description}</p>
)}
</TableCell>
<TableCell className="text-sm font-mono">{d.jobType.replace(/_/g, " ")}</TableCell>
<TableCell>
<Badge variant={statusVariant[d.status] || "secondary"}>{d.status.replace(/_/g, " ")}</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-2 text-sm">
<span className="text-green-600">{d.completedTargets}</span>
<span className="text-muted-foreground">/</span>
<span className="text-muted-foreground">{d.totalTargets}</span>
{d.failedTargets > 0 && (
<span className="text-destructive">({d.failedTargets} failed)</span>
)}
</div>
</TableCell>
<TableCell className="text-sm">{d.createdByName || "—"}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{new Date(d.createdAt).toLocaleString()}
</TableCell>
<TableCell>
<div className="flex gap-1">
{d.status === "draft" && (
<Button size="sm" variant="outline" onClick={() => handleApprove(d.id)}>
<Check className="h-3 w-3 mr-1" /> Approve
</Button>
)}
{["draft", "approved"].includes(d.status) && (
<Button size="sm" variant="ghost" onClick={() => handleCancel(d.id)}>
Cancel
</Button>
)}
{d.status === "draft" && (
<Button size="sm" variant="ghost" className="text-destructive" onClick={() => handleDelete(d.id)}>
Delete
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
</DashboardLayout>
);
}