kubeops 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +219 -0
- package/electron/main.js +429 -0
- package/electron/preload.js +13 -0
- package/electron-builder.yml +60 -0
- package/next.config.ts +8 -0
- package/package.json +98 -0
- package/postcss.config.mjs +7 -0
- package/resources/icon.icns +0 -0
- package/resources/icon.ico +0 -0
- package/resources/icon.png +0 -0
- package/resources/icon.svg +86 -0
- package/scripts/build-server.mjs +20 -0
- package/scripts/generate-icons.mjs +61 -0
- package/server.ts +58 -0
- package/src/app/api/clusters/[clusterId]/health/route.ts +27 -0
- package/src/app/api/clusters/[clusterId]/metrics/route.ts +196 -0
- package/src/app/api/clusters/[clusterId]/namespaces/route.ts +48 -0
- package/src/app/api/clusters/[clusterId]/nodes/[nodeName]/route.ts +21 -0
- package/src/app/api/clusters/[clusterId]/nodes/route.ts +31 -0
- package/src/app/api/clusters/[clusterId]/resources/[namespace]/[...resourcePath]/route.ts +204 -0
- package/src/app/api/clusters/route.ts +48 -0
- package/src/app/api/kubeconfig/route.ts +25 -0
- package/src/app/api/port-forward/route.ts +143 -0
- package/src/app/api/tsh/login/route.ts +50 -0
- package/src/app/clusters/[clusterId]/app-map/page.tsx +42 -0
- package/src/app/clusters/[clusterId]/clusterrolebindings/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/clusterrolebindings/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/clusterroles/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/clusterroles/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/layout.tsx +9 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/configmaps/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/configmaps/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/cronjobs/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/cronjobs/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/daemonsets/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/daemonsets/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/deployments/[name]/page.tsx +457 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/deployments/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/endpoints/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/endpoints/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/events/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/ingresses/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/ingresses/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/jobs/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/jobs/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/networkpolicies/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/networkpolicies/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/pods/[podName]/exec/page.tsx +173 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/pods/[podName]/logs/page.tsx +137 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/pods/[podName]/page.tsx +448 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/pods/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/pvcs/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/pvcs/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/replicasets/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/replicasets/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/rolebindings/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/rolebindings/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/roles/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/roles/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/secrets/[name]/page.tsx +168 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/secrets/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/serviceaccounts/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/serviceaccounts/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/services/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/services/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/statefulsets/[name]/page.tsx +302 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/statefulsets/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/nodes/[nodeName]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/nodes/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/page.tsx +635 -0
- package/src/app/clusters/[clusterId]/port-forwarding/page.tsx +145 -0
- package/src/app/clusters/[clusterId]/pvs/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/pvs/page.tsx +5 -0
- package/src/app/clusters/page.tsx +166 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +167 -0
- package/src/app/layout.tsx +48 -0
- package/src/app/page.tsx +5 -0
- package/src/components/clusters/cluster-selector.tsx +64 -0
- package/src/components/layout/app-shell.tsx +26 -0
- package/src/components/layout/breadcrumbs.tsx +97 -0
- package/src/components/layout/command-palette.tsx +112 -0
- package/src/components/layout/header.tsx +184 -0
- package/src/components/layout/sidebar.tsx +84 -0
- package/src/components/layout/theme-toggle.tsx +21 -0
- package/src/components/namespaces/namespace-selector.tsx +165 -0
- package/src/components/panel/bottom-panel.tsx +127 -0
- package/src/components/panel/logs-tab.tsx +109 -0
- package/src/components/panel/terminal-tab.tsx +180 -0
- package/src/components/pods/pod-watch-button.tsx +44 -0
- package/src/components/resources/resource-columns.tsx +320 -0
- package/src/components/resources/resource-detail-page.tsx +191 -0
- package/src/components/resources/resource-list-page.tsx +78 -0
- package/src/components/resources/scale-dialog.tsx +107 -0
- package/src/components/settings/settings-dialog.tsx +103 -0
- package/src/components/shared/age-display.tsx +27 -0
- package/src/components/shared/confirm-dialog.tsx +52 -0
- package/src/components/shared/data-table.tsx +149 -0
- package/src/components/shared/env-value-resolver.tsx +570 -0
- package/src/components/shared/error-display.tsx +109 -0
- package/src/components/shared/loading-skeleton.tsx +25 -0
- package/src/components/shared/metrics-charts-impl.tsx +434 -0
- package/src/components/shared/metrics-charts.tsx +24 -0
- package/src/components/shared/port-forward-btn.tsx +60 -0
- package/src/components/shared/resource-info-drawer.tsx +542 -0
- package/src/components/shared/resource-node.tsx +157 -0
- package/src/components/shared/resource-tree-impl.tsx +228 -0
- package/src/components/shared/resource-tree.tsx +20 -0
- package/src/components/shared/status-badge.tsx +35 -0
- package/src/components/shared/yaml-editor.tsx +438 -0
- package/src/components/ui/badge.tsx +48 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/card.tsx +92 -0
- package/src/components/ui/command.tsx +184 -0
- package/src/components/ui/dialog.tsx +158 -0
- package/src/components/ui/dropdown-menu.tsx +257 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/popover.tsx +89 -0
- package/src/components/ui/scroll-area.tsx +58 -0
- package/src/components/ui/select.tsx +190 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +143 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/sonner.tsx +40 -0
- package/src/components/ui/table.tsx +116 -0
- package/src/components/ui/tabs.tsx +91 -0
- package/src/components/ui/tooltip.tsx +57 -0
- package/src/hooks/use-age-tick.ts +40 -0
- package/src/hooks/use-auto-update.ts +100 -0
- package/src/hooks/use-clusters.ts +32 -0
- package/src/hooks/use-namespaces.ts +17 -0
- package/src/hooks/use-pod-watcher.ts +79 -0
- package/src/hooks/use-port-forwards.ts +18 -0
- package/src/hooks/use-resource-detail.ts +28 -0
- package/src/hooks/use-resource-list.ts +26 -0
- package/src/hooks/use-resource-tree.ts +440 -0
- package/src/lib/api-client.ts +31 -0
- package/src/lib/constants.ts +126 -0
- package/src/lib/k8s/client-factory.ts +57 -0
- package/src/lib/k8s/kubeconfig-manager.ts +43 -0
- package/src/lib/k8s/resource-api.ts +223 -0
- package/src/lib/k8s/types.ts +29 -0
- package/src/lib/utils.ts +6 -0
- package/src/providers/swr-provider.tsx +20 -0
- package/src/providers/theme-provider.tsx +17 -0
- package/src/stores/cluster-store.ts +32 -0
- package/src/stores/namespace-store.ts +27 -0
- package/src/stores/panel-store.ts +61 -0
- package/src/stores/pod-watcher-store.ts +69 -0
- package/src/stores/settings-store.ts +24 -0
- package/src/stores/sidebar-store.ts +22 -0
- package/src/types/cluster.ts +19 -0
- package/src/types/css.d.ts +6 -0
- package/src/types/electron.d.ts +25 -0
- package/src/types/navigation.ts +4 -0
- package/src/types/resource.ts +27 -0
- package/tsconfig.json +34 -0
- package/ws/exec-handler.ts +112 -0
- package/ws/index.ts +2 -0
- package/ws/logs-handler.ts +70 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Skeleton } from '@/components/ui/skeleton';
|
|
2
|
+
|
|
3
|
+
export function LoadingSkeleton() {
|
|
4
|
+
return (
|
|
5
|
+
<div className="flex flex-col gap-4 p-6">
|
|
6
|
+
<Skeleton className="h-8 w-48" />
|
|
7
|
+
<div className="flex flex-col gap-2">
|
|
8
|
+
{Array.from({ length: 8 }).map((_, i) => (
|
|
9
|
+
<Skeleton key={i} className="h-12 w-full" />
|
|
10
|
+
))}
|
|
11
|
+
</div>
|
|
12
|
+
</div>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function TableSkeleton() {
|
|
17
|
+
return (
|
|
18
|
+
<div className="flex flex-col gap-2">
|
|
19
|
+
<Skeleton className="h-10 w-full" />
|
|
20
|
+
{Array.from({ length: 6 }).map((_, i) => (
|
|
21
|
+
<Skeleton key={i} className="h-12 w-full" />
|
|
22
|
+
))}
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import useSWR from 'swr';
|
|
5
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
6
|
+
import { cn } from '@/lib/utils';
|
|
7
|
+
import { Cpu, MemoryStick, Network, HardDrive } from 'lucide-react';
|
|
8
|
+
import {
|
|
9
|
+
AreaChart, Area, BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, CartesianGrid,
|
|
10
|
+
} from 'recharts';
|
|
11
|
+
|
|
12
|
+
interface MetricsPoint {
|
|
13
|
+
time: string;
|
|
14
|
+
cpu: number; // millicores
|
|
15
|
+
memory: number; // MiB
|
|
16
|
+
netRx?: number; // KB/s
|
|
17
|
+
netTx?: number; // KB/s
|
|
18
|
+
fsRead?: number; // KB/s
|
|
19
|
+
fsWrite?: number; // KB/s
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseCpu(val: string): number {
|
|
23
|
+
if (!val) return 0;
|
|
24
|
+
if (val.endsWith('n')) return parseInt(val) / 1_000_000;
|
|
25
|
+
if (val.endsWith('u')) return parseInt(val) / 1_000;
|
|
26
|
+
if (val.endsWith('m')) return parseInt(val);
|
|
27
|
+
return parseFloat(val) * 1000;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseMemory(val: string): number {
|
|
31
|
+
if (!val) return 0;
|
|
32
|
+
if (val.endsWith('Ki')) return parseInt(val) / 1024;
|
|
33
|
+
if (val.endsWith('Mi')) return parseInt(val);
|
|
34
|
+
if (val.endsWith('Gi')) return parseInt(val) * 1024;
|
|
35
|
+
if (val.endsWith('k')) return parseInt(val) / 1024;
|
|
36
|
+
if (val.endsWith('M')) return parseInt(val);
|
|
37
|
+
if (val.endsWith('G')) return parseInt(val) * 1024;
|
|
38
|
+
return parseInt(val) / (1024 * 1024); // bytes to MiB
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function formatCpu(val: number): string {
|
|
42
|
+
if (val >= 1000) return `${(val / 1000).toFixed(1)} cores`;
|
|
43
|
+
return `${Math.round(val)}m`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function formatMemory(val: number): string {
|
|
47
|
+
if (val >= 1024) return `${(val / 1024).toFixed(1)} GiB`;
|
|
48
|
+
return `${Math.round(val)} MiB`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function formatNetRate(val: number): string {
|
|
52
|
+
if (val >= 1024) return `${(val / 1024).toFixed(1)} MB/s`;
|
|
53
|
+
return `${Math.round(val)} KB/s`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function MetricTooltip({ active, payload, label }: any) {
|
|
57
|
+
if (!active || !payload?.length) return null;
|
|
58
|
+
return (
|
|
59
|
+
<div className="rounded-md border bg-popover px-3 py-2 text-xs shadow-md space-y-1">
|
|
60
|
+
<p className="text-muted-foreground">{label}</p>
|
|
61
|
+
{payload.map((p: any) => {
|
|
62
|
+
let text = '';
|
|
63
|
+
if (p.dataKey === 'cpu') text = `CPU: ${formatCpu(p.value)}`;
|
|
64
|
+
else if (p.dataKey === 'memory') text = `Memory: ${formatMemory(p.value)}`;
|
|
65
|
+
else if (p.dataKey === 'netRx') text = `Rx: ${formatNetRate(p.value)}`;
|
|
66
|
+
else if (p.dataKey === 'netTx') text = `Tx: ${formatNetRate(p.value)}`;
|
|
67
|
+
else if (p.dataKey === 'fsRead') text = `Read: ${formatNetRate(p.value)}`;
|
|
68
|
+
else if (p.dataKey === 'fsWrite') text = `Write: ${formatNetRate(p.value)}`;
|
|
69
|
+
else text = `${p.dataKey}: ${p.value}`;
|
|
70
|
+
return <p key={p.dataKey} style={{ color: p.color }}>{text}</p>;
|
|
71
|
+
})}
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// === Pod Metrics ===
|
|
77
|
+
interface PodMetricsProps {
|
|
78
|
+
clusterId: string;
|
|
79
|
+
namespace: string;
|
|
80
|
+
podName: string;
|
|
81
|
+
nodeName?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function PodMetricsCharts({ clusterId, namespace, podName, nodeName }: PodMetricsProps) {
|
|
85
|
+
const prevNetRef = useRef<{ rx: number; tx: number; ts: number } | null>(null);
|
|
86
|
+
const prevFsRef = useRef<{ read: number; write: number; ts: number } | null>(null);
|
|
87
|
+
const [history, setHistory] = useState<MetricsPoint[]>([]);
|
|
88
|
+
|
|
89
|
+
const { data } = useSWR(
|
|
90
|
+
`/api/clusters/${encodeURIComponent(clusterId)}/metrics?type=pods&namespace=${namespace}&name=${podName}`,
|
|
91
|
+
{ refreshInterval: 5000 }
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// Prometheus for network & filesystem I/O (with node fallback)
|
|
95
|
+
const { data: promData, error: promError } = useSWR(
|
|
96
|
+
`/api/clusters/${encodeURIComponent(clusterId)}/metrics?type=prometheus&namespace=${namespace}&name=${podName}${nodeName ? `&node=${nodeName}` : ''}`,
|
|
97
|
+
{ refreshInterval: 5000, revalidateOnFocus: false }
|
|
98
|
+
);
|
|
99
|
+
const promUnavailable = promError?.status === 404;
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (!data?.containers) return;
|
|
103
|
+
const containers = data.containers || [];
|
|
104
|
+
let totalCpu = 0;
|
|
105
|
+
let totalMem = 0;
|
|
106
|
+
containers.forEach((c: any) => {
|
|
107
|
+
totalCpu += parseCpu(c.usage?.cpu || '0');
|
|
108
|
+
totalMem += parseMemory(c.usage?.memory || '0');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Network & FS rate from Prometheus (cumulative counters → rate)
|
|
112
|
+
let netRx = 0, netTx = 0, fsRead = 0, fsWrite = 0;
|
|
113
|
+
if (promData && !promData.error) {
|
|
114
|
+
const rxBytes = promData.netRxBytes || 0;
|
|
115
|
+
const txBytes = promData.netTxBytes || 0;
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
if (prevNetRef.current) {
|
|
118
|
+
const dt = (now - prevNetRef.current.ts) / 1000;
|
|
119
|
+
if (dt > 0) {
|
|
120
|
+
netRx = Math.max(0, (rxBytes - prevNetRef.current.rx) / 1024 / dt);
|
|
121
|
+
netTx = Math.max(0, (txBytes - prevNetRef.current.tx) / 1024 / dt);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
prevNetRef.current = { rx: rxBytes, tx: txBytes, ts: now };
|
|
125
|
+
|
|
126
|
+
const readB = promData.fsReadBytes || 0;
|
|
127
|
+
const writeB = promData.fsWriteBytes || 0;
|
|
128
|
+
if (prevFsRef.current) {
|
|
129
|
+
const dt = (now - prevFsRef.current.ts) / 1000;
|
|
130
|
+
if (dt > 0) {
|
|
131
|
+
fsRead = Math.max(0, (readB - prevFsRef.current.read) / 1024 / dt);
|
|
132
|
+
fsWrite = Math.max(0, (writeB - prevFsRef.current.write) / 1024 / dt);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
prevFsRef.current = { read: readB, write: writeB, ts: now };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const now = new Date();
|
|
139
|
+
const point: MetricsPoint = {
|
|
140
|
+
time: now.toLocaleTimeString('en', { hour12: false, minute: '2-digit', second: '2-digit' }),
|
|
141
|
+
cpu: Math.round(totalCpu * 10) / 10,
|
|
142
|
+
memory: Math.round(totalMem * 10) / 10,
|
|
143
|
+
netRx: Math.round(netRx * 10) / 10,
|
|
144
|
+
netTx: Math.round(netTx * 10) / 10,
|
|
145
|
+
fsRead: Math.round(fsRead * 10) / 10,
|
|
146
|
+
fsWrite: Math.round(fsWrite * 10) / 10,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
setHistory((prev) => [...prev, point].slice(-60)); // Keep 5 min at 5s interval
|
|
150
|
+
}, [data, promData]);
|
|
151
|
+
|
|
152
|
+
if (history.length === 0) {
|
|
153
|
+
return (
|
|
154
|
+
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
155
|
+
{[{ icon: Cpu, label: 'CPU Usage', color: 'text-blue-500' }, { icon: MemoryStick, label: 'Memory Usage', color: 'text-purple-500' }, { icon: Network, label: 'Network I/O', color: 'text-emerald-500' }, { icon: HardDrive, label: 'Filesystem I/O', color: 'text-cyan-500' }].map((m) => (
|
|
156
|
+
<Card key={m.label}>
|
|
157
|
+
<CardHeader className="pb-1">
|
|
158
|
+
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
|
159
|
+
<m.icon className={`h-4 w-4 ${m.color}`} />{m.label}
|
|
160
|
+
</CardTitle>
|
|
161
|
+
</CardHeader>
|
|
162
|
+
<CardContent className="pt-2 flex items-center justify-center h-[120px] text-xs text-muted-foreground">
|
|
163
|
+
Waiting for metrics...
|
|
164
|
+
</CardContent>
|
|
165
|
+
</Card>
|
|
166
|
+
))}
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const latest = history[history.length - 1];
|
|
172
|
+
const latestCpu = latest?.cpu || 0;
|
|
173
|
+
const latestMem = latest?.memory || 0;
|
|
174
|
+
const latestRx = latest?.netRx || 0;
|
|
175
|
+
const latestTx = latest?.netTx || 0;
|
|
176
|
+
const hasNetData = history.some(h => (h.netRx || 0) > 0 || (h.netTx || 0) > 0);
|
|
177
|
+
const hasFsData = history.some(h => (h.fsRead || 0) > 0 || (h.fsWrite || 0) > 0);
|
|
178
|
+
const latestFsRead = latest?.fsRead || 0;
|
|
179
|
+
const latestFsWrite = latest?.fsWrite || 0;
|
|
180
|
+
|
|
181
|
+
// After 12+ data points (~1 min) with promData loaded, if still 0 → metric doesn't exist
|
|
182
|
+
const promLoaded = !!promData && !promError;
|
|
183
|
+
const enoughSamples = history.length >= 12;
|
|
184
|
+
const netUnavailable = promUnavailable || (promLoaded && enoughSamples && !hasNetData);
|
|
185
|
+
const fsUnavailable = promUnavailable || (promLoaded && enoughSamples && !hasFsData);
|
|
186
|
+
|
|
187
|
+
const chartCount = 2 + (netUnavailable ? 0 : 1) + (fsUnavailable ? 0 : 1);
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<div className={cn('grid gap-4', chartCount <= 2 ? 'md:grid-cols-2' : 'md:grid-cols-2 xl:grid-cols-4')}>
|
|
191
|
+
<Card>
|
|
192
|
+
<CardHeader className="pb-1">
|
|
193
|
+
<CardTitle className="text-sm font-medium flex items-center justify-between">
|
|
194
|
+
<div className="flex items-center gap-2">
|
|
195
|
+
<Cpu className="h-4 w-4 text-blue-500" />
|
|
196
|
+
CPU Usage
|
|
197
|
+
</div>
|
|
198
|
+
<span className="text-base font-bold text-blue-500">{formatCpu(latestCpu)}</span>
|
|
199
|
+
</CardTitle>
|
|
200
|
+
</CardHeader>
|
|
201
|
+
<CardContent className="pt-2">
|
|
202
|
+
<ResponsiveContainer width="100%" height={120}>
|
|
203
|
+
<AreaChart data={history} margin={{ top: 5, right: 5, bottom: 0, left: 5 }}>
|
|
204
|
+
<defs>
|
|
205
|
+
<linearGradient id="cpuGrad" x1="0" y1="0" x2="0" y2="1">
|
|
206
|
+
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
|
207
|
+
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
|
208
|
+
</linearGradient>
|
|
209
|
+
</defs>
|
|
210
|
+
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
|
211
|
+
<XAxis dataKey="time" tick={{ fontSize: 9, fill: 'currentColor' }} tickLine={false} axisLine={false} interval="preserveStartEnd" />
|
|
212
|
+
<YAxis tick={{ fontSize: 9, fill: 'currentColor' }} tickLine={false} axisLine={false} tickFormatter={(v) => formatCpu(v)} width={45} />
|
|
213
|
+
<Tooltip content={<MetricTooltip />} />
|
|
214
|
+
<Area type="monotone" dataKey="cpu" stroke="#3b82f6" strokeWidth={1.5} fill="url(#cpuGrad)" dot={false} />
|
|
215
|
+
</AreaChart>
|
|
216
|
+
</ResponsiveContainer>
|
|
217
|
+
</CardContent>
|
|
218
|
+
</Card>
|
|
219
|
+
|
|
220
|
+
<Card>
|
|
221
|
+
<CardHeader className="pb-1">
|
|
222
|
+
<CardTitle className="text-sm font-medium flex items-center justify-between">
|
|
223
|
+
<div className="flex items-center gap-2">
|
|
224
|
+
<MemoryStick className="h-4 w-4 text-purple-500" />
|
|
225
|
+
Memory Usage
|
|
226
|
+
</div>
|
|
227
|
+
<span className="text-base font-bold text-purple-500">{formatMemory(latestMem)}</span>
|
|
228
|
+
</CardTitle>
|
|
229
|
+
</CardHeader>
|
|
230
|
+
<CardContent className="pt-2">
|
|
231
|
+
<ResponsiveContainer width="100%" height={120}>
|
|
232
|
+
<AreaChart data={history} margin={{ top: 5, right: 5, bottom: 0, left: 5 }}>
|
|
233
|
+
<defs>
|
|
234
|
+
<linearGradient id="memGrad" x1="0" y1="0" x2="0" y2="1">
|
|
235
|
+
<stop offset="5%" stopColor="#a855f7" stopOpacity={0.3} />
|
|
236
|
+
<stop offset="95%" stopColor="#a855f7" stopOpacity={0} />
|
|
237
|
+
</linearGradient>
|
|
238
|
+
</defs>
|
|
239
|
+
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
|
240
|
+
<XAxis dataKey="time" tick={{ fontSize: 9, fill: 'currentColor' }} tickLine={false} axisLine={false} interval="preserveStartEnd" />
|
|
241
|
+
<YAxis tick={{ fontSize: 9, fill: 'currentColor' }} tickLine={false} axisLine={false} tickFormatter={(v) => formatMemory(v)} width={55} />
|
|
242
|
+
<Tooltip content={<MetricTooltip />} />
|
|
243
|
+
<Area type="monotone" dataKey="memory" stroke="#a855f7" strokeWidth={1.5} fill="url(#memGrad)" dot={false} />
|
|
244
|
+
</AreaChart>
|
|
245
|
+
</ResponsiveContainer>
|
|
246
|
+
</CardContent>
|
|
247
|
+
</Card>
|
|
248
|
+
|
|
249
|
+
{/* Network I/O */}
|
|
250
|
+
{!netUnavailable && <Card>
|
|
251
|
+
<CardHeader className="pb-1">
|
|
252
|
+
<CardTitle className="text-sm font-medium flex items-center justify-between">
|
|
253
|
+
<div className="flex items-center gap-2">
|
|
254
|
+
<Network className="h-4 w-4 text-emerald-500" />
|
|
255
|
+
Network I/O
|
|
256
|
+
</div>
|
|
257
|
+
{hasNetData ? (
|
|
258
|
+
<span className="text-xs text-muted-foreground">
|
|
259
|
+
<span className="text-emerald-500 font-medium">Rx {formatNetRate(latestRx)}</span>
|
|
260
|
+
{' / '}
|
|
261
|
+
<span className="text-orange-500 font-medium">Tx {formatNetRate(latestTx)}</span>
|
|
262
|
+
</span>
|
|
263
|
+
) : (
|
|
264
|
+
<span className="text-xs text-muted-foreground">Collecting...</span>
|
|
265
|
+
)}
|
|
266
|
+
</CardTitle>
|
|
267
|
+
</CardHeader>
|
|
268
|
+
<CardContent className="pt-2">
|
|
269
|
+
{hasNetData ? (
|
|
270
|
+
<ResponsiveContainer width="100%" height={120}>
|
|
271
|
+
<AreaChart data={history} margin={{ top: 5, right: 5, bottom: 0, left: 5 }}>
|
|
272
|
+
<defs>
|
|
273
|
+
<linearGradient id="rxGrad" x1="0" y1="0" x2="0" y2="1">
|
|
274
|
+
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
|
|
275
|
+
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
|
276
|
+
</linearGradient>
|
|
277
|
+
<linearGradient id="txGrad" x1="0" y1="0" x2="0" y2="1">
|
|
278
|
+
<stop offset="5%" stopColor="#f97316" stopOpacity={0.3} />
|
|
279
|
+
<stop offset="95%" stopColor="#f97316" stopOpacity={0} />
|
|
280
|
+
</linearGradient>
|
|
281
|
+
</defs>
|
|
282
|
+
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
|
283
|
+
<XAxis dataKey="time" tick={{ fontSize: 9, fill: 'currentColor' }} tickLine={false} axisLine={false} interval="preserveStartEnd" />
|
|
284
|
+
<YAxis tick={{ fontSize: 9, fill: 'currentColor' }} tickLine={false} axisLine={false} tickFormatter={(v) => formatNetRate(v)} width={55} />
|
|
285
|
+
<Tooltip content={<MetricTooltip />} />
|
|
286
|
+
<Area type="monotone" dataKey="netRx" stroke="#10b981" strokeWidth={1.5} fill="url(#rxGrad)" dot={false} name="Rx" />
|
|
287
|
+
<Area type="monotone" dataKey="netTx" stroke="#f97316" strokeWidth={1.5} fill="url(#txGrad)" dot={false} name="Tx" />
|
|
288
|
+
</AreaChart>
|
|
289
|
+
</ResponsiveContainer>
|
|
290
|
+
) : (
|
|
291
|
+
<div className="flex items-center justify-center h-[120px] text-xs text-muted-foreground">
|
|
292
|
+
{promUnavailable ? 'Prometheus not found' : 'Waiting for network data...'}
|
|
293
|
+
</div>
|
|
294
|
+
)}
|
|
295
|
+
</CardContent>
|
|
296
|
+
</Card>}
|
|
297
|
+
|
|
298
|
+
{/* Filesystem I/O */}
|
|
299
|
+
{!fsUnavailable && <Card>
|
|
300
|
+
<CardHeader className="pb-1">
|
|
301
|
+
<CardTitle className="text-sm font-medium flex items-center justify-between">
|
|
302
|
+
<div className="flex items-center gap-2">
|
|
303
|
+
<HardDrive className="h-4 w-4 text-cyan-500" />
|
|
304
|
+
Filesystem I/O
|
|
305
|
+
</div>
|
|
306
|
+
{hasFsData ? (
|
|
307
|
+
<span className="text-xs text-muted-foreground">
|
|
308
|
+
<span className="text-cyan-500 font-medium">R {formatNetRate(latestFsRead)}</span>
|
|
309
|
+
{' / '}
|
|
310
|
+
<span className="text-rose-500 font-medium">W {formatNetRate(latestFsWrite)}</span>
|
|
311
|
+
</span>
|
|
312
|
+
) : (
|
|
313
|
+
<span className="text-xs text-muted-foreground">{promUnavailable ? 'No Prometheus' : 'Collecting...'}</span>
|
|
314
|
+
)}
|
|
315
|
+
</CardTitle>
|
|
316
|
+
</CardHeader>
|
|
317
|
+
<CardContent className="pt-2">
|
|
318
|
+
{hasFsData ? (
|
|
319
|
+
<ResponsiveContainer width="100%" height={120}>
|
|
320
|
+
<AreaChart data={history} margin={{ top: 5, right: 5, bottom: 0, left: 5 }}>
|
|
321
|
+
<defs>
|
|
322
|
+
<linearGradient id="fsReadGrad" x1="0" y1="0" x2="0" y2="1">
|
|
323
|
+
<stop offset="5%" stopColor="#06b6d4" stopOpacity={0.3} />
|
|
324
|
+
<stop offset="95%" stopColor="#06b6d4" stopOpacity={0} />
|
|
325
|
+
</linearGradient>
|
|
326
|
+
<linearGradient id="fsWriteGrad" x1="0" y1="0" x2="0" y2="1">
|
|
327
|
+
<stop offset="5%" stopColor="#f43f5e" stopOpacity={0.3} />
|
|
328
|
+
<stop offset="95%" stopColor="#f43f5e" stopOpacity={0} />
|
|
329
|
+
</linearGradient>
|
|
330
|
+
</defs>
|
|
331
|
+
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
|
|
332
|
+
<XAxis dataKey="time" tick={{ fontSize: 9, fill: 'currentColor' }} tickLine={false} axisLine={false} interval="preserveStartEnd" />
|
|
333
|
+
<YAxis tick={{ fontSize: 9, fill: 'currentColor' }} tickLine={false} axisLine={false} tickFormatter={(v) => formatNetRate(v)} width={55} />
|
|
334
|
+
<Tooltip content={<MetricTooltip />} />
|
|
335
|
+
<Area type="monotone" dataKey="fsRead" stroke="#06b6d4" strokeWidth={1.5} fill="url(#fsReadGrad)" dot={false} name="Read" />
|
|
336
|
+
<Area type="monotone" dataKey="fsWrite" stroke="#f43f5e" strokeWidth={1.5} fill="url(#fsWriteGrad)" dot={false} name="Write" />
|
|
337
|
+
</AreaChart>
|
|
338
|
+
</ResponsiveContainer>
|
|
339
|
+
) : (
|
|
340
|
+
<div className="flex items-center justify-center h-[120px] text-xs text-muted-foreground">
|
|
341
|
+
{promUnavailable ? 'Prometheus not found' : 'Waiting for filesystem data...'}
|
|
342
|
+
</div>
|
|
343
|
+
)}
|
|
344
|
+
</CardContent>
|
|
345
|
+
</Card>}
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// === Node Metrics Summary for Cluster Overview ===
|
|
351
|
+
interface NodeMetricsProps {
|
|
352
|
+
clusterId: string;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function NodeMetricTooltip({ active, payload, label }: any) {
|
|
356
|
+
if (!active || !payload?.length) return null;
|
|
357
|
+
return (
|
|
358
|
+
<div className="rounded-lg border bg-popover/95 backdrop-blur-sm px-3 py-2 text-xs shadow-xl space-y-1">
|
|
359
|
+
<p className="font-medium">{label}</p>
|
|
360
|
+
{payload.map((p: any) => (
|
|
361
|
+
<div key={p.dataKey} className="flex items-center gap-2">
|
|
362
|
+
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: p.color }} />
|
|
363
|
+
<span className="text-muted-foreground">{p.dataKey === 'cpu' ? 'CPU' : 'Memory'}:</span>
|
|
364
|
+
<span className="font-semibold">{p.dataKey === 'cpu' ? formatCpu(p.value) : formatMemory(p.value)}</span>
|
|
365
|
+
</div>
|
|
366
|
+
))}
|
|
367
|
+
</div>
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function NodeMetricsSummary({ clusterId }: NodeMetricsProps) {
|
|
372
|
+
const { data } = useSWR(
|
|
373
|
+
`/api/clusters/${encodeURIComponent(clusterId)}/metrics?type=nodes`,
|
|
374
|
+
{ refreshInterval: 15000 }
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
if (!data?.items?.length) return null;
|
|
378
|
+
|
|
379
|
+
const nodes = data.items.map((n: any) => ({
|
|
380
|
+
name: n.metadata?.name?.split('.')[0] || n.metadata?.name || '',
|
|
381
|
+
cpu: parseCpu(n.usage?.cpu || '0'),
|
|
382
|
+
memory: parseMemory(n.usage?.memory || '0'),
|
|
383
|
+
}));
|
|
384
|
+
|
|
385
|
+
const barHeight = Math.max(120, nodes.length * 32 + 30);
|
|
386
|
+
|
|
387
|
+
return (
|
|
388
|
+
<Card>
|
|
389
|
+
<CardHeader className="pb-0">
|
|
390
|
+
<CardTitle className="text-sm font-medium">Node Resource Usage</CardTitle>
|
|
391
|
+
</CardHeader>
|
|
392
|
+
<CardContent className="pt-4">
|
|
393
|
+
<div className="grid grid-cols-2 gap-4">
|
|
394
|
+
{/* CPU */}
|
|
395
|
+
<div>
|
|
396
|
+
<p className="text-[10px] text-muted-foreground mb-1 font-medium text-center">CPU</p>
|
|
397
|
+
<ResponsiveContainer width="100%" height={barHeight}>
|
|
398
|
+
<BarChart data={nodes} layout="vertical" margin={{ top: 5, right: 10, bottom: 5, left: 5 }}>
|
|
399
|
+
<defs>
|
|
400
|
+
<linearGradient id="nodeCpuGrad" x1="0" y1="0" x2="1" y2="0">
|
|
401
|
+
<stop offset="0%" stopColor="#3b82f6" stopOpacity={0.8} />
|
|
402
|
+
<stop offset="100%" stopColor="#6366f1" stopOpacity={1} />
|
|
403
|
+
</linearGradient>
|
|
404
|
+
</defs>
|
|
405
|
+
<XAxis type="number" tick={{ fontSize: 9, fill: 'currentColor' }} tickLine={false} axisLine={false} tickFormatter={(v) => formatCpu(v)} />
|
|
406
|
+
<YAxis type="category" dataKey="name" tick={{ fontSize: 9, fill: 'currentColor' }} tickLine={false} axisLine={false} width={70} />
|
|
407
|
+
<Tooltip content={<NodeMetricTooltip />} />
|
|
408
|
+
<Bar dataKey="cpu" fill="url(#nodeCpuGrad)" radius={[0, 6, 6, 0]} animationDuration={600} />
|
|
409
|
+
</BarChart>
|
|
410
|
+
</ResponsiveContainer>
|
|
411
|
+
</div>
|
|
412
|
+
{/* Memory */}
|
|
413
|
+
<div>
|
|
414
|
+
<p className="text-[10px] text-muted-foreground mb-1 font-medium text-center">Memory</p>
|
|
415
|
+
<ResponsiveContainer width="100%" height={barHeight}>
|
|
416
|
+
<BarChart data={nodes} layout="vertical" margin={{ top: 5, right: 10, bottom: 5, left: 5 }}>
|
|
417
|
+
<defs>
|
|
418
|
+
<linearGradient id="nodeMemGrad" x1="0" y1="0" x2="1" y2="0">
|
|
419
|
+
<stop offset="0%" stopColor="#a855f7" stopOpacity={0.8} />
|
|
420
|
+
<stop offset="100%" stopColor="#7c3aed" stopOpacity={1} />
|
|
421
|
+
</linearGradient>
|
|
422
|
+
</defs>
|
|
423
|
+
<XAxis type="number" tick={{ fontSize: 9, fill: 'currentColor' }} tickLine={false} axisLine={false} tickFormatter={(v) => formatMemory(v)} />
|
|
424
|
+
<YAxis type="category" dataKey="name" tick={{ fontSize: 9, fill: 'currentColor' }} tickLine={false} axisLine={false} width={70} />
|
|
425
|
+
<Tooltip content={<NodeMetricTooltip />} />
|
|
426
|
+
<Bar dataKey="memory" fill="url(#nodeMemGrad)" radius={[0, 6, 6, 0]} animationDuration={600} />
|
|
427
|
+
</BarChart>
|
|
428
|
+
</ResponsiveContainer>
|
|
429
|
+
</div>
|
|
430
|
+
</div>
|
|
431
|
+
</CardContent>
|
|
432
|
+
</Card>
|
|
433
|
+
);
|
|
434
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import dynamic from 'next/dynamic';
|
|
4
|
+
|
|
5
|
+
const ChartLoading = () => (
|
|
6
|
+
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
7
|
+
{Array.from({ length: 2 }).map((_, i) => (
|
|
8
|
+
<div key={i} className="rounded-lg border bg-card p-4">
|
|
9
|
+
<div className="h-4 w-24 bg-muted rounded animate-pulse mb-4" />
|
|
10
|
+
<div className="h-[120px] bg-muted/50 rounded animate-pulse" />
|
|
11
|
+
</div>
|
|
12
|
+
))}
|
|
13
|
+
</div>
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
export const PodMetricsCharts = dynamic(
|
|
17
|
+
() => import('./metrics-charts-impl').then((mod) => ({ default: mod.PodMetricsCharts })),
|
|
18
|
+
{ ssr: false, loading: ChartLoading }
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
export const NodeMetricsSummary = dynamic(
|
|
22
|
+
() => import('./metrics-charts-impl').then((mod) => ({ default: mod.NodeMetricsSummary })),
|
|
23
|
+
{ ssr: false, loading: ChartLoading }
|
|
24
|
+
);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { mutate as globalMutate } from 'swr';
|
|
5
|
+
import { Plug, ExternalLink, X } from 'lucide-react';
|
|
6
|
+
import { apiClient } from '@/lib/api-client';
|
|
7
|
+
import { toast } from 'sonner';
|
|
8
|
+
import { Button } from '@/components/ui/button';
|
|
9
|
+
import { usePortForwards } from '@/hooks/use-port-forwards';
|
|
10
|
+
|
|
11
|
+
interface PortForwardBtnProps {
|
|
12
|
+
clusterId: string;
|
|
13
|
+
namespace: string;
|
|
14
|
+
resourceType: string;
|
|
15
|
+
resourceName: string;
|
|
16
|
+
port: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function PortForwardBtn({ clusterId, namespace, resourceType, resourceName, port }: PortForwardBtnProps) {
|
|
20
|
+
const { forwards } = usePortForwards();
|
|
21
|
+
const [starting, setStarting] = useState(false);
|
|
22
|
+
const active = forwards.find((f: any) => f.containerPort === port && f.id.includes(resourceName));
|
|
23
|
+
|
|
24
|
+
const start = async () => {
|
|
25
|
+
setStarting(true);
|
|
26
|
+
try {
|
|
27
|
+
await apiClient.post('/api/port-forward', { clusterId, namespace, resourceType, resourceName, containerPort: port, localPort: port });
|
|
28
|
+
globalMutate('/api/port-forward');
|
|
29
|
+
toast.success(`Forwarding localhost:${port} → ${port}`);
|
|
30
|
+
} catch (err: any) { toast.error(err.message); }
|
|
31
|
+
finally { setStarting(false); }
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const stop = async () => {
|
|
35
|
+
if (!active) return;
|
|
36
|
+
await apiClient.delete(`/api/port-forward?id=${encodeURIComponent(active.id)}`);
|
|
37
|
+
globalMutate('/api/port-forward');
|
|
38
|
+
toast.success('Port forward stopped');
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
if (active) {
|
|
42
|
+
return (
|
|
43
|
+
<span className="inline-flex items-center gap-1.5">
|
|
44
|
+
<a href={`http://localhost:${active.localPort}`} target="_blank" rel="noopener"
|
|
45
|
+
className="inline-flex items-center gap-1 rounded-md border border-green-500/30 bg-green-500/10 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-400 hover:bg-green-500/20 transition-colors">
|
|
46
|
+
<ExternalLink className="h-3 w-3" />localhost:{active.localPort}
|
|
47
|
+
</a>
|
|
48
|
+
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={stop}>
|
|
49
|
+
<X className="h-3.5 w-3.5 text-muted-foreground hover:text-destructive" />
|
|
50
|
+
</Button>
|
|
51
|
+
</span>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<Button variant="outline" size="sm" className="h-6 px-2 text-xs gap-1" onClick={start} disabled={starting}>
|
|
57
|
+
<Plug className="h-3 w-3" />{starting ? 'Starting...' : 'Forward'}
|
|
58
|
+
</Button>
|
|
59
|
+
);
|
|
60
|
+
}
|