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 +77 -49
- package/electron/main.js +8 -2
- package/electron-builder.yml +14 -7
- package/{next.config.ts → next.config.mjs} +2 -3
- package/package.json +5 -3
- package/scripts/build-server.mjs +1 -1
- package/src/app/api/clusters/route.ts +7 -2
- package/src/app/api/tsh/login/route.ts +4 -1
- package/src/app/api/tsh/status/route.ts +40 -0
- package/src/app/clusters/[clusterId]/app-map/page.tsx +1 -0
- package/src/app/clusters/page.tsx +204 -47
- package/src/app/globals.css +4 -2
- package/src/components/panel/logs-tab.tsx +119 -8
- package/src/components/resources/resource-list-page.tsx +2 -2
- package/src/components/shared/loading-skeleton.tsx +113 -6
- package/src/components/shared/metrics-charts-impl.tsx +3 -2
- package/src/components/shared/resource-tree-impl.tsx +5 -0
- package/src/stores/panel-store.ts +7 -0
- package/ws/exec-handler.ts +25 -9
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
|
|
28
|
-
|
|
27
|
+
| Platform | File |
|
|
28
|
+
| --------------------- | ----------------------------- |
|
|
29
29
|
| macOS (Apple Silicon) | `KubeOps-{version}-arm64.dmg` |
|
|
30
|
-
| macOS (Intel)
|
|
31
|
-
| Linux
|
|
32
|
-
| Windows
|
|
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
|
-
|
|
58
|
+
|
|
59
|
+

|
|
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
|
-
|
|
66
|
+
|
|
67
|
+

|
|
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
|
-
|
|
74
|
+
|
|
75
|
+

|
|
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
|
-
|
|
82
|
+
|
|
83
|
+

|
|
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
|
-
|
|
90
|
+
|
|
91
|
+

|
|
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
|
-
|
|
102
|
+
|
|
103
|
+

|
|
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
|
-
|
|
110
|
+
|
|
111
|
+

|
|
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
|
-
|
|
118
|
+
|
|
119
|
+

|
|
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
|
|
145
|
-
| Windows
|
|
146
|
-
| 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
|
|
155
|
-
|
|
156
|
-
| Desktop shell
|
|
157
|
-
| Frontend
|
|
158
|
-
| State
|
|
159
|
-
| Data fetching
|
|
160
|
-
| K8s API
|
|
161
|
-
| Terminal
|
|
162
|
-
| Charts
|
|
163
|
-
| Resource graph | React Flow + Dagre
|
|
164
|
-
| 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
|
|
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
|
|
193
|
-
| Windows
|
|
194
|
-
| Linux
|
|
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
|
|
201
|
-
|
|
202
|
-
| Open Error Log
|
|
203
|
-
| Show Log Folder
|
|
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
|
|
213
|
-
|
|
214
|
-
|
|
|
215
|
-
|
|
|
216
|
-
|
|
|
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
|
|
219
|
-
| Diagnosing crashes
|
|
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 {
|
|
349
|
+
const { spawn } = require('child_process');
|
|
345
350
|
const appRoot = path.join(__dirname, '..');
|
|
346
351
|
|
|
347
|
-
serverProcess =
|
|
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
|
});
|
package/electron-builder.yml
CHANGED
|
@@ -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
|
-
-
|
|
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:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kubeops",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
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",
|
package/scripts/build-server.mjs
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
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
|
+
}
|
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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 “{search}”
|
package/src/app/globals.css
CHANGED
|
@@ -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}×tamps=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
|
-
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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 {
|
|
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 <
|
|
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
|
-
|
|
7
|
-
<
|
|
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
|
-
<
|
|
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="
|
|
19
|
-
<
|
|
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
|
-
<
|
|
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={
|
|
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={
|
|
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
|
}));
|
package/ws/exec-handler.ts
CHANGED
|
@@ -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 = '
|
|
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
|
-
//
|
|
31
|
-
|
|
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,
|