@skyhook-io/radar-app 0.1.2 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -6
- package/src/App.tsx +28 -12
- package/src/RadarApp.tsx +1 -1
- package/src/api/config.ts +1 -1
- package/src/components/ConnectionErrorView.tsx +7 -3
- package/src/components/ContextSwitcher.tsx +11 -10
- package/src/components/helm/OwnedResources.tsx +2 -1
- package/src/components/home/CertificateHealthCard.tsx +22 -13
- package/src/components/home/ClusterHealthCard.tsx +30 -6
- package/src/components/home/HomeView.tsx +2 -5
- package/src/components/logs/LogsViewer.tsx +3 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +3 -0
- package/src/components/portforward/PortForwardManager.tsx +3 -2
- package/src/components/resources/ResourcesView.tsx +10 -2
- package/src/components/settings/SettingsDialog.tsx +64 -4
- package/src/components/timeline/TimelineSwimlanes.tsx +3 -3
- package/src/components/traffic/TrafficFlowList.tsx +2 -1
- package/src/components/traffic/TrafficView.tsx +48 -37
- package/src/index.css +8 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/radar-app",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar
|
|
3
|
+
"version": "0.1.5",
|
|
4
|
+
"description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/skyhook-io/radar",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"@playwright/test": "^1.59.1",
|
|
54
54
|
"@skyhook-io/k8s-ui": "*",
|
|
55
55
|
"@tailwindcss/typography": "^0.5.19",
|
|
56
|
-
"@tailwindcss/vite": "^4.2.
|
|
56
|
+
"@tailwindcss/vite": "^4.2.4",
|
|
57
57
|
"@tanstack/react-query": "^5.99.0",
|
|
58
58
|
"@types/diff": "^8.0.0",
|
|
59
59
|
"@types/node": "^25.5.0",
|
|
@@ -68,11 +68,11 @@
|
|
|
68
68
|
"prettier": "^3.8.1",
|
|
69
69
|
"react": "^19.2.5",
|
|
70
70
|
"react-dom": "^19.2.5",
|
|
71
|
-
"react-router-dom": "^7.
|
|
71
|
+
"react-router-dom": "^7.14.2",
|
|
72
72
|
"tailwind-merge": "^3.5.0",
|
|
73
|
-
"tailwindcss": "^4.2.
|
|
73
|
+
"tailwindcss": "^4.2.4",
|
|
74
74
|
"typescript": "^6.0.2",
|
|
75
|
-
"vite": "^8.0.
|
|
75
|
+
"vite": "^8.0.9"
|
|
76
76
|
},
|
|
77
77
|
"sideEffects": [
|
|
78
78
|
"*.css"
|
package/src/App.tsx
CHANGED
|
@@ -881,19 +881,35 @@ function AppInner() {
|
|
|
881
881
|
</button>
|
|
882
882
|
)}
|
|
883
883
|
|
|
884
|
-
{/* Theme toggle
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
884
|
+
{/* Theme toggle — hidden in embedded mode. Host apps (e.g. Radar
|
|
885
|
+
Cloud) own the user-theme preference and mount their own picker
|
|
886
|
+
in the account menu; a second toggle in Radar's topbar would
|
|
887
|
+
fight them (one writes to Radar's localStorage key, the other
|
|
888
|
+
to the host's cookie/backend) and the user would see the theme
|
|
889
|
+
bounce on every navigation between host routes and /c/:id. */}
|
|
890
|
+
{!navCustomization.embedded && (
|
|
891
|
+
<div className="hidden md:block">
|
|
892
|
+
<ThemeToggle />
|
|
893
|
+
</div>
|
|
894
|
+
)}
|
|
888
895
|
|
|
889
|
-
{/* Settings
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
896
|
+
{/* Settings — hidden in embedded mode. The standalone dialog
|
|
897
|
+
exposes local-binary controls (kubeconfig paths, server port,
|
|
898
|
+
"open browser on start", "Stop and restart the radar command
|
|
899
|
+
to apply") that don't apply to a hosted user who doesn't SSH
|
|
900
|
+
into the cluster. The audit view still opens the dialog via
|
|
901
|
+
its "N namespaces hidden" link for the narrow audit-ignores
|
|
902
|
+
setting — that's a deliberate escape hatch, not a general
|
|
903
|
+
surface. */}
|
|
904
|
+
{!navCustomization.embedded && (
|
|
905
|
+
<button
|
|
906
|
+
onClick={() => setShowSettings(true)}
|
|
907
|
+
className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
|
|
908
|
+
title="Settings"
|
|
909
|
+
>
|
|
910
|
+
<Settings className="w-4 h-4" />
|
|
911
|
+
</button>
|
|
912
|
+
)}
|
|
897
913
|
|
|
898
914
|
{/* User menu (when auth enabled) — hidden in embedded mode;
|
|
899
915
|
host app typically provides its own via rightExtras. */}
|
package/src/RadarApp.tsx
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// Config model:
|
|
9
9
|
// - apiBase — base URL for REST/SSE/WS. Default '/api' (same-origin,
|
|
10
10
|
// Radar's own binary). Hub passes a cluster-scoped URL
|
|
11
|
-
// like '/c/abc/api' or 'https://api.
|
|
11
|
+
// like '/c/abc/api' or 'https://api.radarhq.io/c/abc/api'.
|
|
12
12
|
// - basename — router basename. Default '' (mounted at root). Hub
|
|
13
13
|
// passes '/c/abc' when embedding, so Radar's internal
|
|
14
14
|
// paths (/topology, /resources/...) resolve correctly.
|
package/src/api/config.ts
CHANGED
|
@@ -28,7 +28,7 @@ export function getApiBase(): string {
|
|
|
28
28
|
*
|
|
29
29
|
* Accepts either:
|
|
30
30
|
* - A relative path: `/api`, `/c/abc/api` (URLs derive scheme + host from window.location)
|
|
31
|
-
* - An absolute URL: `https://api.
|
|
31
|
+
* - An absolute URL: `https://api.radarhq.io/c/abc/api` (URLs use that origin)
|
|
32
32
|
*/
|
|
33
33
|
export function setApiBase(url: string): void {
|
|
34
34
|
apiBase = url.replace(/\/+$/, '');
|
|
@@ -3,7 +3,7 @@ import { useState } from 'react'
|
|
|
3
3
|
import type { ConnectionState } from '../context/ConnectionContext'
|
|
4
4
|
import { ContextSwitcher } from './ContextSwitcher'
|
|
5
5
|
import { parseContextName } from '../utils/context-name'
|
|
6
|
-
import { useOpenLocalTerminal } from '@skyhook-io/k8s-ui'
|
|
6
|
+
import { useOpenLocalTerminal, ClusterName } from '@skyhook-io/k8s-ui'
|
|
7
7
|
|
|
8
8
|
interface ConnectionErrorViewProps {
|
|
9
9
|
connection: ConnectionState
|
|
@@ -193,8 +193,12 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
|
|
|
193
193
|
{connection.errorType === 'config' ? 'No Cluster Configuration' : 'Cannot Connect to Cluster'}
|
|
194
194
|
</h2>
|
|
195
195
|
|
|
196
|
-
<p className="text-sm text-theme-text-secondary mb-1">
|
|
197
|
-
Context:
|
|
196
|
+
<p className="text-sm text-theme-text-secondary mb-1 inline-flex items-center gap-1.5">
|
|
197
|
+
Context: {connection.context ? (
|
|
198
|
+
<ClusterName name={connection.context} />
|
|
199
|
+
) : (
|
|
200
|
+
<span className="font-mono text-theme-text-primary">(none)</span>
|
|
201
|
+
)}
|
|
198
202
|
</p>
|
|
199
203
|
|
|
200
204
|
{connection.clusterName && (
|
|
@@ -6,6 +6,7 @@ import { useToast } from '../components/ui/Toast'
|
|
|
6
6
|
import { useDock } from '../components/dock'
|
|
7
7
|
import type { ContextInfo } from '../types'
|
|
8
8
|
import { parseContextName, type ParsedContextName } from '../utils/context-name'
|
|
9
|
+
import { pluralize } from '@skyhook-io/k8s-ui'
|
|
9
10
|
|
|
10
11
|
interface ContextSwitcherProps {
|
|
11
12
|
className?: string
|
|
@@ -343,8 +344,8 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
|
|
|
343
344
|
<div className="border-t border-theme-border-light my-1" />
|
|
344
345
|
)}
|
|
345
346
|
{showHeader && (
|
|
346
|
-
<div className="px-3 py-1
|
|
347
|
-
<span className="text-[
|
|
347
|
+
<div className="px-3 py-1 bg-theme-elevated/60 border-b border-theme-border/60">
|
|
348
|
+
<span className="text-[11px] text-theme-text-secondary font-semibold">
|
|
348
349
|
{headerLabel}
|
|
349
350
|
</span>
|
|
350
351
|
</div>
|
|
@@ -382,19 +383,19 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
|
|
|
382
383
|
<span className={`text-sm font-medium truncate ${item.context.isCurrent ? 'text-blue-600 dark:text-blue-400' : 'text-theme-text-primary'}`}>
|
|
383
384
|
{item.clusterName}
|
|
384
385
|
</span>
|
|
385
|
-
{item.region && (
|
|
386
|
-
<span className="shrink-0 text-[10px] text-theme-text-tertiary bg-theme-elevated px-1 rounded">
|
|
387
|
-
{item.region}
|
|
388
|
-
</span>
|
|
389
|
-
)}
|
|
390
386
|
{item.context.isCurrent && (
|
|
391
387
|
<span className="shrink-0 text-[9px] text-blue-600 dark:text-blue-400">
|
|
392
388
|
●
|
|
393
389
|
</span>
|
|
394
390
|
)}
|
|
391
|
+
{item.region && (
|
|
392
|
+
<span className="shrink-0 ml-auto text-[10px] text-theme-text-tertiary bg-theme-elevated px-1 rounded">
|
|
393
|
+
{item.region}
|
|
394
|
+
</span>
|
|
395
|
+
)}
|
|
395
396
|
</div>
|
|
396
397
|
{item.provider && (
|
|
397
|
-
<div className="text-[10px] text-theme-text-tertiary truncate mt-0.5" title={item.raw}>
|
|
398
|
+
<div className="text-[10px] text-theme-text-tertiary opacity-70 truncate mt-0.5" title={item.raw}>
|
|
398
399
|
{item.raw}
|
|
399
400
|
</div>
|
|
400
401
|
)}
|
|
@@ -444,13 +445,13 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
|
|
|
444
445
|
{sessionCounts.portForwards > 0 && (
|
|
445
446
|
<li className="flex items-center gap-2">
|
|
446
447
|
<span className="w-1.5 h-1.5 rounded-full bg-blue-400" />
|
|
447
|
-
{sessionCounts.portForwards
|
|
448
|
+
{pluralize(sessionCounts.portForwards, 'port forward')}
|
|
448
449
|
</li>
|
|
449
450
|
)}
|
|
450
451
|
{sessionCounts.execSessions > 0 && (
|
|
451
452
|
<li className="flex items-center gap-2">
|
|
452
453
|
<span className="w-1.5 h-1.5 rounded-full bg-green-400" />
|
|
453
|
-
{sessionCounts.execSessions
|
|
454
|
+
{pluralize(sessionCounts.execSessions, 'terminal session')}
|
|
454
455
|
</li>
|
|
455
456
|
)}
|
|
456
457
|
</ul>
|
|
@@ -12,6 +12,7 @@ import { useStartPortForward } from '../portforward/PortForwardManager'
|
|
|
12
12
|
import { useAvailablePorts } from '../../api/client'
|
|
13
13
|
import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
|
|
14
14
|
import { useNamespacedCapabilities } from '../../contexts/CapabilitiesContext'
|
|
15
|
+
import { pluralize } from '@skyhook-io/k8s-ui'
|
|
15
16
|
|
|
16
17
|
interface OwnedResourcesProps {
|
|
17
18
|
resources: HelmOwnedResource[]
|
|
@@ -98,7 +99,7 @@ export function OwnedResources({ resources, onNavigate }: OwnedResourcesProps) {
|
|
|
98
99
|
<div className="flex items-center justify-between">
|
|
99
100
|
<div className="text-sm text-theme-text-secondary">
|
|
100
101
|
{healthFilter === 'all' ? (
|
|
101
|
-
<>{resources.length
|
|
102
|
+
<>{pluralize(resources.length, 'resource')} created by this release</>
|
|
102
103
|
) : (
|
|
103
104
|
<span className="flex items-center gap-2">
|
|
104
105
|
Showing {filteredResources.length} of {resources.length} resources
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { DashboardCertificateHealth } from '../../api/client'
|
|
2
2
|
import { Shield, ArrowRight } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
|
+
import { StatusDot, type StatusTone } from '@skyhook-io/k8s-ui'
|
|
4
5
|
|
|
5
6
|
interface CertificateHealthCardProps {
|
|
6
7
|
data: DashboardCertificateHealth
|
|
@@ -34,25 +35,25 @@ export function CertificateHealthCard({ data, onNavigate }: CertificateHealthCar
|
|
|
34
35
|
<div className="flex items-center gap-3 w-full">
|
|
35
36
|
{/* Color bar showing distribution */}
|
|
36
37
|
<div className="flex-1 h-3 rounded-full overflow-hidden bg-theme-hover flex">
|
|
37
|
-
{data.healthy > 0 && (
|
|
38
|
+
{data.total > 0 && data.healthy > 0 && (
|
|
38
39
|
<div
|
|
39
40
|
className="h-full bg-green-500"
|
|
40
41
|
style={{ width: `${(data.healthy / data.total) * 100}%` }}
|
|
41
42
|
/>
|
|
42
43
|
)}
|
|
43
|
-
{data.warning > 0 && (
|
|
44
|
+
{data.total > 0 && data.warning > 0 && (
|
|
44
45
|
<div
|
|
45
46
|
className="h-full bg-yellow-500"
|
|
46
47
|
style={{ width: `${(data.warning / data.total) * 100}%` }}
|
|
47
48
|
/>
|
|
48
49
|
)}
|
|
49
|
-
{data.critical > 0 && (
|
|
50
|
+
{data.total > 0 && data.critical > 0 && (
|
|
50
51
|
<div
|
|
51
52
|
className="h-full bg-orange-500"
|
|
52
53
|
style={{ width: `${(data.critical / data.total) * 100}%` }}
|
|
53
54
|
/>
|
|
54
55
|
)}
|
|
55
|
-
{data.expired > 0 && (
|
|
56
|
+
{data.total > 0 && data.expired > 0 && (
|
|
56
57
|
<div
|
|
57
58
|
className="h-full bg-red-500"
|
|
58
59
|
style={{ width: `${(data.expired / data.total) * 100}%` }}
|
|
@@ -63,10 +64,10 @@ export function CertificateHealthCard({ data, onNavigate }: CertificateHealthCar
|
|
|
63
64
|
|
|
64
65
|
{/* Breakdown */}
|
|
65
66
|
<div className="grid grid-cols-2 gap-x-6 gap-y-2 mt-4 w-full">
|
|
66
|
-
<BucketRow label="Healthy" count={data.healthy}
|
|
67
|
-
<BucketRow label="Warning" subtitle="< 30d" count={data.warning}
|
|
68
|
-
<BucketRow label="Critical" subtitle="< 7d" count={data.critical}
|
|
69
|
-
<BucketRow label="Expired" count={data.expired}
|
|
67
|
+
<BucketRow label="Healthy" count={data.healthy} tone="healthy" />
|
|
68
|
+
<BucketRow label="Warning" subtitle="< 30d" count={data.warning} tone="degraded" />
|
|
69
|
+
<BucketRow label="Critical" subtitle="< 7d" count={data.critical} tone="alert" />
|
|
70
|
+
<BucketRow label="Expired" count={data.expired} tone="unhealthy" />
|
|
70
71
|
</div>
|
|
71
72
|
</div>
|
|
72
73
|
|
|
@@ -85,21 +86,29 @@ export function CertificateHealthCard({ data, onNavigate }: CertificateHealthCar
|
|
|
85
86
|
)
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
|
|
89
|
+
const COUNT_TEXT_COLOR: Record<StatusTone, string> = {
|
|
90
|
+
healthy: 'text-emerald-400',
|
|
91
|
+
degraded: 'text-amber-400',
|
|
92
|
+
alert: 'text-orange-400',
|
|
93
|
+
unhealthy: 'text-rose-400',
|
|
94
|
+
neutral: 'text-sky-400',
|
|
95
|
+
unknown: 'text-slate-400',
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function BucketRow({ label, subtitle, count, tone }: {
|
|
89
99
|
label: string
|
|
90
100
|
subtitle?: string
|
|
91
101
|
count: number
|
|
92
|
-
|
|
93
|
-
dotColor: string
|
|
102
|
+
tone: StatusTone
|
|
94
103
|
}) {
|
|
95
104
|
return (
|
|
96
105
|
<div className="flex items-center gap-2">
|
|
97
|
-
<
|
|
106
|
+
<StatusDot tone={tone} size="md" className="shrink-0" />
|
|
98
107
|
<span className="text-xs text-theme-text-secondary flex-1">
|
|
99
108
|
{label}
|
|
100
109
|
{subtitle && <span className="text-theme-text-tertiary ml-1">{subtitle}</span>}
|
|
101
110
|
</span>
|
|
102
|
-
<span className={clsx('text-sm font-semibold tabular-nums', count > 0 ?
|
|
111
|
+
<span className={clsx('text-sm font-semibold tabular-nums', count > 0 ? COUNT_TEXT_COLOR[tone] : 'text-theme-text-tertiary')}>{count}</span>
|
|
103
112
|
</div>
|
|
104
113
|
)
|
|
105
114
|
}
|
|
@@ -10,6 +10,7 @@ import { clsx } from 'clsx'
|
|
|
10
10
|
import { formatCPUMillicores, formatMemoryMiB } from '../../utils/format'
|
|
11
11
|
import { useCapabilitiesContext } from '../../contexts/CapabilitiesContext'
|
|
12
12
|
import { MCPSetupDialog } from './MCPSetupDialog'
|
|
13
|
+
import { pluralize, parseContextName } from '@skyhook-io/k8s-ui'
|
|
13
14
|
import { Tooltip } from '../ui/Tooltip'
|
|
14
15
|
|
|
15
16
|
interface ClusterHealthCardProps {
|
|
@@ -157,6 +158,13 @@ export function ClusterHealthCard({
|
|
|
157
158
|
{ kind: 'cronjobs', label: 'CronJobs', icon: Clock, total: counts.cronJobs.total, subtitle: `${counts.cronJobs.active} active` },
|
|
158
159
|
]
|
|
159
160
|
const platformInfo = getPlatformInfo(cluster.platform)
|
|
161
|
+
// The raw cluster.name is the kubeconfig context (e.g.
|
|
162
|
+
// `gke_koalabackend_us-east1-b_nonprod-cluster-us-east1`). That string
|
|
163
|
+
// is the user's primary orientation cue, but the bit they actually
|
|
164
|
+
// recognize is the short clusterName. We promote that, push the raw
|
|
165
|
+
// path into a tooltip, and surface project/region as muted metadata.
|
|
166
|
+
const parsedContext = parseContextName(cluster.name || '')
|
|
167
|
+
const headlineName = parsedContext.clusterName || cluster.name || 'Cluster'
|
|
160
168
|
|
|
161
169
|
return (
|
|
162
170
|
<div className="rounded-xl bg-theme-surface shadow-theme-sm overflow-hidden">
|
|
@@ -165,22 +173,38 @@ export function ClusterHealthCard({
|
|
|
165
173
|
<div className="flex items-stretch gap-8">
|
|
166
174
|
{/* Left: Cluster info */}
|
|
167
175
|
<div className="flex flex-col justify-center w-[300px] shrink-0 pr-8 border-r border-theme-border/50">
|
|
168
|
-
<div className="flex items-center gap-2 mb-
|
|
176
|
+
<div className="flex items-center gap-2 mb-1.5">
|
|
169
177
|
{platformInfo.icon ? (
|
|
170
178
|
<img src={platformInfo.icon} alt={platformInfo.name} className="w-5 h-5 object-contain" />
|
|
171
179
|
) : (
|
|
172
180
|
<Server className="w-4 h-4 text-theme-text-tertiary" />
|
|
173
181
|
)}
|
|
174
|
-
<span className="text-xs text-theme-text-secondary">{platformInfo.name}</span>
|
|
182
|
+
<span className="text-xs text-theme-text-secondary truncate">{platformInfo.name}</span>
|
|
175
183
|
</div>
|
|
176
|
-
<h2
|
|
177
|
-
|
|
184
|
+
<h2
|
|
185
|
+
className="text-xl font-semibold text-theme-text-primary truncate mb-1.5 leading-tight"
|
|
186
|
+
title={cluster.name}
|
|
187
|
+
>
|
|
188
|
+
{headlineName}
|
|
178
189
|
</h2>
|
|
179
|
-
<div className="flex flex-col gap-
|
|
190
|
+
<div className="flex flex-col gap-0.5 text-xs text-theme-text-tertiary">
|
|
191
|
+
{(parsedContext.account || parsedContext.region) && (
|
|
192
|
+
<span className="truncate font-mono" title={[parsedContext.account, parsedContext.region].filter(Boolean).join(' · ')}>
|
|
193
|
+
{[parsedContext.account, parsedContext.region].filter(Boolean).join(' · ')}
|
|
194
|
+
</span>
|
|
195
|
+
)}
|
|
180
196
|
{cluster.version && (
|
|
181
197
|
<span>Kubernetes {cluster.version}</span>
|
|
182
198
|
)}
|
|
183
199
|
<span><span className="font-mono">{counts.namespaces}</span> namespaces</span>
|
|
200
|
+
{cluster.name && cluster.name !== headlineName && (
|
|
201
|
+
<span
|
|
202
|
+
className="font-mono text-[10px] text-theme-text-disabled break-all leading-snug pt-0.5"
|
|
203
|
+
title={cluster.name}
|
|
204
|
+
>
|
|
205
|
+
{cluster.name}
|
|
206
|
+
</span>
|
|
207
|
+
)}
|
|
184
208
|
</div>
|
|
185
209
|
{nodeVersionSkew && (
|
|
186
210
|
<Tooltip
|
|
@@ -190,7 +214,7 @@ export function ClusterHealthCard({
|
|
|
190
214
|
{Object.entries(nodeVersionSkew.versions).map(([version, nodes]) => (
|
|
191
215
|
<div key={version}>
|
|
192
216
|
<span className="font-mono font-medium">v{version}</span>
|
|
193
|
-
<span className="text-theme-text-tertiary"> — {nodes.length
|
|
217
|
+
<span className="text-theme-text-tertiary"> — {pluralize(nodes.length, 'node')}</span>
|
|
194
218
|
<div className="text-[10px] text-theme-text-tertiary pl-2">{nodes.join(', ')}</div>
|
|
195
219
|
</div>
|
|
196
220
|
))}
|
|
@@ -9,7 +9,7 @@ import { TrafficSummary } from './TrafficSummary'
|
|
|
9
9
|
import { CertificateHealthCard } from './CertificateHealthCard'
|
|
10
10
|
import { NetworkPolicyCoverageCard } from './NetworkPolicyCoverageCard'
|
|
11
11
|
import { CostCard } from './CostCard'
|
|
12
|
-
import { AuditCard } from '@skyhook-io/k8s-ui'
|
|
12
|
+
import { AuditCard, StatusDot, mapHealthToTone } from '@skyhook-io/k8s-ui'
|
|
13
13
|
import { ClusterHealthCard } from './ClusterHealthCard'
|
|
14
14
|
import { AlertTriangle, Loader2, Shield } from 'lucide-react'
|
|
15
15
|
import { clsx } from 'clsx'
|
|
@@ -197,10 +197,7 @@ function ProblemsPanel({ problems, onResourceClick }: ProblemsPanelProps) {
|
|
|
197
197
|
group: p.group,
|
|
198
198
|
})}
|
|
199
199
|
>
|
|
200
|
-
<
|
|
201
|
-
'w-1.5 h-1.5 rounded-full shrink-0',
|
|
202
|
-
p.severity === 'critical' ? 'bg-red-400' : p.severity === 'high' ? 'bg-orange-400' : 'bg-yellow-400'
|
|
203
|
-
)} />
|
|
200
|
+
<StatusDot tone={mapHealthToTone(p.severity)} className="shrink-0" />
|
|
204
201
|
<div className="min-w-0 flex-1">
|
|
205
202
|
<div className="flex items-center gap-1.5">
|
|
206
203
|
<span className="text-[10px] text-theme-text-tertiary bg-theme-elevated px-1 py-0.5 rounded">{p.kind}</span>
|
|
@@ -3,6 +3,7 @@ import { fetchJSON, createLogStream } from '../../api/client'
|
|
|
3
3
|
import { LogsViewer as SharedLogsViewer } from '@skyhook-io/k8s-ui'
|
|
4
4
|
import type { LogsFetchParams } from '@skyhook-io/k8s-ui'
|
|
5
5
|
import { useDesktopDownload } from '../../hooks/useDesktopDownload'
|
|
6
|
+
import { useTheme } from '../../context/ThemeContext'
|
|
6
7
|
|
|
7
8
|
interface LogsViewerProps {
|
|
8
9
|
namespace: string
|
|
@@ -13,6 +14,7 @@ interface LogsViewerProps {
|
|
|
13
14
|
|
|
14
15
|
export function LogsViewer({ namespace, podName, containers, initialContainer }: LogsViewerProps) {
|
|
15
16
|
const desktopDownload = useDesktopDownload()
|
|
17
|
+
const { theme } = useTheme()
|
|
16
18
|
|
|
17
19
|
const fetchLogs = useCallback(async (params: LogsFetchParams) => {
|
|
18
20
|
const query = new URLSearchParams()
|
|
@@ -39,6 +41,7 @@ export function LogsViewer({ namespace, podName, containers, initialContainer }:
|
|
|
39
41
|
fetchLogs={fetchLogs}
|
|
40
42
|
createStream={makeStream}
|
|
41
43
|
overrideDownload={desktopDownload}
|
|
44
|
+
forceDark={theme === 'dark' ? true : undefined}
|
|
42
45
|
/>
|
|
43
46
|
)
|
|
44
47
|
}
|
|
@@ -3,6 +3,7 @@ import { fetchJSON, createWorkloadLogStream } from '../../api/client'
|
|
|
3
3
|
import { WorkloadLogsViewer as SharedWorkloadLogsViewer } from '@skyhook-io/k8s-ui'
|
|
4
4
|
import type { WorkloadLogsFetchParams, WorkloadLogsResult } from '@skyhook-io/k8s-ui'
|
|
5
5
|
import { useDesktopDownload } from '../../hooks/useDesktopDownload'
|
|
6
|
+
import { useTheme } from '../../context/ThemeContext'
|
|
6
7
|
|
|
7
8
|
interface WorkloadLogsViewerProps {
|
|
8
9
|
kind: string
|
|
@@ -12,6 +13,7 @@ interface WorkloadLogsViewerProps {
|
|
|
12
13
|
|
|
13
14
|
export function WorkloadLogsViewer({ kind, namespace, name }: WorkloadLogsViewerProps) {
|
|
14
15
|
const desktopDownload = useDesktopDownload()
|
|
16
|
+
const { theme } = useTheme()
|
|
15
17
|
|
|
16
18
|
const fetchAll = useCallback(async (params: WorkloadLogsFetchParams): Promise<WorkloadLogsResult> => {
|
|
17
19
|
const query = new URLSearchParams()
|
|
@@ -35,6 +37,7 @@ export function WorkloadLogsViewer({ kind, namespace, name }: WorkloadLogsViewer
|
|
|
35
37
|
fetchAll={fetchAll}
|
|
36
38
|
createStream={makeStream}
|
|
37
39
|
overrideDownload={desktopDownload}
|
|
40
|
+
forceDark={theme === 'dark' ? true : undefined}
|
|
38
41
|
/>
|
|
39
42
|
)
|
|
40
43
|
}
|
|
@@ -30,6 +30,7 @@ import { Tooltip } from '../ui/Tooltip'
|
|
|
30
30
|
import { useToast } from '../ui/Toast'
|
|
31
31
|
import { openExternal } from '../../utils/navigation'
|
|
32
32
|
import { apiUrl } from '../../api/config'
|
|
33
|
+
import { pluralize } from '@skyhook-io/k8s-ui'
|
|
33
34
|
|
|
34
35
|
// --- Types -------------------------------------------------------------------
|
|
35
36
|
|
|
@@ -279,8 +280,8 @@ export function PortForwardIndicator() {
|
|
|
279
280
|
|
|
280
281
|
const hasErrors = errorSessions.length > 0
|
|
281
282
|
const tooltipText = hasErrors
|
|
282
|
-
? `${count
|
|
283
|
-
: `${count
|
|
283
|
+
? `${pluralize(count, 'port forward')} — ${errorSessions.length} failed`
|
|
284
|
+
: `${pluralize(count, 'active port forward')}`
|
|
284
285
|
|
|
285
286
|
return (
|
|
286
287
|
<Tooltip content={tooltipText} delay={150} position="bottom" disabled={isPanelOpen}>
|
|
@@ -2,7 +2,7 @@ import { useState, useMemo, useCallback, useEffect } from 'react'
|
|
|
2
2
|
import { useLocation, useNavigate } from 'react-router-dom'
|
|
3
3
|
import { useQuery } from '@tanstack/react-query'
|
|
4
4
|
import { ApiError, fetchJSON, isForbiddenError, useSecretCertExpiry, useTopPodMetrics, useTopNodeMetrics } from '../../api/client'
|
|
5
|
-
import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
|
|
5
|
+
import { apiUrl, getAuthHeaders, getCredentialsMode, getBasename } from '../../api/config'
|
|
6
6
|
import { useAPIResources } from '../../api/apiResources'
|
|
7
7
|
import { initNavigationMap } from '@skyhook-io/k8s-ui'
|
|
8
8
|
import { usePinnedKinds } from '../../hooks/useFavorites'
|
|
@@ -166,7 +166,15 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
|
|
|
166
166
|
pinned={pinned}
|
|
167
167
|
togglePin={togglePin}
|
|
168
168
|
isPinned={(kind: string, group?: string) => isPinned(kind, group ?? '')}
|
|
169
|
-
// Navigation
|
|
169
|
+
// Navigation. basePath is the full URL prefix where the Resources view
|
|
170
|
+
// lives — '/resources' for standalone Radar, '/c/{cluster}/resources'
|
|
171
|
+
// when embedded in a host app that mounts RadarApp under a basename.
|
|
172
|
+
// k8s-ui's ResourcesView uses this to read the current kind out of
|
|
173
|
+
// window.location.pathname and to write URL updates (drawer open/close,
|
|
174
|
+
// filter changes) back via history.replaceState. Without the basename
|
|
175
|
+
// prefix, those writes would drop the host-app route context and the
|
|
176
|
+
// URL would no longer be reloadable.
|
|
177
|
+
basePath={getBasename() + '/resources'}
|
|
170
178
|
locationSearch={location.search}
|
|
171
179
|
locationPathname={location.pathname}
|
|
172
180
|
onNavigate={handleNavigate}
|
|
@@ -242,13 +242,13 @@ function StartupConfigTab({
|
|
|
242
242
|
onChange={(v) => onChange('kubeconfig', v || undefined)}
|
|
243
243
|
/>
|
|
244
244
|
|
|
245
|
-
<
|
|
245
|
+
<ConfigArrayField
|
|
246
246
|
label="Kubeconfig Directories"
|
|
247
247
|
help="Comma-separated directories containing kubeconfig files"
|
|
248
|
-
value={config.kubeconfigDirs
|
|
249
|
-
effectiveValue={effectiveConfig?.kubeconfigDirs
|
|
248
|
+
value={config.kubeconfigDirs}
|
|
249
|
+
effectiveValue={effectiveConfig?.kubeconfigDirs}
|
|
250
250
|
placeholder="/path/to/dir1, /path/to/dir2"
|
|
251
|
-
onChange={(v) => onChange('kubeconfigDirs', v
|
|
251
|
+
onChange={(v) => onChange('kubeconfigDirs', v)}
|
|
252
252
|
/>
|
|
253
253
|
|
|
254
254
|
<ConfigField
|
|
@@ -452,6 +452,66 @@ function ConfigField({
|
|
|
452
452
|
)
|
|
453
453
|
}
|
|
454
454
|
|
|
455
|
+
// Comma-separated list input. Keeps a local string buffer so intermediate states
|
|
456
|
+
// like "foo," or "foo,," survive — parsing into an array on every keystroke
|
|
457
|
+
// (split/trim/filter) would otherwise strip trailing commas before they re-render.
|
|
458
|
+
// The focus flag is load-bearing: without it, every parent re-render during typing
|
|
459
|
+
// would overwrite `text` with the canonical joined form and wipe the keystroke.
|
|
460
|
+
// On blur the buffer resyncs to the canonical "a, b" form.
|
|
461
|
+
function ConfigArrayField({
|
|
462
|
+
label,
|
|
463
|
+
help,
|
|
464
|
+
value,
|
|
465
|
+
effectiveValue,
|
|
466
|
+
placeholder,
|
|
467
|
+
onChange,
|
|
468
|
+
}: {
|
|
469
|
+
label: string
|
|
470
|
+
help?: string
|
|
471
|
+
value?: string[]
|
|
472
|
+
effectiveValue?: string[]
|
|
473
|
+
placeholder?: string
|
|
474
|
+
onChange: (value: string[] | undefined) => void
|
|
475
|
+
}) {
|
|
476
|
+
const canonical = (v?: string[]) => v?.join(', ') ?? ''
|
|
477
|
+
const [text, setText] = useState(() => canonical(value))
|
|
478
|
+
const focusedRef = useRef(false)
|
|
479
|
+
|
|
480
|
+
useEffect(() => {
|
|
481
|
+
if (!focusedRef.current) setText(canonical(value))
|
|
482
|
+
}, [value])
|
|
483
|
+
|
|
484
|
+
const commit = (raw: string) => {
|
|
485
|
+
const parts = raw.split(',').map(s => s.trim()).filter(Boolean)
|
|
486
|
+
onChange(parts.length > 0 ? parts : undefined)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return (
|
|
490
|
+
<div>
|
|
491
|
+
<label className="block text-sm font-medium text-theme-text-primary mb-1">
|
|
492
|
+
{label}
|
|
493
|
+
</label>
|
|
494
|
+
{help && <p className="text-xs text-theme-text-tertiary mb-1">{help}</p>}
|
|
495
|
+
<input
|
|
496
|
+
type="text"
|
|
497
|
+
value={text}
|
|
498
|
+
onFocus={() => { focusedRef.current = true }}
|
|
499
|
+
onBlur={() => {
|
|
500
|
+
focusedRef.current = false
|
|
501
|
+
setText(canonical(value))
|
|
502
|
+
}}
|
|
503
|
+
onChange={(e) => {
|
|
504
|
+
setText(e.target.value)
|
|
505
|
+
commit(e.target.value)
|
|
506
|
+
}}
|
|
507
|
+
placeholder={placeholder}
|
|
508
|
+
className="w-full px-3 py-1.5 text-sm bg-theme-elevated border border-theme-border rounded-md text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:border-blue-500"
|
|
509
|
+
/>
|
|
510
|
+
<EffectiveHint current={canonical(value) || undefined} effective={canonical(effectiveValue) || undefined} />
|
|
511
|
+
</div>
|
|
512
|
+
)
|
|
513
|
+
}
|
|
514
|
+
|
|
455
515
|
function ConfigNumberField({
|
|
456
516
|
label,
|
|
457
517
|
help,
|
|
@@ -27,6 +27,7 @@ import { useHasLimitedAccess } from '../../contexts/CapabilitiesContext'
|
|
|
27
27
|
import type { TimelineEvent, Topology } from '../../types'
|
|
28
28
|
import type { NavigateToResource } from '../../utils/navigation'
|
|
29
29
|
import { kindToPlural } from '../../utils/navigation'
|
|
30
|
+
import { pluralize } from '@skyhook-io/k8s-ui'
|
|
30
31
|
import { isChangeEvent, isHistoricalEvent, isOperation, displayKind } from '../../types'
|
|
31
32
|
import { DiffViewer } from './DiffViewer'
|
|
32
33
|
import { getOperationColor, getHealthBadgeColor, getEventTypeColor } from '../../utils/badge-colors'
|
|
@@ -513,8 +514,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
513
514
|
</div>
|
|
514
515
|
<div className="flex items-center gap-4">
|
|
515
516
|
<span className="text-xs text-theme-text-tertiary">
|
|
516
|
-
{visibleLanes.length
|
|
517
|
-
{filteredEvents.length !== 1 ? 's' : ''}
|
|
517
|
+
{pluralize(visibleLanes.length, 'resource')} · {pluralize(filteredEvents.length, 'event')}
|
|
518
518
|
{searchTerm && ` (filtered)`}
|
|
519
519
|
</span>
|
|
520
520
|
{/* Group by app toggle */}
|
|
@@ -707,7 +707,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
707
707
|
const issueCount = allEvents.filter(e => isCriticalIssue(e)).length
|
|
708
708
|
if (issueCount === 0) return null
|
|
709
709
|
return (
|
|
710
|
-
<Tooltip content={`${issueCount
|
|
710
|
+
<Tooltip content={`${pluralize(issueCount, 'critical issue')} (OOMKilled, CrashLoopBackOff, etc.)`} position="top">
|
|
711
711
|
<span className="flex items-center gap-0.5 text-xs px-1 py-0.5 rounded bg-red-500/15 text-red-600 dark:text-red-300">
|
|
712
712
|
<AlertTriangle className="w-3 h-3" />
|
|
713
713
|
{issueCount}
|
|
@@ -3,6 +3,7 @@ import type { TrafficFlow } from '../../types'
|
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
4
|
import { ChevronDown, ChevronUp, ShieldCheck } from 'lucide-react'
|
|
5
5
|
import { SEVERITY_BADGE, SEVERITY_TEXT } from '@skyhook-io/k8s-ui/utils/badge-colors'
|
|
6
|
+
import { pluralize } from '@skyhook-io/k8s-ui'
|
|
6
7
|
import { useFlowSearch } from './TrafficFlowListContext'
|
|
7
8
|
import { useQuery } from '@tanstack/react-query'
|
|
8
9
|
import { fetchJSON } from '../../api/client'
|
|
@@ -321,7 +322,7 @@ export function TrafficFlowList({ flows }: TrafficFlowListProps) {
|
|
|
321
322
|
|
|
322
323
|
{/* Footer */}
|
|
323
324
|
<div className="px-3 py-1.5 border-t border-theme-border text-[10px] text-theme-text-tertiary">
|
|
324
|
-
{sorted.length
|
|
325
|
+
{pluralize(sorted.length, 'flow')}
|
|
325
326
|
{search && ` (filtered from ${flows.length})`}
|
|
326
327
|
</div>
|
|
327
328
|
</div>
|
|
@@ -7,10 +7,11 @@ import { TrafficWizard } from './TrafficWizard'
|
|
|
7
7
|
import { TrafficGraph, type TrafficGraphSelection } from './TrafficGraph'
|
|
8
8
|
import { TrafficFilterSidebar } from './TrafficFilterSidebar'
|
|
9
9
|
import { TrafficFlowListProvider } from './TrafficFlowListContext'
|
|
10
|
-
import { Loader2, RefreshCw, Filter, Plug, ChevronDown, List } from 'lucide-react'
|
|
10
|
+
import { Loader2, RefreshCw, Filter, Plug, ChevronDown, List, Activity, AlertTriangle } from 'lucide-react'
|
|
11
11
|
import { clsx } from 'clsx'
|
|
12
12
|
import { useQueryClient } from '@tanstack/react-query'
|
|
13
13
|
import { useDock } from '../dock'
|
|
14
|
+
import { EmptyState } from '@skyhook-io/k8s-ui'
|
|
14
15
|
|
|
15
16
|
// Addon types for filtering
|
|
16
17
|
export type AddonMode = 'show' | 'group' | 'hide'
|
|
@@ -1168,42 +1169,52 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
1168
1169
|
</div>
|
|
1169
1170
|
</div>
|
|
1170
1171
|
) : (
|
|
1171
|
-
<div className="absolute inset-0 flex items-center justify-center">
|
|
1172
|
-
|
|
1173
|
-
<
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1172
|
+
<div className="absolute inset-0 flex items-center justify-center px-4">
|
|
1173
|
+
{flowStats.total > 0 && flowStats.shown === 0 ? (
|
|
1174
|
+
<EmptyState
|
|
1175
|
+
tone="filtered"
|
|
1176
|
+
variant="card"
|
|
1177
|
+
icon={Filter}
|
|
1178
|
+
headline="All traffic is filtered out"
|
|
1179
|
+
body={`${flowStats.total} ${flowStats.total === 1 ? 'flow' : 'flows'} hidden by current filters.`}
|
|
1180
|
+
action={
|
|
1181
|
+
<button
|
|
1182
|
+
type="button"
|
|
1183
|
+
onClick={() => {
|
|
1184
|
+
setHideSystem(false)
|
|
1185
|
+
setHideExternal(false)
|
|
1186
|
+
setMinConnections(0)
|
|
1187
|
+
}}
|
|
1188
|
+
className="badge badge-sm border border-theme-border bg-theme-elevated text-theme-text-primary hover:bg-theme-hover transition-colors"
|
|
1189
|
+
>
|
|
1190
|
+
Show all
|
|
1191
|
+
</button>
|
|
1192
|
+
}
|
|
1193
|
+
className="max-w-md"
|
|
1194
|
+
/>
|
|
1195
|
+
) : flowsData?.warning ? (
|
|
1196
|
+
<EmptyState
|
|
1197
|
+
tone="neutral"
|
|
1198
|
+
variant="card"
|
|
1199
|
+
icon={AlertTriangle}
|
|
1200
|
+
headline="Unable to fetch traffic data"
|
|
1201
|
+
body={flowsData.warning}
|
|
1202
|
+
className="max-w-md"
|
|
1203
|
+
/>
|
|
1204
|
+
) : (
|
|
1205
|
+
<EmptyState
|
|
1206
|
+
tone="neutral"
|
|
1207
|
+
variant="card"
|
|
1208
|
+
icon={Activity}
|
|
1209
|
+
headline={
|
|
1210
|
+
sourcesData?.active
|
|
1211
|
+
? `Observing via ${sourcesData.active} — no traffic yet`
|
|
1212
|
+
: 'No traffic observed yet'
|
|
1213
|
+
}
|
|
1214
|
+
body="Traffic will appear here once services start communicating."
|
|
1215
|
+
className="max-w-md"
|
|
1216
|
+
/>
|
|
1217
|
+
)}
|
|
1207
1218
|
</div>
|
|
1208
1219
|
)}
|
|
1209
1220
|
</div>
|
package/src/index.css
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
@import "tailwindcss";
|
|
2
|
+
/*
|
|
3
|
+
* Tailwind v4 JIT scans only sources under this CSS file's package (web/).
|
|
4
|
+
* k8s-ui is a workspace package whose TSX files also use Tailwind classes;
|
|
5
|
+
* without this @source, any class used *only* in k8s-ui/ (not also in web/)
|
|
6
|
+
* is silently dropped — e.g. `top-8` in TerminalTab, which collapsed the
|
|
7
|
+
* terminal to zero height and caused issue #518 (blank terminal).
|
|
8
|
+
*/
|
|
9
|
+
@source "../../packages/k8s-ui/src/**/*.{ts,tsx}";
|
|
2
10
|
@variant dark (&:where(.dark, .dark *));
|
|
3
11
|
@import "@skyhook-io/k8s-ui/theme/variables.css";
|
|
4
12
|
@import "@skyhook-io/k8s-ui/theme/tailwind-theme.css";
|
package/src/index.ts
CHANGED