Skip to content
155 linesCodeBlameRaw
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 */
16import type { DatasetQuery, DatasetRange } from "./datasets";
17
18export type DashboardTileType = "stat" | "line" | "area" | "bar" | "stacked_bar" | "table" | "list" | "markdown";
19export const DASHBOARD_TILE_TYPES: readonly DashboardTileType[] = ["stat", "line", "area", "bar", "stacked_bar", "table", "list", "markdown"];
20
21export 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. */
33export type DashboardGrid = { x: number; y: number; w: number; h: number };
34export const DASHBOARD_COLUMNS = 12;
35export const DASHBOARD_MAX_ROWS = 200;
36export const DASHBOARD_MAX_TILES = 60;
37
38export type DashboardRefresh = "manual" | "5m" | "1h";
39export 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. */
42export type DashboardFilters = { range: DatasetRange; project?: string | null; repos?: string[] | null; team?: string | null };
43
44export type DashboardSettings = { filters: DashboardFilters; refresh: DashboardRefresh };
45
46/** How a tile draws its rows. Every field is optional and defaults by tile type. */
47export 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
58export 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. */
71export const DASHBOARD_MAP = "dashboard";
72export const DASHBOARD_TILES = "tiles";
73
74/** What a card shows of a dashboard: tile boxes and titles, never numbers. */
75export type DashboardPreview = { kind: "dashboard"; tiles: { type: DashboardTileType; title: string; grid: DashboardGrid }[] };
76
77/** A change an agent makes to a dashboard. */
78export 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
85export 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. */
88export 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 */
104export 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
153function isObject(value: unknown): value is Record<string, unknown> {
154 return typeof value === "object" && value !== null && !Array.isArray(value);
155}