@skyhook-io/k8s-ui 1.8.9 → 1.8.11
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 +5 -4
- package/src/assets/gitops/argocd.png +0 -0
- package/src/assets/gitops/flux.svg +7 -0
- package/src/components/applications/AppChips.tsx +10 -0
- package/src/components/applications/ApplicationDetail.test.tsx +569 -0
- package/src/components/applications/ApplicationDetail.tsx +2517 -450
- package/src/components/applications/ApplicationsList.tsx +4 -1
- package/src/components/applications/ApplicationsView.tsx +101 -18
- package/src/components/applications/applications-view.test.tsx +47 -0
- package/src/components/applications/index.ts +1 -1
- package/src/components/audit/AuditAlerts.tsx +14 -10
- package/src/components/checks/ChecksView.tsx +81 -51
- package/src/components/checks/severity.ts +39 -45
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
- package/src/components/compare/CompareResourcePicker.tsx +2 -2
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
- package/src/components/gitops/GitOpsTableView.tsx +14 -59
- package/src/components/gitops/ManagedResourcesList.tsx +8 -11
- package/src/components/gitops/SyncCountdown.tsx +26 -17
- package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
- package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
- package/src/components/gitops/insights/insights-helpers.ts +69 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
- package/src/components/issues/IssuesView.tsx +219 -143
- package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
- package/src/components/issues/ResourceIssuesSection.tsx +28 -128
- package/src/components/issues/index.ts +3 -2
- package/src/components/issues/issue-timing.ts +53 -0
- package/src/components/issues/issues.test.ts +146 -4
- package/src/components/issues/severity.ts +29 -32
- package/src/components/logs/LogCore.tsx +14 -4
- package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
- package/src/components/logs/useLogStream.ts +8 -1
- package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
- package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
- package/src/components/resources/ResourcesSidebar.tsx +70 -52
- package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
- package/src/components/resources/ResourcesView.tsx +243 -53
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
- package/src/components/resources/renderers/JobRenderer.tsx +3 -1
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
- package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
- package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
- package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
- package/src/components/resources/renderers/PodRenderer.tsx +81 -70
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils.ts +31 -0
- package/src/components/shared/DetailShell.tsx +2 -2
- package/src/components/shared/ResourceActionsBar.tsx +140 -108
- package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
- package/src/components/timeline/TimelineList.tsx +371 -354
- package/src/components/timeline/TimelineStrip.test.tsx +194 -0
- package/src/components/timeline/TimelineStrip.tsx +836 -0
- package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
- package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
- package/src/components/timeline/TimelineToolbar.tsx +759 -0
- package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
- package/src/components/timeline/index.ts +23 -0
- package/src/components/timeline/scrubber-math.test.ts +235 -0
- package/src/components/timeline/scrubber-math.ts +249 -0
- package/src/components/timeline/shared.test.tsx +219 -0
- package/src/components/timeline/shared.tsx +232 -447
- package/src/components/timeline/timeline-filters.test.ts +196 -0
- package/src/components/timeline/timeline-filters.ts +180 -0
- package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
- package/src/components/timeline/timeline-lane-sort.ts +77 -0
- package/src/components/timeline/timeline-live.test.ts +131 -0
- package/src/components/timeline/timeline-live.ts +77 -0
- package/src/components/topology/GroupNode.tsx +9 -2
- package/src/components/topology/K8sResourceNode.test.ts +11 -0
- package/src/components/topology/K8sResourceNode.tsx +387 -243
- package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
- package/src/components/topology/TopologyGraph.tsx +89 -16
- package/src/components/topology/TopologySearch.tsx +2 -2
- package/src/components/topology/layout.ts +10 -2
- package/src/components/topology/topology.css +0 -92
- package/src/components/ui/Badge.tsx +5 -0
- package/src/components/ui/BoardSkeleton.tsx +47 -0
- package/src/components/ui/CardSection.tsx +117 -0
- package/src/components/ui/CodeViewer.tsx +2 -2
- package/src/components/ui/Collapse.tsx +59 -0
- package/src/components/ui/ConfirmDialog.tsx +7 -3
- package/src/components/ui/DialogPortal.tsx +31 -9
- package/src/components/ui/Input.tsx +24 -0
- package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
- package/src/components/ui/MultiSelectPicker.tsx +167 -0
- package/src/components/ui/PaneLoader.tsx +10 -2
- package/src/components/ui/ResourceBar.tsx +68 -25
- package/src/components/ui/SearchBox.tsx +18 -4
- package/src/components/ui/SearchPillInput.tsx +2 -2
- package/src/components/ui/Toast.tsx +1 -1
- package/src/components/ui/drawer-components.tsx +39 -26
- package/src/components/ui/index.ts +6 -0
- package/src/components/ui/severity-tone.ts +63 -0
- package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
- package/src/components/workload/WorkloadView.tsx +2682 -472
- package/src/components/workload/index.ts +1 -1
- package/src/theme/variables.css +15 -1
- package/src/types/core.ts +57 -4
- package/src/utils/api-resources.test.ts +54 -0
- package/src/utils/api-resources.ts +68 -1
- package/src/utils/application-topology.test.ts +198 -0
- package/src/utils/application-topology.ts +261 -0
- package/src/utils/applications.test.ts +167 -1
- package/src/utils/applications.ts +1157 -393
- package/src/utils/index.ts +2 -0
- package/src/utils/navigation.test.ts +32 -1
- package/src/utils/navigation.ts +70 -0
- package/src/utils/resource-hierarchy.test.ts +990 -1
- package/src/utils/resource-hierarchy.ts +905 -267
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +202 -1
- package/src/utils/topology-neighborhood.ts +194 -13
- package/src/utils/workflow-execution.test.ts +556 -0
- package/src/utils/workflow-execution.ts +814 -0
- package/src/utils/workload-colors.ts +2 -3
- package/tsconfig.json +2 -2
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { TimelineEvent } from '../../types'
|
|
3
|
+
import {
|
|
4
|
+
matchesActivityFilter,
|
|
5
|
+
matchesTimelineSearch,
|
|
6
|
+
computeActivityStats,
|
|
7
|
+
mergeKindOptions,
|
|
8
|
+
describeActiveFilters,
|
|
9
|
+
TIMELINE_RESOURCE_KINDS,
|
|
10
|
+
} from './timeline-filters'
|
|
11
|
+
|
|
12
|
+
function ev(partial: Partial<TimelineEvent>): TimelineEvent {
|
|
13
|
+
return {
|
|
14
|
+
id: 'id',
|
|
15
|
+
timestamp: '2026-01-01T00:00:00Z',
|
|
16
|
+
source: 'informer',
|
|
17
|
+
kind: 'Pod',
|
|
18
|
+
namespace: 'default',
|
|
19
|
+
name: 'thing',
|
|
20
|
+
eventType: 'update',
|
|
21
|
+
...partial,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Fixtures spanning the semantics the list originally encoded.
|
|
26
|
+
const changeAdd = ev({ source: 'informer', eventType: 'add' })
|
|
27
|
+
const changeUpdate = ev({ source: 'informer', eventType: 'update' })
|
|
28
|
+
const changeDelete = ev({ source: 'informer', eventType: 'delete' })
|
|
29
|
+
const changeUnhealthy = ev({ source: 'informer', eventType: 'update', healthState: 'unhealthy' })
|
|
30
|
+
const changeDegraded = ev({ source: 'historical', eventType: 'update', healthState: 'degraded' })
|
|
31
|
+
const changeHealthy = ev({ source: 'informer', eventType: 'update', healthState: 'healthy' })
|
|
32
|
+
const historicalAdd = ev({ source: 'historical', eventType: 'add' })
|
|
33
|
+
const k8sNormal = ev({ source: 'k8s_event', eventType: 'Normal', reason: 'Scheduled' })
|
|
34
|
+
const k8sWarning = ev({ source: 'k8s_event', eventType: 'Warning', reason: 'BackOff' })
|
|
35
|
+
|
|
36
|
+
describe('matchesActivityFilter (matrix mirrors the list view)', () => {
|
|
37
|
+
it('empty selection passes everything', () => {
|
|
38
|
+
for (const e of [changeAdd, k8sNormal, k8sWarning, changeUnhealthy]) {
|
|
39
|
+
expect(matchesActivityFilter(e, [])).toBe(true)
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("'changes' matches informer + historical, not K8s events", () => {
|
|
44
|
+
expect(matchesActivityFilter(changeAdd, ['changes'])).toBe(true)
|
|
45
|
+
expect(matchesActivityFilter(historicalAdd, ['changes'])).toBe(true)
|
|
46
|
+
expect(matchesActivityFilter(changeUnhealthy, ['changes'])).toBe(true)
|
|
47
|
+
expect(matchesActivityFilter(k8sNormal, ['changes'])).toBe(false)
|
|
48
|
+
expect(matchesActivityFilter(k8sWarning, ['changes'])).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("'k8s_events' matches only K8s Event objects (Normal + Warning)", () => {
|
|
52
|
+
expect(matchesActivityFilter(k8sNormal, ['k8s_events'])).toBe(true)
|
|
53
|
+
expect(matchesActivityFilter(k8sWarning, ['k8s_events'])).toBe(true)
|
|
54
|
+
expect(matchesActivityFilter(changeAdd, ['k8s_events'])).toBe(false)
|
|
55
|
+
expect(matchesActivityFilter(historicalAdd, ['k8s_events'])).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("'warnings' matches only Warning-typed events", () => {
|
|
59
|
+
expect(matchesActivityFilter(k8sWarning, ['warnings'])).toBe(true)
|
|
60
|
+
expect(matchesActivityFilter(k8sNormal, ['warnings'])).toBe(false)
|
|
61
|
+
expect(matchesActivityFilter(changeAdd, ['warnings'])).toBe(false)
|
|
62
|
+
// A change is never eventType 'Warning', so it never counts as a warning.
|
|
63
|
+
expect(matchesActivityFilter(changeUnhealthy, ['warnings'])).toBe(false)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("'unhealthy' matches only changes with unhealthy/degraded health (no K8s events)", () => {
|
|
67
|
+
expect(matchesActivityFilter(changeUnhealthy, ['unhealthy'])).toBe(true)
|
|
68
|
+
expect(matchesActivityFilter(changeDegraded, ['unhealthy'])).toBe(true)
|
|
69
|
+
expect(matchesActivityFilter(changeHealthy, ['unhealthy'])).toBe(false)
|
|
70
|
+
expect(matchesActivityFilter(changeAdd, ['unhealthy'])).toBe(false)
|
|
71
|
+
// A K8s warning is unhealthy-looking but is NOT a change → excluded.
|
|
72
|
+
expect(matchesActivityFilter(k8sWarning, ['unhealthy'])).toBe(false)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('matchesActivityFilter union semantics', () => {
|
|
77
|
+
it('empty array = match everything (no chip selected)', () => {
|
|
78
|
+
for (const e of [changeAdd, historicalAdd, k8sNormal, k8sWarning, changeUnhealthy, changeDegraded]) {
|
|
79
|
+
expect(matchesActivityFilter(e, [])).toBe(true)
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('single key behaves like the old single-value filter', () => {
|
|
84
|
+
expect(matchesActivityFilter(changeAdd, ['changes'])).toBe(true)
|
|
85
|
+
expect(matchesActivityFilter(k8sWarning, ['changes'])).toBe(false)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('multiple keys match the UNION (event matches if it satisfies ANY key)', () => {
|
|
89
|
+
// 'changes' + 'warnings': a change and a K8s warning both pass; a plain
|
|
90
|
+
// Normal K8s event (neither a change nor a warning) does not.
|
|
91
|
+
const sel = ['changes', 'warnings'] as const
|
|
92
|
+
expect(matchesActivityFilter(changeAdd, sel)).toBe(true)
|
|
93
|
+
expect(matchesActivityFilter(k8sWarning, sel)).toBe(true)
|
|
94
|
+
expect(matchesActivityFilter(k8sNormal, sel)).toBe(false)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('an event counted under two selected keys still matches once', () => {
|
|
98
|
+
// k8s_events + warnings both cover a Warning event.
|
|
99
|
+
expect(matchesActivityFilter(k8sWarning, ['k8s_events', 'warnings'])).toBe(true)
|
|
100
|
+
// A Normal event matches k8s_events but not warnings — union still passes it.
|
|
101
|
+
expect(matchesActivityFilter(k8sNormal, ['k8s_events', 'warnings'])).toBe(true)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('matchesTimelineSearch', () => {
|
|
106
|
+
const e = ev({
|
|
107
|
+
name: 'checkout-api',
|
|
108
|
+
kind: 'Deployment',
|
|
109
|
+
namespace: 'payments',
|
|
110
|
+
reason: 'ScalingReplicaSet',
|
|
111
|
+
message: 'Scaled up replica set',
|
|
112
|
+
diff: { summary: 'image bumped to v2', fields: [] },
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('empty term matches everything', () => {
|
|
116
|
+
expect(matchesTimelineSearch(e, '')).toBe(true)
|
|
117
|
+
})
|
|
118
|
+
it('matches name / kind / namespace / reason / message / diff summary, case-insensitive', () => {
|
|
119
|
+
expect(matchesTimelineSearch(e, 'CHECKOUT')).toBe(true)
|
|
120
|
+
expect(matchesTimelineSearch(e, 'deployment')).toBe(true)
|
|
121
|
+
expect(matchesTimelineSearch(e, 'payments')).toBe(true)
|
|
122
|
+
expect(matchesTimelineSearch(e, 'scalingreplica')).toBe(true)
|
|
123
|
+
expect(matchesTimelineSearch(e, 'replica set')).toBe(true)
|
|
124
|
+
expect(matchesTimelineSearch(e, 'v2')).toBe(true)
|
|
125
|
+
})
|
|
126
|
+
it('non-match returns false', () => {
|
|
127
|
+
expect(matchesTimelineSearch(e, 'zzz-nope')).toBe(false)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('computeActivityStats', () => {
|
|
132
|
+
it('counts changes, warnings, unhealthy, deleted from the full array', () => {
|
|
133
|
+
const stats = computeActivityStats([
|
|
134
|
+
changeAdd, changeUpdate, changeDelete, changeUnhealthy, changeDegraded,
|
|
135
|
+
k8sNormal, k8sWarning,
|
|
136
|
+
])
|
|
137
|
+
expect(stats.total).toBe(7)
|
|
138
|
+
// changes = informer/historical: add, update, delete, unhealthy, degraded
|
|
139
|
+
expect(stats.changes).toBe(5)
|
|
140
|
+
expect(stats.warnings).toBe(1)
|
|
141
|
+
expect(stats.unhealthy).toBe(2)
|
|
142
|
+
expect(stats.deleted).toBe(1)
|
|
143
|
+
})
|
|
144
|
+
it('handles empty/undefined', () => {
|
|
145
|
+
expect(computeActivityStats([])).toEqual({ total: 0, changes: 0, k8sEvents: 0, warnings: 0, unhealthy: 0, deleted: 0 })
|
|
146
|
+
expect(computeActivityStats(undefined)).toEqual({ total: 0, changes: 0, k8sEvents: 0, warnings: 0, unhealthy: 0, deleted: 0 })
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
describe('describeActiveFilters', () => {
|
|
151
|
+
const none = { search: '', activityFilter: [] as const, kindFilter: [] as const, showDeleted: true }
|
|
152
|
+
|
|
153
|
+
it('returns empty string when nothing is filtering', () => {
|
|
154
|
+
expect(describeActiveFilters(none)).toBe('')
|
|
155
|
+
// Blank/whitespace search does not count as active.
|
|
156
|
+
expect(describeActiveFilters({ ...none, search: ' ' })).toBe('')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('describes a single active filter', () => {
|
|
160
|
+
expect(describeActiveFilters({ ...none, search: 'dfasdf' })).toBe('search "dfasdf"')
|
|
161
|
+
expect(describeActiveFilters({ ...none, activityFilter: ['changes'] })).toBe('1 activity filter')
|
|
162
|
+
expect(describeActiveFilters({ ...none, kindFilter: ['Pod'] })).toBe('1 kind')
|
|
163
|
+
expect(describeActiveFilters({ ...none, showDeleted: false })).toBe('deleted hidden')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('combines active filters in toolbar order with pluralization', () => {
|
|
167
|
+
expect(
|
|
168
|
+
describeActiveFilters({
|
|
169
|
+
search: 'dfasdf',
|
|
170
|
+
// Canonical: source=changes + Problems toggle → two activity picks.
|
|
171
|
+
activityFilter: ['unhealthy'],
|
|
172
|
+
kindFilter: ['Pod', 'Service', 'ConfigMap'],
|
|
173
|
+
showDeleted: false,
|
|
174
|
+
}),
|
|
175
|
+
).toBe('search "dfasdf" · 2 activity filters · 3 kinds · deleted hidden')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('counts the two-axis Problems toggle as ONE activity filter, not two keys', () => {
|
|
179
|
+
// source=all + Problems expands to keys [warnings, unhealthy] but is one pick;
|
|
180
|
+
// counting raw keys read "2 activity filters" for a single toggle.
|
|
181
|
+
expect(describeActiveFilters({ ...none, activityFilter: ['warnings', 'unhealthy'] })).toBe('1 activity filter')
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
describe('mergeKindOptions', () => {
|
|
186
|
+
it('keeps the seed order and appends discovered CRDs alphabetically', () => {
|
|
187
|
+
const opts = mergeKindOptions(['Zebra', 'Alpha', 'Pod', 'Deployment'])
|
|
188
|
+
expect(opts.slice(0, TIMELINE_RESOURCE_KINDS.length)).toEqual(TIMELINE_RESOURCE_KINDS)
|
|
189
|
+
expect(opts.slice(TIMELINE_RESOURCE_KINDS.length)).toEqual(['Alpha', 'Zebra'])
|
|
190
|
+
})
|
|
191
|
+
it('dedups and drops falsy kinds', () => {
|
|
192
|
+
const opts = mergeKindOptions(['', 'Widget', 'Widget'])
|
|
193
|
+
expect(opts.filter((k) => k === 'Widget')).toHaveLength(1)
|
|
194
|
+
expect(opts).not.toContain('')
|
|
195
|
+
})
|
|
196
|
+
})
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { TimelineEvent } from '../../types'
|
|
2
|
+
import { isChangeEvent, isK8sEvent } from '../../types'
|
|
3
|
+
import { pluralize } from '../../utils/pluralize'
|
|
4
|
+
|
|
5
|
+
// Legacy single-value activity filter. Retained for the home-page deep-link
|
|
6
|
+
// (`initialFilter`) compat path that seeds the multi-select from one preset.
|
|
7
|
+
export type ActivityTypeFilter = 'all' | 'changes' | 'k8s_events' | 'warnings' | 'unhealthy'
|
|
8
|
+
|
|
9
|
+
// The individually-selectable activity-type chips (no 'all' — an empty selection
|
|
10
|
+
// means "everything", so 'all' has no key of its own).
|
|
11
|
+
export type ActivityFilterKey = 'changes' | 'k8s_events' | 'warnings' | 'unhealthy'
|
|
12
|
+
|
|
13
|
+
// Per-key semantics, mirroring the list view's original inline filter:
|
|
14
|
+
// changes → informer/historical resource mutations
|
|
15
|
+
// k8s_events → native K8s Event objects (Normal + Warning)
|
|
16
|
+
// warnings → only K8s Warning events (matches the home-page count)
|
|
17
|
+
// unhealthy → changes whose health state is unhealthy or degraded (no K8s events)
|
|
18
|
+
function matchesActivityKey(event: TimelineEvent, key: ActivityFilterKey): boolean {
|
|
19
|
+
switch (key) {
|
|
20
|
+
case 'changes':
|
|
21
|
+
return isChangeEvent(event)
|
|
22
|
+
case 'k8s_events':
|
|
23
|
+
return isK8sEvent(event)
|
|
24
|
+
case 'warnings':
|
|
25
|
+
return event.eventType === 'Warning'
|
|
26
|
+
case 'unhealthy':
|
|
27
|
+
return isChangeEvent(event) && (event.healthState === 'unhealthy' || event.healthState === 'degraded')
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Predicate for the activity-type chips. Extracted so the list and swimlane can't
|
|
32
|
+
// drift — both filter their event stream through the exact same rules. Multi-select
|
|
33
|
+
// with union semantics: an empty selection matches everything; otherwise an event
|
|
34
|
+
// matches if it satisfies ANY selected key.
|
|
35
|
+
export function matchesActivityFilter(event: TimelineEvent, selected: readonly ActivityFilterKey[]): boolean {
|
|
36
|
+
if (selected.length === 0) return true
|
|
37
|
+
return selected.some((key) => matchesActivityKey(event, key))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Two-axis view over the activity keys. The toolbar presents the filter as a
|
|
42
|
+
// SOURCE pick (Changes = watched resource mutations vs K8s Events = native
|
|
43
|
+
// Event objects) crossed with a PROBLEMS toggle (the severity slice of the
|
|
44
|
+
// picked source).
|
|
45
|
+
//
|
|
46
|
+
// The six reachable states are exactly expressible in the existing key
|
|
47
|
+
// vocabulary, so the URL param, the shared predicate, and both views carry
|
|
48
|
+
// ActivityFilterKey[] unchanged:
|
|
49
|
+
// All → [] All+problems → warnings+unhealthy
|
|
50
|
+
// Changes → [changes] Changes+problems → [unhealthy]
|
|
51
|
+
// K8s Events → [k8s_events] Events+problems → [warnings]
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
export type ActivitySource = 'all' | 'changes' | 'k8s_events'
|
|
55
|
+
|
|
56
|
+
export interface ActivitySelection {
|
|
57
|
+
source: ActivitySource
|
|
58
|
+
problemsOnly: boolean
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function selectionToActivityKeys(sel: ActivitySelection): ActivityFilterKey[] {
|
|
62
|
+
if (sel.problemsOnly) {
|
|
63
|
+
if (sel.source === 'changes') return ['unhealthy']
|
|
64
|
+
if (sel.source === 'k8s_events') return ['warnings']
|
|
65
|
+
return ['warnings', 'unhealthy']
|
|
66
|
+
}
|
|
67
|
+
if (sel.source === 'changes') return ['changes']
|
|
68
|
+
if (sel.source === 'k8s_events') return ['k8s_events']
|
|
69
|
+
return []
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Inverse of selectionToActivityKeys for the six canonical states. Legacy
|
|
73
|
+
// multi-select key sets (pre-two-axis URLs) fall back to the widest reading —
|
|
74
|
+
// showing more than a stale link intended beats silently hiding activity.
|
|
75
|
+
export function activityKeysToSelection(keys: readonly ActivityFilterKey[]): ActivitySelection {
|
|
76
|
+
const set = new Set(keys)
|
|
77
|
+
const eq = (...want: ActivityFilterKey[]) => set.size === want.length && want.every((k) => set.has(k))
|
|
78
|
+
if (keys.length === 0) return { source: 'all', problemsOnly: false }
|
|
79
|
+
if (eq('changes')) return { source: 'changes', problemsOnly: false }
|
|
80
|
+
if (eq('k8s_events')) return { source: 'k8s_events', problemsOnly: false }
|
|
81
|
+
if (eq('unhealthy')) return { source: 'changes', problemsOnly: true }
|
|
82
|
+
if (eq('warnings')) return { source: 'k8s_events', problemsOnly: true }
|
|
83
|
+
if (eq('warnings', 'unhealthy')) return { source: 'all', problemsOnly: true }
|
|
84
|
+
return { source: 'all', problemsOnly: false }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Free-text search predicate. Matches name, kind, namespace, reason, message,
|
|
88
|
+
// and diff summary. Shared so the list and swimlane views match identically.
|
|
89
|
+
export function matchesTimelineSearch(event: TimelineEvent, term: string): boolean {
|
|
90
|
+
if (!term) return true
|
|
91
|
+
const t = term.toLowerCase()
|
|
92
|
+
return (
|
|
93
|
+
event.name.toLowerCase().includes(t) ||
|
|
94
|
+
event.kind.toLowerCase().includes(t) ||
|
|
95
|
+
(event.namespace?.toLowerCase().includes(t) ?? false) ||
|
|
96
|
+
(event.reason?.toLowerCase().includes(t) ?? false) ||
|
|
97
|
+
(event.message?.toLowerCase().includes(t) ?? false) ||
|
|
98
|
+
(event.diff?.summary?.toLowerCase().includes(t) ?? false)
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Human-readable summary of the active content filters, for the filtered-empty
|
|
103
|
+
// state's reason line (e.g. `search "dfasdf" · 2 activity filters · 3 kinds ·
|
|
104
|
+
// deleted hidden`). Returns '' when nothing is filtering. Order mirrors the
|
|
105
|
+
// toolbar left-to-right. `showDeleted` defaults on, so only its hidden state
|
|
106
|
+
// counts as active.
|
|
107
|
+
export function describeActiveFilters(opts: {
|
|
108
|
+
search: string
|
|
109
|
+
activityFilter: readonly ActivityFilterKey[]
|
|
110
|
+
kindFilter: readonly string[]
|
|
111
|
+
showDeleted: boolean
|
|
112
|
+
}): string {
|
|
113
|
+
const parts: string[] = []
|
|
114
|
+
const search = opts.search.trim()
|
|
115
|
+
if (search) parts.push(`search "${search}"`)
|
|
116
|
+
// Count what the user set (source segment + Problems toggle), not the raw key
|
|
117
|
+
// array: the single Problems toggle expands to two keys (warnings+unhealthy),
|
|
118
|
+
// which would otherwise read as "2 activity filters" for one pick.
|
|
119
|
+
const sel = activityKeysToSelection(opts.activityFilter)
|
|
120
|
+
const activityCount = (sel.source !== 'all' ? 1 : 0) + (sel.problemsOnly ? 1 : 0)
|
|
121
|
+
if (activityCount > 0) parts.push(pluralize(activityCount, 'activity filter'))
|
|
122
|
+
if (opts.kindFilter.length > 0) parts.push(pluralize(opts.kindFilter.length, 'kind'))
|
|
123
|
+
if (!opts.showDeleted) parts.push('deleted hidden')
|
|
124
|
+
return parts.join(' · ')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface ActivityStats {
|
|
128
|
+
total: number
|
|
129
|
+
changes: number
|
|
130
|
+
k8sEvents: number
|
|
131
|
+
warnings: number
|
|
132
|
+
unhealthy: number
|
|
133
|
+
deleted: number
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Chip counts. Derived from the full (unfiltered) events array so the badges
|
|
137
|
+
// show totals regardless of the active filter — identical in both views.
|
|
138
|
+
export function computeActivityStats(events: TimelineEvent[] | undefined): ActivityStats {
|
|
139
|
+
if (!events || events.length === 0) {
|
|
140
|
+
return { total: 0, changes: 0, k8sEvents: 0, warnings: 0, unhealthy: 0, deleted: 0 }
|
|
141
|
+
}
|
|
142
|
+
let changes = 0
|
|
143
|
+
let k8sEvents = 0
|
|
144
|
+
let warnings = 0
|
|
145
|
+
let unhealthy = 0
|
|
146
|
+
let deleted = 0
|
|
147
|
+
for (const e of events) {
|
|
148
|
+
if (isChangeEvent(e)) changes++
|
|
149
|
+
if (isK8sEvent(e)) k8sEvents++
|
|
150
|
+
if (e.eventType === 'Warning') warnings++
|
|
151
|
+
if (isChangeEvent(e) && (e.healthState === 'unhealthy' || e.healthState === 'degraded')) unhealthy++
|
|
152
|
+
if (e.eventType === 'delete') deleted++
|
|
153
|
+
}
|
|
154
|
+
return { total: events.length, changes, k8sEvents, warnings, unhealthy, deleted }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Curated seed order for the Kind dropdown; discovered kinds (CRDs) append after.
|
|
158
|
+
export const TIMELINE_RESOURCE_KINDS = [
|
|
159
|
+
'Deployment',
|
|
160
|
+
'Pod',
|
|
161
|
+
'Service',
|
|
162
|
+
'ConfigMap',
|
|
163
|
+
'Ingress',
|
|
164
|
+
'Gateway',
|
|
165
|
+
'HTTPRoute',
|
|
166
|
+
'GRPCRoute',
|
|
167
|
+
'TCPRoute',
|
|
168
|
+
'TLSRoute',
|
|
169
|
+
'ReplicaSet',
|
|
170
|
+
'DaemonSet',
|
|
171
|
+
'StatefulSet',
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
// Merge discovered kinds into the curated seed: seed keeps its order, extras are
|
|
175
|
+
// appended alphabetically. Shared so both toolbars offer the same option set.
|
|
176
|
+
export function mergeKindOptions(extraKinds: Iterable<string>): string[] {
|
|
177
|
+
const seeded = new Set(TIMELINE_RESOURCE_KINDS)
|
|
178
|
+
const extra = [...new Set(extraKinds)].filter((k) => !!k && !seeded.has(k)).sort()
|
|
179
|
+
return [...TIMELINE_RESOURCE_KINDS, ...extra]
|
|
180
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { TimelineEvent } from '../../types'
|
|
3
|
+
import type { ResourceLane } from '../../utils/resource-hierarchy'
|
|
4
|
+
import { extractPinnedLanes } from '../../utils/resource-hierarchy'
|
|
5
|
+
import { sortTimelineLanes, type LaneSortContext } from './timeline-lane-sort'
|
|
6
|
+
|
|
7
|
+
function ev(ts: number, over: Partial<TimelineEvent> = {}): TimelineEvent {
|
|
8
|
+
return {
|
|
9
|
+
id: `${ts}-${Math.random()}`,
|
|
10
|
+
timestamp: new Date(ts).toISOString(),
|
|
11
|
+
source: 'informer',
|
|
12
|
+
kind: 'Pod',
|
|
13
|
+
namespace: 'default',
|
|
14
|
+
name: 'thing',
|
|
15
|
+
eventType: 'update',
|
|
16
|
+
...over,
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function lane(over: Partial<ResourceLane> & { id: string; name: string }): ResourceLane {
|
|
21
|
+
return {
|
|
22
|
+
kind: 'Pod',
|
|
23
|
+
namespace: 'default',
|
|
24
|
+
isWorkload: true,
|
|
25
|
+
events: [],
|
|
26
|
+
...over,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Visible window: [1000, 2000].
|
|
31
|
+
const WINDOW = { windowStart: 1000, windowEnd: 2000 }
|
|
32
|
+
|
|
33
|
+
// Importance scores keyed by lane id — injected so the sort stays pure.
|
|
34
|
+
const SCORES: Record<string, number> = { web: 5, api: 1, db: 9, cart: 3 }
|
|
35
|
+
const ctx: LaneSortContext = { ...WINDOW, scoreOf: (l) => SCORES[l.id] ?? 0 }
|
|
36
|
+
|
|
37
|
+
const web = lane({ id: 'web', name: 'web', namespace: 'default', events: [ev(1500)] })
|
|
38
|
+
const api = lane({ id: 'api', name: 'api', namespace: 'default', events: [ev(1900)] })
|
|
39
|
+
// db's only event predates the window — 'recent' falls back to its overall newest.
|
|
40
|
+
const dbLane = lane({ id: 'db', name: 'db', namespace: 'prod', events: [ev(500)] })
|
|
41
|
+
// An app-group lane: sorts by its title, not its synthetic name.
|
|
42
|
+
const cart = lane({ id: 'cart', name: 'cart-app', title: 'Cart', isAppGroup: true, events: [ev(1200)] })
|
|
43
|
+
|
|
44
|
+
const LANES = [web, api, dbLane, cart]
|
|
45
|
+
|
|
46
|
+
const ids = (lanes: ResourceLane[]) => lanes.map((l) => l.id)
|
|
47
|
+
|
|
48
|
+
describe('sortTimelineLanes', () => {
|
|
49
|
+
it("'importance' (default) orders by score, highest first", () => {
|
|
50
|
+
expect(ids(sortTimelineLanes(LANES, 'importance', ctx))).toEqual(['db', 'web', 'cart', 'api'])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("'recent' orders by newest in-window event, falling back to overall newest", () => {
|
|
54
|
+
// In-window newest: api(1900) > web(1500) > cart(1200); db has nothing
|
|
55
|
+
// in-window so its overall newest (500) sinks it to the bottom.
|
|
56
|
+
expect(ids(sortTimelineLanes(LANES, 'recent', ctx))).toEqual(['api', 'web', 'cart', 'db'])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("'recent' breaks ties by importance", () => {
|
|
60
|
+
const a = lane({ id: 'a', name: 'a', events: [ev(1500)] })
|
|
61
|
+
const b = lane({ id: 'b', name: 'b', events: [ev(1500)] })
|
|
62
|
+
const tieCtx: LaneSortContext = { ...WINDOW, scoreOf: (l) => (l.id === 'b' ? 10 : 1) }
|
|
63
|
+
expect(ids(sortTimelineLanes([a, b], 'recent', tieCtx))).toEqual(['b', 'a'])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("'name' orders alphabetically (case-insensitive), app groups by their title", () => {
|
|
67
|
+
// api, Cart (title), db, web.
|
|
68
|
+
expect(ids(sortTimelineLanes(LANES, 'name', ctx))).toEqual(['api', 'cart', 'db', 'web'])
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("'name' breaks ties by namespace", () => {
|
|
72
|
+
const first = lane({ id: 'first', name: 'web', namespace: 'aaa' })
|
|
73
|
+
const second = lane({ id: 'second', name: 'web', namespace: 'zzz' })
|
|
74
|
+
expect(ids(sortTimelineLanes([second, first], 'name', ctx))).toEqual(['first', 'second'])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('does not mutate the input array (pinned extraction reads the original order)', () => {
|
|
78
|
+
const input = [...LANES]
|
|
79
|
+
sortTimelineLanes(input, 'name', ctx)
|
|
80
|
+
expect(ids(input)).toEqual(['web', 'api', 'db', 'cart'])
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('pinned section is unaffected by sort', () => {
|
|
85
|
+
it('keeps strict pin order regardless of the active lane sort', () => {
|
|
86
|
+
const pinnedRefs = [
|
|
87
|
+
{ id: 'db', kind: 'Pod', namespace: 'prod', name: 'db' },
|
|
88
|
+
{ id: 'api', kind: 'Pod', namespace: 'default', name: 'api' },
|
|
89
|
+
]
|
|
90
|
+
// Extract from lanes ordered by two different sorts — the pinned rows follow
|
|
91
|
+
// the ref order, never the lane order.
|
|
92
|
+
const byImportance = sortTimelineLanes(LANES, 'importance', ctx)
|
|
93
|
+
const byName = sortTimelineLanes(LANES, 'name', ctx)
|
|
94
|
+
expect(ids(extractPinnedLanes(byImportance, pinnedRefs))).toEqual(['db', 'api'])
|
|
95
|
+
expect(ids(extractPinnedLanes(byName, pinnedRefs))).toEqual(['db', 'api'])
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ResourceLane } from '../../utils/resource-hierarchy'
|
|
2
|
+
|
|
3
|
+
// The lane orderings offered by the swimlane's View → Sort control. 'importance'
|
|
4
|
+
// is the default (the interestingness ranking); a second/third ordering earns the
|
|
5
|
+
// control its keep — a lone always-on radio would be inert chrome.
|
|
6
|
+
export type TimelineSort = 'importance' | 'recent' | 'name'
|
|
7
|
+
|
|
8
|
+
export const TIMELINE_SORT_DEFAULT: TimelineSort = 'importance'
|
|
9
|
+
|
|
10
|
+
export interface LaneSortContext<L extends ResourceLane = ResourceLane> {
|
|
11
|
+
// Visible window bounds — 'recent' prefers events inside this lens.
|
|
12
|
+
windowStart: number
|
|
13
|
+
windowEnd: number
|
|
14
|
+
// Interestingness score, injected so the sort stays pure and reuses the score
|
|
15
|
+
// the swimlane already computed per lane.
|
|
16
|
+
scoreOf: (lane: L) => number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// App-group lanes sort by their header title; plain lanes by their resource name.
|
|
20
|
+
function laneName(lane: ResourceLane): string {
|
|
21
|
+
return (lane.isAppGroup ? lane.title ?? lane.name : lane.name) ?? ''
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Most recent event time for a lane, preferring events inside the visible window
|
|
25
|
+
// (the live "what just moved" job) and falling back to the lane's newest event
|
|
26
|
+
// overall when nothing lands in-window (e.g. a lane adjacent to a pin). Lanes with
|
|
27
|
+
// no events return -Infinity so they sink to the bottom.
|
|
28
|
+
function laneRecency(lane: ResourceLane, windowStart: number, windowEnd: number): number {
|
|
29
|
+
const events = lane.allEventsSorted ?? lane.events
|
|
30
|
+
let inWindow = -Infinity
|
|
31
|
+
let overall = -Infinity
|
|
32
|
+
for (const e of events) {
|
|
33
|
+
const t = new Date(e.timestamp).getTime()
|
|
34
|
+
if (t > overall) overall = t
|
|
35
|
+
if (t >= windowStart && t <= windowEnd && t > inWindow) inWindow = t
|
|
36
|
+
}
|
|
37
|
+
return inWindow !== -Infinity ? inWindow : overall
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Order the top-level lanes for the chosen sort. Pure + total so the swimlane can
|
|
41
|
+
// memo it and the tests can pin each ordering. The PINNED section is ordered
|
|
42
|
+
// elsewhere (strict pin order) and never flows through here.
|
|
43
|
+
export function sortTimelineLanes<L extends ResourceLane>(
|
|
44
|
+
lanes: L[],
|
|
45
|
+
sort: TimelineSort,
|
|
46
|
+
ctx: LaneSortContext<L>,
|
|
47
|
+
): L[] {
|
|
48
|
+
const copy = [...lanes]
|
|
49
|
+
switch (sort) {
|
|
50
|
+
case 'recent': {
|
|
51
|
+
// Precompute recency once per lane (Schwartzian transform): laneRecency
|
|
52
|
+
// scans every event, so calling it inside the comparator would repeat that
|
|
53
|
+
// scan O(n log n) times.
|
|
54
|
+
const recency = new Map<L, number>()
|
|
55
|
+
for (const lane of copy) recency.set(lane, laneRecency(lane, ctx.windowStart, ctx.windowEnd))
|
|
56
|
+
return copy.sort((a, b) => {
|
|
57
|
+
const ra = recency.get(a)!
|
|
58
|
+
const rb = recency.get(b)!
|
|
59
|
+
if (ra !== rb) return rb - ra
|
|
60
|
+
return ctx.scoreOf(b) - ctx.scoreOf(a)
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
case 'name':
|
|
64
|
+
return copy.sort((a, b) => {
|
|
65
|
+
const na = laneName(a).toLowerCase()
|
|
66
|
+
const nb = laneName(b).toLowerCase()
|
|
67
|
+
if (na !== nb) return na < nb ? -1 : 1
|
|
68
|
+
const nsa = a.namespace ?? ''
|
|
69
|
+
const nsb = b.namespace ?? ''
|
|
70
|
+
if (nsa !== nsb) return nsa < nsb ? -1 : 1
|
|
71
|
+
return 0
|
|
72
|
+
})
|
|
73
|
+
case 'importance':
|
|
74
|
+
default:
|
|
75
|
+
return copy.sort((a, b) => ctx.scoreOf(b) - ctx.scoreOf(a))
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
BASE_QUANTIZE_STEP_MS,
|
|
4
|
+
LENS_LATCH_EPSILON_MS,
|
|
5
|
+
STALE_AMBER_AFTER_MS,
|
|
6
|
+
advanceLatchedLens,
|
|
7
|
+
deriveLiveSelection,
|
|
8
|
+
isLensLatched,
|
|
9
|
+
quantizeBaseWindow,
|
|
10
|
+
} from './timeline-live'
|
|
11
|
+
import { clampSelection, type ScrubberRange } from './scrubber-math'
|
|
12
|
+
|
|
13
|
+
const MIN = 60_000
|
|
14
|
+
const HOUR = 60 * MIN
|
|
15
|
+
|
|
16
|
+
describe('deriveLiveSelection', () => {
|
|
17
|
+
it('pins the window to now with the given width', () => {
|
|
18
|
+
const now = 1_000_000_000
|
|
19
|
+
expect(deriveLiveSelection(HOUR, now)).toEqual({ fromMs: now - HOUR, toMs: now })
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('quantizeBaseWindow', () => {
|
|
24
|
+
it('floors both edges to the step', () => {
|
|
25
|
+
const step = BASE_QUANTIZE_STEP_MS
|
|
26
|
+
const q = quantizeBaseWindow(step * 3 + 12_345, step * 10 + 4_000, step)
|
|
27
|
+
expect(q).toEqual({ fromMs: step * 3, toMs: step * 10 })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('is stable across two ticks inside the same step', () => {
|
|
31
|
+
const step = BASE_QUANTIZE_STEP_MS
|
|
32
|
+
const base = step * 100
|
|
33
|
+
// Two "now" values 30s apart but inside the same 5-minute step.
|
|
34
|
+
const a = quantizeBaseWindow(base - HOUR + 10_000, base + 10_000, step)
|
|
35
|
+
const b = quantizeBaseWindow(base - HOUR + 40_000, base + 40_000, step)
|
|
36
|
+
expect(a).toEqual(b)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('advances once the edge crosses a step boundary', () => {
|
|
40
|
+
const step = BASE_QUANTIZE_STEP_MS
|
|
41
|
+
const before = quantizeBaseWindow(0, step - 1, step)
|
|
42
|
+
const after = quantizeBaseWindow(0, step + 1, step)
|
|
43
|
+
expect(before.toMs).toBe(0)
|
|
44
|
+
expect(after.toMs).toBe(step)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('trailing seam never exceeds one step (covered by the 10-min live poll)', () => {
|
|
48
|
+
const step = BASE_QUANTIZE_STEP_MS
|
|
49
|
+
const now = step * 42 + 137_000
|
|
50
|
+
const q = quantizeBaseWindow(now - HOUR, now, step)
|
|
51
|
+
// Gap between the quantized right edge and now is < one step, and one step
|
|
52
|
+
// (5min) is well inside the 10-min live poll window ⇒ no data hole.
|
|
53
|
+
expect(now - q.toMs).toBeLessThan(step)
|
|
54
|
+
expect(step).toBeLessThan(10 * MIN)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('isLensLatched', () => {
|
|
59
|
+
const sel: ScrubberRange = { fromMs: 0, toMs: 100 * HOUR }
|
|
60
|
+
|
|
61
|
+
it('latched when the lens rides the live edge', () => {
|
|
62
|
+
expect(isLensLatched({ fromMs: 90 * HOUR, toMs: sel.toMs }, sel)).toBe(true)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('latched when the lens is within one tick of the edge', () => {
|
|
66
|
+
const lens = { fromMs: 90 * HOUR, toMs: sel.toMs - 30_000 }
|
|
67
|
+
expect(isLensLatched(lens, sel)).toBe(true)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('exactly at the epsilon boundary counts as latched', () => {
|
|
71
|
+
const lens = { fromMs: 0, toMs: sel.toMs - LENS_LATCH_EPSILON_MS }
|
|
72
|
+
expect(isLensLatched(lens, sel)).toBe(true)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('not latched when dragged into the past', () => {
|
|
76
|
+
const lens = { fromMs: 0, toMs: sel.toMs - 2 * HOUR }
|
|
77
|
+
expect(isLensLatched(lens, sel)).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('advanceLatchedLens', () => {
|
|
82
|
+
it('slides to the new right edge keeping width', () => {
|
|
83
|
+
const lens = { fromMs: 100, toMs: 400 } // width 300
|
|
84
|
+
const next = advanceLatchedLens(lens, { fromMs: 0, toMs: 1_000 })
|
|
85
|
+
expect(next).toEqual({ fromMs: 700, toMs: 1_000 })
|
|
86
|
+
expect(next.toMs - next.fromMs).toBe(300)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('re-latches: an advanced lens stays within epsilon of the next tick edge', () => {
|
|
90
|
+
let lens = { fromMs: 0, toMs: 100 * HOUR }
|
|
91
|
+
let now = 100 * HOUR
|
|
92
|
+
for (let i = 0; i < 5; i++) {
|
|
93
|
+
now += 30_000
|
|
94
|
+
const sel = { fromMs: now - 100 * HOUR, toMs: now }
|
|
95
|
+
expect(isLensLatched(lens, sel)).toBe(true)
|
|
96
|
+
lens = advanceLatchedLens(lens, sel)
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('live selection vs domain clamp (RetainedTimelineScrubber fix)', () => {
|
|
102
|
+
// Regression: a live selection whose to=now(tick) exceeds a stale overview
|
|
103
|
+
// domain.to must NOT be rewritten backward. The fix derives domain.to as
|
|
104
|
+
// max(staleNow, selection.to); this asserts the clamp is then a no-op.
|
|
105
|
+
it('is not clamped when domain.to = max(staleNow, selection.to)', () => {
|
|
106
|
+
const now = 100 * HOUR
|
|
107
|
+
const selection = deriveLiveSelection(24 * HOUR, now)
|
|
108
|
+
const staleNow = now - 45_000 // overview updatedAt lags the tick
|
|
109
|
+
const availableFromMs = now - 7 * 24 * HOUR
|
|
110
|
+
const domainTo = Math.max(staleNow, selection.toMs)
|
|
111
|
+
const domain = { fromMs: availableFromMs, toMs: domainTo }
|
|
112
|
+
const maxSelectionMs = Math.min(7 * 24 * HOUR, domain.toMs - domain.fromMs)
|
|
113
|
+
const { selection: clamped } = clampSelection(selection, domain, maxSelectionMs, 'end')
|
|
114
|
+
expect(clamped).toEqual(selection)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('WITHOUT the fix a stale domain.to drags the live edge backward', () => {
|
|
118
|
+
const now = 100 * HOUR
|
|
119
|
+
const selection = deriveLiveSelection(24 * HOUR, now)
|
|
120
|
+
const staleNow = now - 45_000
|
|
121
|
+
const domain = { fromMs: now - 7 * 24 * HOUR, toMs: staleNow } // no max()
|
|
122
|
+
const { selection: clamped } = clampSelection(selection, domain, 7 * 24 * HOUR, 'end')
|
|
123
|
+
expect(clamped.toMs).toBeLessThan(selection.toMs)
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('STALE_AMBER_AFTER_MS', () => {
|
|
128
|
+
it('is 15 minutes', () => {
|
|
129
|
+
expect(STALE_AMBER_AFTER_MS).toBe(15 * 60_000)
|
|
130
|
+
})
|
|
131
|
+
})
|