feat: Fleet Forge UEM - device management, agent, remote desktop (VNC), TLS reverse proxy
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
transpilePackages: ["@fleetforge/shared"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@fleetforge/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3001",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3001",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "next lint",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fleetforge/shared": "workspace:*",
|
||||
"@novnc/novnc": "^1.7.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.23",
|
||||
"@radix-ui/react-dialog": "^1.1.23",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||
"@radix-ui/react-label": "^2.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.18",
|
||||
"@radix-ui/react-select": "^2.3.7",
|
||||
"@radix-ui/react-separator": "^1.1.15",
|
||||
"@radix-ui/react-slot": "^1.3.3",
|
||||
"@radix-ui/react-tabs": "^1.1.21",
|
||||
"@radix-ui/react-tooltip": "^1.2.16",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.32.0",
|
||||
"next": "^15.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
"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 { Bell } from "lucide-react";
|
||||
|
||||
interface AlertItem {
|
||||
id: string;
|
||||
alertType: string;
|
||||
severity: string;
|
||||
title: string;
|
||||
message: string;
|
||||
isResolved: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const severityVariant: Record<string, "info" | "warning" | "destructive" | "outline"> = {
|
||||
info: "info",
|
||||
warning: "warning",
|
||||
error: "destructive",
|
||||
critical: "destructive",
|
||||
};
|
||||
|
||||
export default function AlertsPage() {
|
||||
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||
|
||||
const fetchAlerts = () => {
|
||||
api.get<AlertItem[]>("/v1/alerts").then((res) => {
|
||||
if (res.success && res.data) setAlerts(res.data);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => { fetchAlerts(); }, []);
|
||||
|
||||
const resolveAlert = async (id: string) => {
|
||||
await api.patch(`/v1/alerts/${id}/resolve`);
|
||||
fetchAlerts();
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Alerts</h1>
|
||||
<p className="text-muted-foreground">System alerts and notifications</p>
|
||||
</div>
|
||||
|
||||
{alerts.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<Bell className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">No alerts. All clear!</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{alerts.map((alert) => (
|
||||
<Card key={alert.id} className={alert.isResolved ? "opacity-60" : ""}>
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge variant={severityVariant[alert.severity] || "outline"}>
|
||||
{alert.severity.toUpperCase()}
|
||||
</Badge>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{alert.title}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{alert.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(alert.createdAt).toLocaleString()}
|
||||
</span>
|
||||
{!alert.isResolved && (
|
||||
<Button variant="ghost" size="sm" onClick={() => resolveAlert(alert.id)}>
|
||||
Resolve
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"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 { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { ScrollText } from "lucide-react";
|
||||
|
||||
interface AuditEvent {
|
||||
id: string;
|
||||
action: string;
|
||||
targetType: string;
|
||||
targetId: string | null;
|
||||
result: string;
|
||||
ipAddress: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
correlationId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function AuditLogPage() {
|
||||
const [events, setEvents] = useState<AuditEvent[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [actionFilter, setActionFilter] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const params: Record<string, string> = {};
|
||||
if (actionFilter) params.action = actionFilter;
|
||||
api.get<AuditEvent[]>("/v1/audit-events", params).then((res) => {
|
||||
if (res.success && res.data) {
|
||||
setEvents(res.data);
|
||||
setTotal(res.meta?.total || 0);
|
||||
}
|
||||
});
|
||||
}, [actionFilter]);
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Audit Log</h1>
|
||||
<p className="text-muted-foreground">{total} event(s) recorded</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<Input
|
||||
placeholder="Filter by action..."
|
||||
value={actionFilter}
|
||||
onChange={(e) => setActionFilter(e.target.value)}
|
||||
className="w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{events.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<ScrollText className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">No audit events recorded yet.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>Target</TableHead>
|
||||
<TableHead>Result</TableHead>
|
||||
<TableHead>IP</TableHead>
|
||||
<TableHead>Correlation ID</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{events.map((event) => (
|
||||
<TableRow key={event.id}>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{new Date(event.createdAt).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-medium">
|
||||
{event.action}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{event.targetType}
|
||||
{event.targetId && (
|
||||
<span className="ml-1">
|
||||
({event.targetId.substring(0, 8)}...)
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={event.result === "success" ? "success" : "destructive"}>
|
||||
{event.result}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{event.ipAddress || "\u2014"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground font-mono">
|
||||
{event.correlationId.substring(0, 12)}...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Server, Wifi, WifiOff, AlertTriangle, Bell, Zap, Clock } from "lucide-react";
|
||||
|
||||
interface DashboardStats {
|
||||
totalDevices: number;
|
||||
onlineDevices: number;
|
||||
offlineDevices: number;
|
||||
warningDevices: number;
|
||||
openAlerts: number;
|
||||
pendingJobs: number;
|
||||
staleDevices: number;
|
||||
osDistribution: Array<{ os: string; total: number }>;
|
||||
recentDevices: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
osName: string;
|
||||
lastHeartbeatAt: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | string;
|
||||
icon: React.ElementType;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{label}
|
||||
</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const variant: "success" | "secondary" | "warning" =
|
||||
status === "online" ? "success" : status === "offline" ? "secondary" : "warning";
|
||||
|
||||
return (
|
||||
<Badge variant={variant}>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
api.get<DashboardStats>("/v1/dashboard/stats").then((res) => {
|
||||
if (res.success && res.data) {
|
||||
setStats(res.data);
|
||||
} else {
|
||||
setError(res.error?.message || "Failed to load dashboard");
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
|
||||
<p className="text-muted-foreground">Fleet overview and status</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-6">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{stats ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<StatCard label="Total Devices" value={stats.totalDevices} icon={Server} />
|
||||
<StatCard label="Online" value={stats.onlineDevices} icon={Wifi} />
|
||||
<StatCard label="Offline" value={stats.offlineDevices} icon={WifiOff} />
|
||||
<StatCard label="Warnings" value={stats.warningDevices} icon={AlertTriangle} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Devices</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stats.recentDevices.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No devices enrolled yet. Create an enrollment token to get started.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{stats.recentDevices.map((device) => (
|
||||
<div
|
||||
key={device.id}
|
||||
className="flex items-center justify-between py-2 border-b border-border last:border-0"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-sm">{device.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{device.osName}</p>
|
||||
</div>
|
||||
<StatusBadge status={device.status} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>OS Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stats.osDistribution.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No device data available yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{stats.osDistribution.map((item) => (
|
||||
<div key={item.os} className="flex items-center justify-between">
|
||||
<span className="text-sm">{item.os}</span>
|
||||
<span className="text-sm font-medium">{item.total}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Open Alerts</CardTitle>
|
||||
<Bell className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.openAlerts}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Pending Jobs</CardTitle>
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.pendingJobs}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Stale Devices</CardTitle>
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.staleDevices}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
{[...Array(7)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-24" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { api } from "@/lib/api";
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ArrowLeft, Cpu, MemoryStick, HardDrive, Wifi, Clock, AlertTriangle, Wrench, Monitor } from "lucide-react";
|
||||
import { DeviceActionsDialog } from "@/components/DeviceActionsDialog";
|
||||
import { RemoteAccessModal } from "@/components/RemoteAccessModal";
|
||||
|
||||
interface DeviceDetail {
|
||||
id: string; name: string; deviceType: string; platform: string;
|
||||
osName: string; osVersion: string; architecture: string; kernelVersion: string | null;
|
||||
cpuModel: string | null; cpuCores: number | null;
|
||||
totalMemoryBytes: number | null; totalDiskBytes: number | null;
|
||||
ipAddress: string | null; agentVersion: string; status: string;
|
||||
lastHeartbeatAt: string | null; enrolledAt: string; tags: string[];
|
||||
}
|
||||
|
||||
interface MetricSnapshot {
|
||||
cpuUsagePercent: number | null; memoryUsagePercent: number | null;
|
||||
diskUsagePercent: number | null; loadAverage1m: number | null; loadAverage5m: number | null;
|
||||
memoryUsedBytes: number | null; memoryTotalBytes: number | null;
|
||||
diskUsedBytes: number | null; diskTotalBytes: number | null;
|
||||
uptimeSeconds: number | null; reportedAt: string;
|
||||
}
|
||||
|
||||
interface PackageInfo { name: string; version: string; manager: string; }
|
||||
interface ServiceInfo { name: string; status: string; enabled: boolean; }
|
||||
interface InventoryData { packages: PackageInfo[]; services: ServiceInfo[]; reportedAt: string | null; }
|
||||
|
||||
interface DeviceJob {
|
||||
id: string; jobType: string; status: string; parameters: Record<string, unknown>;
|
||||
targetStatus: string; targetResult: Record<string, unknown> | null;
|
||||
createdAt: string; completedAt: string | null;
|
||||
}
|
||||
|
||||
interface DeviceAlert {
|
||||
id: string; alertType: string; severity: string; title: string; message: string;
|
||||
isResolved: boolean; createdAt: string;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number | null): string {
|
||||
if (!bytes) return "\u2014";
|
||||
const gb = bytes / (1024 * 1024 * 1024);
|
||||
if (gb >= 1) return `${gb.toFixed(1)} GB`;
|
||||
const mb = bytes / (1024 * 1024);
|
||||
return `${mb.toFixed(0)} MB`;
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number | null): string {
|
||||
if (!seconds) return "\u2014";
|
||||
const d = Math.floor(seconds / 86400); const h = Math.floor((seconds % 86400) / 3600); const m = Math.floor((seconds % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h ${m}m`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const variant: "success" | "secondary" | "warning" = status === "online" ? "success" : status === "offline" ? "secondary" : "warning";
|
||||
return <Badge variant={variant}>{status.charAt(0).toUpperCase() + status.slice(1)}</Badge>;
|
||||
}
|
||||
|
||||
function MetricCard({ icon: Icon, label, value, subtext }: { icon: any; label: string; value: string; subtext?: string }) {
|
||||
return (
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-muted rounded-lg"><Icon className="h-4 w-4 text-muted-foreground" /></div>
|
||||
<div className="flex-1"><p className="text-xs text-muted-foreground">{label}</p><p className="text-lg font-semibold">{value}</p>{subtext && <p className="text-xs text-muted-foreground">{subtext}</p>}</div>
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressCard({ label, percent, used, total }: { label: string; percent: number | null; used?: string; total?: string }) {
|
||||
const p = percent != null ? Math.min(100, Math.max(0, percent)) : 0;
|
||||
const color = p > 90 ? "bg-destructive" : p > 70 ? "bg-yellow-500" : "bg-primary";
|
||||
return (
|
||||
<Card><CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-2"><p className="text-sm font-medium">{label}</p><p className="text-sm text-muted-foreground">{percent != null ? `${percent.toFixed(1)}%` : "\u2014"}</p></div>
|
||||
<div className="w-full bg-muted rounded-full h-2"><div className={`h-2 rounded-full ${color}`} style={{ width: `${p}%` }} /></div>
|
||||
{used && total && <p className="text-xs text-muted-foreground mt-1">{used} / {total}</p>}
|
||||
</CardContent></Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeviceDetailPage() {
|
||||
const params = useParams();
|
||||
const deviceId = params.deviceId as string;
|
||||
const [device, setDevice] = useState<DeviceDetail | null>(null);
|
||||
const [metrics, setMetrics] = useState<MetricSnapshot[]>([]);
|
||||
const [inventory, setInventory] = useState<InventoryData | null>(null);
|
||||
const [jobs, setJobs] = useState<DeviceJob[]>([]);
|
||||
const [alerts, setAlerts] = useState<DeviceAlert[]>([]);
|
||||
const [actionsOpen, setActionsOpen] = useState(false);
|
||||
const [remoteOpen, setRemoteOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<DeviceDetail>(`/v1/devices/${deviceId}`).then((r) => { if (r.success && r.data) setDevice(r.data); });
|
||||
api.get<MetricSnapshot[]>(`/v1/devices/${deviceId}/metrics`).then((r) => { if (r.success && r.data) setMetrics(r.data); });
|
||||
api.get<InventoryData>(`/v1/software/inventory/devices/${deviceId}`).then((r) => { if (r.success && r.data) setInventory(r.data); });
|
||||
api.get<DeviceJob[]>(`/v1/devices/${deviceId}/jobs`).then((r) => { if (r.success && r.data) setJobs(r.data); });
|
||||
api.get<DeviceAlert[]>(`/v1/devices/${deviceId}/alerts`).then((r) => { if (r.success && r.data) setAlerts(r.data); });
|
||||
}, [deviceId]);
|
||||
|
||||
if (!device) return <DashboardLayout><Skeleton className="h-8 w-48 mb-8" /><div className="grid grid-cols-2 gap-4"><Skeleton className="h-96" /><Skeleton className="h-96" /></div></DashboardLayout>;
|
||||
|
||||
const m = metrics[0];
|
||||
const jobStatusVariant: Record<string, "success" | "destructive" | "warning" | "secondary" | "info"> = {
|
||||
succeeded: "success", failed: "destructive", running: "warning", queued: "secondary", dispatched: "info",
|
||||
};
|
||||
const severityVariant: Record<string, "destructive" | "warning" | "info" | "secondary"> = {
|
||||
critical: "destructive", error: "destructive", warning: "warning", info: "info",
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="mb-6">
|
||||
<Link href="/devices" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground"><ArrowLeft className="mr-1 h-4 w-4" />Back to Devices</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{device.name}</h1>
|
||||
<p className="text-muted-foreground mt-1">{device.osName} {device.osVersion} · {device.architecture} · {device.ipAddress || "No IP"}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={() => setRemoteOpen(true)}>
|
||||
<Monitor className="mr-2 h-4 w-4" />Remote
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setActionsOpen(true)}>
|
||||
<Wrench className="mr-2 h-4 w-4" />Actions
|
||||
</Button>
|
||||
<Badge variant="outline" className="font-mono text-xs">{device.agentVersion}</Badge>
|
||||
<StatusBadge status={device.status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3 mb-6">
|
||||
<MetricCard icon={Cpu} label="CPU" value={m?.cpuUsagePercent != null ? `${m.cpuUsagePercent.toFixed(1)}%` : "\u2014"} subtext={device.cpuCores ? `${device.cpuCores} cores \u00b7 ${device.cpuModel?.split(" ").slice(-2).join(" ") || ""}` : undefined} />
|
||||
<MetricCard icon={MemoryStick} label="Memory" value={m?.memoryUsagePercent != null ? `${m.memoryUsagePercent.toFixed(1)}%` : "\u2014"} subtext={m?.memoryUsedBytes ? `${formatBytes(m.memoryUsedBytes)} / ${formatBytes(m.memoryTotalBytes)}` : formatBytes(device.totalMemoryBytes)} />
|
||||
<MetricCard icon={HardDrive} label="Disk" value={m?.diskUsagePercent != null ? `${m.diskUsagePercent.toFixed(1)}%` : "\u2014"} subtext={m?.diskUsedBytes ? `${formatBytes(m.diskUsedBytes)} / ${formatBytes(m.diskTotalBytes)}` : formatBytes(device.totalDiskBytes)} />
|
||||
<MetricCard icon={Clock} label="Uptime" value={m?.uptimeSeconds != null ? formatUptime(m.uptimeSeconds) : "\u2014"} />
|
||||
<MetricCard icon={Wifi} label="Load" value={m?.loadAverage1m != null ? m.loadAverage1m.toFixed(2) : "\u2014"} subtext={m?.loadAverage5m != null ? `5m: ${m.loadAverage5m.toFixed(2)}` : undefined} />
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="system" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="system">System</TabsTrigger>
|
||||
<TabsTrigger value="metrics">Metrics History</TabsTrigger>
|
||||
<TabsTrigger value="packages">Packages ({inventory?.packages?.length || 0})</TabsTrigger>
|
||||
<TabsTrigger value="services">Services ({inventory?.services?.length || 0})</TabsTrigger>
|
||||
<TabsTrigger value="jobs">Jobs ({jobs.length})</TabsTrigger>
|
||||
<TabsTrigger value="alerts">Alerts ({alerts.filter((a) => !a.isResolved).length})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="system">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card><CardHeader><CardTitle className="text-base">Hardware</CardTitle></CardHeader><CardContent className="space-y-1">
|
||||
<InfoRow label="CPU" value={device.cpuModel || "\u2014"} />
|
||||
<InfoRow label="Cores" value={device.cpuCores || "\u2014"} />
|
||||
<InfoRow label="Memory" value={formatBytes(device.totalMemoryBytes)} />
|
||||
<InfoRow label="Disk" value={formatBytes(device.totalDiskBytes)} />
|
||||
<InfoRow label="Architecture" value={device.architecture} />
|
||||
</CardContent></Card>
|
||||
<Card><CardHeader><CardTitle className="text-base">Software</CardTitle></CardHeader><CardContent className="space-y-1">
|
||||
<InfoRow label="OS" value={`${device.osName} ${device.osVersion}`} />
|
||||
<InfoRow label="Kernel" value={device.kernelVersion} />
|
||||
<InfoRow label="Platform" value={device.platform} />
|
||||
<InfoRow label="Device Type" value={device.deviceType.replace("_", " ")} />
|
||||
</CardContent></Card>
|
||||
<Card><CardHeader><CardTitle className="text-base">Network & Agent</CardTitle></CardHeader><CardContent className="space-y-1">
|
||||
<InfoRow label="IP Address" value={device.ipAddress} />
|
||||
<InfoRow label="Agent Version" value={device.agentVersion} />
|
||||
<InfoRow label="Enrolled" value={new Date(device.enrolledAt).toLocaleString()} />
|
||||
<InfoRow label="Last Heartbeat" value={device.lastHeartbeatAt ? new Date(device.lastHeartbeatAt).toLocaleString() : "Never"} />
|
||||
</CardContent></Card>
|
||||
<Card><CardHeader><CardTitle className="text-base">Live Metrics</CardTitle></CardHeader><CardContent className="space-y-3">
|
||||
{m ? (<>
|
||||
<ProgressCard label="CPU" percent={m.cpuUsagePercent} />
|
||||
<ProgressCard label="Memory" percent={m.memoryUsagePercent} used={formatBytes(m.memoryUsedBytes)} total={formatBytes(m.memoryTotalBytes)} />
|
||||
<ProgressCard label="Disk" percent={m.diskUsagePercent} used={formatBytes(m.diskUsedBytes)} total={formatBytes(m.diskTotalBytes)} />
|
||||
</>) : <p className="text-sm text-muted-foreground">No live metrics available.</p>}
|
||||
</CardContent></Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="metrics">
|
||||
<Card><CardContent className="p-0">
|
||||
{metrics.length === 0 ? <p className="p-6 text-muted-foreground text-sm text-center">No metrics recorded yet.</p> : (
|
||||
<Table><TableHeader><TableRow><TableHead>Time</TableHead><TableHead>CPU</TableHead><TableHead>Memory</TableHead><TableHead>Disk</TableHead><TableHead>Load (1m)</TableHead><TableHead>Uptime</TableHead></TableRow></TableHeader>
|
||||
<TableBody>{metrics.slice(0, 50).map((m, i) => (
|
||||
<TableRow key={i}><TableCell className="text-sm">{new Date(m.reportedAt).toLocaleString()}</TableCell>
|
||||
<TableCell className="text-sm">{m.cpuUsagePercent != null ? `${m.cpuUsagePercent.toFixed(1)}%` : "\u2014"}</TableCell>
|
||||
<TableCell className="text-sm">{m.memoryUsagePercent != null ? `${m.memoryUsagePercent.toFixed(1)}%` : "\u2014"}</TableCell>
|
||||
<TableCell className="text-sm">{m.diskUsagePercent != null ? `${m.diskUsagePercent.toFixed(1)}%` : "\u2014"}</TableCell>
|
||||
<TableCell className="text-sm">{m.loadAverage1m != null ? m.loadAverage1m.toFixed(2) : "\u2014"}</TableCell>
|
||||
<TableCell className="text-sm">{formatUptime(m.uptimeSeconds)}</TableCell>
|
||||
</TableRow>
|
||||
))}</TableBody></Table>
|
||||
)}
|
||||
</CardContent></Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="packages">
|
||||
<Card><CardContent>
|
||||
{!inventory?.packages?.length ? <p className="text-sm text-muted-foreground text-center py-6">No packages reported. Waiting for agent heartbeat.</p> : (
|
||||
<Table><TableHeader><TableRow><TableHead>Name</TableHead><TableHead>Version</TableHead><TableHead>Manager</TableHead></TableRow></TableHeader>
|
||||
<TableBody>{inventory.packages.sort((a, b) => a.name.localeCompare(b.name)).map((p, i) => (
|
||||
<TableRow key={i}><TableCell className="font-mono text-sm">{p.name}</TableCell><TableCell className="text-sm">{p.version}</TableCell><TableCell><Badge variant="outline">{p.manager}</Badge></TableCell></TableRow>
|
||||
))}</TableBody></Table>
|
||||
)}
|
||||
</CardContent></Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="services">
|
||||
<Card><CardContent>
|
||||
{!inventory?.services?.length ? <p className="text-sm text-muted-foreground text-center py-6">No services reported.</p> : (
|
||||
<Table><TableHeader><TableRow><TableHead>Name</TableHead><TableHead>Status</TableHead><TableHead>Enabled</TableHead></TableRow></TableHeader>
|
||||
<TableBody>{inventory.services.sort((a, b) => a.name.localeCompare(b.name)).map((s, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="font-mono text-sm">{s.name}</TableCell>
|
||||
<TableCell><Badge variant={s.status === "running" ? "success" : s.status === "active" ? "success" : "secondary"}>{s.status}</Badge></TableCell>
|
||||
<TableCell className="text-sm">{s.enabled ? "Yes" : "No"}</TableCell>
|
||||
</TableRow>
|
||||
))}</TableBody></Table>
|
||||
)}
|
||||
</CardContent></Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="jobs">
|
||||
<Card><CardContent>
|
||||
{jobs.length === 0 ? <p className="text-sm text-muted-foreground text-center py-6">No jobs executed on this device yet.</p> : (
|
||||
<Table><TableHeader><TableRow><TableHead>Type</TableHead><TableHead>Status</TableHead><TableHead>Created</TableHead><TableHead>Duration</TableHead></TableRow></TableHeader>
|
||||
<TableBody>{jobs.map((j) => (
|
||||
<TableRow key={j.id}>
|
||||
<TableCell className="font-mono text-sm">{j.jobType.replace(/_/g, " ")}</TableCell>
|
||||
<TableCell><Badge variant={jobStatusVariant[j.targetStatus] || "secondary"}>{j.targetStatus}</Badge></TableCell>
|
||||
<TableCell className="text-sm">{new Date(j.createdAt).toLocaleString()}</TableCell>
|
||||
<TableCell className="text-sm">{j.completedAt ? `${((new Date(j.completedAt).getTime() - new Date(j.createdAt).getTime()) / 1000).toFixed(1)}s` : "\u2014"}</TableCell>
|
||||
</TableRow>
|
||||
))}</TableBody></Table>
|
||||
)}
|
||||
</CardContent></Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="alerts">
|
||||
<Card><CardContent>
|
||||
{alerts.length === 0 ? <p className="text-sm text-muted-foreground text-center py-6">No alerts for this device.</p> : (
|
||||
<div className="space-y-2">{alerts.map((a) => (
|
||||
<div key={a.id} className="flex items-start gap-3 p-3 rounded-lg border">
|
||||
<AlertTriangle className={`h-4 w-4 mt-0.5 ${a.severity === "critical" || a.severity === "error" ? "text-destructive" : a.severity === "warning" ? "text-yellow-500" : "text-muted-foreground"}`} />
|
||||
<div className="flex-1"><div className="flex items-center gap-2"><p className="text-sm font-medium">{a.title}</p><Badge variant={severityVariant[a.severity] || "secondary"} className="text-xs">{a.severity}</Badge>{a.isResolved && <Badge variant="outline" className="text-xs">Resolved</Badge>}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{a.message}</p><p className="text-xs text-muted-foreground mt-1">{new Date(a.createdAt).toLocaleString()}</p></div>
|
||||
</div>
|
||||
))}</div>
|
||||
)}
|
||||
</CardContent></Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DeviceActionsDialog
|
||||
device={{ id: device.id, name: device.name, status: device.status }}
|
||||
open={actionsOpen}
|
||||
onOpenChange={setActionsOpen}
|
||||
/>
|
||||
|
||||
<RemoteAccessModal
|
||||
deviceId={device.id}
|
||||
deviceName={device.name}
|
||||
open={remoteOpen}
|
||||
onOpenChange={setRemoteOpen}
|
||||
/>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string | number | null }) {
|
||||
return (
|
||||
<div className="flex justify-between py-2 border-b border-border last:border-0">
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
<span className="text-sm font-medium">{value || "\u2014"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { api } from "@/lib/api";
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Server, Trash2, Loader2, Wrench, Monitor } from "lucide-react";
|
||||
import { DeviceActionsDialog } from "@/components/DeviceActionsDialog";
|
||||
import { RemoteAccessModal } from "@/components/RemoteAccessModal";
|
||||
|
||||
interface Device {
|
||||
id: string;
|
||||
name: string;
|
||||
deviceType: string;
|
||||
osName: string;
|
||||
osVersion: string;
|
||||
architecture: string;
|
||||
status: string;
|
||||
agentVersion: string;
|
||||
lastHeartbeatAt: string | null;
|
||||
enrolledAt: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const variant: "success" | "secondary" | "warning" =
|
||||
status === "online" ? "success" : status === "offline" ? "secondary" : "warning";
|
||||
return <Badge variant={variant}>{status.charAt(0).toUpperCase() + status.slice(1)}</Badge>;
|
||||
}
|
||||
|
||||
export default function DevicesPage() {
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [deleteTarget, setDeleteTarget] = useState<Device | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [actionsTarget, setActionsTarget] = useState<Device | null>(null);
|
||||
const [remoteTarget, setRemoteTarget] = useState<Device | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const params: Record<string, string> = {};
|
||||
if (search) params.search = search;
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
|
||||
api.get<Device[]>("/v1/devices", params).then((res) => {
|
||||
if (res.success && res.data) {
|
||||
setDevices(res.data);
|
||||
setTotal(res.meta?.total || 0);
|
||||
}
|
||||
});
|
||||
}, [search, statusFilter]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.delete(`/v1/devices/${deleteTarget.id}`);
|
||||
setDevices((prev) => prev.filter((d) => d.id !== deleteTarget.id));
|
||||
setTotal((t) => Math.max(0, t - 1));
|
||||
setDeleteTarget(null);
|
||||
} catch {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Devices</h1>
|
||||
<p className="text-muted-foreground">{total} device(s) enrolled</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 mb-6">
|
||||
<Input
|
||||
placeholder="Search devices..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-64"
|
||||
/>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="online">Online</SelectItem>
|
||||
<SelectItem value="offline">Offline</SelectItem>
|
||||
<SelectItem value="warning">Warning</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{devices.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<Server className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground text-lg mb-2">No devices found</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Enroll a device using the enrollment token system in Settings.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Device</TableHead>
|
||||
<TableHead>OS</TableHead>
|
||||
<TableHead>Architecture</TableHead>
|
||||
<TableHead>Agent</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Last Heartbeat</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{devices.map((device) => (
|
||||
<TableRow key={device.id}>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/devices/${device.id}`}
|
||||
className="text-primary hover:underline font-medium text-sm"
|
||||
>
|
||||
{device.name}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{device.deviceType.replace("_", " ")}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{device.osName} {device.osVersion}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{device.architecture}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{device.agentVersion}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={device.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{device.lastHeartbeatAt
|
||||
? new Date(device.lastHeartbeatAt).toLocaleString()
|
||||
: "Never"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-primary"
|
||||
title="Remote desktop"
|
||||
onClick={() => setRemoteTarget(device)}
|
||||
>
|
||||
<Monitor className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-primary"
|
||||
title="Device actions"
|
||||
onClick={() => setActionsTarget(device)}
|
||||
>
|
||||
<Wrench className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
title="Delete device"
|
||||
onClick={() => setDeleteTarget(device)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && !deleting && setDeleteTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete device?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will permanently remove <strong>{deleteTarget?.name}</strong> and all of its
|
||||
historical data (metrics, inventory, jobs, alerts, compliance results). This action
|
||||
cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" disabled={deleting} onClick={() => setDeleteTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" disabled={deleting} onClick={handleDelete}>
|
||||
{deleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
Delete device
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DeviceActionsDialog
|
||||
device={
|
||||
actionsTarget
|
||||
? { id: actionsTarget.id, name: actionsTarget.name, status: actionsTarget.status }
|
||||
: { id: "", name: "", status: "" }
|
||||
}
|
||||
open={!!actionsTarget}
|
||||
onOpenChange={(open) => !open && setActionsTarget(null)}
|
||||
/>
|
||||
|
||||
<RemoteAccessModal
|
||||
deviceId={remoteTarget ? remoteTarget.id : ""}
|
||||
deviceName={remoteTarget ? remoteTarget.name : ""}
|
||||
open={!!remoteTarget}
|
||||
onOpenChange={(open) => !open && setRemoteTarget(null)}
|
||||
/>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import { Card, CardContent, CardHeader, CardTitle } 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Download,
|
||||
Terminal,
|
||||
MonitorSmartphone,
|
||||
Apple,
|
||||
Copy,
|
||||
Check,
|
||||
Loader2,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
|
||||
interface PlatformInfo {
|
||||
os: string;
|
||||
arch: string;
|
||||
label: string;
|
||||
filename: string;
|
||||
available: boolean;
|
||||
size: number;
|
||||
sizeFormatted: string;
|
||||
installHint: string;
|
||||
}
|
||||
|
||||
interface AgentInfo {
|
||||
version: string;
|
||||
platforms: PlatformInfo[];
|
||||
}
|
||||
|
||||
const osIcons: Record<string, typeof Terminal> = {
|
||||
linux: Terminal,
|
||||
windows: MonitorSmartphone,
|
||||
darwin: Apple,
|
||||
};
|
||||
|
||||
function getApiBase() {
|
||||
return process.env.NEXT_PUBLIC_API_URL || "http://localhost:3002/api";
|
||||
}
|
||||
|
||||
function WindowsInstallerCard() {
|
||||
const [serverUrl, setServerUrl] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setServerUrl(getApiBase().replace(/\/api$/, ""));
|
||||
}, []);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
try {
|
||||
const token = api.getToken();
|
||||
const res = await fetch(`${getApiBase()}/v1/installers/generate`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ serverUrl }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let message = `Failed to generate installer (${res.status})`;
|
||||
try {
|
||||
const data = await res.json();
|
||||
if (data?.error?.message) message = data.error.message;
|
||||
} catch {}
|
||||
setError(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = blobUrl;
|
||||
a.download = "forge-agent.msi";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to generate installer"
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="mb-8">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MonitorSmartphone className="h-5 w-5" />
|
||||
Windows MSI Installer
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-url">Server URL</Label>
|
||||
<Input
|
||||
id="server-url"
|
||||
value={serverUrl}
|
||||
onChange={(e) => setServerUrl(e.target.value)}
|
||||
placeholder="http://your-server:3002"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The installer will be pre-configured to point the agent at this
|
||||
Fleet Forge server.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleGenerate} disabled={loading || !serverUrl.trim()}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Generate Installer
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success && !error && (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Installer generated and downloaded as forge-agent.msi.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Alert>
|
||||
<Shield className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
The installer embeds a single-use enrollment token. Each generated
|
||||
MSI can only enroll one device; generate a new installer for every
|
||||
device you deploy.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DownloadsPage() {
|
||||
const [agentInfo, setAgentInfo] = useState<AgentInfo | null>(null);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<AgentInfo>("/v1/downloads/agent").then((res) => {
|
||||
if (res.success && res.data) setAgentInfo(res.data);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDownload = async (filename: string) => {
|
||||
const token = api.getToken();
|
||||
const url = `${getApiBase()}/v1/downloads/agent/${filename}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const blob = await res.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = blobUrl;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string, key: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(key);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
};
|
||||
|
||||
const gpoTemplate = `msiexec /i forge-agent.msi /qn /l*v install.log`;
|
||||
|
||||
const uninstallTemplate = `sc stop FleetForgeAgent
|
||||
msiexec /x forge-agent.msi /qn`;
|
||||
|
||||
const linuxTemplate = `chmod +x forge-agent-linux-amd64
|
||||
sudo ./forge-agent enroll -token <TOKEN> -server http://SERVER:3002
|
||||
sudo ./forge-agent run`;
|
||||
|
||||
const systemdTemplate = `[Unit]
|
||||
Description=Fleet Forge Agent
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/forge-agent run
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target`;
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Agent Downloads</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Download the Fleet Forge agent for your platform
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="windows-msi" className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="windows-msi">Windows MSI Installer</TabsTrigger>
|
||||
<TabsTrigger value="binaries">All Binaries</TabsTrigger>
|
||||
<TabsTrigger value="deployment-guide">Deployment Guide</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="windows-msi">
|
||||
<WindowsInstallerCard />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="binaries">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Download className="h-5 w-5" />
|
||||
Forge Agent {agentInfo?.version}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{agentInfo?.platforms.map((platform) => {
|
||||
const Icon = osIcons[platform.os] || Download;
|
||||
return (
|
||||
<Card key={`${platform.os}-${platform.arch}`}>
|
||||
<CardContent className="p-4 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">
|
||||
{platform.label}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{platform.sizeFormatted}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={!platform.available}
|
||||
onClick={() => handleDownload(platform.filename)}
|
||||
>
|
||||
<Download className="h-3 w-3 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!agentInfo && (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
Loading agent binaries...
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="deployment-guide">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Windows GPO Deployment
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Deploy the MSI silently across your fleet via Group Policy:
|
||||
</p>
|
||||
<div className="relative">
|
||||
<pre className="bg-muted rounded-md p-4 text-xs overflow-x-auto font-mono whitespace-pre">
|
||||
{gpoTemplate}
|
||||
</pre>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2"
|
||||
onClick={() => copyToClipboard(gpoTemplate, "gpo")}
|
||||
>
|
||||
{copied === "gpo" ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Uninstall</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Stop the service and remove the agent from a Windows device:
|
||||
</p>
|
||||
<div className="relative">
|
||||
<pre className="bg-muted rounded-md p-4 text-xs overflow-x-auto font-mono whitespace-pre">
|
||||
{uninstallTemplate}
|
||||
</pre>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2"
|
||||
onClick={() => copyToClipboard(uninstallTemplate, "uninstall")}
|
||||
>
|
||||
{copied === "uninstall" ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Linux Quick Start</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Download the Linux binary, then enroll and run it:
|
||||
</p>
|
||||
<div className="relative">
|
||||
<pre className="bg-muted rounded-md p-4 text-xs overflow-x-auto font-mono whitespace-pre">
|
||||
{linuxTemplate}
|
||||
</pre>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2"
|
||||
onClick={() => copyToClipboard(linuxTemplate, "linux")}
|
||||
>
|
||||
{copied === "linux" ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Linux Systemd Service
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
For permanent installation on Linux, create a systemd unit
|
||||
file:
|
||||
</p>
|
||||
<div className="relative">
|
||||
<pre className="bg-muted rounded-md p-4 text-xs overflow-x-auto font-mono whitespace-pre">
|
||||
{systemdTemplate}
|
||||
</pre>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2"
|
||||
onClick={() => copyToClipboard(systemdTemplate, "systemd")}
|
||||
>
|
||||
{copied === "systemd" ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 210 40% 98%;
|
||||
--foreground: 222 47% 11%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222 47% 11%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222 47% 11%;
|
||||
|
||||
--primary: 222 47% 11%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
|
||||
--secondary: 210 40% 96%;
|
||||
--secondary-foreground: 222 47% 11%;
|
||||
|
||||
--muted: 210 40% 96%;
|
||||
--muted-foreground: 215 16% 47%;
|
||||
|
||||
--accent: 210 40% 96%;
|
||||
--accent-foreground: 222 47% 11%;
|
||||
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 214 32% 91%;
|
||||
--input: 214 32% 91%;
|
||||
--ring: 222 47% 11%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
|
||||
--sidebar: 222 47% 11%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-accent: 217 33% 17%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217 33% 17%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222 47% 6%;
|
||||
--foreground: 210 40% 98%;
|
||||
|
||||
--card: 222 47% 8%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
|
||||
--popover: 222 47% 8%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222 47% 11%;
|
||||
|
||||
--secondary: 217 33% 14%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
--muted: 217 33% 14%;
|
||||
--muted-foreground: 215 20% 55%;
|
||||
|
||||
--accent: 217 33% 14%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
--destructive: 0 63% 31%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 217 33% 14%;
|
||||
--input: 217 33% 14%;
|
||||
--ring: 212 27% 84%;
|
||||
|
||||
--sidebar: 222 47% 4%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-accent: 217 33% 12%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217 33% 12%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"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 { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Plus, Users } from "lucide-react";
|
||||
|
||||
interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function GroupsPage() {
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newDesc, setNewDesc] = useState("");
|
||||
|
||||
const fetchGroups = () => {
|
||||
api.get<Group[]>("/v1/groups").then((res) => {
|
||||
if (res.success && res.data) setGroups(res.data);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => { fetchGroups(); }, []);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await api.post("/v1/groups", { name: newName, description: newDesc });
|
||||
setNewName("");
|
||||
setNewDesc("");
|
||||
setShowCreate(false);
|
||||
fetchGroups();
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Groups</h1>
|
||||
<p className="text-muted-foreground">Organize devices into groups</p>
|
||||
</div>
|
||||
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Group
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleCreate}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Group</DialogTitle>
|
||||
<DialogDescription>Add a new device group to organize your fleet.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Name</Label>
|
||||
<Input value={newName} onChange={(e) => setNewName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Input value={newDesc} onChange={(e) => setNewDesc(e.target.value)} placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="button" variant="outline" onClick={() => setShowCreate(false)}>Cancel</Button>
|
||||
<Button type="submit">Create</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{groups.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<Users className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">No groups created yet.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groups.map((g) => (
|
||||
<TableRow key={g.id}>
|
||||
<TableCell className="font-medium">{g.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{g.description || "\u2014"}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{new Date(g.createdAt).toLocaleDateString()}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Zap, Ban, Trash2, Eye, RefreshCw } from "lucide-react";
|
||||
|
||||
interface Target {
|
||||
id: string;
|
||||
jobId: string;
|
||||
deviceId: string;
|
||||
deviceName: string | null;
|
||||
status: string;
|
||||
result: Record<string, unknown> | null;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
}
|
||||
|
||||
interface Job {
|
||||
id: string;
|
||||
jobType: string;
|
||||
status: string;
|
||||
parameters: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
completedAt: string | null;
|
||||
targets?: Target[];
|
||||
}
|
||||
|
||||
const statusVariant: Record<string, "info" | "warning" | "success" | "destructive" | "secondary" | "outline"> = {
|
||||
queued: "info",
|
||||
dispatched: "info",
|
||||
running: "warning",
|
||||
succeeded: "success",
|
||||
failed: "destructive",
|
||||
cancelled: "secondary",
|
||||
expired: "outline",
|
||||
};
|
||||
|
||||
const ACTIVE_STATUSES = new Set(["queued", "dispatched", "running"]);
|
||||
const POLL_INTERVAL = 4000;
|
||||
|
||||
async function fetchJobs(): Promise<Job[]> {
|
||||
const res = await api.get<Job[]>("/v1/jobs");
|
||||
return res.success && res.data ? res.data : [];
|
||||
}
|
||||
|
||||
async function fetchJobDetail(id: string): Promise<Job> {
|
||||
const res = await api.get<Job>(`/v1/jobs/${id}`);
|
||||
if (res.success && res.data) return res.data;
|
||||
throw new Error(res.error?.message || "Failed to load job");
|
||||
}
|
||||
|
||||
export default function JobsPage() {
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const list = await fetchJobs();
|
||||
setJobs(list);
|
||||
setTotal(list.length);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const id = setInterval(load, POLL_INTERVAL);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!detailOpen || !selectedJob) return;
|
||||
const id = setInterval(async () => {
|
||||
try {
|
||||
const detail = await fetchJobDetail(selectedJob.id);
|
||||
setSelectedJob(detail);
|
||||
setJobs((prev) => prev.map((j) => (j.id === detail.id ? detail : j)));
|
||||
} catch {
|
||||
/* ignore transient polling errors */
|
||||
}
|
||||
}, POLL_INTERVAL);
|
||||
return () => clearInterval(id);
|
||||
}, [detailOpen, selectedJob]);
|
||||
|
||||
const openDetail = async (job: Job) => {
|
||||
setSelectedJob(job);
|
||||
setDetailOpen(true);
|
||||
try {
|
||||
const detail = await fetchJobDetail(job.id);
|
||||
setSelectedJob(detail);
|
||||
} catch {
|
||||
/* keep cached summary */
|
||||
}
|
||||
};
|
||||
|
||||
const cancelJob = async (id: string) => {
|
||||
setBusyId(id);
|
||||
await api.patch(`/v1/jobs/${id}/cancel`);
|
||||
setBusyId(null);
|
||||
await load();
|
||||
};
|
||||
|
||||
const deleteJob = async (id: string) => {
|
||||
setBusyId(id);
|
||||
await api.delete(`/v1/jobs/${id}`);
|
||||
setBusyId(null);
|
||||
if (selectedJob?.id === id) setDetailOpen(false);
|
||||
await load();
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="mb-8 flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Jobs</h1>
|
||||
<p className="text-muted-foreground">{total} job(s) total</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={load}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" /> Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{jobs.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<Zap className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">No jobs have been created yet.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Completed</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{jobs.map((job) => (
|
||||
<TableRow key={job.id}>
|
||||
<TableCell className="font-medium text-sm">
|
||||
{job.jobType.replace(/_/g, " ")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant[job.status] || "secondary"}>
|
||||
{job.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{new Date(job.createdAt).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{job.completedAt ? new Date(job.completedAt).toLocaleString() : "\u2014"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => openDetail(job)}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
{ACTIVE_STATUSES.has(job.status) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busyId === job.id}
|
||||
onClick={() => cancelJob(job.id)}
|
||||
>
|
||||
<Ban className="h-4 w-4 text-amber-500" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busyId === job.id}
|
||||
onClick={() => deleteJob(job.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<span>{selectedJob?.jobType.replace(/_/g, " ")}</span>
|
||||
{selectedJob && (
|
||||
<Badge variant={statusVariant[selectedJob.status] || "secondary"}>
|
||||
{selectedJob.status}
|
||||
</Badge>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedJob && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end gap-2">
|
||||
{ACTIVE_STATUSES.has(selectedJob.status) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busyId === selectedJob.id}
|
||||
onClick={() => {
|
||||
cancelJob(selectedJob.id);
|
||||
setSelectedJob({ ...selectedJob, status: "cancelled" });
|
||||
}}
|
||||
>
|
||||
<Ban className="h-4 w-4 mr-2 text-amber-500" /> Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={busyId === selectedJob.id}
|
||||
onClick={() => deleteJob(selectedJob.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" /> Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">Job ID</div>
|
||||
<div className="font-mono">{selectedJob.id}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">Created</div>
|
||||
<div>{new Date(selectedJob.createdAt).toLocaleString()}</div>
|
||||
</div>
|
||||
{selectedJob.completedAt && (
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">Completed</div>
|
||||
<div>{new Date(selectedJob.completedAt).toLocaleString()}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs mb-2">Parameters</div>
|
||||
<pre className="bg-muted rounded p-3 text-xs max-h-40 overflow-auto font-mono">
|
||||
{JSON.stringify(selectedJob.parameters, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs mb-2">
|
||||
Targets ({selectedJob.targets?.length || 0})
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Device</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Started</TableHead>
|
||||
<TableHead>Result</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(selectedJob.targets || []).map((t) => (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell className="text-sm">{t.deviceName || t.deviceId}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant[t.status] || "secondary"}>
|
||||
{t.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{t.startedAt ? new Date(t.startedAt).toLocaleTimeString() : "\u2014"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{t.status === "failed" &&
|
||||
(t.result as { error?: string } | null)?.error ? (
|
||||
<span className="text-destructive break-all">
|
||||
{(t.result as { error?: string }).error}
|
||||
</span>
|
||||
) : t.status === "succeeded" ? (
|
||||
<span className="text-muted-foreground">
|
||||
{(t.result as { output?: string } | null)?.output
|
||||
?.slice(0, 120) || "OK"}
|
||||
</span>
|
||||
) : t.status === "running" ? (
|
||||
<span className="text-warning">Installing\u2026</span>
|
||||
) : (
|
||||
"\u2014"
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Fleet Forge",
|
||||
description: "Unified Endpoint & Infrastructure Management Platform",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<body className="min-h-screen bg-background antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
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, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Hexagon, Loader2 } from "lucide-react";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState("admin@fleetforge.local");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await api.post<{ token: string; user: { id: string; email: string; displayName: string; role: string } }>("/v1/auth/login", { email, password });
|
||||
if (res.success && res.data) {
|
||||
api.setToken(res.data.token);
|
||||
router.push("/dashboard");
|
||||
} else {
|
||||
setError(res.error?.message || "Login failed");
|
||||
}
|
||||
} catch {
|
||||
setError("Connection failed. Is the API running?");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-sidebar">
|
||||
<div className="w-full max-w-md px-4">
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
<Hexagon className="h-10 w-10 text-primary-foreground" />
|
||||
<h1 className="text-3xl font-bold text-primary-foreground tracking-tight">
|
||||
Fleet Forge
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-primary-foreground/50 text-sm">
|
||||
Endpoint & Infrastructure Management
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sign in to your account</CardTitle>
|
||||
<CardDescription>Enter your credentials to continue</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="admin@fleetforge.local"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{loading ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-center text-primary-foreground/30 text-xs mt-6">
|
||||
Fleet Forge v0.1.0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/login");
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Shield } from "lucide-react";
|
||||
|
||||
interface Policy {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
rules: Array<{ type: string; condition: string; value: string | number | boolean }>;
|
||||
isEnabled: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function PoliciesPage() {
|
||||
const [policies, setPolicies] = useState<Policy[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Policy[]>("/v1/policies").then((res) => {
|
||||
if (res.success && res.data) setPolicies(res.data);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Policies</h1>
|
||||
<p className="text-muted-foreground">Compliance and configuration policies</p>
|
||||
</div>
|
||||
|
||||
{policies.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<Shield className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">No policies configured yet.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{policies.map((policy) => (
|
||||
<Card key={policy.id}>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-lg">{policy.name}</CardTitle>
|
||||
<Badge variant={policy.isEnabled ? "success" : "secondary"}>
|
||||
{policy.isEnabled ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{policy.description && (
|
||||
<p className="text-sm text-muted-foreground mb-3">{policy.description}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{policy.rules.length} rule(s) · Created{" "}
|
||||
{new Date(policy.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Plus, KeyRound, Copy, Check } from "lucide-react";
|
||||
|
||||
interface EnrollToken {
|
||||
id: string;
|
||||
expiresAt: string;
|
||||
maxUses: number | null;
|
||||
useCount: number;
|
||||
isRevoked: boolean;
|
||||
label: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function EnrollmentPage() {
|
||||
const [tokens, setTokens] = useState<EnrollToken[]>([]);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newToken, setNewToken] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
label: "",
|
||||
expiresInHours: 24,
|
||||
maxUses: null as number | null,
|
||||
});
|
||||
|
||||
const fetchTokens = () => {
|
||||
api.get<EnrollToken[]>("/v1/enrollment-tokens").then((res) => {
|
||||
if (res.success && res.data) setTokens(res.data);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => { fetchTokens(); }, []);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const res = await api.post<{ id: string; token: string; expiresAt: string; label: string | null }>(
|
||||
"/v1/enrollment-tokens",
|
||||
form
|
||||
);
|
||||
if (res.success && res.data) {
|
||||
setNewToken(res.data.token);
|
||||
}
|
||||
setShowCreate(false);
|
||||
fetchTokens();
|
||||
};
|
||||
|
||||
const revokeToken = async (id: string) => {
|
||||
await api.delete(`/v1/enrollment-tokens/${id}`);
|
||||
fetchTokens();
|
||||
};
|
||||
|
||||
const copyToken = async () => {
|
||||
if (newToken) {
|
||||
await navigator.clipboard.writeText(newToken);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Enrollment Tokens</h1>
|
||||
<p className="text-muted-foreground">Create tokens for device enrollment</p>
|
||||
</div>
|
||||
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Token
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleCreate}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Enrollment Token</DialogTitle>
|
||||
<DialogDescription>Generate a new token for device enrollment.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Label</Label>
|
||||
<Input value={form.label} onChange={(e) => setForm({ ...form, label: e.target.value })} placeholder="Optional" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Expires in hours</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={720}
|
||||
value={form.expiresInHours}
|
||||
onChange={(e) => setForm({ ...form, expiresInHours: parseInt(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Max uses (empty = unlimited)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.maxUses ?? ""}
|
||||
onChange={(e) => setForm({ ...form, maxUses: e.target.value ? parseInt(e.target.value) : null })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="button" variant="outline" onClick={() => setShowCreate(false)}>Cancel</Button>
|
||||
<Button type="submit">Generate Token</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{newToken && (
|
||||
<Alert className="mb-6 border-green-200 bg-green-50">
|
||||
<KeyRound className="h-4 w-4 text-green-600" />
|
||||
<AlertDescription>
|
||||
<p className="text-sm font-medium text-green-800 mb-2">
|
||||
Enrollment token created. Copy it now — it won't be shown again.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-green-100 p-3 rounded text-sm text-green-900 break-all font-mono">
|
||||
{newToken}
|
||||
</code>
|
||||
<Button variant="outline" size="icon" onClick={copyToken}>
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setNewToken(null)}
|
||||
className="mt-2 text-xs text-green-700 hover:text-green-900"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Label</TableHead>
|
||||
<TableHead>Uses</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tokens.map((token) => (
|
||||
<TableRow key={token.id}>
|
||||
<TableCell className="text-sm">{token.label || "\u2014"}</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{token.useCount}
|
||||
{token.maxUses ? ` / ${token.maxUses}` : ""}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{new Date(token.expiresAt).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
token.isRevoked
|
||||
? "destructive"
|
||||
: new Date(token.expiresAt) < new Date()
|
||||
? "outline"
|
||||
: "success"
|
||||
}
|
||||
>
|
||||
{token.isRevoked ? "Revoked" : new Date(token.expiresAt) < new Date() ? "Expired" : "Active"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{new Date(token.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{!token.isRevoked && (
|
||||
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={() => revokeToken(token.id)}>
|
||||
Revoke
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{tokens.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground py-12">
|
||||
No enrollment tokens created yet.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: string;
|
||||
isActive: boolean;
|
||||
lastLoginAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
email: "",
|
||||
password: "",
|
||||
displayName: "",
|
||||
role: "operator",
|
||||
});
|
||||
|
||||
const fetchUsers = () => {
|
||||
api.get<User[]>("/v1/users").then((res) => {
|
||||
if (res.success && res.data) setUsers(res.data);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => { fetchUsers(); }, []);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await api.post("/v1/users", form);
|
||||
setForm({ email: "", password: "", displayName: "", role: "operator" });
|
||||
setShowCreate(false);
|
||||
fetchUsers();
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Users</h1>
|
||||
<p className="text-muted-foreground">Manage platform users</p>
|
||||
</div>
|
||||
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add User
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleCreate}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create User</DialogTitle>
|
||||
<DialogDescription>Add a new user to the platform.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Display Name</Label>
|
||||
<Input value={form.displayName} onChange={(e) => setForm({ ...form, displayName: e.target.value })} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Email</Label>
|
||||
<Input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Password</Label>
|
||||
<Input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required minLength={8} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Role</Label>
|
||||
<Select value={form.role} onValueChange={(v) => setForm({ ...form, role: v })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="operator">Operator</SelectItem>
|
||||
<SelectItem value="read_only">Read Only</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="mt-6">
|
||||
<Button type="button" variant="outline" onClick={() => setShowCreate(false)}>Cancel</Button>
|
||||
<Button type="submit">Create User</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Last Login</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<p className="text-sm font-medium">{user.displayName}</p>
|
||||
<p className="text-xs text-muted-foreground">{user.email}</p>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
<Badge variant="outline">{user.role}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={user.isActive ? "success" : "destructive"}>
|
||||
{user.isActive ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : "Never"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
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 { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Package, Search, Loader2, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
interface CatalogPackage {
|
||||
id: string;
|
||||
name: string;
|
||||
publisher?: string;
|
||||
latestVersion: string;
|
||||
versions?: string[];
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
iconUrl?: string;
|
||||
homepage?: string;
|
||||
license?: string;
|
||||
downloadCount?: number;
|
||||
}
|
||||
|
||||
interface CatalogResponse {
|
||||
packages: CatalogPackage[];
|
||||
total?: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
interface Device {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
winget: "Windows Package Manager (winget)",
|
||||
chocolatey: "Chocolatey",
|
||||
};
|
||||
|
||||
export function AppCatalogTab({ source }: { source: "winget" | "chocolatey" }) {
|
||||
const [packages, setPackages] = useState<CatalogPackage[]>([]);
|
||||
const [total, setTotal] = useState<number | undefined>(undefined);
|
||||
const [page, setPage] = useState(1);
|
||||
const [limit] = useState(25);
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
const [installPkg, setInstallPkg] = useState<CatalogPackage | null>(null);
|
||||
const [selectedDevice, setSelectedDevice] = useState("");
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [installError, setInstallError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
setDebouncedSearch(search);
|
||||
setPage(1);
|
||||
}, 400);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const params: Record<string, string> = { page: String(page), limit: String(limit) };
|
||||
if (debouncedSearch.trim()) params.search = debouncedSearch.trim();
|
||||
api
|
||||
.get<CatalogResponse>(`/v1/appcatalog/${source}`, params)
|
||||
.then((r) => {
|
||||
if (r.success && r.data) {
|
||||
setPackages(r.data.packages || []);
|
||||
setTotal(r.data.total);
|
||||
} else {
|
||||
setError(r.error?.message || "Failed to load catalog");
|
||||
setPackages([]);
|
||||
}
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Failed to load catalog");
|
||||
setLoading(false);
|
||||
});
|
||||
}, [source, page, limit, debouncedSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Device[]>("/v1/devices?limit=100").then((r) => setDevices(r.data || []));
|
||||
}, []);
|
||||
|
||||
const hasMore = packages.length >= limit;
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!installPkg || !selectedDevice) return;
|
||||
setInstalling(true);
|
||||
setInstallError("");
|
||||
const parameters: Record<string, unknown> = {
|
||||
source,
|
||||
package_id: installPkg.id,
|
||||
package_version: installPkg.latestVersion,
|
||||
};
|
||||
const r = await api.post("/v1/jobs", {
|
||||
jobType: "install_registry_package",
|
||||
deviceIds: [selectedDevice],
|
||||
parameters,
|
||||
});
|
||||
setInstalling(false);
|
||||
if (!r.success) {
|
||||
setInstallError(r.error?.message || "Failed to create installation job");
|
||||
return;
|
||||
}
|
||||
setInstallPkg(null);
|
||||
setSelectedDevice("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={`Search ${source} packages...`}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{total !== undefined ? `${total} packages` : `${packages.length} packages`}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
|
||||
|
||||
{loading ? (
|
||||
<Card><CardContent className="p-12 text-center"><Loader2 className="h-8 w-8 animate-spin mx-auto" /></CardContent></Card>
|
||||
) : packages.length === 0 ? (
|
||||
<Card><CardContent className="p-12 text-center"><Package className="h-12 w-12 text-muted-foreground mx-auto mb-4" /><p className="text-muted-foreground">No packages found{debouncedSearch ? ` for "${debouncedSearch}"` : ""}.</p></CardContent></Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Package</TableHead>
|
||||
<TableHead>Version</TableHead>
|
||||
<TableHead>Publisher</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
{source === "chocolatey" && <TableHead>Downloads</TableHead>}
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{packages.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{p.iconUrl ? (
|
||||
<img src={p.iconUrl} alt="" className="h-6 w-6 rounded object-contain" />
|
||||
) : (
|
||||
<Package className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium text-sm">{p.name}</div>
|
||||
<div className="text-xs font-mono text-muted-foreground">{p.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-mono">{p.latestVersion}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{p.publisher || "—"}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground max-w-md">
|
||||
<span className="line-clamp-2">{p.description || "—"}</span>
|
||||
{p.tags && p.tags.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{p.tags.slice(0, 4).map((t) => (
|
||||
<Badge key={t} variant="outline" className="text-[10px]">{t}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
{source === "chocolatey" && (
|
||||
<TableCell className="text-sm">{p.downloadCount?.toLocaleString() ?? "—"}</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<Button size="sm" variant="outline" onClick={() => setInstallPkg(p)}>Install</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{total !== undefined ? `Page ${page} of ${Math.max(1, Math.ceil((total || 0) / limit))}` : `Page ${page}`}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1 || loading} onClick={() => setPage((p) => p - 1)}>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />Previous
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={!hasMore || loading} onClick={() => setPage((p) => p + 1)}>
|
||||
Next<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={!!installPkg} onOpenChange={() => setInstallPkg(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Install {installPkg?.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Deploy {installPkg?.name} ({installPkg?.id}) v{installPkg?.latestVersion} to a device via {SOURCE_LABELS[source]}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 mt-4">
|
||||
{installError && <Alert variant="destructive"><AlertDescription>{installError}</AlertDescription></Alert>}
|
||||
<div className="space-y-2">
|
||||
<Label>Target Device</Label>
|
||||
<Select value={selectedDevice} onValueChange={setSelectedDevice}>
|
||||
<SelectTrigger><SelectValue placeholder="Select a device" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{devices.map((d) => (
|
||||
<SelectItem key={d.id} value={d.id}>{d.name} ({d.status})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="outline" onClick={() => setInstallPkg(null)}>Cancel</Button>
|
||||
<Button disabled={!selectedDevice || installing} onClick={handleInstall}>
|
||||
{installing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}Install
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef } 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Package, Upload, Trash2, Search, Loader2 } from "lucide-react";
|
||||
import { AppCatalogTab } from "./AppCatalogTab";
|
||||
|
||||
interface SoftwareEntry { name: string; versions: PackageVersion[]; totalDevices: number; }
|
||||
interface PackageVersion { version: string; manager: string; deviceCount: number; devices: Array<{ deviceId: string; deviceName: string }>; }
|
||||
interface RepoPackage { id: string; name: string; description: string | null; version: string; packageType: string; platform: string; architecture: string; filename: string; fileSize: number; tags: string[]; createdAt: string; }
|
||||
interface Device { id: string; name: string; status: string; platform: string; }
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes) return "0 B";
|
||||
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];
|
||||
}
|
||||
|
||||
export default function SoftwarePage() {
|
||||
const [tab, setTab] = useState("inventory");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Software</h1>
|
||||
<p className="text-muted-foreground">Manage software across your fleet</p>
|
||||
</div>
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="inventory">Fleet Inventory</TabsTrigger>
|
||||
<TabsTrigger value="repository">Package Repository</TabsTrigger>
|
||||
<TabsTrigger value="winget">winget</TabsTrigger>
|
||||
<TabsTrigger value="chocolatey">Chocolatey</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="inventory"><InventoryTab search={search} setSearch={setSearch} /></TabsContent>
|
||||
<TabsContent value="repository"><RepositoryTab /></TabsContent>
|
||||
<TabsContent value="winget"><AppCatalogTab source="winget" /></TabsContent>
|
||||
<TabsContent value="chocolatey"><AppCatalogTab source="chocolatey" /></TabsContent>
|
||||
</Tabs>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function InventoryTab({ search, setSearch }: { search: string; setSearch: (v: string) => void }) {
|
||||
const [packages, setPackages] = useState<SoftwareEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => { setLoading(true); api.get<SoftwareEntry[]>("/v1/software/inventory").then((r) => { setPackages(r.data || []); setLoading(false); }); }, []);
|
||||
const filtered = packages.filter((p) => !search || p.name.toLowerCase().includes(search.toLowerCase()));
|
||||
return (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-sm"><Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /><Input placeholder="Search packages..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-9" /></div>
|
||||
<Badge variant="secondary">{packages.length} packages</Badge>
|
||||
</div>
|
||||
{loading ? <Card><CardContent className="p-12 text-center"><Loader2 className="h-8 w-8 animate-spin mx-auto" /></CardContent></Card>
|
||||
: filtered.length === 0 ? <Card><CardContent className="p-12 text-center"><Package className="h-12 w-12 text-muted-foreground mx-auto mb-4" /><p className="text-muted-foreground">No packages found.</p></CardContent></Card>
|
||||
: <div className="space-y-3">{filtered.map((pkg) => (
|
||||
<Card key={pkg.name}><CardContent className="p-4">
|
||||
<div className="flex items-center gap-3 mb-2"><Package className="h-5 w-5 text-muted-foreground" /><span className="font-mono font-medium text-sm">{pkg.name}</span><Badge variant="outline">{pkg.totalDevices} device{pkg.totalDevices !== 1 ? "s" : ""}</Badge></div>
|
||||
<div className="space-y-1 ml-8">{pkg.versions.map((v, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs"><Badge variant="secondary" className="font-mono">{v.version}</Badge><Badge variant="outline" className="text-xs">{v.manager}</Badge><span className="text-muted-foreground">on {v.devices.map((d) => d.deviceName).join(", ")}</span></div>
|
||||
))}</div>
|
||||
</CardContent></Card>
|
||||
))}</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RepositoryTab() {
|
||||
const [packages, setPackages] = useState<RepoPackage[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState("");
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
const [installPkg, setInstallPkg] = useState<RepoPackage | null>(null);
|
||||
const [selectedDevice, setSelectedDevice] = useState("");
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [form, setForm] = useState({ name: "", version: "1.0.0", description: "", platform: "windows", architecture: "amd64", tags: "" });
|
||||
|
||||
const load = () => { setLoading(true); api.get<RepoPackage[]>("/v1/software/repository").then((r) => { setPackages(r.data || []); setTotal(r.meta?.total || 0); setLoading(false); }); };
|
||||
const loadDevices = () => { api.get<Device[]>("/v1/devices?limit=100").then((r) => { setDevices(r.data || []); }); };
|
||||
useEffect(() => { load(); loadDevices(); }, []);
|
||||
|
||||
const handleUpload = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setUploadError("");
|
||||
const file = fileRef.current?.files?.[0];
|
||||
if (!file) { setUploadError("Please select a file."); return; }
|
||||
setUploading(true);
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("name", form.name || file.name);
|
||||
fd.append("version", form.version);
|
||||
fd.append("description", form.description);
|
||||
fd.append("platform", form.platform);
|
||||
fd.append("architecture", form.architecture);
|
||||
fd.append("tags", JSON.stringify(form.tags.split(",").map((t) => t.trim()).filter(Boolean)));
|
||||
try {
|
||||
const token = api.getToken();
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000/api"}/v1/software/repository`, { method: "POST", headers: token ? { Authorization: `Bearer ${token}` } : {}, body: fd });
|
||||
const data = await res.json();
|
||||
if (data.success) { setShowUpload(false); setForm({ name: "", version: "1.0.0", description: "", platform: "windows", architecture: "amd64", tags: "" }); if (fileRef.current) fileRef.current.value = ""; load(); }
|
||||
else { setUploadError(data.error?.message || "Upload failed"); }
|
||||
} catch (err) { setUploadError("Upload failed"); }
|
||||
setUploading(false);
|
||||
};
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!installPkg || !selectedDevice) return;
|
||||
setInstalling(true);
|
||||
await api.post("/v1/jobs", { jobType: "download_and_install", deviceIds: [selectedDevice], parameters: { package_id: installPkg.id } });
|
||||
setInstalling(false); setInstallPkg(null); setSelectedDevice("");
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("Delete this package?")) return;
|
||||
await api.delete(`/v1/software/repository/${id}`);
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="secondary">{total} packages</Badge>
|
||||
<Dialog open={showUpload} onOpenChange={setShowUpload}>
|
||||
<DialogTrigger asChild><Button><Upload className="mr-2 h-4 w-4" />Upload Package</Button></DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<form onSubmit={handleUpload}>
|
||||
<DialogHeader><DialogTitle>Upload Software Package</DialogTitle><DialogDescription>Upload MSI, EXE, DEB, RPM, DMG, or PKG files.</DialogDescription></DialogHeader>
|
||||
<div className="space-y-4 mt-4">
|
||||
{uploadError && <Alert variant="destructive"><AlertDescription>{uploadError}</AlertDescription></Alert>}
|
||||
<div className="space-y-2"><Label>File *</Label><input ref={fileRef} type="file" accept=".msi,.exe,.msix,.deb,.rpm,.dmg,.pkg,.appx,.tar.gz" className="block w-full text-sm file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:bg-primary file:text-primary-foreground" required /></div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2"><Label>Name</Label><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="Auto-detect from filename" /></div>
|
||||
<div className="space-y-2"><Label>Version</Label><Input value={form.version} onChange={(e) => setForm({ ...form, version: e.target.value })} /></div>
|
||||
</div>
|
||||
<div className="space-y-2"><Label>Description</Label><Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2"><Label>Platform</Label><Select value={form.platform} onValueChange={(v) => setForm({ ...form, platform: v })}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="windows">Windows</SelectItem><SelectItem value="linux">Linux</SelectItem><SelectItem value="macos">macOS</SelectItem></SelectContent></Select></div>
|
||||
<div className="space-y-2"><Label>Architecture</Label><Select value={form.architecture} onValueChange={(v) => setForm({ ...form, architecture: v })}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="amd64">amd64</SelectItem><SelectItem value="arm64">arm64</SelectItem><SelectItem value="all">all</SelectItem></SelectContent></Select></div>
|
||||
</div>
|
||||
<div className="space-y-2"><Label>Tags (comma-separated)</Label><Input value={form.tags} onChange={(e) => setForm({ ...form, tags: e.target.value })} placeholder="e.g. utility, development" /></div>
|
||||
</div>
|
||||
<DialogFooter className="mt-6"><Button type="button" variant="outline" onClick={() => setShowUpload(false)}>Cancel</Button><Button type="submit" disabled={uploading}>{uploading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}Upload</Button></DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Dialog open={!!installPkg} onOpenChange={() => setInstallPkg(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Install Package</DialogTitle><DialogDescription>Deploy {installPkg?.name} v{installPkg?.version} to a device</DialogDescription></DialogHeader>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="space-y-2"><Label>Target Device</Label><Select value={selectedDevice} onValueChange={setSelectedDevice}><SelectTrigger><SelectValue placeholder="Select a device" /></SelectTrigger><SelectContent>{devices.map((d) => <SelectItem key={d.id} value={d.id}>{d.name} ({d.status})</SelectItem>)}</SelectContent></Select></div>
|
||||
</div>
|
||||
<DialogFooter className="mt-4"><Button variant="outline" onClick={() => setInstallPkg(null)}>Cancel</Button><Button disabled={!selectedDevice || installing} onClick={handleInstall}>{installing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}Install</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{loading ? <Card><CardContent className="p-12 text-center"><Loader2 className="h-8 w-8 animate-spin mx-auto" /></CardContent></Card>
|
||||
: packages.length === 0 ? <Card><CardContent className="p-12 text-center"><Package className="h-12 w-12 text-muted-foreground mx-auto mb-4" /><p className="text-muted-foreground">No packages uploaded yet.</p></CardContent></Card>
|
||||
: <Card><Table><TableHeader><TableRow><TableHead>Name</TableHead><TableHead>Version</TableHead><TableHead>Platform</TableHead><TableHead>Type</TableHead><TableHead>Size</TableHead><TableHead>Actions</TableHead></TableRow></TableHeader>
|
||||
<TableBody>{packages.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell><div className="font-medium text-sm">{p.name}</div>{p.description && <p className="text-xs text-muted-foreground">{p.description}</p>}</TableCell>
|
||||
<TableCell className="text-sm font-mono">{p.version}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="capitalize">{p.platform}</Badge></TableCell>
|
||||
<TableCell className="text-sm font-mono">{p.packageType}</TableCell>
|
||||
<TableCell className="text-sm">{formatBytes(p.fileSize)}</TableCell>
|
||||
<TableCell><div className="flex gap-1"><Button size="sm" variant="outline" onClick={() => setInstallPkg(p)}>Install</Button><Button size="sm" variant="ghost" className="text-destructive" onClick={() => handleDelete(p.id)}><Trash2 className="h-3 w-3" /></Button></div></TableCell>
|
||||
</TableRow>
|
||||
))}</TableBody></Table></Card>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { ClipboardCheck, Check, X } from "lucide-react";
|
||||
|
||||
interface UpdateRequest {
|
||||
id: string;
|
||||
deviceId: string;
|
||||
deviceName: string | null;
|
||||
packageName: string;
|
||||
packageManager: string;
|
||||
currentVersion: string | null;
|
||||
targetVersion: string;
|
||||
requestedByName: string | null;
|
||||
status: string;
|
||||
rejectionReason: string | null;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const statusVariant: Record<string, "info" | "warning" | "success" | "destructive" | "secondary" | "outline"> = {
|
||||
pending: "warning",
|
||||
approved: "success",
|
||||
rejected: "destructive",
|
||||
deployed: "success",
|
||||
cancelled: "secondary",
|
||||
};
|
||||
|
||||
export default function UpdateApprovalsPage() {
|
||||
const [requests, setRequests] = useState<UpdateRequest[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
const loadRequests = () => {
|
||||
api.get<UpdateRequest[]>("/v1/update-requests").then((res) => {
|
||||
if (res.success && res.data) {
|
||||
setRequests(res.data);
|
||||
setTotal(res.meta?.total || 0);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => { loadRequests(); }, []);
|
||||
|
||||
const handleApprove = async (id: string) => {
|
||||
await api.patch(`/v1/update-requests/${id}/approve`, { deployNow: true });
|
||||
loadRequests();
|
||||
};
|
||||
|
||||
const handleReject = async (id: string) => {
|
||||
const reason = prompt("Rejection reason:");
|
||||
if (!reason) return;
|
||||
await api.patch(`/v1/update-requests/${id}/reject`, { reason });
|
||||
loadRequests();
|
||||
};
|
||||
|
||||
const handleCancel = async (id: string) => {
|
||||
await api.patch(`/v1/update-requests/${id}/cancel`);
|
||||
loadRequests();
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Update Approvals</h1>
|
||||
<p className="text-muted-foreground">{total} update request(s)</p>
|
||||
</div>
|
||||
|
||||
{requests.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<ClipboardCheck className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">No update requests pending.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Package</TableHead>
|
||||
<TableHead>Device</TableHead>
|
||||
<TableHead>Versions</TableHead>
|
||||
<TableHead>Requested By</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{requests.map((req) => (
|
||||
<TableRow key={req.id}>
|
||||
<TableCell className="font-mono text-sm font-medium">{req.packageName}</TableCell>
|
||||
<TableCell className="text-sm">{req.deviceName || req.deviceId.slice(0, 8)}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{req.currentVersion || "?"} → <span className="font-mono">{req.targetVersion}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{req.requestedByName || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant[req.status] || "secondary"}>{req.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{new Date(req.createdAt).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{req.status === "pending" && (
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant="outline" onClick={() => handleApprove(req.id)}>
|
||||
<Check className="h-3 w-3 mr-1" /> Approve
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => handleReject(req.id)}>
|
||||
<X className="h-3 w-3 mr-1" /> Reject
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{req.status === "pending" && (
|
||||
<Button size="sm" variant="ghost" className="text-xs" onClick={() => handleCancel(req.id)}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{req.rejectionReason && (
|
||||
<p className="text-xs text-destructive mt-1">{req.rejectionReason}</p>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { user, loading, logout } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
router.push("/login");
|
||||
}
|
||||
}, [user, loading, router]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background">
|
||||
<aside className="w-64 bg-sidebar border-r border-sidebar-border p-6 space-y-4">
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<div className="space-y-3 mt-8">
|
||||
{[...Array(7)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-9 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 p-8 space-y-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-24" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background">
|
||||
<Sidebar onLogout={logout} />
|
||||
<main className="flex-1 p-8 overflow-auto">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
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 {
|
||||
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Package, Search, Loader2, ArrowLeft, Power, RotateCw, Trash2, RefreshCw, Wrench, Download,
|
||||
} from "lucide-react";
|
||||
|
||||
interface DeviceRef {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface CatalogPackage {
|
||||
id: string;
|
||||
name: string;
|
||||
latestVersion: string;
|
||||
publisher?: string;
|
||||
description?: string;
|
||||
iconUrl?: string;
|
||||
}
|
||||
|
||||
interface CatalogResponse {
|
||||
packages: CatalogPackage[];
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
type View = "actions" | "install" | "remove";
|
||||
type ConfirmKind = "reboot" | "shutdown" | null;
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
winget: "winget",
|
||||
chocolatey: "Chocolatey",
|
||||
};
|
||||
|
||||
export function DeviceActionsDialog({
|
||||
device,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
device: DeviceRef;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const [view, setView] = useState<View>("actions");
|
||||
const [confirm, setConfirm] = useState<ConfirmKind>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [feedback, setFeedback] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
// install subview
|
||||
const [source, setSource] = useState<"winget" | "chocolatey">("winget");
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [packages, setPackages] = useState<CatalogPackage[]>([]);
|
||||
const [loadingCatalog, setLoadingCatalog] = useState(false);
|
||||
const [installError, setInstallError] = useState("");
|
||||
|
||||
// remove subview
|
||||
const [removePackageId, setRemovePackageId] = useState("");
|
||||
const [removeSource, setRemoveSource] = useState<"winget" | "chocolatey">("winget");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setView("actions");
|
||||
setConfirm(null);
|
||||
setFeedback(null);
|
||||
setSearch("");
|
||||
setDebouncedSearch("");
|
||||
setPackages([]);
|
||||
setRemovePackageId("");
|
||||
setInstallError("");
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(search), 400);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
|
||||
const loadCatalog = useCallback(() => {
|
||||
if (!debouncedSearch.trim()) {
|
||||
setPackages([]);
|
||||
return;
|
||||
}
|
||||
setLoadingCatalog(true);
|
||||
setInstallError("");
|
||||
api
|
||||
.get<CatalogResponse>(`/v1/appcatalog/${source}`, {
|
||||
search: debouncedSearch.trim(),
|
||||
limit: "10",
|
||||
})
|
||||
.then((r) => {
|
||||
if (r.success && r.data) {
|
||||
setPackages(r.data.packages || []);
|
||||
} else {
|
||||
setPackages([]);
|
||||
setInstallError(r.error?.message || "Search failed");
|
||||
}
|
||||
setLoadingCatalog(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setPackages([]);
|
||||
setInstallError("Search failed");
|
||||
setLoadingCatalog(false);
|
||||
});
|
||||
}, [source, debouncedSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
loadCatalog();
|
||||
}, [loadCatalog]);
|
||||
|
||||
const reset = () => {
|
||||
setBusy(false);
|
||||
setFeedback(null);
|
||||
setConfirm(null);
|
||||
setView("actions");
|
||||
};
|
||||
|
||||
const runJob = async (
|
||||
jobType: string,
|
||||
parameters: Record<string, unknown>,
|
||||
successMessage: string
|
||||
) => {
|
||||
setBusy(true);
|
||||
setFeedback(null);
|
||||
const r = await api.post("/v1/jobs", {
|
||||
jobType,
|
||||
deviceIds: [device.id],
|
||||
parameters,
|
||||
});
|
||||
setBusy(false);
|
||||
if (!r.success) {
|
||||
setFeedback({ type: "error", text: r.error?.message || "Failed to create job" });
|
||||
return false;
|
||||
}
|
||||
setFeedback({ type: "success", text: successMessage });
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (confirm === "reboot") {
|
||||
await runJob("reboot_system", {}, "Reboot job created. Device will restart shortly.");
|
||||
setConfirm(null);
|
||||
} else if (confirm === "shutdown") {
|
||||
await runJob("shutdown_system", {}, "Shutdown job created. Device will power off shortly.");
|
||||
setConfirm(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstall = async (pkg: CatalogPackage) => {
|
||||
const ok = await runJob(
|
||||
"install_registry_package",
|
||||
{ source, package_id: pkg.id, package_version: pkg.latestVersion },
|
||||
`Installation job created for ${pkg.name}.`
|
||||
);
|
||||
if (ok) setDebouncedSearch("");
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
if (!removePackageId.trim()) return;
|
||||
const ok = await runJob(
|
||||
"remove_package",
|
||||
{ source: removeSource, package_name: removePackageId.trim() },
|
||||
`Removal job created for ${removePackageId.trim()}.`
|
||||
);
|
||||
if (ok) setRemovePackageId("");
|
||||
};
|
||||
|
||||
const online = device.status === "online";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { onOpenChange(o); if (!o) reset(); }}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Wrench className="h-4 w-4" />
|
||||
<span>Actions</span>
|
||||
<Badge variant={online ? "success" : "secondary"}>{device.status}</Badge>
|
||||
<span className="text-muted-foreground text-sm font-normal">{device.name}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{view === "install"
|
||||
? "Search a software catalog and deploy it to this device."
|
||||
: view === "remove"
|
||||
? "Remove a software package from this device."
|
||||
: "Choose an action to run on this device."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{feedback && (
|
||||
<Alert variant={feedback.type === "error" ? "destructive" : "default"}>
|
||||
<AlertDescription>{feedback.text}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{view === "actions" && (
|
||||
<div className="grid gap-2">
|
||||
<Button variant="outline" className="justify-start" onClick={() => setView("install")}>
|
||||
<Download className="mr-2 h-4 w-4" /> Software installieren
|
||||
</Button>
|
||||
<Button variant="outline" className="justify-start" onClick={() => setView("remove")}>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> Software entfernen
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-start"
|
||||
onClick={() => setConfirm(confirm === "reboot" ? null : "reboot")}
|
||||
>
|
||||
<RotateCw className="mr-2 h-4 w-4" /> Neu starten
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-start"
|
||||
onClick={() => setConfirm(confirm === "shutdown" ? null : "shutdown")}
|
||||
>
|
||||
<Power className="mr-2 h-4 w-4" /> Herunterfahren
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-start"
|
||||
disabled={busy}
|
||||
onClick={() => runJob("update_inventory", {}, "Inventory refresh job created.")}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" /> Inventar aktualisieren
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-start"
|
||||
disabled={busy}
|
||||
onClick={() => runJob("update_system_info", {}, "System info refresh job created.")}
|
||||
>
|
||||
<Loader2 className="mr-2 h-4 w-4" /> System-Infos aktualisieren
|
||||
</Button>
|
||||
|
||||
{confirm && (
|
||||
<div className="rounded-md border border-amber-300/50 bg-amber-50 p-3 text-sm">
|
||||
<p className="mb-3">
|
||||
{confirm === "reboot"
|
||||
? `${device.name} wird jetzt neu gestartet. Fortfahren?`
|
||||
: `${device.name} wird jetzt heruntergefahren. Fortfahren?`}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" disabled={busy} onClick={() => setConfirm(null)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
variant={confirm === "shutdown" ? "destructive" : "default"}
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Bestätigen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === "install" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={source} onValueChange={(v) => setSource(v as "winget" | "chocolatey")}>
|
||||
<SelectTrigger className="w-44"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="winget">winget</SelectItem>
|
||||
<SelectItem value="chocolatey">Chocolatey</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={`Search ${SOURCE_LABELS[source]}...`}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{installError && <Alert variant="destructive"><AlertDescription>{installError}</AlertDescription></Alert>}
|
||||
|
||||
{loadingCatalog ? (
|
||||
<div className="py-8 text-center"><Loader2 className="mx-auto h-6 w-6 animate-spin text-muted-foreground" /></div>
|
||||
) : debouncedSearch.trim() && packages.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">No packages found.</p>
|
||||
) : (
|
||||
<div className="max-h-72 overflow-y-auto space-y-1">
|
||||
{packages.map((p) => (
|
||||
<div key={p.id} className="flex items-center gap-2 rounded-md border p-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{p.iconUrl ? (
|
||||
<img src={p.iconUrl} alt="" className="h-5 w-5 rounded object-contain" />
|
||||
) : (
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-sm font-medium truncate">{p.name}</span>
|
||||
</div>
|
||||
<div className="text-xs font-mono text-muted-foreground truncate mt-0.5">
|
||||
{p.id} · v{p.latestVersion}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" disabled={busy} onClick={() => handleInstall(p)}>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : "Install"}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => setView("actions")}
|
||||
>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" /> Back to actions
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === "remove" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Package (Name oder ID)</Label>
|
||||
<Input
|
||||
placeholder="z.B. Obsidian / Notepad++.Notepad++"
|
||||
value={removePackageId}
|
||||
onChange={(e) => setRemovePackageId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Quelle</Label>
|
||||
<Select value={removeSource} onValueChange={(v) => setRemoveSource(v as "winget" | "chocolatey")}>
|
||||
<SelectTrigger className="w-44"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="winget">winget</SelectItem>
|
||||
<SelectItem value="chocolatey">Chocolatey</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setView("actions")}>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" /> Back
|
||||
</Button>
|
||||
<Button variant="destructive" disabled={!removePackageId.trim() || busy} onClick={handleRemove}>
|
||||
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Entfernen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Loader2, MonitorX, Minimize2, Maximize2 } from "lucide-react";
|
||||
|
||||
interface SessionInfo {
|
||||
id: string;
|
||||
deviceId: string;
|
||||
status: string;
|
||||
vncPort: number;
|
||||
vncPassword?: string;
|
||||
errorMessage?: string | null;
|
||||
deviceName?: string;
|
||||
}
|
||||
|
||||
function wsBaseUrl(): string {
|
||||
const apiBase = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3002/api";
|
||||
return apiBase.replace(/^http/, "ws").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function RemoteAccessModal({
|
||||
deviceId,
|
||||
deviceName,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const [session, setSession] = useState<SessionInfo | null>(null);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const rfbRef = useRef<any>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const rfbCreatedRef = useRef(false);
|
||||
|
||||
const createSession = useCallback(async () => {
|
||||
setSession(null);
|
||||
setError("");
|
||||
setConnected(false);
|
||||
setConnecting(true);
|
||||
rfbCreatedRef.current = false;
|
||||
|
||||
const created = await api.post<SessionInfo>("/v1/remote/sessions", {
|
||||
deviceId,
|
||||
vncPort: 5900,
|
||||
});
|
||||
|
||||
if (!created.success || !created.data) {
|
||||
setError(created.error?.message || "Failed to create remote session");
|
||||
setConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSession(created.data);
|
||||
|
||||
const pollInterval = setInterval(async () => {
|
||||
const res = await api.get<SessionInfo>(`/v1/remote/sessions/${created.data!.id}`);
|
||||
if (!res.success || !res.data) {
|
||||
clearInterval(pollInterval);
|
||||
setError(res.error?.message || "Remote session unavailable");
|
||||
setConnecting(false);
|
||||
return;
|
||||
}
|
||||
setSession(res.data);
|
||||
if (res.data.status === "active") {
|
||||
clearInterval(pollInterval);
|
||||
setConnecting(false);
|
||||
connectVnc(res.data);
|
||||
} else if (res.data.status === "failed" || res.data.status === "closed") {
|
||||
clearInterval(pollInterval);
|
||||
setConnecting(false);
|
||||
setError(res.data.errorMessage || `Session ended (${res.data.status})`);
|
||||
}
|
||||
}, 1500);
|
||||
}, [deviceId]);
|
||||
|
||||
const connectVnc = useCallback((s: SessionInfo) => {
|
||||
if (rfbCreatedRef.current) return;
|
||||
const token = api.getToken();
|
||||
if (!token) {
|
||||
setError("Not authenticated");
|
||||
return;
|
||||
}
|
||||
const wsUrl = `${wsBaseUrl()}/v1/remote/browser/${s.id}?token=${encodeURIComponent(token)}`;
|
||||
|
||||
import("@novnc/novnc").then((mod) => {
|
||||
const RFB = mod.default;
|
||||
const target =
|
||||
containerRef.current || document.createElement("div");
|
||||
const rfb = new RFB(target, wsUrl, {
|
||||
credentials: { password: s.vncPassword || "" },
|
||||
share: true,
|
||||
});
|
||||
rfbRef.current = rfb;
|
||||
rfbCreatedRef.current = true;
|
||||
rfb.addEventListener("connect", () => {
|
||||
setConnected(true);
|
||||
});
|
||||
rfb.addEventListener("disconnect", (ev: any) => {
|
||||
setConnected(false);
|
||||
setError(ev?.detail?.clean ? "Disconnected" : `Disconnected: ${ev?.detail?.reason || "connection lost"}`);
|
||||
});
|
||||
rfb.addEventListener("credentialsrequired", () => {
|
||||
setConnected(false);
|
||||
setError("VNC password required but not provided");
|
||||
});
|
||||
rfb.scaleViewport = true;
|
||||
rfb.resizeSession = true;
|
||||
}).catch(() => {
|
||||
setError("Failed to load VNC client");
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
rfbCreatedRef.current = false;
|
||||
createSession();
|
||||
}, [open, createSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open && rfbRef.current) {
|
||||
try {
|
||||
rfbRef.current.disconnect();
|
||||
} catch { /* ignore */ }
|
||||
rfbRef.current = null;
|
||||
rfbCreatedRef.current = false;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const stopSession = async () => {
|
||||
if (session) {
|
||||
await api.delete(`/v1/remote/sessions/${session.id}`);
|
||||
}
|
||||
if (rfbRef.current) {
|
||||
try {
|
||||
rfbRef.current.disconnect();
|
||||
} catch { /* ignore */ }
|
||||
rfbRef.current = null;
|
||||
}
|
||||
rfbCreatedRef.current = false;
|
||||
if (containerRef.current) {
|
||||
containerRef.current.innerHTML = "";
|
||||
}
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const vncElement = (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`relative w-full ${fullscreen ? "h-[calc(100vh-140px)]" : "h-[62vh]"} border rounded-md overflow-hidden bg-black`}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) stopSession(); }}>
|
||||
<DialogContent className={`${fullscreen ? "max-w-[96vw]" : "max-w-5xl"}`}>
|
||||
{!fullscreen && (
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<MonitorX className="h-4 w-4" />
|
||||
<span>Remote Desktop</span>
|
||||
<span className="text-muted-foreground text-sm font-normal">{deviceName}</span>
|
||||
{connected ? (
|
||||
<Badge variant="success">Connected</Badge>
|
||||
) : connecting ? (
|
||||
<Badge variant="warning">Starting VNC…</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Disconnected</Badge>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{session?.vncPassword
|
||||
? `VNC via encrypted tunnel · Password: ${session.vncPassword}`
|
||||
: "Establishing a secure VNC tunnel to this device…"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{(connecting || (session && session.status !== "active")) && !error && (
|
||||
<div className="flex items-center justify-center py-10 space-x-2 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>Waiting for the agent to start VNC…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!connecting && !error && session && session.status === "active" && (
|
||||
<>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setFullscreen((f) => !f)}
|
||||
>
|
||||
{fullscreen ? (
|
||||
<>
|
||||
<Minimize2 className="mr-1 h-4 w-4" /> Exit fullscreen
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Maximize2 className="mr-1 h-4 w-4" /> Fullscreen
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => stopSession()}>
|
||||
End session
|
||||
</Button>
|
||||
</div>
|
||||
{vncElement}
|
||||
</>
|
||||
)}
|
||||
|
||||
{connected && !fullscreen && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Hint: use the toolbar area above the screen for clipboard and keys.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Server,
|
||||
Users,
|
||||
Shield,
|
||||
Zap,
|
||||
Bell,
|
||||
ScrollText,
|
||||
UserCog,
|
||||
KeyRound,
|
||||
LogOut,
|
||||
Hexagon,
|
||||
Package,
|
||||
ClipboardCheck,
|
||||
Rocket,
|
||||
Download,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ href: "/devices", label: "Devices", icon: Server },
|
||||
{ href: "/groups", label: "Groups", icon: Users },
|
||||
{ href: "/software", label: "Software", icon: Package },
|
||||
{ href: "/deployments", label: "Deployments", icon: Rocket },
|
||||
{ href: "/update-approvals", label: "Update Approvals", icon: ClipboardCheck },
|
||||
{ href: "/policies", label: "Policies", icon: Shield },
|
||||
{ href: "/jobs", label: "Jobs", icon: Zap },
|
||||
{ href: "/alerts", label: "Alerts", icon: Bell },
|
||||
{ href: "/downloads", label: "Downloads", icon: Download },
|
||||
{ href: "/audit-log", label: "Audit Log", icon: ScrollText },
|
||||
];
|
||||
|
||||
const bottomItems = [
|
||||
{ href: "/settings/users", label: "Users", icon: UserCog },
|
||||
{ href: "/settings/enrollment", label: "Enrollment", icon: KeyRound },
|
||||
];
|
||||
|
||||
export default function Sidebar({ onLogout }: { onLogout: () => void }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isActive = (href: string) => pathname.startsWith(href);
|
||||
|
||||
return (
|
||||
<aside className="w-64 bg-sidebar border-r border-sidebar-border flex flex-col min-h-screen">
|
||||
<div className="p-6 border-b border-sidebar-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Hexagon className="h-6 w-6 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-sidebar-foreground tracking-tight">
|
||||
Fleet Forge
|
||||
</h1>
|
||||
<p className="text-xs text-sidebar-foreground/50">
|
||||
Management Platform
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 px-3 py-4">
|
||||
<nav className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive(item.href)
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/60 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="p-3 border-t border-sidebar-border">
|
||||
<nav className="space-y-1">
|
||||
{bottomItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive(item.href)
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/60 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<Separator className="my-2" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-3 text-sidebar-foreground/60 hover:text-red-400 hover:bg-sidebar-accent/50"
|
||||
onClick={onLogout}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sign out
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Alert = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & { variant?: "default" | "destructive" }>(
|
||||
({ className, variant = "default", ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(
|
||||
"relative w-full rounded-lg border p-4 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||
variant === "destructive" && "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Alert.displayName = "Alert";
|
||||
|
||||
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} />
|
||||
)
|
||||
);
|
||||
AlertTitle.displayName = "AlertTitle";
|
||||
|
||||
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
|
||||
)
|
||||
);
|
||||
AlertDescription.displayName = "AlertDescription";
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive: "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
success: "border-transparent bg-green-100 text-green-800",
|
||||
warning: "border-transparent bg-yellow-100 text-yellow-800",
|
||||
info: "border-transparent bg-blue-100 text-blue-800",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("font-semibold leading-none tracking-tight", className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
@@ -0,0 +1,84 @@
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export { Dialog, DialogPortal, DialogOverlay, DialogClose, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription };
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel };
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
|
||||
({ className, value = 0, max = 100, ...props }, ref) => {
|
||||
const percentage = Math.min(100, Math.max(0, (value / max) * 100));
|
||||
return (
|
||||
<div ref={ref} className={`relative h-2 w-full overflow-hidden rounded-full bg-muted ${className || ""}`} {...props}>
|
||||
<div className="h-full bg-primary transition-all" style={{ width: `${percentage}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
Progress.displayName = "Progress";
|
||||
|
||||
export { Progress };
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as React from "react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
@@ -0,0 +1,80 @@
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" && "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" && "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem };
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,9 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={cn("animate-pulse rounded-md bg-primary/10", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -0,0 +1,62 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
Table.displayName = "Table";
|
||||
|
||||
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
)
|
||||
);
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
|
||||
)
|
||||
);
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tfoot ref={ref} className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)} {...props} />
|
||||
)
|
||||
);
|
||||
TableFooter.displayName = "TableFooter";
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr ref={ref} className={cn("border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", className)} {...props} />
|
||||
)
|
||||
);
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<th ref={ref} className={cn("h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", className)} {...props} />
|
||||
)
|
||||
);
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<td ref={ref} className={cn("p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", className)} {...props} />
|
||||
)
|
||||
);
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<caption ref={ref} className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
|
||||
)
|
||||
);
|
||||
TableCaption.displayName = "TableCaption";
|
||||
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const token = api.getToken();
|
||||
if (!token) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000/api"}/v1/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.success) {
|
||||
setUser(data.data);
|
||||
} else {
|
||||
api.setToken(null);
|
||||
}
|
||||
})
|
||||
.catch(() => api.setToken(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
const res = await api.post<{ token: string; user: User }>("/v1/auth/login", {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
if (res.success && res.data) {
|
||||
api.setToken(res.data.token);
|
||||
setUser(res.data.user);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
api.setToken(null);
|
||||
setUser(null);
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
return { user, loading, login, logout };
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000/api";
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: { code: string; message: string };
|
||||
meta?: { page: number; limit: number; total: number; totalPages: number };
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private token: string | null = null;
|
||||
|
||||
setToken(token: string | null) {
|
||||
this.token = token;
|
||||
if (token) {
|
||||
localStorage.setItem("ff_token", token);
|
||||
} else {
|
||||
localStorage.removeItem("ff_token");
|
||||
}
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
if (!this.token) {
|
||||
this.token = typeof window !== "undefined" ? localStorage.getItem("ff_token") : null;
|
||||
}
|
||||
return this.token;
|
||||
}
|
||||
|
||||
async request<T>(path: string, options: RequestInit = {}): Promise<ApiResponse<T>> {
|
||||
const token = this.getToken();
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...((options.headers as Record<string, string>) || {}),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
this.setToken(null);
|
||||
if (typeof window !== "undefined" && !window.location.pathname.includes("/login")) {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
|
||||
if (res.status === 204) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
async get<T>(path: string, params?: Record<string, string>): Promise<ApiResponse<T>> {
|
||||
const query = params ? "?" + new URLSearchParams(params).toString() : "";
|
||||
return this.request<T>(`${path}${query}`);
|
||||
}
|
||||
|
||||
async post<T>(path: string, body?: unknown): Promise<ApiResponse<T>> {
|
||||
return this.request<T>(path, {
|
||||
method: "POST",
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async patch<T>(path: string, body?: unknown): Promise<ApiResponse<T>> {
|
||||
return this.request<T>(path, {
|
||||
method: "PATCH",
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async delete<T>(path: string): Promise<ApiResponse<T>> {
|
||||
return this.request<T>(path, { method: "DELETE" });
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient();
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
declare module "@novnc/novnc" {
|
||||
export default class RFB extends EventTarget {
|
||||
constructor(target: HTMLElement, url: string, options?: Record<string, unknown>);
|
||||
scaleViewport: boolean;
|
||||
resizeSession: boolean;
|
||||
clipViewport: boolean;
|
||||
disconnect(): void;
|
||||
sendCtrlAltDel(): void;
|
||||
addEventListener(type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions): void;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: "hsl(var(--sidebar))",
|
||||
foreground: "hsl(var(--sidebar-foreground))",
|
||||
accent: "hsl(var(--sidebar-accent))",
|
||||
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
|
||||
border: "hsl(var(--sidebar-border))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"noEmit": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user