kubeops 0.1.2 → 0.1.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kubeops",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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
+ }
@@ -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
+ }