377 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|