feat: Fleet Forge UEM - device management, agent, remote desktop (VNC), TLS reverse proxy
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user