Skip to content
154 linesCodeBlameRaw

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