kubeops 0.1.5 → 0.1.6
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 +63 -7
- package/package.json +1 -1
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/deployments/[name]/page.tsx +7 -1
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/pods/[podName]/page.tsx +7 -1
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/statefulsets/[name]/page.tsx +7 -1
- package/src/app/clusters/[clusterId]/page.tsx +14 -0
- package/src/app/clusters/overview/page.tsx +241 -0
- package/src/app/clusters/page.tsx +5 -1
- package/src/components/dashboard/workload-health-summary.tsx +151 -0
- package/src/components/namespaces/namespace-selector.tsx +198 -67
- package/src/components/resources/resource-actions.tsx +212 -0
- package/src/components/resources/resource-detail-page.tsx +21 -1
- package/src/components/resources/resource-list-page.tsx +73 -28
- package/src/components/shared/data-table.tsx +2 -2
- package/src/components/shared/resource-diff-dialog.tsx +248 -0
- package/src/components/shared/yaml-editor.tsx +37 -9
- package/src/hooks/use-multi-cluster-data.ts +90 -0
- package/src/stores/namespace-store.ts +18 -0
- package/src/types/resource.ts +121 -0
package/README.md
CHANGED
|
@@ -54,6 +54,10 @@ There are many Kubernetes tools out there. Here's how KubeOps compares:
|
|
|
54
54
|
| Visual resource topology | **Yes** | Extension | No | Limited |
|
|
55
55
|
| Built-in terminal & logs | **Yes** | Yes | Yes | Yes |
|
|
56
56
|
| Real-time metrics & charts | **Yes** | Yes | Basic | Basic |
|
|
57
|
+
| Quick actions on resource rows | **Yes** | No | Yes | No |
|
|
58
|
+
| Multi-cluster overview | **Yes** | Yes | No | No |
|
|
59
|
+
| Cross-cluster resource diff | **Yes** | No | No | No |
|
|
60
|
+
| Multi-namespace selection | **Yes** | No | No | No |
|
|
57
61
|
| Helm chart management | **Yes** | Yes | View only | No |
|
|
58
62
|
| RBAC visualization | **Yes** | No | Basic | No |
|
|
59
63
|
| YAML diff view | **Yes** | No | No | No |
|
|
@@ -67,6 +71,10 @@ There are many Kubernetes tools out there. Here's how KubeOps compares:
|
|
|
67
71
|
- **Free forever** — No subscriptions, no feature gates, no telemetry. MIT licensed.
|
|
68
72
|
- **Visual-first** — Interactive App Map shows how Ingresses, Services, Deployments, and Pods connect. Not just resource lists.
|
|
69
73
|
- **All-in-one desktop app** — Terminal, logs, port forwarding, metrics charts, YAML editor, Helm management in a single window. No server to deploy, no browser extension to install.
|
|
74
|
+
- **Quick actions** — Restart, scale, delete, view logs, and exec directly from resource list rows without navigating to detail pages.
|
|
75
|
+
- **Multi-cluster overview** — See the health of all connected clusters at a glance on a single dashboard.
|
|
76
|
+
- **Cross-cluster diff** — Compare the same resource across clusters or namespaces with a visual YAML diff.
|
|
77
|
+
- **Multi-namespace** — Select multiple namespaces simultaneously and view resources across them in one list.
|
|
70
78
|
- **Helm chart management** — Browse, install, upgrade, rollback, and uninstall Helm releases directly from the UI.
|
|
71
79
|
- **RBAC visualization** — See who can do what with a permission matrix and interactive access review.
|
|
72
80
|
- **Modern & lightweight** — Fast startup, small footprint. No Electron bloat from bundled IDE features you don't need.
|
|
@@ -79,7 +87,7 @@ There are many Kubernetes tools out there. Here's how KubeOps compares:
|
|
|
79
87
|
|
|
80
88
|
### Cluster Overview Dashboard
|
|
81
89
|
|
|
82
|
-
Auto-detects all clusters from kubeconfig. Select a cluster to see node/pod counts, pod status distribution, workload health, CPU usage per node, active services with port-forward buttons, ingress endpoints, and recent warning events.
|
|
90
|
+
Auto-detects all clusters from kubeconfig. Select a cluster to see node/pod counts, pod status distribution, workload health, CPU usage per node, active services with port-forward buttons, ingress endpoints, and recent warning events. A **Workload Health Summary** at the top surfaces failing pods, non-ready workloads, container restarts, and warning events at a glance — or shows a green "All Healthy" banner when everything is fine.
|
|
83
91
|
|
|
84
92
|
<!-- Screenshot: Cluster Overview -->
|
|
85
93
|
|
|
@@ -93,6 +101,15 @@ Interactive flowchart visualizing resource relationships: Ingress → Service
|
|
|
93
101
|
|
|
94
102
|

