diff --git a/apps/web/app/dashboard/k8s/page.tsx b/apps/web/app/dashboard/k8s/page.tsx index 600e7a8..2314ea3 100644 --- a/apps/web/app/dashboard/k8s/page.tsx +++ b/apps/web/app/dashboard/k8s/page.tsx @@ -5,221 +5,175 @@ import type { DeploymentInfo, K8sConfigMapSummary, K8sServiceSummary, - K8sStatus, PodInfo, StatefulSetInfo, } from "@minikura/api"; -import { AlertCircle, CheckCircle2, XCircle } from "lucide-react"; -import { useEffect, useState } from "react"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Skeleton } from "@/components/ui/skeleton"; +import { AlertCircle, CheckCircle2, RefreshCw, XCircle } from "lucide-react"; +import { K8sPhaseBadge } from "@/components/k8s/k8s-phase-badge"; import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; + type K8sResourceColumn, + K8sResourceTableCard, +} from "@/components/k8s/k8s-resource-table-card"; +import { PageHeader, PageShell, StatePanel } from "@/components/page-layout"; +import { Badge } from "@/components/ui/badge"; 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) => }, + { 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[]; + +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[]; + +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[]; + +const serviceColumns = [ + { header: "Name", render: (service) => service.name, className: "font-medium" }, + { + header: "Type", + render: (service) => {service.type}, + }, + { + 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[]; + +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[]; + +const customResourceColumns = [ + { header: "Name", render: (resource) => resource.name, className: "font-medium" }, + { + header: "Status", + render: (resource) => , + }, + { header: "Age", render: (resource) => resource.age, className: secondaryCell }, +] satisfies readonly K8sResourceColumn[]; export default function K8sResourcesPage() { - const [status, setStatus] = useState(null); - const [pods, setPods] = useState([]); - const [deployments, setDeployments] = useState([]); - const [statefulSets, setStatefulSets] = useState([]); - const [services, setServices] = useState([]); - const [configMaps, setConfigMaps] = useState([]); - const [minecraftServers, setMinecraftServers] = useState([]); - const [reverseProxyServers, setReverseProxyServers] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const { + status, + pods, + deployments, + statefulSets, + services, + configMaps, + minecraftServers, + reverseProxyServers, + initialLoading, + refreshing, + error, + } = useK8sResources(); - const fetchData = async () => { - try { - setLoading(true); - setError(null); - - const [ - statusRes, - podsRes, - deploymentsRes, - statefulSetsRes, - servicesRes, - configMapsRes, - minecraftServersRes, - reverseProxyServersRes, - ] = await Promise.allSettled([ - api.api.k8s.status.get(), - api.api.k8s.pods.get(), - api.api.k8s.deployments.get(), - 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); + const pageHeader = ( + + +
+

+ Cluster link +

+ Connected +
+ {refreshing && } + + ) : undefined } + /> + ); - if (podsRes.status === "fulfilled" && podsRes.value.data) { - 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; - + if (initialLoading && !status) { return ( - - - {phase} - - ); - }; - - if (loading && !status) { - return ( -
-
-

Kubernetes Resources

-

View and monitor your Kubernetes resources

-
- - - - - - - - -
+ + {pageHeader} + + ); } if (error) { return ( -
-
-

Kubernetes Resources

-

View and monitor your Kubernetes resources

-
- - - - - Error - - - -

{error}

-
-
-
+ + {pageHeader} + } + tone="error" + /> + ); } if (!status?.initialized) { return ( -
-
-

Kubernetes Resources

-

View and monitor your Kubernetes resources

-
- - - - - Kubernetes Not Connected - - - The Kubernetes client is not initialized. Please ensure the operator is running with - proper Kubernetes configuration. - - - -

- Set{" "} - KUBERNETES_SKIP_TLS_VERIFY=true{" "} - if using self-signed certificates. -

-
-
-
+ + {pageHeader} + } + description={ + <> +

Ensure the operator is running with a valid Kubernetes configuration.

+

+ Set{" "} + + KUBERNETES_SKIP_TLS_VERIFY=true + {" "} + when using self-signed certificates. +

+ + } + /> +
); } return ( -
-
-

Kubernetes Resources

-

View and monitor your Kubernetes resources

-
- -
- - Connected to Kubernetes -
+ + {pageHeader} - + Pods ({pods.length}) Deployments ({deployments.length}) StatefulSets ({statefulSets.length}) @@ -232,302 +186,75 @@ export default function K8sResourcesPage() { - - - Pods - Running pods in the minikura namespace - - - {pods.length === 0 ? ( -

No pods found

- ) : ( -
- - - - Name - Status - Ready - Restarts - Node - Age - - - - {pods.map((pod) => ( - - {pod.name} - {getStatusBadge(pod.status)} - {pod.ready} - {pod.restarts} - - {pod.nodeName || "-"} - - {pod.age} - - ))} - -
-
- )} -
-
+
- - - Deployments - Deployments in the minikura namespace - - - {deployments.length === 0 ? ( -

No deployments found

- ) : ( -
- - - - Name - Ready - Up-to-date - Available - Age - - - - {deployments.map((deployment) => ( - - {deployment.name} - {deployment.ready} - {deployment.upToDate ?? deployment.updated} - {deployment.available ?? 0} - - {deployment.age} - - - ))} - -
-
- )} -
-
+
- - - StatefulSets - StatefulSets in the minikura namespace - - - {statefulSets.length === 0 ? ( -

No statefulsets found

- ) : ( -
- - - - Name - Ready - Desired - Current - Age - - - - {statefulSets.map((statefulSet) => ( - - {statefulSet.name} - {statefulSet.ready} - {statefulSet.desired} - {statefulSet.current} - - {statefulSet.age} - - - ))} - -
-
- )} -
-
+
- - - Services - Services in the minikura namespace - - - {services.length === 0 ? ( -

No services found

- ) : ( -
- - - - Name - Type - Cluster IP - External IP - Ports - Age - - - - {services.map((service) => ( - - {service.name} - - {service.type} - - - {service.clusterIP} - - - {service.externalIP} - - - {service.ports} - - - {service.age} - - - ))} - -
-
- )} -
-
+
- - - ConfigMaps - ConfigMaps in the minikura namespace - - - {configMaps.length === 0 ? ( -

No configmaps found

- ) : ( -
- - - - Name - Data Keys - Age - - - - {configMaps.map((cm) => ( - - {cm.name} - {cm.data} - {cm.age} - - ))} - -
-
- )} -
-
+
- - - Minecraft Servers - Custom Minecraft server resources - - - {minecraftServers.length === 0 ? ( -

No Minecraft servers found

- ) : ( -
- - - - Name - Status - Age - - - - {minecraftServers.map((server) => ( - - {server.name} - - {server.status?.phase ? ( - getStatusBadge(server.status.phase) - ) : ( - Unknown - )} - - - {server.age} - - - ))} - -
-
- )} -
-
+
- - - Reverse Proxy Servers - Custom reverse proxy server resources - - - {reverseProxyServers.length === 0 ? ( -

No reverse proxy servers found

- ) : ( -
- - - - Name - Status - Age - - - - {reverseProxyServers.map((server) => ( - - {server.name} - - {server.status?.phase ? ( - getStatusBadge(server.status.phase) - ) : ( - Unknown - )} - - - {server.age} - - - ))} - -
-
- )} -
-
+
-
+ ); } diff --git a/apps/web/components/k8s/k8s-phase-badge.tsx b/apps/web/components/k8s/k8s-phase-badge.tsx new file mode 100644 index 0000000..b263559 --- /dev/null +++ b/apps/web/components/k8s/k8s-phase-badge.tsx @@ -0,0 +1,17 @@ +import { StatusBadge, type StatusTone } from "@/components/status-badge"; + +const phaseTones: Record = { + Running: "success", + Succeeded: "success", + Failed: "error", + Pending: "warning", + Unknown: "neutral", +}; + +export function K8sPhaseBadge({ phase }: { phase?: string }) { + if (!phase) { + return Unknown; + } + + return {phase}; +} diff --git a/apps/web/components/k8s/k8s-resource-table-card.tsx b/apps/web/components/k8s/k8s-resource-table-card.tsx new file mode 100644 index 0000000..c56d140 --- /dev/null +++ b/apps/web/components/k8s/k8s-resource-table-card.tsx @@ -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 = { + header: string; + render: (resource: T) => ReactNode; + className?: string; +}; + +type K8sResourceTableCardProps = { + title: string; + description: string; + emptyMessage: string; + resources: readonly T[]; + columns: readonly K8sResourceColumn[]; +}; + +export function K8sResourceTableCard({ + title, + description, + emptyMessage, + resources, + columns, +}: K8sResourceTableCardProps) { + const tableColumns: readonly DataTableColumn[] = columns.map((column) => ({ + id: column.header, + header: column.header, + cell: column.render, + className: column.className, + })); + + return ( + + {resources.length === 0 ? ( +
+ {emptyMessage} +
+ ) : ( + resource.name ?? index} + /> + )} +
+ ); +} diff --git a/apps/web/hooks/use-k8s-resources.ts b/apps/web/hooks/use-k8s-resources.ts index 802ee75..cdf360a 100644 --- a/apps/web/hooks/use-k8s-resources.ts +++ b/apps/web/hooks/use-k8s-resources.ts @@ -6,28 +6,53 @@ import type { K8sConfigMapSummary, K8sServiceSummary, K8sStatus, + PodInfo, StatefulSetInfo, } from "@minikura/api"; -import { LABEL_PREFIX } from "@minikura/api"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; 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() { + const [status, setStatus] = useState(null); + const [pods, setPods] = useState([]); const [statefulSets, setStatefulSets] = useState([]); const [deployments, setDeployments] = useState([]); const [services, setServices] = useState([]); const [configMaps, setConfigMaps] = useState([]); const [minecraftServers, setMinecraftServers] = useState([]); const [reverseProxyServers, setReverseProxyServers] = useState([]); - const [status, _setStatus] = useState({ initialized: false }); - const [loading, setLoading] = useState(true); + const [initialLoading, setInitialLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); + const hasLoaded = useRef(false); const fetchData = useCallback(async () => { + const isInitialLoad = !hasLoaded.current; + + if (!isInitialLoad) { + setRefreshing(true); + } + setError(null); + try { const [ - _statusRes, - _podsRes, + statusRes, + podsRes, deploymentsRes, statefulSetsRes, servicesRes, @@ -45,6 +70,14 @@ export function useK8sResources() { 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) { setStatefulSets(statefulSetsRes.value.data as StatefulSetInfo[]); } @@ -68,33 +101,54 @@ export function useK8sResources() { if (reverseProxyServersRes.status === "fulfilled" && reverseProxyServersRes.value.data) { 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) { - const errorMessage = - err instanceof Error ? err.message : "Failed to fetch Kubernetes resources"; - setError(errorMessage); + setError(getErrorMessage(err)); } finally { - setLoading(false); + hasLoaded.current = true; + setInitialLoading(false); + setRefreshing(false); } }, []); - // biome-ignore lint/correctness/useExhaustiveDependencies: fetchData intentionally omitted to avoid infinite loop useEffect(() => { - fetchData(); - const interval = setInterval(fetchData, 30000); + void fetchData(); + const interval = setInterval(() => { + void fetchData(); + }, 30000); return () => clearInterval(interval); - }, []); + }, [fetchData]); return { + status, + pods, statefulSets, deployments, services, configMaps, minecraftServers, reverseProxyServers, - status, - loading, + initialLoading, + refreshing, error, refresh: fetchData, - labelPrefix: LABEL_PREFIX, }; }