kubeops 0.1.0 → 0.1.2

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
@@ -24,12 +24,12 @@
24
24
 
25
25
  Download the latest version for your platform from the **[Releases](https://github.com/trustspirit/kubeops/releases/latest)** page.
26
26
 
27
- | Platform | File |
28
- |----------|------|
27
+ | Platform | File |
28
+ | --------------------- | ----------------------------- |
29
29
  | macOS (Apple Silicon) | `KubeOps-{version}-arm64.dmg` |
30
- | macOS (Intel) | `KubeOps-{version}-x64.dmg` |
31
- | Linux | `KubeOps-{version}.AppImage` |
32
- | Windows | `KubeOps-{version}.exe` |
30
+ | macOS (Intel) | `KubeOps-{version}-x64.dmg` |
31
+ | Linux | `KubeOps-{version}.AppImage` |
32
+ | Windows | `KubeOps-{version}.exe` |
33
33
 
34
34
  > The app supports auto-update after installation.
35
35
 
@@ -55,59 +55,68 @@ Download the latest version for your platform from the **[Releases](https://gith
55
55
  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.
56
56
 
57
57
  <!-- Screenshot: Cluster Overview -->
58
- > _Screenshot: Cluster overview dashboard showing node health, pod distribution chart, and workload status cards_
58
+
59
+ ![KubeOps Interface](assets/dashboard.png)
59
60
 
60
61
  ### App Map (Resource Topology)
61
62
 
62
63
  Interactive flowchart visualizing resource relationships: Ingress → Service → Deployment → ReplicaSet → Pod. Auto-layout with pan, zoom, and fit-to-view. Each node shows kind, name, health status, and summary info with Detail and Info action buttons.
63
64
 
64
65
  <!-- Screenshot: App Map -->
65
- > _Screenshot: App Map view showing connected resources in a visual topology graph_
66
+
67
+ ![KubeOps map](assets/app-map.png)
66
68
 
67
69
  ### Live Status Display
68
70
 
69
71
  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.
70
72
 
71
73
  <!-- Screenshot: Resource List -->
72
- > _Screenshot: Resource list with status badges, search, and sortable columns_
74
+
75
+ ![KubeOps list](assets/live-list.png)
73
76
 
74
77
  ### Pod Terminal & Logs
75
78
 
76
79
  A resizable bottom panel supports multiple concurrent sessions as tabs. Full PTY-based terminal via `kubectl exec` with keyboard input and resizing. Real-time log streaming with pause/follow toggle, jump-to-bottom, and download. Sessions persist across page navigation.
77
80
 
78
81
  <!-- Screenshot: Terminal & Logs -->
79
- > _Screenshot: Split view with terminal session and live log streaming in bottom panel tabs_
82
+
83
+ ![KubeOps log](assets/log.png)
80
84
 
81
85
  ### Port Forwarding
82
86
 
83
87
  Start port forwards from pod container ports, service ports, or YAML editor fields. Manage all active forwards from the Port Forwarding page — view status (starting / active / error), open in browser, or stop individually.
84
88
 
85
89
  <!-- Screenshot: Port Forwarding -->
86
- > _Screenshot: Port forwarding management page with active forwards and status indicators_
90
+
91
+ ![KubeOps forward](assets/port-forward.png)
87
92
 
88
93
  ### YAML Editor (Table / YAML / Edit)
89
94
 
90
95
  Three viewing modes for every resource manifest:
96
+
91
97
  - **Table view** — Structured, collapsible sections with smart value rendering
92
98
  - **YAML view** — Read-only formatted output
93
99
  - **Edit mode** — Syntax-highlighted editor with validation, save with `Cmd+S`
94
100
 
95
101
  <!-- Screenshot: YAML Editor -->
96
- > _Screenshot: YAML editor in edit mode with syntax highlighting and validation_
102
+
103
+ ![KubeOps table](assets/yaml-table.png)
97
104
 
98
105
  ### Command Palette
99
106
 
100
107
  Press `Cmd+K` (or `Ctrl+K`) to open a fuzzy-search palette. Quickly jump to clusters (with connection status), namespaces, or any resource type.
101
108
 
102
109
  <!-- Screenshot: Command Palette -->
103
- > _Screenshot: Command palette open with search results for clusters and resources_
110
+
111
+ ![KubeOps cmd](assets/cmd-k.png)
104
112
 
105
113
  ### Resource Info Drawer
106
114
 
107
115
  Click the info icon on any App Map node to open a right-side drawer with Overview (metadata, status, labels), Events (sorted by severity with warning highlights), and footer actions to navigate to detail pages or open logs.
108
116
 
109
117
  <!-- Screenshot: Resource Info Drawer -->
110
- > _Screenshot: Info drawer open over App Map showing resource overview and events tabs_
118
+
119
+ ![KubeOps drawer](assets/drawer.png)
111
120
 
112
121
  ### Pod Restart Watcher
113
122
 
@@ -139,11 +148,11 @@ The app opens automatically once the dev server is ready (port 51230).
139
148
 
140
149
  Create a distributable package for your platform:
141
150
 
142
- | Platform | Command |
143
- |----------|---------|
144
- | macOS | `npm run electron:build:mac` |
145
- | Windows | `npm run electron:build:win` |
146
- | Linux | `npm run electron:build:linux` |
151
+ | Platform | Command |
152
+ | -------- | ------------------------------ |
153
+ | macOS | `npm run electron:build:mac` |
154
+ | Windows | `npm run electron:build:win` |
155
+ | Linux | `npm run electron:build:linux` |
147
156
 
148
157
  Output is written to `dist-electron/`.
149
158
 
@@ -151,25 +160,25 @@ Output is written to `dist-electron/`.
151
160
 
152
161
  ## Architecture
153
162
 
154
- | Layer | Technology |
155
- |-------|------------|
156
- | Desktop shell | Electron |
157
- | Frontend | Next.js 16 (App Router), React 19, Tailwind CSS |
158
- | State | Zustand (persisted to localStorage) |
159
- | Data fetching | SWR with auto-refresh |
160
- | K8s API | `@kubernetes/client-node` via Next.js API routes |
161
- | Terminal | xterm.js + node-pty over WebSocket |
162
- | Charts | Recharts |
163
- | Resource graph | React Flow + Dagre |
164
- | YAML | js-yaml |
163
+ | Layer | Technology |
164
+ | -------------- | ------------------------------------------------ |
165
+ | Desktop shell | Electron |
166
+ | Frontend | Next.js 16 (App Router), React 19, Tailwind CSS |
167
+ | State | Zustand (persisted to localStorage) |
168
+ | Data fetching | SWR with auto-refresh |
169
+ | K8s API | `@kubernetes/client-node` via Next.js API routes |
170
+ | Terminal | xterm.js + node-pty over WebSocket |
171
+ | Charts | Recharts |
172
+ | Resource graph | React Flow + Dagre |
173
+ | YAML | js-yaml |
165
174
 
166
175
  ---
167
176
 
168
177
  ## Keyboard Shortcuts
169
178
 
170
- | Shortcut | Action |
171
- |----------|--------|
172
- | `Cmd+K` / `Ctrl+K` | Open command palette |
179
+ | Shortcut | Action |
180
+ | ------------------ | ---------------------- |
181
+ | `Cmd+K` / `Ctrl+K` | Open command palette |
173
182
  | `Cmd+S` / `Ctrl+S` | Save YAML in edit mode |
174
183
 
175
184
  ---
@@ -187,21 +196,21 @@ KubeOps automatically captures errors and writes them to a log file so you can d
187
196
 
188
197
  **Log location:**
189
198
 
190
- | Platform | Path |
191
- |----------|------|
192
- | macOS | `~/Library/Application Support/KubeOps/logs/error.log` |
193
- | Windows | `%APPDATA%\KubeOps\logs\error.log` |
194
- | Linux | `~/.config/KubeOps/logs/error.log` |
199
+ | Platform | Path |
200
+ | -------- | ------------------------------------------------------ |
201
+ | macOS | `~/Library/Application Support/KubeOps/logs/error.log` |
202
+ | Windows | `%APPDATA%\KubeOps\logs\error.log` |
203
+ | Linux | `~/.config/KubeOps/logs/error.log` |
195
204
 
196
205
  **Accessing logs from the app:**
197
206
 
198
207
  Use the **Help** menu:
199
208
 
200
- | Menu Item | Action |
201
- |-----------|--------|
202
- | Open Error Log | Opens the log file in your default text editor |
203
- | Show Log Folder | Opens the log directory in Finder / Explorer |
204
- | Export Error Log… | Save a copy to a location of your choice |
209
+ | Menu Item | Action |
210
+ | ----------------- | ---------------------------------------------- |
211
+ | Open Error Log | Opens the log file in your default text editor |
212
+ | Show Log Folder | Opens the log directory in Finder / Explorer |
213
+ | Export Error Log… | Save a copy to a location of your choice |
205
214
 
206
215
  Logs rotate automatically at 5 MB (previous log kept as `error.log.old`).
207
216
 
@@ -209,11 +218,30 @@ Logs rotate automatically at 5 MB (previous log kept as `error.log.old`).
209
218
 
210
219
  ## Troubleshooting
211
220
 
212
- | Problem | Solution |
213
- |---------|----------|
214
- | "No clusters found" | Verify `~/.kube/config` is valid — run `kubectl config get-contexts` |
215
- | Connection refused | Restart the app or check if port 51230 is in use |
216
- | Metrics charts empty | Ensure `metrics-server` is installed in the cluster |
221
+ | Problem | Solution |
222
+ | ------------------------- | --------------------------------------------------------------------------- |
223
+ | macOS Gatekeeper warning | See [macOS Gatekeeper](#macos-gatekeeper) below |
224
+ | "No clusters found" | Verify `~/.kube/config` is valid run `kubectl config get-contexts` |
225
+ | Connection refused | Restart the app or check if port 51230 is in use |
226
+ | Metrics charts empty | Ensure `metrics-server` is installed in the cluster |
217
227
  | Network/FS charts missing | Requires Prometheus with `container_network_*` and `container_fs_*` metrics |
218
- | Port forward fails | Check that `kubectl` is on your PATH and the target pod is running |
219
- | Diagnosing crashes | Open **Help → Open Error Log** to see captured errors |
228
+ | Port forward fails | Check that `kubectl` is on your PATH and the target pod is running |
229
+ | Diagnosing crashes | Open **Help → Open Error Log** to see captured errors |
230
+
231
+ ### macOS Gatekeeper
232
+
233
+ Because the app is not yet code-signed with an Apple Developer certificate, macOS may show:
234
+
235
+ > "KubeOps.app" cannot be opened because the developer cannot be verified.
236
+
237
+ **Option A — System Settings (recommended)**
238
+
239
+ 1. Open **System Settings → Privacy & Security**
240
+ 2. Scroll down to find the blocked message for KubeOps
241
+ 3. Click **Open Anyway**
242
+
243
+ **Option B — Terminal**
244
+
245
+ ```bash
246
+ xattr -cr /Applications/KubeOps.app
247
+ ```
package/electron/main.js CHANGED
@@ -152,6 +152,11 @@ function createWindow() {
152
152
  mainWindow.show();
153
153
  });
154
154
 
155
+ mainWindow.webContents.on('did-fail-load', (_e, code, desc) => {
156
+ writeErrorLog('renderer:did-fail-load', `code=${code} ${desc}`);
157
+ mainWindow.show();
158
+ });
159
+
155
160
  mainWindow.webContents.setWindowOpenHandler(({ url }) => {
156
161
  shell.openExternal(url);
157
162
  return { action: 'deny' };
@@ -341,15 +346,16 @@ function waitForServer(port, maxRetries = 60) {
341
346
  }
342
347
 
343
348
  function startProductionServer() {
344
- const { fork } = require('child_process');
349
+ const { spawn } = require('child_process');
345
350
  const appRoot = path.join(__dirname, '..');
346
351
 
347
- serverProcess = fork(path.join(appRoot, 'dist', 'server.cjs'), [], {
352
+ serverProcess = spawn(process.execPath, [path.join(appRoot, 'dist', 'server.cjs')], {
348
353
  cwd: appRoot,
349
354
  env: {
350
355
  ...process.env,
351
356
  NODE_ENV: 'production',
352
357
  PORT: String(PORT),
358
+ ELECTRON_RUN_AS_NODE: '1',
353
359
  },
354
360
  stdio: 'pipe',
355
361
  });
@@ -9,16 +9,25 @@ asar: false
9
9
 
10
10
  files:
11
11
  - electron/**/*
12
- - .next/**/*
12
+ - .next/server/**/*
13
+ - .next/static/**/*
14
+ - .next/*.json
15
+ - .next/BUILD_ID
16
+ - .next/package.json
13
17
  - dist/**/*
14
- - src/**/*
15
- - ws/**/*
16
18
  - resources/**/*
17
- - server.ts
18
- - next.config.ts
19
+ - next.config.mjs
19
20
  - package.json
20
21
  - "!node_modules/.cache"
22
+ - "!node_modules/.package-lock.json"
23
+ - "!.next/cache"
24
+ - "!.next/trace*"
21
25
  - "!**/*.map"
26
+ - "!**/*.ts"
27
+ - "!**/tsconfig*"
28
+ - "!**/*.md"
29
+ - "!**/LICENSE*"
30
+ - "!**/CHANGELOG*"
22
31
 
23
32
  publish:
24
33
  - provider: github
@@ -33,11 +42,9 @@ mac:
33
42
  - target: dmg
34
43
  arch:
35
44
  - arm64
36
- - x64
37
45
  - target: zip
38
46
  arch:
39
47
  - arm64
40
- - x64
41
48
  darkModeSupport: true
42
49
 
43
50
  linux:
@@ -1,6 +1,5 @@
1
- import type { NextConfig } from "next";
2
-
3
- const nextConfig: NextConfig = {
1
+ /** @type {import('next').NextConfig} */
2
+ const nextConfig = {
4
3
  transpilePackages: ['@xterm/xterm'],
5
4
  turbopack: {},
6
5
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kubeops",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "A modern desktop client for Kubernetes cluster management",
5
5
  "main": "electron/main.js",
6
6
  "repository": {
@@ -24,7 +24,7 @@
24
24
  "resources",
25
25
  "scripts",
26
26
  "server.ts",
27
- "next.config.ts",
27
+ "next.config.mjs",
28
28
  "tsconfig.json",
29
29
  "postcss.config.mjs",
30
30
  "electron-builder.yml"
@@ -42,7 +42,8 @@
42
42
  "electron:publish": "npm run build && node scripts/build-server.mjs && electron-builder --publish always",
43
43
  "electron:publish:mac": "npm run build && node scripts/build-server.mjs && electron-builder --mac --publish always",
44
44
  "electron:publish:linux": "npm run build && node scripts/build-server.mjs && electron-builder --linux --publish always",
45
- "electron:publish:win": "npm run build && node scripts/build-server.mjs && electron-builder --win --publish always"
45
+ "electron:publish:win": "npm run build && node scripts/build-server.mjs && electron-builder --win --publish always",
46
+ "postinstall": "chmod +x node_modules/**/node-pty/prebuilds/*/spawn-helper 2>/dev/null || true"
46
47
  },
47
48
  "dependencies": {
48
49
  "@kubernetes/client-node": "^1.4.0",
@@ -52,6 +53,7 @@
52
53
  "@xterm/addon-web-links": "^0.12.0",
53
54
  "@xterm/xterm": "^6.0.0",
54
55
  "@xyflow/react": "^12.10.0",
56
+ "ansi-to-html": "^0.7.2",
55
57
  "class-variance-authority": "^0.7.1",
56
58
  "clsx": "^2.1.1",
57
59
  "cmdk": "^1.1.1",
@@ -12,8 +12,8 @@ await esbuild.build({
12
12
  sourcemap: true,
13
13
  external: [
14
14
  'next',
15
- '@kubernetes/client-node',
16
15
  'ws',
16
+ 'node-pty',
17
17
  ],
18
18
  });
19
19
 
@@ -6,6 +6,11 @@ import { ClusterInfo } from '@/lib/k8s/types';
6
6
 
7
7
  export const dynamic = 'force-dynamic';
8
8
 
9
+ function stripAnsi(str: string): string {
10
+ // eslint-disable-next-line no-control-regex
11
+ return str.replace(/\x1B\[[0-9;]*m/g, '');
12
+ }
13
+
9
14
  export async function GET() {
10
15
  try {
11
16
  const contexts = getContexts();
@@ -25,7 +30,7 @@ export async function GET() {
25
30
  status: 'connected',
26
31
  };
27
32
  } catch (error: unknown) {
28
- const message = error instanceof Error ? error.message : 'Connection failed';
33
+ const raw = error instanceof Error ? error.message : 'Connection failed';
29
34
  return {
30
35
  name: ctx.name,
31
36
  context: ctx.name,
@@ -34,7 +39,7 @@ export async function GET() {
34
39
  namespace: ctx.namespace || undefined,
35
40
  server: getClusterServer(ctx.name),
36
41
  status: 'error',
37
- error: message,
42
+ error: stripAnsi(raw),
38
43
  };
39
44
  }
40
45
  })
@@ -44,7 +44,10 @@ export async function POST(request: NextRequest) {
44
44
  return NextResponse.json({ error: `Unknown action: ${action}` }, { status: 400 });
45
45
  } catch (err: unknown) {
46
46
  const e = err as { stderr?: Buffer; message?: string };
47
- const message = e.stderr?.toString() || e.message || 'tsh command failed';
47
+ const raw = e.stderr?.toString() || e.message || 'tsh command failed';
48
+ // Strip ANSI escape codes (e.g. [31m, [0m)
49
+ // eslint-disable-next-line no-control-regex
50
+ const message = raw.replace(/\x1B\[[0-9;]*m/g, '');
48
51
  return NextResponse.json({ error: message }, { status: 500 });
49
52
  }
50
53
  }
@@ -0,0 +1,40 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { execSync } from 'child_process';
3
+
4
+ export const dynamic = 'force-dynamic';
5
+
6
+ function findTsh(): string | null {
7
+ try {
8
+ return execSync('which tsh', { encoding: 'utf-8' }).trim();
9
+ } catch {
10
+ return null;
11
+ }
12
+ }
13
+
14
+ export async function GET() {
15
+ const tshPath = findTsh();
16
+ if (!tshPath) {
17
+ return NextResponse.json({ loggedIn: false, reason: 'tsh not found' });
18
+ }
19
+
20
+ try {
21
+ const output = execSync(`${tshPath} status --format=json`, {
22
+ encoding: 'utf-8',
23
+ timeout: 5_000,
24
+ });
25
+ const data = JSON.parse(output);
26
+ const active = data?.active;
27
+ if (!active?.username) {
28
+ return NextResponse.json({ loggedIn: false });
29
+ }
30
+ return NextResponse.json({
31
+ loggedIn: true,
32
+ username: active.username,
33
+ cluster: active.cluster,
34
+ validUntil: active.valid_until,
35
+ });
36
+ } catch {
37
+ // tsh status fails when not logged in
38
+ return NextResponse.json({ loggedIn: false });
39
+ }
40
+ }
@@ -36,6 +36,7 @@ export default function AppMapPage() {
36
36
  isLoading={isLoading}
37
37
  height="calc(100vh - 220px)"
38
38
  direction="LR"
39
+ zoomOnScroll
39
40
  />
40
41
  </div>
41
42
  );
@@ -1,20 +1,119 @@
1
1
  'use client';
2
2
 
3
- import { useState } from 'react';
3
+ import { useState, useCallback } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import useSWR from 'swr';
4
6
  import { useClusters } from '@/hooks/use-clusters';
7
+ import { useSettingsStore } from '@/stores/settings-store';
5
8
  import { Badge } from '@/components/ui/badge';
6
9
  import { Input } from '@/components/ui/input';
7
10
  import { Skeleton } from '@/components/ui/skeleton';
8
11
  import { Button } from '@/components/ui/button';
9
- import { Server, ArrowRight, Search, Settings } from 'lucide-react';
12
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
13
+ import { Server, ArrowRight, Search, Settings, RefreshCw, LogIn, Loader2, CircleCheck } from 'lucide-react';
10
14
  import { ThemeToggle } from '@/components/layout/theme-toggle';
11
15
  import { SettingsDialog } from '@/components/settings/settings-dialog';
12
- import Link from 'next/link';
16
+ import { toast } from 'sonner';
17
+
18
+ /** Extract the real kube cluster name by stripping the kubeconfig cluster prefix.
19
+ * e.g. context="ovdr-teleport-dev-bet-aws-apne2-a01", cluster="ovdr-teleport"
20
+ * → prefix "ovdr-teleport-", real name "dev-bet-aws-apne2-a01" */
21
+ function parseClusterName(contextName: string, clusterField: string) {
22
+ const prefix = clusterField + '-';
23
+ if (contextName.startsWith(prefix) && contextName.length > prefix.length) {
24
+ return { prefix, realName: contextName.slice(prefix.length) };
25
+ }
26
+ return { prefix: '', realName: contextName };
27
+ }
13
28
 
14
29
  export default function ClustersPage() {
15
- const { clusters, isLoading, error } = useClusters();
30
+ const router = useRouter();
31
+ const { clusters, isLoading, error, mutate } = useClusters();
32
+ const { tshProxyUrl, tshAuthType } = useSettingsStore();
16
33
  const [search, setSearch] = useState('');
17
34
  const [settingsOpen, setSettingsOpen] = useState(false);
35
+ const [refreshing, setRefreshing] = useState(false);
36
+ const [loggingIn, setLoggingIn] = useState(false);
37
+ const [kubeLoggingIn, setKubeLoggingIn] = useState<string | null>(null);
38
+ const { data: tshStatus, isLoading: tshLoading, mutate: mutateTshStatus } = useSWR('/api/tsh/status', { refreshInterval: 60_000 });
39
+ const tshLoggedIn = tshStatus?.loggedIn === true;
40
+
41
+ const handleRefresh = useCallback(async () => {
42
+ setRefreshing(true);
43
+ try {
44
+ await mutate();
45
+ } finally {
46
+ setRefreshing(false);
47
+ }
48
+ }, [mutate]);
49
+
50
+ const handleTshLogin = useCallback(async () => {
51
+ if (!tshProxyUrl) {
52
+ toast.error('Teleport Proxy URL is not configured. Please set it in Settings.');
53
+ setSettingsOpen(true);
54
+ return;
55
+ }
56
+ setLoggingIn(true);
57
+ try {
58
+ const res = await fetch('/api/tsh/login', {
59
+ method: 'POST',
60
+ headers: { 'Content-Type': 'application/json' },
61
+ body: JSON.stringify({
62
+ action: 'proxy-login',
63
+ proxyUrl: tshProxyUrl,
64
+ authType: tshAuthType || undefined,
65
+ }),
66
+ });
67
+ const data = await res.json();
68
+ if (!res.ok) {
69
+ toast.error(`tsh login failed: ${data.error}`);
70
+ return;
71
+ }
72
+ toast.success('Teleport login successful');
73
+ await Promise.all([mutate(), mutateTshStatus()]);
74
+ } catch (err: unknown) {
75
+ const message = err instanceof Error ? err.message : 'Unknown error';
76
+ toast.error(`tsh login failed: ${message}`);
77
+ } finally {
78
+ setLoggingIn(false);
79
+ }
80
+ }, [tshProxyUrl, tshAuthType, mutate, mutateTshStatus]);
81
+
82
+ const handleClusterClick = useCallback(async (contextName: string, clusterField: string, status: string) => {
83
+ if (status === 'connected') {
84
+ router.push(`/clusters/${encodeURIComponent(contextName)}`);
85
+ return;
86
+ }
87
+ // For disconnected/error clusters, try tsh kube login with the real cluster name
88
+ const { realName } = parseClusterName(contextName, clusterField);
89
+ setKubeLoggingIn(contextName);
90
+ try {
91
+ const res = await fetch('/api/tsh/login', {
92
+ method: 'POST',
93
+ headers: { 'Content-Type': 'application/json' },
94
+ body: JSON.stringify({ action: 'kube-login', cluster: realName }),
95
+ });
96
+ const data = await res.json();
97
+ if (!res.ok) {
98
+ toast.error('tsh kube login failed', {
99
+ description: `${realName}: ${data.error}`,
100
+ });
101
+ return;
102
+ }
103
+ toast.success('Cluster login successful', {
104
+ description: realName,
105
+ });
106
+ await mutate();
107
+ router.push(`/clusters/${encodeURIComponent(contextName)}`);
108
+ } catch (err: unknown) {
109
+ const message = err instanceof Error ? err.message : 'Unknown error';
110
+ toast.error('tsh kube login failed', {
111
+ description: `${realName}: ${message}`,
112
+ });
113
+ } finally {
114
+ setKubeLoggingIn(null);
115
+ }
116
+ }, [router, mutate]);
18
117
 
19
118
  const filtered = clusters.filter((c) => {
20
119
  const q = search.toLowerCase();
@@ -33,6 +132,48 @@ export default function ClustersPage() {
33
132
  <h1 className="text-lg font-bold tracking-tight">KubeOps</h1>
34
133
  </div>
35
134
  <div className="flex items-center gap-2 no-drag-region">
135
+ <Tooltip>
136
+ <TooltipTrigger asChild>
137
+ <Button
138
+ variant="ghost"
139
+ size="sm"
140
+ className={`h-8 gap-1.5 ${tshLoggedIn ? 'text-green-500' : ''}`}
141
+ onClick={handleTshLogin}
142
+ disabled={loggingIn || tshLoading}
143
+ >
144
+ {loggingIn || tshLoading ? (
145
+ <Loader2 className="h-4 w-4 animate-spin" />
146
+ ) : tshLoggedIn ? (
147
+ <CircleCheck className="h-4 w-4" />
148
+ ) : (
149
+ <LogIn className="h-4 w-4" />
150
+ )}
151
+ <span className="text-xs">
152
+ {tshLoading ? 'TSH' : tshLoggedIn ? tshStatus.username?.split('@')[0] : 'TSH Login'}
153
+ </span>
154
+ </Button>
155
+ </TooltipTrigger>
156
+ <TooltipContent>
157
+ {tshLoading
158
+ ? 'Checking Teleport status...'
159
+ : tshLoggedIn
160
+ ? `Logged in as ${tshStatus.username} · ${tshStatus.cluster}`
161
+ : tshProxyUrl
162
+ ? `tsh login --proxy=${tshProxyUrl}`
163
+ : 'Configure Teleport in Settings first'
164
+ }
165
+ </TooltipContent>
166
+ </Tooltip>
167
+ <Button
168
+ variant="ghost"
169
+ size="icon"
170
+ className="h-8 w-8"
171
+ onClick={handleRefresh}
172
+ disabled={refreshing}
173
+ title="Refresh cluster list"
174
+ >
175
+ <RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
176
+ </Button>
36
177
  <Button
37
178
  variant="ghost"
38
179
  size="icon"
@@ -102,53 +243,69 @@ export default function ClustersPage() {
102
243
  </div>
103
244
 
104
245
  <div className="flex flex-col rounded-md border divide-y">
105
- {filtered.map((cluster) => (
106
- <Link
107
- key={cluster.name}
108
- href={`/clusters/${encodeURIComponent(cluster.name)}`}
109
- className="flex items-center gap-4 px-4 py-3 hover:bg-accent/50 transition-colors group"
110
- >
246
+ {filtered.map((cluster) => {
247
+ const isKubeLogging = kubeLoggingIn === cluster.name;
248
+ const { prefix, realName } = parseClusterName(cluster.name, cluster.cluster);
249
+ return (
111
250
  <div
112
- className={`h-2 w-2 rounded-full shrink-0 ${
113
- cluster.status === 'connected'
114
- ? 'bg-green-500'
115
- : cluster.status === 'error'
116
- ? 'bg-red-500'
117
- : 'bg-yellow-500'
118
- }`}
119
- />
120
- <div className="flex-1 min-w-0">
121
- <div className="flex items-center gap-3">
122
- <span className="font-medium truncate">{cluster.name}</span>
123
- <Badge
124
- variant="outline"
125
- className={
126
- cluster.status === 'connected'
127
- ? 'bg-green-500/10 text-green-500 border-green-500/20'
128
- : cluster.status === 'error'
129
- ? 'bg-red-500/10 text-red-500 border-red-500/20'
130
- : 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20'
131
- }
132
- >
133
- {cluster.status}
134
- </Badge>
135
- </div>
136
- <div className="flex items-center gap-2 mt-0.5">
137
- {cluster.server && (
138
- <span className="text-xs text-muted-foreground truncate">
139
- {cluster.server}
140
- </span>
141
- )}
142
- {cluster.error && (
143
- <span className="text-xs text-destructive truncate">
144
- {cluster.error}
251
+ key={cluster.name}
252
+ role="button"
253
+ tabIndex={0}
254
+ onClick={() => !isKubeLogging && handleClusterClick(cluster.name, cluster.cluster, cluster.status)}
255
+ onKeyDown={(e) => e.key === 'Enter' && !isKubeLogging && handleClusterClick(cluster.name, cluster.cluster, cluster.status)}
256
+ className={`flex items-center gap-4 px-4 py-3 hover:bg-accent/50 transition-colors group cursor-pointer ${isKubeLogging ? 'opacity-60 pointer-events-none' : ''}`}
257
+ >
258
+ <div
259
+ className={`h-2 w-2 rounded-full shrink-0 ${
260
+ cluster.status === 'connected'
261
+ ? 'bg-green-500'
262
+ : cluster.status === 'error'
263
+ ? 'bg-red-500'
264
+ : 'bg-yellow-500'
265
+ }`}
266
+ />
267
+ <div className="flex-1 min-w-0">
268
+ <div className="flex items-center gap-3">
269
+ <span className="font-medium truncate">
270
+ {prefix && <span className="text-muted-foreground">{prefix}</span>}
271
+ <span className="text-primary">{realName}</span>
145
272
  </span>
146
- )}
273
+ <Badge
274
+ variant="outline"
275
+ className={
276
+ cluster.status === 'connected'
277
+ ? 'bg-green-500/10 text-green-500 border-green-500/20'
278
+ : cluster.status === 'error'
279
+ ? 'bg-red-500/10 text-red-500 border-red-500/20'
280
+ : 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20'
281
+ }
282
+ >
283
+ {cluster.status}
284
+ </Badge>
285
+ {isKubeLogging && (
286
+ <span className="flex items-center gap-1 text-xs text-muted-foreground">
287
+ <Loader2 className="h-3 w-3 animate-spin" />
288
+ Logging in...
289
+ </span>
290
+ )}
291
+ </div>
292
+ <div className="flex items-center gap-2 mt-0.5">
293
+ {cluster.server && (
294
+ <span className="text-xs text-muted-foreground truncate">
295
+ {cluster.server}
296
+ </span>
297
+ )}
298
+ {cluster.error && (
299
+ <span className="text-xs text-destructive truncate">
300
+ {cluster.error}
301
+ </span>
302
+ )}
303
+ </div>
147
304
  </div>
305
+ <ArrowRight className="h-4 w-4 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground" />
148
306
  </div>
149
- <ArrowRight className="h-4 w-4 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground" />
150
- </Link>
151
- ))}
307
+ );
308
+ })}
152
309
  {filtered.length === 0 && (
153
310
  <div className="px-4 py-8 text-center text-sm text-muted-foreground">
154
311
  No clusters matching &ldquo;{search}&rdquo;
@@ -156,9 +156,11 @@
156
156
  -webkit-app-region: no-drag;
157
157
  }
158
158
 
159
- /* Electron: push header content right to avoid traffic light overlap */
159
+ /* Electron: push header content right to avoid traffic light overlap.
160
+ Uses !important because Tailwind v4 px-* utilities use padding-inline
161
+ (logical property) which conflicts with padding-left (physical property). */
160
162
  :root.electron .electron-header-inset {
161
- padding-left: 80px;
163
+ padding-left: 80px !important;
162
164
  }
163
165
 
164
166
  /* React Flow Controls - ensure icons are visible in both themes */
@@ -1,9 +1,13 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useRef, useState } from 'react';
3
+ import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
4
4
  import { Button } from '@/components/ui/button';
5
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
5
6
  import { Pause, Play, Download, ArrowDown } from 'lucide-react';
7
+ import AnsiToHtml from 'ansi-to-html';
6
8
  import type { PanelTab } from '@/stores/panel-store';
9
+ import { usePanelStore } from '@/stores/panel-store';
10
+ import { useResourceList } from '@/hooks/use-resource-list';
7
11
 
8
12
  const MAX_LOG_SIZE = 512 * 1024; // 512KB
9
13
 
@@ -20,15 +24,63 @@ interface LogsTabProps {
20
24
 
21
25
  export function LogsTab({ tab }: LogsTabProps) {
22
26
  const { clusterId, namespace, podName, container } = tab;
27
+ const updateTab = usePanelStore((s) => s.updateTab);
23
28
  const [follow, setFollow] = useState(true);
24
29
  const [logs, setLogs] = useState('');
25
30
  const [connected, setConnected] = useState(false);
26
31
  const logRef = useRef<HTMLPreElement>(null);
27
32
  const wsRef = useRef<WebSocket | null>(null);
28
33
 
34
+ // Batching: buffer incoming chunks between animation frames
35
+ const bufferRef = useRef('');
36
+ const rafRef = useRef<number>(0);
37
+
38
+ // Per-instance converter (fresh state per pod/container switch)
39
+ const converterRef = useRef(new AnsiToHtml({ fg: '#cdd6f4', bg: '#1e1e2e', escapeXML: true }));
40
+
41
+ // Fetch all pods in namespace to find siblings
42
+ const { data: podsData } = useResourceList({
43
+ clusterId: clusterId ? decodeURIComponent(clusterId) : null,
44
+ namespace,
45
+ resourceType: 'pods',
46
+ refreshInterval: 10000,
47
+ });
48
+
49
+ // Find current pod object and derive sibling pods + container list
50
+ const currentPod = useMemo(() => {
51
+ const allPods: any[] = podsData?.items || [];
52
+ return allPods.find((p: any) => p.metadata?.name === podName);
53
+ }, [podsData, podName]);
54
+
55
+ const siblingPods = useMemo(() => {
56
+ const allPods: any[] = podsData?.items || [];
57
+ const ownerUid = currentPod?.metadata?.ownerReferences?.[0]?.uid;
58
+ if (!ownerUid) return [];
59
+ return allPods
60
+ .filter((p: any) => p.metadata?.ownerReferences?.[0]?.uid === ownerUid)
61
+ .map((p: any) => p.metadata?.name as string)
62
+ .sort();
63
+ }, [podsData, currentPod]);
64
+
65
+ const containers = useMemo(() => {
66
+ const regular: string[] = (currentPod?.spec?.containers || []).map((c: any) => c.name);
67
+ const init: string[] = (currentPod?.spec?.initContainers || []).map((c: any) => c.name);
68
+ return [...regular, ...init];
69
+ }, [currentPod]);
70
+
71
+ const flushBuffer = useCallback(() => {
72
+ const chunk = bufferRef.current;
73
+ if (!chunk) return;
74
+ bufferRef.current = '';
75
+ setLogs((prev) => trimLogs(prev + chunk));
76
+ }, []);
77
+
29
78
  useEffect(() => {
30
79
  if (!container) return;
31
80
 
81
+ // Reset converter state for new stream
82
+ converterRef.current = new AnsiToHtml({ fg: '#cdd6f4', bg: '#1e1e2e', escapeXML: true });
83
+
32
84
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
33
85
  const decodedCluster = decodeURIComponent(clusterId);
34
86
  const wsUrl = `${protocol}//${window.location.host}/ws/logs/${encodeURIComponent(decodedCluster)}/${namespace}/${podName}?container=${container}&follow=${follow}&timestamps=true&tailLines=500`;
@@ -42,11 +94,18 @@ export function LogsTab({ tab }: LogsTabProps) {
42
94
  try {
43
95
  const msg = JSON.parse(event.data);
44
96
  if (msg.type === 'error') {
45
- setLogs((prev) => trimLogs(prev + `\n[ERROR] ${msg.message}\n`));
97
+ bufferRef.current += `\n[ERROR] ${msg.message}\n`;
98
+ if (!rafRef.current) rafRef.current = requestAnimationFrame(flushBuffer);
46
99
  return;
47
100
  }
48
101
  } catch { /* not JSON */ }
49
- setLogs((prev) => trimLogs(prev + event.data));
102
+ bufferRef.current += event.data;
103
+ if (!rafRef.current) {
104
+ rafRef.current = requestAnimationFrame(() => {
105
+ rafRef.current = 0;
106
+ flushBuffer();
107
+ });
108
+ }
50
109
  }
51
110
  };
52
111
  ws.onclose = () => setConnected(false);
@@ -54,15 +113,24 @@ export function LogsTab({ tab }: LogsTabProps) {
54
113
 
55
114
  return () => {
56
115
  ws.close();
116
+ if (rafRef.current) cancelAnimationFrame(rafRef.current);
117
+ rafRef.current = 0;
118
+ bufferRef.current = '';
57
119
  setLogs('');
58
120
  };
59
- }, [container, clusterId, namespace, podName, follow]);
121
+ }, [container, clusterId, namespace, podName, follow, flushBuffer]);
122
+
123
+ // Memoize ANSI conversion — only re-runs when logs change
124
+ const logsHtml = useMemo(() => {
125
+ if (!logs) return 'Connecting...';
126
+ return converterRef.current.toHtml(logs);
127
+ }, [logs]);
60
128
 
61
129
  useEffect(() => {
62
130
  if (follow && logRef.current) {
63
131
  logRef.current.scrollTop = logRef.current.scrollHeight;
64
132
  }
65
- }, [logs, follow]);
133
+ }, [logsHtml, follow]);
66
134
 
67
135
  const handleDownload = () => {
68
136
  const blob = new Blob([logs], { type: 'text/plain' });
@@ -78,6 +146,22 @@ export function LogsTab({ tab }: LogsTabProps) {
78
146
  if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
79
147
  };
80
148
 
149
+ const handlePodSwitch = (newPodName: string) => {
150
+ if (newPodName === podName) return;
151
+ updateTab(tab.id, {
152
+ podName: newPodName,
153
+ title: `logs: ${newPodName}/${container}`,
154
+ });
155
+ };
156
+
157
+ const handleContainerSwitch = (newContainer: string) => {
158
+ if (newContainer === container) return;
159
+ updateTab(tab.id, {
160
+ container: newContainer,
161
+ title: `logs: ${podName}/${newContainer}`,
162
+ });
163
+ };
164
+
81
165
  return (
82
166
  <div className="flex flex-col h-full">
83
167
  <div className="flex items-center gap-2 px-3 py-1.5 border-b bg-card shrink-0">
@@ -93,6 +177,34 @@ export function LogsTab({ tab }: LogsTabProps) {
93
177
  <Download className="h-3 w-3 mr-1" />
94
178
  Download
95
179
  </Button>
180
+ {siblingPods.length >= 2 && (
181
+ <Select value={podName} onValueChange={handlePodSwitch}>
182
+ <SelectTrigger size="sm" className="h-6 text-xs max-w-[200px]">
183
+ <SelectValue />
184
+ </SelectTrigger>
185
+ <SelectContent>
186
+ {siblingPods.map((name) => (
187
+ <SelectItem key={name} value={name} className="text-xs">
188
+ {name}
189
+ </SelectItem>
190
+ ))}
191
+ </SelectContent>
192
+ </Select>
193
+ )}
194
+ {containers.length >= 2 && (
195
+ <Select value={container} onValueChange={handleContainerSwitch}>
196
+ <SelectTrigger size="sm" className="h-6 text-xs max-w-[160px]">
197
+ <SelectValue />
198
+ </SelectTrigger>
199
+ <SelectContent>
200
+ {containers.map((name) => (
201
+ <SelectItem key={name} value={name} className="text-xs">
202
+ {name}
203
+ </SelectItem>
204
+ ))}
205
+ </SelectContent>
206
+ </Select>
207
+ )}
96
208
  <div className="ml-auto flex items-center gap-1.5">
97
209
  <div className={`h-2 w-2 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`} />
98
210
  <span className="text-xs text-muted-foreground">{connected ? 'Streaming' : 'Disconnected'}</span>
@@ -101,9 +213,8 @@ export function LogsTab({ tab }: LogsTabProps) {
101
213
  <pre
102
214
  ref={logRef}
103
215
  className="flex-1 min-h-0 overflow-auto bg-[#1e1e2e] text-[#cdd6f4] p-3 font-mono text-xs leading-5 whitespace-pre"
104
- >
105
- {logs || 'Connecting...'}
106
- </pre>
216
+ dangerouslySetInnerHTML={{ __html: logsHtml }}
217
+ />
107
218
  </div>
108
219
  );
109
220
  }
@@ -4,7 +4,7 @@ import { useParams } from 'next/navigation';
4
4
  import Link from 'next/link';
5
5
  import { useResourceList } from '@/hooks/use-resource-list';
6
6
  import { DataTable } from '@/components/shared/data-table';
7
- import { LoadingSkeleton } from '@/components/shared/loading-skeleton';
7
+ import { ListSkeleton } from '@/components/shared/loading-skeleton';
8
8
  import { ErrorDisplay } from '@/components/shared/error-display';
9
9
  import { COLUMN_MAP } from './resource-columns';
10
10
  import { RESOURCE_LABELS } from '@/lib/constants';
@@ -36,7 +36,7 @@ export function ResourceListPage({ resourceType, clusterScoped }: ResourceListPa
36
36
  resourceType === 'pods' ? data?.items : undefined
37
37
  );
38
38
 
39
- if (isLoading) return <LoadingSkeleton />;
39
+ if (isLoading) return <ListSkeleton />;
40
40
  if (error) return <ErrorDisplay error={error} onRetry={() => mutate()} clusterId={clusterId} />;
41
41
 
42
42
  const items = data?.items || [];
@@ -1,24 +1,131 @@
1
1
  import { Skeleton } from '@/components/ui/skeleton';
2
2
 
3
+ /** Generic fallback */
3
4
  export function LoadingSkeleton() {
5
+ return <DetailSkeleton />;
6
+ }
7
+
8
+ /** Resource list page: title + search bar + table header + rows */
9
+ export function ListSkeleton() {
4
10
  return (
5
11
  <div className="flex flex-col gap-4 p-6">
6
- <Skeleton className="h-8 w-48" />
7
- <div className="flex flex-col gap-2">
12
+ {/* Title */}
13
+ <Skeleton className="h-8 w-40" />
14
+ {/* Search + filter bar */}
15
+ <div className="flex items-center gap-2">
16
+ <Skeleton className="h-9 w-64 rounded-md" />
17
+ <Skeleton className="h-9 w-24 rounded-md" />
18
+ </div>
19
+ {/* Table */}
20
+ <div className="rounded-md border overflow-hidden">
21
+ {/* Header */}
22
+ <div className="flex items-center gap-4 px-4 py-3 border-b bg-muted/30">
23
+ {[100, 80, 60, 80, 60].map((w, i) => (
24
+ <Skeleton key={i} className="h-4 rounded" style={{ width: w }} />
25
+ ))}
26
+ </div>
27
+ {/* Rows */}
8
28
  {Array.from({ length: 8 }).map((_, i) => (
9
- <Skeleton key={i} className="h-12 w-full" />
29
+ <div key={i} className="flex items-center gap-4 px-4 py-3 border-b last:border-0">
30
+ <Skeleton className="h-4 w-[100px] rounded" />
31
+ <Skeleton className="h-4 w-[80px] rounded" />
32
+ <Skeleton className="h-4 w-[60px] rounded" />
33
+ <Skeleton className="h-4 w-[80px] rounded" />
34
+ <Skeleton className="h-4 w-[60px] rounded" />
35
+ </div>
36
+ ))}
37
+ </div>
38
+ </div>
39
+ );
40
+ }
41
+
42
+ /** Detail page: back button + title/status + tabs + overview content */
43
+ export function DetailSkeleton() {
44
+ return (
45
+ <div className="flex flex-col gap-4 p-6">
46
+ {/* Header */}
47
+ <div className="flex items-center gap-3">
48
+ <Skeleton className="h-9 w-9 rounded-md" />
49
+ <div className="flex-1 space-y-2">
50
+ <div className="flex items-center gap-2">
51
+ <Skeleton className="h-7 w-48" />
52
+ <Skeleton className="h-5 w-16 rounded-full" />
53
+ </div>
54
+ <Skeleton className="h-4 w-72" />
55
+ </div>
56
+ <div className="flex gap-2">
57
+ <Skeleton className="h-8 w-24 rounded-md" />
58
+ <Skeleton className="h-8 w-20 rounded-md" />
59
+ </div>
60
+ </div>
61
+
62
+ {/* Tabs */}
63
+ <div className="flex gap-1">
64
+ {['w-20', 'w-24', 'w-16', 'w-14'].map((w, i) => (
65
+ <Skeleton key={i} className={`h-9 ${w} rounded-md`} />
10
66
  ))}
11
67
  </div>
68
+
69
+ {/* Resource tree placeholder */}
70
+ <div className="space-y-2">
71
+ <Skeleton className="h-4 w-28" />
72
+ <Skeleton className="h-[200px] w-full rounded-lg" />
73
+ </div>
74
+
75
+ {/* Info table */}
76
+ <div className="rounded-md border overflow-hidden">
77
+ {Array.from({ length: 3 }).map((_, i) => (
78
+ <div key={i} className="flex border-b last:border-0">
79
+ {Array.from({ length: 3 }).map((_, j) => (
80
+ <div key={j} className="flex items-center gap-2 px-3 py-2 flex-1">
81
+ <Skeleton className="h-3 w-16" />
82
+ <Skeleton className="h-3 w-20" />
83
+ </div>
84
+ ))}
85
+ </div>
86
+ ))}
87
+ </div>
88
+
89
+ {/* Pods table */}
90
+ <div className="space-y-2">
91
+ <Skeleton className="h-4 w-12" />
92
+ <div className="rounded-md border overflow-hidden">
93
+ <div className="flex items-center gap-4 px-4 py-3 border-b bg-muted/30">
94
+ {[120, 60, 50, 50, 60].map((w, i) => (
95
+ <Skeleton key={i} className="h-3 rounded" style={{ width: w }} />
96
+ ))}
97
+ </div>
98
+ {Array.from({ length: 3 }).map((_, i) => (
99
+ <div key={i} className="flex items-center gap-4 px-4 py-3 border-b last:border-0">
100
+ <Skeleton className="h-3 w-[120px] rounded" />
101
+ <Skeleton className="h-3 w-[60px] rounded" />
102
+ <Skeleton className="h-3 w-[50px] rounded" />
103
+ <Skeleton className="h-3 w-[50px] rounded" />
104
+ <Skeleton className="h-3 w-[60px] rounded" />
105
+ </div>
106
+ ))}
107
+ </div>
108
+ </div>
12
109
  </div>
13
110
  );
14
111
  }
15
112
 
113
+ /** Table-only skeleton (for inline use) */
16
114
  export function TableSkeleton() {
17
115
  return (
18
- <div className="flex flex-col gap-2">
19
- <Skeleton className="h-10 w-full" />
116
+ <div className="rounded-md border overflow-hidden">
117
+ <div className="flex items-center gap-4 px-4 py-3 border-b bg-muted/30">
118
+ {[100, 80, 60, 80].map((w, i) => (
119
+ <Skeleton key={i} className="h-4 rounded" style={{ width: w }} />
120
+ ))}
121
+ </div>
20
122
  {Array.from({ length: 6 }).map((_, i) => (
21
- <Skeleton key={i} className="h-12 w-full" />
123
+ <div key={i} className="flex items-center gap-4 px-4 py-3 border-b last:border-0">
124
+ <Skeleton className="h-4 w-[100px] rounded" />
125
+ <Skeleton className="h-4 w-[80px] rounded" />
126
+ <Skeleton className="h-4 w-[60px] rounded" />
127
+ <Skeleton className="h-4 w-[80px] rounded" />
128
+ </div>
22
129
  ))}
23
130
  </div>
24
131
  );
@@ -383,6 +383,7 @@ export function NodeMetricsSummary({ clusterId }: NodeMetricsProps) {
383
383
  }));
384
384
 
385
385
  const barHeight = Math.max(120, nodes.length * 32 + 30);
386
+ const yAxisWidth = Math.min(150, Math.max(70, ...nodes.map((n: any) => n.name.length * 6 + 10)));
386
387
 
387
388
  return (
388
389
  <Card>
@@ -403,7 +404,7 @@ export function NodeMetricsSummary({ clusterId }: NodeMetricsProps) {
403
404
  </linearGradient>
404
405
  </defs>
405
406
  <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
+ <YAxis type="category" dataKey="name" tick={{ fontSize: 9, fill: 'currentColor' }} tickLine={false} axisLine={false} width={yAxisWidth} />
407
408
  <Tooltip content={<NodeMetricTooltip />} />
408
409
  <Bar dataKey="cpu" fill="url(#nodeCpuGrad)" radius={[0, 6, 6, 0]} animationDuration={600} />
409
410
  </BarChart>
@@ -421,7 +422,7 @@ export function NodeMetricsSummary({ clusterId }: NodeMetricsProps) {
421
422
  </linearGradient>
422
423
  </defs>
423
424
  <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
+ <YAxis type="category" dataKey="name" tick={{ fontSize: 9, fill: 'currentColor' }} tickLine={false} axisLine={false} width={yAxisWidth} />
425
426
  <Tooltip content={<NodeMetricTooltip />} />
426
427
  <Bar dataKey="memory" fill="url(#nodeMemGrad)" radius={[0, 6, 6, 0]} animationDuration={600} />
427
428
  </BarChart>
@@ -88,6 +88,7 @@ interface ResourceTreeViewInnerProps {
88
88
  direction?: 'LR' | 'TB';
89
89
  className?: string;
90
90
  focusNodeId?: string;
91
+ zoomOnScroll?: boolean;
91
92
  onInfoClick: (data: ResourceNodeData) => void;
92
93
  }
93
94
 
@@ -99,6 +100,7 @@ function ResourceTreeViewInner({
99
100
  direction = 'LR',
100
101
  className,
101
102
  focusNodeId,
103
+ zoomOnScroll = false,
102
104
  onInfoClick,
103
105
  }: ResourceTreeViewInnerProps) {
104
106
  const { fitView } = useReactFlow();
@@ -180,6 +182,8 @@ function ResourceTreeViewInner({
180
182
  minZoom={0.3}
181
183
  maxZoom={1.5}
182
184
  proOptions={proOptions}
185
+ zoomOnScroll={zoomOnScroll}
186
+ preventScrolling={zoomOnScroll}
183
187
  nodesDraggable={false}
184
188
  nodesConnectable={false}
185
189
  elementsSelectable={false}
@@ -199,6 +203,7 @@ interface ResourceTreeViewProps {
199
203
  direction?: 'LR' | 'TB';
200
204
  className?: string;
201
205
  focusNodeId?: string;
206
+ zoomOnScroll?: boolean;
202
207
  }
203
208
 
204
209
  export function ResourceTreeView(props: ResourceTreeViewProps) {
@@ -20,6 +20,7 @@ interface PanelState {
20
20
  addTab: (tab: PanelTab) => void;
21
21
  removeTab: (id: string) => void;
22
22
  setActiveTab: (id: string) => void;
23
+ updateTab: (id: string, updates: Partial<Pick<PanelTab, 'podName' | 'container' | 'title'>>) => void;
23
24
  toggle: () => void;
24
25
  }
25
26
 
@@ -57,5 +58,11 @@ export const usePanelStore = create<PanelState>()((set, get) => ({
57
58
  },
58
59
 
59
60
  setActiveTab: (id) => set({ activeTabId: id }),
61
+
62
+ updateTab: (id, updates) => {
63
+ const { tabs } = get();
64
+ set({ tabs: tabs.map((t) => (t.id === id ? { ...t, ...updates } : t)) });
65
+ },
66
+
60
67
  toggle: () => set((s) => ({ open: !s.open })),
61
68
  }));
@@ -4,8 +4,16 @@ import { parse } from 'url';
4
4
  import { execSync } from 'child_process';
5
5
  import * as pty from 'node-pty';
6
6
 
7
+ // macOS GUI apps don't inherit shell PATH — ensure common tool directories are included
8
+ const EXTRA_PATHS = ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'];
9
+ const currentPath = process.env.PATH || '';
10
+ const missingPaths = EXTRA_PATHS.filter(p => !currentPath.split(':').includes(p));
11
+ if (missingPaths.length) {
12
+ process.env.PATH = [...currentPath.split(':'), ...missingPaths].filter(Boolean).join(':');
13
+ }
14
+
7
15
  // Resolve kubectl path at startup
8
- let kubectlPath = '/usr/local/bin/kubectl';
16
+ let kubectlPath = 'kubectl';
9
17
  try {
10
18
  const paths = execSync('which -a kubectl', { encoding: 'utf-8' }).trim().split('\n');
11
19
  kubectlPath = paths.find(p => !p.includes(' ') && !p.includes('.rd/bin'))
@@ -15,6 +23,11 @@ try {
15
23
  } catch { /* use default */ }
16
24
  console.log(`[Exec] Using kubectl: ${kubectlPath}`);
17
25
 
26
+ /** Shell-escape a single argument */
27
+ function shellEscape(arg: string): string {
28
+ return "'" + arg.replace(/'/g, "'\\''") + "'";
29
+ }
30
+
18
31
  export function handleExecConnection(ws: WebSocket, req: IncomingMessage) {
19
32
  const { pathname, query } = parse(req.url!, true);
20
33
  const parts = pathname!.split('/').filter(Boolean);
@@ -27,15 +40,18 @@ export function handleExecConnection(ws: WebSocket, req: IncomingMessage) {
27
40
 
28
41
  let ptyProcess: pty.IPty;
29
42
  try {
30
- // OpenLens-style: kubectl exec -it with shell fallback chain
31
- ptyProcess = pty.spawn(kubectlPath, [
43
+ // Spawn via /bin/sh to avoid posix_spawnp issues in Electron
44
+ const cmd = [
45
+ shellEscape(kubectlPath),
32
46
  'exec', '-i', '-t',
33
- '--context', clusterId,
34
- '-n', namespace,
35
- '-c', container,
36
- podName,
37
- '--', 'sh', '-c', 'clear; (bash || ash || sh)',
38
- ], {
47
+ '--context', shellEscape(clusterId),
48
+ '-n', shellEscape(namespace),
49
+ '-c', shellEscape(container),
50
+ shellEscape(podName),
51
+ '--', 'sh', '-c', "'clear; (bash || ash || sh)'",
52
+ ].join(' ');
53
+
54
+ ptyProcess = pty.spawn('/bin/sh', ['-c', cmd], {
39
55
  name: 'xterm-256color',
40
56
  cols: 80,
41
57
  rows: 24,