diff --git a/module/AUDIT_METRICS.md b/module/AUDIT_METRICS.md index 02fdd4225..fd87791c6 100644 --- a/module/AUDIT_METRICS.md +++ b/module/AUDIT_METRICS.md @@ -204,6 +204,35 @@ events means partial telemetry. Older servers without samples cannot establish numeric completeness. SDK series expose `coverage: complete | partial | empty` and nullable `total`. +Time buckets are **UTC** days, weeks and months, whatever zone the database +session runs in, and every key reads `YYYY-MM-DDT00:00:00+00`. Keyed in the +session's zone, a key's offset would move across daylight saving, so neither a +bucket the aggregate did not return nor the kit's UTC label could be placed. + +A count, distinct count or sum grouped by **time** is zero-filled where the series +is produced, in the SDK's `runMetric`: every day (or week, or month) from the +first bucket the aggregate returned through the end of the range — the bucket +holding `to`, or today for an open-ended query — that it did not return gets a +`0`, because no event matched there. Without it a chart spaces points by +position, so a day with no events vanished and the line ran straight over it, +and the days since the last event vanished too, so the newest point and the +"vs previous bucket" delta read an old day as the latest. The current bucket is +in progress, so its value is so far. An average, minimum, maximum or percentile +has no value over no events and is not filled; a bucket the aggregate *did* +return whose value was dropped as unknown stays out and keeps the series +partial; days before the first bucket are not filled; returned keys that +disagree about their offset are left as they are rather than given a guessed +one; and a series made only of filled zeros has no total. + +A derived metric over such series reads a day an input has no bucket for as +that input's `0`, wherever the day falls: a failure rate whose failures start +after the requests, or never happen, reads `0` on those days and stays +complete, rather than dropping them as missing telemetry. A day no input had +events on has no ratio (0/0) and keeps the series complete; a sum or difference +of such series is filled the same way and can feed another derived metric. An +input that is partial, or whose aggregation has no value over no events, keeps +a missing day unknown. + Missing derived operands and zero denominators stay unknown. Partial series keep observed chart points with a visible label and withhold the total. Distinct counts, averages, percentiles and ratios have no scalar total across multiple @@ -228,11 +257,25 @@ does not have to ask which half of the kit its dashboard will get. | `line` / `area` / `bar` | the kit's metric chart over a `ChartSeries` — axes from the appearance palette, a crosshair readout, a legend for two or more series, and every exact value in a visually-hidden data table | the chart's own **No data** plot, plus a **No data** badge on the card | the observed points still plot, with a **Partial data** badge on the card | nothing: a series widget shows no total | | `table` | key/value rows | **No data yet.** | a **Partial data** badge on the card | — | -A scalar widget's `format` names how its value is written, and `percent` takes a -value that is **already a percentage**: 45 renders "45%". It is not a share — -0.4 renders "0.4%". A producer whose own `percent` means a 0-to-1 share scales it -before it reaches the view, because the field is bound structurally and nothing -on the wire distinguishes the two conventions. +A widget's `format` names how its values are written — a scalar's number, a +chart's axis, readout and data table, and a table's values all write through the +one function `StatTile` uses — and the view's `percent` takes a value that is +**already a percentage**: 45 renders "45%". It is not a share — 0.4 renders +"0.4%". A data graph's `format: "percent"` means the opposite, a share from 0 to +1, because that is what a ratio metric produces. The two meet at exactly one +place: `runDashboard` multiplies a percent metric's points and total by 100 on +the widget it resolves (`byMetric` keeps the shares), so a declared 0.4 is drawn +"40%" on every mark, and nothing after it scales again. A producer of its own +with share semantics must do the same before the view. + +A view may group its widgets into **sections**: `sections` lists them in the +order they are drawn (`id`, `title`, an optional `description`) and a widget's +`section` names the one it is drawn in. Each section is headed by the kit's +`DashboardSectionHeader`, whose title is an `
host layout
; + }; + const { rerender } = render( +Empty.
+Group {group.id}
, + }))} + onSwap={vi.fn()} + itemLabel={(id) => `Tile ${id}`} + renderItem={(id) => `Tile ${id}`} + {...props} + /> + ); + } + + function renderBoard(groups = GROUPS) { + const onSwap = vi.fn(); + const onMove = vi.fn(); + const view = render(board(groups, { onSwap, onMove })); + return { onSwap, onMove, ...view }; + } + + const middle = (element: HTMLElement) => { + const { left, top } = element.getBoundingClientRect(); + return { clientX: left + CELL / 2, clientY: top + CELL / 2 }; + }; + const slots = () => screen.queryAllByText("Move here"); + const slotOf = (group: string) => { + const found = slots().find( + (element) => element.dataset.sortableSlot === group, + ); + if (!found) throw new Error(`no slot in group ${group}`); + return found; + }; + + // Picks a tile up, past the 5px a mouse drag needs to start. Returns the + // moves and the drop, so a test can aim at a slot the drag brought up. + function pickUp(id: string) { + const start = middle(tile(id)); + fireEvent.mouseDown(tile(id), { ...start, button: 0 }); + fireEvent.mouseMove(document, { + clientX: start.clientX + 10, + clientY: start.clientY + 10, + }); + let at = start; + return { + moveTo(to: { clientX: number; clientY: number }) { + fireEvent.mouseMove(document, { + clientX: (at.clientX + to.clientX) / 2, + clientY: (at.clientY + to.clientY) / 2, + }); + fireEvent.mouseMove(document, to); + at = to; + }, + drop() { + fireEvent.mouseUp(document, at); + }, + }; + } + + const announced = () => + document.querySelector('[id^="DndLiveRegion"]')?.textContent ?? ""; + + it("draws each group's header above its tiles, in order", () => { + renderBoard(); + expect(screen.getByText("Group one")).toBeTruthy(); + expect(order()).toEqual(["a", "b", "c", "x", "y"]); + const two = tile("x").closest("[data-sortable-group]"); + expect(two?.textContent).toBe("Group twoTile xTile y"); + }); + + it("swaps a tile dropped on a tile in another group, and names that group", () => { + const { onSwap, onMove } = renderBoard(); + const drag = pickUp("a"); + drag.moveTo(middle(tile("y"))); + // The two trade places across the groups while the drag is in flight. + expect(tile("a").style.transform).toBe("translate3d(110px, 400px, 0)"); + expect(tile("y").style.transform).toBe("translate3d(-110px, -400px, 0)"); + expect(tile("b").style.transform).toBe(""); + expect(announced()).toContain( + "Tile a will swap with Tile y and move to Group two.", + ); + + drag.drop(); + expect(onSwap).toHaveBeenCalledExactlyOnceWith("a", "y"); + expect(onMove).not.toHaveBeenCalled(); + }); + + it("shows a slot at the end of every group only while a drag is in flight", () => { + renderBoard(); + expect(slots()).toEqual([]); + const drag = pickUp("b"); + expect(slots().map((element) => element.dataset.sortableSlot)).toEqual([ + "one", + "two", + ]); + // Each slot comes after its group's last tile. + expect(slotOf("two").previousElementSibling).toBe(tile("y")); + drag.drop(); + expect(slots()).toEqual([]); + }); + + it("moves a tile dropped on a group's slot to the end of that group", () => { + const { onSwap, onMove } = renderBoard(); + const drag = pickUp("a"); + drag.moveTo(middle(slotOf("two"))); + expect(announced()).toContain("Tile a will move to the end of Group two."); + drag.drop(); + expect(onMove).toHaveBeenCalledExactlyOnceWith("a", "two"); + expect(onSwap).not.toHaveBeenCalled(); + }); + + it("changes nothing when the last tile of a group is dropped on that group's slot", () => { + const { onSwap, onMove } = renderBoard(); + const drag = pickUp("y"); + drag.moveTo(middle(slotOf("two"))); + drag.drop(); + expect(onMove).not.toHaveBeenCalled(); + expect(onSwap).not.toHaveBeenCalled(); + }); + + it("lands a drop in the gap between a group's tiles on its nearest one", () => { + const { onSwap } = renderBoard(); + const drag = pickUp("a"); + // 3px right of x, in the gap before y: x's center is the nearer. + drag.moveTo({ clientX: CELL + 3, clientY: GROUP_GAP + 40 }); + drag.drop(); + expect(onSwap).toHaveBeenCalledExactlyOnceWith("a", "x"); + }); + + it("changes nothing when the drop is between two groups, or off the board", () => { + const { onSwap, onMove } = renderBoard(); + const between = pickUp("x"); + // Below group one's slot and above group two: on neither group. + between.moveTo({ clientX: 50, clientY: GROUP_GAP - 50 }); + between.drop(); + const off = pickUp("x"); + off.moveTo({ clientX: 900, clientY: 2000 }); + off.drop(); + expect(onSwap).not.toHaveBeenCalled(); + expect(onMove).not.toHaveBeenCalled(); + }); + + it("glides a tile that moved to another group from where it was drawn", () => { + const animate = vi.fn(); + HTMLElement.prototype.animate = animate; + const { rerender } = renderBoard(); + rerender( + board([ + { id: "one", ids: ["b", "c"] }, + { id: "two", ids: ["x", "y", "a"] }, + ]), + ); + const glides = new Map( + animate.mock.contexts.map((element, call) => [ + (element as HTMLElement).dataset.sortableId, + animate.mock.calls[call][0][0].transform, + ]), + ); + // a left the first cell of group one for the third of group two; b and + // c each moved up a place behind it; x and y did not move. + expect(glides).toEqual( + new Map([ + ["b", "translate(110px, 0px)"], + ["c", "translate(-110px, 110px)"], + ["a", "translate(0px, -510px)"], + ]), + ); + }); +}); + +describe("SortableBoard, moving groups", () => { + // The board starts TOP down the page, under whatever the page draws above + // it. Each group is a band BAND tall, GROUP_GAP below the one before it, + // with a header HEADER tall at its top; its tiles are the same two-column + // cells, under the header. Heights below are from the board's top: group + // one's middle is at 150, two's at 550. + const TOP = 200; + const GROUP_GAP = 400; + const BAND = 300; + const HEADER = 30; + const WIDTH = 2 * CELL + GAP; + const bandOf = (group: Element | null | undefined) => + group?.parentElement ? [...group.parentElement.children].indexOf(group) : 0; + beforeEach(() => { + HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) { + let rect = { left: 0, top: 0, right: CELL, bottom: CELL }; + if (this.dataset.sortableGroup !== undefined) { + const top = TOP + bandOf(this) * GROUP_GAP; + rect = { left: 0, top, right: WIDTH, bottom: top + BAND }; + } else if (this.dataset.sortableGroupHeader !== undefined) { + const top = TOP + bandOf(this.parentElement) * GROUP_GAP; + rect = { left: 0, top, right: WIDTH, bottom: top + HEADER }; + } else if (this.dataset.sortableId !== undefined) { + const list = this.parentElement; + const cell = slot([...(list?.children ?? [])].indexOf(this)); + const top = TOP + bandOf(list?.parentElement) * GROUP_GAP + HEADER; + rect = { ...cell, top: cell.top + top, bottom: cell.bottom + top }; + } + return { + ...rect, + x: rect.left, + y: rect.top, + width: rect.right - rect.left, + height: rect.bottom - rect.top, + toJSON: () => ({}), + } as DOMRect; + }; + }); + + const GROUPS = [ + { id: "one", ids: ["a", "b"] }, + { id: "two", ids: ["x"] }, + { id: "three", ids: ["y"] }, + ]; + + // Each group's header holds a grip it is dragged by. + function board( + groups: { id: string; ids: string[]; fixed?: boolean }[], + props: PartialPreview {group.id}
, + }))} + onSwap={vi.fn()} + itemLabel={(id) => `Tile ${id}`} + renderItem={(id) => `Tile ${id}`} + {...props} + /> + ); + } + + function renderBoard( + groups: { id: string; ids: string[]; fixed?: boolean }[] = GROUPS, + ) { + const onSwap = vi.fn(); + const onMove = vi.fn(); + const onMoveGroup = vi.fn(); + const view = render(board(groups, { onSwap, onMove, onMoveGroup })); + return { onSwap, onMove, onMoveGroup, ...view }; + } + + const group = (id: string) => { + const found = document.querySelector{description}
+ )} + {children} ++ No widgets in this section. +
+ ) : ( +No widgets.
)) ) : slots?.layout ? ( slots.layout(tiles) + ) : sections.length > 0 ? ( ++ No widgets in this section. +
++ + {group.title} +
+ ), + preview:{group.title}
, + className: "grid grid-cols-2 gap-4", + }))} + onSwap={(dragged, target) => + setGroups((current) => + current.map((group) => ({ + ...group, + ids: group.ids.map((id) => + id === dragged ? target : id === target ? dragged : id, + ), + })), + ) + } + onMove={(dragged, to) => + setGroups((current) => + current.map((group) => ({ + ...group, + ids: [ + ...group.ids.filter((id) => id !== dragged), + ...(group.id === to ? [dragged] : []), + ], + })), + ) + } + onMoveGroup={(moved, index) => + setGroups((current) => { + const next = current.filter((group) => group.id !== moved); + const group = current.find(({ id }) => id === moved); + if (group) next.splice(index, 0, group); + return next; + }) + } + renderItem={(id) => ( +{id}
+{title}
+ {description && ( +{description}
+ )} ++ No widgets in this section. Drag a tile here, or add one back with +. +
+ )} +- No widgets are shown. Add one back with the + button. -
- ), - }} - /> ++ No widgets are shown. Add one back with the + button. +
+ ), + }} + /> + ++ {announcement} +
+