@forgeportal/plugin-kubernetes 1.3.0

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.
Files changed (55) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/LICENSE +21 -0
  3. package/dist/KubernetesTab.d.ts +8 -0
  4. package/dist/KubernetesTab.d.ts.map +1 -0
  5. package/dist/KubernetesTab.js +91 -0
  6. package/dist/KubernetesTab.js.map +1 -0
  7. package/dist/LogsDrawer.d.ts +11 -0
  8. package/dist/LogsDrawer.d.ts.map +1 -0
  9. package/dist/LogsDrawer.js +21 -0
  10. package/dist/LogsDrawer.js.map +1 -0
  11. package/dist/PodStatusBadge.d.ts +7 -0
  12. package/dist/PodStatusBadge.d.ts.map +1 -0
  13. package/dist/PodStatusBadge.js +21 -0
  14. package/dist/PodStatusBadge.js.map +1 -0
  15. package/dist/__tests__/api-client.test.d.ts +2 -0
  16. package/dist/__tests__/api-client.test.d.ts.map +1 -0
  17. package/dist/__tests__/api-client.test.js +260 -0
  18. package/dist/__tests__/api-client.test.js.map +1 -0
  19. package/dist/actions.d.ts +27 -0
  20. package/dist/actions.d.ts.map +1 -0
  21. package/dist/actions.js +124 -0
  22. package/dist/actions.js.map +1 -0
  23. package/dist/api-client.d.ts +27 -0
  24. package/dist/api-client.d.ts.map +1 -0
  25. package/dist/api-client.js +193 -0
  26. package/dist/api-client.js.map +1 -0
  27. package/dist/index.d.ts +14 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +35 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/routes.d.ts +14 -0
  32. package/dist/routes.d.ts.map +1 -0
  33. package/dist/routes.js +115 -0
  34. package/dist/routes.js.map +1 -0
  35. package/dist/types.d.ts +138 -0
  36. package/dist/types.d.ts.map +1 -0
  37. package/dist/types.js +3 -0
  38. package/dist/types.js.map +1 -0
  39. package/dist/ui.d.ts +11 -0
  40. package/dist/ui.d.ts.map +1 -0
  41. package/dist/ui.js +18 -0
  42. package/dist/ui.js.map +1 -0
  43. package/forgeportal-plugin.json +32 -0
  44. package/package.json +51 -0
  45. package/src/KubernetesTab.tsx +391 -0
  46. package/src/LogsDrawer.tsx +83 -0
  47. package/src/PodStatusBadge.tsx +36 -0
  48. package/src/__tests__/api-client.test.ts +324 -0
  49. package/src/actions.ts +146 -0
  50. package/src/api-client.ts +248 -0
  51. package/src/index.ts +46 -0
  52. package/src/routes.ts +154 -0
  53. package/src/types.ts +103 -0
  54. package/src/ui.ts +19 -0
  55. package/tsconfig.json +11 -0
