it0/it0-web-admin/src/app/(admin)/servers/page.tsx

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>
);
}