@weft-js/warp 0.0.0-stage → 0.1.0
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/LICENSE +16 -0
- package/README.md +55 -2
- package/package.json +43 -4
- package/plugin.d.mts +30 -0
- package/plugin.mjs +358 -0
- package/src/Accordion.tsx +58 -0
- package/src/Alert.tsx +25 -0
- package/src/Badge.tsx +17 -0
- package/src/Button.tsx +100 -0
- package/src/Calendar.tsx +162 -0
- package/src/Canvas.tsx +110 -0
- package/src/Card.tsx +29 -0
- package/src/Chart.tsx +133 -0
- package/src/Charts.tsx +69 -0
- package/src/Chip.tsx +27 -0
- package/src/Choice.tsx +105 -0
- package/src/CodeBlock.tsx +65 -0
- package/src/CodeInput.tsx +130 -0
- package/src/Combobox.tsx +113 -0
- package/src/CommandPalette.tsx +348 -0
- package/src/DataGrid.tsx +561 -0
- package/src/DateField.tsx +221 -0
- package/src/Dither.tsx +129 -0
- package/src/Drawer.tsx +41 -0
- package/src/DurationField.tsx +98 -0
- package/src/Feed.tsx +74 -0
- package/src/Field.tsx +147 -0
- package/src/Icon.tsx +8 -0
- package/src/Kbd.tsx +6 -0
- package/src/KeyValueEditor.tsx +114 -0
- package/src/Layout.tsx +109 -0
- package/src/Markdown.tsx +63 -0
- package/src/MentionsInput.tsx +150 -0
- package/src/Menu.tsx +111 -0
- package/src/Modal.tsx +40 -0
- package/src/MonthPicker.tsx +190 -0
- package/src/Nav.tsx +20 -0
- package/src/NumberField.tsx +78 -0
- package/src/Pagination.tsx +122 -0
- package/src/PaymentCard.tsx +63 -0
- package/src/Picker.tsx +120 -0
- package/src/PlaceField.tsx +126 -0
- package/src/Popover.tsx +34 -0
- package/src/Progress.tsx +26 -0
- package/src/RecurrenceField.tsx +126 -0
- package/src/RichSelect.tsx +243 -0
- package/src/RichTextEditor.tsx +207 -0
- package/src/ScrollGrid.tsx +190 -0
- package/src/Segments.tsx +70 -0
- package/src/Select.tsx +192 -0
- package/src/Shape.tsx +20 -0
- package/src/Shell.tsx +308 -0
- package/src/SideNav.tsx +147 -0
- package/src/SignaturePad.tsx +120 -0
- package/src/Skeleton.tsx +14 -0
- package/src/Slider.tsx +42 -0
- package/src/Spinner.tsx +7 -0
- package/src/Table.tsx +528 -0
- package/src/Tabs.tsx +67 -0
- package/src/Tag.tsx +15 -0
- package/src/Ticker.tsx +36 -0
- package/src/TimeField.tsx +237 -0
- package/src/Toast.tsx +56 -0
- package/src/Toggle.tsx +42 -0
- package/src/Tooltip.tsx +32 -0
- package/src/Type.tsx +46 -0
- package/src/VirtualList.tsx +386 -0
- package/src/admin/ApiKeys.tsx +136 -0
- package/src/admin/Audit.tsx +93 -0
- package/src/admin/Billing.tsx +123 -0
- package/src/admin/Cart.tsx +96 -0
- package/src/admin/Checkout.tsx +300 -0
- package/src/admin/CsvImport.tsx +174 -0
- package/src/admin/Danger.tsx +110 -0
- package/src/admin/Integrations.tsx +200 -0
- package/src/admin/InviteDialog.tsx +64 -0
- package/src/admin/Members.tsx +186 -0
- package/src/admin/Order.tsx +63 -0
- package/src/admin/PlainTable.tsx +22 -0
- package/src/admin/Plans.tsx +167 -0
- package/src/admin/SaveBar.tsx +112 -0
- package/src/admin/Secret.tsx +37 -0
- package/src/admin/Settings.tsx +76 -0
- package/src/admin/StepBar.tsx +35 -0
- package/src/admin/TagsInput.tsx +79 -0
- package/src/admin/atoms.tsx +33 -0
- package/src/admin/index.ts +16 -0
- package/src/agent/Actions.tsx +85 -0
- package/src/agent/AgentPlan.tsx +49 -0
- package/src/agent/ArtifactPanel.tsx +80 -0
- package/src/agent/Assistant.tsx +96 -0
- package/src/agent/Checkpoint.tsx +14 -0
- package/src/agent/Cite.tsx +39 -0
- package/src/agent/ContextMeter.tsx +31 -0
- package/src/agent/Conversation.tsx +34 -0
- package/src/agent/Message.tsx +53 -0
- package/src/agent/ModelPicker.tsx +65 -0
- package/src/agent/Prompt.tsx +197 -0
- package/src/agent/Prose.tsx +54 -0
- package/src/agent/Question.tsx +52 -0
- package/src/agent/Reasoning.tsx +19 -0
- package/src/agent/Shimmer.tsx +7 -0
- package/src/agent/Sources.tsx +23 -0
- package/src/agent/SpeechInput.tsx +102 -0
- package/src/agent/Suggestions.tsx +22 -0
- package/src/agent/ToolCall.tsx +91 -0
- package/src/agent/TraceViewer.tsx +55 -0
- package/src/agent/bits.tsx +40 -0
- package/src/agent/index.ts +22 -0
- package/src/bind.ts +42 -0
- package/src/collab/AutoTrack.tsx +42 -0
- package/src/collab/Comments.tsx +94 -0
- package/src/collab/Composer.tsx +173 -0
- package/src/collab/ConversationList.tsx +73 -0
- package/src/collab/Cursors.tsx +35 -0
- package/src/collab/DataIntegrity.tsx +45 -0
- package/src/collab/Kanban.tsx +185 -0
- package/src/collab/Lock.tsx +23 -0
- package/src/collab/PresenceStack.tsx +67 -0
- package/src/collab/ShareDialog.tsx +118 -0
- package/src/collab/Status.tsx +42 -0
- package/src/collab/SyncedForm.tsx +19 -0
- package/src/collab/Thread.tsx +273 -0
- package/src/collab/VersionHistory.tsx +49 -0
- package/src/collab/ViewportRail.tsx +49 -0
- package/src/collab/index.ts +16 -0
- package/src/collab/peer.tsx +16 -0
- package/src/fields.ts +5 -0
- package/src/flow/AuthScreen.tsx +40 -0
- package/src/flow/AuthSteps.tsx +120 -0
- package/src/flow/Checklist.tsx +84 -0
- package/src/flow/ConnBanner.tsx +147 -0
- package/src/flow/Flow.tsx +63 -0
- package/src/flow/MfaSetup.tsx +103 -0
- package/src/flow/Otp.tsx +114 -0
- package/src/flow/ReauthDialog.tsx +47 -0
- package/src/flow/ShortcutsOverlay.tsx +60 -0
- package/src/flow/SignGate.tsx +98 -0
- package/src/flow/SignIn.tsx +80 -0
- package/src/flow/SignUp.tsx +71 -0
- package/src/flow/Sso.tsx +38 -0
- package/src/flow/SystemPage.tsx +247 -0
- package/src/flow/Tour.tsx +108 -0
- package/src/flow/index.ts +17 -0
- package/src/flow/kit.tsx +170 -0
- package/src/index.ts +74 -0
- package/src/layout.css +4 -0
- package/src/links.css +4 -0
- package/src/live/ActivityFeed.tsx +49 -0
- package/src/live/Bell.tsx +62 -0
- package/src/live/Connection.tsx +49 -0
- package/src/live/Inbox.tsx +220 -0
- package/src/live/MeetingRoom.tsx +298 -0
- package/src/live/NotificationPreferences.tsx +193 -0
- package/src/live/Prejoin.tsx +140 -0
- package/src/live/PushSoftAsk.tsx +37 -0
- package/src/live/TransportPicker.tsx +163 -0
- package/src/live/index.ts +12 -0
- package/src/live/notify.tsx +16 -0
- package/src/live/shared.tsx +39 -0
- package/src/parts.tsx +93 -0
- package/src/schedule/Agenda.tsx +173 -0
- package/src/schedule/AvailabilityEditor.tsx +164 -0
- package/src/schedule/BookingCalendar.tsx +132 -0
- package/src/schedule/Calendar.tsx +746 -0
- package/src/schedule/EventEditor.tsx +87 -0
- package/src/schedule/EventPopover.tsx +65 -0
- package/src/schedule/MeetingPlanner.tsx +214 -0
- package/src/schedule/MoveSheet.tsx +78 -0
- package/src/schedule/YearView.tsx +163 -0
- package/src/schedule/bits.tsx +51 -0
- package/src/schedule/index.ts +10 -0
- package/src/skeleton-css.ts +4 -0
- package/src/styles.ts +10 -0
- package/src/tokens.d.ts +2 -0
- package/src/type.css +15 -0
- package/src/ui.ts +26 -0
- package/src/use-ui.ts +10 -0
- package/tailwind.css +38 -0
- package/tokens/color.mjs +66 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import { derived, DEV, effect, flush, onCleanup, onMount, state, untrack } from '@weft-js/core'
|
|
2
|
+
import { measureEngine, type MeasureHandle, type MeasureProvider } from '@weft-js/core/text'
|
|
3
|
+
import { slices, virtualizer, type VirtualAlign, type VirtualKey, type Virtualizer } from '@weft-js/warp-core'
|
|
4
|
+
import './styles'
|
|
5
|
+
import '@weft-js/warp-core/styles/data/virtual.css'
|
|
6
|
+
|
|
7
|
+
/** What a list hands out through `control`, to scroll and measure from outside. */
|
|
8
|
+
export interface VirtualControl {
|
|
9
|
+
scrollTo(key: VirtualKey, align?: VirtualAlign): void
|
|
10
|
+
scrollToEnd(): void
|
|
11
|
+
/** Render the row, wherever it is, and move focus to it. */
|
|
12
|
+
focus(key: VirtualKey): void
|
|
13
|
+
/** Move focus by rows from the focused one. */
|
|
14
|
+
move(by: number): void
|
|
15
|
+
/** The key of the focused or last-focused row. */
|
|
16
|
+
active(): VirtualKey | undefined
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Props for VirtualList. */
|
|
20
|
+
export interface VirtualListProps<T> {
|
|
21
|
+
items: readonly T[]
|
|
22
|
+
rowKey: (item: T) => VirtualKey
|
|
23
|
+
children: (item: () => T, index: () => number) => unknown
|
|
24
|
+
/** The list's name. */
|
|
25
|
+
label?: string | undefined
|
|
26
|
+
/** The row's text. Rows get exact heights before they render when the app has `text.measure`, and it's measured otherwise. */
|
|
27
|
+
text?: (item: T) => string | null | undefined
|
|
28
|
+
/** Rows that look different around the same text: each gets its own measurement of everything but the text. */
|
|
29
|
+
variant?: ((item: T) => string) | undefined
|
|
30
|
+
/** A row's height before it's known. Default 48. */
|
|
31
|
+
estimate?: number | undefined
|
|
32
|
+
/** Pixels between rows. */
|
|
33
|
+
gap?: number | undefined
|
|
34
|
+
/** Pixels of rows kept rendered past each edge of the view, so a fast scroll shows no gaps. */
|
|
35
|
+
overscan?: number | undefined
|
|
36
|
+
/** Rows on the server and in the first paint. Default 20. */
|
|
37
|
+
initial?: number | undefined
|
|
38
|
+
/** Stay at the end as rows arrive while the user is within this many pixels of it. */
|
|
39
|
+
stick?: number | undefined
|
|
40
|
+
/** `end` opens at the last row and grows upward, as a chat does. */
|
|
41
|
+
from?: 'start' | 'end' | undefined
|
|
42
|
+
/** `list` by default (rows are `listitem`s; `group` with `bareRows`); `feed`, `log` and the rest bring their own row role. */
|
|
43
|
+
role?: string | undefined
|
|
44
|
+
/** The role each row takes, such as `option` or `article`. */
|
|
45
|
+
rowRole?: string | undefined
|
|
46
|
+
/** Rows take no list semantics: what's inside them says what it is, and the scroller is a labelled group. */
|
|
47
|
+
bareRows?: boolean | undefined
|
|
48
|
+
/** Keep this role off the scroller and put it on the list inside. */
|
|
49
|
+
inner?: boolean | undefined
|
|
50
|
+
/** `ol` renders an ordered list with <li> rows; `div` otherwise. */
|
|
51
|
+
tag?: 'div' | 'ol' | undefined
|
|
52
|
+
/** Row keys move focus: ↑/↓, Home/End, one tab stop on the rows. */
|
|
53
|
+
roving?: boolean | undefined
|
|
54
|
+
/** The total isn't the list's to know (a log with history to load): `aria-setsize` is -1, and an arrival writes no row. */
|
|
55
|
+
open?: boolean | undefined
|
|
56
|
+
/** On a key the list doesn't use. */
|
|
57
|
+
onRowKey?: ((e: KeyboardEvent, control: VirtualControl) => void) | undefined
|
|
58
|
+
/** Called when the view nears the first row: load older rows. */
|
|
59
|
+
onStart?: (() => void) | undefined
|
|
60
|
+
/** Called when the view nears the last row: load more. */
|
|
61
|
+
onEnd?: (() => void) | undefined
|
|
62
|
+
/** Called with `true` when the list starts holding to the end, and `false` when the user scrolls away. */
|
|
63
|
+
onStick?: ((stuck: boolean) => void) | undefined
|
|
64
|
+
/** Called when a row's measured height differs from the estimate, with both heights. For tuning. */
|
|
65
|
+
onDrift?: ((index: number, predicted: number, actual: number) => void) | undefined
|
|
66
|
+
/** Hands over a control to scroll to, focus or move between rows from outside. */
|
|
67
|
+
control?: ((c: VirtualControl) => void) | undefined
|
|
68
|
+
/** The scroller's tabindex. Default 0, none with `roving`. */
|
|
69
|
+
tabindex?: number | undefined
|
|
70
|
+
/** The scroller's `data-scope`, for styling it as another component. */
|
|
71
|
+
scope?: string | undefined
|
|
72
|
+
/** The scroller's `data-part`, when it is a part of a larger component. */
|
|
73
|
+
part?: string | undefined
|
|
74
|
+
/** The inner list's `data-scope`. */
|
|
75
|
+
listScope?: string | undefined
|
|
76
|
+
height?: string | undefined
|
|
77
|
+
/** `aria-busy`: more is loading. */
|
|
78
|
+
busy?: boolean | undefined
|
|
79
|
+
class?: string | undefined
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type MeasureOptions = Parameters<MeasureProvider['prepare']>[2]
|
|
83
|
+
interface Metric { font: string; line: number; wrap: number; chrome: number; opts: { whiteSpace: 'normal' | 'pre-wrap'; letterSpacing?: number | undefined } }
|
|
84
|
+
const EDGE = 8 // rows from the end at which onStart and onEnd ask for more
|
|
85
|
+
const BATCH = 256 // rows prepared between index updates
|
|
86
|
+
const LONG = 'mmmm '.repeat(400)
|
|
87
|
+
|
|
88
|
+
function fontOf(c: CSSStyleDeclaration) {
|
|
89
|
+
return `${c.fontStyle} ${c.fontWeight} ${c.fontSize} ${c.fontFamily}`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One row's text and everything around it, read from a real hidden row: nothing here knows warp's padding. */
|
|
93
|
+
function metricOf(probe: HTMLElement): Metric | undefined {
|
|
94
|
+
const box = probe.querySelector<HTMLElement>('[data-measure]')
|
|
95
|
+
if (!box) return
|
|
96
|
+
const c = getComputedStyle(box)
|
|
97
|
+
const line = parseFloat(c.lineHeight)
|
|
98
|
+
if (!line) return // `normal` has no one answer
|
|
99
|
+
box.style.display = 'block'
|
|
100
|
+
box.textContent = 'x'
|
|
101
|
+
const chrome = probe.getBoundingClientRect().height - line
|
|
102
|
+
box.textContent = LONG
|
|
103
|
+
const wrap = box.getBoundingClientRect().width - (parseFloat(c.paddingInlineStart) || 0) - (parseFloat(c.paddingInlineEnd) || 0)
|
|
104
|
+
const white = c.whiteSpaceCollapse === 'preserve' || /pre/.test(c.whiteSpace)
|
|
105
|
+
return { font: fontOf(c), line, wrap, chrome, opts: { whiteSpace: white ? 'pre-wrap' : 'normal', letterSpacing: parseFloat(c.letterSpacing) || undefined } }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A long list (06 §9): one window of rows at known offsets. Give it `text` and rows have exact heights before they
|
|
110
|
+
* exist; rows the engine can't size are measured when they render, and what's on screen never moves when one corrects.
|
|
111
|
+
*/
|
|
112
|
+
export function VirtualList<T>(props: VirtualListProps<T>) {
|
|
113
|
+
let el: HTMLElement | undefined, list: HTMLElement | undefined
|
|
114
|
+
let v: Virtualizer | undefined
|
|
115
|
+
let engine: MeasureProvider | null = null
|
|
116
|
+
let stop: (() => void) | undefined
|
|
117
|
+
let measuredAt = -1
|
|
118
|
+
let slicing = false, cursor = 0 // what the idle pass has reached: a change behind it is prepared now, ahead of it later
|
|
119
|
+
const waiting: [HTMLElement, VirtualKey, (undo: () => void) => void][] = []
|
|
120
|
+
const metrics = new Map<string, Metric>()
|
|
121
|
+
const prepared = new Map<VirtualKey, { text: string; handle: MeasureHandle; font: string }>()
|
|
122
|
+
const probes = new Map<string, HTMLElement>()
|
|
123
|
+
const active = state<VirtualKey | undefined>(undefined)
|
|
124
|
+
const pending = state<VirtualKey | undefined>(undefined)
|
|
125
|
+
|
|
126
|
+
const items = derived(() => props.items)
|
|
127
|
+
const keyOf = (item: T) => props.rowKey(item)
|
|
128
|
+
const keys = derived(() => items.value.map(keyOf))
|
|
129
|
+
const variantOf = (item: T) => props.variant?.(item) ?? ''
|
|
130
|
+
const first = untrack(() => {
|
|
131
|
+
const n = items.value.length, count = Math.min(n, props.initial ?? 20)
|
|
132
|
+
return Array.from({ length: count }, (_, i) => (props.from === 'end' ? n - count + i : i))
|
|
133
|
+
})
|
|
134
|
+
// Keys, not indices: a prepend renumbers every row, and that mustn't look like a new set of rows.
|
|
135
|
+
const shown = state<VirtualKey[]>(untrack(() => first.map(i => keyOf(items.value[i]!))))
|
|
136
|
+
const index = derived(() => new Map(keys.value.map((k, i) => [k, i])))
|
|
137
|
+
const rows = derived(() => {
|
|
138
|
+
const all = items.value, at = index.value
|
|
139
|
+
return shown.value.flatMap(key => { const i = at.get(key); return i === undefined ? [] : [{ item: all[i]!, i, key }] })
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const samples = derived(() => {
|
|
143
|
+
if (!props.text) return []
|
|
144
|
+
const seen = new Map<string, T>()
|
|
145
|
+
for (const item of items.value) {
|
|
146
|
+
const name = variantOf(item)
|
|
147
|
+
if (!seen.has(name)) seen.set(name, item)
|
|
148
|
+
if (!props.variant) break
|
|
149
|
+
}
|
|
150
|
+
return [...seen]
|
|
151
|
+
})
|
|
152
|
+
const probing = state(false)
|
|
153
|
+
|
|
154
|
+
function prepare(i: number) {
|
|
155
|
+
const item = untrack(() => items.value[i]), m = metrics.get(variantOf(item!)), value = props.text?.(item!)
|
|
156
|
+
if (!engine || !m || value == null) return false
|
|
157
|
+
const key = keyOf(item!), had = prepared.get(key)
|
|
158
|
+
if (had && had.text === value && had.font === m.font) return false
|
|
159
|
+
// Preserved white space leaves the fast tier, so it's asked for only by text that has some to preserve.
|
|
160
|
+
const handle = engine.prepare(value, m.font, m.opts.whiteSpace === 'pre-wrap' && !/\n| {2}|\t/.test(value) ? { ...m.opts, whiteSpace: 'normal' } as MeasureOptions : m.opts as MeasureOptions)
|
|
161
|
+
prepared.set(key, { text: value, handle, font: m.font })
|
|
162
|
+
void handle.ready?.then(() => { const at = index.value.get(key); if (at !== undefined) v?.refresh(at, at + 1) })
|
|
163
|
+
return true
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const size = (i: number) => {
|
|
167
|
+
if (!engine) return
|
|
168
|
+
const item = items.value[i]!, m = metrics.get(variantOf(item)), p = prepared.get(keyOf(item))
|
|
169
|
+
if (!m || !p || p.text !== props.text?.(item)) return
|
|
170
|
+
return m.chrome + Math.max(m.line, engine.layout(p.handle, m.wrap, m.line).height)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function calibrate() {
|
|
174
|
+
metrics.clear()
|
|
175
|
+
for (const [name, probe] of probes) {
|
|
176
|
+
const m = metricOf(probe)
|
|
177
|
+
if (m) metrics.set(name, m)
|
|
178
|
+
}
|
|
179
|
+
measuredAt = list?.getBoundingClientRect().width ?? -1
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The window now, so it paints exact; the rest a few ms at a time, so 10,000 rows never block a frame. */
|
|
183
|
+
function prepareAll() {
|
|
184
|
+
stop?.()
|
|
185
|
+
slicing = true
|
|
186
|
+
cursor = 0
|
|
187
|
+
for (const key of shown.value) { const i = index.value.get(key); if (i !== undefined) prepare(i) }
|
|
188
|
+
v?.refresh()
|
|
189
|
+
let lo = 0, done = 0
|
|
190
|
+
stop = slices(() => {
|
|
191
|
+
if (cursor >= items.value.length) {
|
|
192
|
+
slicing = false
|
|
193
|
+
if (cursor > lo) v?.refresh(lo, cursor)
|
|
194
|
+
return false
|
|
195
|
+
}
|
|
196
|
+
if (prepare(cursor++)) done++
|
|
197
|
+
if (done >= BATCH) { v?.refresh(lo, cursor); lo = cursor; done = 0 }
|
|
198
|
+
return true
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function start() {
|
|
203
|
+
calibrate()
|
|
204
|
+
prepareAll()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const fontsDone = () => { engine?.clear(); prepared.clear(); start() }
|
|
208
|
+
const root = () => el!.ownerDocument.documentElement
|
|
209
|
+
let dials: MutationObserver | undefined
|
|
210
|
+
|
|
211
|
+
/** Bring a row into view if it isn't. Before its key has arrived this does nothing, and the keys effect comes back. */
|
|
212
|
+
function reveal(key: VirtualKey) {
|
|
213
|
+
const i = index.value.get(key)
|
|
214
|
+
if (i === undefined || !v) return
|
|
215
|
+
const a = v.anchor(), now = v.sizes.offset(a.index) + a.within, top = v.sizes.offset(i)
|
|
216
|
+
if (top < now) v.scrollTo(i, 'start')
|
|
217
|
+
else if (top + v.sizes.get(i)! > now + el!.clientHeight) v.scrollTo(i, 'end')
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const control: VirtualControl = {
|
|
221
|
+
scrollTo(key, align) {
|
|
222
|
+
const i = index.value.get(key)
|
|
223
|
+
if (i !== undefined) v?.scrollTo(i, align)
|
|
224
|
+
},
|
|
225
|
+
scrollToEnd: () => v?.scrollToEnd(),
|
|
226
|
+
focus(key) {
|
|
227
|
+
active.value = key
|
|
228
|
+
pending.value = key
|
|
229
|
+
v?.update({})
|
|
230
|
+
reveal(key)
|
|
231
|
+
},
|
|
232
|
+
move(by) {
|
|
233
|
+
const all = keys.value, at = all.indexOf(active.value as VirtualKey)
|
|
234
|
+
control.focus(all[Math.max(0, Math.min(all.length - 1, (at < 0 ? v!.anchor().index : at) + by))]!)
|
|
235
|
+
},
|
|
236
|
+
active: () => active.value,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function onKeyDown(e: KeyboardEvent) {
|
|
240
|
+
if (props.roving && !e.altKey && !e.ctrlKey && !e.metaKey) {
|
|
241
|
+
const n = keys.value.length
|
|
242
|
+
const to = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : e.key === 'Home' ? -n : e.key === 'End' ? n : 0
|
|
243
|
+
if (to && (e.target as HTMLElement).closest?.('[data-part="row"]') === e.target) {
|
|
244
|
+
e.preventDefault()
|
|
245
|
+
control.move(to)
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
props.onRowKey?.(e, control)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let askedStart = -1, askedEnd = -1
|
|
253
|
+
function edges(indices: number[]) {
|
|
254
|
+
const n = keys.value.length
|
|
255
|
+
if (!n || !indices.length) return
|
|
256
|
+
if (props.onEnd && indices.at(-1)! >= n - EDGE && askedEnd !== n) { askedEnd = n; props.onEnd() }
|
|
257
|
+
if (props.onStart && indices[0]! <= EDGE && askedStart !== n) { askedStart = n; props.onStart() }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
onMount(() => {
|
|
261
|
+
queueMicrotask(() => {
|
|
262
|
+
if (!el) return
|
|
263
|
+
v = virtualizer(el, {
|
|
264
|
+
keys: untrack(() => keys.value), spacer: list!, estimate: untrack(() => props.estimate ?? 48), gap: untrack(() => props.gap), overscan: untrack(() => props.overscan),
|
|
265
|
+
stick: untrack(() => props.stick), from: untrack(() => props.from), size: (i, width) => {
|
|
266
|
+
if (width !== measuredAt && metrics.size) calibrate()
|
|
267
|
+
return size(i)
|
|
268
|
+
},
|
|
269
|
+
pinned: () => (active.value === undefined ? [] : [active.value]),
|
|
270
|
+
onRange: indices => { for (const i of indices) prepare(i); shown.value = indices.map(i => keys.value[i]!); edges(indices) },
|
|
271
|
+
onStick: s => props.onStick?.(s), onDrift: (i, p, a) => {
|
|
272
|
+
props.onDrift?.(i, p, a)
|
|
273
|
+
if (DEV && engine && Math.abs(p - a) > 1 && prepared.has(keys.value[i]!)) console.debug(`weft: X001 row ${i} predicted ${p}px and renders ${a}px`)
|
|
274
|
+
},
|
|
275
|
+
})
|
|
276
|
+
for (const [row, key, keep] of waiting.splice(0)) keep(v.attach(row, key))
|
|
277
|
+
effect(() => keys.value, k => {
|
|
278
|
+
v!.update({ keys: k })
|
|
279
|
+
const key = untrack(() => pending.value)
|
|
280
|
+
if (key !== undefined) reveal(key)
|
|
281
|
+
})
|
|
282
|
+
effect(() => active.value, () => v!.update({}))
|
|
283
|
+
untrack(() => props.control?.(control))
|
|
284
|
+
if (!untrack(() => props.text)) return
|
|
285
|
+
void measureEngine().then(e => {
|
|
286
|
+
if (!e || !el) return
|
|
287
|
+
engine = e
|
|
288
|
+
probing.value = true
|
|
289
|
+
flush()
|
|
290
|
+
start()
|
|
291
|
+
effect(() => items.value, (_, prev) => {
|
|
292
|
+
if (prev === undefined) return
|
|
293
|
+
let lo = -1, hi = 0
|
|
294
|
+
const to = slicing ? cursor : items.value.length
|
|
295
|
+
for (let i = 0; i < to; i++) if (prepare(i)) { if (lo < 0) lo = i; hi = i + 1 }
|
|
296
|
+
if (lo >= 0) v!.refresh(lo, hi)
|
|
297
|
+
})
|
|
298
|
+
effect(() => samples.value.length, () => { // a kind of row that wasn't here at the start
|
|
299
|
+
if (untrack(() => samples.value.every(([name]) => metrics.has(name)))) return
|
|
300
|
+
queueMicrotask(() => { calibrate(); prepareAll() })
|
|
301
|
+
})
|
|
302
|
+
document.fonts?.addEventListener?.('loadingdone', fontsDone)
|
|
303
|
+
dials = new MutationObserver(() => { calibrate(); prepareAll() })
|
|
304
|
+
dials.observe(root(), { attributes: true, attributeFilter: ['class', 'style', 'data-theme', 'data-expression', 'data-density'] })
|
|
305
|
+
})
|
|
306
|
+
})
|
|
307
|
+
onCleanup(() => {
|
|
308
|
+
stop?.()
|
|
309
|
+
dials?.disconnect()
|
|
310
|
+
document.fonts?.removeEventListener?.('loadingdone', fontsDone)
|
|
311
|
+
v?.destroy()
|
|
312
|
+
})
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
function Row(p: { row: { item: T; i: number; key: VirtualKey } }) {
|
|
316
|
+
let node: HTMLElement | undefined
|
|
317
|
+
const key = untrack(() => p.row.key)
|
|
318
|
+
onMount(() => {
|
|
319
|
+
let undo: (() => void) | undefined
|
|
320
|
+
const keep = (u: () => void) => (undo = u)
|
|
321
|
+
if (v) keep(v.attach(node!, key))
|
|
322
|
+
else waiting.push([node!, key, keep])
|
|
323
|
+
onCleanup(() => undo?.())
|
|
324
|
+
effect(() => pending.value === key, go => {
|
|
325
|
+
if (!go) return
|
|
326
|
+
pending.value = undefined
|
|
327
|
+
;(node!.querySelector<HTMLElement>('[data-focus]') ?? node!).focus()
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
const pos = () => p.row.i
|
|
331
|
+
const place = untrack(() => `position:absolute;inset-inline:0;inset-block-start:0;transform:translateY(${pos() * (props.estimate ?? 48)}px)`) // the first paint; the virtualizer owns it after
|
|
332
|
+
const attrs = () => ({
|
|
333
|
+
ref: (n: HTMLElement) => (node = n),
|
|
334
|
+
'data-part': 'row',
|
|
335
|
+
role: rowRole(),
|
|
336
|
+
'aria-posinset': props.bareRows ? undefined : pos() + 1,
|
|
337
|
+
'aria-setsize': props.bareRows ? undefined : props.open ? -1 : keys.value.length,
|
|
338
|
+
'data-index': props.bareRows ? pos() + 1 : undefined,
|
|
339
|
+
tabindex: props.roving ? (active.value === key || (active.value === undefined && key === shown.value[0]) ? 0 : -1) : undefined,
|
|
340
|
+
style: place,
|
|
341
|
+
onFocusIn: () => { active.value = key },
|
|
342
|
+
})
|
|
343
|
+
const body = props.children(() => p.row.item, pos)
|
|
344
|
+
return props.tag === 'ol' ? <li {...attrs()}>{body}</li> : <div {...attrs()}>{body}</div>
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const inner = untrack(() => props.inner)
|
|
348
|
+
const listRole = () => props.role ?? (props.bareRows ? 'group' : 'list')
|
|
349
|
+
/** A list's rows are its items (axe aria-required-children); `feed`, `log` and bare rows bring or need none. */
|
|
350
|
+
const rowRole = () => props.rowRole ?? (props.tag !== 'ol' && listRole() === 'list' ? 'listitem' : undefined)
|
|
351
|
+
const listAttrs = () => ({
|
|
352
|
+
ref: (n: HTMLElement) => (list = n),
|
|
353
|
+
'data-part': 'spacer',
|
|
354
|
+
'data-scope': props.listScope,
|
|
355
|
+
role: inner ? listRole() : props.tag === 'ol' ? undefined : rowRole() ? 'presentation' : undefined,
|
|
356
|
+
'aria-label': inner ? props.label : undefined,
|
|
357
|
+
'aria-busy': inner && props.busy ? 'true' : undefined,
|
|
358
|
+
style: 'position:relative;flex:none',
|
|
359
|
+
})
|
|
360
|
+
function Probe(p: { sample: [string, T] }) {
|
|
361
|
+
const [name, item] = untrack(() => p.sample)
|
|
362
|
+
const body = props.children(() => item, () => 0) // once: measuring overwrites the row's text
|
|
363
|
+
const attrs = { ref: (n: HTMLElement) => probes.set(name, n), 'data-part': 'row', 'data-probe': '', 'aria-hidden': 'true', inert: true, style: 'position:absolute;inset-inline:0;inset-block-start:0;visibility:hidden;contain:layout' }
|
|
364
|
+
return untrack(() => props.tag) === 'ol' ? <li {...attrs}>{body}</li> : <div {...attrs}>{body}</div>
|
|
365
|
+
}
|
|
366
|
+
return (
|
|
367
|
+
<div
|
|
368
|
+
ref={n => (el = n)}
|
|
369
|
+
class={props.class}
|
|
370
|
+
data-scope={props.scope ?? (props.part ? undefined : 'virtual')}
|
|
371
|
+
data-part={props.part}
|
|
372
|
+
data-variable={props.scope || props.part ? undefined : ''}
|
|
373
|
+
role={inner ? undefined : listRole()}
|
|
374
|
+
aria-label={inner ? undefined : props.label}
|
|
375
|
+
aria-live={props.role === 'log' ? 'off' : undefined}
|
|
376
|
+
aria-busy={!inner && props.busy ? 'true' : undefined}
|
|
377
|
+
tabindex={untrack(() => props.roving) ? undefined : props.tabindex ?? 0}
|
|
378
|
+
style={props.height ? `block-size:${props.height}` : undefined}
|
|
379
|
+
onKeyDown={onKeyDown}
|
|
380
|
+
>
|
|
381
|
+
{untrack(() => props.tag) === 'ol'
|
|
382
|
+
? <ol {...listAttrs()}>{rows.value.map(r => <Row key={r.key} row={r} />)}{probing.value && samples.value.map(s => <Probe key={s[0]} sample={s} />)}</ol>
|
|
383
|
+
: <div {...listAttrs()}>{rows.value.map(r => <Row key={r.key} row={r} />)}{probing.value && samples.value.map(s => <Probe key={s[0]} sample={s} />)}</div>}
|
|
384
|
+
</div>
|
|
385
|
+
)
|
|
386
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { KeyIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { agoText, usedWithinDay } from '@weft-js/warp-core'
|
|
3
|
+
import { derived, state, type State } from '@weft-js/core'
|
|
4
|
+
import { Badge } from '../Badge'
|
|
5
|
+
import { Button } from '../Button'
|
|
6
|
+
import { Checkbox } from '../Choice'
|
|
7
|
+
import { Input } from '../Field'
|
|
8
|
+
import { Modal } from '../Modal'
|
|
9
|
+
import { Select } from '../Select'
|
|
10
|
+
import { Table, type Column } from '../Table'
|
|
11
|
+
import { toast } from '../Toast'
|
|
12
|
+
import { ConfirmDialog, TypeToConfirm } from './Danger'
|
|
13
|
+
import { SecretReveal, useReveal } from './Secret'
|
|
14
|
+
import { afterFlush, guarded, latch, read } from './atoms'
|
|
15
|
+
import '../styles'
|
|
16
|
+
import '@weft-js/warp-core/styles/flows/tokens.css'
|
|
17
|
+
import '@weft-js/warp-core/styles/flows/base.css'
|
|
18
|
+
import '@weft-js/warp-core/styles/rich/helpers.css'
|
|
19
|
+
import '@weft-js/warp-core/styles/layout/tokens.css'
|
|
20
|
+
import '@weft-js/warp-core/styles/layout/row.css'
|
|
21
|
+
import '@weft-js/warp-core/styles/layout/stack.css'
|
|
22
|
+
import '@weft-js/warp-core/styles/flows/api-key.css'
|
|
23
|
+
import '@weft-js/warp-core/styles/flows/choice.css'
|
|
24
|
+
|
|
25
|
+
/** An API key as listed: never the secret, only its prefix. */
|
|
26
|
+
export type ApiKey = { id: string; name: string; prefix: string; scopes: string[]; created: string; lastUsed?: string }
|
|
27
|
+
/** What the create form hands to `onCreate`. */
|
|
28
|
+
export type KeyDraft = { name: string; scopes: string[]; expires: string }
|
|
29
|
+
|
|
30
|
+
/** Props for ApiKeys. */
|
|
31
|
+
export interface ApiKeysProps {
|
|
32
|
+
keys: readonly ApiKey[] | State<readonly ApiKey[]>
|
|
33
|
+
kind?: 'test' | 'live' | undefined
|
|
34
|
+
/** Now, for "last used" and the 24-hour revoke rule. */
|
|
35
|
+
now: string
|
|
36
|
+
scopes?: { value: string; label: string }[]
|
|
37
|
+
expiries?: { value: string; label: string }[]
|
|
38
|
+
/** Fresh credentials first (09 §2.1, a 5-minute grant); false stops. */
|
|
39
|
+
onReauth?: (reason: string) => Promise<boolean>
|
|
40
|
+
/** Returns the full secret, which leaves this component only through the Copy button. */
|
|
41
|
+
onCreate: (draft: KeyDraft) => Promise<string>
|
|
42
|
+
onRevoke: (key: ApiKey) => Promise<void> | void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const SCOPES = [{ value: 'read', label: 'Read projects and deploys' }, { value: 'deploy', label: 'Trigger deploys' }, { value: 'admin', label: 'Manage members (admins only)' }]
|
|
46
|
+
const EXPIRIES = [{ value: '30', label: 'In 30 days' }, { value: '90', label: 'In 90 days' }, { value: 'never', label: 'Never' }]
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* API keys (09 §4.2): a DataTable of prefixes. Create asks for re-auth, then name, scopes and expiry, then shows the key
|
|
50
|
+
* once: it can't be dismissed before "I've saved it". Revoke is type-to-confirm when the key was used in the last 24 h.
|
|
51
|
+
* Live keys sit behind SignGate, which the app wraps around this component.
|
|
52
|
+
*/
|
|
53
|
+
export function ApiKeys({ keys, kind = 'test', now, scopes = SCOPES, expiries = EXPIRIES, onReauth, onCreate, onRevoke }: ApiKeysProps) {
|
|
54
|
+
const creating = state(false)
|
|
55
|
+
const { secret, seen } = useReveal(creating)
|
|
56
|
+
const dialog = guarded(creating, () => !secret.value || seen.value)
|
|
57
|
+
const armed = latch(creating)
|
|
58
|
+
const name = state('')
|
|
59
|
+
const chosen = state<string[]>(['read'])
|
|
60
|
+
const expires = state('90')
|
|
61
|
+
const nameError = state('')
|
|
62
|
+
const scopeError = state('')
|
|
63
|
+
const busy = state(false)
|
|
64
|
+
const revoking = state<ApiKey | undefined>(undefined)
|
|
65
|
+
const typedOpen = state(false)
|
|
66
|
+
const plainOpen = state(false)
|
|
67
|
+
let opener: HTMLElement | undefined
|
|
68
|
+
let form: HTMLElement | undefined
|
|
69
|
+
const list = derived(() => read(keys))
|
|
70
|
+
const title = derived(() => (kind === 'live' ? 'live' : 'test'))
|
|
71
|
+
|
|
72
|
+
const start = async (e: MouseEvent) => {
|
|
73
|
+
opener = e.currentTarget as HTMLElement
|
|
74
|
+
if (onReauth && !(await onReauth('Creating an API key needs a fresh sign-in.'))) return
|
|
75
|
+
name.value = ''; chosen.value = ['read']; expires.value = '90'; nameError.value = ''; scopeError.value = ''
|
|
76
|
+
creating.value = true
|
|
77
|
+
}
|
|
78
|
+
const make = async () => {
|
|
79
|
+
if (busy.value) return
|
|
80
|
+
const n = name.value.trim()
|
|
81
|
+
nameError.value = !n ? 'Name the key so you can find it later.' : list.value.some(k => k.name.toLowerCase() === n.toLowerCase()) ? 'You already have a key with that name.' : ''
|
|
82
|
+
scopeError.value = chosen.value.length ? '' : 'Pick at least one scope.'
|
|
83
|
+
if (nameError.value || scopeError.value) return afterFlush(() => form?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus())
|
|
84
|
+
busy.value = true
|
|
85
|
+
try { secret.value = await onCreate({ name: n, scopes: chosen.value, expires: expires.value }) }
|
|
86
|
+
catch { toast.show({ title: 'The key wasn’t created', description: 'Try again in a moment.', tone: 'critical', action: { label: 'Retry', onClick: make } }) }
|
|
87
|
+
finally { busy.value = false }
|
|
88
|
+
if (secret.value) afterFlush(() => form?.ownerDocument.querySelector<HTMLElement>('dialog[open] input[readonly]')?.focus())
|
|
89
|
+
}
|
|
90
|
+
const toggle = (v: string, on: boolean) => { chosen.value = on ? [...chosen.value, v] : chosen.value.filter(x => x !== v); scopeError.value = '' }
|
|
91
|
+
const revoke = (k: ApiKey) => {
|
|
92
|
+
revoking.value = k
|
|
93
|
+
if (usedWithinDay(k.lastUsed, now)) typedOpen.value = true
|
|
94
|
+
else plainOpen.value = true
|
|
95
|
+
}
|
|
96
|
+
const gone = async () => {
|
|
97
|
+
const k = revoking.value!
|
|
98
|
+
await onRevoke(k)
|
|
99
|
+
toast.show({ title: `“${k.name}” revoked`, description: 'Requests with it now get 401.', tone: 'critical' })
|
|
100
|
+
afterFlush(() => opener?.focus())
|
|
101
|
+
}
|
|
102
|
+
const columns: Column<ApiKey>[] = [
|
|
103
|
+
{ id: 'name', header: 'Name', rowHeader: true, card: 'title', cell: k => <span data-scope="row"><b>{k.name}</b>{k.scopes.map(s => <Badge key={s}>{s}</Badge>)}</span> },
|
|
104
|
+
{ id: 'prefix', header: 'Key', cell: k => <span data-scope="api-key"><code data-part="prefix">{k.prefix}…</code></span> },
|
|
105
|
+
{ id: 'created', header: 'Created' },
|
|
106
|
+
{ id: 'lastUsed', header: 'Last used', value: k => (k.lastUsed ? agoText(k.lastUsed, now) : 'Never') },
|
|
107
|
+
{ id: 'revoke', header: 'Actions', value: () => '', cell: k => <Button emphasis="soft" tone="critical" size="sm" aria-label={`Revoke ${k.name}`} onClick={(e: MouseEvent) => { opener ??= e.currentTarget as HTMLElement; revoke(k) }}>Revoke</Button> },
|
|
108
|
+
]
|
|
109
|
+
const tools = derived(() => <Button onClick={start}>{`Create ${title.value} key`}</Button>) // one build: Table reads the prop twice
|
|
110
|
+
const footer = derived(() => (secret.value
|
|
111
|
+
? <Button disabled={!seen.value} onClick={() => (dialog.value = false)}>Done</Button>
|
|
112
|
+
: <><Button emphasis="frame" data-close="">Cancel</Button><Button loading={busy.value} onClick={make}>Create key</Button></>))
|
|
113
|
+
return (
|
|
114
|
+
<div data-scope="stack">
|
|
115
|
+
<Table caption={`${title.value === 'live' ? 'Live' : 'Test'} API keys`} captionHidden rows={list} columns={columns} picker={false} heading={3} actions={tools.value} empty={{ title: 'No keys yet', text: 'Create one to call the API.' }} />
|
|
116
|
+
{armed.value && (
|
|
117
|
+
<Modal open={dialog} alert={!!secret.value} title={secret.value ? 'Copy your new key' : `Create a ${title.value} key`} description={secret.value ? 'You won’t see it again. Store it in your secrets manager now.' : 'Give it a name you’ll recognise in the audit log.'} footer={<>{footer.value}</>}>
|
|
118
|
+
{secret.value
|
|
119
|
+
? <SecretReveal label={name.value} secret={secret.value} done={seen} ack="I’ve saved this key somewhere safe" />
|
|
120
|
+
: (
|
|
121
|
+
<form ref={n => (form = n)} data-scope="stack" style="padding-block-start:var(--warp-space-related)" novalidate onSubmit={(e: Event) => { e.preventDefault(); void make() }}>
|
|
122
|
+
<Input label="Key name" placeholder="e.g. CI deploys" autofocus value={name.value} error={nameError.value} onInput={e => { name.value = e.currentTarget.value; nameError.value = '' }} />
|
|
123
|
+
<fieldset data-scope="choice">
|
|
124
|
+
<legend>Scopes</legend>
|
|
125
|
+
{scopes.map(s => <Checkbox key={s.value} checked={chosen.value.includes(s.value)} error={s === scopes[0] && scopeError.value ? scopeError.value : undefined} onChange={(e: Event) => toggle(s.value, (e.currentTarget as HTMLInputElement).checked)}>{s.label}</Checkbox>)}
|
|
126
|
+
</fieldset>
|
|
127
|
+
<Select label="Expires" options={expiries} value={expires.value} onChange={(e: Event) => (expires.value = (e.currentTarget as HTMLInputElement).value)} />
|
|
128
|
+
</form>
|
|
129
|
+
)}
|
|
130
|
+
</Modal>
|
|
131
|
+
)}
|
|
132
|
+
<TypeToConfirm open={typedOpen} glyph={KeyIcon} title={`Revoke “${revoking.value?.name}”?`} description="This key was used in the last 24 hours. Anything using it stops working right away." confirmText={revoking.value?.name ?? ''} action="Revoke key" onConfirm={gone} />
|
|
133
|
+
<ConfirmDialog open={plainOpen} glyph={KeyIcon} tone="critical" title={`Revoke “${revoking.value?.name}”?`} description="Anything still using it stops working right away." action="Revoke key" onConfirm={gone} />
|
|
134
|
+
</div>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { agoText, announce, plural } from '@weft-js/warp-core'
|
|
2
|
+
import { derived, onCleanup, state, type State } from '@weft-js/core'
|
|
3
|
+
import { Button } from '../Button'
|
|
4
|
+
import { LiveStatus, type LiveState } from '../parts'
|
|
5
|
+
import { Tooltip } from '../Tooltip'
|
|
6
|
+
import { Segmented } from '../Choice'
|
|
7
|
+
import { Select } from '../Select'
|
|
8
|
+
import { Table, type Column } from '../Table'
|
|
9
|
+
import { read } from './atoms'
|
|
10
|
+
import '../styles'
|
|
11
|
+
import '@weft-js/warp-core/styles/flows/tokens.css'
|
|
12
|
+
import '@weft-js/warp-core/styles/flows/base.css'
|
|
13
|
+
import '@weft-js/warp-core/styles/rich/helpers.css'
|
|
14
|
+
import '@weft-js/warp-core/styles/layout/tokens.css'
|
|
15
|
+
import '@weft-js/warp-core/styles/layout/row.css'
|
|
16
|
+
import '@weft-js/warp-core/styles/layout/stack.css'
|
|
17
|
+
|
|
18
|
+
/** One line of the audit log. */
|
|
19
|
+
export type AuditEvent = { id: string; at: string; actor: string; category: string; verb: string; target: string; href?: string; ip: string; diff?: { field: string; from: string; to: string }[] }
|
|
20
|
+
|
|
21
|
+
/** Props for AuditLog. */
|
|
22
|
+
export interface AuditLogProps {
|
|
23
|
+
events: readonly AuditEvent[] | State<readonly AuditEvent[]>
|
|
24
|
+
now: string
|
|
25
|
+
categories?: { value: string; label: string }[]
|
|
26
|
+
/** The realtime pill, when the rows come from a live query. */
|
|
27
|
+
live?: LiveState | undefined
|
|
28
|
+
/** Resolve with `{ retryAfter }` (seconds) when rate limited (§5.5). */
|
|
29
|
+
onExport?: () => Promise<{ retryAfter?: number } | void>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const RANGES = [{ value: 'all', label: 'Any time', hours: Infinity }, { value: '24h', label: 'Last 24 hours', hours: 24 }, { value: '7d', label: 'Last 7 days', hours: 168 }, { value: '30d', label: 'Last 30 days', hours: 720 }]
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The audit log (09 §4.5): a read-only DataTable. Time is relative, exact on hover and focus; each event opens its diff.
|
|
36
|
+
* Filters: actor, action, date range. New rows arrive by key, so a live query never rebuilds the table.
|
|
37
|
+
*/
|
|
38
|
+
const stamp = (at: string) => at.replace('T', ' ').replace(/:\d\dZ$/, ' UTC')
|
|
39
|
+
|
|
40
|
+
/** The audit log: who did what and when, filtered by category and actor, with an export. */
|
|
41
|
+
export function AuditLog({ events, now, categories = [{ value: 'all', label: 'All' }], live, onExport }: AuditLogProps) {
|
|
42
|
+
const category = state('all')
|
|
43
|
+
const actor = state('all')
|
|
44
|
+
const range = state('all')
|
|
45
|
+
const wait = state(0)
|
|
46
|
+
const list = derived(() => read(events))
|
|
47
|
+
const actors = derived(() => [{ value: 'all', label: 'Everyone' }, ...[...new Set(list.value.map(e => e.actor))].map(a => ({ value: a, label: a }))])
|
|
48
|
+
const shown = derived(() => {
|
|
49
|
+
const hours = RANGES.find(r => r.value === range.value)!.hours
|
|
50
|
+
return list.value.filter(e => (category.value === 'all' || e.category === category.value) && (actor.value === 'all' || e.actor === actor.value) && Date.parse(now) - Date.parse(e.at) <= hours * 36e5)
|
|
51
|
+
})
|
|
52
|
+
let tick: ReturnType<typeof setInterval> | undefined
|
|
53
|
+
let root: HTMLElement | undefined
|
|
54
|
+
const say = (t: string) => { if (root) announce(root.ownerDocument, t) }
|
|
55
|
+
onCleanup(() => clearInterval(tick))
|
|
56
|
+
const refilter = () => queueMicrotask(() => say(`${plural(shown.value.length, 'event')} shown`))
|
|
57
|
+
const exportIt = async () => {
|
|
58
|
+
const r = await onExport?.()
|
|
59
|
+
if (!r?.retryAfter) return
|
|
60
|
+
wait.value = r.retryAfter
|
|
61
|
+
say(`Export limit reached. Try again in ${r.retryAfter} seconds.`)
|
|
62
|
+
tick = setInterval(() => {
|
|
63
|
+
wait.value -= 1
|
|
64
|
+
if (wait.value > 0) return
|
|
65
|
+
clearInterval(tick)
|
|
66
|
+
say('You can export again.')
|
|
67
|
+
}, 1000)
|
|
68
|
+
}
|
|
69
|
+
const columns: Column<AuditEvent>[] = [
|
|
70
|
+
{ id: 'at', header: 'When', sort: r => r.at, cell: e => { const at = stamp(e.at); return <Tooltip content={at}><time datetime={e.at} tabindex="0">{agoText(e.at, now)}</time></Tooltip> } },
|
|
71
|
+
{ id: 'actor', header: 'Who', rowHeader: true, card: 'title' },
|
|
72
|
+
{ id: 'what', header: 'What', value: e => `${e.verb} ${e.target}`, cell: e => <>{e.verb} {e.href ? <a href={e.href}>{e.target}</a> : e.target}</> },
|
|
73
|
+
{ id: 'ip', header: 'IP', priority: 2, cell: e => <code>{e.ip}</code> },
|
|
74
|
+
]
|
|
75
|
+
const diff = (e: AuditEvent) => (e.diff?.length
|
|
76
|
+
? <dl data-scope="desc">{e.diff.map(d => <div key={d.field}><dt>{d.field}</dt><dd>{d.from} → {d.to}</dd></div>)}</dl>
|
|
77
|
+
: <span class="muted">No field changes were recorded.</span>)
|
|
78
|
+
const hasLive = derived(() => live !== undefined)
|
|
79
|
+
return (
|
|
80
|
+
<div ref={n => (root = n)} data-scope="stack">
|
|
81
|
+
<div data-scope="row" data-justify="between">
|
|
82
|
+
<Segmented label="Filter events" size="sm" value={category.value} options={categories} onChange={v => { category.value = v; refilter() }} />
|
|
83
|
+
{hasLive.value && <LiveStatus state={live!} label="Audit log" />}
|
|
84
|
+
</div>
|
|
85
|
+
<div data-scope="row">
|
|
86
|
+
<div data-grow=""><Select label="Actor" size="sm" options={actors.value} value={actor.value} onChange={(e: Event) => { actor.value = (e.currentTarget as HTMLInputElement).value; refilter() }} /></div>
|
|
87
|
+
<div data-grow=""><Select label="Date range" size="sm" options={RANGES} value={range.value} onChange={(e: Event) => { range.value = (e.currentTarget as HTMLInputElement).value; refilter() }} /></div>
|
|
88
|
+
{onExport && <Button emphasis="frame" aria-disabled={wait.value > 0 ? 'true' : undefined} onClick={exportIt}>{wait.value > 0 ? `Try again in ${wait.value}s` : 'Export CSV'}</Button>}
|
|
89
|
+
</div>
|
|
90
|
+
<Table caption="Recent events" captionHidden rows={shown} columns={columns} picker={false} heading={3} details="sheet" expand={diff} empty={{ title: 'Nothing matches', text: 'Try a wider filter.' }} />
|
|
91
|
+
</div>
|
|
92
|
+
)
|
|
93
|
+
}
|