mirror of
https://github.com/YuzuZensai/Minikura.git
synced 2026-09-13 18:59:25 +00:00
✨ feat: improve Kubernetes resource monitoring
This commit is contained in:
+192
-465
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user