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 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
  ![KubeOps map](assets/app-map.png)
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, save with `Cmd+S`
139
- - **Review Changes** — Side-by-side diff showing additions (green) and deletions (red) before saving
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
  ![KubeOps drawer](assets/drawer.png)
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` | Save YAML in edit mode |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kubeops",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "A modern desktop client for Kubernetes cluster management",
5
5
  "main": "electron/main.js",
6
6
  "repository": {
@@ -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>