@skyhook-io/radar-app 1.1.0 → 1.1.2

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.
Files changed (39) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +81 -18
  3. package/src/api/client.ts +200 -26
  4. package/src/api/rbac.ts +57 -0
  5. package/src/components/compare/CompareViewRoute.tsx +116 -0
  6. package/src/components/compare/useCompareCandidates.ts +27 -0
  7. package/src/components/compare/useCompareLauncher.tsx +76 -0
  8. package/src/components/cost/CostView.tsx +1 -1
  9. package/src/components/gitops/GitOpsView.tsx +258 -1862
  10. package/src/components/helm/ChartBrowser.tsx +61 -10
  11. package/src/components/helm/HelmView.tsx +28 -11
  12. package/src/components/helm/InstallWizard.tsx +5 -5
  13. package/src/components/helm/ManifestDiffViewer.tsx +1 -1
  14. package/src/components/helm/ValuesViewer.tsx +3 -39
  15. package/src/components/helm/helm-utils.ts +4 -0
  16. package/src/components/home/HomeView.tsx +18 -2
  17. package/src/components/resource/HPACharts.tsx +232 -0
  18. package/src/components/resource/PVCUsageBar.tsx +59 -0
  19. package/src/components/resource/PrometheusCharts.tsx +151 -434
  20. package/src/components/resource/PrometheusChartsGrid.tsx +339 -0
  21. package/src/components/resource/RestartChart.tsx +124 -0
  22. package/src/components/resource/RightsizingStrip.tsx +167 -0
  23. package/src/components/resources/CompositeRenderer.tsx +101 -0
  24. package/src/components/resources/renderers/HPARenderer.tsx +17 -1
  25. package/src/components/resources/renderers/NamespaceRenderer.tsx +22 -0
  26. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  27. package/src/components/resources/renderers/PodRenderer.tsx +13 -0
  28. package/src/components/resources/renderers/RoleBindingRenderer.tsx +43 -1
  29. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  30. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  31. package/src/components/resources/renderers/WorkloadRenderer.tsx +12 -0
  32. package/src/components/resources/renderers/index.ts +1 -0
  33. package/src/components/settings/MyPermissionsDialog.tsx +231 -0
  34. package/src/components/ui/DiagnosticsOverlay.tsx +1 -0
  35. package/src/components/workload/WorkloadView.tsx +107 -3
  36. package/src/context/NavCustomization.tsx +13 -0
  37. package/src/contexts/CapabilitiesContext.tsx +8 -3
  38. package/src/components/gitops/RollbackDialog.tsx +0 -107
  39. package/src/components/gitops/SyncOptionsDialog.tsx +0 -144
@@ -23,6 +23,9 @@ import {
23
23
  fetchJSON,
24
24
  } from '../../api/client'
25
25
  import { PrometheusCharts, isPrometheusSupported } from '../resource/PrometheusCharts'
26
+ import { PrometheusChartsGrid } from '../resource/PrometheusChartsGrid'
27
+ import { RestartEventLane } from '../resource/RestartChart'
28
+ import { RightsizingStrip } from '../resource/RightsizingStrip'
26
29
  import { useResourceAudit, useResources } from '../../api/client'
27
30
  import { AuditAlerts } from '@skyhook-io/k8s-ui'
28
31
  import { WorkloadLogsViewer } from '../logs/WorkloadLogsViewer'
@@ -35,15 +38,30 @@ import { PodRenderer } from '../resources/renderers/PodRenderer'
35
38
  import { NodeRenderer } from '../resources/renderers/NodeRenderer'
36
39
  import { ServiceRenderer } from '../resources/renderers/ServiceRenderer'
37
40
  import { WorkloadRenderer } from '../resources/renderers/WorkloadRenderer'
41
+ import { CompositeRenderer } from '../resources/CompositeRenderer'
42
+ import { ServiceAccountRenderer } from '../resources/renderers/ServiceAccountRenderer'
43
+ import { RoleRenderer } from '../resources/renderers/RoleRenderer'
44
+ import { RoleBindingRenderer } from '../resources/renderers/RoleBindingRenderer'
45
+ import { NamespaceRenderer } from '../resources/renderers/NamespaceRenderer'
46
+ import { HPARenderer } from '../resources/renderers/HPARenderer'
47
+ import { PVCRenderer } from '../resources/renderers/PVCRenderer'
38
48
  import { CreateResourceDialog } from '../shared/CreateResourceDialog'
