feat: improve Kubernetes resource monitoring

This commit is contained in:
2026-08-13 02:19:26 +07:00
parent bd473863f3
commit e4729d4d57
4 changed files with 328 additions and 482 deletions
+192 -465
View File
@@ -5,221 +5,175 @@ import type {
DeploymentInfo, DeploymentInfo,
K8sConfigMapSummary, K8sConfigMapSummary,
K8sServiceSummary, K8sServiceSummary,
K8sStatus,
PodInfo, PodInfo,
StatefulSetInfo, StatefulSetInfo,
} from "@minikura/api"; } from "@minikura/api";
import { AlertCircle, CheckCircle2, XCircle } from "lucide-react"; import { AlertCircle, CheckCircle2, RefreshCw, XCircle } from "lucide-react";
import { useEffect, useState } from "react"; import { K8sPhaseBadge } from "@/components/k8s/k8s-phase-badge";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { import {
Table, type K8sResourceColumn,
TableBody, K8sResourceTableCard,
TableCell, } from "@/components/k8s/k8s-resource-table-card";
TableHead, import { PageHeader, PageShell, StatePanel } from "@/components/page-layout";
TableHeader, import { Badge } from "@/components/ui/badge";
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/lib/api-client"; import { useK8sResources } from "@/hooks/use-k8s-resources";
const secondaryCell = "text-sm text-muted-foreground";
const podColumns = [
{ header: "Name", render: (pod) => pod.name, className: "font-medium" },
{ header: "Status", render: (pod) => <K8sPhaseBadge phase={pod.status} /> },
{ header: "Ready", render: (pod) => pod.ready },
{ header: "Restarts", render: (pod) => pod.restarts },
{ header: "Node", render: (pod) => pod.nodeName || "-", className: secondaryCell },
{ header: "Age", render: (pod) => pod.age, className: secondaryCell },
] satisfies readonly K8sResourceColumn<PodInfo>[];
const deploymentColumns = [
{ header: "Name", render: (deployment) => deployment.name, className: "font-medium" },
{ header: "Ready", render: (deployment) => deployment.ready },
{
header: "Up-to-date",
render: (deployment) => deployment.upToDate ?? deployment.updated,
},
{ header: "Available", render: (deployment) => deployment.available ?? 0 },
{ header: "Age", render: (deployment) => deployment.age, className: secondaryCell },
] satisfies readonly K8sResourceColumn<DeploymentInfo>[];
const statefulSetColumns = [
{ header: "Name", render: (statefulSet) => statefulSet.name, className: "font-medium" },
{ header: "Ready", render: (statefulSet) => statefulSet.ready },
{ header: "Desired", render: (statefulSet) => statefulSet.desired },
{ header: "Current", render: (statefulSet) => statefulSet.current },
{ header: "Age", render: (statefulSet) => statefulSet.age, className: secondaryCell },
] satisfies readonly K8sResourceColumn<StatefulSetInfo>[];
const serviceColumns = [
{ header: "Name", render: (service) => service.name, className: "font-medium" },
{
header: "Type",
render: (service) => <Badge variant="outline">{service.type}</Badge>,
},
{
header: "Cluster IP",
render: (service) => service.clusterIP,
className: secondaryCell,
},
{
header: "External IP",
render: (service) => service.externalIP,
className: secondaryCell,
},
{ header: "Ports", render: (service) => service.ports, className: secondaryCell },
{ header: "Age", render: (service) => service.age, className: secondaryCell },
] satisfies readonly K8sResourceColumn<K8sServiceSummary>[];
const configMapColumns = [
{ header: "Name", render: (configMap) => configMap.name, className: "font-medium" },
{ header: "Data Keys", render: (configMap) => configMap.data },
{ header: "Age", render: (configMap) => configMap.age, className: secondaryCell },
] satisfies readonly K8sResourceColumn<K8sConfigMapSummary>[];
const customResourceColumns = [
{ header: "Name", render: (resource) => resource.name, className: "font-medium" },
{
header: "Status",
render: (resource) => <K8sPhaseBadge phase={resource.status?.phase} />,
},
{ header: "Age", render: (resource) => resource.age, className: secondaryCell },
] satisfies readonly K8sResourceColumn<CustomResourceSummary>[];
export default function K8sResourcesPage() { export default function K8sResourcesPage() {
const [status, setStatus] = useState<K8sStatus | null>(null); const {
const [pods, setPods] = useState<PodInfo[]>([]); status,
const [deployments, setDeployments] = useState<DeploymentInfo[]>([]); pods,
const [statefulSets, setStatefulSets] = useState<StatefulSetInfo[]>([]); deployments,
const [services, setServices] = useState<K8sServiceSummary[]>([]); statefulSets,
const [configMaps, setConfigMaps] = useState<K8sConfigMapSummary[]>([]); services,
const [minecraftServers, setMinecraftServers] = useState<CustomResourceSummary[]>([]); configMaps,
const [reverseProxyServers, setReverseProxyServers] = useState<CustomResourceSummary[]>([]); minecraftServers,
const [loading, setLoading] = useState(true); reverseProxyServers,
const [error, setError] = useState<string | null>(null); initialLoading,
refreshing,
error,
} = useK8sResources();
const fetchData = async () => { const pageHeader = (
try { <PageHeader
setLoading(true); eyebrow="Kubernetes"
setError(null); title="Resources"
description="Inspect workload, networking, configuration, and custom resources."
const [ actions={
statusRes, status?.initialized ? (
podsRes, <div className="flex items-center gap-3 border border-border bg-card px-4 py-3">
deploymentsRes, <CheckCircle2 className="size-5 text-primary" />
statefulSetsRes, <div>
servicesRes, <p className="font-mono text-[9px] font-bold uppercase tracking-wider text-muted-foreground">
configMapsRes, Cluster link
minecraftServersRes, </p>
reverseProxyServersRes, <span className="text-sm font-bold">Connected</span>
] = await Promise.allSettled([ </div>
api.api.k8s.status.get(), {refreshing && <RefreshCw className="ml-2 size-3 animate-spin text-muted-foreground" />}
api.api.k8s.pods.get(), </div>
api.api.k8s.deployments.get(), ) : undefined
api.api.k8s.statefulsets.get(),
api.api.k8s.services.get(),
api.api.k8s.configmaps.get(),
api.api.k8s["minecraft-servers"].get(),
api.api.k8s["reverse-proxy-servers"].get(),
]);
if (statusRes.status === "fulfilled" && statusRes.value.data) {
setStatus(statusRes.value.data as K8sStatus);
} }
/>
);
if (podsRes.status === "fulfilled" && podsRes.value.data) { if (initialLoading && !status) {
setPods(podsRes.value.data as PodInfo[]);
}
if (deploymentsRes.status === "fulfilled" && deploymentsRes.value.data) {
setDeployments(deploymentsRes.value.data as DeploymentInfo[]);
}
if (statefulSetsRes.status === "fulfilled" && statefulSetsRes.value.data) {
setStatefulSets(statefulSetsRes.value.data as StatefulSetInfo[]);
}
if (servicesRes.status === "fulfilled" && servicesRes.value.data) {
setServices(servicesRes.value.data as K8sServiceSummary[]);
}
if (configMapsRes.status === "fulfilled" && configMapsRes.value.data) {
setConfigMaps(configMapsRes.value.data as K8sConfigMapSummary[]);
}
if (minecraftServersRes.status === "fulfilled" && minecraftServersRes.value.data) {
setMinecraftServers(minecraftServersRes.value.data as CustomResourceSummary[]);
}
if (reverseProxyServersRes.status === "fulfilled" && reverseProxyServersRes.value.data) {
setReverseProxyServers(reverseProxyServersRes.value.data as CustomResourceSummary[]);
}
} catch (err: unknown) {
const errorMessage =
err instanceof Error ? err.message : "Failed to fetch Kubernetes resources";
setError(errorMessage);
} finally {
setLoading(false);
}
};
// biome-ignore lint/correctness/useExhaustiveDependencies: fetchData intentionally omitted to avoid infinite loop
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 30000);
return () => clearInterval(interval);
}, []);
const getStatusBadge = (phase: string) => {
const variants: Record<
string,
{
icon: React.ComponentType<{ className?: string }>;
variant: "default" | "destructive" | "secondary";
}
> = {
Running: { icon: CheckCircle2, variant: "default" },
Succeeded: { icon: CheckCircle2, variant: "default" },
Failed: { icon: XCircle, variant: "destructive" },
Pending: { icon: AlertCircle, variant: "secondary" },
Unknown: { icon: AlertCircle, variant: "secondary" },
};
const status = variants[phase] || variants.Unknown;
const Icon = status.icon;
return ( return (
<Badge variant={status.variant}> <PageShell>
<Icon className="mr-1 h-3 w-3" /> {pageHeader}
{phase} <StatePanel loading title="Loading Kubernetes resources..." className="h-64" />
</Badge> </PageShell>
);
};
if (loading && !status) {
return (
<div className="space-y-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">Kubernetes Resources</h1>
<p className="text-muted-foreground">View and monitor your Kubernetes resources</p>
</div>
<Card>
<CardHeader>
<Skeleton className="h-6 w-48" />
</CardHeader>
<CardContent>
<Skeleton className="h-40 w-full" />
</CardContent>
</Card>
</div>
); );
} }
if (error) { if (error) {
return ( return (
<div className="space-y-4"> <PageShell>
<div> {pageHeader}
<h1 className="text-3xl font-bold tracking-tight">Kubernetes Resources</h1> <StatePanel
<p className="text-muted-foreground">View and monitor your Kubernetes resources</p> title="Unable to load Kubernetes resources"
</div> description={error}
<Card> icon={<XCircle className="size-6" />}
<CardHeader> tone="error"
<CardTitle className="text-destructive flex items-center gap-2"> />
<XCircle className="h-5 w-5" /> </PageShell>
Error
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{error}</p>
</CardContent>
</Card>
</div>
); );
} }
if (!status?.initialized) { if (!status?.initialized) {
return ( return (
<div className="space-y-4"> <PageShell>
<div> {pageHeader}
<h1 className="text-3xl font-bold tracking-tight">Kubernetes Resources</h1> <StatePanel
<p className="text-muted-foreground">View and monitor your Kubernetes resources</p> title="Kubernetes not connected"
</div> icon={<AlertCircle className="size-6 text-yellow-500" />}
<Card> description={
<CardHeader> <>
<CardTitle className="flex items-center gap-2"> <p>Ensure the operator is running with a valid Kubernetes configuration.</p>
<AlertCircle className="h-5 w-5 text-yellow-500" /> <p className="mt-2">
Kubernetes Not Connected Set{" "}
</CardTitle> <code className="rounded bg-muted px-1 py-0.5">
<CardDescription> KUBERNETES_SKIP_TLS_VERIFY=true
The Kubernetes client is not initialized. Please ensure the operator is running with </code>{" "}
proper Kubernetes configuration. when using self-signed certificates.
</CardDescription> </p>
</CardHeader> </>
<CardContent> }
<p className="text-sm text-muted-foreground"> />
Set{" "} </PageShell>
<code className="bg-muted px-1 py-0.5 rounded">KUBERNETES_SKIP_TLS_VERIFY=true</code>{" "}
if using self-signed certificates.
</p>
</CardContent>
</Card>
</div>
); );
} }
return ( return (
<div className="space-y-4"> <PageShell>
<div> {pageHeader}
<h1 className="text-3xl font-bold tracking-tight">Kubernetes Resources</h1>
<p className="text-muted-foreground">View and monitor your Kubernetes resources</p>
</div>
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-500" />
<span className="text-sm font-medium">Connected to Kubernetes</span>
</div>
<Tabs defaultValue="pods" className="space-y-4"> <Tabs defaultValue="pods" className="space-y-4">
<TabsList> <TabsList className="w-full justify-start overflow-x-auto">
<TabsTrigger value="pods">Pods ({pods.length})</TabsTrigger> <TabsTrigger value="pods">Pods ({pods.length})</TabsTrigger>
<TabsTrigger value="deployments">Deployments ({deployments.length})</TabsTrigger> <TabsTrigger value="deployments">Deployments ({deployments.length})</TabsTrigger>
<TabsTrigger value="statefulsets">StatefulSets ({statefulSets.length})</TabsTrigger> <TabsTrigger value="statefulsets">StatefulSets ({statefulSets.length})</TabsTrigger>
@@ -232,302 +186,75 @@ export default function K8sResourcesPage() {
</TabsList> </TabsList>
<TabsContent value="pods" className="space-y-4"> <TabsContent value="pods" className="space-y-4">
<Card> <K8sResourceTableCard
<CardHeader> title="Pods"
<CardTitle>Pods</CardTitle> description="Running pods in the minikura namespace"
<CardDescription>Running pods in the minikura namespace</CardDescription> emptyMessage="No pods found"
</CardHeader> resources={pods}
<CardContent> columns={podColumns}
{pods.length === 0 ? ( />
<p className="text-sm text-muted-foreground">No pods found</p>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Ready</TableHead>
<TableHead>Restarts</TableHead>
<TableHead>Node</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pods.map((pod) => (
<TableRow key={pod.name}>
<TableCell className="font-medium">{pod.name}</TableCell>
<TableCell>{getStatusBadge(pod.status)}</TableCell>
<TableCell>{pod.ready}</TableCell>
<TableCell>{pod.restarts}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{pod.nodeName || "-"}
</TableCell>
<TableCell className="text-sm text-muted-foreground">{pod.age}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="deployments" className="space-y-4"> <TabsContent value="deployments" className="space-y-4">
<Card> <K8sResourceTableCard
<CardHeader> title="Deployments"
<CardTitle>Deployments</CardTitle> description="Deployments in the minikura namespace"
<CardDescription>Deployments in the minikura namespace</CardDescription> emptyMessage="No deployments found"
</CardHeader> resources={deployments}
<CardContent> columns={deploymentColumns}
{deployments.length === 0 ? ( />
<p className="text-sm text-muted-foreground">No deployments found</p>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Ready</TableHead>
<TableHead>Up-to-date</TableHead>
<TableHead>Available</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{deployments.map((deployment) => (
<TableRow key={deployment.name}>
<TableCell className="font-medium">{deployment.name}</TableCell>
<TableCell>{deployment.ready}</TableCell>
<TableCell>{deployment.upToDate ?? deployment.updated}</TableCell>
<TableCell>{deployment.available ?? 0}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{deployment.age}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="statefulsets" className="space-y-4"> <TabsContent value="statefulsets" className="space-y-4">
<Card> <K8sResourceTableCard
<CardHeader> title="StatefulSets"
<CardTitle>StatefulSets</CardTitle> description="StatefulSets in the minikura namespace"
<CardDescription>StatefulSets in the minikura namespace</CardDescription> emptyMessage="No statefulsets found"
</CardHeader> resources={statefulSets}
<CardContent> columns={statefulSetColumns}
{statefulSets.length === 0 ? ( />
<p className="text-sm text-muted-foreground">No statefulsets found</p>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Ready</TableHead>
<TableHead>Desired</TableHead>
<TableHead>Current</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{statefulSets.map((statefulSet) => (
<TableRow key={statefulSet.name}>
<TableCell className="font-medium">{statefulSet.name}</TableCell>
<TableCell>{statefulSet.ready}</TableCell>
<TableCell>{statefulSet.desired}</TableCell>
<TableCell>{statefulSet.current}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{statefulSet.age}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="services" className="space-y-4"> <TabsContent value="services" className="space-y-4">
<Card> <K8sResourceTableCard
<CardHeader> title="Services"
<CardTitle>Services</CardTitle> description="Services in the minikura namespace"
<CardDescription>Services in the minikura namespace</CardDescription> emptyMessage="No services found"
</CardHeader> resources={services}
<CardContent> columns={serviceColumns}
{services.length === 0 ? ( />
<p className="text-sm text-muted-foreground">No services found</p>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Cluster IP</TableHead>
<TableHead>External IP</TableHead>
<TableHead>Ports</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{services.map((service) => (
<TableRow key={service.name}>
<TableCell className="font-medium">{service.name}</TableCell>
<TableCell>
<Badge variant="outline">{service.type}</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{service.clusterIP}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{service.externalIP}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{service.ports}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{service.age}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="configmaps" className="space-y-4"> <TabsContent value="configmaps" className="space-y-4">
<Card> <K8sResourceTableCard
<CardHeader> title="ConfigMaps"
<CardTitle>ConfigMaps</CardTitle> description="ConfigMaps in the minikura namespace"
<CardDescription>ConfigMaps in the minikura namespace</CardDescription> emptyMessage="No configmaps found"
</CardHeader> resources={configMaps}
<CardContent> columns={configMapColumns}
{configMaps.length === 0 ? ( />
<p className="text-sm text-muted-foreground">No configmaps found</p>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Data Keys</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{configMaps.map((cm) => (
<TableRow key={cm.name}>
<TableCell className="font-medium">{cm.name}</TableCell>
<TableCell>{cm.data}</TableCell>
<TableCell className="text-sm text-muted-foreground">{cm.age}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="minecraft" className="space-y-4"> <TabsContent value="minecraft" className="space-y-4">
<Card> <K8sResourceTableCard
<CardHeader> title="Minecraft Servers"
<CardTitle>Minecraft Servers</CardTitle> description="Custom Minecraft server resources"
<CardDescription>Custom Minecraft server resources</CardDescription> emptyMessage="No Minecraft servers found"
</CardHeader> resources={minecraftServers}
<CardContent> columns={customResourceColumns}
{minecraftServers.length === 0 ? ( />
<p className="text-sm text-muted-foreground">No Minecraft servers found</p>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{minecraftServers.map((server) => (
<TableRow key={server.name}>
<TableCell className="font-medium">{server.name}</TableCell>
<TableCell>
{server.status?.phase ? (
getStatusBadge(server.status.phase)
) : (
<Badge variant="secondary">Unknown</Badge>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{server.age}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="reverseproxy" className="space-y-4"> <TabsContent value="reverseproxy" className="space-y-4">
<Card> <K8sResourceTableCard
<CardHeader> title="Reverse Proxy Servers"
<CardTitle>Reverse Proxy Servers</CardTitle> description="Custom reverse proxy server resources"
<CardDescription>Custom reverse proxy server resources</CardDescription> emptyMessage="No reverse proxy servers found"
</CardHeader> resources={reverseProxyServers}
<CardContent> columns={customResourceColumns}
{reverseProxyServers.length === 0 ? ( />
<p className="text-sm text-muted-foreground">No reverse proxy servers found</p>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{reverseProxyServers.map((server) => (
<TableRow key={server.name}>
<TableCell className="font-medium">{server.name}</TableCell>
<TableCell>
{server.status?.phase ? (
getStatusBadge(server.status.phase)
) : (
<Badge variant="secondary">Unknown</Badge>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{server.age}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </PageShell>
); );
} }
@@ -0,0 +1,17 @@
import { StatusBadge, type StatusTone } from "@/components/status-badge";
const phaseTones: Record<string, StatusTone> = {
Running: "success",
Succeeded: "success",
Failed: "error",
Pending: "warning",
Unknown: "neutral",
};
export function K8sPhaseBadge({ phase }: { phase?: string }) {
if (!phase) {
return <StatusBadge>Unknown</StatusBadge>;
}
return <StatusBadge tone={phaseTones[phase] ?? "neutral"}>{phase}</StatusBadge>;
}
@@ -0,0 +1,48 @@
import type { ReactNode } from "react";
import { DataTable, type DataTableColumn } from "@/components/data-table";
import { SectionCard } from "@/components/section-card";
export type K8sResourceColumn<T> = {
header: string;
render: (resource: T) => ReactNode;
className?: string;
};
type K8sResourceTableCardProps<T extends { name?: string }> = {
title: string;
description: string;
emptyMessage: string;
resources: readonly T[];
columns: readonly K8sResourceColumn<T>[];
};
export function K8sResourceTableCard<T extends { name?: string }>({
title,
description,
emptyMessage,
resources,
columns,
}: K8sResourceTableCardProps<T>) {
const tableColumns: readonly DataTableColumn<T>[] = columns.map((column) => ({
id: column.header,
header: column.header,
cell: column.render,
className: column.className,
}));
return (
<SectionCard title={title} description={description}>
{resources.length === 0 ? (
<div className="border border-dashed bg-muted/25 px-4 py-12 text-center text-sm text-muted-foreground">
{emptyMessage}
</div>
) : (
<DataTable
data={resources}
columns={tableColumns}
getRowKey={(resource, index) => resource.name ?? index}
/>
)}
</SectionCard>
);
}
+71 -17
View File
@@ -6,28 +6,53 @@ import type {
K8sConfigMapSummary, K8sConfigMapSummary,
K8sServiceSummary, K8sServiceSummary,
K8sStatus, K8sStatus,
PodInfo,
StatefulSetInfo, StatefulSetInfo,
} from "@minikura/api"; } from "@minikura/api";
import { LABEL_PREFIX } from "@minikura/api"; import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
function getErrorMessage(error: unknown) {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "object" && error) {
const value = "value" in error ? error.value : error;
if (typeof value === "object" && value && "message" in value) {
return String(value.message);
}
}
return "Failed to fetch Kubernetes resources";
}
export function useK8sResources() { export function useK8sResources() {
const [status, setStatus] = useState<K8sStatus | null>(null);
const [pods, setPods] = useState<PodInfo[]>([]);
const [statefulSets, setStatefulSets] = useState<StatefulSetInfo[]>([]); const [statefulSets, setStatefulSets] = useState<StatefulSetInfo[]>([]);
const [deployments, setDeployments] = useState<DeploymentInfo[]>([]); const [deployments, setDeployments] = useState<DeploymentInfo[]>([]);
const [services, setServices] = useState<K8sServiceSummary[]>([]); const [services, setServices] = useState<K8sServiceSummary[]>([]);
const [configMaps, setConfigMaps] = useState<K8sConfigMapSummary[]>([]); const [configMaps, setConfigMaps] = useState<K8sConfigMapSummary[]>([]);
const [minecraftServers, setMinecraftServers] = useState<CustomResourceSummary[]>([]); const [minecraftServers, setMinecraftServers] = useState<CustomResourceSummary[]>([]);
const [reverseProxyServers, setReverseProxyServers] = useState<CustomResourceSummary[]>([]); const [reverseProxyServers, setReverseProxyServers] = useState<CustomResourceSummary[]>([]);
const [status, _setStatus] = useState<K8sStatus>({ initialized: false }); const [initialLoading, setInitialLoading] = useState(true);
const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const hasLoaded = useRef(false);
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
const isInitialLoad = !hasLoaded.current;
if (!isInitialLoad) {
setRefreshing(true);
}
setError(null);
try { try {
const [ const [
_statusRes, statusRes,
_podsRes, podsRes,
deploymentsRes, deploymentsRes,
statefulSetsRes, statefulSetsRes,
servicesRes, servicesRes,
@@ -45,6 +70,14 @@ export function useK8sResources() {
api.api.k8s["reverse-proxy-servers"].get(), api.api.k8s["reverse-proxy-servers"].get(),
]); ]);
if (statusRes.status === "fulfilled" && statusRes.value.data) {
setStatus(statusRes.value.data as K8sStatus);
}
if (podsRes.status === "fulfilled" && podsRes.value.data) {
setPods(podsRes.value.data as PodInfo[]);
}
if (statefulSetsRes.status === "fulfilled" && statefulSetsRes.value.data) { if (statefulSetsRes.status === "fulfilled" && statefulSetsRes.value.data) {
setStatefulSets(statefulSetsRes.value.data as StatefulSetInfo[]); setStatefulSets(statefulSetsRes.value.data as StatefulSetInfo[]);
} }
@@ -68,33 +101,54 @@ export function useK8sResources() {
if (reverseProxyServersRes.status === "fulfilled" && reverseProxyServersRes.value.data) { if (reverseProxyServersRes.status === "fulfilled" && reverseProxyServersRes.value.data) {
setReverseProxyServers(reverseProxyServersRes.value.data as CustomResourceSummary[]); setReverseProxyServers(reverseProxyServersRes.value.data as CustomResourceSummary[]);
} }
const failedRequest = [
statusRes,
podsRes,
deploymentsRes,
statefulSetsRes,
servicesRes,
configMapsRes,
minecraftServersRes,
reverseProxyServersRes,
].find((result) => result.status === "rejected" || Boolean(result.value.error));
if (failedRequest) {
setError(
getErrorMessage(
failedRequest.status === "rejected" ? failedRequest.reason : failedRequest.value.error
)
);
}
} catch (err: unknown) { } catch (err: unknown) {
const errorMessage = setError(getErrorMessage(err));
err instanceof Error ? err.message : "Failed to fetch Kubernetes resources";
setError(errorMessage);
} finally { } finally {
setLoading(false); hasLoaded.current = true;
setInitialLoading(false);
setRefreshing(false);
} }
}, []); }, []);
// biome-ignore lint/correctness/useExhaustiveDependencies: fetchData intentionally omitted to avoid infinite loop
useEffect(() => { useEffect(() => {
fetchData(); void fetchData();
const interval = setInterval(fetchData, 30000); const interval = setInterval(() => {
void fetchData();
}, 30000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, [fetchData]);
return { return {
status,
pods,
statefulSets, statefulSets,
deployments, deployments,
services, services,
configMaps, configMaps,
minecraftServers, minecraftServers,
reverseProxyServers, reverseProxyServers,
status, initialLoading,
loading, refreshing,
error, error,
refresh: fetchData, refresh: fetchData,
labelPrefix: LABEL_PREFIX,
}; };
} }