631 lines
21 KiB
TypeScript
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>
|
|
);
|
|
}
|