39
49
  import { cleanYamlForDuplicate } from '../../utils/skeleton-yaml'
40
50
  import { useDesktopDownload } from '../../hooks/useDesktopDownload'
51
+ import { useCompareLauncher } from '../compare/useCompareLauncher'
52
+ import { apiVersionToGroup } from '../../utils/navigation'
41
53
 
42
54
  type TabType = 'overview' | 'timeline' | 'logs' | 'metrics' | 'yaml'
43
55
 
44
56
  // Stable reference — web renderer wrappers inject platform hooks internally
45
57
  const rendererOverrides: RendererOverrides = {
46
- PodRenderer, NodeRenderer, ServiceRenderer, WorkloadRenderer,
58
+ PodRenderer, NodeRenderer, ServiceRenderer, WorkloadRenderer, CompositeRenderer,
59
+ ServiceAccountRenderer,
60
+ RoleRenderer,
61
+ RoleBindingRenderer,
62
+ NamespaceRenderer,
63
+ HPARenderer,
64
+ PVCRenderer,
47
65
  }
48
66
 
49
67
  // ============================================================================
@@ -323,9 +341,27 @@ export function WorkloadView({
323
341
  // RBAC
324
342
  const canUpdateSecrets = useCanUpdateSecrets()
325
343
  const updateResource = useUpdateResource()
326
- const actionsBarProps = useActionsBarProps(kindProp, namespace, name)
344
+ const baseActionsBarProps = useActionsBarProps(kindProp, namespace, name)
327
345
  const desktopDownload = useDesktopDownload()
328
346
 
347
+ const resourceGroup = useMemo(
348
+ () => (resource?.apiVersion ? apiVersionToGroup(resource.apiVersion) : undefined),
349
+ [resource?.apiVersion],
350
+ )
351
+ const { onCompareTo, onCompareAcrossClusters, picker: comparePicker } = useCompareLauncher({
352
+ kind: kindProp,
353
+ namespace,
354
+ name,
355
+ // Prefer the URL-supplied group so Compare works even before the resource
356
+ // fetch completes; fall back to the derived group for callers that don't
357
+ // pass one.
358
+ group: rest.group || resourceGroup || undefined,
359
+ })
360
+ const actionsBarProps = useMemo(
361
+ () => ({ ...baseActionsBarProps, onCompareTo, onCompareAcrossClusters }),
362
+ [baseActionsBarProps, onCompareTo, onCompareAcrossClusters],
363
+ )
364
+
329
365
  const handleUpdateResource = useCallback(async (params: { kind: string; namespace: string; name: string; yaml: string }) => {
330
366
  await updateResource.mutateAsync(params)
331
367
  }, [updateResource])
@@ -384,7 +420,7 @@ export function WorkloadView({
384
420
  // Render props
385
421
  renderLogsTab={(props) => <LogsTabContent {...props} />}
386
422
  renderMetricsTab={({ kind, namespace: ns, name: n }) => (
387
- <PrometheusCharts kind={kind} namespace={ns} name={n} showEmptyState />
423
+ <MetricsTabContent kind={kind} namespace={ns} name={n} resource={resource} expanded={expanded} />
388
424
  )}
389
425
  isMetricsAvailable={(kind, res) =>
390
426
  isPrometheusSupported(kind) && !(kind === 'Pod' && res?.status?.phase === 'Pending')
@@ -412,6 +448,7 @@ export function WorkloadView({
412
448
  rest.onNavigateToResource?.({ kind: kindToPlural(result.kind), namespace: result.namespace, name: result.name, group: '' })
413
449
  }}
414
450
  />
451
+ {comparePicker}
415
452
  </>
416
453
  )
417
454
  }
@@ -651,6 +688,73 @@ function FluxSourceConsumersInner({ sourceKind, namespace, name }: { sourceKind:
651
688
  )
652
689
  }
653
690
 
691
+ // Drawer mode: single chart + category tabs (compact for ~500px width).
692
+ // Full-screen mode: multi-chart grid so CPU + Memory + Network can be
693
+ // compared side-by-side without tab switching.
694
+ function MetricsTabContent({ kind, namespace, name, resource, expanded }: {
695
+ kind: string
696
+ namespace: string
697
+ name: string
698
+ resource: any
699
+ expanded: boolean
700
+ }) {
701
+ const showRightsizing = expanded && ['Deployment', 'StatefulSet', 'DaemonSet'].includes(kind)
702
+
703
+ if (expanded) {
704
+ return (
705
+ <div className="flex flex-col h-full">
706
+ {showRightsizing && (
707
+ <div className="px-4 pt-4">
708
+ <RightsizingStrip kind={kind} namespace={namespace} name={name} />
709
+ </div>
710
+ )}
711
+ <div className="flex-1 min-h-0">
712
+ <PrometheusChartsGrid
713
+ kind={kind}
714
+ namespace={namespace}
715
+ name={name}
716
+ resource={resource}
717
+ />
718
+ </div>
719
+ </div>
720
+ )
721
+ }
722
+
723
+ // Drawer fallback: single chart with tabs + restart lane below. The chart's
724
+ // time-range selector is mirrored to the restart lane so they stay aligned.
725
+ return (
726
+ <DrawerMetricsContent
727
+ kind={kind}
728
+ namespace={namespace}
729
+ name={name}
730
+ resource={resource}
731
+ />
732
+ )
733
+ }
734
+
735
+ function DrawerMetricsContent({ kind, namespace, name, resource }: {
736
+ kind: string
737
+ namespace: string
738
+ name: string
739
+ resource: any
740
+ }) {
741
+ const [chartRange, setChartRange] = useState<import('../../api/client').PrometheusTimeRange>('1h')
742
+ const showRestartLane = kind !== 'Node'
743
+
744
+ return (
745
+ <div className="flex flex-col h-full">
746
+ <div className="flex-1 min-h-0">
747
+ <PrometheusCharts kind={kind} namespace={namespace} name={name} showEmptyState resource={resource} onTimeRangeChange={setChartRange} />
748
+ </div>
749
+ {showRestartLane && (
750
+ <div className="px-4 pb-4">
751
+ <RestartEventLane kind={kind} namespace={namespace} name={name} range={chartRange} />
752
+ </div>
753
+ )}
754
+ </div>
755
+ )
756
+ }
757
+
654
758
  // FLUX_SOURCE_KIND_BY_LOWER maps lowercase kind (what the inner WorkloadView
655
759
  // produces via its plural-to-singular fallback) to the wire-correct
656
760
  // PascalCase form that consumers carry in spec.sourceRef.kind. HelmChart is
@@ -18,6 +18,19 @@ interface NavCustomizationBase {
18
18
  brandSlot?: ReactNode;
19
19
  /** Replaces the ContextSwitcher (kubeconfig-context picker). */
20
20
  contextSlot?: ReactNode;
21
+ /**
22
+ * When set, a "Compare across clusters" option is added to the Compare
23
+ * button in resource action bars. The host returns the URL that should
24
+ * be navigated to (via window.location.assign — typically a hub fleet
25
+ * route). Standalone Radar omits this and the compare action stays
26
+ * single-cluster.
27
+ */
28
+ crossClusterCompareHref?: (ref: {
29
+ kind: string;
30
+ namespace: string;
31
+ name: string;
32
+ group?: string;
33
+ }) => string;
21
34
  }
22
35
 
23
36
  /**
@@ -1,6 +1,6 @@
1
1
  import { createContext, useContext, useMemo, ReactNode } from 'react'
2
2
  import { useCapabilities, useNamespaceCapabilities } from '../api/client'
3
- import type { Capabilities, ResourcePermissions } from '../types'
3
+ import { OPTIONAL_RESOURCE_KINDS, type Capabilities, type ResourcePermissions } from '../types'
4
4
 
5
5
  // Default capabilities for local development (when running locally, all features work)
6
6
  const defaultCapabilities: Capabilities = {
@@ -100,12 +100,17 @@ export function useResourcePermissions(): ResourcePermissions | undefined {
100
100
  return useContext(CapabilitiesContext).resources
101
101
  }
102
102
 
103
+ // See OPTIONAL_RESOURCE_KINDS for why these are filtered.
104
+ function isOptionalKind(kind: string): boolean {
105
+ return (OPTIONAL_RESOURCE_KINDS as ReadonlyArray<string>).includes(kind)
106
+ }
107
+
103
108
  export function useRestrictedResources(): string[] {
104
109
  const resources = useContext(CapabilitiesContext).resources
105
110
  return useMemo(() => {
106
111
  if (!resources) return []
107
112
  return Object.entries(resources)
108
- .filter(([, allowed]) => !allowed)
113
+ .filter(([kind, allowed]) => !allowed && !isOptionalKind(kind))
109
114
  .map(([kind]) => kind)
110
115
  }, [resources])
111
116
  }
@@ -113,7 +118,7 @@ export function useRestrictedResources(): string[] {
113
118
  export function useHasLimitedAccess(): boolean {
114
119
  const resources = useContext(CapabilitiesContext).resources
115
120
  if (!resources) return false
116
- return Object.values(resources).some(allowed => !allowed)
121
+ return Object.entries(resources).some(([kind, allowed]) => !allowed && !isOptionalKind(kind))
117
122
  }
118
123
 
119
124
  // Namespace-scoped capability hooks: lazily re-check exec/logs/portForward
@@ -1,107 +0,0 @@
1
- import { useState, useEffect } from 'react'
2
- import { DialogPortal } from '@skyhook-io/k8s-ui'
3
- import { History, Loader2 } from 'lucide-react'
4
- import { Tooltip } from '../ui/Tooltip'
5
-
6
- interface RollbackDialogProps {
7
- open: boolean
8
- // Identifies the target history entry. Caller provides the user-visible
9
- // revision (mono SHA / tag) and the API id; Argo uses id, the user reads
10
- // the revision.
11
- appLabel: string
12
- revision: string
13
- historyId?: string
14
- pending?: boolean
15
- onCancel: () => void
16
- onConfirm: (opts: { prune: boolean; dryRun: boolean }) => void
17
- }
18
-
19
- // Confirms an Argo Application rollback. Defaults are conservative — no
20
- // prune (avoid surprise deletions), no dry run (just do it). Operators
21
- // who want to preview check Dry Run; the result lands in Activity.
22
- export function RollbackDialog({ open, appLabel, revision, historyId, pending, onCancel, onConfirm }: RollbackDialogProps) {
23
- const [prune, setPrune] = useState(false)
24
- const [dryRun, setDryRun] = useState(false)
25
-
26
- useEffect(() => {
27
- if (open) {
28
- setPrune(false)
29
- setDryRun(false)
30
- }
31
- }, [open])
32
-
33
- return (
34
- <DialogPortal open={open} onClose={pending ? () => {} : onCancel} className="w-[440px]" closable={!pending}>
35
- <div className="border-b border-theme-border px-4 py-3">
36
- <h2 className="text-sm font-semibold text-theme-text-primary">Roll back application</h2>
37
- <p className="mt-0.5 text-xs text-theme-text-tertiary">{appLabel}</p>
38
- </div>
39
- <div className="space-y-4 px-4 py-4 text-sm">
40
- <div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-400">
41
- Argo will sync this application to a previous revision. This is a write operation on the cluster.
42
- </div>
43
- <dl className="grid grid-cols-[80px_minmax(0,1fr)] gap-x-3 gap-y-1 text-xs">
44
- <dt className="text-theme-text-tertiary">Revision</dt>
45
- <dd className="min-w-0">
46
- <Tooltip content={revision} delay={400} disabled={!revision} wrapperClassName="block max-w-full">
47
- <span className="block truncate font-mono text-theme-text-primary">{revision || '-'}</span>
48
- </Tooltip>
49
- </dd>
50
- {historyId && (
51
- <>
52
- <dt className="text-theme-text-tertiary">History ID</dt>
53
- <dd className="font-mono text-theme-text-primary">#{historyId}</dd>
54
- </>
55
- )}
56
- </dl>
57
- <div className="space-y-2">
58
- <label className="flex items-start gap-2">
59
- <input
60
- type="checkbox"
61
- checked={prune}
62
- onChange={(e) => setPrune(e.target.checked)}
63
- disabled={pending}
64
- className="mt-0.5 h-3.5 w-3.5 cursor-pointer accent-sky-500 disabled:cursor-not-allowed"
65
- />
66
- <div className="min-w-0">
67
- <div className="text-xs text-theme-text-primary">Prune resources added since this revision</div>
68
- <div className="text-[11px] text-theme-text-tertiary">Off by default — leaves any resources created after this revision untouched.</div>
69
- </div>
70
- </label>
71
- <label className="flex items-start gap-2">
72
- <input
73
- type="checkbox"
74
- checked={dryRun}
75
- onChange={(e) => setDryRun(e.target.checked)}
76
- disabled={pending}
77
- className="mt-0.5 h-3.5 w-3.5 cursor-pointer accent-sky-500 disabled:cursor-not-allowed"
78
- />
79
- <div className="min-w-0">
80
- <div className="text-xs text-theme-text-primary">Dry run</div>
81
- <div className="text-[11px] text-theme-text-tertiary">Preview the rollback without applying it.</div>
82
- </div>
83
- </label>
84
- </div>
85
- </div>
86
- <div className="flex items-center justify-end gap-2 border-t border-theme-border bg-theme-base px-4 py-3">
87
- <button
88
- type="button"
89
- onClick={onCancel}
90
- disabled={pending}
91
- className="rounded-md border border-theme-border bg-theme-surface px-3 py-1.5 text-xs text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary disabled:cursor-not-allowed disabled:opacity-50"
92
- >
93
- Cancel
94
- </button>
95
- <button
96
- type="button"
97
- onClick={() => onConfirm({ prune, dryRun })}
98
- disabled={pending}
99
- className="inline-flex items-center gap-1.5 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-1.5 text-xs font-medium text-amber-700 hover:bg-amber-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-amber-400"
100
- >
101
- {pending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <History className="h-3.5 w-3.5" />}
102
- {dryRun ? 'Run dry-run' : 'Roll back'}
103
- </button>
104
- </div>
105
- </DialogPortal>
106
- )
107
- }
@@ -1,144 +0,0 @@
1
- import { useState, useEffect, type ComponentType } from 'react'
2
- import { DialogPortal } from '@skyhook-io/k8s-ui'
3
- import { Loader2, RefreshCw } from 'lucide-react'
4
-
5
- interface SyncOptionsDialogProps {
6
- open: boolean
7
- appLabel: string
8
- pending?: boolean
9
- onCancel: () => void
10
- onConfirm: (opts: {
11
- revision?: string
12
- prune: boolean
13
- dryRun: boolean
14
- force: boolean
15
- applyOnly: boolean
16
- syncOptions: string[]
17
- }) => void
18
- }
19
-
20
- // Modeled on ArgoCD's Sync drawer. Single Sync-now button at the bottom;
21
- // defaults are "what most users want" (prune true, no dry-run, no force)
22
- // so the common path is two clicks. Revision is optional and falls
23
- // through to whatever Argo had targeted before.
24
- export function SyncOptionsDialog({ open, appLabel, pending, onCancel, onConfirm }: SyncOptionsDialogProps) {
25
- const [revision, setRevision] = useState('')
26
- const [prune, setPrune] = useState(true)
27
- const [dryRun, setDryRun] = useState(false)
28
- const [force, setForce] = useState(false)
29
- const [applyOnly, setApplyOnly] = useState(false)
30
- const [replace, setReplace] = useState(false)
31
- const [serverSideApply, setServerSideApply] = useState(false)
32
-
33
- // Reset on each open so a previous attempt's flags don't leak into the
34
- // next sync — easy footgun in modal-heavy flows.
35
- useEffect(() => {
36
- if (open) {
37
- setRevision('')
38
- setPrune(true)
39
- setDryRun(false)
40
- setForce(false)
41
- setApplyOnly(false)
42
- setReplace(false)
43
- setServerSideApply(false)
44
- }
45
- }, [open])
46
-
47
- function submit() {
48
- const syncOptions: string[] = []
49
- if (replace) syncOptions.push('Replace=true')
50
- if (serverSideApply) syncOptions.push('ServerSideApply=true')
51
- onConfirm({
52
- revision: revision.trim() || undefined,
53
- prune,
54
- dryRun,
55
- force,
56
- applyOnly,
57
- syncOptions,
58
- })
59
- }
60
-
61
- return (
62
- <DialogPortal open={open} onClose={pending ? () => {} : onCancel} className="w-[480px]" closable={!pending}>
63
- <div className="border-b border-theme-border px-4 py-3">
64
- <h2 className="text-sm font-semibold text-theme-text-primary">Sync application</h2>
65
- <p className="mt-0.5 text-xs text-theme-text-tertiary">{appLabel}</p>
66
- </div>
67
- <div className="space-y-4 px-4 py-4 text-sm">
68
- <label className="block">
69
- <span className="text-xs font-medium text-theme-text-secondary">Revision (optional)</span>
70
- <input
71
- type="text"
72
- value={revision}
73
- onChange={(e) => setRevision(e.target.value)}
74
- placeholder="HEAD"
75
- disabled={pending}
76
- className="mt-1 w-full rounded-md border border-theme-border bg-theme-base px-2 py-1.5 font-mono text-xs text-theme-text-primary outline-none placeholder:text-theme-text-tertiary focus:border-sky-500"
77
- />
78
- <span className="mt-0.5 block text-[11px] text-theme-text-tertiary">
79
- Branch, tag, or commit SHA. Leave empty to use the Application's targetRevision.
80
- </span>
81
- </label>
82
-
83
- {/* Common (Prune / Dry run) sit above a divider; Advanced toggles
84
- stay accessible but visually subordinate so the common-case user
85
- can scan past them without parsing every helper line. */}
86
- <fieldset className="space-y-2">
87
- <legend className="mb-1 text-xs font-medium text-theme-text-secondary">Sync options</legend>
88
- <Toggle label="Prune" checked={prune} onChange={setPrune} disabled={pending} hint="Delete resources that are no longer in Git." />
89
- <Toggle label="Dry run" checked={dryRun} onChange={setDryRun} disabled={pending} hint="Preview only — Argo computes the diff but applies nothing." />
90
- </fieldset>
91
- <fieldset className="space-y-2 border-t border-theme-border pt-3">
92
- <legend className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Advanced</legend>
93
- <Toggle label="Apply only" checked={applyOnly} onChange={setApplyOnly} disabled={pending} hint="Skip PreSync / PostSync / SyncFail hooks." />
94
- <Toggle label="Force" checked={force} onChange={setForce} disabled={pending} hint="Use kubectl --force; required for some immutable-field changes." />
95
- <Toggle label="Replace" checked={replace} onChange={setReplace} disabled={pending} hint="kubectl replace instead of apply (drops fields not in source)." />
96
- <Toggle label="Server-side apply" checked={serverSideApply} onChange={setServerSideApply} disabled={pending} hint="Use the K8s server-side apply mechanism for ownership tracking." />
97
- </fieldset>
98
- </div>
99
- <div className="flex items-center justify-end gap-2 border-t border-theme-border bg-theme-base px-4 py-3">
100
- <button
101
- type="button"
102
- onClick={onCancel}
103
- disabled={pending}
104
- className="rounded-md border border-theme-border bg-theme-surface px-3 py-1.5 text-xs text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary disabled:cursor-not-allowed disabled:opacity-50"
105
- >
106
- Cancel
107
- </button>
108
- <PrimaryButton onClick={submit} disabled={pending} icon={pending ? Loader2 : RefreshCw} loading={pending} label={dryRun ? 'Run dry-run' : 'Sync now'} />
109
- </div>
110
- </DialogPortal>
111
- )
112
- }
113
-
114
- function Toggle({ label, checked, onChange, disabled, hint }: { label: string; checked: boolean; onChange: (v: boolean) => void; disabled?: boolean; hint?: string }) {
115
- return (
116
- <label className="flex items-start gap-2">
117
- <input
118
- type="checkbox"
119
- checked={checked}
120
- onChange={(e) => onChange(e.target.checked)}
121
- disabled={disabled}
122
- className="mt-0.5 h-3.5 w-3.5 cursor-pointer accent-sky-500 disabled:cursor-not-allowed"
123
- />
124
- <div className="min-w-0">
125
- <div className="text-xs text-theme-text-primary">{label}</div>
126
- {hint && <div className="text-[11px] text-theme-text-tertiary">{hint}</div>}
127
- </div>
128
- </label>
129
- )
130
- }
131
-
132
- function PrimaryButton({ onClick, disabled, icon: Icon, loading, label }: { onClick: () => void; disabled?: boolean; icon: ComponentType<{ className?: string }>; loading?: boolean; label: string }) {
133
- return (
134
- <button
135
- type="button"
136
- onClick={onClick}
137
- disabled={disabled}
138
- className="btn-brand inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium disabled:cursor-not-allowed disabled:opacity-50"
139
- >
140
- <Icon className={`h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} />
141
- {label}
142
- </button>
143
- )
144
- }