| 1 | /** |
| 2 | * Dashboards (`kind: "dashboard"`, beta): a dashboard's definition and the |
| 3 | * changes agents make to it. Artifacts mode, docs/ARTIFACTS_MODE.md |
| 4 | * section 3.4. Wire shapes are snake_case. |
| 5 | * |
| 6 | * In the Yjs document: `Y.Map("dashboard")` holds `DashboardSettings`; |
| 7 | * `Y.Array("tiles")` holds one `Y.Map` per tile (`DashboardTile`). Only the |
| 8 | * definition is stored. Numbers are computed per viewer when the page asks |
| 9 | * (`query_tile`), and never saved in the folio, its versions, text, preview, |
| 10 | * index or templates. |
| 11 | * |
| 12 | * A tile's query is a `DatasetQuery` (datasets.ts). Ops are checked here |
| 13 | * with the query validator passed in (`datasetQueryError`), so this file |
| 14 | * stays free of imports Node can't run as is in tests. |
| 15 | */ |
| 16 | import type { DatasetQuery, DatasetRange } from "./datasets"; |
| 17 | |
| 18 | export type DashboardTileType = "stat" | "line" | "area" | "bar" | "stacked_bar" | "table" | "list" | "markdown"; |
| 19 | export const DASHBOARD_TILE_TYPES: readonly DashboardTileType[] = ["stat", "line", "area", "bar", "stacked_bar", "table", "list", "markdown"]; |
| 20 | |
| 21 | export const DASHBOARD_TILE_TYPE_LABELS: Record<DashboardTileType, string> = { |
| 22 | stat: "Number", |
| 23 | line: "Line chart", |
| 24 | area: "Area chart", |
| 25 | bar: "Bar chart", |
| 26 | stacked_bar: "Stacked bars", |
| 27 | table: "Table", |
| 28 | list: "List", |
| 29 | markdown: "Text", |
| 30 | }; |
| 31 | |
| 32 | /** Where a tile sits on the 12-column grid: column, row, width and height in cells. */ |
| 33 | export type DashboardGrid = { x: number; y: number; w: number; h: number }; |
| 34 | export const DASHBOARD_COLUMNS = 12; |
| 35 | export const DASHBOARD_MAX_ROWS = 200; |
| 36 | export const DASHBOARD_MAX_TILES = 60; |
| 37 | |
| 38 | export type DashboardRefresh = "manual" | "5m" | "1h"; |
| 39 | export const DASHBOARD_REFRESHES: readonly DashboardRefresh[] = ["manual", "5m", "1h"]; |
| 40 | |
| 41 | /** Filters every tile starts from; a tile's own `range` wins over the dashboard's. */ |
| 42 | export type DashboardFilters = { range: DatasetRange; project?: string | null; repos?: string[] | null; team?: string | null }; |
| 43 | |
| 44 | export type DashboardSettings = { filters: DashboardFilters; refresh: DashboardRefresh }; |
| 45 | |
| 46 | /** How a tile draws its rows. Every field is optional and defaults by tile type. */ |
| 47 | export type DashboardViz = { |
| 48 | /** Show a number as money, a percentage, a duration or plain. */ |
| 49 | format?: "number" | "money" | "percent" | "duration" | null; |
| 50 | /** Lines and bars: show the legend. */ |
| 51 | legend?: boolean; |
| 52 | /** stat: draw the sparkline when the query has an interval. */ |
| 53 | sparkline?: boolean; |
| 54 | /** Colours by series name; else the theme's order. */ |
| 55 | colors?: Record<string, string> | null; |
| 56 | }; |
| 57 | |
| 58 | export type DashboardTile = { |
| 59 | id: string; |
| 60 | type: DashboardTileType; |
| 61 | title: string; |
| 62 | description: string; |
| 63 | /** Null on a `markdown` tile, which has `markdown` instead. */ |
| 64 | query: DatasetQuery | null; |
| 65 | markdown?: string | null; |
| 66 | viz: DashboardViz; |
| 67 | grid: DashboardGrid; |
| 68 | }; |
| 69 | |
| 70 | /** The Yjs names a dashboard uses. */ |
| 71 | export const DASHBOARD_MAP = "dashboard"; |
| 72 | export const DASHBOARD_TILES = "tiles"; |
| 73 | |
| 74 | /** What a card shows of a dashboard: tile boxes and titles, never numbers. */ |
| 75 | export type DashboardPreview = { kind: "dashboard"; tiles: { type: DashboardTileType; title: string; grid: DashboardGrid }[] }; |
| 76 | |
| 77 | /** A change an agent makes to a dashboard. */ |
| 78 | export type DashboardOp = |
| 79 | /** Add a tile, or replace the one with its id. */ |
| 80 | | { op: "upsert_tile"; tile: DashboardTile } |
| 81 | | { op: "delete_tiles"; ids: string[] } |
| 82 | | { op: "set_filters"; filters: DashboardFilters } |
| 83 | | { op: "set_layout"; tiles: { id: string; grid: DashboardGrid }[] }; |
| 84 | |
| 85 | export const DASHBOARD_OPS: readonly DashboardOp["op"][] = ["upsert_tile", "delete_tiles", "set_filters", "set_layout"]; |
| 86 | |
| 87 | /** What is wrong with a tile's place on the grid, or null. */ |
| 88 | export function dashboardGridError(grid: unknown): string | null { |
| 89 | if (!isObject(grid)) return "grid is { x, y, w, h }."; |
| 90 | const { x, y, w, h } = grid; |
| 91 | if (![x, y, w, h].every((n) => typeof n === "number" && Number.isInteger(n))) return "grid's x, y, w and h are whole numbers."; |
| 92 | const [gx, gy, gw, gh] = [x, y, w, h] as number[]; |
| 93 | if (gx < 0 || gy < 0 || gw < 1 || gh < 1) return "grid starts at 0, 0 and is at least 1 by 1."; |
| 94 | if (gx + gw > DASHBOARD_COLUMNS) return `A tile fits in ${DASHBOARD_COLUMNS} columns.`; |
| 95 | if (gy + gh > DASHBOARD_MAX_ROWS) return `A dashboard is at most ${DASHBOARD_MAX_ROWS} rows tall.`; |
| 96 | return null; |
| 97 | } |
| 98 | |
| 99 | /** |
| 100 | * What is wrong with a dashboard op, or null. `queryError` checks a tile's |
| 101 | * query: pass `datasetQueryError` from datasets.ts. Whether ids exist is the |
| 102 | * room's to say. |
| 103 | */ |
| 104 | export function dashboardOpError(op: unknown, queryError: (query: DatasetQuery) => string | null): string | null { |
| 105 | if (!isObject(op) || typeof op.op !== "string") return "A dashboard change has an op."; |
| 106 | switch (op.op) { |
| 107 | case "upsert_tile": { |
| 108 | const tile = op.tile; |
| 109 | if (!isObject(tile)) return "upsert_tile needs a tile."; |
| 110 | if (typeof tile.id !== "string" || tile.id.length === 0) return "A tile has an id."; |
| 111 | if (typeof tile.type !== "string" || !(DASHBOARD_TILE_TYPES as readonly string[]).includes(tile.type)) return `There is no tile type called ${String(tile.type)}.`; |
| 112 | if (typeof tile.title !== "string" || tile.title.length > 200) return "A tile's title is text of at most 200 characters."; |
| 113 | if (tile.description !== undefined && typeof tile.description !== "string") return "A tile's description is text."; |
| 114 | if (tile.type === "markdown") { |
| 115 | if (tile.query != null) return "A text tile has no query."; |
| 116 | if (typeof tile.markdown !== "string") return "A text tile has markdown."; |
| 117 | } else { |
| 118 | if (!isObject(tile.query)) return "A chart tile has a query."; |
| 119 | const error = queryError(tile.query as DatasetQuery); |
| 120 | if (error) return error; |
| 121 | } |
| 122 | return dashboardGridError(tile.grid); |
| 123 | } |
| 124 | case "delete_tiles": |
| 125 | return Array.isArray(op.ids) && op.ids.length > 0 && op.ids.every((id) => typeof id === "string" && id.length > 0) ? null : "delete_tiles needs ids."; |
| 126 | case "set_filters": { |
| 127 | const filters = op.filters; |
| 128 | if (!isObject(filters)) return "set_filters needs filters."; |
| 129 | const range = filters.range; |
| 130 | const preset = typeof range === "string" && ["7d", "30d", "90d"].includes(range); |
| 131 | if (!preset && !(isObject(range) && typeof range.from === "string" && typeof range.to === "string")) return "range is 7d, 30d, 90d or { from, to }."; |
| 132 | if (isObject(range)) { |
| 133 | const error = queryError({ dataset: "issues", measure: { op: "count" }, range: { from: range.from as string, to: range.to as string } }); |
| 134 | if (error) return error; |
| 135 | } |
| 136 | if (filters.repos != null && !(Array.isArray(filters.repos) && filters.repos.every((repo) => typeof repo === "string"))) return "repos is a list of owner/name."; |
| 137 | return null; |
| 138 | } |
| 139 | case "set_layout": { |
| 140 | if (!Array.isArray(op.tiles) || op.tiles.length === 0) return "set_layout needs tiles."; |
| 141 | for (const tile of op.tiles) { |
| 142 | if (!isObject(tile) || typeof tile.id !== "string") return "set_layout's tiles are { id, grid }."; |
| 143 | const error = dashboardGridError(tile.grid); |
| 144 | if (error) return error; |
| 145 | } |
| 146 | return null; |
| 147 | } |
| 148 | default: |
| 149 | return `There is no dashboard op called ${op.op}.`; |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | function isObject(value: unknown): value is Record<string, unknown> { |
| 154 | return typeof value === "object" && value !== null && !Array.isArray(value); |
| 155 | } |