572 lines
19 KiB
TypeScript
572 lines
19 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useCallback } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { apiClient } from '@/infrastructure/api/api-client';
|
|
import { queryKeys } from '@/infrastructure/api/query-keys';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface Server {
|
|
id: string;
|
|
hostname: string;
|
|
host: string;
|
|
sshPort: number;
|
|
environment: 'dev' | 'staging' | 'prod';
|
|
role: string;
|
|
status: 'online' | 'offline' | 'maintenance';
|
|
tags: string[];
|
|
description: string;
|
|
}
|
|
|
|
interface ServerFormData {
|
|
hostname: string;
|
|
host: string;
|
|
sshPort: number;
|
|
environment: 'dev' | 'staging' | 'prod';
|
|
role: string;
|
|
tags: string;
|
|
description: string;
|
|
}
|
|
|
|
interface ServersResponse {
|
|
data: Server[];
|
|
total: number;
|
|
}
|
|
|
|
type EnvironmentFilter = 'all' | 'dev' | 'staging' | 'prod';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const ENVIRONMENTS: { label: string; value: EnvironmentFilter }[] = [
|
|
{ label: 'All', value: 'all' },
|
|
{ label: 'Dev', value: 'dev' },
|
|
{ label: 'Staging', value: 'staging' },
|
|
{ label: 'Prod', value: 'prod' },
|
|
];
|
|
|
|
const EMPTY_FORM: ServerFormData = {
|
|
hostname: '',
|
|
host: '',
|
|
sshPort: 22,
|
|
environment: 'dev',
|
|
role: '',
|
|
tags: '',
|
|
description: '',
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Status badge
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function StatusBadge({ status }: { status: Server['status'] }) {
|
|
const styles: Record<Server['status'], string> = {
|
|
online: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
|
offline: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
|
maintenance: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
|
};
|
|
|
|
return (
|
|
<span
|
|
className={cn(
|
|
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize',
|
|
styles[status],
|
|
)}
|
|
>
|
|
{status}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Server form dialog
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function ServerDialog({
|
|
open,
|
|
title,
|
|
form,
|
|
errors,
|
|
saving,
|
|
onClose,
|
|
onChange,
|
|
onSubmit,
|
|
}: {
|
|
open: boolean;
|
|
title: string;
|
|
form: ServerFormData;
|
|
errors: Partial<Record<keyof ServerFormData, string>>;
|
|
saving: boolean;
|
|
onClose: () => void;
|
|
onChange: (field: keyof ServerFormData, value: string | number) => void;
|
|
onSubmit: () => void;
|
|
}) {
|
|
if (!open) return null;
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
{/* backdrop */}
|
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
|
|
|
{/* dialog */}
|
|
<div className="relative z-10 w-full max-w-lg bg-card border rounded-lg shadow-lg p-6 mx-4">
|
|
<h2 className="text-lg font-semibold mb-4">{title}</h2>
|
|
|
|
<div className="space-y-4 max-h-[70vh] overflow-y-auto pr-1">
|
|
{/* hostname */}
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">
|
|
Hostname <span className="text-destructive">*</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={form.hostname}
|
|
onChange={(e) => onChange('hostname', e.target.value)}
|
|
className={cn(
|
|
'w-full px-3 py-2 rounded-md border bg-background text-sm',
|
|
errors.hostname ? 'border-destructive' : 'border-input',
|
|
)}
|
|
placeholder="web-server-01"
|
|
/>
|
|
{errors.hostname && (
|
|
<p className="text-xs text-destructive mt-1">{errors.hostname}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* host */}
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">
|
|
Host (IP) <span className="text-destructive">*</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={form.host}
|
|
onChange={(e) => onChange('host', e.target.value)}
|
|
className={cn(
|
|
'w-full px-3 py-2 rounded-md border bg-background text-sm',
|
|
errors.host ? 'border-destructive' : 'border-input',
|
|
)}
|
|
placeholder="192.168.1.100"
|
|
/>
|
|
{errors.host && (
|
|
<p className="text-xs text-destructive mt-1">{errors.host}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* sshPort */}
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">SSH Port</label>
|
|
<input
|
|
type="number"
|
|
value={form.sshPort}
|
|
onChange={(e) => onChange('sshPort', parseInt(e.target.value, 10) || 22)}
|
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
|
min={1}
|
|
max={65535}
|
|
/>
|
|
</div>
|
|
|
|
{/* environment */}
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">Environment</label>
|
|
<select
|
|
value={form.environment}
|
|
onChange={(e) => onChange('environment', e.target.value)}
|
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
|
>
|
|
<option value="dev">dev</option>
|
|
<option value="staging">staging</option>
|
|
<option value="prod">prod</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* role */}
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">Role</label>
|
|
<input
|
|
type="text"
|
|
value={form.role}
|
|
onChange={(e) => onChange('role', e.target.value)}
|
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
|
placeholder="web, db, cache..."
|
|
/>
|
|
</div>
|
|
|
|
{/* tags */}
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">Tags</label>
|
|
<input
|
|
type="text"
|
|
value={form.tags}
|
|
onChange={(e) => onChange('tags', e.target.value)}
|
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm"
|
|
placeholder="linux, ubuntu, docker (comma-separated)"
|
|
/>
|
|
</div>
|
|
|
|
{/* description */}
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">Description</label>
|
|
<textarea
|
|
value={form.description}
|
|
onChange={(e) => onChange('description', e.target.value)}
|
|
rows={3}
|
|
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm resize-none"
|
|
placeholder="Optional description..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* actions */}
|
|
<div className="flex justify-end gap-3 mt-6 pt-4 border-t">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
|
disabled={saving}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onSubmit}
|
|
disabled={saving}
|
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
|
>
|
|
{saving ? 'Saving...' : 'Save'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Delete confirmation dialog
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function DeleteDialog({
|
|
open,
|
|
hostname,
|
|
deleting,
|
|
onClose,
|
|
onConfirm,
|
|
}: {
|
|
open: boolean;
|
|
hostname: string;
|
|
deleting: boolean;
|
|
onClose: () => void;
|
|
onConfirm: () => void;
|
|
}) {
|
|
if (!open) return null;
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
|
<div className="relative z-10 w-full max-w-sm bg-card border rounded-lg shadow-lg p-6 mx-4">
|
|
<h2 className="text-lg font-semibold mb-2">Delete Server</h2>
|
|
<p className="text-sm text-muted-foreground mb-6">
|
|
Are you sure you want to delete <strong>{hostname}</strong>? This action cannot be undone.
|
|
</p>
|
|
<div className="flex justify-end gap-3">
|
|
<button
|
|
onClick={onClose}
|
|
className="px-4 py-2 text-sm rounded-md border border-input hover:bg-accent"
|
|
disabled={deleting}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={onConfirm}
|
|
disabled={deleting}
|
|
className="px-4 py-2 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
|
|
>
|
|
{deleting ? 'Deleting...' : 'Delete'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main page component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export default function ServersPage() {
|
|
const queryClient = useQueryClient();
|
|
|
|
// State ----------------------------------------------------------------
|
|
const [envFilter, setEnvFilter] = useState<EnvironmentFilter>('all');
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
const [editingServer, setEditingServer] = useState<Server | null>(null);
|
|
const [deleteTarget, setDeleteTarget] = useState<Server | null>(null);
|
|
const [form, setForm] = useState<ServerFormData>({ ...EMPTY_FORM });
|
|
const [errors, setErrors] = useState<Partial<Record<keyof ServerFormData, string>>>({});
|
|
|
|
// Queries --------------------------------------------------------------
|
|
const queryParams = envFilter !== 'all' ? { environment: envFilter } : undefined;
|
|
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: queryKeys.servers.list(queryParams),
|
|
queryFn: () => {
|
|
const qs = envFilter !== 'all' ? `?environment=${envFilter}` : '';
|
|
return apiClient<ServersResponse>(`/api/v1/inventory/servers${qs}`);
|
|
},
|
|
});
|
|
|
|
const servers = data?.data ?? [];
|
|
|
|
// Mutations ------------------------------------------------------------
|
|
const createMutation = useMutation({
|
|
mutationFn: (body: Record<string, unknown>) =>
|
|
apiClient<Server>('/api/v1/inventory/servers', { method: 'POST', body }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.servers.all });
|
|
closeDialog();
|
|
},
|
|
});
|
|
|
|
const updateMutation = useMutation({
|
|
mutationFn: ({ id, body }: { id: string; body: Record<string, unknown> }) =>
|
|
apiClient<Server>(`/api/v1/inventory/servers/${id}`, { method: 'PUT', body }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.servers.all });
|
|
closeDialog();
|
|
},
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (id: string) =>
|
|
apiClient<void>(`/api/v1/inventory/servers/${id}`, { method: 'DELETE' }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.servers.all });
|
|
setDeleteTarget(null);
|
|
},
|
|
});
|
|
|
|
// Helpers --------------------------------------------------------------
|
|
const validate = useCallback((): boolean => {
|
|
const next: Partial<Record<keyof ServerFormData, string>> = {};
|
|
if (!form.hostname.trim()) next.hostname = 'Hostname is required';
|
|
if (!form.host.trim()) next.host = 'Host is required';
|
|
setErrors(next);
|
|
return Object.keys(next).length === 0;
|
|
}, [form]);
|
|
|
|
const closeDialog = useCallback(() => {
|
|
setDialogOpen(false);
|
|
setEditingServer(null);
|
|
setForm({ ...EMPTY_FORM });
|
|
setErrors({});
|
|
}, []);
|
|
|
|
const openAdd = useCallback(() => {
|
|
setEditingServer(null);
|
|
setForm({ ...EMPTY_FORM });
|
|
setErrors({});
|
|
setDialogOpen(true);
|
|
}, []);
|
|
|
|
const openEdit = useCallback((server: Server) => {
|
|
setEditingServer(server);
|
|
setForm({
|
|
hostname: server.hostname,
|
|
host: server.host,
|
|
sshPort: server.sshPort,
|
|
environment: server.environment,
|
|
role: server.role,
|
|
tags: (server.tags ?? []).join(', '),
|
|
description: server.description ?? '',
|
|
});
|
|
setErrors({});
|
|
setDialogOpen(true);
|
|
}, []);
|
|
|
|
const handleChange = useCallback(
|
|
(field: keyof ServerFormData, value: string | number) => {
|
|
setForm((prev) => ({ ...prev, [field]: value }));
|
|
setErrors((prev) => {
|
|
const next = { ...prev };
|
|
delete next[field];
|
|
return next;
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleSubmit = useCallback(() => {
|
|
if (!validate()) return;
|
|
const body: Record<string, unknown> = {
|
|
hostname: form.hostname.trim(),
|
|
host: form.host.trim(),
|
|
sshPort: form.sshPort,
|
|
environment: form.environment,
|
|
role: form.role.trim(),
|
|
tags: form.tags
|
|
.split(',')
|
|
.map((t) => t.trim())
|
|
.filter(Boolean),
|
|
description: form.description.trim(),
|
|
};
|
|
|
|
if (editingServer) {
|
|
updateMutation.mutate({ id: editingServer.id, body });
|
|
} else {
|
|
createMutation.mutate(body);
|
|
}
|
|
}, [form, editingServer, validate, createMutation, updateMutation]);
|
|
|
|
const isSaving = createMutation.isPending || updateMutation.isPending;
|
|
|
|
// Render ---------------------------------------------------------------
|
|
return (
|
|
<div>
|
|
{/* Header */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
|
<div>
|
|
<h1 className="text-2xl font-bold">Servers</h1>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
Manage server inventory, clusters, and SSH configurations.
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={openAdd}
|
|
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 whitespace-nowrap"
|
|
>
|
|
Add Server
|
|
</button>
|
|
</div>
|
|
|
|
{/* Environment filter tabs */}
|
|
<div className="flex gap-1 mb-6 p-1 bg-muted rounded-lg w-fit">
|
|
{ENVIRONMENTS.map((env) => (
|
|
<button
|
|
key={env.value}
|
|
onClick={() => setEnvFilter(env.value)}
|
|
className={cn(
|
|
'px-4 py-1.5 text-sm rounded-md font-medium transition-colors',
|
|
envFilter === env.value
|
|
? 'bg-background text-foreground shadow-sm'
|
|
: 'text-muted-foreground hover:text-foreground',
|
|
)}
|
|
>
|
|
{env.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Error state */}
|
|
{error && (
|
|
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
|
|
Failed to load servers: {(error as Error).message}
|
|
</div>
|
|
)}
|
|
|
|
{/* Loading state */}
|
|
{isLoading && (
|
|
<div className="text-sm text-muted-foreground py-12 text-center">
|
|
Loading servers...
|
|
</div>
|
|
)}
|
|
|
|
{/* Data table */}
|
|
{!isLoading && !error && (
|
|
<div className="border rounded-lg overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b bg-muted/50">
|
|
<th className="text-left px-4 py-3 font-medium">Hostname</th>
|
|
<th className="text-left px-4 py-3 font-medium">Host</th>
|
|
<th className="text-left px-4 py-3 font-medium">Environment</th>
|
|
<th className="text-left px-4 py-3 font-medium">Role</th>
|
|
<th className="text-left px-4 py-3 font-medium">Status</th>
|
|
<th className="text-right px-4 py-3 font-medium">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{servers.length === 0 ? (
|
|
<tr>
|
|
<td
|
|
colSpan={6}
|
|
className="text-center text-muted-foreground py-12"
|
|
>
|
|
No servers found.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
servers.map((server) => (
|
|
<tr
|
|
key={server.id}
|
|
className="border-b last:border-b-0 hover:bg-muted/30 transition-colors"
|
|
>
|
|
<td className="px-4 py-3 font-medium">{server.hostname}</td>
|
|
<td className="px-4 py-3 font-mono text-xs">{server.host}</td>
|
|
<td className="px-4 py-3">
|
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-muted capitalize">
|
|
{server.environment}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-muted-foreground">
|
|
{server.role || '--'}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<StatusBadge status={server.status} />
|
|
</td>
|
|
<td className="px-4 py-3 text-right">
|
|
<div className="flex items-center justify-end gap-2">
|
|
<button
|
|
onClick={() => openEdit(server)}
|
|
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
|
|
>
|
|
Edit
|
|
</button>
|
|
<button
|
|
onClick={() => setDeleteTarget(server)}
|
|
className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Add / Edit dialog */}
|
|
<ServerDialog
|
|
open={dialogOpen}
|
|
title={editingServer ? 'Edit Server' : 'Add Server'}
|
|
form={form}
|
|
errors={errors}
|
|
saving={isSaving}
|
|
onClose={closeDialog}
|
|
onChange={handleChange}
|
|
onSubmit={handleSubmit}
|
|
/>
|
|
|
|
{/* Delete confirmation dialog */}
|
|
<DeleteDialog
|
|
open={!!deleteTarget}
|
|
hostname={deleteTarget?.hostname ?? ''}
|
|
deleting={deleteMutation.isPending}
|
|
onClose={() => setDeleteTarget(null)}
|
|
onConfirm={() => {
|
|
if (deleteTarget) deleteMutation.mutate(deleteTarget.id);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|