@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "0.1.2",
4
- "description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Hub.",
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.1",
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.13.1",
71
+ "react-router-dom": "^7.14.2",
72
72
  "tailwind-merge": "^3.5.0",
73
- "tailwindcss": "^4.2.2",
73
+ "tailwindcss": "^4.2.4",
74
74
  "typescript": "^6.0.2",
75
- "vite": "^8.0.5"
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
- <div className="hidden md:block">
886
- <ThemeToggle />
887
- </div>
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
- <button
891
- onClick={() => setShowSettings(true)}
892
- className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
893
- title="Settings"
894
- >
895
- <Settings className="w-4 h-4" />
896
- </button>
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.radar.skyhook.io/c/abc/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.radar.skyhook.io/c/abc/api` (URLs use that origin)
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: <span className="font-mono text-theme-text-primary">{connection.context || '(none)'}</span>
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.5 bg-theme-elevated/30">
347
- <span className="text-[10px] text-theme-text-tertiary font-medium">
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} port forward{sessionCounts.portForwards !== 1 ? 's' : ''}
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} terminal session{sessionCounts.execSessions !== 1 ? 's' : ''}
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} resource{resources.length !== 1 ? 's' : ''} created by this release</>
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} color="text-green-400" dotColor="bg-green-500" />
67
- <BucketRow label="Warning" subtitle="< 30d" count={data.warning} color="text-yellow-400" dotColor="bg-yellow-500" />
68
- <BucketRow label="Critical" subtitle="< 7d" count={data.critical} color="text-orange-400" dotColor="bg-orange-500" />
69
- <BucketRow label="Expired" count={data.expired} color="text-red-400" dotColor="bg-red-500" />
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
- function BucketRow({ label, subtitle, count, color, dotColor }: {
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
- color: string
93
- dotColor: string
102
+ tone: StatusTone
94
103
  }) {
95
104
  return (
96
105
  <div className="flex items-center gap-2">
97
- <span className={clsx('w-2 h-2 rounded-full shrink-0', dotColor)} />
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 ? color : 'text-theme-text-tertiary')}>{count}</span>
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-2">
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 className="text-sm font-semibold text-theme-text-primary break-all mb-1" title={cluster.name}>
177
- {cluster.name || 'Cluster'}
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-1 text-xs text-theme-text-tertiary">
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} node{nodes.length > 1 ? 's' : ''}</span>
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
- <span className={clsx(
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} port forward${count !== 1 ? 's' : ''} — ${errorSessions.length} failed`
283
- : `${count} active port forward${count !== 1 ? 's' : ''}`
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
- <ConfigField
245
+ <ConfigArrayField
246
246
  label="Kubeconfig Directories"
247
247
  help="Comma-separated directories containing kubeconfig files"
248
- value={config.kubeconfigDirs?.join(', ') ?? ''}
249
- effectiveValue={effectiveConfig?.kubeconfigDirs?.join(', ')}
248
+ value={config.kubeconfigDirs}
249
+ effectiveValue={effectiveConfig?.kubeconfigDirs}
250
250
  placeholder="/path/to/dir1, /path/to/dir2"
251
- onChange={(v) => onChange('kubeconfigDirs', v ? v.split(',').map(s => s.trim()).filter(Boolean) : undefined)}
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} resource{visibleLanes.length !== 1 ? 's' : ''} · {filteredEvents.length} event
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} critical issue${issueCount > 1 ? 's' : ''} (OOMKilled, CrashLoopBackOff, etc.)`} position="top">
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} flow{sorted.length !== 1 ? 's' : ''}
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
- <div className="text-center space-y-2">
1173
- <Filter className="h-12 w-12 text-theme-text-tertiary mx-auto" />
1174
- {flowStats.total > 0 && flowStats.shown === 0 ? (
1175
- <>
1176
- <p className="text-theme-text-secondary">All traffic is filtered out</p>
1177
- <p className="text-xs text-theme-text-tertiary">
1178
- {flowStats.total} flows hidden by current filters.
1179
- <button
1180
- onClick={() => {
1181
- setHideSystem(false)
1182
- setHideExternal(false)
1183
- setMinConnections(0)
1184
- }}
1185
- className="ml-1 text-blue-400 hover:underline"
1186
- >
1187
- Show all
1188
- </button>
1189
- </p>
1190
- </>
1191
- ) : flowsData?.warning ? (
1192
- <>
1193
- <p className="text-theme-text-secondary">Unable to fetch traffic data</p>
1194
- <p className="text-xs text-yellow-500 max-w-md">
1195
- {flowsData.warning}
1196
- </p>
1197
- </>
1198
- ) : (
1199
- <>
1200
- <p className="text-theme-text-secondary">No traffic observed</p>
1201
- <p className="text-xs text-theme-text-tertiary">
1202
- Traffic will appear here once connections are made between services
1203
- </p>
1204
- </>
1205
- )}
1206
- </div>
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
@@ -15,3 +15,4 @@ export {
15
15
  getCredentialsMode,
16
16
  } from './api/config';
17
17
  export type { NavCustomization } from './context/NavCustomization';
18
+ export { ShortcutHelpOverlay } from './components/ui/ShortcutHelpOverlay';