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 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
- const result = await autoUpdater.checkForUpdates();
112
- return result?.updateInfo;
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
- await autoUpdater.downloadUpdate();
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 (production only)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kubeops",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "A modern desktop client for Kubernetes cluster management",
5
5
  "main": "electron/main.js",
6
6
  "repository": {
@@ -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
- <h1 className="text-2xl font-bold">App Map</h1>
27
- <p className="text-sm text-muted-foreground">
28
- Resource relationships in {namespace === '_all' ? 'default' : namespace} namespace
29
- {' · '}{nodes.length} resources
30
- </p>
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
+ }
@@ -0,0 +1,7 @@
1
+ 'use client';
2
+
3
+ import { CrdBrowser } from '@/components/custom-resources/crd-browser';
4
+
5
+ export default function CustomResourcesPage() {
6
+ return <CrdBrowser />;
7
+ }
@@ -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
+ }