@countermeasure-platform/web-components 1.3.5 → 1.3.6
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/dist/{component-D5sRm1fq.js → component-D_asjmrt.js} +5 -3
- package/dist/component-D_asjmrt.js.map +1 -0
- package/dist/components/brand/index.d.ts.map +1 -1
- package/dist/components/brand/index.js +11 -5
- package/dist/components/brand/index.js.map +1 -1
- package/dist/components/brand/types.d.ts +2 -0
- package/dist/components/brand/types.d.ts.map +1 -1
- package/dist/icons/index.d.ts +7 -2
- package/dist/icons/index.d.ts.map +1 -1
- package/dist/icons/index.js +7 -2
- package/dist/icons/index.js.map +1 -1
- package/dist/icons/lucide.d.ts +3 -0
- package/dist/icons/lucide.d.ts.map +1 -1
- package/dist/icons/lucide.js +3 -0
- package/dist/icons/lucide.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +126 -125
- package/dist/layout/app-shell.d.ts +7 -0
- package/dist/layout/app-shell.d.ts.map +1 -1
- package/dist/layout/app-shell.js +24 -6
- package/dist/layout/app-shell.js.map +1 -1
- package/dist/layout/core-app-chrome.d.ts.map +1 -1
- package/dist/layout/core-app-chrome.js +8 -5
- package/dist/layout/core-app-chrome.js.map +1 -1
- package/dist/layout/core-app-library-dashboard.d.ts +72 -0
- package/dist/layout/core-app-library-dashboard.d.ts.map +1 -0
- package/dist/layout/core-app-library-dashboard.js +160 -0
- package/dist/layout/core-app-library-dashboard.js.map +1 -0
- package/dist/layout/index.d.ts +2 -0
- package/dist/layout/index.d.ts.map +1 -1
- package/dist/layout/index.js +38 -37
- package/dist/layout/index.js.map +1 -1
- package/dist/react/brand/index.d.ts +3 -1
- package/dist/react/brand/index.d.ts.map +1 -1
- package/dist/react/brand.js +19 -13
- package/dist/react/brand.js.map +1 -1
- package/dist/react/layout/core-app-library-dashboard.d.ts +13 -0
- package/dist/react/layout/core-app-library-dashboard.d.ts.map +1 -0
- package/dist/react/layout/core-app-library-dashboard.js +27 -0
- package/dist/react/layout/core-app-library-dashboard.js.map +1 -0
- package/dist/react/layout/index.d.ts +1 -0
- package/dist/react/layout/index.d.ts.map +1 -1
- package/dist/react/layout/index.js +3 -2
- package/dist/react/primitives/alert.d.ts +1 -1
- package/dist/react/primitives/toast.d.ts +1 -1
- package/dist/react/sidebar.js +1 -1
- package/dist/react.js +97 -96
- package/dist/sidebar/component.d.ts.map +1 -1
- package/dist/sidebar/index.js +1 -1
- package/dist/sidebar/types.d.ts +7 -0
- package/dist/sidebar/types.d.ts.map +1 -1
- package/dist/styles/components/brand.css +22 -0
- package/dist/styles/layout.css +577 -0
- package/dist/styles/sidebar.css +26 -0
- package/package.json +11 -1
- package/src/components/brand/index.ts +22 -4
- package/src/components/brand/types.ts +2 -0
- package/src/icons/icons.test.ts +1 -1
- package/src/icons/index.ts +7 -2
- package/src/icons/lucide.ts +4 -0
- package/src/index.ts +2 -0
- package/src/layout/app-shell.test.ts +76 -0
- package/src/layout/app-shell.ts +38 -7
- package/src/layout/core-app-chrome.test.ts +17 -5
- package/src/layout/core-app-chrome.ts +8 -3
- package/src/layout/core-app-library-dashboard.test.ts +397 -0
- package/src/layout/core-app-library-dashboard.ts +519 -0
- package/src/layout/index.ts +18 -0
- package/src/react/brand/index.test.tsx +10 -0
- package/src/react/brand/index.tsx +25 -4
- package/src/react/layout/core-app-chrome.test.tsx +2 -2
- package/src/react/layout/core-app-library-dashboard.test.tsx +42 -0
- package/src/react/layout/core-app-library-dashboard.tsx +56 -0
- package/src/react/layout/index.ts +6 -0
- package/src/sidebar/component.test.ts +21 -1
- package/src/sidebar/component.ts +14 -8
- package/src/sidebar/types.ts +7 -0
- package/src/styles/components/brand.css +22 -0
- package/src/styles/layout.css +577 -0
- package/src/styles/sidebar.css +26 -0
- package/dist/component-D5sRm1fq.js.map +0 -1
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
import { resolveContainer } from '../primitives/utils'
|
|
2
|
+
|
|
3
|
+
export type CoreAppLibraryMetricTone = 'cyan' | 'orange' | 'violet' | 'neutral'
|
|
4
|
+
export type CoreAppLibraryConnectorStatus = 'active' | 'error' | 'paused' | 'disabled'
|
|
5
|
+
|
|
6
|
+
export interface CoreAppLibraryMetric {
|
|
7
|
+
id: string
|
|
8
|
+
label: string
|
|
9
|
+
value: number | string
|
|
10
|
+
helper?: string
|
|
11
|
+
tone?: CoreAppLibraryMetricTone
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CoreAppLibraryTacticDepth {
|
|
15
|
+
label: string
|
|
16
|
+
value: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CoreAppLibraryFreshnessBucket {
|
|
20
|
+
label: string
|
|
21
|
+
detections: number
|
|
22
|
+
rules?: number
|
|
23
|
+
intel?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CoreAppLibraryConnector {
|
|
27
|
+
id: string
|
|
28
|
+
name: string
|
|
29
|
+
status: CoreAppLibraryConnectorStatus
|
|
30
|
+
schedule: string
|
|
31
|
+
tags: string[]
|
|
32
|
+
lastRun: string
|
|
33
|
+
enabled?: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CoreAppLibraryPipelineSummary {
|
|
37
|
+
connectors: number
|
|
38
|
+
active: number
|
|
39
|
+
error: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CoreAppLibraryDashboardError {
|
|
43
|
+
source?: string
|
|
44
|
+
detail: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface CoreAppLibraryDashboardData {
|
|
48
|
+
title?: string
|
|
49
|
+
subtitle?: string
|
|
50
|
+
metrics?: CoreAppLibraryMetric[]
|
|
51
|
+
tacticDepth?: CoreAppLibraryTacticDepth[]
|
|
52
|
+
freshness?: CoreAppLibraryFreshnessBucket[]
|
|
53
|
+
pipelineSummary?: CoreAppLibraryPipelineSummary
|
|
54
|
+
connectors?: CoreAppLibraryConnector[]
|
|
55
|
+
loading?: boolean
|
|
56
|
+
error?: string
|
|
57
|
+
errors?: CoreAppLibraryDashboardError[]
|
|
58
|
+
generatedAt?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface CoreAppLibraryDashboardMountOptions {
|
|
62
|
+
container: HTMLElement | string
|
|
63
|
+
data?: CoreAppLibraryDashboardData
|
|
64
|
+
className?: string
|
|
65
|
+
clearContainer?: boolean
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface CoreAppLibraryDashboardDomMountOptions {
|
|
69
|
+
root?: ParentNode
|
|
70
|
+
selector?: string
|
|
71
|
+
dataSelector?: string
|
|
72
|
+
data?: CoreAppLibraryDashboardData
|
|
73
|
+
className?: string
|
|
74
|
+
clearContainer?: boolean
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface CoreAppLibraryDashboardMount {
|
|
78
|
+
element: HTMLElement
|
|
79
|
+
update: (data: CoreAppLibraryDashboardData) => void
|
|
80
|
+
destroy: () => void
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface NormalizedDashboardData {
|
|
84
|
+
title: string
|
|
85
|
+
subtitle: string
|
|
86
|
+
metrics: CoreAppLibraryMetric[]
|
|
87
|
+
tacticDepth: CoreAppLibraryTacticDepth[]
|
|
88
|
+
freshness: CoreAppLibraryFreshnessBucket[]
|
|
89
|
+
pipelineSummary: CoreAppLibraryPipelineSummary
|
|
90
|
+
connectors: CoreAppLibraryConnector[]
|
|
91
|
+
loading: boolean
|
|
92
|
+
error?: string
|
|
93
|
+
errors: CoreAppLibraryDashboardError[]
|
|
94
|
+
generatedAt?: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const DEFAULT_DOM_SELECTOR = '[data-core-app-library-dashboard]'
|
|
98
|
+
const DEFAULT_DATA_SELECTOR = '[data-core-app-library-dashboard-data]'
|
|
99
|
+
const DASHBOARD_TITLE_ID_PREFIX = 'cmm-library-dashboard-title'
|
|
100
|
+
let dashboardTitleIdSequence = 0
|
|
101
|
+
|
|
102
|
+
const EMPTY_PIPELINE_SUMMARY: CoreAppLibraryPipelineSummary = {
|
|
103
|
+
connectors: 0,
|
|
104
|
+
active: 0,
|
|
105
|
+
error: 0,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const createElement = <K extends keyof HTMLElementTagNameMap>(
|
|
109
|
+
tagName: K,
|
|
110
|
+
className?: string,
|
|
111
|
+
textContent?: string
|
|
112
|
+
): HTMLElementTagNameMap[K] => {
|
|
113
|
+
const element = document.createElement(tagName)
|
|
114
|
+
if (className !== undefined) element.className = className
|
|
115
|
+
if (textContent !== undefined) element.textContent = textContent
|
|
116
|
+
return element
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const toDisplayValue = (value: number | string): string =>
|
|
120
|
+
typeof value === 'number' ? value.toLocaleString() : value
|
|
121
|
+
|
|
122
|
+
const toNumber = (value: number | undefined): number =>
|
|
123
|
+
Number.isFinite(value) && value !== undefined ? value : 0
|
|
124
|
+
|
|
125
|
+
const createIntegerTicks = (maxValue: number, maxTicks = 5): number[] => {
|
|
126
|
+
const max = Math.max(0, Math.ceil(maxValue))
|
|
127
|
+
if (max === 0) return [0]
|
|
128
|
+
if (max <= maxTicks - 1) {
|
|
129
|
+
return Array.from({ length: max + 1 }, (_, index) => index)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const ticks = new Set<number>([0, max])
|
|
133
|
+
for (let index = 1; index < maxTicks - 1; index += 1) {
|
|
134
|
+
ticks.add(Math.round((max * index) / (maxTicks - 1)))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return Array.from(ticks).sort((left, right) => left - right)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const getDashboardTitleId = (root: HTMLElement): string => {
|
|
141
|
+
const existing = root.getAttribute('aria-labelledby')
|
|
142
|
+
if (existing !== null && existing.length > 0) return existing
|
|
143
|
+
|
|
144
|
+
dashboardTitleIdSequence += 1
|
|
145
|
+
return `${DASHBOARD_TITLE_ID_PREFIX}-${dashboardTitleIdSequence}`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const cloneMetrics = (metrics: CoreAppLibraryMetric[]): CoreAppLibraryMetric[] =>
|
|
149
|
+
metrics.map(metric => ({ ...metric }))
|
|
150
|
+
|
|
151
|
+
const cloneTactics = (tactics: CoreAppLibraryTacticDepth[]): CoreAppLibraryTacticDepth[] =>
|
|
152
|
+
tactics.map(tactic => ({ ...tactic }))
|
|
153
|
+
|
|
154
|
+
const cloneFreshness = (
|
|
155
|
+
buckets: CoreAppLibraryFreshnessBucket[]
|
|
156
|
+
): CoreAppLibraryFreshnessBucket[] => buckets.map(bucket => ({ ...bucket }))
|
|
157
|
+
|
|
158
|
+
const cloneConnectors = (connectors: CoreAppLibraryConnector[]): CoreAppLibraryConnector[] =>
|
|
159
|
+
connectors.map(connector => ({ ...connector, tags: [...connector.tags] }))
|
|
160
|
+
|
|
161
|
+
const cloneErrors = (errors: CoreAppLibraryDashboardError[]): CoreAppLibraryDashboardError[] =>
|
|
162
|
+
errors.map(error => ({ ...error }))
|
|
163
|
+
|
|
164
|
+
const normalizeDashboardData = (
|
|
165
|
+
data: CoreAppLibraryDashboardData | undefined
|
|
166
|
+
): NormalizedDashboardData => ({
|
|
167
|
+
title: data?.title ?? 'Library Dashboard',
|
|
168
|
+
subtitle: data?.subtitle ?? 'Detection content, MITRE coverage, and collection pipeline.',
|
|
169
|
+
metrics: cloneMetrics(data?.metrics ?? []),
|
|
170
|
+
tacticDepth: cloneTactics(data?.tacticDepth ?? []),
|
|
171
|
+
freshness: cloneFreshness(data?.freshness ?? []),
|
|
172
|
+
pipelineSummary: { ...(data?.pipelineSummary ?? EMPTY_PIPELINE_SUMMARY) },
|
|
173
|
+
connectors: cloneConnectors(data?.connectors ?? []),
|
|
174
|
+
loading: data?.loading ?? false,
|
|
175
|
+
...(data?.error !== undefined ? { error: data.error } : {}),
|
|
176
|
+
errors: cloneErrors(data?.errors ?? []),
|
|
177
|
+
...(data?.generatedAt !== undefined ? { generatedAt: data.generatedAt } : {}),
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
const parseDashboardData = (
|
|
181
|
+
element: HTMLElement | null
|
|
182
|
+
): CoreAppLibraryDashboardData | undefined => {
|
|
183
|
+
if (element === null) return undefined
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const parsed: unknown = JSON.parse(element.textContent ?? '')
|
|
187
|
+
return parsed !== null && typeof parsed === 'object'
|
|
188
|
+
? (parsed as CoreAppLibraryDashboardData)
|
|
189
|
+
: undefined
|
|
190
|
+
} catch {
|
|
191
|
+
return undefined
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const appendMetricCard = (container: HTMLElement, metric: CoreAppLibraryMetric): void => {
|
|
196
|
+
const card = createElement('article', 'cmm-library-metric')
|
|
197
|
+
card.dataset.tone = metric.tone ?? 'neutral'
|
|
198
|
+
card.dataset.metricId = metric.id
|
|
199
|
+
|
|
200
|
+
card.append(
|
|
201
|
+
createElement('div', 'cmm-library-metric__label', metric.label),
|
|
202
|
+
createElement('div', 'cmm-library-metric__value', toDisplayValue(metric.value))
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
if (metric.helper !== undefined) {
|
|
206
|
+
card.appendChild(createElement('div', 'cmm-library-metric__helper', metric.helper))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
container.appendChild(card)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const appendEmpty = (
|
|
213
|
+
container: HTMLElement,
|
|
214
|
+
text: string,
|
|
215
|
+
className = 'cmm-library-dashboard__empty'
|
|
216
|
+
): void => {
|
|
217
|
+
container.appendChild(createElement('div', className, text))
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const appendMetricStrip = (container: HTMLElement, metrics: CoreAppLibraryMetric[]): void => {
|
|
221
|
+
const strip = createElement('div', 'cmm-library-dashboard__metrics')
|
|
222
|
+
if (metrics.length === 0) {
|
|
223
|
+
appendEmpty(strip, 'No metrics available')
|
|
224
|
+
container.appendChild(strip)
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (const metric of metrics) {
|
|
229
|
+
appendMetricCard(strip, metric)
|
|
230
|
+
}
|
|
231
|
+
container.appendChild(strip)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const appendDashboardMeta = (
|
|
235
|
+
container: HTMLElement,
|
|
236
|
+
data: Pick<NormalizedDashboardData, 'errors' | 'generatedAt'>
|
|
237
|
+
): void => {
|
|
238
|
+
if (data.errors.length === 0 && data.generatedAt === undefined) return
|
|
239
|
+
|
|
240
|
+
const meta = createElement('div', 'cmm-library-dashboard__meta')
|
|
241
|
+
if (data.generatedAt !== undefined) {
|
|
242
|
+
meta.appendChild(
|
|
243
|
+
createElement('span', 'cmm-library-dashboard__timestamp', `Updated ${data.generatedAt}`)
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
for (const error of data.errors) {
|
|
248
|
+
const detail = error.source !== undefined ? `${error.source}: ${error.detail}` : error.detail
|
|
249
|
+
meta.appendChild(createElement('span', 'cmm-library-dashboard__warning', detail))
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
container.appendChild(meta)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const appendTacticDepthChart = (
|
|
256
|
+
container: HTMLElement,
|
|
257
|
+
tactics: CoreAppLibraryTacticDepth[]
|
|
258
|
+
): void => {
|
|
259
|
+
const panel = createElement('section', 'cmm-library-chart cmm-library-chart--depth')
|
|
260
|
+
const title = createElement('h2', 'cmm-library-chart__title', 'Detection Depth by Tactic')
|
|
261
|
+
const body = createElement('div', 'cmm-library-depth')
|
|
262
|
+
|
|
263
|
+
if (tactics.length === 0) {
|
|
264
|
+
appendEmpty(body, 'No tactic coverage yet', 'cmm-library-chart__empty')
|
|
265
|
+
panel.append(title, body)
|
|
266
|
+
container.appendChild(panel)
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const maxValue = Math.max(1, ...tactics.map(tactic => tactic.value))
|
|
271
|
+
|
|
272
|
+
for (const tactic of tactics) {
|
|
273
|
+
const row = createElement('div', 'cmm-library-depth__row')
|
|
274
|
+
const label = createElement('div', 'cmm-library-depth__label', tactic.label)
|
|
275
|
+
const barTrack = createElement('div', 'cmm-library-depth__track')
|
|
276
|
+
const bar = createElement('span', 'cmm-library-depth__bar')
|
|
277
|
+
bar.style.width = `${Math.max(4, (tactic.value / maxValue) * 100).toFixed(2)}%`
|
|
278
|
+
bar.setAttribute('aria-label', `${tactic.label}: ${tactic.value}`)
|
|
279
|
+
barTrack.appendChild(bar)
|
|
280
|
+
row.append(label, barTrack)
|
|
281
|
+
body.appendChild(row)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const axis = createElement('div', 'cmm-library-depth__axis')
|
|
285
|
+
for (const label of createIntegerTicks(maxValue)) {
|
|
286
|
+
axis.appendChild(createElement('span', undefined, String(label)))
|
|
287
|
+
}
|
|
288
|
+
body.appendChild(axis)
|
|
289
|
+
panel.append(title, body)
|
|
290
|
+
container.appendChild(panel)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const appendFreshnessChart = (
|
|
294
|
+
container: HTMLElement,
|
|
295
|
+
buckets: CoreAppLibraryFreshnessBucket[]
|
|
296
|
+
): void => {
|
|
297
|
+
const panel = createElement('section', 'cmm-library-chart cmm-library-chart--freshness')
|
|
298
|
+
const title = createElement('h2', 'cmm-library-chart__title', 'Content Freshness (30d)')
|
|
299
|
+
const body = createElement('div', 'cmm-library-freshness')
|
|
300
|
+
|
|
301
|
+
if (buckets.length === 0) {
|
|
302
|
+
appendEmpty(body, 'No recent content activity', 'cmm-library-chart__empty')
|
|
303
|
+
panel.append(title, body)
|
|
304
|
+
container.appendChild(panel)
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const plot = createElement('div', 'cmm-library-freshness__plot')
|
|
309
|
+
const maxTotal = Math.max(
|
|
310
|
+
1,
|
|
311
|
+
...buckets.map(bucket => bucket.detections + toNumber(bucket.rules) + toNumber(bucket.intel))
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
for (const bucket of buckets) {
|
|
315
|
+
const column = createElement('div', 'cmm-library-freshness__bucket')
|
|
316
|
+
const stack = createElement('div', 'cmm-library-freshness__stack')
|
|
317
|
+
const total = bucket.detections + toNumber(bucket.rules) + toNumber(bucket.intel)
|
|
318
|
+
stack.style.height = `${Math.max(8, (total / maxTotal) * 100).toFixed(2)}%`
|
|
319
|
+
stack.setAttribute('aria-label', `${bucket.label || 'Freshness bucket'}: ${total}`)
|
|
320
|
+
|
|
321
|
+
const detections = createElement('span', 'cmm-library-freshness__segment')
|
|
322
|
+
detections.dataset.tone = 'cyan'
|
|
323
|
+
detections.style.flexGrow = String(bucket.detections)
|
|
324
|
+
const rules = createElement('span', 'cmm-library-freshness__segment')
|
|
325
|
+
rules.dataset.tone = 'green'
|
|
326
|
+
rules.style.flexGrow = String(toNumber(bucket.rules))
|
|
327
|
+
const intel = createElement('span', 'cmm-library-freshness__segment')
|
|
328
|
+
intel.dataset.tone = 'orange'
|
|
329
|
+
intel.style.flexGrow = String(toNumber(bucket.intel))
|
|
330
|
+
|
|
331
|
+
stack.append(detections, rules, intel)
|
|
332
|
+
column.appendChild(stack)
|
|
333
|
+
if (bucket.label.length > 0) {
|
|
334
|
+
column.appendChild(createElement('span', 'cmm-library-freshness__label', bucket.label))
|
|
335
|
+
}
|
|
336
|
+
plot.appendChild(column)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const axis = createElement('div', 'cmm-library-freshness__axis')
|
|
340
|
+
const axisMax = Math.max(
|
|
341
|
+
1,
|
|
342
|
+
...buckets.map(bucket => bucket.detections + toNumber(bucket.rules) + toNumber(bucket.intel))
|
|
343
|
+
)
|
|
344
|
+
for (const label of createIntegerTicks(axisMax).reverse()) {
|
|
345
|
+
axis.appendChild(createElement('span', undefined, String(label)))
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
body.append(axis, plot)
|
|
349
|
+
panel.append(title, body)
|
|
350
|
+
container.appendChild(panel)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const appendCharts = (
|
|
354
|
+
container: HTMLElement,
|
|
355
|
+
data: Pick<NormalizedDashboardData, 'tacticDepth' | 'freshness'>
|
|
356
|
+
): void => {
|
|
357
|
+
const charts = createElement('div', 'cmm-library-dashboard__charts')
|
|
358
|
+
appendTacticDepthChart(charts, data.tacticDepth)
|
|
359
|
+
appendFreshnessChart(charts, data.freshness)
|
|
360
|
+
container.appendChild(charts)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const connectorTone = (status: CoreAppLibraryConnectorStatus): string => {
|
|
364
|
+
if (status === 'active') return 'success'
|
|
365
|
+
if (status === 'error') return 'error'
|
|
366
|
+
if (status === 'paused') return 'warn'
|
|
367
|
+
return 'neutral'
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const appendConnectorCard = (container: HTMLElement, connector: CoreAppLibraryConnector): void => {
|
|
371
|
+
const card = createElement('article', 'cmm-library-pipeline-card')
|
|
372
|
+
card.dataset.status = connector.status
|
|
373
|
+
|
|
374
|
+
const head = createElement('div', 'cmm-library-pipeline-card__head')
|
|
375
|
+
const nameWrap = createElement('div', 'cmm-library-pipeline-card__name-wrap')
|
|
376
|
+
const glyph = createElement('span', 'cmm-library-pipeline-card__glyph', '')
|
|
377
|
+
glyph.setAttribute('aria-hidden', 'true')
|
|
378
|
+
nameWrap.append(glyph, createElement('h3', 'cmm-library-pipeline-card__name', connector.name))
|
|
379
|
+
|
|
380
|
+
const badge = createElement(
|
|
381
|
+
'span',
|
|
382
|
+
'cmm-library-pipeline-card__badge',
|
|
383
|
+
connector.status[0]?.toUpperCase() + connector.status.slice(1)
|
|
384
|
+
)
|
|
385
|
+
badge.dataset.tone = connectorTone(connector.status)
|
|
386
|
+
head.append(nameWrap, badge)
|
|
387
|
+
|
|
388
|
+
const meta = createElement('div', 'cmm-library-pipeline-card__meta')
|
|
389
|
+
meta.appendChild(createElement('span', undefined, connector.schedule))
|
|
390
|
+
for (const tag of connector.tags) {
|
|
391
|
+
meta.appendChild(createElement('span', undefined, tag))
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const foot = createElement('div', 'cmm-library-pipeline-card__foot')
|
|
395
|
+
foot.append(
|
|
396
|
+
createElement('span', undefined, connector.lastRun),
|
|
397
|
+
createElement('span', undefined, connector.enabled === false ? 'Disabled' : 'Enabled')
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
card.append(head, meta, foot)
|
|
401
|
+
container.appendChild(card)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const appendPipeline = (
|
|
405
|
+
container: HTMLElement,
|
|
406
|
+
data: Pick<NormalizedDashboardData, 'pipelineSummary' | 'connectors'>
|
|
407
|
+
): void => {
|
|
408
|
+
const section = createElement('section', 'cmm-library-dashboard__pipeline')
|
|
409
|
+
const heading = createElement('div', 'cmm-library-dashboard__pipeline-heading')
|
|
410
|
+
heading.appendChild(createElement('h2', 'cmm-library-dashboard__eyebrow', 'Collection Pipeline'))
|
|
411
|
+
|
|
412
|
+
const summary = createElement('div', 'cmm-library-dashboard__pipeline-summary')
|
|
413
|
+
summary.append(
|
|
414
|
+
createElement('span', undefined, `${data.pipelineSummary.connectors} connectors`),
|
|
415
|
+
createElement('span', undefined, `${data.pipelineSummary.active} active`),
|
|
416
|
+
createElement('span', undefined, `${data.pipelineSummary.error} error`)
|
|
417
|
+
)
|
|
418
|
+
heading.appendChild(summary)
|
|
419
|
+
|
|
420
|
+
const grid = createElement('div', 'cmm-library-dashboard__pipeline-grid')
|
|
421
|
+
if (data.connectors.length === 0) {
|
|
422
|
+
appendEmpty(grid, 'No connectors configured')
|
|
423
|
+
} else {
|
|
424
|
+
for (const connector of data.connectors) {
|
|
425
|
+
appendConnectorCard(grid, connector)
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
section.append(heading, grid)
|
|
430
|
+
container.appendChild(section)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const appendDashboardState = (container: HTMLElement, title: string, detail?: string): void => {
|
|
434
|
+
const state = createElement('div', 'cmm-library-dashboard__state')
|
|
435
|
+
state.appendChild(createElement('h2', 'cmm-library-dashboard__state-title', title))
|
|
436
|
+
if (detail !== undefined && detail.length > 0) {
|
|
437
|
+
state.appendChild(createElement('p', 'cmm-library-dashboard__state-detail', detail))
|
|
438
|
+
}
|
|
439
|
+
container.appendChild(state)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const renderDashboard = (root: HTMLElement, data: NormalizedDashboardData): void => {
|
|
443
|
+
const titleId = getDashboardTitleId(root)
|
|
444
|
+
root.setAttribute('aria-labelledby', titleId)
|
|
445
|
+
|
|
446
|
+
const inner = createElement('div', 'cmm-library-dashboard__inner')
|
|
447
|
+
const header = createElement('header', 'cmm-library-dashboard__header')
|
|
448
|
+
const title = createElement('h1', 'cmm-library-dashboard__title', data.title)
|
|
449
|
+
title.id = titleId
|
|
450
|
+
header.append(title, createElement('p', 'cmm-library-dashboard__subtitle', data.subtitle))
|
|
451
|
+
|
|
452
|
+
inner.appendChild(header)
|
|
453
|
+
appendDashboardMeta(inner, data)
|
|
454
|
+
if (data.loading) {
|
|
455
|
+
appendDashboardState(inner, 'Loading library dashboard')
|
|
456
|
+
root.replaceChildren(inner)
|
|
457
|
+
return
|
|
458
|
+
}
|
|
459
|
+
if (data.error !== undefined) {
|
|
460
|
+
appendDashboardState(inner, 'Unable to load dashboard', data.error)
|
|
461
|
+
root.replaceChildren(inner)
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
appendMetricStrip(inner, data.metrics)
|
|
466
|
+
appendCharts(inner, data)
|
|
467
|
+
appendPipeline(inner, data)
|
|
468
|
+
root.replaceChildren(inner)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export function mountCoreAppLibraryDashboard(
|
|
472
|
+
options: CoreAppLibraryDashboardMountOptions
|
|
473
|
+
): CoreAppLibraryDashboardMount {
|
|
474
|
+
const container = resolveContainer(options.container)
|
|
475
|
+
const element = createElement('section', 'cmm-library-dashboard')
|
|
476
|
+
|
|
477
|
+
if (options.className !== undefined && options.className.length > 0) {
|
|
478
|
+
element.classList.add(...options.className.split(/\s+/).filter(Boolean))
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
renderDashboard(element, normalizeDashboardData(options.data))
|
|
482
|
+
|
|
483
|
+
if (options.clearContainer !== false) {
|
|
484
|
+
container.replaceChildren(element)
|
|
485
|
+
} else {
|
|
486
|
+
container.appendChild(element)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return {
|
|
490
|
+
element,
|
|
491
|
+
update: (data: CoreAppLibraryDashboardData) => {
|
|
492
|
+
renderDashboard(element, normalizeDashboardData(data))
|
|
493
|
+
},
|
|
494
|
+
destroy: () => {
|
|
495
|
+
element.remove()
|
|
496
|
+
},
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export function mountCoreAppLibraryDashboardFromDom(
|
|
501
|
+
options: CoreAppLibraryDashboardDomMountOptions = {}
|
|
502
|
+
): CoreAppLibraryDashboardMount | null {
|
|
503
|
+
const root = options.root ?? document
|
|
504
|
+
const container = root.querySelector<HTMLElement>(options.selector ?? DEFAULT_DOM_SELECTOR)
|
|
505
|
+
if (container === null) {
|
|
506
|
+
return null
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const dataElement = root.querySelector<HTMLElement>(options.dataSelector ?? DEFAULT_DATA_SELECTOR)
|
|
510
|
+
const mountOptions: CoreAppLibraryDashboardMountOptions = { container }
|
|
511
|
+
const data = options.data ?? parseDashboardData(dataElement)
|
|
512
|
+
if (data !== undefined) mountOptions.data = data
|
|
513
|
+
if (options.className !== undefined) mountOptions.className = options.className
|
|
514
|
+
if (options.clearContainer !== undefined) {
|
|
515
|
+
mountOptions.clearContainer = options.clearContainer
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return mountCoreAppLibraryDashboard(mountOptions)
|
|
519
|
+
}
|
package/src/layout/index.ts
CHANGED
|
@@ -43,6 +43,24 @@ export type {
|
|
|
43
43
|
CoreAppSidebarPresetOptions,
|
|
44
44
|
CoreAppTopbarPresetOptions,
|
|
45
45
|
} from './core-app-chrome'
|
|
46
|
+
export {
|
|
47
|
+
mountCoreAppLibraryDashboard,
|
|
48
|
+
mountCoreAppLibraryDashboardFromDom,
|
|
49
|
+
} from './core-app-library-dashboard'
|
|
50
|
+
export type {
|
|
51
|
+
CoreAppLibraryConnector,
|
|
52
|
+
CoreAppLibraryConnectorStatus,
|
|
53
|
+
CoreAppLibraryDashboardData,
|
|
54
|
+
CoreAppLibraryDashboardDomMountOptions,
|
|
55
|
+
CoreAppLibraryDashboardError,
|
|
56
|
+
CoreAppLibraryDashboardMount,
|
|
57
|
+
CoreAppLibraryDashboardMountOptions,
|
|
58
|
+
CoreAppLibraryFreshnessBucket,
|
|
59
|
+
CoreAppLibraryMetric,
|
|
60
|
+
CoreAppLibraryMetricTone,
|
|
61
|
+
CoreAppLibraryPipelineSummary,
|
|
62
|
+
CoreAppLibraryTacticDepth,
|
|
63
|
+
} from './core-app-library-dashboard'
|
|
46
64
|
export { PageHeader, createPageHeader } from './page-header'
|
|
47
65
|
export type { PageHeaderConfig, PageHeaderMetadata } from './page-header'
|
|
48
66
|
|
|
@@ -25,4 +25,14 @@ describe('React brand lockup', () => {
|
|
|
25
25
|
expect(lockup?.querySelector('.cmm-diamond')).toBeTruthy()
|
|
26
26
|
expect(lockup?.querySelector('.cmm-brand-lockup__wordmark')).toBeNull()
|
|
27
27
|
})
|
|
28
|
+
|
|
29
|
+
it('supports the Threat Library title-case brand treatment', () => {
|
|
30
|
+
const { container } = render(<BrandLockup subtitle="Platform" wordmarkCase="title" />)
|
|
31
|
+
const lockup = container.querySelector('.cmm-brand-lockup')
|
|
32
|
+
|
|
33
|
+
expect(lockup?.classList.contains('cmm-brand-lockup--title')).toBe(true)
|
|
34
|
+
expect(lockup?.querySelector('.cmm-brand-lockup__counter')?.textContent).toBe('Counter')
|
|
35
|
+
expect(lockup?.querySelector('.cmm-brand-lockup__measure')?.textContent).toBe('Measure')
|
|
36
|
+
expect(lockup?.querySelector('.cmm-brand-lockup__subtitle')?.textContent).toBe('Platform')
|
|
37
|
+
})
|
|
28
38
|
})
|
|
@@ -163,6 +163,8 @@ export function Wordmark({
|
|
|
163
163
|
export interface BrandLockupProps extends React.HTMLAttributes<HTMLSpanElement> {
|
|
164
164
|
markSize?: BrandSize | number
|
|
165
165
|
label?: string
|
|
166
|
+
subtitle?: string
|
|
167
|
+
wordmarkCase?: BrandLockupConfig['wordmarkCase']
|
|
166
168
|
decorative?: boolean
|
|
167
169
|
showWordmark?: boolean
|
|
168
170
|
}
|
|
@@ -171,6 +173,8 @@ export function BrandLockup({
|
|
|
171
173
|
className,
|
|
172
174
|
markSize = 22,
|
|
173
175
|
label = 'CounterMeasure',
|
|
176
|
+
subtitle,
|
|
177
|
+
wordmarkCase = 'uppercase',
|
|
174
178
|
decorative = false,
|
|
175
179
|
showWordmark = true,
|
|
176
180
|
...props
|
|
@@ -180,12 +184,29 @@ export function BrandLockup({
|
|
|
180
184
|
: { role: 'img', 'aria-label': label }
|
|
181
185
|
|
|
182
186
|
return (
|
|
183
|
-
<span
|
|
187
|
+
<span
|
|
188
|
+
className={cn(
|
|
189
|
+
'cmm-brand-lockup',
|
|
190
|
+
wordmarkCase === 'title' && 'cmm-brand-lockup--title',
|
|
191
|
+
className
|
|
192
|
+
)}
|
|
193
|
+
{...accessibilityProps}
|
|
194
|
+
{...props}
|
|
195
|
+
>
|
|
184
196
|
<Diamond size={markSize} decorative />
|
|
185
197
|
{showWordmark ? (
|
|
186
|
-
<span className="cmm-brand-
|
|
187
|
-
<span className="cmm-brand-
|
|
188
|
-
|
|
198
|
+
<span className="cmm-brand-lockup__copy" aria-hidden="true">
|
|
199
|
+
<span className="cmm-brand-lockup__wordmark">
|
|
200
|
+
<span className="cmm-brand-lockup__counter">
|
|
201
|
+
{wordmarkCase === 'title' ? 'Counter' : 'COUNTER'}
|
|
202
|
+
</span>
|
|
203
|
+
<span className="cmm-brand-lockup__measure">
|
|
204
|
+
{wordmarkCase === 'title' ? 'Measure' : 'MEASURE'}
|
|
205
|
+
</span>
|
|
206
|
+
</span>
|
|
207
|
+
{subtitle !== undefined ? (
|
|
208
|
+
<span className="cmm-brand-lockup__subtitle">{subtitle}</span>
|
|
209
|
+
) : null}
|
|
189
210
|
</span>
|
|
190
211
|
) : null}
|
|
191
212
|
</span>
|
|
@@ -28,9 +28,9 @@ describe('CoreAppChrome React wrapper', () => {
|
|
|
28
28
|
|
|
29
29
|
expect(container.querySelector('[data-slot="core-app-chrome"]')).toBeTruthy()
|
|
30
30
|
expect(sidebar?.classList.contains('sidebar--product')).toBe(true)
|
|
31
|
-
expect(sidebar?.getAttribute('data-sidebar')).toBe('
|
|
31
|
+
expect(sidebar?.getAttribute('data-sidebar')).toBe('threat-library')
|
|
32
32
|
expect(active?.classList.contains('sidebar__item--active')).toBe(true)
|
|
33
|
-
expect(topbar?.querySelector('.cmm-topbar__product')?.textContent).toBe('
|
|
33
|
+
expect(topbar?.querySelector('.cmm-topbar__product')?.textContent).toBe('Threat Library')
|
|
34
34
|
expect(topbar?.querySelector('.cmm-topbar__current')?.textContent).toBe('Jobs')
|
|
35
35
|
expect(container.querySelector('.sidebar__scope-current')?.textContent).toBe('All Tenants')
|
|
36
36
|
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { render } from '@testing-library/react'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { CoreAppLibraryDashboard } from './core-app-library-dashboard'
|
|
5
|
+
|
|
6
|
+
describe('CoreAppLibraryDashboard React wrapper', () => {
|
|
7
|
+
it('mounts the vanilla dashboard inside a React host', () => {
|
|
8
|
+
const { container } = render(
|
|
9
|
+
<CoreAppLibraryDashboard
|
|
10
|
+
data={{
|
|
11
|
+
title: 'React Library',
|
|
12
|
+
metrics: [{ id: 'total', label: 'Total', value: '12' }],
|
|
13
|
+
}}
|
|
14
|
+
/>
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
expect(container.querySelector('[data-slot="core-app-library-dashboard"]')).toBeTruthy()
|
|
18
|
+
expect(container.querySelector('.cmm-library-dashboard__title')?.textContent).toBe(
|
|
19
|
+
'React Library'
|
|
20
|
+
)
|
|
21
|
+
expect(container.querySelector('.cmm-library-metric__value')?.textContent).toBe('12')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('updates the existing vanilla mount when data changes', () => {
|
|
25
|
+
const { container, rerender } = render(
|
|
26
|
+
<CoreAppLibraryDashboard data={{ title: 'First Library' }} />
|
|
27
|
+
)
|
|
28
|
+
const dashboard = container.querySelector('.cmm-library-dashboard')
|
|
29
|
+
|
|
30
|
+
expect(container.querySelector('.cmm-library-dashboard__title')?.textContent).toBe(
|
|
31
|
+
'First Library'
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
rerender(<CoreAppLibraryDashboard data={{ title: 'Second Library' }} />)
|
|
35
|
+
|
|
36
|
+
expect(container.querySelector('.cmm-library-dashboard__title')?.textContent).toBe(
|
|
37
|
+
'Second Library'
|
|
38
|
+
)
|
|
39
|
+
expect(container.querySelector('.cmm-library-dashboard')).toBe(dashboard)
|
|
40
|
+
expect(container.querySelectorAll('.cmm-library-dashboard')).toHaveLength(1)
|
|
41
|
+
})
|
|
42
|
+
})
|