@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.
- package/.turbo/turbo-build.log +4 -0
- package/LICENSE +21 -0
- package/dist/KubernetesTab.d.ts +8 -0
- package/dist/KubernetesTab.d.ts.map +1 -0
- package/dist/KubernetesTab.js +91 -0
- package/dist/KubernetesTab.js.map +1 -0
- package/dist/LogsDrawer.d.ts +11 -0
- package/dist/LogsDrawer.d.ts.map +1 -0
- package/dist/LogsDrawer.js +21 -0
- package/dist/LogsDrawer.js.map +1 -0
- package/dist/PodStatusBadge.d.ts +7 -0
- package/dist/PodStatusBadge.d.ts.map +1 -0
- package/dist/PodStatusBadge.js +21 -0
- package/dist/PodStatusBadge.js.map +1 -0
- package/dist/__tests__/api-client.test.d.ts +2 -0
- package/dist/__tests__/api-client.test.d.ts.map +1 -0
- package/dist/__tests__/api-client.test.js +260 -0
- package/dist/__tests__/api-client.test.js.map +1 -0
- package/dist/actions.d.ts +27 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +124 -0
- package/dist/actions.js.map +1 -0
- package/dist/api-client.d.ts +27 -0
- package/dist/api-client.d.ts.map +1 -0
- package/dist/api-client.js +193 -0
- package/dist/api-client.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/routes.d.ts +14 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +115 -0
- package/dist/routes.js.map +1 -0
- package/dist/types.d.ts +138 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/ui.d.ts +11 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +18 -0
- package/dist/ui.js.map +1 -0
- package/forgeportal-plugin.json +32 -0
- package/package.json +51 -0
- package/src/KubernetesTab.tsx +391 -0
- package/src/LogsDrawer.tsx +83 -0
- package/src/PodStatusBadge.tsx +36 -0
- package/src/__tests__/api-client.test.ts +324 -0
- package/src/actions.ts +146 -0
- package/src/api-client.ts +248 -0
- package/src/index.ts +46 -0
- package/src/routes.ts +154 -0
- package/src/types.ts +103 -0
- package/src/ui.ts +19 -0
- package/tsconfig.json +11 -0
package/dist/types.d.ts
ADDED
|
@@ -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 @@
|
|
|
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
|
package/dist/ui.d.ts.map
ADDED
|
@@ -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
|
+
}
|