kubeops 0.1.2 → 0.1.4
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/electron/main.js +36 -4
- package/package.json +1 -1
- package/src/app/api/clusters/[clusterId]/crds/[...path]/route.ts +182 -0
- package/src/app/api/clusters/[clusterId]/crds/route.ts +51 -0
- package/src/app/clusters/[clusterId]/app-map/page.tsx +45 -7
- package/src/app/clusters/[clusterId]/custom-resources/[...resourcePath]/page.tsx +18 -0
- package/src/app/clusters/[clusterId]/custom-resources/page.tsx +7 -0
- package/src/app/clusters/page.tsx +2 -0
- package/src/components/custom-resources/cr-detail-page.tsx +176 -0
- package/src/components/custom-resources/cr-list-page.tsx +173 -0
- package/src/components/custom-resources/crd-browser.tsx +195 -0
- package/src/components/layout/header.tsx +1 -1
- package/src/components/shared/resource-info-drawer.tsx +1 -1
- package/src/components/shared/resource-node.tsx +39 -4
- package/src/components/shared/resource-tree-impl.tsx +62 -22
- package/src/hooks/use-auto-update.ts +32 -2
- package/src/hooks/use-crd-list.ts +42 -0
- package/src/hooks/use-custom-resource-detail.ts +36 -0
- package/src/hooks/use-custom-resource-list.ts +34 -0
- package/src/hooks/use-resource-tree.ts +116 -24
- package/src/lib/constants.ts +8 -0
package/electron/main.js
CHANGED
|
@@ -106,14 +106,43 @@ function setupAutoUpdater() {
|
|
|
106
106
|
});
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
function setupDevUpdaterIPC() {
|
|
110
|
+
ipcMain.handle('updater:check', async () => {
|
|
111
|
+
sendUpdateStatus({ status: 'not-available', version: app.getVersion() });
|
|
112
|
+
return null;
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
ipcMain.handle('updater:download', async () => {
|
|
116
|
+
sendUpdateStatus({ status: 'error', message: 'Updates are not available in development mode' });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
ipcMain.handle('updater:install', () => {});
|
|
120
|
+
|
|
121
|
+
ipcMain.handle('updater:get-version', () => {
|
|
122
|
+
return app.getVersion();
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
109
126
|
function setupUpdaterIPC() {
|
|
110
127
|
ipcMain.handle('updater:check', async () => {
|
|
111
|
-
|
|
112
|
-
|
|
128
|
+
try {
|
|
129
|
+
const result = await autoUpdater.checkForUpdates();
|
|
130
|
+
return result?.updateInfo;
|
|
131
|
+
} catch (err) {
|
|
132
|
+
writeErrorLog('updater:check', err);
|
|
133
|
+
sendUpdateStatus({ status: 'error', message: err.message || 'Update check failed' });
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
113
136
|
});
|
|
114
137
|
|
|
115
138
|
ipcMain.handle('updater:download', async () => {
|
|
116
|
-
|
|
139
|
+
try {
|
|
140
|
+
await autoUpdater.downloadUpdate();
|
|
141
|
+
} catch (err) {
|
|
142
|
+
writeErrorLog('updater:download', err);
|
|
143
|
+
sendUpdateStatus({ status: 'error', message: err.message || 'Download failed' });
|
|
144
|
+
throw err;
|
|
145
|
+
}
|
|
117
146
|
});
|
|
118
147
|
|
|
119
148
|
ipcMain.handle('updater:install', () => {
|
|
@@ -398,7 +427,7 @@ app.whenReady().then(async () => {
|
|
|
398
427
|
}
|
|
399
428
|
createWindow();
|
|
400
429
|
|
|
401
|
-
// Auto-update setup
|
|
430
|
+
// Auto-update setup
|
|
402
431
|
if (!isDev) {
|
|
403
432
|
setupAutoUpdater();
|
|
404
433
|
setupUpdaterIPC();
|
|
@@ -407,6 +436,9 @@ app.whenReady().then(async () => {
|
|
|
407
436
|
writeErrorLog('updater:auto-check', err);
|
|
408
437
|
});
|
|
409
438
|
}, 5000);
|
|
439
|
+
} else {
|
|
440
|
+
// Dev mode: register IPC handlers that respond with dev-friendly messages
|
|
441
|
+
setupDevUpdaterIPC();
|
|
410
442
|
}
|
|
411
443
|
} catch (err) {
|
|
412
444
|
writeErrorLog('main:startup', err);
|
package/package.json
CHANGED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import * as k8s from '@kubernetes/client-node';
|
|
3
|
+
import { getKubeConfigForContext } from '@/lib/k8s/kubeconfig-manager';
|
|
4
|
+
|
|
5
|
+
export const dynamic = 'force-dynamic';
|
|
6
|
+
|
|
7
|
+
interface RouteParams {
|
|
8
|
+
params: Promise<{
|
|
9
|
+
clusterId: string;
|
|
10
|
+
path: string[]; // [group, version, plural] or [group, version, plural, name]
|
|
11
|
+
}>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function extractK8sError(error: any): { status: number; message: string } {
|
|
15
|
+
const raw = error?.statusCode || error?.response?.statusCode || error?.code;
|
|
16
|
+
const status = typeof raw === 'number' && raw >= 200 && raw <= 599 ? raw : 500;
|
|
17
|
+
let body = error?.body;
|
|
18
|
+
if (typeof body === 'string') {
|
|
19
|
+
try { body = JSON.parse(body); } catch { /* keep as string */ }
|
|
20
|
+
}
|
|
21
|
+
const message = body?.message || error?.message || 'Request failed';
|
|
22
|
+
return { status, message };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function buildApiPath(group: string, version: string, plural: string, namespace?: string, name?: string): string {
|
|
26
|
+
const base = `/apis/${group}/${version}`;
|
|
27
|
+
if (namespace && namespace !== '_' && namespace !== '_all') {
|
|
28
|
+
const path = `${base}/namespaces/${namespace}/${plural}`;
|
|
29
|
+
return name ? `${path}/${name}` : path;
|
|
30
|
+
}
|
|
31
|
+
const path = `${base}/${plural}`;
|
|
32
|
+
return name ? `${path}/${name}` : path;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function makeK8sRequest(
|
|
36
|
+
contextName: string,
|
|
37
|
+
method: string,
|
|
38
|
+
apiPath: string,
|
|
39
|
+
body?: any
|
|
40
|
+
): Promise<{ status: number; data: any }> {
|
|
41
|
+
const kc = getKubeConfigForContext(contextName);
|
|
42
|
+
const cluster = kc.getCurrentCluster();
|
|
43
|
+
if (!cluster) throw new Error('Cluster not found');
|
|
44
|
+
|
|
45
|
+
const opts: any = {};
|
|
46
|
+
await kc.applyToHTTPSOptions(opts);
|
|
47
|
+
|
|
48
|
+
const https = require('https');
|
|
49
|
+
const urlObj = new URL(`${cluster.server}${apiPath}`);
|
|
50
|
+
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const reqData = body ? JSON.stringify(body) : undefined;
|
|
53
|
+
const contentType = method === 'PATCH'
|
|
54
|
+
? 'application/merge-patch+json'
|
|
55
|
+
: 'application/json';
|
|
56
|
+
|
|
57
|
+
const reqOpts: any = {
|
|
58
|
+
hostname: urlObj.hostname,
|
|
59
|
+
port: urlObj.port || 443,
|
|
60
|
+
path: urlObj.pathname + urlObj.search,
|
|
61
|
+
method,
|
|
62
|
+
headers: {
|
|
63
|
+
'Content-Type': contentType,
|
|
64
|
+
Accept: 'application/json',
|
|
65
|
+
...(reqData ? { 'Content-Length': Buffer.byteLength(reqData) } : {}),
|
|
66
|
+
},
|
|
67
|
+
ca: opts.ca,
|
|
68
|
+
cert: opts.cert,
|
|
69
|
+
key: opts.key,
|
|
70
|
+
rejectUnauthorized: !(cluster as any).skipTLSVerify,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Apply auth headers (bearer token, etc.)
|
|
74
|
+
if (opts.headers) {
|
|
75
|
+
Object.assign(reqOpts.headers, opts.headers);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const request = https.request(reqOpts, (res: any) => {
|
|
79
|
+
let responseBody = '';
|
|
80
|
+
res.on('data', (chunk: string) => { responseBody += chunk; });
|
|
81
|
+
res.on('end', () => {
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(responseBody);
|
|
84
|
+
resolve({ status: res.statusCode, data: parsed });
|
|
85
|
+
} catch {
|
|
86
|
+
resolve({ status: res.statusCode, data: responseBody });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
request.on('error', reject);
|
|
91
|
+
if (reqData) request.write(reqData);
|
|
92
|
+
request.end();
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function GET(req: NextRequest, { params }: RouteParams) {
|
|
97
|
+
const { clusterId, path } = await params;
|
|
98
|
+
const contextName = decodeURIComponent(clusterId);
|
|
99
|
+
const [group, version, plural, name] = path;
|
|
100
|
+
|
|
101
|
+
if (!group || !version || !plural) {
|
|
102
|
+
return NextResponse.json({ error: 'Path must be: group/version/plural[/name]' }, { status: 400 });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const namespace = req.nextUrl.searchParams.get('namespace') || undefined;
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const apiPath = buildApiPath(group, version, plural, namespace, name);
|
|
109
|
+
const { status, data } = await makeK8sRequest(contextName, 'GET', apiPath);
|
|
110
|
+
|
|
111
|
+
if (status >= 200 && status < 300) {
|
|
112
|
+
return NextResponse.json(data);
|
|
113
|
+
}
|
|
114
|
+
return NextResponse.json(
|
|
115
|
+
{ error: data?.message || 'Request failed' },
|
|
116
|
+
{ status }
|
|
117
|
+
);
|
|
118
|
+
} catch (error: any) {
|
|
119
|
+
const { status, message } = extractK8sError(error);
|
|
120
|
+
console.error(`[K8s API] GET CR ${group}/${version}/${plural}${name ? `/${name}` : ''}: ${status} ${message}`);
|
|
121
|
+
return NextResponse.json({ error: message }, { status });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function PUT(req: NextRequest, { params }: RouteParams) {
|
|
126
|
+
const { clusterId, path } = await params;
|
|
127
|
+
const contextName = decodeURIComponent(clusterId);
|
|
128
|
+
const [group, version, plural, name] = path;
|
|
129
|
+
|
|
130
|
+
if (!group || !version || !plural || !name) {
|
|
131
|
+
return NextResponse.json({ error: 'Path must be: group/version/plural/name' }, { status: 400 });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const namespace = req.nextUrl.searchParams.get('namespace') || undefined;
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const body = await req.json();
|
|
138
|
+
const apiPath = buildApiPath(group, version, plural, namespace, name);
|
|
139
|
+
const { status, data } = await makeK8sRequest(contextName, 'PUT', apiPath, body);
|
|
140
|
+
|
|
141
|
+
if (status >= 200 && status < 300) {
|
|
142
|
+
return NextResponse.json(data);
|
|
143
|
+
}
|
|
144
|
+
return NextResponse.json(
|
|
145
|
+
{ error: data?.message || 'Update failed' },
|
|
146
|
+
{ status }
|
|
147
|
+
);
|
|
148
|
+
} catch (error: any) {
|
|
149
|
+
const { status, message } = extractK8sError(error);
|
|
150
|
+
console.error(`[K8s API] PUT CR ${group}/${version}/${plural}/${name}: ${status} ${message}`);
|
|
151
|
+
return NextResponse.json({ error: message }, { status });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function DELETE(req: NextRequest, { params }: RouteParams) {
|
|
156
|
+
const { clusterId, path } = await params;
|
|
157
|
+
const contextName = decodeURIComponent(clusterId);
|
|
158
|
+
const [group, version, plural, name] = path;
|
|
159
|
+
|
|
160
|
+
if (!group || !version || !plural || !name) {
|
|
161
|
+
return NextResponse.json({ error: 'Path must be: group/version/plural/name' }, { status: 400 });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const namespace = req.nextUrl.searchParams.get('namespace') || undefined;
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const apiPath = buildApiPath(group, version, plural, namespace, name);
|
|
168
|
+
const { status, data } = await makeK8sRequest(contextName, 'DELETE', apiPath);
|
|
169
|
+
|
|
170
|
+
if (status >= 200 && status < 300) {
|
|
171
|
+
return NextResponse.json(data);
|
|
172
|
+
}
|
|
173
|
+
return NextResponse.json(
|
|
174
|
+
{ error: data?.message || 'Delete failed' },
|
|
175
|
+
{ status }
|
|
176
|
+
);
|
|
177
|
+
} catch (error: any) {
|
|
178
|
+
const { status, message } = extractK8sError(error);
|
|
179
|
+
console.error(`[K8s API] DELETE CR ${group}/${version}/${plural}/${name}: ${status} ${message}`);
|
|
180
|
+
return NextResponse.json({ error: message }, { status });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import * as k8s from '@kubernetes/client-node';
|
|
3
|
+
import { getKubeConfigForContext } from '@/lib/k8s/kubeconfig-manager';
|
|
4
|
+
|
|
5
|
+
export const dynamic = 'force-dynamic';
|
|
6
|
+
|
|
7
|
+
interface RouteParams {
|
|
8
|
+
params: Promise<{ clusterId: string }>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function GET(req: NextRequest, { params }: RouteParams) {
|
|
12
|
+
const { clusterId } = await params;
|
|
13
|
+
const contextName = decodeURIComponent(clusterId);
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const kc = getKubeConfigForContext(contextName);
|
|
17
|
+
const client = kc.makeApiClient(k8s.ApiextensionsV1Api);
|
|
18
|
+
const result = await client.listCustomResourceDefinition();
|
|
19
|
+
|
|
20
|
+
// Transform CRD list into a more frontend-friendly format
|
|
21
|
+
const crds = (result.items || []).map((crd: any) => {
|
|
22
|
+
const spec = crd.spec || {};
|
|
23
|
+
const names = spec.names || {};
|
|
24
|
+
const servingVersion = (spec.versions || []).find((v: any) => v.served && v.storage)
|
|
25
|
+
|| (spec.versions || [])[0]
|
|
26
|
+
|| {};
|
|
27
|
+
const printerColumns = servingVersion.additionalPrinterColumns || [];
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
name: crd.metadata?.name,
|
|
31
|
+
group: spec.group,
|
|
32
|
+
kind: names.kind,
|
|
33
|
+
plural: names.plural,
|
|
34
|
+
singular: names.singular,
|
|
35
|
+
scope: spec.scope, // Namespaced or Cluster
|
|
36
|
+
version: servingVersion.name,
|
|
37
|
+
versions: (spec.versions || [])
|
|
38
|
+
.filter((v: any) => v.served)
|
|
39
|
+
.map((v: any) => v.name),
|
|
40
|
+
printerColumns,
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return NextResponse.json({ items: crds });
|
|
45
|
+
} catch (error: any) {
|
|
46
|
+
const status = error?.statusCode || error?.response?.statusCode || 500;
|
|
47
|
+
const message = error?.body?.message || error?.message || 'Failed to list CRDs';
|
|
48
|
+
console.error(`[K8s API] GET CRDs: ${status} ${message}`);
|
|
49
|
+
return NextResponse.json({ error: message }, { status });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import { useState } from 'react';
|
|
3
4
|
import { useParams } from 'next/navigation';
|
|
4
5
|
import { useNamespaceStore } from '@/stores/namespace-store';
|
|
5
6
|
import { useResourceTree } from '@/hooks/use-resource-tree';
|
|
6
7
|
import { ResourceTreeView } from '@/components/shared/resource-tree';
|
|
7
8
|
import { LoadingSkeleton } from '@/components/shared/loading-skeleton';
|
|
9
|
+
import { Badge } from '@/components/ui/badge';
|
|
10
|
+
import { Tag, X } from 'lucide-react';
|
|
8
11
|
|
|
9
12
|
export default function AppMapPage() {
|
|
10
13
|
const params = useParams();
|
|
@@ -12,24 +15,59 @@ export default function AppMapPage() {
|
|
|
12
15
|
const decodedClusterId = decodeURIComponent(clusterId);
|
|
13
16
|
const { getActiveNamespace } = useNamespaceStore();
|
|
14
17
|
const namespace = getActiveNamespace(decodedClusterId);
|
|
18
|
+
const [appFilter, setAppFilter] = useState<string | undefined>();
|
|
15
19
|
|
|
16
|
-
const { nodes, edges, isLoading } = useResourceTree({
|
|
20
|
+
const { nodes, edges, isLoading, appLabels } = useResourceTree({
|
|
17
21
|
clusterId: decodedClusterId,
|
|
18
22
|
namespace: namespace === '_all' ? 'default' : namespace,
|
|
23
|
+
appFilter,
|
|
19
24
|
});
|
|
20
25
|
|
|
21
26
|
if (isLoading && nodes.length === 0) return <LoadingSkeleton />;
|
|
22
27
|
|
|
23
28
|
return (
|
|
24
29
|
<div className="flex flex-col gap-4 p-6 h-full">
|
|
25
|
-
<div>
|
|
26
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
<div className="flex items-center justify-between">
|
|
31
|
+
<div>
|
|
32
|
+
<h1 className="text-2xl font-bold">App Map</h1>
|
|
33
|
+
<p className="text-sm text-muted-foreground">
|
|
34
|
+
Resource relationships in {namespace === '_all' ? 'default' : namespace} namespace
|
|
35
|
+
{' · '}{nodes.length} resources
|
|
36
|
+
</p>
|
|
37
|
+
</div>
|
|
31
38
|
</div>
|
|
32
39
|
|
|
40
|
+
{/* App label filter chips */}
|
|
41
|
+
{appLabels.length > 0 && (
|
|
42
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
43
|
+
<Tag className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
|
44
|
+
<button
|
|
45
|
+
onClick={() => setAppFilter(undefined)}
|
|
46
|
+
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
|
|
47
|
+
!appFilter
|
|
48
|
+
? 'bg-primary text-primary-foreground'
|
|
49
|
+
: 'bg-muted text-muted-foreground hover:bg-accent'
|
|
50
|
+
}`}
|
|
51
|
+
>
|
|
52
|
+
All
|
|
53
|
+
</button>
|
|
54
|
+
{appLabels.map((label) => (
|
|
55
|
+
<button
|
|
56
|
+
key={label}
|
|
57
|
+
onClick={() => setAppFilter(appFilter === label ? undefined : label)}
|
|
58
|
+
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors flex items-center gap-1 ${
|
|
59
|
+
appFilter === label
|
|
60
|
+
? 'bg-primary text-primary-foreground'
|
|
61
|
+
: 'bg-muted text-muted-foreground hover:bg-accent'
|
|
62
|
+
}`}
|
|
63
|
+
>
|
|
64
|
+
{label}
|
|
65
|
+
{appFilter === label && <X className="h-3 w-3" />}
|
|
66
|
+
</button>
|
|
67
|
+
))}
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
|
|
33
71
|
<ResourceTreeView
|
|
34
72
|
treeNodes={nodes}
|
|
35
73
|
treeEdges={edges}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useParams } from 'next/navigation';
|
|
4
|
+
import { CrListPage } from '@/components/custom-resources/cr-list-page';
|
|
5
|
+
import { CrDetailPage } from '@/components/custom-resources/cr-detail-page';
|
|
6
|
+
|
|
7
|
+
export default function CustomResourcePage() {
|
|
8
|
+
const params = useParams();
|
|
9
|
+
const resourcePath = params.resourcePath as string[];
|
|
10
|
+
|
|
11
|
+
// [group, version, plural] = list page
|
|
12
|
+
// [group, version, plural, name] = detail page
|
|
13
|
+
if (resourcePath.length >= 4) {
|
|
14
|
+
return <CrDetailPage />;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return <CrListPage />;
|
|
18
|
+
}
|
|
@@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button';
|
|
|
12
12
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
13
13
|
import { Server, ArrowRight, Search, Settings, RefreshCw, LogIn, Loader2, CircleCheck } from 'lucide-react';
|
|
14
14
|
import { ThemeToggle } from '@/components/layout/theme-toggle';
|
|
15
|
+
import { UpdateIndicator } from '@/components/layout/header';
|
|
15
16
|
import { SettingsDialog } from '@/components/settings/settings-dialog';
|
|
16
17
|
import { toast } from 'sonner';
|
|
17
18
|
|
|
@@ -174,6 +175,7 @@ export default function ClustersPage() {
|
|
|
174
175
|
>
|
|
175
176
|
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
|
176
177
|
</Button>
|
|
178
|
+
<UpdateIndicator />
|
|
177
179
|
<Button
|
|
178
180
|
variant="ghost"
|
|
179
181
|
size="icon"
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
|
4
|
+
import { useCustomResourceDetail } from '@/hooks/use-custom-resource-detail';
|
|
5
|
+
import { LoadingSkeleton } from '@/components/shared/loading-skeleton';
|
|
6
|
+
import { ErrorDisplay } from '@/components/shared/error-display';
|
|
7
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
8
|
+
import { Badge } from '@/components/ui/badge';
|
|
9
|
+
import { Button } from '@/components/ui/button';
|
|
10
|
+
import { ArrowLeft, Trash2 } from 'lucide-react';
|
|
11
|
+
import { AgeDisplay } from '@/components/shared/age-display';
|
|
12
|
+
import { YamlEditor } from '@/components/shared/yaml-editor';
|
|
13
|
+
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
|
|
14
|
+
import { apiClient } from '@/lib/api-client';
|
|
15
|
+
import { toast } from 'sonner';
|
|
16
|
+
import { useState } from 'react';
|
|
17
|
+
|
|
18
|
+
export function CrDetailPage() {
|
|
19
|
+
const params = useParams();
|
|
20
|
+
const router = useRouter();
|
|
21
|
+
const searchParams = useSearchParams();
|
|
22
|
+
const clusterId = params.clusterId as string;
|
|
23
|
+
const resourcePath = params.resourcePath as string[];
|
|
24
|
+
const [group, version, plural, name] = resourcePath || [];
|
|
25
|
+
const namespace = searchParams.get('ns') || undefined;
|
|
26
|
+
|
|
27
|
+
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
28
|
+
const [deleting, setDeleting] = useState(false);
|
|
29
|
+
|
|
30
|
+
const { data, error, isLoading, mutate } = useCustomResourceDetail({
|
|
31
|
+
clusterId: clusterId ? decodeURIComponent(clusterId) : null,
|
|
32
|
+
group,
|
|
33
|
+
version,
|
|
34
|
+
plural,
|
|
35
|
+
name,
|
|
36
|
+
namespace,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const nsParam = namespace ? `?namespace=${namespace}` : '';
|
|
40
|
+
const yamlApiUrl = `/api/clusters/${clusterId}/crds/${group}/${version}/${plural}/${name}${nsParam}`;
|
|
41
|
+
|
|
42
|
+
if (isLoading) return <LoadingSkeleton />;
|
|
43
|
+
if (error) return <ErrorDisplay error={error} onRetry={() => mutate()} clusterId={clusterId} />;
|
|
44
|
+
if (!data) return null;
|
|
45
|
+
|
|
46
|
+
const resource = data;
|
|
47
|
+
const metadata = resource.metadata || {};
|
|
48
|
+
const labels = metadata.labels || {};
|
|
49
|
+
const annotations = metadata.annotations || {};
|
|
50
|
+
|
|
51
|
+
const handleDelete = async () => {
|
|
52
|
+
setDeleting(true);
|
|
53
|
+
try {
|
|
54
|
+
await apiClient.delete(yamlApiUrl);
|
|
55
|
+
toast.success(`${name} deleted`);
|
|
56
|
+
router.back();
|
|
57
|
+
} catch (err: any) {
|
|
58
|
+
toast.error(`Delete failed: ${err.message}`);
|
|
59
|
+
} finally {
|
|
60
|
+
setDeleting(false);
|
|
61
|
+
setDeleteOpen(false);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className="flex flex-col gap-4 p-6">
|
|
67
|
+
<div className="flex items-center gap-3">
|
|
68
|
+
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
|
69
|
+
<ArrowLeft className="h-4 w-4" />
|
|
70
|
+
</Button>
|
|
71
|
+
<div className="flex-1">
|
|
72
|
+
<h1 className="text-2xl font-bold">{metadata.name}</h1>
|
|
73
|
+
<p className="text-sm text-muted-foreground">
|
|
74
|
+
<span className="font-mono">{group}/{version}</span>
|
|
75
|
+
{metadata.namespace && ` in ${metadata.namespace}`}
|
|
76
|
+
</p>
|
|
77
|
+
</div>
|
|
78
|
+
<Button variant="destructive" size="sm" onClick={() => setDeleteOpen(true)}>
|
|
79
|
+
<Trash2 className="h-4 w-4 mr-1" />
|
|
80
|
+
Delete
|
|
81
|
+
</Button>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<Tabs defaultValue="overview">
|
|
85
|
+
<TabsList>
|
|
86
|
+
<TabsTrigger value="overview">Overview</TabsTrigger>
|
|
87
|
+
<TabsTrigger value="yaml">YAML</TabsTrigger>
|
|
88
|
+
</TabsList>
|
|
89
|
+
|
|
90
|
+
<TabsContent value="overview" className="space-y-4 mt-4">
|
|
91
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
92
|
+
<div className="space-y-3">
|
|
93
|
+
<h3 className="text-sm font-semibold">Metadata</h3>
|
|
94
|
+
<div className="rounded-md border p-3 space-y-2 text-sm">
|
|
95
|
+
<div className="flex justify-between">
|
|
96
|
+
<span className="text-muted-foreground">Name</span>
|
|
97
|
+
<span className="font-mono">{metadata.name}</span>
|
|
98
|
+
</div>
|
|
99
|
+
{metadata.namespace && (
|
|
100
|
+
<div className="flex justify-between">
|
|
101
|
+
<span className="text-muted-foreground">Namespace</span>
|
|
102
|
+
<span className="font-mono">{metadata.namespace}</span>
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
<div className="flex justify-between">
|
|
106
|
+
<span className="text-muted-foreground">UID</span>
|
|
107
|
+
<span className="font-mono text-xs">{metadata.uid}</span>
|
|
108
|
+
</div>
|
|
109
|
+
<div className="flex justify-between">
|
|
110
|
+
<span className="text-muted-foreground">Age</span>
|
|
111
|
+
<AgeDisplay timestamp={metadata.creationTimestamp} />
|
|
112
|
+
</div>
|
|
113
|
+
<div className="flex justify-between">
|
|
114
|
+
<span className="text-muted-foreground">API Version</span>
|
|
115
|
+
<span className="font-mono text-xs">{resource.apiVersion}</span>
|
|
116
|
+
</div>
|
|
117
|
+
<div className="flex justify-between">
|
|
118
|
+
<span className="text-muted-foreground">Kind</span>
|
|
119
|
+
<span className="font-mono text-xs">{resource.kind}</span>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<div className="space-y-3">
|
|
125
|
+
<h3 className="text-sm font-semibold">Labels</h3>
|
|
126
|
+
<div className="rounded-md border p-3 flex flex-wrap gap-1">
|
|
127
|
+
{Object.entries(labels).length > 0 ? (
|
|
128
|
+
Object.entries(labels).map(([k, v]) => (
|
|
129
|
+
<Badge key={k} variant="secondary" className="text-xs font-mono">
|
|
130
|
+
{k}={v as string}
|
|
131
|
+
</Badge>
|
|
132
|
+
))
|
|
133
|
+
) : (
|
|
134
|
+
<span className="text-sm text-muted-foreground">No labels</span>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
{Object.entries(annotations).length > 0 && (
|
|
141
|
+
<div className="space-y-3">
|
|
142
|
+
<h3 className="text-sm font-semibold">Annotations</h3>
|
|
143
|
+
<div className="rounded-md border p-3 space-y-1">
|
|
144
|
+
{Object.entries(annotations).map(([k, v]) => (
|
|
145
|
+
<div key={k} className="text-xs">
|
|
146
|
+
<span className="font-mono text-muted-foreground">{k}</span>
|
|
147
|
+
<span className="font-mono ml-2 break-all">{v as string}</span>
|
|
148
|
+
</div>
|
|
149
|
+
))}
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
</TabsContent>
|
|
154
|
+
|
|
155
|
+
<TabsContent value="yaml" className="mt-4">
|
|
156
|
+
<YamlEditor
|
|
157
|
+
data={resource}
|
|
158
|
+
apiUrl={yamlApiUrl}
|
|
159
|
+
onSaved={() => mutate()}
|
|
160
|
+
/>
|
|
161
|
+
</TabsContent>
|
|
162
|
+
</Tabs>
|
|
163
|
+
|
|
164
|
+
<ConfirmDialog
|
|
165
|
+
open={deleteOpen}
|
|
166
|
+
onOpenChange={setDeleteOpen}
|
|
167
|
+
title={`Delete ${metadata.name}?`}
|
|
168
|
+
description={`This will permanently delete "${metadata.name}". This action cannot be undone.`}
|
|
169
|
+
confirmLabel="Delete"
|
|
170
|
+
variant="destructive"
|
|
171
|
+
onConfirm={handleDelete}
|
|
172
|
+
loading={deleting}
|
|
173
|
+
/>
|
|
174
|
+
</div>
|
|
175
|
+
);
|
|
176
|
+
}
|