@skyhook-io/k8s-ui 1.7.11 → 1.7.13
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 +1 -1
- package/src/components/applications/AppChips.tsx +109 -0
- package/src/components/applications/AppTooltips.tsx +199 -0
- package/src/components/applications/ApplicationDetail.tsx +671 -0
- package/src/components/applications/ApplicationsList.tsx +569 -0
- package/src/components/applications/ReadyBar.tsx +22 -0
- package/src/components/applications/index.ts +8 -0
- package/src/components/audit/AuditFindingsTable.tsx +3 -25
- package/src/components/dock/TerminalTab.tsx +4 -2
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +1 -0
- package/src/components/issues/IssuesView.tsx +64 -17
- package/src/components/issues/index.ts +1 -1
- package/src/components/issues/issues.test.ts +4 -4
- package/src/components/issues/severity.ts +5 -0
- package/src/components/issues/types.ts +43 -0
- package/src/components/logs/LogCore.tsx +13 -2
- package/src/components/logs/LogToolbarSelects.tsx +6 -2
- package/src/components/logs/LogsViewer.tsx +66 -10
- package/src/components/logs/WorkloadLogsViewer.tsx +68 -13
- package/src/components/logs/useLogStream.ts +41 -3
- package/src/components/resources/ResourcesView.tsx +550 -52
- package/src/components/resources/column-filter-serialization.test.ts +26 -0
- package/src/components/resources/get-default-container-name.test.ts +31 -0
- package/src/components/resources/renderers/DeviceClassRenderer.tsx +49 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +7 -0
- package/src/components/resources/renderers/NvidiaClusterPolicyRenderer.tsx +57 -0
- package/src/components/resources/renderers/NvidiaDriverRenderer.tsx +47 -0
- package/src/components/resources/renderers/PodRenderer.tsx +2 -2
- package/src/components/resources/renderers/ResourceClaimRenderer.tsx +118 -0
- package/src/components/resources/renderers/ResourceClaimTemplateRenderer.tsx +39 -0
- package/src/components/resources/renderers/ResourceSliceRenderer.tsx +72 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +5 -4
- package/src/components/resources/renderers/dra-cells.tsx +80 -0
- package/src/components/resources/renderers/index.ts +8 -0
- package/src/components/resources/renderers/nvidia-cells.tsx +43 -0
- package/src/components/resources/resource-utils-dra.ts +90 -0
- package/src/components/resources/resource-utils-nvidia.ts +63 -0
- package/src/components/resources/resource-utils.ts +62 -4
- package/src/components/shared/DetailShell.tsx +14 -7
- package/src/components/shared/EditableYamlView.tsx +37 -17
- package/src/components/shared/ResourceActionsBar.tsx +5 -4
- package/src/components/shared/ResourceRendererDispatch.test.tsx +103 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +27 -2
- package/src/components/timeline/TimelineList.tsx +3 -32
- package/src/components/timeline/TimelineSwimlanes.tsx +3 -31
- package/src/components/topology/K8sResourceNode.tsx +26 -5
- package/src/components/topology/TopologyGraph.tsx +102 -3
- package/src/components/topology/layout.ts +36 -11
- package/src/components/ui/CenteredEmpty.tsx +27 -0
- package/src/components/ui/ConfirmDialog.tsx +1 -1
- package/src/components/ui/SearchBox.tsx +85 -0
- package/src/components/ui/drawer-components.tsx +23 -1
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/WorkloadView.tsx +167 -33
- package/src/components/workload/index.ts +1 -1
- package/src/hooks/useKeyboardShortcuts.tsx +3 -1
- package/src/index.ts +4 -0
- package/src/types/core.ts +1 -0
- package/src/utils/api-resources.ts +21 -0
- package/src/utils/applications.test.ts +207 -0
- package/src/utils/applications.ts +674 -0
- package/src/utils/custom-columns.test.ts +111 -0
- package/src/utils/custom-columns.ts +49 -0
- package/src/utils/extended-resources.test.ts +152 -0
- package/src/utils/extended-resources.ts +121 -0
- package/src/utils/format.ts +11 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/topology-neighborhood.test.ts +185 -0
- package/src/utils/topology-neighborhood.ts +262 -0
- package/src/utils/workload-colors.ts +36 -0
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
import { useMemo, useState, useEffect, useRef, useCallback } from 'react'
|
|
2
|
+
import { ChevronRight, ChevronUp, ChevronDown, Layers, Info } from 'lucide-react'
|
|
3
|
+
import { clsx } from 'clsx'
|
|
4
|
+
import { StatusDot, mapHealthToTone } from '../ui/status-tone'
|
|
5
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
6
|
+
import { EmptyState } from '../ui/EmptyState'
|
|
7
|
+
import { SearchBox } from '../ui/SearchBox'
|
|
8
|
+
import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
|
|
9
|
+
import { pluralize } from '../../utils/pluralize'
|
|
10
|
+
import {
|
|
11
|
+
type AppRow,
|
|
12
|
+
type AppHealth,
|
|
13
|
+
type AppWorkloadClass,
|
|
14
|
+
type AppSource,
|
|
15
|
+
type AppCategory,
|
|
16
|
+
HEALTH_ORDER,
|
|
17
|
+
HEALTH_RANK,
|
|
18
|
+
HEALTH_META,
|
|
19
|
+
CLASS_ORDER,
|
|
20
|
+
CLASS_META,
|
|
21
|
+
CATEGORY_ORDER,
|
|
22
|
+
CATEGORY_META,
|
|
23
|
+
CHIP,
|
|
24
|
+
CHIP_TONE,
|
|
25
|
+
SOURCE_ORDER,
|
|
26
|
+
SOURCE_META,
|
|
27
|
+
categoryOf,
|
|
28
|
+
envRank,
|
|
29
|
+
healthOf,
|
|
30
|
+
isSystemNamespace,
|
|
31
|
+
namespaceOf,
|
|
32
|
+
namespacesOf,
|
|
33
|
+
resolveEnv,
|
|
34
|
+
identityEnvInferred,
|
|
35
|
+
sourceOf,
|
|
36
|
+
workloadClassOf,
|
|
37
|
+
classSetOf,
|
|
38
|
+
classCompositionOf,
|
|
39
|
+
foldAppGroups,
|
|
40
|
+
type FoldedRow,
|
|
41
|
+
} from '../../utils/applications'
|
|
42
|
+
import { ReadyBar } from './ReadyBar'
|
|
43
|
+
import { ProvenanceBadge, ClassBadge, CategoryChip, VersionInfo } from './AppChips'
|
|
44
|
+
import { AppIdentityTooltip, EnvHint } from './AppTooltips'
|
|
45
|
+
|
|
46
|
+
// ApplicationsList — pure, single-cluster dense list of logical apps. Health
|
|
47
|
+
// dot + name + provenance/add-on/mixed chips; a Namespace column; an env pill
|
|
48
|
+
// (namespace-inferred shown as ~env); class; ready bar; version; workloads. A
|
|
49
|
+
// facet rail + a health hero bar sit alongside. Data + selection are injected.
|
|
50
|
+
// Styling mirrors the Resources table so the two read as one design.
|
|
51
|
+
|
|
52
|
+
interface AppEntry {
|
|
53
|
+
row: AppRow
|
|
54
|
+
health: AppHealth
|
|
55
|
+
versions: string[]
|
|
56
|
+
namespace: string
|
|
57
|
+
namespaces: string[]
|
|
58
|
+
env: string
|
|
59
|
+
envInferred: boolean
|
|
60
|
+
kinds: Record<string, number>
|
|
61
|
+
workloadClass: AppWorkloadClass
|
|
62
|
+
/** Distinct contained classes — the inclusive facet-matching set. */
|
|
63
|
+
classSet: AppWorkloadClass[]
|
|
64
|
+
classComposition: { cls: AppWorkloadClass; count: number }[]
|
|
65
|
+
category: AppCategory
|
|
66
|
+
ready: number
|
|
67
|
+
desired: number
|
|
68
|
+
/** ready/desired as a fraction for sorting; -1 when nothing is desired. */
|
|
69
|
+
readyRatio: number
|
|
70
|
+
source: AppSource
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function buildEntry(row: AppRow, discoveredEnvs?: ReadonlySet<string>): AppEntry {
|
|
74
|
+
const kinds: Record<string, number> = {}
|
|
75
|
+
let ready = 0
|
|
76
|
+
let desired = 0
|
|
77
|
+
for (const wl of row.workloads || []) {
|
|
78
|
+
kinds[wl.kind] = (kinds[wl.kind] ?? 0) + 1
|
|
79
|
+
ready += wl.ready ?? 0
|
|
80
|
+
desired += wl.desired ?? 0
|
|
81
|
+
}
|
|
82
|
+
const namespace = namespaceOf(row)
|
|
83
|
+
// The server's identity classification carries the authoritative env (label/
|
|
84
|
+
// declared/discovered); plain rows fall back to the trio + discovered-token
|
|
85
|
+
// namespace heuristic.
|
|
86
|
+
const resolved = resolveEnv(undefined, namespace, discoveredEnvs)
|
|
87
|
+
const env = row.identity?.env ?? resolved.env
|
|
88
|
+
const inferred = row.identity ? identityEnvInferred(row.identity) : resolved.inferred
|
|
89
|
+
return {
|
|
90
|
+
row,
|
|
91
|
+
health: healthOf(row.health),
|
|
92
|
+
versions: Array.from(new Set((row.versions || []).filter(Boolean))),
|
|
93
|
+
namespace,
|
|
94
|
+
namespaces: namespacesOf(row),
|
|
95
|
+
env,
|
|
96
|
+
envInferred: inferred,
|
|
97
|
+
kinds,
|
|
98
|
+
workloadClass: workloadClassOf(row.workload_class),
|
|
99
|
+
classSet: classSetOf(row),
|
|
100
|
+
classComposition: classCompositionOf(row),
|
|
101
|
+
category: categoryOf(row.category),
|
|
102
|
+
ready,
|
|
103
|
+
desired,
|
|
104
|
+
readyRatio: desired > 0 ? ready / desired : -1,
|
|
105
|
+
source: sourceOf(row.tier),
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const envLabel = (env: string) => (env ? env : 'unlabeled')
|
|
110
|
+
|
|
111
|
+
function searchTextForEntry(e: AppEntry): string {
|
|
112
|
+
const workloadText = (e.row.workloads || []).flatMap((wl) => [wl.kind, wl.namespace, wl.name, wl.version])
|
|
113
|
+
return [
|
|
114
|
+
e.row.name,
|
|
115
|
+
e.row.key,
|
|
116
|
+
e.namespace,
|
|
117
|
+
SOURCE_META[e.source].label,
|
|
118
|
+
CLASS_META[e.workloadClass].label,
|
|
119
|
+
...e.classSet.map((c) => CLASS_META[c].label),
|
|
120
|
+
CATEGORY_META[e.category].label,
|
|
121
|
+
...e.versions,
|
|
122
|
+
envLabel(e.env),
|
|
123
|
+
...Object.keys(e.kinds),
|
|
124
|
+
...workloadText,
|
|
125
|
+
]
|
|
126
|
+
.filter(Boolean)
|
|
127
|
+
.join(' ')
|
|
128
|
+
.toLowerCase()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function Facet<T extends string>({ title, info, options, selected, onToggle }: { title: string; info?: React.ReactNode; options: { value: T; label: string; count: number; tone?: string; tooltip?: string }[]; selected: Set<T>; onToggle: (v: T) => void }) {
|
|
132
|
+
const visible = options.filter((o) => o.count > 0)
|
|
133
|
+
if (visible.length === 0) return null
|
|
134
|
+
return (
|
|
135
|
+
<div className="flex flex-col gap-1">
|
|
136
|
+
<div className="flex items-center gap-1 px-1 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
|
|
137
|
+
{title}
|
|
138
|
+
{info && (
|
|
139
|
+
<Tooltip content={info} delay={150} position="right">
|
|
140
|
+
<Info className="h-3 w-3 cursor-default text-theme-text-tertiary/70 hover:text-theme-text-secondary" aria-label={`About ${title}`} />
|
|
141
|
+
</Tooltip>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
{visible.map((o) => {
|
|
145
|
+
const on = selected.has(o.value)
|
|
146
|
+
const button = (
|
|
147
|
+
<button
|
|
148
|
+
key={o.value}
|
|
149
|
+
type="button"
|
|
150
|
+
onClick={() => onToggle(o.value)}
|
|
151
|
+
className={`flex w-full items-center justify-between gap-2 rounded px-2 py-1 text-left text-xs ${on ? 'selection selection-ring text-theme-text-primary' : 'text-theme-text-secondary hover:bg-theme-hover'}`}
|
|
152
|
+
>
|
|
153
|
+
<span className={`truncate ${o.tone ?? ''}`}>{o.label}</span>
|
|
154
|
+
<span className="font-mono tabular-nums text-theme-text-tertiary">{o.count}</span>
|
|
155
|
+
</button>
|
|
156
|
+
)
|
|
157
|
+
return o.tooltip ? (
|
|
158
|
+
<Tooltip key={o.value} content={o.tooltip} delay={300} position="right" wrapperClassName="w-full">
|
|
159
|
+
{button}
|
|
160
|
+
</Tooltip>
|
|
161
|
+
) : (
|
|
162
|
+
button
|
|
163
|
+
)
|
|
164
|
+
})}
|
|
165
|
+
</div>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Sortable columns. `health` is the implicit default (worst-first then name);
|
|
170
|
+
// clicking a sortable header cycles asc → desc → off (back to default).
|
|
171
|
+
type SortKey = 'name' | 'ready' | 'version'
|
|
172
|
+
type SortDir = 'asc' | 'desc'
|
|
173
|
+
|
|
174
|
+
function compareEntries(a: AppEntry, b: AppEntry, key: SortKey): number {
|
|
175
|
+
switch (key) {
|
|
176
|
+
case 'name':
|
|
177
|
+
return a.row.name.localeCompare(b.row.name)
|
|
178
|
+
case 'ready':
|
|
179
|
+
return a.readyRatio - b.readyRatio
|
|
180
|
+
case 'version': {
|
|
181
|
+
// Sort by distinct-version count first (skewed apps cluster), then the
|
|
182
|
+
// first tag for a stable, human-meaningful order.
|
|
183
|
+
const byCount = a.versions.length - b.versions.length
|
|
184
|
+
if (byCount !== 0) return byCount
|
|
185
|
+
return (a.versions[0] ?? '').localeCompare(b.versions[0] ?? '')
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function SortHeader({ label, sortKey, sort, onSort, className }: { label: string; sortKey: SortKey; sort: { key: SortKey; dir: SortDir } | null; onSort: (k: SortKey) => void; className?: string }) {
|
|
191
|
+
const active = sort?.key === sortKey
|
|
192
|
+
const ariaSort = active ? (sort!.dir === 'asc' ? 'ascending' : 'descending') : 'none'
|
|
193
|
+
return (
|
|
194
|
+
<th aria-sort={ariaSort} className={clsx('px-2 py-2 text-left text-[10px] font-medium uppercase tracking-wide cursor-pointer select-none text-theme-text-tertiary hover:text-theme-text-primary', className)} onClick={() => onSort(sortKey)}>
|
|
195
|
+
<span className="inline-flex items-center gap-1">
|
|
196
|
+
{label}
|
|
197
|
+
{active ? (sort!.dir === 'asc' ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />) : <span className="w-3" />}
|
|
198
|
+
</span>
|
|
199
|
+
</th>
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface ApplicationsListProps {
|
|
204
|
+
apps: AppRow[]
|
|
205
|
+
onSelect: (key: string) => void
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function ApplicationsList({ apps, onSelect }: ApplicationsListProps) {
|
|
209
|
+
const [textFilter, setTextFilter] = useState('')
|
|
210
|
+
const [fHealth, setFHealth] = useState<Set<AppHealth>>(new Set())
|
|
211
|
+
const [fEnv, setFEnv] = useState<Set<string>>(new Set())
|
|
212
|
+
const [fSource, setFSource] = useState<Set<AppSource>>(new Set())
|
|
213
|
+
const [fClass, setFClass] = useState<Set<AppWorkloadClass>>(new Set())
|
|
214
|
+
const [fType, setFType] = useState<Set<AppCategory>>(new Set())
|
|
215
|
+
const [showSystem, setShowSystem] = useState(false)
|
|
216
|
+
const [sort, setSort] = useState<{ key: SortKey; dir: SortDir } | null>(null)
|
|
217
|
+
|
|
218
|
+
// Env tokens this CLUSTER proved (identity classifications on the wire) feed
|
|
219
|
+
// the namespace heuristic, so sibling-less rows in discovered env namespaces
|
|
220
|
+
// namespace still label without any hardcoded vocabulary.
|
|
221
|
+
const discoveredEnvs = useMemo(() => new Set(apps.map((a) => a.identity?.env).filter((e): e is string => !!e)), [apps])
|
|
222
|
+
const allRaw = useMemo<AppEntry[]>(() => apps.map((a) => buildEntry(a, discoveredEnvs)), [apps, discoveredEnvs])
|
|
223
|
+
// System namespaces are filtered before facet counts so the counts reflect
|
|
224
|
+
// what the user is actually looking at (consistent with the other facets).
|
|
225
|
+
// An app counts as system only when EVERY workload namespace is system —
|
|
226
|
+
// hiding a partly-user app would be worse than showing a partly-system one.
|
|
227
|
+
const isSystemApp = (e: AppEntry) => e.namespaces.length > 0 && e.namespaces.every(isSystemNamespace)
|
|
228
|
+
const all = useMemo(() => (showSystem ? allRaw : allRaw.filter((e) => !isSystemApp(e))), [allRaw, showSystem])
|
|
229
|
+
const systemCount = useMemo(() => allRaw.filter(isSystemApp).length, [allRaw])
|
|
230
|
+
|
|
231
|
+
const entries = useMemo(() => {
|
|
232
|
+
const t = textFilter.trim().toLowerCase()
|
|
233
|
+
const filtered = all.filter((e) => {
|
|
234
|
+
if (t && !searchTextForEntry(e).includes(t)) return false
|
|
235
|
+
if (fHealth.size && !fHealth.has(e.health)) return false
|
|
236
|
+
// Inclusive: a mixed app matches the filter of ANY class it contains.
|
|
237
|
+
if (fClass.size && !e.classSet.some((c) => fClass.has(c))) return false
|
|
238
|
+
if (fType.size && !fType.has(e.category)) return false
|
|
239
|
+
if (fSource.size && !fSource.has(e.source)) return false
|
|
240
|
+
if (fEnv.size && !fEnv.has(e.env || 'none')) return false
|
|
241
|
+
return true
|
|
242
|
+
})
|
|
243
|
+
if (sort) {
|
|
244
|
+
const factor = sort.dir === 'asc' ? 1 : -1
|
|
245
|
+
filtered.sort((a, b) => compareEntries(a, b, sort.key) * factor || a.row.name.localeCompare(b.row.name))
|
|
246
|
+
} else {
|
|
247
|
+
filtered.sort((a, b) => (HEALTH_RANK[b.health] ?? 0) - (HEALTH_RANK[a.health] ?? 0) || a.row.name.localeCompare(b.row.name))
|
|
248
|
+
}
|
|
249
|
+
return filtered
|
|
250
|
+
}, [all, textFilter, fHealth, fEnv, fSource, fClass, fType, sort])
|
|
251
|
+
|
|
252
|
+
// ── App groups ──────────────────────────────────────────────────────────────
|
|
253
|
+
// Instances sharing a wire `identity` fold into one ladder row (foldAppGroups).
|
|
254
|
+
// THE COLLAPSE EXPERIMENT: default collapsed, contingent on (a) text search
|
|
255
|
+
// auto-expanding into hidden instances, (b) instance rows one chevron away,
|
|
256
|
+
// (c) the group chip visibly carrying confidence. If heuristic precision
|
|
257
|
+
// disappoints in the field, default-expand by seeding expandedGroups with
|
|
258
|
+
// every group key instead of an empty set.
|
|
259
|
+
const FAMILY_AUTO_EXPAND_ON_SEARCH = true
|
|
260
|
+
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
|
|
261
|
+
const toggleGroup = (key: string) =>
|
|
262
|
+
setExpandedGroups((s) => {
|
|
263
|
+
const n = new Set(s)
|
|
264
|
+
n.has(key) ? n.delete(key) : n.add(key)
|
|
265
|
+
return n
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
const visibleRows = useMemo<FoldedRow<AppEntry>[]>(
|
|
269
|
+
() => foldAppGroups(entries, expandedGroups, FAMILY_AUTO_EXPAND_ON_SEARCH && textFilter.trim() !== ''),
|
|
270
|
+
[entries, expandedGroups, textFilter],
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
// Row keyboard navigation — same contract as the Resources table: j/k or
|
|
274
|
+
// arrows move a highlight, g g / G jump, Enter opens, Escape clears the
|
|
275
|
+
// highlight. The search box hands off via ArrowDown.
|
|
276
|
+
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
|
277
|
+
const rowsRef = useRef(visibleRows)
|
|
278
|
+
rowsRef.current = visibleRows
|
|
279
|
+
useEffect(() => setHighlightedIndex(-1), [visibleRows])
|
|
280
|
+
const firstOpenableVisibleRow = useCallback(() => {
|
|
281
|
+
const first = rowsRef.current[0]
|
|
282
|
+
if (!first) return null
|
|
283
|
+
return first.kind === 'group' ? first.cells[0]?.firstKey ?? null : first.entry.row.key
|
|
284
|
+
}, [])
|
|
285
|
+
const moveHighlight = (delta: number) =>
|
|
286
|
+
setHighlightedIndex((i) => Math.min(Math.max(i + delta, 0), rowsRef.current.length - 1))
|
|
287
|
+
useRegisterShortcuts([
|
|
288
|
+
{ id: 'applications-nav-down', keys: 'j', description: 'Next row', category: 'Table', scope: 'applications', handler: () => moveHighlight(1) },
|
|
289
|
+
{ id: 'applications-nav-down-arrow', keys: 'ArrowDown', description: 'Next row', category: 'Table', scope: 'applications', handler: () => moveHighlight(1) },
|
|
290
|
+
{ id: 'applications-nav-up', keys: 'k', description: 'Previous row', category: 'Table', scope: 'applications', handler: () => moveHighlight(-1) },
|
|
291
|
+
{ id: 'applications-nav-up-arrow', keys: 'ArrowUp', description: 'Previous row', category: 'Table', scope: 'applications', handler: () => moveHighlight(-1) },
|
|
292
|
+
{ id: 'applications-nav-top', keys: 'g g', description: 'Jump to first row', category: 'Table', scope: 'applications', handler: () => setHighlightedIndex(rowsRef.current.length > 0 ? 0 : -1) },
|
|
293
|
+
{ id: 'applications-nav-bottom', keys: 'G', description: 'Jump to last row', category: 'Table', scope: 'applications', handler: () => setHighlightedIndex(rowsRef.current.length - 1) },
|
|
294
|
+
{
|
|
295
|
+
id: 'applications-open', keys: 'Enter', description: 'Open application', category: 'Table', scope: 'applications',
|
|
296
|
+
handler: () => {
|
|
297
|
+
const r = rowsRef.current[highlightedIndex]
|
|
298
|
+
if (!r) return
|
|
299
|
+
// Enter on a group toggles it; on an instance, opens it.
|
|
300
|
+
if (r.kind === 'group') toggleGroup(r.key)
|
|
301
|
+
else onSelect(r.entry.row.key)
|
|
302
|
+
},
|
|
303
|
+
enabled: highlightedIndex >= 0,
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
id: 'applications-escape', keys: 'Escape', description: 'Clear row highlight', category: 'Table', scope: 'applications',
|
|
307
|
+
handler: () => setHighlightedIndex(-1),
|
|
308
|
+
enabled: highlightedIndex >= 0,
|
|
309
|
+
},
|
|
310
|
+
])
|
|
311
|
+
|
|
312
|
+
const counts = useMemo(() => {
|
|
313
|
+
const health: Record<string, number> = {}
|
|
314
|
+
const env: Record<string, number> = {}
|
|
315
|
+
const source: Record<string, number> = {}
|
|
316
|
+
const workloadClass: Record<string, number> = {}
|
|
317
|
+
const category: Record<string, number> = {}
|
|
318
|
+
for (const e of all) {
|
|
319
|
+
health[e.health] = (health[e.health] ?? 0) + 1
|
|
320
|
+
for (const c of e.classSet) workloadClass[c] = (workloadClass[c] ?? 0) + 1
|
|
321
|
+
source[e.source] = (source[e.source] ?? 0) + 1
|
|
322
|
+
category[e.category] = (category[e.category] ?? 0) + 1
|
|
323
|
+
env[e.env || 'none'] = (env[e.env || 'none'] ?? 0) + 1
|
|
324
|
+
}
|
|
325
|
+
return { health, env, source, workloadClass, category }
|
|
326
|
+
}, [all])
|
|
327
|
+
|
|
328
|
+
const toggle = <T,>(set: Set<T>, setter: (s: Set<T>) => void, v: T) => {
|
|
329
|
+
const next = new Set(set)
|
|
330
|
+
next.has(v) ? next.delete(v) : next.add(v)
|
|
331
|
+
setter(next)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// asc → desc → off (null = default health-worst-first sort).
|
|
335
|
+
const onSort = (key: SortKey) => {
|
|
336
|
+
setSort((prev) => {
|
|
337
|
+
if (!prev || prev.key !== key) return { key, dir: 'asc' }
|
|
338
|
+
if (prev.dir === 'asc') return { key, dir: 'desc' }
|
|
339
|
+
return null
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const total = all.length
|
|
344
|
+
const envOptions = Object.entries(counts.env)
|
|
345
|
+
.sort((a, b) => (envRank(b[0] === 'none' ? undefined : b[0]) ?? -1) - (envRank(a[0] === 'none' ? undefined : a[0]) ?? -1))
|
|
346
|
+
.map(([env, count]) => ({ value: env, label: env === 'none' ? 'unlabeled' : env, count }))
|
|
347
|
+
|
|
348
|
+
return (
|
|
349
|
+
<div className="flex w-full flex-1 flex-col gap-4">
|
|
350
|
+
{/* Health spectrum hero */}
|
|
351
|
+
<div className="flex flex-col gap-1.5 rounded-md border border-theme-border bg-theme-surface px-4 py-3">
|
|
352
|
+
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
|
|
353
|
+
<span className="font-medium text-theme-text-primary">
|
|
354
|
+
{entries.length < total ? `${entries.length} of ${total} applications` : pluralize(total, 'application')}
|
|
355
|
+
</span>
|
|
356
|
+
{HEALTH_ORDER.map((h) => (counts.health[h] ? <span key={h} className={HEALTH_META[h].text}>{HEALTH_META[h].label} {counts.health[h]}</span> : null))}
|
|
357
|
+
<span className="ml-auto text-theme-text-tertiary">
|
|
358
|
+
{sort ? `Sorted by ${sort.key} ${sort.dir === 'asc' ? '↑' : '↓'}` : 'Sorted by status'}
|
|
359
|
+
</span>
|
|
360
|
+
</div>
|
|
361
|
+
<div className="flex h-2 w-full overflow-hidden rounded-full bg-theme-hover">
|
|
362
|
+
{HEALTH_ORDER.map((h) => (counts.health[h] ? <span key={h} className={HEALTH_META[h].bar} style={{ width: `${(counts.health[h] / total) * 100}%` }} title={`${HEALTH_META[h].label} ${counts.health[h]}`} /> : null))}
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
|
|
366
|
+
<SearchBox
|
|
367
|
+
value={textFilter}
|
|
368
|
+
onChange={setTextFilter}
|
|
369
|
+
scope="applications"
|
|
370
|
+
shortcutId="applications-search"
|
|
371
|
+
className="max-w-md"
|
|
372
|
+
onEnter={() => {
|
|
373
|
+
const key = highlightedIndex >= 0 && rowsRef.current[highlightedIndex]?.kind === 'instance'
|
|
374
|
+
? (rowsRef.current[highlightedIndex] as Extract<FoldedRow<AppEntry>, { kind: 'instance' }>).entry.row.key
|
|
375
|
+
: firstOpenableVisibleRow()
|
|
376
|
+
if (key) onSelect(key)
|
|
377
|
+
}}
|
|
378
|
+
onArrowDown={() => {
|
|
379
|
+
if (visibleRows.length > 0) setHighlightedIndex(0)
|
|
380
|
+
}}
|
|
381
|
+
/>
|
|
382
|
+
|
|
383
|
+
<div className="flex w-full gap-4">
|
|
384
|
+
{/* Facet rail */}
|
|
385
|
+
<aside className="hidden w-[200px] shrink-0 flex-col gap-4 lg:flex">
|
|
386
|
+
<Facet title="Availability" options={HEALTH_ORDER.map((h) => ({ value: h, label: HEALTH_META[h].label, count: counts.health[h] ?? 0, tone: HEALTH_META[h].text }))} selected={fHealth} onToggle={(v) => toggle(fHealth, setFHealth, v)} />
|
|
387
|
+
<Facet title="Class" options={CLASS_ORDER.map((c) => ({ value: c, label: CLASS_META[c].label, count: counts.workloadClass[c] ?? 0 }))} selected={fClass} onToggle={(v) => toggle(fClass, setFClass, v)} />
|
|
388
|
+
<Facet title="Type" options={CATEGORY_ORDER.map((c) => ({ value: c, label: CATEGORY_META[c].label, count: counts.category[c] ?? 0, tooltip: CATEGORY_META[c].tooltip }))} selected={fType} onToggle={(v) => toggle(fType, setFType, v)} />
|
|
389
|
+
<Facet title="Environment" info={<EnvHint />} options={envOptions} selected={fEnv} onToggle={(v) => toggle(fEnv, setFEnv, v)} />
|
|
390
|
+
<Facet title="Source" options={SOURCE_ORDER.map((s) => ({ value: s, label: SOURCE_META[s].label, count: counts.source[s] ?? 0 }))} selected={fSource} onToggle={(v) => toggle(fSource, setFSource, v)} />
|
|
391
|
+
{systemCount > 0 && (
|
|
392
|
+
<label className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-xs text-theme-text-secondary hover:bg-theme-hover">
|
|
393
|
+
<input type="checkbox" checked={showSystem} onChange={(e) => setShowSystem(e.target.checked)} className="accent-skyhook-500" />
|
|
394
|
+
<span>Show system namespaces</span>
|
|
395
|
+
<span className="ml-auto font-mono tabular-nums text-theme-text-tertiary">{systemCount}</span>
|
|
396
|
+
</label>
|
|
397
|
+
)}
|
|
398
|
+
</aside>
|
|
399
|
+
|
|
400
|
+
{/* Table */}
|
|
401
|
+
<div className="min-w-0 flex-1">
|
|
402
|
+
{entries.length === 0 ? (
|
|
403
|
+
<EmptyState tone="filtered" variant="card" headline="No applications match the filters" body="Clear the filters above." />
|
|
404
|
+
) : (
|
|
405
|
+
<div className="overflow-hidden rounded-md border border-theme-border">
|
|
406
|
+
<table className="w-full text-left text-sm">
|
|
407
|
+
<thead>
|
|
408
|
+
<tr className="border-b border-theme-border bg-theme-base">
|
|
409
|
+
<SortHeader label="Application" sortKey="name" sort={sort} onSort={onSort} className="pl-3 pr-2" />
|
|
410
|
+
<th className="px-2 py-2 text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">Namespace</th>
|
|
411
|
+
<th className="px-2 py-2 text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">Env</th>
|
|
412
|
+
<th className="px-2 py-2 text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">Class</th>
|
|
413
|
+
<SortHeader label="Ready" sortKey="ready" sort={sort} onSort={onSort} />
|
|
414
|
+
<SortHeader label="Version" sortKey="version" sort={sort} onSort={onSort} />
|
|
415
|
+
<th className="px-2 py-2 text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">Workloads</th>
|
|
416
|
+
<th className="w-8" />
|
|
417
|
+
</tr>
|
|
418
|
+
</thead>
|
|
419
|
+
<tbody>
|
|
420
|
+
{visibleRows.map((r, idx) => r.kind === 'group' ? (
|
|
421
|
+
<tr
|
|
422
|
+
key={`group:${r.key}`}
|
|
423
|
+
ref={idx === highlightedIndex ? (el) => el?.scrollIntoView({ block: 'nearest' }) : undefined}
|
|
424
|
+
aria-expanded={r.expanded}
|
|
425
|
+
className={clsx(
|
|
426
|
+
'group/row cursor-pointer border-b-subtle',
|
|
427
|
+
idx === highlightedIndex ? 'selection selection-ring' : 'hover:bg-theme-hover',
|
|
428
|
+
)}
|
|
429
|
+
onClick={() => toggleGroup(r.key)}
|
|
430
|
+
>
|
|
431
|
+
<td className="py-2.5 pl-3 pr-2">
|
|
432
|
+
<span className="flex items-center gap-2">
|
|
433
|
+
<ChevronRight className={clsx('h-3.5 w-3.5 shrink-0 text-theme-text-tertiary transition-transform', r.expanded && 'rotate-90')} aria-hidden />
|
|
434
|
+
<Tooltip content={HEALTH_META[r.health].label} delay={150}>
|
|
435
|
+
<span className="inline-flex"><StatusDot tone={mapHealthToTone(r.health)} /></span>
|
|
436
|
+
</Tooltip>
|
|
437
|
+
<span className="truncate font-semibold text-theme-text-primary">{r.label}</span>
|
|
438
|
+
<Tooltip
|
|
439
|
+
content={<AppIdentityTooltip identityKey={r.label} members={r.members.map((m) => ({ name: m.row.name, env: m.row.identity!.env, confidence: m.row.identity!.confidence, evidence: m.row.identity!.evidence }))} />}
|
|
440
|
+
delay={150}
|
|
441
|
+
>
|
|
442
|
+
<span className={`${CHIP} ${r.confidence === 'high' ? CHIP_TONE.emerald : CHIP_TONE.neutral}`}>
|
|
443
|
+
<Layers className="mr-1 h-3 w-3" aria-hidden />{r.cells.length} envs
|
|
444
|
+
</span>
|
|
445
|
+
</Tooltip>
|
|
446
|
+
</span>
|
|
447
|
+
</td>
|
|
448
|
+
<td className="px-2 py-2.5">
|
|
449
|
+
<span className="text-xs text-theme-text-tertiary">{pluralize(r.members.length, 'instance')}</span>
|
|
450
|
+
</td>
|
|
451
|
+
<td className="px-2 py-2.5">
|
|
452
|
+
{/* The ladder: env-ordered cells; click drills into that env's instance. */}
|
|
453
|
+
<span className="flex flex-wrap items-center gap-1">
|
|
454
|
+
{/* Ladder cells scale-capped: a handful inline, the
|
|
455
|
+
rest behind "+N" (expand shows every instance). */}
|
|
456
|
+
{r.cells.slice(0, 4).map((c) => (
|
|
457
|
+
<Tooltip key={c.env} content={`${c.env}${c.version ? ` · ${c.version}` : ''}${c.count > 1 ? ` · ${c.count} instances — expand to choose` : ' — open'}`} delay={150}>
|
|
458
|
+
<button
|
|
459
|
+
type="button"
|
|
460
|
+
onClick={(ev) => {
|
|
461
|
+
ev.stopPropagation()
|
|
462
|
+
if (c.count > 1) toggleGroup(r.key)
|
|
463
|
+
else onSelect(c.firstKey)
|
|
464
|
+
}}
|
|
465
|
+
className={`${CHIP} ${CHIP_TONE.neutral} gap-1 hover:bg-theme-hover`}
|
|
466
|
+
>
|
|
467
|
+
<StatusDot tone={mapHealthToTone(c.health)} />{c.env}
|
|
468
|
+
</button>
|
|
469
|
+
</Tooltip>
|
|
470
|
+
))}
|
|
471
|
+
{r.cells.length > 4 && (
|
|
472
|
+
<Tooltip content={`${r.cells.length - 4} more environments — expand to see all instances`} delay={150}>
|
|
473
|
+
<button
|
|
474
|
+
type="button"
|
|
475
|
+
onClick={(ev) => { ev.stopPropagation(); toggleGroup(r.key) }}
|
|
476
|
+
className={`${CHIP} ${CHIP_TONE.muted} hover:bg-theme-hover`}
|
|
477
|
+
>
|
|
478
|
+
+{r.cells.length - 4}
|
|
479
|
+
</button>
|
|
480
|
+
</Tooltip>
|
|
481
|
+
)}
|
|
482
|
+
</span>
|
|
483
|
+
</td>
|
|
484
|
+
<td className="px-2 py-2.5"><ClassBadge workloadClass={r.workloadClass} composition={r.classComposition} /></td>
|
|
485
|
+
<td className="px-2 py-2.5"><ReadyBar ready={r.ready} desired={r.desired} /></td>
|
|
486
|
+
<td className="px-2 py-2.5">
|
|
487
|
+
{r.lag ? (
|
|
488
|
+
<Tooltip content={`Promotion lag: ${r.lag} (${r.cells.filter((c) => c.version).map((c) => `${c.env}=${c.version}`).join(', ')})`} delay={150}>
|
|
489
|
+
<span className={`${CHIP} ${CHIP_TONE.amber}`}>{r.lag}</span>
|
|
490
|
+
</Tooltip>
|
|
491
|
+
) : (
|
|
492
|
+
<span className="text-theme-text-tertiary">—</span>
|
|
493
|
+
)}
|
|
494
|
+
</td>
|
|
495
|
+
<td className="px-2 py-2.5">
|
|
496
|
+
<span className="text-xs text-theme-text-secondary">{Object.entries(r.kinds).map(([k, n]) => pluralize(n, k)).join(' · ')}</span>
|
|
497
|
+
</td>
|
|
498
|
+
<td className="pr-2 text-right" />
|
|
499
|
+
</tr>
|
|
500
|
+
) : ((e) => (
|
|
501
|
+
<tr
|
|
502
|
+
key={e.row.key}
|
|
503
|
+
ref={idx === highlightedIndex ? (el) => el?.scrollIntoView({ block: 'nearest' }) : undefined}
|
|
504
|
+
className={clsx(
|
|
505
|
+
'group/row cursor-pointer border-b-subtle',
|
|
506
|
+
idx === highlightedIndex ? 'selection selection-ring' : 'hover:bg-theme-hover',
|
|
507
|
+
)}
|
|
508
|
+
onClick={() => onSelect(e.row.key)}
|
|
509
|
+
>
|
|
510
|
+
<td className={clsx('py-2.5 pr-2', r.child ? 'pl-10' : 'pl-3')}>
|
|
511
|
+
<span className="flex items-center gap-2">
|
|
512
|
+
<Tooltip content={HEALTH_META[e.health].label} delay={150}>
|
|
513
|
+
<span className="inline-flex"><StatusDot tone={mapHealthToTone(e.health)} /></span>
|
|
514
|
+
</Tooltip>
|
|
515
|
+
<span className="truncate font-medium text-theme-text-primary">{e.row.name}</span>
|
|
516
|
+
<ProvenanceBadge tier={e.row.tier} appKey={e.row.key} confidence={e.row.confidence} />
|
|
517
|
+
<CategoryChip category={e.category} addonReason={e.row.addonReason} />
|
|
518
|
+
</span>
|
|
519
|
+
</td>
|
|
520
|
+
<td className="px-2 py-2.5">
|
|
521
|
+
{e.namespace ? (
|
|
522
|
+
<span className="truncate font-mono text-xs text-theme-text-secondary">{e.namespace}</span>
|
|
523
|
+
) : e.namespaces.length > 1 ? (
|
|
524
|
+
<Tooltip content={e.namespaces.join(', ')} delay={150}>
|
|
525
|
+
<span className="text-xs text-theme-text-secondary">{e.namespaces.length} namespaces</span>
|
|
526
|
+
</Tooltip>
|
|
527
|
+
) : (
|
|
528
|
+
<span className="text-theme-text-tertiary">—</span>
|
|
529
|
+
)}
|
|
530
|
+
</td>
|
|
531
|
+
<td className="px-2 py-2.5">
|
|
532
|
+
{e.env ? (
|
|
533
|
+
e.envInferred ? (
|
|
534
|
+
<Tooltip content={`Inferred from namespace "${e.namespace || e.env}" — confirm with an environment label.`} delay={150}>
|
|
535
|
+
<span className={`${CHIP} italic ${CHIP_TONE.muted}`}>~{e.env}</span>
|
|
536
|
+
</Tooltip>
|
|
537
|
+
) : (
|
|
538
|
+
<span className={`${CHIP} ${CHIP_TONE.neutral}`}>{e.env}</span>
|
|
539
|
+
)
|
|
540
|
+
) : (
|
|
541
|
+
<Tooltip content={<EnvHint unlabeled />} delay={300}>
|
|
542
|
+
<span className="cursor-default text-theme-text-tertiary">—</span>
|
|
543
|
+
</Tooltip>
|
|
544
|
+
)}
|
|
545
|
+
</td>
|
|
546
|
+
<td className="px-2 py-2.5"><ClassBadge workloadClass={e.workloadClass} composition={e.classComposition} /></td>
|
|
547
|
+
<td className="px-2 py-2.5"><ReadyBar ready={e.ready} desired={e.desired} /></td>
|
|
548
|
+
<td className="px-2 py-2.5">
|
|
549
|
+
<VersionInfo app={e.row} variant="cell" />
|
|
550
|
+
</td>
|
|
551
|
+
<td className="px-2 py-2.5">
|
|
552
|
+
{Object.keys(e.kinds).length === 0 ? (
|
|
553
|
+
<span className="text-xs text-theme-text-tertiary">—</span>
|
|
554
|
+
) : (
|
|
555
|
+
<span className="text-xs text-theme-text-secondary">{Object.entries(e.kinds).map(([k, n]) => pluralize(n, k)).join(' · ')}</span>
|
|
556
|
+
)}
|
|
557
|
+
</td>
|
|
558
|
+
<td className="pr-2 text-right"><ChevronRight className="inline h-4 w-4 text-theme-text-tertiary" /></td>
|
|
559
|
+
</tr>
|
|
560
|
+
))(r.entry))}
|
|
561
|
+
</tbody>
|
|
562
|
+
</table>
|
|
563
|
+
</div>
|
|
564
|
+
)}
|
|
565
|
+
</div>
|
|
566
|
+
</div>
|
|
567
|
+
</div>
|
|
568
|
+
)
|
|
569
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { HEALTH_META } from '../../utils/applications'
|
|
2
|
+
|
|
3
|
+
/** Ready/desired progress bar shared by the Applications list and detail —
|
|
4
|
+
* colors come from HEALTH_META so the bar can't drift from the health system. */
|
|
5
|
+
export function ReadyBar({ ready, desired, width = 'w-12' }: { ready: number; desired: number; width?: string }) {
|
|
6
|
+
if (desired <= 0) {
|
|
7
|
+
return <span className="font-mono text-xs tabular-nums text-theme-text-tertiary">—</span>
|
|
8
|
+
}
|
|
9
|
+
const pct = Math.min(100, Math.round((ready / desired) * 100))
|
|
10
|
+
const ok = ready >= desired
|
|
11
|
+
// Text matches the bar's tier: amber for partial readiness, red only when
|
|
12
|
+
// nothing is ready — partial must not read as fully down.
|
|
13
|
+
const tier = ok ? HEALTH_META.healthy : ready === 0 ? HEALTH_META.unhealthy : HEALTH_META.degraded
|
|
14
|
+
return (
|
|
15
|
+
<span className="inline-flex items-center gap-1.5">
|
|
16
|
+
<span className={`inline-block h-1.5 ${width} rounded-full bg-theme-hover`}>
|
|
17
|
+
<span className={`block h-1.5 rounded-full ${tier.bar}`} style={{ width: `${pct}%` }} />
|
|
18
|
+
</span>
|
|
19
|
+
<span className={`font-mono text-xs tabular-nums ${ok ? 'text-theme-text-secondary' : tier.text}`}>{ready}/{desired || '—'}</span>
|
|
20
|
+
</span>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { ApplicationsList, Facet } from './ApplicationsList'
|
|
2
|
+
export type { ApplicationsListProps } from './ApplicationsList'
|
|
3
|
+
export { ApplicationDetail } from './ApplicationDetail'
|
|
4
|
+
export type { ApplicationDetailProps, SelectedAppWorkload, AppIdentityInstance } from './ApplicationDetail'
|
|
5
|
+
export { CenteredEmpty } from '../ui/CenteredEmpty'
|
|
6
|
+
export { ProvenanceBadge, ClassBadge, CategoryChip, VersionInfo } from './AppChips'
|
|
7
|
+
export { ProvenanceTooltip, CategoryTooltip, VersionTooltip, AppIdentityTooltip } from './AppTooltips'
|
|
8
|
+
export { ReadyBar } from './ReadyBar'
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { useState, useMemo, useRef, useEffect, type Dispatch, type SetStateAction } from 'react'
|
|
2
|
-
import { ShieldAlert, AlertTriangle, ChevronRight, CheckCircle2,
|
|
2
|
+
import { ShieldAlert, AlertTriangle, ChevronRight, CheckCircle2, ExternalLink, MoreHorizontal, EyeOff, Layers } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
4
|
import type { AuditFinding } from './AuditAlerts'
|
|
5
5
|
import { SEVERITY_TEXT, BP_CATEGORY_BADGE, DEFAULT_BADGE_COLOR } from '../../utils/badge-colors'
|
|
6
6
|
import { EmptyState } from '../ui/EmptyState'
|
|
7
|
+
import { SearchBox } from '../ui/SearchBox'
|
|
7
8
|
import { FilterPill } from '../ui/FilterPill'
|
|
8
9
|
import { pluralize } from '../../utils/pluralize'
|
|
9
10
|
|
|
@@ -62,7 +63,6 @@ export function AuditFindingsTable({ groups, findings, checks, onResourceClick,
|
|
|
62
63
|
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
|
63
64
|
const [expandedNS, setExpandedNS] = useState<Set<string>>(new Set())
|
|
64
65
|
const [groupByNS, setGroupByNS] = useState(false)
|
|
65
|
-
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
66
66
|
|
|
67
67
|
const toggleInSet = (setter: Dispatch<SetStateAction<Set<string>>>, value: string) => {
|
|
68
68
|
setter(prev => {
|
|
@@ -84,18 +84,6 @@ export function AuditFindingsTable({ groups, findings, checks, onResourceClick,
|
|
|
84
84
|
setSearchTerm('')
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
// "/" keyboard shortcut to focus search
|
|
88
|
-
useEffect(() => {
|
|
89
|
-
const handler = (e: KeyboardEvent) => {
|
|
90
|
-
if (e.key === '/' && !e.ctrlKey && !e.metaKey && document.activeElement?.tagName !== 'INPUT') {
|
|
91
|
-
e.preventDefault()
|
|
92
|
-
searchInputRef.current?.focus()
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
document.addEventListener('keydown', handler)
|
|
96
|
-
return () => document.removeEventListener('keydown', handler)
|
|
97
|
-
}, [])
|
|
98
|
-
|
|
99
87
|
// Compute totals from whichever data source we have
|
|
100
88
|
const allFindings = useMemo(() => {
|
|
101
89
|
if (groups) return groups.flatMap(g => g.findings)
|
|
@@ -242,17 +230,7 @@ export function AuditFindingsTable({ groups, findings, checks, onResourceClick,
|
|
|
242
230
|
<SummaryBadge label="Critical" count={dangerCount} color={SEVERITY_TEXT.error} />
|
|
243
231
|
<SummaryBadge label="Warning" count={warningCount} color={SEVERITY_TEXT.warning} />
|
|
244
232
|
|
|
245
|
-
<
|
|
246
|
-
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
|
|
247
|
-
<input
|
|
248
|
-
ref={searchInputRef}
|
|
249
|
-
type="text"
|
|
250
|
-
placeholder="Search... (press /)"
|
|
251
|
-
value={searchTerm}
|
|
252
|
-
onChange={(e) => setSearchTerm(e.target.value)}
|
|
253
|
-
className="w-56 pl-10 pr-4 py-1.5 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-skyhook-500"
|
|
254
|
-
/>
|
|
255
|
-
</div>
|
|
233
|
+
<SearchBox value={searchTerm} onChange={setSearchTerm} scope="audit" shortcutId="audit-search" className="w-64" />
|
|
256
234
|
|
|
257
235
|
<div className="flex-1" />
|
|
258
236
|
|