kubeops 0.1.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/README.md +219 -0
- package/electron/main.js +429 -0
- package/electron/preload.js +13 -0
- package/electron-builder.yml +60 -0
- package/next.config.ts +8 -0
- package/package.json +98 -0
- package/postcss.config.mjs +7 -0
- package/resources/icon.icns +0 -0
- package/resources/icon.ico +0 -0
- package/resources/icon.png +0 -0
- package/resources/icon.svg +86 -0
- package/scripts/build-server.mjs +20 -0
- package/scripts/generate-icons.mjs +61 -0
- package/server.ts +58 -0
- package/src/app/api/clusters/[clusterId]/health/route.ts +27 -0
- package/src/app/api/clusters/[clusterId]/metrics/route.ts +196 -0
- package/src/app/api/clusters/[clusterId]/namespaces/route.ts +48 -0
- package/src/app/api/clusters/[clusterId]/nodes/[nodeName]/route.ts +21 -0
- package/src/app/api/clusters/[clusterId]/nodes/route.ts +31 -0
- package/src/app/api/clusters/[clusterId]/resources/[namespace]/[...resourcePath]/route.ts +204 -0
- package/src/app/api/clusters/route.ts +48 -0
- package/src/app/api/kubeconfig/route.ts +25 -0
- package/src/app/api/port-forward/route.ts +143 -0
- package/src/app/api/tsh/login/route.ts +50 -0
- package/src/app/clusters/[clusterId]/app-map/page.tsx +42 -0
- package/src/app/clusters/[clusterId]/clusterrolebindings/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/clusterrolebindings/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/clusterroles/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/clusterroles/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/layout.tsx +9 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/configmaps/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/configmaps/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/cronjobs/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/cronjobs/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/daemonsets/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/daemonsets/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/deployments/[name]/page.tsx +457 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/deployments/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/endpoints/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/endpoints/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/events/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/ingresses/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/ingresses/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/jobs/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/jobs/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/networkpolicies/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/networkpolicies/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/pods/[podName]/exec/page.tsx +173 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/pods/[podName]/logs/page.tsx +137 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/pods/[podName]/page.tsx +448 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/pods/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/pvcs/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/pvcs/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/replicasets/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/replicasets/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/rolebindings/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/rolebindings/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/roles/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/roles/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/secrets/[name]/page.tsx +168 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/secrets/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/serviceaccounts/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/serviceaccounts/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/services/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/services/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/statefulsets/[name]/page.tsx +302 -0
- package/src/app/clusters/[clusterId]/namespaces/[namespace]/statefulsets/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/nodes/[nodeName]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/nodes/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/page.tsx +635 -0
- package/src/app/clusters/[clusterId]/port-forwarding/page.tsx +145 -0
- package/src/app/clusters/[clusterId]/pvs/[name]/page.tsx +5 -0
- package/src/app/clusters/[clusterId]/pvs/page.tsx +5 -0
- package/src/app/clusters/page.tsx +166 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +167 -0
- package/src/app/layout.tsx +48 -0
- package/src/app/page.tsx +5 -0
- package/src/components/clusters/cluster-selector.tsx +64 -0
- package/src/components/layout/app-shell.tsx +26 -0
- package/src/components/layout/breadcrumbs.tsx +97 -0
- package/src/components/layout/command-palette.tsx +112 -0
- package/src/components/layout/header.tsx +184 -0
- package/src/components/layout/sidebar.tsx +84 -0
- package/src/components/layout/theme-toggle.tsx +21 -0
- package/src/components/namespaces/namespace-selector.tsx +165 -0
- package/src/components/panel/bottom-panel.tsx +127 -0
- package/src/components/panel/logs-tab.tsx +109 -0
- package/src/components/panel/terminal-tab.tsx +180 -0
- package/src/components/pods/pod-watch-button.tsx +44 -0
- package/src/components/resources/resource-columns.tsx +320 -0
- package/src/components/resources/resource-detail-page.tsx +191 -0
- package/src/components/resources/resource-list-page.tsx +78 -0
- package/src/components/resources/scale-dialog.tsx +107 -0
- package/src/components/settings/settings-dialog.tsx +103 -0
- package/src/components/shared/age-display.tsx +27 -0
- package/src/components/shared/confirm-dialog.tsx +52 -0
- package/src/components/shared/data-table.tsx +149 -0
- package/src/components/shared/env-value-resolver.tsx +570 -0
- package/src/components/shared/error-display.tsx +109 -0
- package/src/components/shared/loading-skeleton.tsx +25 -0
- package/src/components/shared/metrics-charts-impl.tsx +434 -0
- package/src/components/shared/metrics-charts.tsx +24 -0
- package/src/components/shared/port-forward-btn.tsx +60 -0
- package/src/components/shared/resource-info-drawer.tsx +542 -0
- package/src/components/shared/resource-node.tsx +157 -0
- package/src/components/shared/resource-tree-impl.tsx +228 -0
- package/src/components/shared/resource-tree.tsx +20 -0
- package/src/components/shared/status-badge.tsx +35 -0
- package/src/components/shared/yaml-editor.tsx +438 -0
- package/src/components/ui/badge.tsx +48 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/card.tsx +92 -0
- package/src/components/ui/command.tsx +184 -0
- package/src/components/ui/dialog.tsx +158 -0
- package/src/components/ui/dropdown-menu.tsx +257 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/popover.tsx +89 -0
- package/src/components/ui/scroll-area.tsx +58 -0
- package/src/components/ui/select.tsx +190 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +143 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/sonner.tsx +40 -0
- package/src/components/ui/table.tsx +116 -0
- package/src/components/ui/tabs.tsx +91 -0
- package/src/components/ui/tooltip.tsx +57 -0
- package/src/hooks/use-age-tick.ts +40 -0
- package/src/hooks/use-auto-update.ts +100 -0
- package/src/hooks/use-clusters.ts +32 -0
- package/src/hooks/use-namespaces.ts +17 -0
- package/src/hooks/use-pod-watcher.ts +79 -0
- package/src/hooks/use-port-forwards.ts +18 -0
- package/src/hooks/use-resource-detail.ts +28 -0
- package/src/hooks/use-resource-list.ts +26 -0
- package/src/hooks/use-resource-tree.ts +440 -0
- package/src/lib/api-client.ts +31 -0
- package/src/lib/constants.ts +126 -0
- package/src/lib/k8s/client-factory.ts +57 -0
- package/src/lib/k8s/kubeconfig-manager.ts +43 -0
- package/src/lib/k8s/resource-api.ts +223 -0
- package/src/lib/k8s/types.ts +29 -0
- package/src/lib/utils.ts +6 -0
- package/src/providers/swr-provider.tsx +20 -0
- package/src/providers/theme-provider.tsx +17 -0
- package/src/stores/cluster-store.ts +32 -0
- package/src/stores/namespace-store.ts +27 -0
- package/src/stores/panel-store.ts +61 -0
- package/src/stores/pod-watcher-store.ts +69 -0
- package/src/stores/settings-store.ts +24 -0
- package/src/stores/sidebar-store.ts +22 -0
- package/src/types/cluster.ts +19 -0
- package/src/types/css.d.ts +6 -0
- package/src/types/electron.d.ts +25 -0
- package/src/types/navigation.ts +4 -0
- package/src/types/resource.ts +27 -0
- package/tsconfig.json +34 -0
- package/ws/exec-handler.ts +112 -0
- package/ws/index.ts +2 -0
- package/ws/logs-handler.ts +70 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import useSWR from 'swr';
|
|
5
|
+
import { Eye, EyeOff, Loader2 } from 'lucide-react';
|
|
6
|
+
import { Badge } from '@/components/ui/badge';
|
|
7
|
+
|
|
8
|
+
// Sensitive key patterns
|
|
9
|
+
const SENSITIVE_PATTERNS = /password|passwd|secret|token|api[_-]?key|apikey|auth|credential|private/i;
|
|
10
|
+
|
|
11
|
+
function isSensitiveKey(name: string): boolean {
|
|
12
|
+
return SENSITIVE_PATTERNS.test(name);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function MaskedValue({ value }: { value: string }) {
|
|
16
|
+
const [revealed, setRevealed] = useState(false);
|
|
17
|
+
return (
|
|
18
|
+
<span className="inline-flex items-center gap-1">
|
|
19
|
+
{revealed ? (
|
|
20
|
+
<>
|
|
21
|
+
<span className="font-mono break-all">{value}</span>
|
|
22
|
+
<button onClick={() => setRevealed(false)} className="text-muted-foreground hover:text-foreground p-0.5"><EyeOff className="h-3 w-3" /></button>
|
|
23
|
+
</>
|
|
24
|
+
) : (
|
|
25
|
+
<>
|
|
26
|
+
<span className="font-mono">{'•'.repeat(Math.min(value.length, 16))}</span>
|
|
27
|
+
<button onClick={() => setRevealed(true)} className="text-muted-foreground hover:text-foreground p-0.5"><Eye className="h-3 w-3" /></button>
|
|
28
|
+
</>
|
|
29
|
+
)}
|
|
30
|
+
</span>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function SecretValueInline({ clusterId, namespace, secretName, secretKey }: {
|
|
35
|
+
clusterId: string; namespace: string; secretName: string; secretKey: string;
|
|
36
|
+
}) {
|
|
37
|
+
const { data, error, isLoading } = useSWR(
|
|
38
|
+
`/api/clusters/${encodeURIComponent(clusterId)}/resources/${namespace}/secrets/${secretName}`,
|
|
39
|
+
{ revalidateOnFocus: false }
|
|
40
|
+
);
|
|
41
|
+
if (isLoading) return <Loader2 className="h-3 w-3 animate-spin inline" />;
|
|
42
|
+
if (error) return <span className="text-amber-600 dark:text-amber-400">secret:{secretName}/{secretKey}</span>;
|
|
43
|
+
|
|
44
|
+
const rawValue = data?.data?.[secretKey];
|
|
45
|
+
if (!rawValue) return <span className="text-muted-foreground italic">key not found</span>;
|
|
46
|
+
|
|
47
|
+
let decoded: string;
|
|
48
|
+
try { decoded = atob(rawValue); } catch { decoded = rawValue; }
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<span className="inline-flex items-center gap-1">
|
|
52
|
+
{isSensitiveKey(secretKey) ? <MaskedValue value={decoded} /> : <span className="font-mono break-all">{decoded}</span>}
|
|
53
|
+
<span className="text-[9px] text-amber-600/60 dark:text-amber-400/60 ml-1">({secretName})</span>
|
|
54
|
+
</span>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function ConfigMapValueInline({ clusterId, namespace, configMapName, configMapKey }: {
|
|
59
|
+
clusterId: string; namespace: string; configMapName: string; configMapKey: string;
|
|
60
|
+
}) {
|
|
61
|
+
const { data, error, isLoading } = useSWR(
|
|
62
|
+
`/api/clusters/${encodeURIComponent(clusterId)}/resources/${namespace}/configmaps/${configMapName}`,
|
|
63
|
+
{ revalidateOnFocus: false }
|
|
64
|
+
);
|
|
65
|
+
if (isLoading) return <Loader2 className="h-3 w-3 animate-spin inline" />;
|
|
66
|
+
if (error) return <span className="text-cyan-600 dark:text-cyan-400">configmap:{configMapName}/{configMapKey}</span>;
|
|
67
|
+
|
|
68
|
+
const value = data?.data?.[configMapKey];
|
|
69
|
+
if (value === undefined) return <span className="text-muted-foreground italic">key not found</span>;
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<span className="inline-flex items-center gap-1">
|
|
73
|
+
{isSensitiveKey(configMapKey) ? <MaskedValue value={value} /> : <span className="font-mono break-all">{value.length > 100 ? value.substring(0, 100) + '...' : value}</span>}
|
|
74
|
+
<span className="text-[9px] text-cyan-600/60 dark:text-cyan-400/60 ml-1">({configMapName})</span>
|
|
75
|
+
</span>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Single env var value cell
|
|
80
|
+
export function EnvValueCell({ env, clusterId, namespace }: {
|
|
81
|
+
env: any; clusterId: string; namespace: string;
|
|
82
|
+
}) {
|
|
83
|
+
if (env.value !== undefined) {
|
|
84
|
+
if (isSensitiveKey(env.name) && env.value) return <MaskedValue value={env.value} />;
|
|
85
|
+
return <span>{env.value || <span className="text-muted-foreground italic">empty</span>}</span>;
|
|
86
|
+
}
|
|
87
|
+
if (env.valueFrom?.secretKeyRef) {
|
|
88
|
+
return <SecretValueInline clusterId={clusterId} namespace={namespace} secretName={env.valueFrom.secretKeyRef.name} secretKey={env.valueFrom.secretKeyRef.key} />;
|
|
89
|
+
}
|
|
90
|
+
if (env.valueFrom?.configMapKeyRef) {
|
|
91
|
+
return <ConfigMapValueInline clusterId={clusterId} namespace={namespace} configMapName={env.valueFrom.configMapKeyRef.name} configMapKey={env.valueFrom.configMapKeyRef.key} />;
|
|
92
|
+
}
|
|
93
|
+
if (env.valueFrom?.fieldRef) return <span className="text-purple-600 dark:text-purple-400">field:{env.valueFrom.fieldRef.fieldPath}</span>;
|
|
94
|
+
if (env.valueFrom?.resourceFieldRef) return <span className="text-purple-600 dark:text-purple-400">resource:{env.valueFrom.resourceFieldRef.resource}</span>;
|
|
95
|
+
return <span className="text-muted-foreground">-</span>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Resolve envFrom references into individual env var rows
|
|
99
|
+
export function EnvFromRows({ envFrom, clusterId, namespace }: {
|
|
100
|
+
envFrom: any[]; clusterId: string; namespace: string;
|
|
101
|
+
}) {
|
|
102
|
+
return (
|
|
103
|
+
<>
|
|
104
|
+
{envFrom.map((ef: any, efi: number) => (
|
|
105
|
+
<EnvFromBlock key={efi} entry={ef} clusterId={clusterId} namespace={namespace} />
|
|
106
|
+
))}
|
|
107
|
+
</>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Resolve mounted Secret/ConfigMap volumes into env-like rows
|
|
112
|
+
export function MountedSecretRows({ volumes, volumeMounts, clusterId, namespace }: {
|
|
113
|
+
volumes: any[]; volumeMounts: any[]; clusterId: string; namespace: string;
|
|
114
|
+
}) {
|
|
115
|
+
// Find volume mounts that reference secrets or configmaps
|
|
116
|
+
const mountedSecrets: { volumeName: string; secretName: string; mountPath: string }[] = [];
|
|
117
|
+
const mountedConfigMaps: { volumeName: string; configMapName: string; mountPath: string }[] = [];
|
|
118
|
+
const seen = new Set<string>();
|
|
119
|
+
|
|
120
|
+
for (const vm of volumeMounts || []) {
|
|
121
|
+
const vol = (volumes || []).find((v: any) => v.name === vm.name);
|
|
122
|
+
if (!vol) continue;
|
|
123
|
+
if (vol.secret && !seen.has(`secret:${vol.secret.secretName}`)) {
|
|
124
|
+
seen.add(`secret:${vol.secret.secretName}`);
|
|
125
|
+
mountedSecrets.push({ volumeName: vm.name, secretName: vol.secret.secretName, mountPath: vm.mountPath });
|
|
126
|
+
} else if (vol.configMap && !seen.has(`cm:${vol.configMap.name}`)) {
|
|
127
|
+
seen.add(`cm:${vol.configMap.name}`);
|
|
128
|
+
mountedConfigMaps.push({ volumeName: vm.name, configMapName: vol.configMap.name, mountPath: vm.mountPath });
|
|
129
|
+
} else if (vol.projected) {
|
|
130
|
+
// Handle projected volumes (can contain multiple secrets/configmaps)
|
|
131
|
+
for (const source of vol.projected.sources || []) {
|
|
132
|
+
if (source.secret && !seen.has(`secret:${source.secret.name}`)) {
|
|
133
|
+
seen.add(`secret:${source.secret.name}`);
|
|
134
|
+
mountedSecrets.push({ volumeName: vm.name, secretName: source.secret.name, mountPath: vm.mountPath });
|
|
135
|
+
}
|
|
136
|
+
if (source.configMap && !seen.has(`cm:${source.configMap.name}`)) {
|
|
137
|
+
seen.add(`cm:${source.configMap.name}`);
|
|
138
|
+
mountedConfigMaps.push({ volumeName: vm.name, configMapName: source.configMap.name, mountPath: vm.mountPath });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (mountedSecrets.length === 0 && mountedConfigMaps.length === 0) return null;
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<>
|
|
148
|
+
{mountedSecrets.map((ms) => (
|
|
149
|
+
<MountedResourceBlock
|
|
150
|
+
key={ms.secretName}
|
|
151
|
+
clusterId={clusterId}
|
|
152
|
+
namespace={namespace}
|
|
153
|
+
resourceType="secrets"
|
|
154
|
+
resourceName={ms.secretName}
|
|
155
|
+
mountPath={ms.mountPath}
|
|
156
|
+
isSecret={true}
|
|
157
|
+
/>
|
|
158
|
+
))}
|
|
159
|
+
{mountedConfigMaps.map((mc) => (
|
|
160
|
+
<MountedResourceBlock
|
|
161
|
+
key={mc.configMapName}
|
|
162
|
+
clusterId={clusterId}
|
|
163
|
+
namespace={namespace}
|
|
164
|
+
resourceType="configmaps"
|
|
165
|
+
resourceName={mc.configMapName}
|
|
166
|
+
mountPath={mc.mountPath}
|
|
167
|
+
isSecret={false}
|
|
168
|
+
/>
|
|
169
|
+
))}
|
|
170
|
+
</>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function MountedResourceBlock({ clusterId, namespace, resourceType, resourceName, mountPath, isSecret }: {
|
|
175
|
+
clusterId: string; namespace: string; resourceType: string; resourceName: string; mountPath: string; isSecret: boolean;
|
|
176
|
+
}) {
|
|
177
|
+
const { data, error, isLoading } = useSWR(
|
|
178
|
+
`/api/clusters/${encodeURIComponent(clusterId)}/resources/${namespace}/${resourceType}/${resourceName}`,
|
|
179
|
+
{ revalidateOnFocus: false }
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
if (isLoading) {
|
|
183
|
+
return (
|
|
184
|
+
<tr className="border-t">
|
|
185
|
+
<td colSpan={2} className="px-3 py-1 text-muted-foreground">
|
|
186
|
+
<Loader2 className="h-3 w-3 animate-spin inline mr-1" />
|
|
187
|
+
Loading {resourceType}/{resourceName}...
|
|
188
|
+
</td>
|
|
189
|
+
</tr>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (error) {
|
|
194
|
+
return (
|
|
195
|
+
<tr className="border-t">
|
|
196
|
+
<td colSpan={2} className="px-3 py-1">
|
|
197
|
+
<span className={isSecret ? 'text-amber-600 dark:text-amber-400' : 'text-cyan-600 dark:text-cyan-400'}>
|
|
198
|
+
mounted: {resourceType}/{resourceName} (access denied)
|
|
199
|
+
</span>
|
|
200
|
+
</td>
|
|
201
|
+
</tr>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Support both data and stringData fields
|
|
206
|
+
const rawData = data?.data || {};
|
|
207
|
+
const stringData = data?.stringData || {};
|
|
208
|
+
const mergedData = { ...rawData, ...stringData };
|
|
209
|
+
const entries = Object.entries(mergedData).sort(([a], [b]) => a.localeCompare(b));
|
|
210
|
+
|
|
211
|
+
if (entries.length === 0) return null;
|
|
212
|
+
|
|
213
|
+
// Check if a single key contains dotenv-style content (KEY=VALUE lines)
|
|
214
|
+
const parsedEntries: [string, string][] = [];
|
|
215
|
+
for (const [key, rawValue] of entries) {
|
|
216
|
+
let value = rawValue as string;
|
|
217
|
+
if (isSecret && data?.data?.[key]) {
|
|
218
|
+
// Only decode base64 for items from data (not stringData)
|
|
219
|
+
try { value = atob(value); } catch { /* keep raw */ }
|
|
220
|
+
}
|
|
221
|
+
// Try to parse as dotenv (.env file format)
|
|
222
|
+
if (value.includes('=') && value.includes('\n')) {
|
|
223
|
+
const lines = value.split('\n');
|
|
224
|
+
for (const line of lines) {
|
|
225
|
+
const trimmed = line.trim();
|
|
226
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
227
|
+
const eqIdx = trimmed.indexOf('=');
|
|
228
|
+
if (eqIdx > 0) {
|
|
229
|
+
const envKey = trimmed.substring(0, eqIdx).trim();
|
|
230
|
+
let envVal = trimmed.substring(eqIdx + 1).trim();
|
|
231
|
+
// Remove surrounding quotes
|
|
232
|
+
if ((envVal.startsWith('"') && envVal.endsWith('"')) || (envVal.startsWith("'") && envVal.endsWith("'"))) {
|
|
233
|
+
envVal = envVal.slice(1, -1);
|
|
234
|
+
}
|
|
235
|
+
parsedEntries.push([envKey, envVal]);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
parsedEntries.push([key, value]);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
<>
|
|
245
|
+
<tr className="border-t bg-muted/30">
|
|
246
|
+
<td colSpan={2} className="px-3 py-1">
|
|
247
|
+
<Badge variant={isSecret ? 'default' : 'secondary'} className="text-[9px] py-0 h-4 font-normal">
|
|
248
|
+
Mounted {isSecret ? 'Secret' : 'ConfigMap'}: {resourceName}
|
|
249
|
+
</Badge>
|
|
250
|
+
<span className="text-[10px] text-muted-foreground ml-1.5">{mountPath}</span>
|
|
251
|
+
</td>
|
|
252
|
+
</tr>
|
|
253
|
+
{parsedEntries.map(([key, value], idx) => {
|
|
254
|
+
const sensitive = isSensitiveKey(key);
|
|
255
|
+
return (
|
|
256
|
+
<tr key={`${resourceName}:${idx}`} className="border-t hover:bg-muted/30">
|
|
257
|
+
<td className="px-3 py-1 font-mono font-medium text-blue-600 dark:text-blue-400 truncate">{key}</td>
|
|
258
|
+
<td className="px-3 py-1 font-mono overflow-hidden">
|
|
259
|
+
<div className="break-all">
|
|
260
|
+
{sensitive && value ? (
|
|
261
|
+
<MaskedValue value={value} />
|
|
262
|
+
) : (
|
|
263
|
+
value
|
|
264
|
+
)}
|
|
265
|
+
</div>
|
|
266
|
+
</td>
|
|
267
|
+
</tr>
|
|
268
|
+
);
|
|
269
|
+
})}
|
|
270
|
+
</>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Pod-level: extract ALL secret/configmap names from entire pod spec and show all keys
|
|
275
|
+
export function PodLinkedSecrets({ podSpec, clusterId, namespace }: {
|
|
276
|
+
podSpec: any; clusterId: string; namespace: string;
|
|
277
|
+
}) {
|
|
278
|
+
// Gather every unique secret & configmap referenced in the entire pod spec
|
|
279
|
+
const secretNames = new Set<string>();
|
|
280
|
+
const configMapNames = new Set<string>();
|
|
281
|
+
|
|
282
|
+
const allContainers = [
|
|
283
|
+
...(podSpec.containers || []),
|
|
284
|
+
...(podSpec.initContainers || []),
|
|
285
|
+
...(podSpec.ephemeralContainers || []),
|
|
286
|
+
];
|
|
287
|
+
|
|
288
|
+
for (const ctr of allContainers) {
|
|
289
|
+
// env[].valueFrom.secretKeyRef / configMapKeyRef
|
|
290
|
+
for (const env of ctr.env || []) {
|
|
291
|
+
if (env.valueFrom?.secretKeyRef?.name) secretNames.add(env.valueFrom.secretKeyRef.name);
|
|
292
|
+
if (env.valueFrom?.configMapKeyRef?.name) configMapNames.add(env.valueFrom.configMapKeyRef.name);
|
|
293
|
+
}
|
|
294
|
+
// envFrom[].secretRef / configMapRef
|
|
295
|
+
for (const ef of ctr.envFrom || []) {
|
|
296
|
+
if (ef.secretRef?.name) secretNames.add(ef.secretRef.name);
|
|
297
|
+
if (ef.configMapRef?.name) configMapNames.add(ef.configMapRef.name);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// volumes
|
|
302
|
+
for (const vol of podSpec.volumes || []) {
|
|
303
|
+
if (vol.secret?.secretName) secretNames.add(vol.secret.secretName);
|
|
304
|
+
if (vol.configMap?.name) configMapNames.add(vol.configMap.name);
|
|
305
|
+
if (vol.projected) {
|
|
306
|
+
for (const src of vol.projected.sources || []) {
|
|
307
|
+
if (src.secret?.name) secretNames.add(src.secret.name);
|
|
308
|
+
if (src.configMap?.name) configMapNames.add(src.configMap.name);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (vol.csi?.volumeAttributes?.secretProviderClass) {
|
|
312
|
+
// CSI secrets-store volumes: we can't directly resolve these
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// imagePullSecrets
|
|
317
|
+
for (const ips of podSpec.imagePullSecrets || []) {
|
|
318
|
+
if (ips.name) secretNames.add(ips.name);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const secrets = Array.from(secretNames).sort();
|
|
322
|
+
const configMaps = Array.from(configMapNames).sort();
|
|
323
|
+
|
|
324
|
+
if (secrets.length === 0 && configMaps.length === 0) return null;
|
|
325
|
+
|
|
326
|
+
return (
|
|
327
|
+
<div className="space-y-3">
|
|
328
|
+
<h3 className="text-sm font-semibold">Linked Secrets & ConfigMaps</h3>
|
|
329
|
+
<div className="rounded-md border overflow-hidden">
|
|
330
|
+
<table className="w-full text-xs" style={{ tableLayout: 'fixed' }}>
|
|
331
|
+
<colgroup>
|
|
332
|
+
<col style={{ width: '35%' }} />
|
|
333
|
+
<col style={{ width: '65%' }} />
|
|
334
|
+
</colgroup>
|
|
335
|
+
<thead className="bg-muted/50 sticky top-0 z-10">
|
|
336
|
+
<tr>
|
|
337
|
+
<th className="px-3 py-1.5 text-left font-medium">Key</th>
|
|
338
|
+
<th className="px-3 py-1.5 text-left font-medium">Value</th>
|
|
339
|
+
</tr>
|
|
340
|
+
</thead>
|
|
341
|
+
<tbody>
|
|
342
|
+
{secrets.map((name) => (
|
|
343
|
+
<LinkedResourceBlock
|
|
344
|
+
key={`s:${name}`}
|
|
345
|
+
clusterId={clusterId}
|
|
346
|
+
namespace={namespace}
|
|
347
|
+
resourceType="secrets"
|
|
348
|
+
resourceName={name}
|
|
349
|
+
isSecret={true}
|
|
350
|
+
/>
|
|
351
|
+
))}
|
|
352
|
+
{configMaps.map((name) => (
|
|
353
|
+
<LinkedResourceBlock
|
|
354
|
+
key={`cm:${name}`}
|
|
355
|
+
clusterId={clusterId}
|
|
356
|
+
namespace={namespace}
|
|
357
|
+
resourceType="configmaps"
|
|
358
|
+
resourceName={name}
|
|
359
|
+
isSecret={false}
|
|
360
|
+
/>
|
|
361
|
+
))}
|
|
362
|
+
</tbody>
|
|
363
|
+
</table>
|
|
364
|
+
</div>
|
|
365
|
+
</div>
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function LinkedResourceBlock({ clusterId, namespace, resourceType, resourceName, isSecret }: {
|
|
370
|
+
clusterId: string; namespace: string; resourceType: string; resourceName: string; isSecret: boolean;
|
|
371
|
+
}) {
|
|
372
|
+
const { data, error, isLoading } = useSWR(
|
|
373
|
+
`/api/clusters/${encodeURIComponent(clusterId)}/resources/${namespace}/${resourceType}/${resourceName}`,
|
|
374
|
+
{ revalidateOnFocus: false }
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
if (isLoading) {
|
|
378
|
+
return (
|
|
379
|
+
<tr className="border-t">
|
|
380
|
+
<td colSpan={2} className="px-3 py-1 text-muted-foreground">
|
|
381
|
+
<Loader2 className="h-3 w-3 animate-spin inline mr-1" />
|
|
382
|
+
{resourceName}...
|
|
383
|
+
</td>
|
|
384
|
+
</tr>
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (error) {
|
|
389
|
+
return (
|
|
390
|
+
<tr className="border-t">
|
|
391
|
+
<td colSpan={2} className="px-3 py-1">
|
|
392
|
+
<Badge variant={isSecret ? 'default' : 'secondary'} className="text-[9px] py-0 h-4 font-normal mr-1">
|
|
393
|
+
{isSecret ? 'Secret' : 'ConfigMap'}
|
|
394
|
+
</Badge>
|
|
395
|
+
<span className={isSecret ? 'text-amber-600 dark:text-amber-400' : 'text-cyan-600 dark:text-cyan-400'}>
|
|
396
|
+
{resourceName} <span className="text-muted-foreground">(access denied)</span>
|
|
397
|
+
</span>
|
|
398
|
+
</td>
|
|
399
|
+
</tr>
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const rawData = data?.data || {};
|
|
404
|
+
const stringData = data?.stringData || {};
|
|
405
|
+
const mergedData = { ...rawData, ...stringData };
|
|
406
|
+
const entries = Object.entries(mergedData).sort(([a], [b]) => a.localeCompare(b));
|
|
407
|
+
|
|
408
|
+
if (entries.length === 0) {
|
|
409
|
+
return (
|
|
410
|
+
<tr className="border-t">
|
|
411
|
+
<td colSpan={2} className="px-3 py-1 text-muted-foreground italic">
|
|
412
|
+
<Badge variant={isSecret ? 'default' : 'secondary'} className="text-[9px] py-0 h-4 font-normal mr-1">
|
|
413
|
+
{isSecret ? 'Secret' : 'ConfigMap'}
|
|
414
|
+
</Badge>
|
|
415
|
+
{resourceName} (empty)
|
|
416
|
+
</td>
|
|
417
|
+
</tr>
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Parse entries, decode base64 for secrets, expand dotenv format
|
|
422
|
+
const parsedEntries: [string, string][] = [];
|
|
423
|
+
for (const [key, rawValue] of entries) {
|
|
424
|
+
let value = rawValue as string;
|
|
425
|
+
if (isSecret && rawData[key]) {
|
|
426
|
+
try { value = atob(value); } catch { /* keep raw */ }
|
|
427
|
+
}
|
|
428
|
+
// Try to parse as dotenv (.env file format)
|
|
429
|
+
if (value.includes('=') && value.includes('\n')) {
|
|
430
|
+
const lines = value.split('\n');
|
|
431
|
+
for (const line of lines) {
|
|
432
|
+
const trimmed = line.trim();
|
|
433
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
434
|
+
const eqIdx = trimmed.indexOf('=');
|
|
435
|
+
if (eqIdx > 0) {
|
|
436
|
+
const envKey = trimmed.substring(0, eqIdx).trim();
|
|
437
|
+
let envVal = trimmed.substring(eqIdx + 1).trim();
|
|
438
|
+
if ((envVal.startsWith('"') && envVal.endsWith('"')) || (envVal.startsWith("'") && envVal.endsWith("'"))) {
|
|
439
|
+
envVal = envVal.slice(1, -1);
|
|
440
|
+
}
|
|
441
|
+
parsedEntries.push([envKey, envVal]);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
} else {
|
|
445
|
+
parsedEntries.push([key, value]);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return (
|
|
450
|
+
<>
|
|
451
|
+
<tr className="border-t bg-muted/30">
|
|
452
|
+
<td colSpan={2} className="px-3 py-1">
|
|
453
|
+
<Badge variant={isSecret ? 'default' : 'secondary'} className="text-[9px] py-0 h-4 font-normal">
|
|
454
|
+
{isSecret ? 'Secret' : 'ConfigMap'}: {resourceName}
|
|
455
|
+
</Badge>
|
|
456
|
+
<span className="text-[10px] text-muted-foreground ml-1.5">{parsedEntries.length} keys</span>
|
|
457
|
+
</td>
|
|
458
|
+
</tr>
|
|
459
|
+
{parsedEntries.map(([key, value], pi) => {
|
|
460
|
+
const sensitive = isSensitiveKey(key);
|
|
461
|
+
return (
|
|
462
|
+
<tr key={`linked:${resourceName}:${pi}`} className="border-t hover:bg-muted/30">
|
|
463
|
+
<td className="px-3 py-1 font-mono font-medium text-blue-600 dark:text-blue-400 truncate">{key}</td>
|
|
464
|
+
<td className="px-3 py-1 font-mono overflow-hidden">
|
|
465
|
+
<div className="break-all">
|
|
466
|
+
{sensitive && value ? (
|
|
467
|
+
<MaskedValue value={value} />
|
|
468
|
+
) : (
|
|
469
|
+
value
|
|
470
|
+
)}
|
|
471
|
+
</div>
|
|
472
|
+
</td>
|
|
473
|
+
</tr>
|
|
474
|
+
);
|
|
475
|
+
})}
|
|
476
|
+
</>
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function EnvFromBlock({ entry, clusterId, namespace }: {
|
|
481
|
+
entry: any; clusterId: string; namespace: string;
|
|
482
|
+
}) {
|
|
483
|
+
const isSecret = !!entry.secretRef;
|
|
484
|
+
const resourceName = entry.secretRef?.name || entry.configMapRef?.name;
|
|
485
|
+
const prefix = entry.prefix || '';
|
|
486
|
+
const resourceType = isSecret ? 'secrets' : 'configmaps';
|
|
487
|
+
|
|
488
|
+
const { data, error, isLoading } = useSWR(
|
|
489
|
+
resourceName ? `/api/clusters/${encodeURIComponent(clusterId)}/resources/${namespace}/${resourceType}/${resourceName}` : null,
|
|
490
|
+
{ revalidateOnFocus: false }
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
if (!resourceName) return null;
|
|
494
|
+
|
|
495
|
+
if (isLoading) {
|
|
496
|
+
return (
|
|
497
|
+
<tr className="border-t">
|
|
498
|
+
<td colSpan={2} className="px-3 py-1 text-muted-foreground">
|
|
499
|
+
<Loader2 className="h-3 w-3 animate-spin inline mr-1" />
|
|
500
|
+
Loading {resourceType}/{resourceName}...
|
|
501
|
+
</td>
|
|
502
|
+
</tr>
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (error) {
|
|
507
|
+
return (
|
|
508
|
+
<tr className="border-t">
|
|
509
|
+
<td colSpan={2} className="px-3 py-1">
|
|
510
|
+
<span className={isSecret ? 'text-amber-600 dark:text-amber-400' : 'text-cyan-600 dark:text-cyan-400'}>
|
|
511
|
+
envFrom: {resourceType}/{resourceName} (access denied)
|
|
512
|
+
</span>
|
|
513
|
+
</td>
|
|
514
|
+
</tr>
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const rawData = data?.data || {};
|
|
519
|
+
const entries = Object.entries(rawData).sort(([a], [b]) => a.localeCompare(b));
|
|
520
|
+
|
|
521
|
+
if (entries.length === 0) {
|
|
522
|
+
return (
|
|
523
|
+
<tr className="border-t">
|
|
524
|
+
<td colSpan={2} className="px-3 py-1 text-muted-foreground italic">
|
|
525
|
+
envFrom: {resourceType}/{resourceName} (empty)
|
|
526
|
+
</td>
|
|
527
|
+
</tr>
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return (
|
|
532
|
+
<>
|
|
533
|
+
{/* Section header */}
|
|
534
|
+
<tr className="border-t bg-muted/30">
|
|
535
|
+
<td colSpan={2} className="px-3 py-1">
|
|
536
|
+
<Badge variant={isSecret ? 'default' : 'secondary'} className="text-[9px] py-0 h-4 font-normal">
|
|
537
|
+
{isSecret ? 'Secret' : 'ConfigMap'}: {resourceName}
|
|
538
|
+
</Badge>
|
|
539
|
+
{prefix && <span className="text-[10px] text-muted-foreground ml-1.5">prefix: {prefix}</span>}
|
|
540
|
+
</td>
|
|
541
|
+
</tr>
|
|
542
|
+
{entries.map(([key, rawValue], idx) => {
|
|
543
|
+
const envName = `${prefix}${key}`;
|
|
544
|
+
let displayValue = rawValue as string;
|
|
545
|
+
|
|
546
|
+
// Decode base64 for secrets
|
|
547
|
+
if (isSecret) {
|
|
548
|
+
try { displayValue = atob(displayValue); } catch { /* keep raw */ }
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const sensitive = isSensitiveKey(envName) || isSensitiveKey(key);
|
|
552
|
+
|
|
553
|
+
return (
|
|
554
|
+
<tr key={`${resourceName}:${idx}`} className="border-t hover:bg-muted/30">
|
|
555
|
+
<td className="px-3 py-1 font-mono font-medium text-blue-600 dark:text-blue-400 truncate">{envName}</td>
|
|
556
|
+
<td className="px-3 py-1 font-mono overflow-hidden">
|
|
557
|
+
<div className="break-all">
|
|
558
|
+
{sensitive && displayValue ? (
|
|
559
|
+
<MaskedValue value={displayValue} />
|
|
560
|
+
) : (
|
|
561
|
+
displayValue
|
|
562
|
+
)}
|
|
563
|
+
</div>
|
|
564
|
+
</td>
|
|
565
|
+
</tr>
|
|
566
|
+
);
|
|
567
|
+
})}
|
|
568
|
+
</>
|
|
569
|
+
);
|
|
570
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { AlertTriangle, RefreshCw, LogIn, Loader2 } from 'lucide-react';
|
|
5
|
+
import { Button } from '@/components/ui/button';
|
|
6
|
+
import { useSettingsStore } from '@/stores/settings-store';
|
|
7
|
+
import { apiClient } from '@/lib/api-client';
|
|
8
|
+
import { toast } from 'sonner';
|
|
9
|
+
|
|
10
|
+
interface ErrorDisplayProps {
|
|
11
|
+
error: Error & { status?: number };
|
|
12
|
+
onRetry?: () => void;
|
|
13
|
+
clusterId?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function ErrorDisplay({ error, onRetry, clusterId }: ErrorDisplayProps) {
|
|
17
|
+
const { tshProxyUrl, tshAuthType } = useSettingsStore();
|
|
18
|
+
const [tshLoading, setTshLoading] = useState(false);
|
|
19
|
+
const [kubeLoading, setKubeLoading] = useState(false);
|
|
20
|
+
|
|
21
|
+
const isTeleportAuth = error.message?.includes('credentials') ||
|
|
22
|
+
error.message?.includes('certificate') ||
|
|
23
|
+
error.message?.includes('unauthorized') ||
|
|
24
|
+
error.status === 401;
|
|
25
|
+
|
|
26
|
+
const tshConfigured = !!tshProxyUrl;
|
|
27
|
+
|
|
28
|
+
const handleTshLogin = async () => {
|
|
29
|
+
setTshLoading(true);
|
|
30
|
+
try {
|
|
31
|
+
await apiClient.post('/api/tsh/login', {
|
|
32
|
+
action: 'proxy-login',
|
|
33
|
+
proxyUrl: tshProxyUrl,
|
|
34
|
+
authType: tshAuthType || undefined,
|
|
35
|
+
});
|
|
36
|
+
toast.success('TSH login successful');
|
|
37
|
+
onRetry?.();
|
|
38
|
+
} catch (err: unknown) {
|
|
39
|
+
toast.error(`TSH login failed: ${(err as Error).message}`);
|
|
40
|
+
} finally {
|
|
41
|
+
setTshLoading(false);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const handleKubeLogin = async () => {
|
|
46
|
+
if (!clusterId) return;
|
|
47
|
+
setKubeLoading(true);
|
|
48
|
+
try {
|
|
49
|
+
await apiClient.post('/api/tsh/login', {
|
|
50
|
+
action: 'kube-login',
|
|
51
|
+
cluster: decodeURIComponent(clusterId),
|
|
52
|
+
});
|
|
53
|
+
toast.success('Kube login successful');
|
|
54
|
+
onRetry?.();
|
|
55
|
+
} catch (err: unknown) {
|
|
56
|
+
toast.error(`Kube login failed: ${(err as Error).message}`);
|
|
57
|
+
} finally {
|
|
58
|
+
setKubeLoading(false);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className="flex flex-col items-center justify-center gap-4 p-12 text-center">
|
|
64
|
+
<AlertTriangle className="h-12 w-12 text-destructive" />
|
|
65
|
+
<div>
|
|
66
|
+
<h3 className="text-lg font-semibold">
|
|
67
|
+
{isTeleportAuth ? 'Authentication Required' : 'Error'}
|
|
68
|
+
</h3>
|
|
69
|
+
<p className="mt-1 text-sm text-muted-foreground">{error.message}</p>
|
|
70
|
+
{isTeleportAuth && (
|
|
71
|
+
<p className="mt-2 rounded-md bg-muted p-3 font-mono text-sm">
|
|
72
|
+
tsh kube login <cluster-name>
|
|
73
|
+
</p>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
{isTeleportAuth && (
|
|
77
|
+
<div className="flex gap-2">
|
|
78
|
+
<Button
|
|
79
|
+
variant="outline"
|
|
80
|
+
onClick={handleTshLogin}
|
|
81
|
+
disabled={!tshConfigured || tshLoading}
|
|
82
|
+
className="gap-2"
|
|
83
|
+
title={!tshConfigured ? 'Configure TSH proxy in Settings first' : undefined}
|
|
84
|
+
>
|
|
85
|
+
{tshLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <LogIn className="h-4 w-4" />}
|
|
86
|
+
TSH Login
|
|
87
|
+
</Button>
|
|
88
|
+
{clusterId && (
|
|
89
|
+
<Button
|
|
90
|
+
variant="outline"
|
|
91
|
+
onClick={handleKubeLogin}
|
|
92
|
+
disabled={kubeLoading}
|
|
93
|
+
className="gap-2"
|
|
94
|
+
>
|
|
95
|
+
{kubeLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <LogIn className="h-4 w-4" />}
|
|
96
|
+
Kube Login
|
|
97
|
+
</Button>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
{onRetry && (
|
|
102
|
+
<Button variant="outline" onClick={onRetry} className="gap-2">
|
|
103
|
+
<RefreshCw className="h-4 w-4" />
|
|
104
|
+
Retry
|
|
105
|
+
</Button>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|