@@ -0,0 +1,138 @@
1
+ export interface ClusterConfig {
2
+ name: string;
3
+ url: string;
4
+ /** Service-account token. Sourced from FORGEPORTAL_PLUGIN_KUBERNETES_<NAME>_TOKEN. */
5
+ token: string;
6
+ skipTLSVerify: boolean;
7
+ }
8
+ export interface K8sDeployment {
9
+ name: string;
10
+ namespace: string;
11
+ replicas: {
12
+ desired: number;
13
+ ready: number;
14
+ available: number;
15
+ };
16
+ image: string;
17
+ lastRollout: string | null;
18
+ healthy: boolean;
19
+ }
20
+ export interface K8sPod {
21
+ name: string;
22
+ namespace: string;
23
+ /** e.g. "Running" | "Pending" | "CrashLoopBackOff" | "Completed" */
24
+ status: string;
25
+ ready: boolean;
26
+ containers: number;
27
+ nodeName: string | null;
28
+ startTime: string | null;
29
+ }
30
+ export interface K8sService {
31
+ name: string;
32
+ namespace: string;
33
+ type: string;
34
+ clusterIp: string;
35
+ ports: Array<{
36
+ port: number;
37
+ protocol: string;
38
+ targetPort: string | number;
39
+ }>;
40
+ }
41
+ export interface K8sIngress {
42
+ name: string;
43
+ namespace: string;
44
+ hosts: string[];
45
+ tls: boolean;
46
+ }
47
+ export interface WorkloadsResponse {
48
+ cluster: string;
49
+ namespace: string;
50
+ labelSelector: string;
51
+ deployments: K8sDeployment[];
52
+ pods: K8sPod[];
53
+ services: K8sService[];
54
+ ingresses: K8sIngress[];
55
+ }
56
+ export interface RawDeployment {
57
+ metadata: {
58
+ name: string;
59
+ namespace: string;
60
+ creationTimestamp?: string;
61
+ };
62
+ spec: {
63
+ replicas?: number;
64
+ template: {
65
+ spec: {
66
+ containers: Array<{
67
+ image?: string;
68
+ }>;
69
+ };
70
+ };
71
+ };
72
+ status?: {
73
+ replicas?: number;
74
+ readyReplicas?: number;
75
+ availableReplicas?: number;
76
+ conditions?: Array<{
77
+ type: string;
78
+ status: string;
79
+ lastUpdateTime?: string;
80
+ }>;
81
+ };
82
+ }
83
+ export interface RawPod {
84
+ metadata: {
85
+ name: string;
86
+ namespace: string;
87
+ };
88
+ spec?: {
89
+ nodeName?: string;
90
+ containers?: Array<{
91
+ name: string;
92
+ }>;
93
+ };
94
+ status?: {
95
+ phase?: string;
96
+ startTime?: string;
97
+ conditions?: Array<{
98
+ type: string;
99
+ status: string;
100
+ }>;
101
+ containerStatuses?: Array<{
102
+ ready?: boolean;
103
+ state?: {
104
+ waiting?: {
105
+ reason?: string;
106
+ };
107
+ };
108
+ }>;
109
+ };
110
+ }
111
+ export interface RawService {
112
+ metadata: {
113
+ name: string;
114
+ namespace: string;
115
+ };
116
+ spec?: {
117
+ type?: string;
118
+ clusterIP?: string;
119
+ ports?: Array<{
120
+ port: number;
121
+ protocol?: string;
122
+ targetPort?: string | number;
123
+ }>;
124
+ };
125
+ }
126
+ export interface RawIngress {
127
+ metadata: {
128
+ name: string;
129
+ namespace: string;
130
+ };
131
+ spec?: {
132
+ tls?: unknown[];
133
+ rules?: Array<{
134
+ host?: string;
135
+ }>;
136
+ };
137
+ }
138
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/types.js ADDED
@@ -0,0 +1,3 @@
1
+ // ─── Cluster config ──────────────────────────────────────────────────────────
2
+ export {};
3
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,gFAAgF"}
package/dist/ui.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import type { ForgePluginSDK } from '@forgeportal/plugin-sdk';
2
+ /**
3
+ * UI entry point for the Kubernetes plugin.
4
+ * Called by the ForgePortal UI shell at startup.
5
+ *
6
+ * Registration in apps/ui/src/plugins/index.ts:
7
+ * import { registerPlugin as registerKubernetes } from '@forgeportal/plugin-kubernetes/ui';
8
+ * registerPluginById('kubernetes', registerKubernetes);
9
+ */
10
+ export declare function registerPlugin(sdk: ForgePluginSDK): void;
11
+ //# sourceMappingURL=ui.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ui.d.ts","sourceRoot":"","sources":["../src/ui.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAG9D;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,cAAc,GAAG,IAAI,CAOxD"}
package/dist/ui.js ADDED
@@ -0,0 +1,18 @@
1
+ import { KubernetesTab } from './KubernetesTab.js';
2
+ /**
3
+ * UI entry point for the Kubernetes plugin.
4
+ * Called by the ForgePortal UI shell at startup.
5
+ *
6
+ * Registration in apps/ui/src/plugins/index.ts:
7
+ * import { registerPlugin as registerKubernetes } from '@forgeportal/plugin-kubernetes/ui';
8
+ * registerPluginById('kubernetes', registerKubernetes);
9
+ */
10
+ export function registerPlugin(sdk) {
11
+ sdk.registerEntityTab({
12
+ id: 'kubernetes-tab',
13
+ title: 'Kubernetes',
14
+ component: KubernetesTab,
15
+ // No appliesTo restriction — the tab component handles missing annotation gracefully
16
+ });
17
+ }
18
+ //# sourceMappingURL=ui.js.map
package/dist/ui.js.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ui.js","sourceRoot":"","sources":["../src/ui.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAEnD;;;;;;;GAOG;AACH,MAAM,UAAU,cAAc,CAAC,GAAmB;IAChD,GAAG,CAAC,iBAAiB,CAAC;QACpB,EAAE,EAAS,gBAAgB;QAC3B,KAAK,EAAM,YAAY;QACvB,SAAS,EAAE,aAAa;QACxB,qFAAqF;KACtF,CAAC,CAAC;AACL,CAAC"}
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@forgeportal/plugin-kubernetes",
3
+ "version": "1.0.0",
4
+ "forgeportal": {
5
+ "engineVersion": "^1.0.0",
6
+ "type": "fullstack",
7
+ "capabilities": {
8
+ "ui": {
9
+ "entityTabs": ["kubernetes-tab"]
10
+ },
11
+ "backend": {
12
+ "routes": ["/api/v1/plugins/kubernetes"],
13
+ "actionProviders": [
14
+ "kubernetes.restartDeployment@v1",
15
+ "kubernetes.scaleDeployment@v1"
16
+ ]
17
+ }
18
+ },
19
+ "config": {
20
+ "clusters": {
21
+ "type": "string",
22
+ "description": "JSON array of cluster configs: [{name,url,skipTLSVerify?}]. Token per cluster via FORGEPORTAL_PLUGIN_KUBERNETES_<CLUSTER_NAME>_TOKEN env var.",
23
+ "required": true
24
+ },
25
+ "defaultNamespace": {
26
+ "type": "string",
27
+ "description": "Default Kubernetes namespace when not specified by entity annotation.",
28
+ "required": false
29
+ }
30
+ }
31
+ }
32
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@forgeportal/plugin-kubernetes",
3
+ "version": "1.3.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "types": "./dist/index.d.ts",
8
+ "import": "./dist/index.js"
9
+ },
10
+ "./ui": {
11
+ "types": "./dist/ui.d.ts",
12
+ "import": "./dist/ui.js"
13
+ },
14
+ "./package.json": "./package.json",
15
+ "./forgeportal-plugin.json": "./forgeportal-plugin.json"
16
+ },
17
+ "main": "dist/index.js",
18
+ "types": "dist/index.d.ts",
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "dependencies": {
23
+ "undici": "^7.22.0",
24
+ "@forgeportal/plugin-sdk": "1.3.0"
25
+ },
26
+ "devDependencies": {
27
+ "@tanstack/react-query": "*",
28
+ "@types/react": "*",
29
+ "fastify": "^5.3.3",
30
+ "react": "*",
31
+ "vitest": "*"
32
+ },
33
+ "peerDependencies": {
34
+ "@tanstack/react-query": ">=5",
35
+ "react": ">=19"
36
+ },
37
+ "peerDependenciesMeta": {
38
+ "react": {
39
+ "optional": true
40
+ },
41
+ "@tanstack/react-query": {
42
+ "optional": true
43
+ }
44
+ },
45
+ "scripts": {
46
+ "build": "tsc",
47
+ "test": "vitest run",
48
+ "lint": "eslint src/",
49
+ "clean": "rm -rf dist"
50
+ }
51
+ }
@@ -0,0 +1,391 @@
1
+ import React, { useState } from 'react';
2
+ import { useApi } from '@forgeportal/plugin-sdk/react';
3
+ import type { Entity } from '@forgeportal/plugin-sdk';
4
+ import type { WorkloadsResponse } from './types.js';
5
+ import { PodStatusBadge } from './PodStatusBadge.js';
6
+ import { LogsDrawer } from './LogsDrawer.js';
7
+
8
+ // ─── Sub-components ──────────────────────────────────────────────────────────
9
+
10
+ function SectionHeader({ title, count }: { title: string; count: number }): React.ReactElement {
11
+ return (
12
+ <div className="flex items-center gap-2 mb-3">
13
+ <h3 className="text-sm font-semibold text-gray-700">{title}</h3>
14
+ <span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500">{count}</span>
15
+ </div>
16
+ );
17
+ }
18
+
19
+ function EmptyRow({ cols, message }: { cols: number; message: string }): React.ReactElement {
20
+ return (
21
+ <tr>
22
+ <td colSpan={cols} className="py-6 text-center text-sm text-gray-400">{message}</td>
23
+ </tr>
24
+ );
25
+ }
26
+
27
+ // ─── Main tab ────────────────────────────────────────────────────────────────
28
+
29
+ interface KubernetesTabProps {
30
+ entity: Entity;
31
+ }
32
+
33
+ interface ClustersResponse {
34
+ data: { name: string; url: string; skipTLSVerify: boolean }[];
35
+ }
36
+
37
+ export function KubernetesTab({ entity }: KubernetesTabProps): React.ReactElement {
38
+ const [selectedCluster, setSelectedCluster] = useState<string | undefined>(undefined);
39
+ const [logsTarget, setLogsTarget] = useState<{ podName: string; namespace: string } | null>(null);
40
+
41
+ // Annotations are now part of the SDK Entity — no extra API call needed.
42
+ const annotations = entity.annotations ?? {};
43
+ const labelSelector = annotations['forgeportal.dev/k8s-label-selector'];
44
+ const clusterAnnotation = annotations['forgeportal.dev/k8s-cluster'];
45
+
46
+ // Fetch available clusters from plugin backend
47
+ const { data: clustersData } = useApi<ClustersResponse>(
48
+ '/api/v1/plugins/kubernetes/clusters',
49
+ );
50
+ const availableClusters = clustersData?.data ?? [];
51
+
52
+ // Active cluster: user pick > entity annotation > first cluster
53
+ const activeCluster =
54
+ selectedCluster ??
55
+ clusterAnnotation ??
56
+ (availableClusters.length > 0 ? availableClusters[0]?.name : undefined);
57
+
58
+ // Build workloads URL — only when label selector is known
59
+ const workloadsParams = labelSelector
60
+ ? new URLSearchParams({
61
+ labelSelector,
62
+ ...(activeCluster ? { cluster: activeCluster } : {}),
63
+ })
64
+ : null;
65
+
66
+ const workloadsUrl = workloadsParams
67
+ ? `/api/v1/plugins/kubernetes/entities/${entity.id}/workloads?${workloadsParams.toString()}`
68
+ : null;
69
+
70
+ const {
71
+ data: workloadsData,
72
+ isPending: workloadsLoading,
73
+ isError,
74
+ error,
75
+ refetch,
76
+ } = useApi<{ data: WorkloadsResponse }>(workloadsUrl ?? '', {
77
+ enabled: !!workloadsUrl,
78
+ refetchInterval: 15_000,
79
+ retry: 1,
80
+ });
81
+
82
+ // ── Not configured state
83
+ if (!labelSelector) {
84
+ return (
85
+ <div className="rounded-lg border border-dashed border-gray-200 bg-gray-50 p-8 text-center">
86
+ <p className="text-sm font-medium text-gray-700 mb-1">Kubernetes not configured for this entity</p>
87
+ <p className="text-xs text-gray-500 mb-4">
88
+ Add the annotation <code className="rounded bg-gray-100 px-1 py-0.5">forgeportal.dev/k8s-label-selector</code> to your <code className="rounded bg-gray-100 px-1 py-0.5">entity.yaml</code> to see live workloads.
89
+ </p>
90
+ <pre className="mx-auto max-w-md rounded bg-gray-800 p-3 text-left text-xs text-green-300">
91
+ {`metadata:\n annotations:\n forgeportal.dev/k8s-label-selector: "app=${entity.name}"\n forgeportal.dev/k8s-cluster: production # optional`}
92
+ </pre>
93
+ </div>
94
+ );
95
+ }
96
+
97
+ const workloads = workloadsData?.data;
98
+
99
+ return (
100
+ <div className="space-y-6">
101
+ {/* Toolbar */}
102
+ <div className="flex items-center justify-between">
103
+ <div className="flex items-center gap-3">
104
+ {workloads && (
105
+ <span className="text-xs text-gray-500">
106
+ Cluster: <strong>{workloads.cluster}</strong> · ns: <strong>{workloads.namespace}</strong>
107
+ {workloads.labelSelector ? ` · selector: ${workloads.labelSelector}` : ''}
108
+ </span>
109
+ )}
110
+ </div>
111
+ <div className="flex items-center gap-2">
112
+ {/* Multi-cluster dropdown — only shown when 2+ clusters are configured */}
113
+ {availableClusters.length > 1 && (
114
+ <select
115
+ value={activeCluster ?? ''}
116
+ onChange={(e) => setSelectedCluster(e.target.value)}
117
+ className="rounded border border-gray-200 bg-white px-2 py-1.5 text-xs text-gray-700 focus:outline-none focus:ring-1 focus:ring-indigo-400"
118
+ aria-label="Select cluster"
119
+ >
120
+ {availableClusters.map((c) => (
121
+ <option key={c.name} value={c.name}>{c.name}</option>
122
+ ))}
123
+ </select>
124
+ )}
125
+ <button
126
+ type="button"
127
+ onClick={() => void refetch()}
128
+ className="rounded border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
129
+ >
130
+ ↻ Refresh
131
+ </button>
132
+ </div>
133
+ </div>
134
+
135
+ {/* Loading */}
136
+ {workloadsLoading && (
137
+ <div className="space-y-3">
138
+ {[1, 2, 3].map((i) => (
139
+ <div key={i} className="h-10 animate-pulse rounded bg-gray-100" />
140
+ ))}
141
+ </div>
142
+ )}
143
+
144
+ {/* Error */}
145
+ {isError && (
146
+ <div className="rounded-md bg-red-50 border border-red-200 p-4 text-sm text-red-700">
147
+ Failed to load workloads: {error?.message}
148
+ </div>
149
+ )}
150
+
151
+ {/* Deployments */}
152
+ {workloads && (
153
+ <>
154
+ <section>
155
+ <SectionHeader title="Deployments" count={workloads.deployments.length} />
156
+ <div className="overflow-x-auto rounded-lg border border-gray-200">
157
+ <table className="min-w-full divide-y divide-gray-200 text-sm">
158
+ <thead className="bg-gray-50">
159
+ <tr>
160
+ <th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
161
+ <th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ready</th>
162
+ <th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Image</th>
163
+ <th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last Rollout</th>
164
+ <th className="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
165
+ </tr>
166
+ </thead>
167
+ <tbody className="divide-y divide-gray-100 bg-white">
168
+ {workloads.deployments.length === 0 && (
169
+ <EmptyRow cols={5} message="No deployments found" />
170
+ )}
171
+ {workloads.deployments.map((d) => (
172
+ <tr key={d.name} className="hover:bg-gray-50">
173
+ <td className="px-4 py-2 font-medium text-gray-900">{d.name}</td>
174
+ <td className="px-4 py-2">
175
+ <span className={`text-xs font-medium ${d.healthy ? 'text-green-700' : 'text-red-600'}`}>
176
+ {d.replicas.ready}/{d.replicas.desired}
177
+ </span>
178
+ </td>
179
+ <td className="px-4 py-2 max-w-xs truncate text-xs text-gray-600" title={d.image}>
180
+ {d.image.split('/').pop()}
181
+ </td>
182
+ <td className="px-4 py-2 text-xs text-gray-500">
183
+ {d.lastRollout ? new Date(d.lastRollout).toLocaleString() : '—'}
184
+ </td>
185
+ <td className="px-4 py-2 text-right">
186
+ <RestartButton
187
+ entityId={entity.id}
188
+ deploymentName={d.name}
189
+ namespace={workloads.namespace}
190
+ cluster={workloads.cluster}
191
+ />
192
+ </td>
193
+ </tr>
194
+ ))}
195
+ </tbody>
196
+ </table>
197
+ </div>
198
+ </section>
199
+
200
+ {/* Pods */}
201
+ <section>
202
+ <SectionHeader title="Pods" count={workloads.pods.length} />
203
+ <div className="overflow-x-auto rounded-lg border border-gray-200">
204
+ <table className="min-w-full divide-y divide-gray-200 text-sm">
205
+ <thead className="bg-gray-50">
206
+ <tr>
207
+ <th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
208
+ <th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
209
+ <th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Containers</th>
210
+ <th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Node</th>
211
+ <th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Age</th>
212
+ <th className="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Logs</th>
213
+ </tr>
214
+ </thead>
215
+ <tbody className="divide-y divide-gray-100 bg-white">
216
+ {workloads.pods.length === 0 && (
217
+ <EmptyRow cols={6} message="No pods found" />
218
+ )}
219
+ {workloads.pods.map((p) => (
220
+ <tr key={p.name} className="hover:bg-gray-50">
221
+ <td className="px-4 py-2 font-mono text-xs text-gray-800">{p.name}</td>
222
+ <td className="px-4 py-2"><PodStatusBadge status={p.status} /></td>
223
+ <td className="px-4 py-2 text-xs text-gray-600">{p.containers}</td>
224
+ <td className="px-4 py-2 text-xs text-gray-500">{p.nodeName ?? '—'}</td>
225
+ <td className="px-4 py-2 text-xs text-gray-500">
226
+ {p.startTime ? formatAge(p.startTime) : '—'}
227
+ </td>
228
+ <td className="px-4 py-2 text-right">
229
+ <button
230
+ type="button"
231
+ onClick={() => setLogsTarget({ podName: p.name, namespace: workloads.namespace })}
232
+ className="text-xs text-indigo-600 hover:text-indigo-800 font-medium"
233
+ >
234
+ View logs
235
+ </button>
236
+ </td>
237
+ </tr>
238
+ ))}
239
+ </tbody>
240
+ </table>
241
+ </div>
242
+ </section>
243
+
244
+ {/* Services */}
245
+ <section>
246
+ <SectionHeader title="Services" count={workloads.services.length} />
247
+ <div className="overflow-x-auto rounded-lg border border-gray-200">
248
+ <table className="min-w-full divide-y divide-gray-200 text-sm">
249
+ <thead className="bg-gray-50">
250
+ <tr>
251
+ <th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
252
+ <th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
253
+ <th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Cluster IP</th>
254
+ <th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ports</th>
255
+ </tr>
256
+ </thead>
257
+ <tbody className="divide-y divide-gray-100 bg-white">
258
+ {workloads.services.length === 0 && (
259
+ <EmptyRow cols={4} message="No services found" />
260
+ )}
261
+ {workloads.services.map((s) => (
262
+ <tr key={s.name} className="hover:bg-gray-50">
263
+ <td className="px-4 py-2 font-medium text-gray-900">{s.name}</td>
264
+ <td className="px-4 py-2 text-xs text-gray-600">{s.type}</td>
265
+ <td className="px-4 py-2 font-mono text-xs text-gray-600">{s.clusterIp || '—'}</td>
266
+ <td className="px-4 py-2 text-xs text-gray-500">
267
+ {s.ports.map((p) => `${p.port}/${p.protocol}`).join(', ') || '—'}
268
+ </td>
269
+ </tr>
270
+ ))}
271
+ </tbody>
272
+ </table>
273
+ </div>
274
+ </section>
275
+
276
+ {/* Ingresses */}
277
+ {workloads.ingresses.length > 0 && (
278
+ <section>
279
+ <SectionHeader title="Ingresses" count={workloads.ingresses.length} />
280
+ <div className="overflow-x-auto rounded-lg border border-gray-200">
281
+ <table className="min-w-full divide-y divide-gray-200 text-sm">
282
+ <thead className="bg-gray-50">
283
+ <tr>
284
+ <th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
285
+ <th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Hosts</th>
286
+ <th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">TLS</th>
287
+ </tr>
288
+ </thead>
289
+ <tbody className="divide-y divide-gray-100 bg-white">
290
+ {workloads.ingresses.map((i) => (
291
+ <tr key={i.name} className="hover:bg-gray-50">
292
+ <td className="px-4 py-2 font-medium text-gray-900">{i.name}</td>
293
+ <td className="px-4 py-2 text-xs text-gray-600">{i.hosts.join(', ') || '—'}</td>
294
+ <td className="px-4 py-2 text-xs">
295
+ {i.tls
296
+ ? <span className="text-green-700 font-medium">Yes</span>
297
+ : <span className="text-gray-400">No</span>
298
+ }
299
+ </td>
300
+ </tr>
301
+ ))}
302
+ </tbody>
303
+ </table>
304
+ </div>
305
+ </section>
306
+ )}
307
+ </>
308
+ )}
309
+
310
+ {/* Logs drawer */}
311
+ {logsTarget && workloads && (
312
+ <LogsDrawer
313
+ entityId={entity.id}
314
+ podName={logsTarget.podName}
315
+ namespace={logsTarget.namespace}
316
+ cluster={activeCluster}
317
+ onClose={() => setLogsTarget(null)}
318
+ />
319
+ )}
320
+ </div>
321
+ );
322
+ }
323
+
324
+ // ─── Restart button ───────────────────────────────────────────────────────────
325
+
326
+ function RestartButton({
327
+ entityId,
328
+ deploymentName,
329
+ namespace,
330
+ cluster,
331
+ }: {
332
+ entityId: string;
333
+ deploymentName: string;
334
+ namespace: string;
335
+ cluster: string;
336
+ }): React.ReactElement {
337
+ const [loading, setLoading] = useState(false);
338
+ const [feedback, setFeedback] = useState<string | null>(null);
339
+
340
+ const handleRestart = async () => {
341
+ setLoading(true);
342
+ setFeedback(null);
343
+ try {
344
+ const res = await fetch(
345
+ `/api/v1/plugins/kubernetes/entities/${entityId}/deployments/${encodeURIComponent(deploymentName)}/restart`,
346
+ {
347
+ method: 'POST',
348
+ credentials: 'include',
349
+ headers: { 'Content-Type': 'application/json' },
350
+ body: JSON.stringify({ namespace, cluster }),
351
+ },
352
+ );
353
+ setFeedback(res.ok ? 'Restarted' : 'Failed');
354
+ } catch {
355
+ setFeedback('Error');
356
+ } finally {
357
+ setLoading(false);
358
+ setTimeout(() => setFeedback(null), 3000);
359
+ }
360
+ };
361
+
362
+ if (feedback) {
363
+ return (
364
+ <span className={`text-xs font-medium ${feedback === 'Restarted' ? 'text-green-600' : 'text-red-600'}`}>
365
+ {feedback}
366
+ </span>
367
+ );
368
+ }
369
+
370
+ return (
371
+ <button
372
+ type="button"
373
+ onClick={() => void handleRestart()}
374
+ disabled={loading}
375
+ className="rounded border border-gray-200 bg-white px-2 py-1 text-xs text-gray-600 hover:bg-red-50 hover:border-red-200 hover:text-red-700 disabled:opacity-50 transition-colors"
376
+ >
377
+ {loading ? '…' : 'Restart'}
378
+ </button>
379
+ );
380
+ }
381
+
382
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
383
+
384
+ function formatAge(isoTime: string): string {
385
+ const diffMs = Date.now() - new Date(isoTime).getTime();
386
+ const minutes = Math.floor(diffMs / 60_000);
387
+ if (minutes < 60) return `${minutes}m`;
388
+ const hours = Math.floor(minutes / 60);
389
+ if (hours < 24) return `${hours}h`;
390
+ return `${Math.floor(hours / 24)}d`;
391
+ }