|
|
95
103
|
|
|
104
|
+
### Quick Actions
|
|
105
|
+
|
|
106
|
+
Every resource list row has a `...` context menu for common operations without navigating to the detail page:
|
|
107
|
+
|
|
108
|
+
- **Pods** — View Logs, Exec, Delete
|
|
109
|
+
- **Deployments / StatefulSets** — Restart, Scale, Delete
|
|
110
|
+
- **DaemonSets** — Restart, Delete
|
|
111
|
+
- **All resources** — Delete, Compare (cross-cluster diff)
|
|
112
|
+
|
|
96
113
|
### Live Status Display
|
|
97
114
|
|
|
98
115
|
Every resource list features searchable, sortable tables with health status badges and relative age display. Warnings and unhealthy states (CrashLoopBackOff, ImagePullBackOff, OOMKilled) are highlighted and surfaced first.
|
|
@@ -135,8 +152,8 @@ Four viewing modes for every resource manifest:
|
|
|
135
152
|
|
|
136
153
|
- **Table view** — Structured, collapsible sections with smart value rendering
|
|
137
154
|
- **YAML view** — Read-only formatted output
|
|
138
|
-
- **Edit mode** — Syntax-highlighted editor with validation,
|
|
139
|
-
- **Review Changes** — Side-by-side diff showing additions (green) and deletions (red) before
|
|
155
|
+
- **Edit mode** — Syntax-highlighted editor with validation, `Cmd+S` to proceed to review
|
|
156
|
+
- **Review Changes (mandatory)** — Side-by-side diff showing additions (green) and deletions (red). You must review before applying — direct save from edit mode is disabled
|
|
140
157
|
|
|
141
158
|
<!-- Screenshot: YAML Editor -->
|
|
142
159
|
|
|
@@ -158,6 +175,36 @@ Click the info icon on any App Map node to open a right-side drawer with Overvie
|
|
|
158
175
|
|
|
159
176
|

