@skyhook-io/k8s-ui 1.6.0 → 1.6.1

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -87,7 +87,7 @@
87
87
  "elkjs": "^0.11.1",
88
88
  "lucide-react": "^1.12.0",
89
89
  "react": "^19.2.6",
90
- "react-dom": "^19.2.4",
90
+ "react-dom": "^19.2.6",
91
91
  "typescript": "^6.0.2",
92
92
  "vitest": "^4.1.5",
93
93
  "yaml": "^2.9.0"
@@ -0,0 +1,621 @@
1
+ import { useEffect, type ComponentType, type ReactNode } from 'react'
2
+ import { ChevronDown, ChevronRight, Clock3, GitBranch, GitCommit, Loader2, Pause, Play, RefreshCw, RotateCw, Settings, Trash2, XCircle } from 'lucide-react'
3
+
4
+ import { HealthStatusBadge, SyncStatusBadge } from './GitOpsStatusBadge'
5
+ import { GitOpsIssuesBand, GitOpsStatusStrip } from './insights'
6
+ import { Tooltip } from '../ui/Tooltip'
7
+ import type { GitOpsHealthStatus, GitOpsInsight, GitOpsIssue, GitOpsRemediation, SyncStatus } from '../../types'
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Props
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export interface GitOpsDetailIdentity {
14
+ kind: string // 'applications' | 'kustomizations' | ...
15
+ group: string // 'argoproj.io' | 'kustomize.toolkit.fluxcd.io' | ''
16
+ namespace: string
17
+ name: string
18
+ // toolLabel: "ArgoCD" | "FluxCD" — passed through as a chip; kept as a
19
+ // free string so future tools (Komodor, Argo Workflows, etc.) don't need
20
+ // an enum bump here.
21
+ toolLabel: string
22
+ // kindLabel: human-facing kind (e.g. "Application"). Caller does the
23
+ // lookup against its API resource catalog.
24
+ kindLabel: string
25
+ }
26
+
27
+ export interface GitOpsDetailLineage {
28
+ // Parent in a nested-GitOps chain (app-of-apps, Kustomization-applies-
29
+ // Kustomization). Renders an extra clickable breadcrumb segment.
30
+ kind: string
31
+ namespace: string
32
+ name: string
33
+ group: string
34
+ }
35
+
36
+ export interface GitOpsDetailStatus {
37
+ sync: SyncStatus
38
+ health: GitOpsHealthStatus
39
+ // suspended is the *effective* suspended state (status-derived OR
40
+ // suspended-by-Radar via annotations). Caller resolves the OR.
41
+ suspended: boolean
42
+ }
43
+
44
+ export interface GitOpsDetailMetadata {
45
+ // The "spec/config" facts under the title. Caller pre-formats each
46
+ // string — formatSourceRepo, formatDestination, etc.
47
+ project?: string
48
+ repository?: string
49
+ path?: string
50
+ chart?: string
51
+ destination?: string
52
+ destinationNamespace?: string
53
+ // autoSyncMode: free-string description from insights.summary
54
+ // (e.g. "auto-sync (prune)"). Renders as a fact when present.
55
+ autoSyncMode?: string
56
+ }
57
+
58
+ export interface ArgoActionHandlers {
59
+ onSyncRequested: () => void // opens caller's sync options dialog
60
+ onRefresh: (kind: 'normal' | 'hard') => void
61
+ onTerminate: () => void
62
+ onSuspend: () => void
63
+ onResume: () => void
64
+ // Per-action busy state for spinner rendering. Caller maintains these
65
+ // from React Query mutations or equivalent.
66
+ syncing: boolean
67
+ refreshing: boolean
68
+ // refreshingKind: which refresh is in flight — 'normal' or 'hard'.
69
+ // Drives which button shows the spinner so the caller doesn't have to
70
+ // disambiguate from a single boolean.
71
+ refreshingKind: 'normal' | 'hard'
72
+ terminating: boolean
73
+ suspending: boolean
74
+ resuming: boolean
75
+ // autoSyncEnabled: caller derives from spec.syncPolicy.automated.
76
+ autoSyncEnabled: boolean
77
+ // isRunning: caller derives from status.operationState.phase === 'Running'.
78
+ isRunning: boolean
79
+ }
80
+
81
+ export interface FluxActionHandlers {
82
+ onReconcile: () => void
83
+ onSyncWithSource: () => void // only invoked for Kustomization + HelmRelease
84
+ onSuspend: () => void
85
+ onResume: () => void
86
+ reconciling: boolean
87
+ syncingWithSource: boolean
88
+ suspending: boolean
89
+ resuming: boolean
90
+ }
91
+
92
+ export interface GitOpsHelmValuesData {
93
+ yaml: string
94
+ keyCount: number
95
+ source: 'flux' | 'argo-object' | 'argo-string' | 'argo-parameters'
96
+ }
97
+
98
+ export type GitOpsDetailTab = 'topology' | 'changes' | 'activity'
99
+
100
+ export interface GitOpsDetailLayoutProps {
101
+ // Identity
102
+ identity: GitOpsDetailIdentity
103
+ // Lineage breadcrumb (parent GitOps CR), null when none.
104
+ parent?: GitOpsDetailLineage | null
105
+
106
+ // Status
107
+ status: GitOpsDetailStatus | null
108
+ // Caller resolves terminating + tooltip text. `actionDisabledTooltip` is
109
+ // what disabled action buttons show; `chipTooltip` is what the
110
+ // [Terminating] chip shows.
111
+ terminating: boolean
112
+ terminatingChipTooltip: string
113
+ terminatingActionTooltip: string
114
+
115
+ // Spec/config row
116
+ detail: GitOpsDetailMetadata
117
+
118
+ // Insights — drives StatusStrip + IssuesBand
119
+ insight: GitOpsInsight | null
120
+ insightLoading: boolean
121
+ // Issue interactions — caller wires onSelectIssue to jump to Changes tab
122
+ // and highlight the affected resource.
123
+ onSelectIssue?: (issue: GitOpsIssue) => void
124
+ // Remediation — caller handles each remediation kind (e.g. create-namespace
125
+ // applies a manifest and triggers a follow-on sync).
126
+ onRemediate?: (remediation: GitOpsRemediation) => void
127
+ remediationPending?: boolean
128
+
129
+ // Helm values disclosure — caller renders the values payload; this prop
130
+ // controls whether the disclosure shows up + its open/closed state.
131
+ helmValues?: GitOpsHelmValuesData | null
132
+ helmValuesOpen?: boolean
133
+ onToggleHelmValues?: () => void
134
+ // helmValuesContent is the rendered YAML viewer (CodeViewer in OSS;
135
+ // hub-web brings its own). Kept as a slot so the layout doesn't depend
136
+ // on the OSS-specific CodeViewer component.
137
+ helmValuesContent?: ReactNode
138
+
139
+ // Action buttons — Argo + Flux. Exactly one applies for any given CR.
140
+ isArgoApp: boolean
141
+ isFlux: boolean
142
+ isFluxWorkload: boolean // Kustomization | HelmRelease — gates the
143
+ // "Sync with source" button
144
+ argo?: ArgoActionHandlers
145
+ flux?: FluxActionHandlers
146
+
147
+ // Tab state
148
+ activeTab: GitOpsDetailTab
149
+ onTabChange: (tab: GitOpsDetailTab) => void
150
+ // Fullscreen mode hides the page chrome (header + status strip + tabs
151
+ // become a single slim title bar) and lets the body region expand to
152
+ // fill the viewport. Caller toggles + tracks state.
153
+ fullscreen: boolean
154
+ onToggleFullscreen: () => void
155
+ // Tab body — caller renders the body for the active tab. The shell
156
+ // provides the tab bar + chrome; the body is fully caller-owned so
157
+ // hub-web and OSS can wire their own data sources without the layout
158
+ // knowing.
159
+ renderTabBody: (ctx: { tab: GitOpsDetailTab; fullscreen: boolean }) => ReactNode
160
+ // Optional: top-right tab-bar accessory (Clear filters + Fullscreen
161
+ // buttons in OSS, can be anything else in hub-web).
162
+ renderTabBarAccessory?: (ctx: { tab: GitOpsDetailTab; fullscreen: boolean }) => ReactNode
163
+ // Optional: counts/summary string in the middle of the tab bar (e.g.
164
+ // OSS's TopologyCounts). Caller decides when to render it.
165
+ renderTabBarCounts?: (ctx: { tab: GitOpsDetailTab }) => ReactNode
166
+
167
+ // Loading / error state for the underlying resource fetch — caller
168
+ // surfaces these on the body region (shell renders a spinner / error
169
+ // banner that replaces the body).
170
+ resourceLoading?: boolean
171
+ resourceError?: Error | null
172
+
173
+ // Navigation
174
+ onNavigateRoot: () => void // back to /gitops list
175
+ onNavigateParent?: () => void // breadcrumb parent click
176
+ // (No keyboard-shortcut prop on the layout — registration is
177
+ // router-aware so callers wire `useRegisterShortcut` or equivalent
178
+ // themselves.)
179
+
180
+ // Fleet context — when true, render a destination cluster chip in the
181
+ // header next to the title. Hub-web sets this to surface cross-cluster
182
+ // lineage; OSS leaves it false so single-cluster Radar's UX is unchanged.
183
+ isFleetContext?: boolean
184
+ destinationCluster?: { id: string; name: string }
185
+
186
+ // Tab-title side effect — sets document.title to "<name> — Radar" while
187
+ // mounted, restores on unmount. Opt-in so hub-web fleet detail can pick
188
+ // its own title format ("<name> — Fleet GitOps").
189
+ manageDocumentTitle?: boolean
190
+ documentTitleSuffix?: string // defaults to " — Radar"
191
+
192
+ // Children slot — for dialogs (SyncOptionsDialog, RollbackDialog, …)
193
+ // that should portal to body. Caller owns dialog state. Children render
194
+ // outside the layout's main flex column.
195
+ children?: ReactNode
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // Layout
200
+ // ---------------------------------------------------------------------------
201
+
202
+ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
203
+ const {
204
+ identity,
205
+ parent,
206
+ status,
207
+ terminating,
208
+ terminatingChipTooltip,
209
+ terminatingActionTooltip,
210
+ detail,
211
+ insight,
212
+ insightLoading,
213
+ onSelectIssue,
214
+ onRemediate,
215
+ remediationPending,
216
+ helmValues,
217
+ helmValuesOpen,
218
+ onToggleHelmValues,
219
+ helmValuesContent,
220
+ isArgoApp,
221
+ isFlux,
222
+ isFluxWorkload,
223
+ argo,
224
+ flux,
225
+ activeTab,
226
+ onTabChange,
227
+ fullscreen,
228
+ onToggleFullscreen,
229
+ renderTabBody,
230
+ renderTabBarAccessory,
231
+ renderTabBarCounts,
232
+ resourceLoading,
233
+ resourceError,
234
+ onNavigateRoot,
235
+ onNavigateParent,
236
+ isFleetContext,
237
+ destinationCluster,
238
+ manageDocumentTitle,
239
+ documentTitleSuffix,
240
+ children,
241
+ } = props
242
+
243
+ // Document title side effect — opt-in so hub-web can take ownership of
244
+ // its own title format.
245
+ useEffect(() => {
246
+ if (!manageDocumentTitle) return
247
+ const previous = document.title
248
+ const suffix = documentTitleSuffix ?? ' — Radar'
249
+ document.title = `${identity.name}${suffix}`
250
+ return () => { document.title = previous }
251
+ }, [identity.name, manageDocumentTitle, documentTitleSuffix])
252
+
253
+ const effectiveSuspended = status?.suspended ?? false
254
+ // graphFullscreen hides everything chrome-side; the body region expands.
255
+ // Mirrors the OSS GitOpsDetailView shell behavior so the visual chrome
256
+ // class set carries through.
257
+ const bodyShellClass = fullscreen
258
+ ? 'fixed inset-0 z-[80] flex min-h-0 min-w-0 flex-col bg-theme-base'
259
+ : 'flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden'
260
+
261
+ return (
262
+ <div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-theme-base">
263
+ {!fullscreen && (
264
+ <div className="shrink-0 border-b border-theme-border bg-theme-base px-4 py-3">
265
+ <div className="flex flex-wrap items-start justify-between gap-3">
266
+ <div className="min-w-0">
267
+ {/* Breadcrumb row */}
268
+ <div className="flex flex-wrap items-center gap-2">
269
+ <button
270
+ type="button"
271
+ onClick={onNavigateRoot}
272
+ className="shrink-0 text-xs font-medium text-sky-500 transition-colors hover:text-sky-400"
273
+ >
274
+ GitOps
275
+ </button>
276
+ <span className="shrink-0 text-xs text-theme-text-tertiary">/</span>
277
+ {parent && onNavigateParent && (
278
+ <>
279
+ <button
280
+ type="button"
281
+ onClick={onNavigateParent}
282
+ className="shrink-0 truncate max-w-[200px] text-xs font-medium text-sky-500 transition-colors hover:text-sky-400"
283
+ title={`Open parent: ${parent.namespace ? `${parent.namespace}/` : ''}${parent.name}`}
284
+ >
285
+ {parent.namespace ? `${parent.namespace}/` : ''}{parent.name}
286
+ </button>
287
+ <span className="shrink-0 text-xs text-theme-text-tertiary">/</span>
288
+ </>
289
+ )}
290
+ <h1 className="min-w-0 truncate text-lg font-semibold text-theme-text-primary">
291
+ {identity.namespace ? `${identity.namespace}/` : ''}{identity.name}
292
+ </h1>
293
+ {/* Status badges suppressed on Terminating — last-observed
294
+ sync/health values become stale the moment deletion is
295
+ initiated; the Terminating chip becomes the sole status
296
+ indicator. */}
297
+ {status && !terminating && (
298
+ <>
299
+ <SyncStatusBadge sync={status.sync} suspended={effectiveSuspended} />
300
+ {!effectiveSuspended && <HealthStatusBadge health={status.health} />}
301
+ </>
302
+ )}
303
+ {terminating && (
304
+ <Tooltip content={terminatingChipTooltip}>
305
+ <span className="badge border border-orange-500/40 bg-orange-500/10 text-orange-400">
306
+ <Trash2 className="h-3 w-3" />
307
+ Terminating
308
+ </span>
309
+ </Tooltip>
310
+ )}
311
+ <span className="inline-flex shrink-0 items-center rounded border border-theme-border bg-theme-hover/50 px-1.5 py-0.5 text-[11px] font-medium text-theme-text-secondary">
312
+ {identity.toolLabel} · {identity.kindLabel}
313
+ </span>
314
+ {isFleetContext && destinationCluster && (
315
+ // Cross-cluster lineage chip — fleet-only. Renders the
316
+ // destination cluster alongside the tool/kind chip so
317
+ // operators see at a glance which cluster the workloads
318
+ // actually live in. Clickable navigation lives one level
319
+ // up (the chip in the fleet list page) so the detail
320
+ // header stays compact.
321
+ <span className="inline-flex shrink-0 items-center gap-1 rounded border border-sky-500/40 bg-sky-500/10 px-1.5 py-0.5 text-[11px] font-medium text-sky-400">
322
+ Deployed to: {destinationCluster.name}
323
+ </span>
324
+ )}
325
+ </div>
326
+ {/* Spec/config row */}
327
+ <div className="mt-2 flex flex-wrap gap-x-5 gap-y-0.5 text-[11px] text-theme-text-tertiary">
328
+ <AppFact label="Project" value={detail.project || '-'} />
329
+ {detail.repository && <AppFact label="Source" value={detail.repository} />}
330
+ {detail.path && <AppFact label="Path" value={detail.path} />}
331
+ {detail.chart && <AppFact label="Chart" value={detail.chart} />}
332
+ <AppFact label="Destination" value={detail.destination || '-'} />
333
+ {detail.autoSyncMode && <AppFact label="Sync mode" value={detail.autoSyncMode} />}
334
+ </div>
335
+ </div>
336
+ {/* Action buttons */}
337
+ <div className="flex flex-wrap items-center gap-2">
338
+ {isArgoApp && argo && (
339
+ <>
340
+ <ActionButton
341
+ label="Sync…"
342
+ description="Apply manifests from Git to the cluster. Opens an options dialog (prune, dry-run, revision)."
343
+ icon={RefreshCw}
344
+ loading={argo.syncing}
345
+ onClick={argo.onSyncRequested}
346
+ disabled={effectiveSuspended || terminating}
347
+ disabledReason={terminating ? terminatingActionTooltip : undefined}
348
+ primary
349
+ />
350
+ <ActionButton
351
+ label="Refresh"
352
+ description="Re-check Git for new commits and recompute sync status. Doesn't apply anything."
353
+ icon={RotateCw}
354
+ loading={argo.refreshing && argo.refreshingKind === 'normal'}
355
+ onClick={() => argo.onRefresh('normal')}
356
+ />
357
+ <ActionButton
358
+ label="Hard refresh"
359
+ description="Like Refresh, but also bypasses Argo's manifest cache (re-renders Helm/Kustomize)."
360
+ icon={RotateCw}
361
+ loading={argo.refreshing && argo.refreshingKind === 'hard'}
362
+ onClick={() => argo.onRefresh('hard')}
363
+ />
364
+ {argo.isRunning && (
365
+ <ActionButton
366
+ label="Terminate"
367
+ description="Cancel the in-progress sync operation."
368
+ icon={XCircle}
369
+ loading={argo.terminating}
370
+ onClick={argo.onTerminate}
371
+ danger
372
+ />
373
+ )}
374
+ {argo.autoSyncEnabled ? (
375
+ <ActionButton
376
+ label="Disable auto-sync"
377
+ description="Stop Argo from automatically syncing Git changes. Manual Sync still works."
378
+ icon={Pause}
379
+ loading={argo.suspending}
380
+ onClick={argo.onSuspend}
381
+ disabled={terminating}
382
+ disabledReason={terminating ? terminatingActionTooltip : undefined}
383
+ />
384
+ ) : (
385
+ <ActionButton
386
+ label="Enable auto-sync"
387
+ description="Re-enable automatic syncing of Git changes to the cluster."
388
+ icon={Play}
389
+ loading={argo.resuming}
390
+ onClick={argo.onResume}
391
+ disabled={terminating}
392
+ disabledReason={terminating ? terminatingActionTooltip : undefined}
393
+ />
394
+ )}
395
+ </>
396
+ )}
397
+ {isFlux && flux && (
398
+ <>
399
+ <ActionButton
400
+ label="Reconcile"
401
+ description="Tell Flux to reconcile this resource now: re-read its source, re-apply the manifests, update status. Skips waiting for the regular reconciliation interval."
402
+ icon={RefreshCw}
403
+ loading={flux.reconciling}
404
+ onClick={flux.onReconcile}
405
+ disabled={effectiveSuspended || terminating}
406
+ disabledReason={terminating ? terminatingActionTooltip : undefined}
407
+ primary
408
+ />
409
+ {isFluxWorkload && (
410
+ <ActionButton
411
+ label="Sync with source"
412
+ description="Reconcile the upstream source CR (GitRepository/HelmRepository) first — re-fetching from Git or Helm — then reconcile this resource against the refreshed source."
413
+ icon={GitCommit}
414
+ loading={flux.syncingWithSource}
415
+ onClick={flux.onSyncWithSource}
416
+ disabled={terminating}
417
+ disabledReason={terminating ? terminatingActionTooltip : undefined}
418
+ />
419
+ )}
420
+ {effectiveSuspended ? (
421
+ <ActionButton
422
+ label="Resume"
423
+ description="Resume Flux reconciliation. Flux will start applying changes from the source again on its normal interval."
424
+ icon={Play}
425
+ loading={flux.resuming}
426
+ onClick={flux.onResume}
427
+ disabled={terminating}
428
+ disabledReason={terminating ? terminatingActionTooltip : undefined}
429
+ />
430
+ ) : (
431
+ <ActionButton
432
+ label="Suspend"
433
+ description="Pause Flux reconciliation. The resource stays exactly as-is — new commits in the source won't be applied until you resume."
434
+ icon={Pause}
435
+ loading={flux.suspending}
436
+ onClick={flux.onSuspend}
437
+ disabled={terminating}
438
+ disabledReason={terminating ? terminatingActionTooltip : undefined}
439
+ />
440
+ )}
441
+ </>
442
+ )}
443
+ </div>
444
+ </div>
445
+ </div>
446
+ )}
447
+ {!fullscreen && (
448
+ <>
449
+ <GitOpsStatusStrip insight={insight ?? undefined} loading={insightLoading} />
450
+ <GitOpsIssuesBand
451
+ issues={insight?.issues}
452
+ terminating={terminating}
453
+ onSelectIssue={onSelectIssue}
454
+ remediationPending={remediationPending}
455
+ onRemediate={onRemediate}
456
+ />
457
+ {helmValues && onToggleHelmValues && (
458
+ <div className="shrink-0 border-b border-theme-border bg-theme-base">
459
+ <button
460
+ type="button"
461
+ onClick={onToggleHelmValues}
462
+ className="flex w-full items-center gap-2 px-4 py-2 text-left text-xs text-theme-text-secondary hover:bg-theme-hover"
463
+ aria-expanded={helmValuesOpen}
464
+ >
465
+ {helmValuesOpen ? (
466
+ <ChevronDown className="h-3.5 w-3.5 shrink-0" />
467
+ ) : (
468
+ <ChevronRight className="h-3.5 w-3.5 shrink-0" />
469
+ )}
470
+ <Settings className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
471
+ <span className="font-medium text-theme-text-primary">Helm values</span>
472
+ <span className="tabular-nums text-theme-text-tertiary">
473
+ {helmValues.keyCount} {helmValues.keyCount === 1 ? 'key' : 'keys'}
474
+ </span>
475
+ {helmValues.source === 'argo-parameters' && (
476
+ <span className="text-[10px] uppercase tracking-wider text-theme-text-tertiary">parameters</span>
477
+ )}
478
+ </button>
479
+ {helmValuesOpen && helmValuesContent && (
480
+ <div className="border-t border-theme-border bg-theme-surface px-4 py-3">
481
+ {helmValuesContent}
482
+ </div>
483
+ )}
484
+ </div>
485
+ )}
486
+ </>
487
+ )}
488
+
489
+ {resourceLoading ? (
490
+ <div className="flex flex-1 items-center justify-center text-theme-text-secondary">
491
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading GitOps resource…
492
+ </div>
493
+ ) : resourceError ? (
494
+ <div className="p-4 text-sm text-red-500">Failed to load resource: {resourceError.message}</div>
495
+ ) : (
496
+ <div className={bodyShellClass}>
497
+ <div className="flex shrink-0 items-center justify-between gap-3 border-b border-theme-border bg-theme-base px-4 py-2">
498
+ <div className="flex items-center gap-1 rounded-md border border-theme-border bg-theme-surface p-1">
499
+ <ViewButton active={activeTab === 'topology'} icon={GitBranch} label="Topology" onClick={() => onTabChange('topology')} />
500
+ <ViewButton active={activeTab === 'changes'} icon={GitCommit} label="Resources" onClick={() => onTabChange('changes')} />
501
+ <ViewButton active={activeTab === 'activity'} icon={Clock3} label="Activity" onClick={() => onTabChange('activity')} />
502
+ </div>
503
+ {fullscreen ? (
504
+ <div className="min-w-0 flex-1 truncate text-sm font-medium text-theme-text-primary">
505
+ {identity.namespace ? `${identity.namespace}/` : ''}{identity.name}
506
+ </div>
507
+ ) : (
508
+ renderTabBarCounts?.({ tab: activeTab })
509
+ )}
510
+ <div className="flex items-center gap-2">
511
+ {renderTabBarAccessory?.({ tab: activeTab, fullscreen })}
512
+ {activeTab === 'topology' && (
513
+ <button
514
+ type="button"
515
+ onClick={onToggleFullscreen}
516
+ className="rounded-md border border-theme-border bg-theme-surface px-2 py-1 text-xs text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary"
517
+ >
518
+ {fullscreen ? 'Exit fullscreen' : 'Fullscreen'}
519
+ </button>
520
+ )}
521
+ </div>
522
+ </div>
523
+
524
+ {renderTabBody({ tab: activeTab, fullscreen })}
525
+ </div>
526
+ )}
527
+ {children}
528
+ </div>
529
+ )
530
+ }
531
+
532
+ // ---------------------------------------------------------------------------
533
+ // Small subcomponents kept private to keep the public surface narrow.
534
+ // AppFact, ActionButton, ViewButton are tightly coupled to the layout's
535
+ // visual language; if a caller needs them standalone we can export later.
536
+ // ---------------------------------------------------------------------------
537
+
538
+ function AppFact({ label, value }: { label: string; value: string }) {
539
+ // inline-flex sizes each fact to its content; flex-wrap on the parent
540
+ // handles row breaks. max-w-full + truncate guard against single facts
541
+ // wider than the screen (very long destination URLs).
542
+ return (
543
+ <span className="inline-flex min-w-0 max-w-full items-baseline gap-1">
544
+ <span className="shrink-0 text-theme-text-tertiary">{label}:</span>
545
+ <Tooltip content={value} delay={400} wrapperClassName="min-w-0">
546
+ <span className="block truncate text-theme-text-primary">{value}</span>
547
+ </Tooltip>
548
+ </span>
549
+ )
550
+ }
551
+
552
+ function ViewButton({
553
+ active,
554
+ icon: Icon,
555
+ label,
556
+ onClick,
557
+ }: {
558
+ active: boolean
559
+ icon: ComponentType<{ className?: string }>
560
+ label: string
561
+ onClick: () => void
562
+ }) {
563
+ return (
564
+ <button
565
+ type="button"
566
+ onClick={onClick}
567
+ className={`inline-flex items-center gap-1.5 rounded px-2.5 py-1 text-xs font-medium transition-colors ${
568
+ active
569
+ ? 'bg-skyhook-500 text-white'
570
+ : 'text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary'
571
+ }`}
572
+ >
573
+ <Icon className="h-3.5 w-3.5" />
574
+ {label}
575
+ </button>
576
+ )
577
+ }
578
+
579
+ function ActionButton({
580
+ label,
581
+ description,
582
+ icon: Icon,
583
+ loading,
584
+ disabled,
585
+ disabledReason,
586
+ danger,
587
+ primary,
588
+ onClick,
589
+ }: {
590
+ label: string
591
+ description?: string
592
+ icon: ComponentType<{ className?: string }>
593
+ loading?: boolean
594
+ disabled?: boolean
595
+ disabledReason?: string
596
+ danger?: boolean
597
+ primary?: boolean
598
+ onClick: () => void
599
+ }) {
600
+ // primary → brand fill (one per page); danger → red (destructive);
601
+ // default → bordered ghost on theme surface (secondary actions).
602
+ const variantClass = primary
603
+ ? 'btn-brand'
604
+ : danger
605
+ ? 'border border-red-500/40 bg-red-500/10 text-red-500 hover:bg-red-500/20'
606
+ : 'border border-theme-border bg-theme-surface text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary'
607
+ const tooltip = disabled && disabledReason ? disabledReason : (description || label)
608
+ return (
609
+ <Tooltip content={tooltip}>
610
+ <button
611
+ type="button"
612
+ onClick={onClick}
613
+ disabled={loading || disabled}
614
+ className={`inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${variantClass}`}
615
+ >
616
+ {loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Icon className="h-3.5 w-3.5" />}
617
+ {label}
618
+ </button>
619
+ </Tooltip>
620
+ )
621
+ }