Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet | 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 | } |