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

631 lines
21 KiB
TypeScript

'use client';
import { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys';
import { cn } from '@/lib/utils';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface Cluster {
id: string;
name: string;
description: string;
environment: 'dev' | 'staging' | 'prod';
tags: string[];
serverIds: string[];
serverCount: number;
healthySummary: { online: number; offline: number; maintenance: number };
createdAt: string;
updatedAt: string;
}
interface Server {
id: string;
hostname: string;
host: string;
status: 'online' | 'offline' | 'maintenance';
environment: string;
}
type ClustersResponse = Cluster[];
type ServersResponse = Server[];
interface ClusterFormData {
name: string;
description: string;
environment: 'dev' | 'staging' | 'prod';
tags: string;
serverIds: string[];
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const EMPTY_FORM: ClusterFormData = {
name: '',
description: '',
environment: 'dev',
tags: '',
serverIds: [],
};
const CLUSTER_QUERY_KEY = ['clusters'] as const;
const ENV_COLORS: Record<string, string> = {
dev: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
staging: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
prod: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
};
// ---------------------------------------------------------------------------
// Environment badge
// ---------------------------------------------------------------------------
function EnvironmentBadge({ environment }: { environment: string }) {
return (
<span
className={cn(
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize',
ENV_COLORS[environment] ?? 'bg-muted text-muted-foreground',
)}
>
{environment}
</span>
);
}
// ---------------------------------------------------------------------------
// Health summary dots
// ---------------------------------------------------------------------------
function HealthSummary({
summary,
}: {
summary: Cluster['healthySummary'];
}) {
const { t } = useTranslation('servers');
const parts: { count: number; labelKey: string; color: string }[] = [];
if (summary.online > 0) {
parts.push({ count: summary.online, labelKey: 'clusters.health.online', color: 'bg-green-500' });
}
if (summary.offline > 0) {
parts.push({ count: summary.offline, labelKey: 'clusters.health.offline', color: 'bg-red-500' });
}
if (summary.maintenance > 0) {
parts.push({ count: summary.maintenance, labelKey: 'clusters.health.maintenance', color: 'bg-yellow-500' });
}
if (parts.length === 0) {
return (
<span className="text-xs text-muted-foreground">{t('clusters.health.noServers')}</span>
);
}
return (
<div className="flex items-center gap-3">
{parts.map((part) => (
<span key={part.labelKey} className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<span className={cn('w-2 h-2 rounded-full', part.color)} />
{part.count} {t(part.labelKey)}
</span>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// Cluster form dialog
// ---------------------------------------------------------------------------
function ClusterDialog({
open,
title,
form,
errors,
saving,
servers,
serversLoading,
onClose,
onChange,
onToggleServer,
onSubmit,
}: {
open: boolean;
title: string;
form: ClusterFormData;
errors: Partial<Record<keyof ClusterFormData, string>>;
saving: boolean;
servers: Server[];
serversLoading: boolean;
onClose: () => void;
onChange: (field: keyof ClusterFormData, value: string) => void;
onToggleServer: (serverId: string) => void;
onSubmit: () => void;
}) {
const { t } = useTranslation('servers');
const { t: tc } = useTranslation('common');
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">
{/* name */}
<div>
<label className="block text-sm font-medium mb-1">
{t('clusters.form.name')} <span className="text-destructive">*</span>
</label>
<input
type="text"
value={form.name}
onChange={(e) => onChange('name', e.target.value)}
className={cn(
'w-full px-3 py-2 rounded-md border bg-background text-sm',
errors.name ? 'border-destructive' : 'border-input',
)}
placeholder="Production Web Tier"
/>
{errors.name && (
<p className="text-xs text-destructive mt-1">{errors.name}</p>
)}
</div>
{/* description */}
<div>
<label className="block text-sm font-medium mb-1">{t('clusters.form.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 for this cluster..."
/>
</div>
{/* environment */}
<div>
<label className="block text-sm font-medium mb-1">{t('clusters.form.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>
{/* tags */}
<div>
<label className="block text-sm font-medium mb-1">{t('clusters.form.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="web, nginx, load-balanced (comma-separated)"
/>
</div>
{/* server selection */}
<div>
<label className="block text-sm font-medium mb-1">{t('clusters.form.servers')}</label>
{serversLoading ? (
<div className="text-xs text-muted-foreground py-4 text-center">
{tc('loading')}
</div>
) : servers.length === 0 ? (
<div className="text-xs text-muted-foreground py-4 text-center">
{tc('noData')}
</div>
) : (
<div className="max-h-[200px] overflow-y-auto border border-input rounded-md">
{servers.map((server) => (
<label
key={server.id}
className="flex items-center gap-3 px-3 py-2 hover:bg-muted/30 cursor-pointer border-b last:border-b-0 transition-colors"
>
<input
type="checkbox"
checked={form.serverIds.includes(server.id)}
onChange={() => onToggleServer(server.id)}
className="rounded border-input"
/>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">{server.hostname}</span>
<span className="text-xs text-muted-foreground ml-2 font-mono">
{server.host}
</span>
</div>
<span
className={cn(
'w-2 h-2 rounded-full flex-shrink-0',
server.status === 'online'
? 'bg-green-500'
: server.status === 'offline'
? 'bg-red-500'
: 'bg-yellow-500',
)}
/>
</label>
))}
</div>
)}
{form.serverIds.length > 0 && (
<p className="text-xs text-muted-foreground mt-1">
{form.serverIds.length} server{form.serverIds.length !== 1 ? 's' : ''} selected
</p>
)}
</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}
>
{tc('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 ? tc('saving') : tc('save')}
</button>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Delete confirmation dialog
// ---------------------------------------------------------------------------
function DeleteDialog({
open,
clusterName,
deleting,
onClose,
onConfirm,
}: {
open: boolean;
clusterName: string;
deleting: boolean;
onClose: () => void;
onConfirm: () => void;
}) {
const { t } = useTranslation('servers');
const { t: tc } = useTranslation('common');
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">{t('clusters.deleteCluster')}</h2>
<p className="text-sm text-muted-foreground mb-6">
{t('dialog.deleteConfirm')} <strong>{clusterName}</strong>? {t('dialog.deleteWarning')}
</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}
>
{tc('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 ? tc('deleting') : tc('delete')}
</button>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main page component
// ---------------------------------------------------------------------------
export default function ClustersPage() {
const { t } = useTranslation('servers');
const { t: tc } = useTranslation('common');
const queryClient = useQueryClient();
// State ----------------------------------------------------------------
const [dialogOpen, setDialogOpen] = useState(false);
const [editingCluster, setEditingCluster] = useState<Cluster | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Cluster | null>(null);
const [form, setForm] = useState<ClusterFormData>({ ...EMPTY_FORM });
const [errors, setErrors] = useState<Partial<Record<keyof ClusterFormData, string>>>({});
// Queries --------------------------------------------------------------
const { data, isLoading, error } = useQuery({
queryKey: CLUSTER_QUERY_KEY,
queryFn: () => apiClient<ClustersResponse>('/api/v1/inventory/clusters'),
});
const clusters = data?? [];
// Fetch servers for the dialog server selection
const { data: serversData, isLoading: serversLoading } = useQuery({
queryKey: queryKeys.servers.list(),
queryFn: () => apiClient<ServersResponse>('/api/v1/inventory/servers'),
enabled: dialogOpen,
});
const servers = serversData?? [];
// Mutations ------------------------------------------------------------
const createMutation = useMutation({
mutationFn: (body: Record<string, unknown>) =>
apiClient<Cluster>('/api/v1/inventory/clusters', { method: 'POST', body }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CLUSTER_QUERY_KEY });
closeDialog();
},
});
const updateMutation = useMutation({
mutationFn: ({ id, body }: { id: string; body: Record<string, unknown> }) =>
apiClient<Cluster>(`/api/v1/inventory/clusters/${id}`, { method: 'PUT', body }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CLUSTER_QUERY_KEY });
closeDialog();
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) =>
apiClient<void>(`/api/v1/inventory/clusters/${id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CLUSTER_QUERY_KEY });
setDeleteTarget(null);
},
});
// Helpers --------------------------------------------------------------
const validate = useCallback((): boolean => {
const next: Partial<Record<keyof ClusterFormData, string>> = {};
if (!form.name.trim()) next.name = t('validation.hostnameRequired');
setErrors(next);
return Object.keys(next).length === 0;
}, [form]);
const closeDialog = useCallback(() => {
setDialogOpen(false);
setEditingCluster(null);
setForm({ ...EMPTY_FORM });
setErrors({});
}, []);
const openAdd = useCallback(() => {
setEditingCluster(null);
setForm({ ...EMPTY_FORM });
setErrors({});
setDialogOpen(true);
}, []);
const openEdit = useCallback((cluster: Cluster) => {
setEditingCluster(cluster);
setForm({
name: cluster.name,
description: cluster.description ?? '',
environment: cluster.environment,
tags: (cluster.tags ?? []).join(', '),
serverIds: cluster.serverIds ?? [],
});
setErrors({});
setDialogOpen(true);
}, []);
const handleChange = useCallback(
(field: keyof ClusterFormData, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }));
setErrors((prev) => {
const next = { ...prev };
delete next[field];
return next;
});
},
[],
);
const handleToggleServer = useCallback((serverId: string) => {
setForm((prev) => {
const exists = prev.serverIds.includes(serverId);
return {
...prev,
serverIds: exists
? prev.serverIds.filter((id) => id !== serverId)
: [...prev.serverIds, serverId],
};
});
}, []);
const handleSubmit = useCallback(() => {
if (!validate()) return;
const body: Record<string, unknown> = {
name: form.name.trim(),
description: form.description.trim(),
environment: form.environment,
tags: form.tags
.split(',')
.map((t) => t.trim())
.filter(Boolean),
serverIds: form.serverIds,
};
if (editingCluster) {
updateMutation.mutate({ id: editingCluster.id, body });
} else {
createMutation.mutate(body);
}
}, [form, editingCluster, 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">{t('clusters.title')}</h1>
<p className="text-sm text-muted-foreground mt-1">
{t('clusters.subtitle')}
</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"
>
{t('clusters.addCluster')}
</button>
</div>
{/* Error state */}
{error && (
<div className="p-4 mb-4 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-sm">
{t('clusters.loadError')} {(error as Error).message}
</div>
)}
{/* Loading state */}
{isLoading && (
<div className="text-sm text-muted-foreground py-12 text-center">
{t('clusters.loading')}
</div>
)}
{/* Empty state */}
{!isLoading && !error && clusters.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
<p className="text-sm">{t('clusters.empty')}</p>
<p className="text-xs mt-1">{t('clusters.subtitle')}</p>
</div>
)}
{/* Cluster cards grid */}
{!isLoading && !error && clusters.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{clusters.map((cluster) => (
<div
key={cluster.id}
className="bg-card border rounded-lg p-4 hover:shadow-sm transition-shadow"
>
{/* Card header: name + environment */}
<div className="flex items-start justify-between gap-2 mb-2">
<h3 className="font-semibold text-base truncate">{cluster.name}</h3>
<EnvironmentBadge environment={cluster.environment} />
</div>
{/* Description */}
{cluster.description && (
<p className="text-sm text-muted-foreground mb-3 line-clamp-2">
{cluster.description}
</p>
)}
{/* Server count badge */}
<div className="flex items-center gap-2 mb-3">
<span className="inline-flex items-center bg-muted px-2 py-0.5 rounded text-xs font-medium">
{cluster.serverCount} server{cluster.serverCount !== 1 ? 's' : ''}
</span>
</div>
{/* Tags */}
{cluster.tags && cluster.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{cluster.tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-muted/60 text-muted-foreground"
>
{tag}
</span>
))}
</div>
)}
{/* Health summary */}
<div className="mb-4">
<HealthSummary summary={cluster.healthySummary} />
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-3 border-t">
<button
onClick={() => openEdit(cluster)}
className="px-3 py-1 text-xs rounded-md border border-input hover:bg-accent transition-colors"
>
{tc('edit')}
</button>
<button
onClick={() => setDeleteTarget(cluster)}
className="px-3 py-1 text-xs rounded-md border border-destructive/50 text-destructive hover:bg-destructive/10 transition-colors"
>
{tc('delete')}
</button>
</div>
</div>
))}
</div>
)}
{/* Add / Edit dialog */}
<ClusterDialog
open={dialogOpen}
title={editingCluster ? t('clusters.editCluster') : t('clusters.addCluster')}
form={form}
errors={errors}
saving={isSaving}
servers={servers}
serversLoading={serversLoading}
onClose={closeDialog}
onChange={handleChange}
onToggleServer={handleToggleServer}
onSubmit={handleSubmit}
/>
{/* Delete confirmation dialog */}
<DeleteDialog
open={!!deleteTarget}
clusterName={deleteTarget?.name ?? ''}
deleting={deleteMutation.isPending}
onClose={() => setDeleteTarget(null)}
onConfirm={() => {
if (deleteTarget) deleteMutation.mutate(deleteTarget.id);
}}
/>
</div>
);
}