|
|
160
177
|
|
|
178
|
+
### Cross-Cluster Resource Diff
|
|
179
|
+
|
|
180
|
+
Compare the same resource across different clusters or namespaces. Click the **Compare** button on any resource detail page (or from the Quick Actions menu) to open a diff dialog:
|
|
181
|
+
|
|
182
|
+
1. Select a target cluster and namespace
|
|
183
|
+
2. Click **Compare** to see a side-by-side YAML diff
|
|
184
|
+
3. Metadata noise (uid, resourceVersion, managedFields) and status are stripped for a clean comparison
|
|
185
|
+
|
|
186
|
+
Useful for verifying configuration parity across staging and production environments.
|
|
187
|
+
|
|
188
|
+
### Multi-Namespace Selector
|
|
189
|
+
|
|
190
|
+
Select multiple namespaces simultaneously to view resources across them in a single list. Click the checklist icon in the namespace selector to enter multi-select mode:
|
|
191
|
+
|
|
192
|
+
- Toggle individual namespaces with checkboxes
|
|
193
|
+
- "All Namespaces" toggles select-all / deselect-all
|
|
194
|
+
- The trigger button shows "N namespaces" when multiple are selected
|
|
195
|
+
- Resources are fetched from all namespaces and filtered client-side
|
|
196
|
+
|
|
197
|
+
### Multi-Cluster Overview
|
|
198
|
+
|
|
199
|
+
Navigate to `/clusters` and click the **Overview** button to see all connected clusters on a single dashboard:
|
|
200
|
+
|
|
201
|
+
- **Summary stats** — Total connected clusters, running pods, failing pods, and warning events
|
|
202
|
+
- **Cluster health grid** — Cards per cluster with status dot, pod count, failing count, and warning count
|
|
203
|
+
- **Failing pods breakdown** — Table of clusters with failing pods, sorted by severity
|
|
204
|
+
- **Warning events breakdown** — Table of clusters with recent warning events
|
|
205
|
+
|
|
206
|
+
Click any cluster card to jump directly to that cluster's overview page.
|
|
207
|
+
|
|
161
208
|
### Deployment Scaling
|
|
162
209
|
|
|
163
210
|
Scale Deployments and StatefulSets directly from the UI. A dedicated scale dialog lets you set the desired replica count and apply it instantly.
|
|
@@ -197,6 +244,14 @@ npm run electron:dev
|
|
|
197
244
|
|
|
198
245
|
The app opens automatically once the dev server is ready (port 51230).
|
|
199
246
|
|
|
247
|
+
#### Mock Mode
|
|
248
|
+
|
|
249
|
+
Run without a real cluster using built-in mock data (3 clusters, various pod states, warning events):
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
npm run dev:mock # http://localhost:51230
|
|
253
|
+
```
|
|
254
|
+
|
|
200
255
|
### Build
|
|
201
256
|
|
|
202
257
|
Create a distributable package for your platform:
|
|
@@ -213,10 +268,11 @@ Output is written to `dist-electron/`.
|
|
|
213
268
|
|
|
214
269
|
## Keyboard Shortcuts
|
|
215
270
|
|
|
216
|
-
| Shortcut | Action
|
|
217
|
-
| ------------------ |
|
|
218
|
-
| `Cmd+K` / `Ctrl+K` | Open command palette
|
|
219
|
-
| `Cmd+S` / `Ctrl+S` |
|
|
271
|
+
| Shortcut | Action |
|
|
272
|
+
| ------------------ | ----------------------------------- |
|
|
273
|
+
| `Cmd+K` / `Ctrl+K` | Open command palette |
|
|
274
|
+
| `Cmd+S` / `Ctrl+S` | Review changes (edit mode) |
|
|
275
|
+
| `Cmd+S` / `Ctrl+S` | Apply changes (review mode) |
|
|
220
276
|
|
|
221
277
|
---
|
|
222
278
|
|
package/package.json
CHANGED
|
@@ -14,7 +14,7 @@ import { ScaleDialog } from '@/components/resources/scale-dialog';
|
|
|
14
14
|
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
|
|
15
15
|
import { YamlEditor } from '@/components/shared/yaml-editor';
|
|
16
16
|
import { PodMetricsCharts } from '@/components/shared/metrics-charts';
|
|
17
|
-
import { ArrowLeft, Trash2, Scaling, Terminal, ScrollText, RotateCcw } from 'lucide-react';
|
|
17
|
+
import { ArrowLeft, Trash2, Scaling, Terminal, ScrollText, RotateCcw, GitCompare } from 'lucide-react';
|
|
18
18
|
import { apiClient } from '@/lib/api-client';
|
|
19
19
|
import { toast } from 'sonner';
|
|
20
20
|
import { useState } from 'react';
|
|
@@ -24,6 +24,7 @@ import Link from 'next/link';
|
|
|
24
24
|
import { PortForwardBtn } from '@/components/shared/port-forward-btn';
|
|
25
25
|
import { ResourceTreeView } from '@/components/shared/resource-tree';
|
|
26
26
|
import { useResourceTree } from '@/hooks/use-resource-tree';
|
|
27
|
+
import { ResourceDiffDialog } from '@/components/shared/resource-diff-dialog';
|
|
27
28
|
|
|
28
29
|
export default function DeploymentDetailPage() {
|
|
29
30
|
const params = useParams();
|
|
@@ -35,6 +36,7 @@ export default function DeploymentDetailPage() {
|
|
|
35
36
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
36
37
|
const [deleting, setDeleting] = useState(false);
|
|
37
38
|
const [restarting, setRestarting] = useState(false);
|
|
39
|
+
const [compareOpen, setCompareOpen] = useState(false);
|
|
38
40
|
const { addTab } = usePanelStore();
|
|
39
41
|
|
|
40
42
|
const decodedClusterId = decodeURIComponent(clusterId);
|
|
@@ -173,6 +175,9 @@ export default function DeploymentDetailPage() {
|
|
|
173
175
|
</p>
|
|
174
176
|
</div>
|
|
175
177
|
<div className="flex gap-2">
|
|
178
|
+
<Button variant="outline" size="sm" onClick={() => setCompareOpen(true)}>
|
|
179
|
+
<GitCompare className="h-4 w-4 mr-1" />Compare
|
|
180
|
+
</Button>
|
|
176
181
|
<Button variant="outline" size="sm" onClick={handleRestart} disabled={restarting}>
|
|
177
182
|
<RotateCcw className={`h-4 w-4 mr-1 ${restarting ? 'animate-spin' : ''}`} />
|
|
178
183
|
{restarting ? 'Restarting...' : 'Restart'}
|
|
@@ -452,6 +457,7 @@ export default function DeploymentDetailPage() {
|
|
|
452
457
|
|
|
453
458
|
<ScaleDialog open={scaleOpen} onOpenChange={setScaleOpen} clusterId={decodedClusterId} namespace={namespace} resourceType="deployments" name={name} currentReplicas={spec.replicas || 0} onScaled={() => mutate()} />
|
|
454
459
|
<ConfirmDialog open={deleteOpen} onOpenChange={setDeleteOpen} title={`Delete ${name}?`} description={`This will permanently delete the deployment "${name}".`} confirmLabel="Delete" variant="destructive" onConfirm={handleDelete} loading={deleting} />
|
|
460
|
+
<ResourceDiffDialog open={compareOpen} onOpenChange={setCompareOpen} sourceClusterId={clusterId} sourceNamespace={namespace} resourceType="deployments" resourceName={name} sourceResource={dep} />
|
|
455
461
|
</div>
|
|
456
462
|
);
|
|
457
463
|
}
|
|
@@ -9,7 +9,7 @@ import { Badge } from '@/components/ui/badge';
|
|
|
9
9
|
import { Button } from '@/components/ui/button';
|
|
10
10
|
import { StatusBadge } from '@/components/shared/status-badge';
|
|
11
11
|
import { AgeDisplay } from '@/components/shared/age-display';
|
|
12
|
-
import { ArrowLeft, Terminal, ScrollText, Trash2, KeyRound } from 'lucide-react';
|
|
12
|
+
import { ArrowLeft, Terminal, ScrollText, Trash2, KeyRound, GitCompare } from 'lucide-react';
|
|
13
13
|
import { useState, useMemo } from 'react';
|
|
14
14
|
import { usePanelStore } from '@/stores/panel-store';
|
|
15
15
|
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
|
|
@@ -24,6 +24,7 @@ import { PodWatchButton } from '@/components/pods/pod-watch-button';
|
|
|
24
24
|
import { usePodRestartWatcher } from '@/hooks/use-pod-watcher';
|
|
25
25
|
import { ResourceTreeView } from '@/components/shared/resource-tree';
|
|
26
26
|
import { useResourceTree } from '@/hooks/use-resource-tree';
|
|
27
|
+
import { ResourceDiffDialog } from '@/components/shared/resource-diff-dialog';
|
|
27
28
|
|
|
28
29
|
function PodResourceTree({ clusterId, namespace, rootKind, rootName, focusPodName }: {
|
|
29
30
|
clusterId: string;
|
|
@@ -57,6 +58,7 @@ export default function PodDetailPage() {
|
|
|
57
58
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
58
59
|
const [deleting, setDeleting] = useState(false);
|
|
59
60
|
const [envDrawerOpen, setEnvDrawerOpen] = useState(false);
|
|
61
|
+
const [compareOpen, setCompareOpen] = useState(false);
|
|
60
62
|
const { addTab } = usePanelStore();
|
|
61
63
|
const decodedClusterId = decodeURIComponent(clusterId);
|
|
62
64
|
|
|
@@ -168,6 +170,9 @@ export default function PodDetailPage() {
|
|
|
168
170
|
</Button>
|
|
169
171
|
</div>
|
|
170
172
|
))}
|
|
173
|
+
<Button variant="outline" size="sm" onClick={() => setCompareOpen(true)}>
|
|
174
|
+
<GitCompare className="h-4 w-4 mr-1" />Compare
|
|
175
|
+
</Button>
|
|
171
176
|
<PodWatchButton clusterId={decodedClusterId} namespace={namespace} podName={podName} />
|
|
172
177
|
<Button variant="destructive" size="sm" onClick={() => setDeleteOpen(true)}>
|
|
173
178
|
<Trash2 className="h-4 w-4 mr-1" />Delete
|
|
@@ -392,6 +397,7 @@ export default function PodDetailPage() {
|
|
|
392
397
|
</Tabs>
|
|
393
398
|
|
|
394
399
|
<ConfirmDialog open={deleteOpen} onOpenChange={setDeleteOpen} title={`Delete ${podName}?`} description={`This will delete the pod "${podName}". If managed by a controller, it will be recreated.`} confirmLabel="Delete" variant="destructive" onConfirm={handleDelete} loading={deleting} />
|
|
400
|
+
<ResourceDiffDialog open={compareOpen} onOpenChange={setCompareOpen} sourceClusterId={clusterId} sourceNamespace={namespace} resourceType="pods" resourceName={podName} sourceResource={pod} />
|
|
395
401
|
|
|
396
402
|
{/* Env Variables Drawer */}
|
|
397
403
|
<Sheet open={envDrawerOpen} onOpenChange={setEnvDrawerOpen}>
|
|
@@ -14,7 +14,7 @@ import { ScaleDialog } from '@/components/resources/scale-dialog';
|
|
|
14
14
|
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
|
|
15
15
|
import { YamlEditor } from '@/components/shared/yaml-editor';
|
|
16
16
|
import { PodMetricsCharts } from '@/components/shared/metrics-charts';
|
|
17
|
-
import { ArrowLeft, Trash2, Scaling, Terminal, ScrollText, RotateCcw } from 'lucide-react';
|
|
17
|
+
import { ArrowLeft, Trash2, Scaling, Terminal, ScrollText, RotateCcw, GitCompare } from 'lucide-react';
|
|
18
18
|
import { apiClient } from '@/lib/api-client';
|
|
19
19
|
import { toast } from 'sonner';
|
|
20
20
|
import { useState } from 'react';
|
|
@@ -23,6 +23,7 @@ import { usePanelStore } from '@/stores/panel-store';
|
|
|
23
23
|
import { PortForwardBtn } from '@/components/shared/port-forward-btn';
|
|
24
24
|
import { ResourceTreeView } from '@/components/shared/resource-tree';
|
|
25
25
|
import { useResourceTree } from '@/hooks/use-resource-tree';
|
|
26
|
+
import { ResourceDiffDialog } from '@/components/shared/resource-diff-dialog';
|
|
26
27
|
|
|
27
28
|
export default function StatefulSetDetailPage() {
|
|
28
29
|
const params = useParams();
|
|
@@ -34,6 +35,7 @@ export default function StatefulSetDetailPage() {
|
|
|
34
35
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
35
36
|
const [deleting, setDeleting] = useState(false);
|
|
36
37
|
const [restarting, setRestarting] = useState(false);
|
|
38
|
+
const [compareOpen, setCompareOpen] = useState(false);
|
|
37
39
|
const { addTab } = usePanelStore();
|
|
38
40
|
|
|
39
41
|
const decodedClusterId = decodeURIComponent(clusterId);
|
|
@@ -116,6 +118,9 @@ export default function StatefulSetDetailPage() {
|
|
|
116
118
|
<p className="text-sm text-muted-foreground">StatefulSet in {namespace} - Ready: {status.readyReplicas || 0}/{spec.replicas || 0}</p>
|
|
117
119
|
</div>
|
|
118
120
|
<div className="flex gap-2">
|
|
121
|
+
<Button variant="outline" size="sm" onClick={() => setCompareOpen(true)}>
|
|
122
|
+
<GitCompare className="h-4 w-4 mr-1" />Compare
|
|
123
|
+
</Button>
|
|
119
124
|
<Button variant="outline" size="sm" onClick={handleRestart} disabled={restarting}>
|
|
120
125
|
<RotateCcw className={`h-4 w-4 mr-1 ${restarting ? 'animate-spin' : ''}`} />
|
|
121
126
|
{restarting ? 'Restarting...' : 'Restart'}
|
|
@@ -297,6 +302,7 @@ export default function StatefulSetDetailPage() {
|
|
|
297
302
|
|
|
298
303
|
<ScaleDialog open={scaleOpen} onOpenChange={setScaleOpen} clusterId={decodedClusterId} namespace={namespace} resourceType="statefulsets" name={name} currentReplicas={spec.replicas || 0} onScaled={() => mutate()} />
|
|
299
304
|
<ConfirmDialog open={deleteOpen} onOpenChange={setDeleteOpen} title={`Delete ${name}?`} description={`This will permanently delete the statefulset "${name}".`} confirmLabel="Delete" variant="destructive" onConfirm={handleDelete} loading={deleting} />
|
|
305
|
+
<ResourceDiffDialog open={compareOpen} onOpenChange={setCompareOpen} sourceClusterId={clusterId} sourceNamespace={namespace} resourceType="statefulsets" resourceName={name} sourceResource={sts} />
|
|
300
306
|
</div>
|
|
301
307
|
);
|
|
302
308
|
}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
AreaChart, Area,
|
|
17
17
|
} from 'recharts';
|
|
18
18
|
import { NodeMetricsSummary } from '@/components/shared/metrics-charts';
|
|
19
|
+
import { WorkloadHealthSummary } from '@/components/dashboard/workload-health-summary';
|
|
19
20
|
|
|
20
21
|
const POD_COLORS: Record<string, string> = {
|
|
21
22
|
Running: '#22c55e', Succeeded: '#3b82f6', Pending: '#eab308',
|
|
@@ -222,6 +223,19 @@ export default function ClusterOverviewPage() {
|
|
|
222
223
|
</div>
|
|
223
224
|
</div>
|
|
224
225
|
|
|
226
|
+
{/* Workload Health Summary */}
|
|
227
|
+
{coreLoaded && (
|
|
228
|
+
<WorkloadHealthSummary
|
|
229
|
+
clusterId={clusterId}
|
|
230
|
+
namespace={namespace}
|
|
231
|
+
pods={pods}
|
|
232
|
+
deployments={deployments}
|
|
233
|
+
statefulsets={statefulsets}
|
|
234
|
+
daemonsets={daemonsets}
|
|
235
|
+
events={events}
|
|
236
|
+
/>
|
|
237
|
+
)}
|
|
238
|
+
|
|
225
239
|
{/* Stats */}
|
|
226
240
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
227
241
|
{stats.map((stat) => {
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useRouter } from 'next/navigation';
|
|
4
|
+
import { useClusters } from '@/hooks/use-clusters';
|
|
5
|
+
import { useMultiClusterData } from '@/hooks/use-multi-cluster-data';
|
|
6
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
7
|
+
import { Button } from '@/components/ui/button';
|
|
8
|
+
import { Badge } from '@/components/ui/badge';
|
|
9
|
+
import { Skeleton } from '@/components/ui/skeleton';
|
|
10
|
+
import { ThemeToggle } from '@/components/layout/theme-toggle';
|
|
11
|
+
import { ArrowLeft, Box, AlertTriangle, AlertCircle, Server } from 'lucide-react';
|
|
12
|
+
|
|
13
|
+
export default function MultiClusterOverviewPage() {
|
|
14
|
+
const router = useRouter();
|
|
15
|
+
const { clusters, isLoading: clustersLoading } = useClusters();
|
|
16
|
+
const { clusterData, isLoading: dataLoading } = useMultiClusterData(clusters);
|
|
17
|
+
|
|
18
|
+
const isLoading = clustersLoading || dataLoading;
|
|
19
|
+
|
|
20
|
+
const totalPods = clusterData.reduce((s, c) => s + c.podCount, 0);
|
|
21
|
+
const totalRunning = clusterData.reduce((s, c) => s + c.runningPods, 0);
|
|
22
|
+
const totalFailing = clusterData.reduce((s, c) => s + c.failingPods, 0);
|
|
23
|
+
const totalWarnings = clusterData.reduce((s, c) => s + c.warningEvents, 0);
|
|
24
|
+
const connectedCount = clusterData.filter((c) => c.status === 'connected').length;
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className="flex h-screen flex-col">
|
|
28
|
+
{/* Header */}
|
|
29
|
+
<header className="electron-header-inset flex h-14 items-center justify-between border-b px-4 shrink-0 drag-region">
|
|
30
|
+
<div className="flex items-center gap-3 no-drag-region">
|
|
31
|
+
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => router.push('/clusters')}>
|
|
32
|
+
<ArrowLeft className="h-4 w-4" />
|
|
33
|
+
</Button>
|
|
34
|
+
<h1 className="text-lg font-bold tracking-tight">Multi-Cluster Overview</h1>
|
|
35
|
+
</div>
|
|
36
|
+
<div className="flex items-center gap-2 no-drag-region">
|
|
37
|
+
<ThemeToggle />
|
|
38
|
+
</div>
|
|
39
|
+
</header>
|
|
40
|
+
|
|
41
|
+
{/* Content */}
|
|
42
|
+
<div className="flex-1 overflow-auto">
|
|
43
|
+
<div className="flex flex-col gap-6 p-6">
|
|
44
|
+
{/* Summary Stats */}
|
|
45
|
+
<div className="grid gap-4 grid-cols-2 md:grid-cols-4">
|
|
46
|
+
<Card>
|
|
47
|
+
<CardContent className="flex items-center gap-3 p-4">
|
|
48
|
+
<div className="rounded-lg p-2.5 bg-blue-500/10">
|
|
49
|
+
<Server className="h-5 w-5 text-blue-500" />
|
|
50
|
+
</div>
|
|
51
|
+
<div>
|
|
52
|
+
<p className="text-2xl font-bold">{connectedCount}</p>
|
|
53
|
+
<p className="text-xs text-muted-foreground">Connected Clusters</p>
|
|
54
|
+
</div>
|
|
55
|
+
</CardContent>
|
|
56
|
+
</Card>
|
|
57
|
+
<Card>
|
|
58
|
+
<CardContent className="flex items-center gap-3 p-4">
|
|
59
|
+
<div className="rounded-lg p-2.5 bg-green-500/10">
|
|
60
|
+
<Box className="h-5 w-5 text-green-500" />
|
|
61
|
+
</div>
|
|
62
|
+
<div>
|
|
63
|
+
<p className="text-2xl font-bold">{totalRunning}<span className="text-sm font-normal text-muted-foreground">/{totalPods}</span></p>
|
|
64
|
+
<p className="text-xs text-muted-foreground">Running Pods</p>
|
|
65
|
+
</div>
|
|
66
|
+
</CardContent>
|
|
67
|
+
</Card>
|
|
68
|
+
<Card>
|
|
69
|
+
<CardContent className="flex items-center gap-3 p-4">
|
|
70
|
+
<div className={`rounded-lg p-2.5 ${totalFailing > 0 ? 'bg-red-500/10' : 'bg-green-500/10'}`}>
|
|
71
|
+
<AlertCircle className={`h-5 w-5 ${totalFailing > 0 ? 'text-red-500' : 'text-green-500'}`} />
|
|
72
|
+
</div>
|
|
73
|
+
<div>
|
|
74
|
+
<p className="text-2xl font-bold">{totalFailing}</p>
|
|
75
|
+
<p className="text-xs text-muted-foreground">Failing Pods</p>
|
|
76
|
+
</div>
|
|
77
|
+
</CardContent>
|
|
78
|
+
</Card>
|
|
79
|
+
<Card>
|
|
80
|
+
<CardContent className="flex items-center gap-3 p-4">
|
|
81
|
+
<div className={`rounded-lg p-2.5 ${totalWarnings > 0 ? 'bg-amber-500/10' : 'bg-green-500/10'}`}>
|
|
82
|
+
<AlertTriangle className={`h-5 w-5 ${totalWarnings > 0 ? 'text-amber-500' : 'text-green-500'}`} />
|
|
83
|
+
</div>
|
|
84
|
+
<div>
|
|
85
|
+
<p className="text-2xl font-bold">{totalWarnings}</p>
|
|
86
|
+
<p className="text-xs text-muted-foreground">Warning Events (1h)</p>
|
|
87
|
+
</div>
|
|
88
|
+
</CardContent>
|
|
89
|
+
</Card>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
{/* Cluster Health Grid */}
|
|
93
|
+
<div>
|
|
94
|
+
<h2 className="text-lg font-semibold mb-3">Cluster Health</h2>
|
|
95
|
+
{isLoading && (
|
|
96
|
+
<div className="grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
|
97
|
+
{Array.from({ length: 6 }).map((_, i) => (
|
|
98
|
+
<Skeleton key={i} className="h-28" />
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
{!isLoading && clusterData.length === 0 && (
|
|
103
|
+
<div className="text-center text-muted-foreground py-8">
|
|
104
|
+
No connected clusters found. Go back to connect clusters.
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
{!isLoading && clusterData.length > 0 && (
|
|
108
|
+
<div className="grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
|
109
|
+
{clusterData.map((cluster) => {
|
|
110
|
+
const hasIssues = cluster.failingPods > 0 || cluster.warningEvents > 0;
|
|
111
|
+
return (
|
|
112
|
+
<Card
|
|
113
|
+
key={cluster.name}
|
|
114
|
+
className="cursor-pointer transition-colors hover:border-primary/50 hover:bg-accent/50"
|
|
115
|
+
onClick={() => router.push(`/clusters/${encodeURIComponent(cluster.name)}`)}
|
|
116
|
+
>
|
|
117
|
+
<CardHeader className="pb-2 pt-4 px-4">
|
|
118
|
+
<div className="flex items-center gap-2">
|
|
119
|
+
<div className={`h-2.5 w-2.5 rounded-full shrink-0 ${
|
|
120
|
+
cluster.status === 'connected'
|
|
121
|
+
? hasIssues ? 'bg-amber-500' : 'bg-green-500'
|
|
122
|
+
: 'bg-red-500'
|
|
123
|
+
}`} />
|
|
124
|
+
<CardTitle className="text-sm font-medium truncate">{cluster.name}</CardTitle>
|
|
125
|
+
</div>
|
|
126
|
+
</CardHeader>
|
|
127
|
+
<CardContent className="px-4 pb-4">
|
|
128
|
+
{cluster.error ? (
|
|
129
|
+
<p className="text-xs text-destructive">{cluster.error}</p>
|
|
130
|
+
) : (
|
|
131
|
+
<div className="grid grid-cols-3 gap-2 text-center">
|
|
132
|
+
<div>
|
|
133
|
+
<p className="text-lg font-bold">{cluster.runningPods}<span className="text-xs font-normal text-muted-foreground">/{cluster.podCount}</span></p>
|
|
134
|
+
<p className="text-[10px] text-muted-foreground">Pods</p>
|
|
135
|
+
</div>
|
|
136
|
+
<div>
|
|
137
|
+
<p className={`text-lg font-bold ${cluster.failingPods > 0 ? 'text-red-500' : ''}`}>{cluster.failingPods}</p>
|
|
138
|
+
<p className="text-[10px] text-muted-foreground">Failing</p>
|
|
139
|
+
</div>
|
|
140
|
+
<div>
|
|
141
|
+
<p className={`text-lg font-bold ${cluster.warningEvents > 0 ? 'text-amber-500' : ''}`}>{cluster.warningEvents}</p>
|
|
142
|
+
<p className="text-[10px] text-muted-foreground">Warnings</p>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
</CardContent>
|
|
147
|
+
</Card>
|
|
148
|
+
);
|
|
149
|
+
})}
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
{/* Aggregated Failing Pods */}
|
|
155
|
+
{totalFailing > 0 && (
|
|
156
|
+
<div>
|
|
157
|
+
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
|
158
|
+
<AlertCircle className="h-5 w-5 text-red-500" />
|
|
159
|
+
Failing Pods Breakdown
|
|
160
|
+
</h2>
|
|
161
|
+
<div className="rounded-md border overflow-hidden">
|
|
162
|
+
<table className="w-full text-sm">
|
|
163
|
+
<thead className="bg-muted/50">
|
|
164
|
+
<tr>
|
|
165
|
+
<th className="px-4 py-2 text-left font-medium">Cluster</th>
|
|
166
|
+
<th className="px-4 py-2 text-left font-medium">Failing Pods</th>
|
|
167
|
+
<th className="px-4 py-2 text-left font-medium">Total Pods</th>
|
|
168
|
+
</tr>
|
|
169
|
+
</thead>
|
|
170
|
+
<tbody>
|
|
171
|
+
{clusterData
|
|
172
|
+
.filter((c) => c.failingPods > 0)
|
|
173
|
+
.sort((a, b) => b.failingPods - a.failingPods)
|
|
174
|
+
.map((c) => (
|
|
175
|
+
<tr key={c.name} className="border-t hover:bg-muted/30">
|
|
176
|
+
<td className="px-4 py-2">
|
|
177
|
+
<button
|
|
178
|
+
onClick={() => router.push(`/clusters/${encodeURIComponent(c.name)}`)}
|
|
179
|
+
className="text-primary hover:underline font-medium"
|
|
180
|
+
>
|
|
181
|
+
{c.name}
|
|
182
|
+
</button>
|
|
183
|
+
</td>
|
|
184
|
+
<td className="px-4 py-2">
|
|
185
|
+
<Badge variant="destructive">{c.failingPods}</Badge>
|
|
186
|
+
</td>
|
|
187
|
+
<td className="px-4 py-2 text-muted-foreground">{c.podCount}</td>
|
|
188
|
+
</tr>
|
|
189
|
+
))}
|
|
190
|
+
</tbody>
|
|
191
|
+
</table>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
)}
|
|
195
|
+
|
|
196
|
+
{/* Aggregated Warning Events */}
|
|
197
|
+
{totalWarnings > 0 && (
|
|
198
|
+
<div>
|
|
199
|
+
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2">
|
|
200
|
+
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
|
201
|
+
Warning Events Breakdown
|
|
202
|
+
</h2>
|
|
203
|
+
<div className="rounded-md border overflow-hidden">
|
|
204
|
+
<table className="w-full text-sm">
|
|
205
|
+
<thead className="bg-muted/50">
|
|
206
|
+
<tr>
|
|
207
|
+
<th className="px-4 py-2 text-left font-medium">Cluster</th>
|
|
208
|
+
<th className="px-4 py-2 text-left font-medium">Warnings (1h)</th>
|
|
209
|
+
</tr>
|
|
210
|
+
</thead>
|
|
211
|
+
<tbody>
|
|
212
|
+
{clusterData
|
|
213
|
+
.filter((c) => c.warningEvents > 0)
|
|
214
|
+
.sort((a, b) => b.warningEvents - a.warningEvents)
|
|
215
|
+
.map((c) => (
|
|
216
|
+
<tr key={c.name} className="border-t hover:bg-muted/30">
|
|
217
|
+
<td className="px-4 py-2">
|
|
218
|
+
<button
|
|
219
|
+
onClick={() => router.push(`/clusters/${encodeURIComponent(c.name)}`)}
|
|
220
|
+
className="text-primary hover:underline font-medium"
|
|
221
|
+
>
|
|
222
|
+
{c.name}
|
|
223
|
+
</button>
|
|
224
|
+
</td>
|
|
225
|
+
<td className="px-4 py-2">
|
|
226
|
+
<Badge variant="outline" className="bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20">
|
|
227
|
+
{c.warningEvents}
|
|
228
|
+
</Badge>
|
|
229
|
+
</td>
|
|
230
|
+
</tr>
|
|
231
|
+
))}
|
|
232
|
+
</tbody>
|
|
233
|
+
</table>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
@@ -13,7 +13,7 @@ import { Input } from '@/components/ui/input';
|
|
|
13
13
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
14
14
|
import { Button } from '@/components/ui/button';
|
|
15
15
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
16
|
-
import { Server, ArrowRight, Search, Settings, RotateCw, LogIn, Loader2, CircleCheck, LayoutGrid, List, Star, Tag, ChevronDown, ChevronRight } from 'lucide-react';
|
|
16
|
+
import { Server, ArrowRight, Search, Settings, RotateCw, LogIn, Loader2, CircleCheck, LayoutGrid, LayoutDashboard, List, Star, Tag, ChevronDown, ChevronRight } from 'lucide-react';
|
|
17
17
|
import { ThemeToggle } from '@/components/layout/theme-toggle';
|
|
18
18
|
import { UpdateIndicator } from '@/components/layout/header';
|
|
19
19
|
import { SettingsDialog } from '@/components/settings/settings-dialog';
|
|
@@ -164,6 +164,10 @@ export default function ClustersPage() {
|
|
|
164
164
|
}
|
|
165
165
|
</TooltipContent>
|
|
166
166
|
</Tooltip>
|
|
167
|
+
<Button variant="ghost" size="sm" className="h-8 gap-1.5" onClick={() => router.push('/clusters/overview')} title="Multi-Cluster Overview">
|
|
168
|
+
<LayoutDashboard className="h-4 w-4" />
|
|
169
|
+
<span className="text-xs">Overview</span>
|
|
170
|
+
</Button>
|
|
167
171
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleRefresh} disabled={refreshing} title="Refresh cluster list">
|
|
168
172
|
<RotateCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
|
169
173
|
</Button>
|