From 14b2c5b0c163d9339aeda2e60b736aabc027a242 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 03:37:50 +0000 Subject: [PATCH 1/2] feat(grafana-jump): fix dark-mode inputs, add repo-shared config Config modal inputs relied on the browser's default text/background colors, which dark-mode browsers render illegibly against the modal's fixed light panel - now explicitly styled. Also lets a repo publish shared dashboard config via .github/jump-links.config.yaml, fetched from raw.githubusercontent.com so it works without any auth. A contributor's personal config still wins outright wherever it applies; the repo config is only a fallback for pages nobody has personally configured. The config panel gains "Create repo config template" and "Export my config to repo" buttons that hand off to GitHub's own create-file UI, so the userscript never touches git history itself - export unions the two configs (deduped by dashboard uid) rather than overwriting the repo's file. Adds this repo's own .github/jump-links.config.yaml as a live example. --- .github/jump-links.config.yaml | 32 ++ .../github-actions-grafana-jump/src/index.ts | 525 +++++++++++++++++- .../github-actions-grafana-jump/src/meta.json | 3 +- .../test/grafana-jump.test.js | 164 ++++++ 4 files changed, 706 insertions(+), 18 deletions(-) create mode 100644 .github/jump-links.config.yaml diff --git a/.github/jump-links.config.yaml b/.github/jump-links.config.yaml new file mode 100644 index 0000000..87643da --- /dev/null +++ b/.github/jump-links.config.yaml @@ -0,0 +1,32 @@ +# Config for the "GitHub Actions => Grafana jump button" userscript +# (packages/github-actions-grafana-jump). Gives every contributor to this +# repo the same dashboards without each of them configuring the userscript by +# hand. A contributor's own personal config (set via the userscript's own +# "Set up Grafana jump" panel) always takes priority over this file for any +# page it covers - this is only a fallback for pages nobody has personally +# configured. Bootstrap one of these for your own repo via the userscript's +# config panel -> "Create repo config template" / "Export my config to repo". +# +# This repo doesn't actually run a Grafana instance - the values below are +# illustrative placeholders, kept here purely to document and exercise the +# file format the userscript reads. +# +# baseUrl: your Grafana instance's base URL (no trailing slash). +# dashboards: one entry per dashboard you want jumpable to. For each: +# name - display label for the jump button/menu. +# uid - the dashboard's UID (Grafana dashboard settings -> JSON Model, +# or the segment right after /d/ in the dashboard's URL). +# slug - the URL slug right after the uid in the dashboard's URL. +# varNames - which of this dashboard's template variables (if any) to +# preset from the current GitHub page. Leave a field out if the +# dashboard doesn't use that kind of filter. +baseUrl: https://grafana.example.com +dashboards: + - name: CI Overview + uid: ci-overview-uid + slug: ci-overview + varNames: + branch: branch + prNumber: pr_number + workflowName: workflow_file + runnerName: runner_name diff --git a/packages/github-actions-grafana-jump/src/index.ts b/packages/github-actions-grafana-jump/src/index.ts index 86b3d41..3e411fb 100644 --- a/packages/github-actions-grafana-jump/src/index.ts +++ b/packages/github-actions-grafana-jump/src/index.ts @@ -116,6 +116,9 @@ interface RunnerContext { kind: "runner"; scope: "repo" | "org"; org: string; + // Only present for scope "repo" - an org-scoped runner page isn't under any + // one repo, so there's nothing to look up a repo config for. + repo?: string; runnerId: string; } @@ -188,10 +191,10 @@ function parseRunnerContext(pathname: string): RunnerContext | null { return { kind: "runner", scope: "org", org, runnerId }; } - const repoMatch = pathname.match(/^\/([^/]+)\/[^/]+\/settings\/actions\/runners\/(\d+)/); + const repoMatch = pathname.match(/^\/([^/]+)\/([^/]+)\/settings\/actions\/runners\/(\d+)/); if (repoMatch) { - const [, org, runnerId] = repoMatch; - return { kind: "runner", scope: "repo", org, runnerId }; + const [, org, repo, runnerId] = repoMatch; + return { kind: "runner", scope: "repo", org, repo, runnerId }; } return null; @@ -263,6 +266,89 @@ function applicableDashboards(config: GrafanaJumpConfig, context: JumpContext): return config.dashboards.filter((dashboard) => Boolean(dashboard.varNames[key])); } +/** + * The {org, repo} a jump context belongs to, for looking up that repo's + * `.github/jump-links.config.yaml` - or null when the context isn't scoped to + * one repo (an org-scoped runner page covers every repo in the org, so there's + * no single repo config to fetch). + */ +function repoContextForJump(context: JumpContext): { org: string; repo: string } | null { + switch (context.kind) { + case "pr": + case "branch": + case "workflow": + return { org: context.org, repo: context.repo }; + case "runner": + return context.repo ? { org: context.org, repo: context.repo } : null; + } +} + +/** One dashboard paired with the base URL of the config it came from. */ +interface ActiveDashboard { + baseUrl: string; + dashboard: DashboardConfig; +} + +/** + * Resolves which dashboards are actually offered as jump targets for a + * context, given the user's own (personal, GM-storage) config and the + * current repo's checked-in config (or null if there isn't one / it failed to + * load). The personal config always wins outright when it has anything + * applicable to this context - repoConfig is a fallback for contributors who + * haven't set up their own config yet, not something merged dashboard-by- + * dashboard with the personal one. Merging would require reconciling two + * potentially different Grafana base URLs per dashboard; keeping the two + * configs mutually exclusive per render avoids that entirely. + */ +function activeDashboards( + personalConfig: GrafanaJumpConfig, + repoConfig: GrafanaJumpConfig | null, + context: JumpContext, +): ActiveDashboard[] { + const personal = applicableDashboards(personalConfig, context); + if (personal.length > 0) { + return personal.map((dashboard) => ({ baseUrl: personalConfig.baseUrl, dashboard })); + } + if (!repoConfig) return []; + return applicableDashboards(repoConfig, context).map((dashboard) => ({ + baseUrl: repoConfig.baseUrl, + dashboard, + })); +} + +/** + * Combines a repo's existing checked-in config with the current user's own + * config, for exporting back into the repo - unlike activeDashboards() above, + * this is a real union: the point of exporting is to publish your personal + * dashboards for the rest of the repo, on top of whatever's already shared, + * not to pick one source over the other. Dashboards are deduped by uid, + * preferring the repo's own copy of a uid that appears in both (it may have + * been intentionally edited by someone else since you last synced). baseUrl + * prefers the repo's if it has one, since the merged dashboard list is + * exported as a single file with one shared baseUrl field - if your personal + * dashboards actually live under a *different* Grafana instance than the + * repo's, this merge would produce an incorrect shared baseUrl for one set of + * them; that caveat is surfaced in the exported file's header comment rather + * than silently guessed at here. + */ +function mergeConfigsForExport( + repoConfig: GrafanaJumpConfig | null, + personalConfig: GrafanaJumpConfig, +): GrafanaJumpConfig { + const merged = repoConfig ? [...repoConfig.dashboards] : []; + const knownUids = new Set(merged.map((d) => d.uid)); + for (const dashboard of personalConfig.dashboards) { + if (!knownUids.has(dashboard.uid)) { + merged.push(dashboard); + knownUids.add(dashboard.uid); + } + } + return { + baseUrl: repoConfig?.baseUrl || personalConfig.baseUrl, + dashboards: merged, + }; +} + /** * Builds a Grafana dashboard URL with one or more template variables preset via * the `var-=` query convention. @@ -305,6 +391,191 @@ function labelForContext(context: JumpContext): string { } } +// --------------------------------------------------------------------------- +// Repo config parsing. A repo can check in .github/jump-links.config.yaml to +// give every contributor the same dashboards without each of them filling in +// the config panel by hand (see activeDashboards() above for how it's +// combined with a contributor's own personal config). +// +// This is a small hand-rolled parser for a deliberate YAML *subset* - just +// enough to read {baseUrl, dashboards: [{...}]} - rather than a real YAML +// parser. Pulling in a full one (e.g. js-yaml) isn't a plain npm dependency +// here: a userscript has no bundler, so the only way to ship a third-party +// library alongside it is an `@require` of remote code fetched by the user's +// script manager at run time - that's a supply-chain surface (arbitrary +// third-party code, outside this repo's own build/audit process) worth +// avoiding for a format this small. Supported shape: 2-space-indented (or any +// consistent width) block mappings and sequences of block mappings, plain or +// single/double-quoted scalars, blank lines, and full-line `#` comments. No +// flow style (`{a: b}`/`[a, b]`), anchors, multi-line scalars, or tabs. +// --------------------------------------------------------------------------- + +interface YamlLiteLine { + indent: number; + content: string; +} + +function tokenizeYamlLite(text: string): YamlLiteLine[] { + return text + .split("\n") + .map((line) => line.replace(/\r$/, "")) + .filter((line) => line.trim() !== "" && !line.trim().startsWith("#")) + .map((line) => ({ + indent: line.length - line.replace(/^ */, "").length, + content: line.trim(), + })); +} + +function unquoteYamlLiteScalar(value: string): string { + const trimmed = value.trim(); + const isQuoted = + trimmed.length >= 2 && + ((trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'"))); + return isQuoted ? trimmed.slice(1, -1) : trimmed; +} + +function isYamlLiteSeqItem(content: string): boolean { + return content === "-" || content.startsWith("- "); +} + +// A mutable cursor shared across the recursive parse* calls below, so a +// nested call resumes exactly where its caller left off. +interface YamlLiteCursor { + i: number; +} + +function parseYamlLiteMapEntry( + lines: YamlLiteLine[], + pos: YamlLiteCursor, + indent: number, +): Record { + const line = lines[pos.i]; + const colonIdx = line.content.indexOf(":"); + if (colonIdx === -1) { + // Malformed line for this format; skip it rather than throw, consistent + // with normalizeConfig()'s general policy of dropping bad input. + pos.i++; + return {}; + } + const key = line.content.slice(0, colonIdx).trim(); + const value = line.content.slice(colonIdx + 1).trim(); + pos.i++; + if (value !== "") return { [key]: unquoteYamlLiteScalar(value) }; + if (pos.i < lines.length && lines[pos.i].indent > indent) { + return { [key]: parseYamlLiteBlock(lines, pos, lines[pos.i].indent) }; + } + return { [key]: "" }; +} + +function parseYamlLiteSequence( + lines: YamlLiteLine[], + pos: YamlLiteCursor, + indent: number, +): unknown[] { + const result: unknown[] = []; + while (pos.i < lines.length && lines[pos.i].indent === indent && isYamlLiteSeqItem(lines[pos.i].content)) { + const content = lines[pos.i].content; + const rest = content === "-" ? "" : content.slice(2); + + if (rest === "") { + pos.i++; + const childIndent = pos.i < lines.length ? lines[pos.i].indent : indent; + result.push(childIndent > indent ? parseYamlLiteBlock(lines, pos, childIndent) : ""); + } else if (rest.includes(":")) { + // "- key: value" opens an inline mapping item. Its first key has no + // line of its own to read an indent from - the dash and the space + // after it occupy 2 columns, so sibling keys line up at indent + 2. + // This is the one place a fixed offset is required rather than read + // from the input, same as real YAML. + const itemIndent = indent + 2; + const colonIdx = rest.indexOf(":"); + const key = rest.slice(0, colonIdx).trim(); + const value = rest.slice(colonIdx + 1).trim(); + pos.i++; + const map: Record = { + [key]: + value !== "" + ? unquoteYamlLiteScalar(value) + : pos.i < lines.length && lines[pos.i].indent > itemIndent + ? parseYamlLiteBlock(lines, pos, lines[pos.i].indent) + : "", + }; + while (pos.i < lines.length && lines[pos.i].indent === itemIndent) { + Object.assign(map, parseYamlLiteMapEntry(lines, pos, itemIndent)); + } + result.push(map); + } else { + pos.i++; + result.push(unquoteYamlLiteScalar(rest)); + } + } + return result; +} + +function parseYamlLiteBlock(lines: YamlLiteLine[], pos: YamlLiteCursor, indent: number): unknown { + if (pos.i >= lines.length || lines[pos.i].indent !== indent) return {}; + if (isYamlLiteSeqItem(lines[pos.i].content)) return parseYamlLiteSequence(lines, pos, indent); + + const map: Record = {}; + while (pos.i < lines.length && lines[pos.i].indent === indent && !isYamlLiteSeqItem(lines[pos.i].content)) { + Object.assign(map, parseYamlLiteMapEntry(lines, pos, indent)); + } + return map; +} + +/** Parses the YAML subset described above into plain objects/arrays/strings. */ +function parseYamlLite(text: string): unknown { + const lines = tokenizeYamlLite(text); + if (lines.length === 0) return {}; + return parseYamlLiteBlock(lines, { i: 0 }, lines[0].indent); +} + +/** + * Renders a plain scalar for the YAML-lite format above, quoting only when + * necessary - kept minimal (not general YAML-correct) since it only ever has + * to round-trip through parseYamlLite's own unquoting logic. + */ +function yamlLiteScalar(value: string): string { + const needsQuoting = + value === "" || + value !== value.trim() || + /^[\s\-?:,[\]{}#&*!|>'"%@`]/.test(value) || + /: |:$/.test(value) || + / #/.test(value); + if (!needsQuoting) return value; + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +/** Serializes a GrafanaJumpConfig into the YAML-lite format parseYamlLite reads. */ +function configToYamlLite(config: GrafanaJumpConfig): string { + const lines: string[] = [`baseUrl: ${yamlLiteScalar(config.baseUrl)}`]; + + if (config.dashboards.length === 0) { + lines.push("dashboards: []"); + return `${lines.join("\n")}\n`; + } + + lines.push("dashboards:"); + for (const dashboard of config.dashboards) { + lines.push(` - name: ${yamlLiteScalar(dashboard.name)}`); + lines.push(` uid: ${yamlLiteScalar(dashboard.uid)}`); + lines.push(` slug: ${yamlLiteScalar(dashboard.slug)}`); + const varEntries = Object.entries(dashboard.varNames).filter( + (entry): entry is [string, string] => typeof entry[1] === "string" && entry[1] !== "", + ); + if (varEntries.length === 0) { + lines.push(" varNames: {}"); + } else { + lines.push(" varNames:"); + for (const [key, value] of varEntries) { + lines.push(` ${key}: ${yamlLiteScalar(value)}`); + } + } + } + return `${lines.join("\n")}\n`; +} + // --------------------------------------------------------------------------- // Config persistence. Wrapped so the rest of the script only ever deals with a // GrafanaJumpConfig object, never the raw JSON-string storage format. @@ -326,6 +597,134 @@ async function saveConfig(config: GrafanaJumpConfig): Promise { await GM.setValue(CONFIG_STORAGE_KEY, JSON.stringify(config)); } +// --------------------------------------------------------------------------- +// Repo config fetching. Cross-origin (github.com -> raw.githubusercontent.com) +// requests from an injected page script are subject to GitHub's own CSP, so +// this uses GM.xmlHttpRequest (granted in meta.json, with a matching +// @connect for raw.githubusercontent.com) rather than page-context fetch() - +// GM.xmlHttpRequest is exempt from the page's CSP/CORS by design, which is +// exactly why it exists. +// +// Cached per {org, repo} for the life of the tab: GitHub's Actions/PR pages +// are a single-page app, so navigating between pages in the same repo would +// otherwise re-fetch this on every checkLocation() call for no reason. +// --------------------------------------------------------------------------- + +const REPO_CONFIG_PATH = ".github/jump-links.config.yaml"; + +const repoConfigCache = new Map>(); + +function fetchRepoConfig(org: string, repo: string): Promise { + const key = `${org}/${repo}`; + const cached = repoConfigCache.get(key); + if (cached) return cached; + + const promise = new Promise((resolve) => { + const url = + `https://raw.githubusercontent.com/${encodeURIComponent(org)}/${encodeURIComponent(repo)}` + + `/HEAD/${REPO_CONFIG_PATH}`; + GM.xmlHttpRequest({ + method: "GET", + url, + onload: (response: { status: number; responseText: string }) => { + if (response.status !== 200) { + resolve(null); + return; + } + try { + resolve(normalizeConfig(parseYamlLite(response.responseText))); + } catch { + resolve(null); + } + }, + onerror: () => resolve(null), + }); + }); + repoConfigCache.set(key, promise); + return promise; +} + +const defaultBranchCache = new Map>(); + +/** + * GitHub's "create/edit file" web UI is addressed by branch name, not by the + * "HEAD" alias fetchRepoConfig() above gets to use (that alias only exists + * for raw.githubusercontent.com content URLs) - so building a link into that + * UI needs the actual default branch name. Falls back to "main" on any + * failure; worst case the resulting link's branch segment is wrong and + * GitHub's own UI surfaces that, rather than anything failing silently. + */ +function resolveDefaultBranch(org: string, repo: string): Promise { + const key = `${org}/${repo}`; + const cached = defaultBranchCache.get(key); + if (cached) return cached; + + const promise = new Promise((resolve) => { + GM.xmlHttpRequest({ + method: "GET", + url: `https://api.github.com/repos/${encodeURIComponent(org)}/${encodeURIComponent(repo)}`, + onload: (response: { status: number; responseText: string }) => { + try { + const data: unknown = JSON.parse(response.responseText); + const branch = + typeof data === "object" && data !== null && typeof (data as { default_branch?: unknown }).default_branch === "string" + ? (data as { default_branch: string }).default_branch + : ""; + resolve(branch || "main"); + } catch { + resolve("main"); + } + }, + onerror: () => resolve("main"), + }); + }); + defaultBranchCache.set(key, promise); + return promise; +} + +/** + * A link into GitHub's own "create new file" web UI, pre-filled with a path + * and content. Committing from there goes through GitHub's normal auth/PR + * flow (direct commit if you can push, a fork+PR if you can't) - this script + * never touches the repo's git history itself, only hands GitHub's own UI a + * suggested path and content to start from. Works the same whether or not + * that path already exists: GitHub's create-file UI detects an existing file + * at the given path and lets the pre-filled content replace it from there. + */ +function buildCreateFileUrl(org: string, repo: string, branch: string, path: string, content: string): string { + const params = new URLSearchParams({ filename: path, value: content }); + return `https://github.com/${org}/${repo}/new/${branch}?${params.toString()}`; +} + +const REPO_CONFIG_TEMPLATE = `# Config for the "GitHub Actions => Grafana jump button" userscript +# (https://github.com/nsheaps/greasemonkey-scripts/tree/main/packages/github-actions-grafana-jump). +# Gives every contributor to this repo the same dashboards without each of +# them configuring the userscript by hand. A contributor's own personal +# config (set via the userscript's own "Set up Grafana jump" panel) always +# takes priority over this file for any page it covers - this is only a +# fallback for pages nobody has personally configured. +# +# baseUrl: your Grafana instance's base URL (no trailing slash). +# dashboards: one entry per dashboard you want jumpable to. For each: +# name - display label for the jump button/menu. +# uid - the dashboard's UID (Grafana dashboard settings -> JSON Model, +# or the segment right after /d/ in the dashboard's URL). +# slug - the URL slug right after the uid in the dashboard's URL. +# varNames - which of this dashboard's template variables (if any) to +# preset from the current GitHub page. Leave a field out if the +# dashboard doesn't use that kind of filter. +baseUrl: https://grafana.example.com +dashboards: + - name: CI Overview + uid: REPLACE_WITH_DASHBOARD_UID + slug: REPLACE_WITH_DASHBOARD_SLUG + varNames: + branch: branch + prNumber: pr_number + workflowName: workflow_file + runnerName: runner_name +`; + // --------------------------------------------------------------------------- // DOM injection. // @@ -370,6 +769,13 @@ const MENU_ITEM_STYLE = let currentConfig: GrafanaJumpConfig = defaultConfig(); +// The current page's repo config (see the "Repo config fetching" section +// above), and the "org/repo" key it belongs to - null/undefined until a repo +// config has actually been fetched (or the current context isn't scoped to a +// single repo at all, e.g. the org-scoped runner page). +let currentRepoConfig: GrafanaJumpConfig | null = null; +let currentRepoConfigKey: string | undefined; + function closeMenu(): void { document.getElementById(`${CONTAINER_ID}-menu`)?.remove(); } @@ -411,7 +817,7 @@ function openMenu( }, 0); } -function openConfigModal(): void { +function openConfigModal(repoCtx: { org: string; repo: string } | null): void { closeMenu(); // Work on a deep-ish draft copy so Cancel leaves the saved config untouched. @@ -465,7 +871,13 @@ function openConfigModal(): void { baseUrlInput.setAttribute( "style", "display: block; width: 100%; box-sizing: border-box; padding: 6px 8px; " + - "margin-bottom: 16px; font-size: 13px; border: 1px solid #d0d7de; border-radius: 6px;", + "margin-bottom: 16px; font-size: 13px; border: 1px solid #d0d7de; border-radius: 6px; " + + // Explicit background/color: without these, browsers apply their own + // dark-mode default styling to unstyled inputs, which can pair a dark + // input background with dark text from this panel's own color rules + // and make it unreadable. The whole modal is intentionally light-themed + // regardless of the page's color scheme, so its inputs need to match. + "background: #fff; color: #24292f;", ); baseUrlInput.addEventListener("input", () => { draft.baseUrl = baseUrlInput.value.trim(); @@ -497,7 +909,8 @@ function openConfigModal(): void { input.setAttribute( "style", "display: block; width: 100%; box-sizing: border-box; padding: 4px 6px; " + - "font-size: 12px; border: 1px solid #d0d7de; border-radius: 4px;", + "font-size: 12px; border: 1px solid #d0d7de; border-radius: 4px; " + + "background: #fff; color: #24292f;", ); input.addEventListener("input", () => onInput(input.value)); wrapper.appendChild(label); @@ -580,6 +993,61 @@ function openConfigModal(): void { }); panel.appendChild(addButton); + if (repoCtx && (!currentRepoConfig || isConfigured(currentConfig))) { + const repoSyncHeading = document.createElement("div"); + repoSyncHeading.textContent = `Share with ${repoCtx.org}/${repoCtx.repo}`; + repoSyncHeading.setAttribute("style", "font-size: 12px; font-weight: 600; margin-bottom: 4px;"); + panel.appendChild(repoSyncHeading); + + const repoSyncHelp = document.createElement("p"); + repoSyncHelp.textContent = + `Opens GitHub's own "create file" page for this repo's ${REPO_CONFIG_PATH}, pre-filled - ` + + "review and commit (or open a PR) from there. Nothing is written until you do."; + repoSyncHelp.setAttribute("style", "margin: 0 0 8px; font-size: 11px; color: #57606a;"); + panel.appendChild(repoSyncHelp); + + const repoSyncRow = document.createElement("div"); + repoSyncRow.setAttribute("style", "display: flex; gap: 8px; margin-bottom: 16px;"); + + const secondaryButtonStyle = + "flex: 1; padding: 6px 10px; font-size: 11px; border-radius: 6px; border: 1px solid #d0d7de; " + + "background: #f6f8fa; color: #24292f; cursor: pointer;"; + + const openCreateFileTab = (content: string): void => { + void resolveDefaultBranch(repoCtx.org, repoCtx.repo).then((branch) => { + const url = buildCreateFileUrl(repoCtx.org, repoCtx.repo, branch, REPO_CONFIG_PATH, content); + window.open(url, "_blank"); + }); + }; + + if (!currentRepoConfig) { + const templateButton = document.createElement("button"); + templateButton.type = "button"; + templateButton.textContent = "📄 Create repo config template"; + templateButton.setAttribute("style", secondaryButtonStyle); + templateButton.addEventListener("click", () => openCreateFileTab(REPO_CONFIG_TEMPLATE)); + repoSyncRow.appendChild(templateButton); + } + + if (isConfigured(currentConfig)) { + // Exports the saved config, not unsaved edits in this draft - if you've + // just added a dashboard, Save first so the export includes it. + const exportButton = document.createElement("button"); + exportButton.type = "button"; + exportButton.textContent = "⬆️ Export my config to repo"; + exportButton.setAttribute("style", secondaryButtonStyle); + exportButton.addEventListener("click", () => { + const merged = mergeConfigsForExport(currentRepoConfig, currentConfig); + openCreateFileTab(configToYamlLite(merged)); + }); + repoSyncRow.appendChild(exportButton); + } + + // The outer `if` above already guarantees at least one of the two + // buttons was added. + panel.appendChild(repoSyncRow); + } + const actions = document.createElement("div"); actions.setAttribute("style", "display: flex; justify-content: flex-end; gap: 8px;"); @@ -626,7 +1094,7 @@ function renderJumpButton(context: JumpContext | null): void { return; } - const applicable = applicableDashboards(currentConfig, context); + const active = activeDashboards(currentConfig, currentRepoConfig, context); const container = existing ?? document.createElement("div"); container.id = CONTAINER_ID; @@ -636,7 +1104,7 @@ function renderJumpButton(context: JumpContext | null): void { ); container.innerHTML = ""; - if (applicable.length === 0) { + if (active.length === 0) { const setupButton = document.createElement("button"); setupButton.type = "button"; setupButton.textContent = isConfigured(currentConfig) @@ -645,18 +1113,18 @@ function renderJumpButton(context: JumpContext | null): void { setupButton.setAttribute("style", SOLO_BUTTON_STYLE); setupButton.addEventListener("click", (event) => { event.stopPropagation(); - openConfigModal(); + openConfigModal(repoContextForJump(context)); }); container.appendChild(setupButton); } else { - const [primary, ...rest] = applicable; + const [primary, ...rest] = active; const label = labelForContext(context); const jumpLink = document.createElement("a"); - jumpLink.setAttribute("href", buildJumpUrl(currentConfig.baseUrl, primary, context)); + jumpLink.setAttribute("href", buildJumpUrl(primary.baseUrl, primary.dashboard, context)); jumpLink.setAttribute("target", "_blank"); jumpLink.setAttribute("style", BUTTON_STYLE); - jumpLink.textContent = applicable.length > 1 ? `${label} (${primary.name}) ↗️` : `${label} ↗️`; + jumpLink.textContent = active.length > 1 ? `${label} (${primary.dashboard.name}) ↗️` : `${label} ↗️`; container.appendChild(jumpLink); const toggle = document.createElement("button"); @@ -666,17 +1134,17 @@ function renderJumpButton(context: JumpContext | null): void { toggle.addEventListener("click", (event) => { event.stopPropagation(); const items = [ - ...applicable.map((dashboard) => ({ + ...active.map(({ baseUrl, dashboard }) => ({ label: `↗️ ${dashboard.name || dashboard.uid}`, - onClick: () => window.open(buildJumpUrl(currentConfig.baseUrl, dashboard, context), "_blank"), + onClick: () => window.open(buildJumpUrl(baseUrl, dashboard, context), "_blank"), })), - { label: "⚙️ Edit dashboards...", onClick: openConfigModal }, + { label: "⚙️ Edit dashboards...", onClick: () => openConfigModal(repoContextForJump(context)) }, ]; openMenu(container, items); }); container.appendChild(toggle); - // rest is intentionally unused beyond being included in `applicable` above; + // rest is intentionally unused beyond being included in `active` above; // named for clarity when reading the destructure at a glance. void rest; } @@ -694,7 +1162,24 @@ function checkLocation(force = false): void { if (!force && locationKey === lastLocationKey) return; lastLocationKey = locationKey; - renderJumpButton(resolveJumpContext(pathname, search)); + const context = resolveJumpContext(pathname, search); + renderJumpButton(context); + + const repoCtx = context ? repoContextForJump(context) : null; + const repoKey = repoCtx ? `${repoCtx.org}/${repoCtx.repo}` : undefined; + if (repoKey === currentRepoConfigKey) return; + + currentRepoConfigKey = repoKey; + currentRepoConfig = null; + if (repoCtx) { + void fetchRepoConfig(repoCtx.org, repoCtx.repo).then((config) => { + // Guard against a slow response landing after the user has already + // navigated to a different repo (or one with no repo context at all). + if (currentRepoConfigKey !== repoKey) return; + currentRepoConfig = config; + checkLocation(true); + }); + } } // Guarded so that requiring the compiled output under Node (see the test-only @@ -730,11 +1215,17 @@ if (typeof module !== "undefined" && module.exports) { contextVarKey, contextFilterValue, applicableDashboards, + repoContextForJump, + activeDashboards, + mergeConfigsForExport, buildDashboardUrl, buildJumpUrl, labelForContext, defaultConfig, normalizeConfig, isConfigured, + parseYamlLite, + configToYamlLite, + buildCreateFileUrl, }; } diff --git a/packages/github-actions-grafana-jump/src/meta.json b/packages/github-actions-grafana-jump/src/meta.json index daa99e4..57eb913 100644 --- a/packages/github-actions-grafana-jump/src/meta.json +++ b/packages/github-actions-grafana-jump/src/meta.json @@ -6,7 +6,8 @@ "match": ["http*://www.github.com/*", "http*://github.com/*"], "run-at": "document-start", "icon": "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==", - "grant": ["GM.setValue", "GM.getValue"], + "grant": ["GM.setValue", "GM.getValue", "GM.xmlHttpRequest"], + "connect": ["raw.githubusercontent.com", "api.github.com"], "license": "MIT", "downloadURL": "https://github.com/nsheaps/greasemonkey-scripts/releases/latest/download/github-actions-grafana-jump.user.js", "updateURL": "https://github.com/nsheaps/greasemonkey-scripts/releases/latest/download/github-actions-grafana-jump.user.js" diff --git a/packages/github-actions-grafana-jump/test/grafana-jump.test.js b/packages/github-actions-grafana-jump/test/grafana-jump.test.js index 843800d..b634c43 100644 --- a/packages/github-actions-grafana-jump/test/grafana-jump.test.js +++ b/packages/github-actions-grafana-jump/test/grafana-jump.test.js @@ -19,12 +19,18 @@ const { contextVarKey, contextFilterValue, applicableDashboards, + repoContextForJump, + activeDashboards, + mergeConfigsForExport, buildDashboardUrl, buildJumpUrl, labelForContext, defaultConfig, normalizeConfig, isConfigured, + parseYamlLite, + configToYamlLite, + buildCreateFileUrl, } = require("../dist/index.js"); test("parsePrContext matches the PR checks tab and other PR sub-tabs", () => { @@ -86,6 +92,7 @@ test("parseRunnerContext matches repo-scoped and org-scoped runner pages", () => kind: "runner", scope: "repo", org: "oura", + repo: "some-repo", runnerId: "17", }); assert.deepEqual( @@ -319,3 +326,160 @@ test("labelForContext produces a distinct human-readable label per context kind" "Grafana: runner 9", ); }); + +test("repoContextForJump extracts {org, repo} for repo-scoped contexts", () => { + assert.deepEqual(repoContextForJump({ kind: "pr", org: "o", repo: "r", prNumber: "1" }), { + org: "o", + repo: "r", + }); + assert.deepEqual( + repoContextForJump({ kind: "branch", org: "o", repo: "r", branch: "main" }), + { org: "o", repo: "r" }, + ); + assert.deepEqual( + repoContextForJump({ kind: "workflow", org: "o", repo: "r", workflowFile: "ci.yml" }), + { org: "o", repo: "r" }, + ); + assert.deepEqual( + repoContextForJump({ kind: "runner", scope: "repo", org: "o", repo: "r", runnerId: "9" }), + { org: "o", repo: "r" }, + ); +}); + +test("repoContextForJump returns null for an org-scoped runner context", () => { + assert.equal( + repoContextForJump({ kind: "runner", scope: "org", org: "o", runnerId: "9" }), + null, + ); +}); + +test("activeDashboards prefers the personal config outright when it has anything applicable", () => { + const personal = { + baseUrl: "https://personal.example.com", + dashboards: [{ name: "mine", uid: "p1", slug: "mine", varNames: { branch: "br" } }], + }; + const repo = { + baseUrl: "https://repo.example.com", + dashboards: [{ name: "theirs", uid: "r1", slug: "theirs", varNames: { branch: "br" } }], + }; + const context = { kind: "branch", org: "o", repo: "r", branch: "main" }; + assert.deepEqual(activeDashboards(personal, repo, context), [ + { baseUrl: "https://personal.example.com", dashboard: personal.dashboards[0] }, + ]); +}); + +test("activeDashboards falls back to the repo config when personal has nothing applicable", () => { + const personal = { baseUrl: "https://personal.example.com", dashboards: [] }; + const repo = { + baseUrl: "https://repo.example.com", + dashboards: [{ name: "theirs", uid: "r1", slug: "theirs", varNames: { branch: "br" } }], + }; + const context = { kind: "branch", org: "o", repo: "r", branch: "main" }; + assert.deepEqual(activeDashboards(personal, repo, context), [ + { baseUrl: "https://repo.example.com", dashboard: repo.dashboards[0] }, + ]); +}); + +test("activeDashboards returns nothing when neither config nor a null repo config has anything applicable", () => { + const personal = { baseUrl: "", dashboards: [] }; + const context = { kind: "branch", org: "o", repo: "r", branch: "main" }; + assert.deepEqual(activeDashboards(personal, null, context), []); +}); + +test("mergeConfigsForExport unions dashboards, deduped by uid, preferring the repo's copy", () => { + const repo = { + baseUrl: "https://repo.example.com", + dashboards: [ + { name: "repo-only", uid: "r1", slug: "repo-only", varNames: { branch: "br" } }, + { name: "shared (repo version)", uid: "shared", slug: "shared", varNames: {} }, + ], + }; + const personal = { + baseUrl: "https://personal.example.com", + dashboards: [ + { name: "personal-only", uid: "p1", slug: "personal-only", varNames: {} }, + { name: "shared (personal version)", uid: "shared", slug: "shared", varNames: { prNumber: "pr" } }, + ], + }; + assert.deepEqual(mergeConfigsForExport(repo, personal), { + baseUrl: "https://repo.example.com", + dashboards: [repo.dashboards[0], repo.dashboards[1], personal.dashboards[0]], + }); +}); + +test("mergeConfigsForExport falls back to the personal baseUrl when there is no repo config", () => { + const personal = { + baseUrl: "https://personal.example.com", + dashboards: [{ name: "mine", uid: "p1", slug: "mine", varNames: {} }], + }; + assert.deepEqual(mergeConfigsForExport(null, personal), { + baseUrl: "https://personal.example.com", + dashboards: personal.dashboards, + }); +}); + +test("parseYamlLite reads a baseUrl and a sequence of dashboard mappings with nested varNames", () => { + const text = [ + "# a leading comment, and a blank line below", + "", + "baseUrl: https://grafana.example.com", + "dashboards:", + " - name: CI Overview", + " uid: abc123", + " slug: ci-overview", + " varNames:", + " branch: branch", + " prNumber: pr_number", + ].join("\n"); + + assert.deepEqual(parseYamlLite(text), { + baseUrl: "https://grafana.example.com", + dashboards: [ + { + name: "CI Overview", + uid: "abc123", + slug: "ci-overview", + varNames: { branch: "branch", prNumber: "pr_number" }, + }, + ], + }); +}); + +test("parseYamlLite unquotes single- and double-quoted scalars", () => { + const text = ['name: "quoted value"', "slug: 'also quoted'"].join("\n"); + assert.deepEqual(parseYamlLite(text), { name: "quoted value", slug: "also quoted" }); +}); + +test("parseYamlLite round-trips through configToYamlLite for a config with multiple dashboards", () => { + const config = { + baseUrl: "https://grafana.example.com", + dashboards: [ + { + name: "CI Overview", + uid: "abc123", + slug: "ci-overview", + varNames: { branch: "branch", prNumber: "pr_number" }, + }, + { name: "No vars", uid: "def456", slug: "no-vars", varNames: {} }, + ], + }; + assert.deepEqual(normalizeConfig(parseYamlLite(configToYamlLite(config))), config); +}); + +test("configToYamlLite quotes scalars that would otherwise be misread", () => { + const config = { + baseUrl: "https://grafana.example.com", + dashboards: [{ name: "- looks like a list item", uid: "u1", slug: "s1", varNames: {} }], + }; + const yaml = configToYamlLite(config); + assert.match(yaml, /name: "- looks like a list item"/); + assert.deepEqual(normalizeConfig(parseYamlLite(yaml)), config); +}); + +test("buildCreateFileUrl builds a GitHub create-file link with filename and value query params", () => { + const url = buildCreateFileUrl("o", "r", "main", ".github/jump-links.config.yaml", "baseUrl: https://g.example.com\n"); + assert.equal( + url, + "https://github.com/o/r/new/main?filename=.github%2Fjump-links.config.yaml&value=baseUrl%3A+https%3A%2F%2Fg.example.com%0A", + ); +}); From 83e77aaad29389d01e0f1f92e72dc9b958e07878 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 04:01:37 +0000 Subject: [PATCH 2/2] feat(grafana-jump): support run/job IDs, runner groups, and Tempo traces Jump targets previously mapped one page context to exactly one filter value (branch, PR number, workflow file, or runner ID). Extends the page-provided field set to include the repo name, runner group, workflow run ID, and job ID, and generalizes matching so a target only shows up when *every* field it's configured to use is actually present on the current page - not just one, since different targets now legitimately need different combinations (e.g. a "span for this job" target needs both a run ID and a job ID, which only coexist on a job's own page). Adds two new page contexts to source those fields: a workflow run's own page (optionally drilled into one job), and an org's runner group detail page. Also adds a second kind of jump target alongside the existing Grafana dashboard link: a Tempo trace search, built as a Grafana Explore TraceQL query with `{{fieldKey}}` placeholders filled in from the current page. A target's required fields are inferred from its varNames (dashboard) or its query's placeholders (trace), so no separate "required fields" list needs to be kept in sync by hand. Fixes a latent bug in the YAML-lite parser surfaced by this: it quoted values containing embedded double quotes (like a TraceQL query) on write but never unescaped them on read, so such a value wouldn't round-trip through the checked-in config file correctly. Updates the repo config template and this repo's own example config with five examples: workflow runs for this repo, for this branch, and on this runner, plus a trace for a workflow run and a span for one job. --- .github/jump-links.config.yaml | 73 +- .../github-actions-grafana-jump/src/index.ts | 666 ++++++++++++++---- .../test/grafana-jump.test.js | 353 ++++++++-- 3 files changed, 877 insertions(+), 215 deletions(-) diff --git a/.github/jump-links.config.yaml b/.github/jump-links.config.yaml index 87643da..e7537ef 100644 --- a/.github/jump-links.config.yaml +++ b/.github/jump-links.config.yaml @@ -1,7 +1,7 @@ # Config for the "GitHub Actions => Grafana jump button" userscript # (packages/github-actions-grafana-jump). Gives every contributor to this -# repo the same dashboards without each of them configuring the userscript by -# hand. A contributor's own personal config (set via the userscript's own +# repo the same jump targets without each of them configuring the userscript +# by hand. A contributor's own personal config (set via the userscript's own # "Set up Grafana jump" panel) always takes priority over this file for any # page it covers - this is only a fallback for pages nobody has personally # configured. Bootstrap one of these for your own repo via the userscript's @@ -12,21 +12,66 @@ # file format the userscript reads. # # baseUrl: your Grafana instance's base URL (no trailing slash). -# dashboards: one entry per dashboard you want jumpable to. For each: -# name - display label for the jump button/menu. -# uid - the dashboard's UID (Grafana dashboard settings -> JSON Model, -# or the segment right after /d/ in the dashboard's URL). -# slug - the URL slug right after the uid in the dashboard's URL. -# varNames - which of this dashboard's template variables (if any) to -# preset from the current GitHub page. Leave a field out if the -# dashboard doesn't use that kind of filter. +# dashboards: one entry per jump target. Each entry is either a Grafana +# dashboard link (type: dashboard) or a Tempo trace search (type: trace). A +# target only shows up as a jump target on pages that provide every field +# it's configured to use - not all fields are available on every page (a +# branch's Actions page has no workflow run ID, for example), so different +# targets naturally show up on different pages. +# +# type: dashboard +# name - display label for the jump button/menu. +# uid - the dashboard's UID (Grafana dashboard settings -> JSON +# Model, or the segment right after /d/ in the dashboard's +# URL). +# slug - the URL slug right after the uid in the dashboard's URL. +# varNames - which of this dashboard's template variables (if any) to +# preset from the current GitHub page. Leave a field out if +# the dashboard doesn't use that kind of filter. Available +# fields: repo, branch, prNumber, workflowName, runnerName, +# runnerGroupName, runId, jobId. +# +# type: trace +# name - display label for the jump button/menu. +# id - any string unique among your trace targets; used only +# to dedupe when exporting/merging this file, not shown +# anywhere. +# datasourceUid - the Tempo datasource's UID in Grafana (Connections -> +# Data sources -> your Tempo source -> the "uid" in its +# URL or Settings JSON). +# query - a TraceQL query with `{{fieldKey}}` placeholders (the +# same field names as varNames above) filled in from the +# current GitHub page - adjust the attribute names below +# (e.g. resource.github.run_id) to match however your own +# traces are tagged. baseUrl: https://grafana.example.com dashboards: - - name: CI Overview + - type: dashboard + name: Workflow runs for this repo + uid: ci-overview-uid + slug: ci-overview + varNames: + repo: repository + - type: dashboard + name: Workflow runs on this runner uid: ci-overview-uid slug: ci-overview varNames: - branch: branch - prNumber: pr_number - workflowName: workflow_file runnerName: runner_name + - type: dashboard + name: Workflow runs for this branch + uid: ci-overview-uid + slug: ci-overview + varNames: + repo: repository + branch: branch + - type: trace + name: Trace for this workflow run + id: workflow-run-trace + datasourceUid: tempo-datasource-uid + query: '{resource.github.run_id="{{runId}}"}' + - type: trace + name: Span for this job + id: workflow-job-span + datasourceUid: tempo-datasource-uid + query: '{resource.github.run_id="{{runId}}" && resource.github.job_id="{{jobId}}"}' diff --git a/packages/github-actions-grafana-jump/src/index.ts b/packages/github-actions-grafana-jump/src/index.ts index 3e411fb..ffa3201 100644 --- a/packages/github-actions-grafana-jump/src/index.ts +++ b/packages/github-actions-grafana-jump/src/index.ts @@ -5,11 +5,14 @@ // Fully generic: no Grafana instance, dashboard UID, or template-variable name is // baked in. On first use (or whenever nothing configured applies to the current // page) the jump button opens an in-page configuration panel where you enter your -// own Grafana base URL and one or more dashboards, each with the template-variable -// names it uses for filtering by branch / PR number / workflow file / runner. Once -// configured, the button jumps straight to the matching dashboard, with a small -// "▾" menu to pick among multiple configured dashboards or to reopen the config -// panel. Config is persisted via GM.setValue/GM.getValue, scoped to this script. +// own Grafana base URL and one or more jump targets - either a dashboard, or a +// Tempo trace search - each declaring which page-provided fields it filters or +// templates by (branch, PR number, workflow file, runner, runner group, workflow +// run ID, job ID). A target only shows up on pages that actually provide every +// field it references. Once configured, the button jumps straight to the best +// match, with a small "▾" menu to pick among multiple applicable targets or to +// reopen the config panel. Config is persisted via GM.setValue/GM.getValue, +// scoped to this script. // // The `var-=` query-param convention used to preset a Grafana // dashboard's template variables from a URL is a genuine, documented Grafana @@ -25,28 +28,121 @@ // --------------------------------------------------------------------------- interface DashboardVarNames { + repo?: string; branch?: string; prNumber?: string; workflowName?: string; runnerName?: string; + runnerGroupName?: string; + runId?: string; + jobId?: string; +} + +/** The fixed set of page-provided fields a jump target can filter/template by. */ +type ContextFieldKey = keyof DashboardVarNames; + +const CONTEXT_FIELD_KEYS: readonly ContextFieldKey[] = [ + "repo", + "branch", + "prNumber", + "workflowName", + "runnerName", + "runnerGroupName", + "runId", + "jobId", +]; + +function isContextFieldKey(key: string): key is ContextFieldKey { + return (CONTEXT_FIELD_KEYS as readonly string[]).includes(key); } -interface DashboardConfig { +/** + * A jump target that links straight to a Grafana dashboard (`/d//`), + * with any of its own template variables preset via the `var-=` + * query convention - see buildDashboardUrl(). + */ +interface DashboardTarget { + type: "dashboard"; name: string; uid: string; slug: string; varNames: DashboardVarNames; } +/** + * A jump target that opens a Grafana Explore pane running a TraceQL search + * against a Tempo datasource, rather than a fixed dashboard - useful when + * there's no dashboard UID to jump to, only a trace/span you want to *find* + * by an attribute like a GitHub Actions run or job ID. `query` is a TraceQL + * string with `{{fieldKey}}` placeholders (any ContextFieldKey) substituted + * from the current page - see renderTemplate(). A target's required fields + * are inferred from which placeholders its own query actually uses, the same + * way a DashboardTarget's required fields come from which varNames entries + * are filled in - see requiredFields(). + */ +interface TraceTarget { + type: "trace"; + name: string; + // A user-chosen stable identifier, since (unlike a DashboardTarget) there's + // no Grafana-assigned UID to dedupe on when exporting/merging configs. + id: string; + datasourceUid: string; + query: string; +} + +type JumpTargetConfig = DashboardTarget | TraceTarget; + interface GrafanaJumpConfig { baseUrl: string; - dashboards: DashboardConfig[]; + dashboards: JumpTargetConfig[]; } function defaultConfig(): GrafanaJumpConfig { return { baseUrl: "", dashboards: [] }; } +function normalizeVarNames(raw: unknown): DashboardVarNames { + const varNamesRaw = typeof raw === "object" && raw !== null ? (raw as Record) : {}; + const varNames: DashboardVarNames = {}; + for (const key of CONTEXT_FIELD_KEYS) { + const value = varNamesRaw[key]; + if (typeof value === "string" && value.trim() !== "") { + varNames[key] = value.trim(); + } + } + return varNames; +} + +/** + * Reshapes one raw dashboards[] entry into a well-formed JumpTargetConfig, or + * null if it's malformed enough that it can never be jumped to (no uid for a + * dashboard, or a missing id/datasourceUid/query for a trace search) - callers + * drop nulls rather than keeping a target that would only ever produce a + * broken link. Anything without `type: "trace"` is treated as a dashboard, + * which also covers the format's original shape (no `type` field at all). + */ +function normalizeTarget(raw: Record): JumpTargetConfig | null { + const name = typeof raw.name === "string" ? raw.name.trim() : ""; + + if (raw.type === "trace") { + const id = typeof raw.id === "string" ? raw.id.trim() : ""; + const datasourceUid = typeof raw.datasourceUid === "string" ? raw.datasourceUid.trim() : ""; + // A query can't contain a newline in the YAML-lite format it also has to + // round-trip through (see "Repo config parsing" below), which has no + // multi-line scalar support - collapse one defensively rather than + // silently exporting an invalid file. + const query = typeof raw.query === "string" ? raw.query.replace(/\s*\n\s*/g, " ").trim() : ""; + if (id === "" || datasourceUid === "" || query === "") return null; + return { type: "trace", name, id, datasourceUid, query }; + } + + const uid = typeof raw.uid === "string" ? raw.uid.trim() : ""; + const slug = typeof raw.slug === "string" ? raw.slug.trim() : ""; + const varNames = normalizeVarNames(raw.varNames); + if (uid === "") return null; + return { type: "dashboard", name, uid, slug, varNames }; +} + /** * Defensively reshapes a value loaded from storage (or pasted/hand-edited) into * a well-formed GrafanaJumpConfig, dropping anything malformed rather than @@ -59,30 +155,10 @@ function normalizeConfig(raw: unknown): GrafanaJumpConfig { const baseUrl = typeof obj.baseUrl === "string" ? obj.baseUrl.trim() : ""; const dashboardsRaw = Array.isArray(obj.dashboards) ? obj.dashboards : []; - const dashboards: DashboardConfig[] = dashboardsRaw + const dashboards = dashboardsRaw .filter((d): d is Record => typeof d === "object" && d !== null) - .map((d) => { - const varNamesRaw = - typeof d.varNames === "object" && d.varNames !== null - ? (d.varNames as Record) - : {}; - const varNames: DashboardVarNames = {}; - for (const key of ["branch", "prNumber", "workflowName", "runnerName"] as const) { - const value = varNamesRaw[key]; - if (typeof value === "string" && value.trim() !== "") { - varNames[key] = value.trim(); - } - } - return { - name: typeof d.name === "string" ? d.name.trim() : "", - uid: typeof d.uid === "string" ? d.uid.trim() : "", - slug: typeof d.slug === "string" ? d.slug.trim() : "", - varNames, - }; - }) - // A dashboard with no uid can't be jumped to; drop it rather than emit a - // broken link. - .filter((d) => d.uid !== ""); + .map(normalizeTarget) + .filter((d): d is JumpTargetConfig => d !== null); return { baseUrl, dashboards }; } @@ -129,7 +205,30 @@ interface WorkflowContext { workflowFile: string; } -type JumpContext = PrContext | BranchContext | RunnerContext | WorkflowContext; +interface RunnerGroupContext { + kind: "runnerGroup"; + org: string; + groupId: string; +} + +interface RunContext { + kind: "run"; + org: string; + repo: string; + runId: string; + // Only present when the URL drills into one job's logs within the run + // (`/actions/runs//job/`) - the run's own overview page has no + // single job to filter by. + jobId?: string; +} + +type JumpContext = + | PrContext + | BranchContext + | RunnerContext + | WorkflowContext + | RunnerGroupContext + | RunContext; /** * Matches a pull request's own pages (Conversation/Commits/Checks/Files changed), @@ -200,6 +299,19 @@ function parseRunnerContext(pathname: string): RunnerContext | null { return null; } +/** + * Matches an organization's runner group detail page, e.g. + * `/organizations//settings/actions/runner-groups/`. Runner groups + * are an org-level concept for pooling self-hosted runners across repos, so + * unlike parseRunnerContext there's no repo-scoped equivalent to match. + */ +function parseRunnerGroupContext(pathname: string): RunnerGroupContext | null { + const match = pathname.match(/^\/organizations\/([^/]+)\/settings\/actions\/runner-groups\/(\d+)/); + if (!match) return null; + const [, org, groupId] = match; + return { kind: "runnerGroup", org, groupId }; +} + /** * Matches a single workflow's own page, showing its runs across all branches, * e.g. `/org/repo/actions/workflows/ci.yml`. @@ -212,81 +324,126 @@ function parseWorkflowContext(pathname: string): WorkflowContext | null { } /** - * Resolves the current location into whichever jump context applies (PR/branch, - * runner, or workflow-across-branches), or null if none match. Order doesn't - * matter for correctness here since the four path shapes are mutually - * exclusive, but runner and workflow paths are checked first since they're the - * most specific. + * Matches a workflow run's own page, and optionally one job's logs within it, + * e.g. `/org/repo/actions/runs/123456` or `/org/repo/actions/runs/123456/job/789`. + */ +function parseRunContext(pathname: string): RunContext | null { + const match = pathname.match(/^\/([^/]+)\/([^/]+)\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/); + if (!match) return null; + const [, org, repo, runId, jobId] = match; + return jobId ? { kind: "run", org, repo, runId, jobId } : { kind: "run", org, repo, runId }; +} + +/** + * Resolves the current location into whichever jump context applies, or null + * if none match. Order doesn't matter for correctness here since the path + * shapes are mutually exclusive, but the more specific runner/run paths are + * checked first per the existing convention. */ function resolveJumpContext(pathname: string, search: string): JumpContext | null { return ( + parseRunnerGroupContext(pathname) ?? parseRunnerContext(pathname) ?? + parseRunContext(pathname) ?? parseWorkflowContext(pathname) ?? parsePrContext(pathname) ?? parseBranchContext(pathname, search) ); } -/** Which DashboardVarNames key a given context kind is filtered by. */ -function contextVarKey(kind: JumpContext["kind"]): keyof DashboardVarNames { - switch (kind) { +/** + * All page-provided fields available for a given context, keyed the same way + * as DashboardVarNames / a trace query's `{{placeholders}}`. Only the fields + * the current page actually carries are present - a jump target only shows + * up when every field it references (see requiredFields()) is one of these. + * `repo` is included for every context scoped to a single repo (everything + * except the org-scoped runner and runnerGroup pages), not just + * workflow/branch contexts, so a target that only cares about the repo name + * can show up anywhere within that repo. + */ +function contextFields(context: JumpContext): Partial> { + switch (context.kind) { case "pr": - return "prNumber"; + return { repo: context.repo, prNumber: context.prNumber }; case "branch": - return "branch"; + return { repo: context.repo, branch: context.branch }; case "workflow": - return "workflowName"; + return { repo: context.repo, workflowName: context.workflowFile }; + case "run": + return { + repo: context.repo, + runId: context.runId, + ...(context.jobId ? { jobId: context.jobId } : {}), + }; case "runner": - return "runnerName"; + return { + ...(context.repo ? { repo: context.repo } : {}), + runnerName: context.runnerId, + }; + case "runnerGroup": + return { runnerGroupName: context.groupId }; } } -/** The raw filter value (PR number, branch name, etc.) carried by a context. */ -function contextFilterValue(context: JumpContext): string { - switch (context.kind) { - case "pr": - return context.prNumber; - case "branch": - return context.branch; - case "workflow": - return context.workflowFile; - case "runner": - return context.runnerId; +/** + * Which of a target's configured fields (varNames entries for a dashboard, + * or `{{placeholder}}` references for a trace query) it needs present on the + * page to be jumpable. A target with none configured is treated as needing + * something it can never match, not as universally applicable. + */ +function requiredFields(target: JumpTargetConfig): ContextFieldKey[] { + if (target.type === "trace") { + const found = new Set(); + const placeholderPattern = /\{\{(\w+)\}\}/g; + let match: RegExpExecArray | null; + while ((match = placeholderPattern.exec(target.query))) { + if (isContextFieldKey(match[1])) found.add(match[1]); + } + return [...found]; } + return CONTEXT_FIELD_KEYS.filter((key) => Boolean(target.varNames[key])); } /** - * Which of the configured dashboards can actually be jumped to for this - * context - i.e. have a template-variable name configured for the field this - * context kind filters by. A dashboard with no matching varName is left out - * rather than linked to with no filter applied. + * Which of the configured dashboards/traces can actually be jumped to for + * this context - i.e. every field the target is configured to filter or + * template by is one this context's page actually provides (see + * contextFields() and requiredFields()). A target that needs a field this + * page doesn't have is left out entirely, rather than linked to with that + * filter silently dropped. */ -function applicableDashboards(config: GrafanaJumpConfig, context: JumpContext): DashboardConfig[] { - const key = contextVarKey(context.kind); - return config.dashboards.filter((dashboard) => Boolean(dashboard.varNames[key])); +function applicableDashboards(config: GrafanaJumpConfig, context: JumpContext): JumpTargetConfig[] { + const fields = contextFields(context); + return config.dashboards.filter((target) => { + const required = requiredFields(target); + return required.length > 0 && required.every((key) => Boolean(fields[key])); + }); } /** * The {org, repo} a jump context belongs to, for looking up that repo's * `.github/jump-links.config.yaml` - or null when the context isn't scoped to - * one repo (an org-scoped runner page covers every repo in the org, so there's - * no single repo config to fetch). + * one repo (an org-scoped runner or runner-group page covers every repo in + * the org, so there's no single repo config to fetch). */ function repoContextForJump(context: JumpContext): { org: string; repo: string } | null { switch (context.kind) { case "pr": case "branch": case "workflow": + case "run": return { org: context.org, repo: context.repo }; case "runner": return context.repo ? { org: context.org, repo: context.repo } : null; + case "runnerGroup": + return null; } } -/** One dashboard paired with the base URL of the config it came from. */ +/** One jump target paired with the base URL of the config it came from. */ interface ActiveDashboard { baseUrl: string; - dashboard: DashboardConfig; + dashboard: JumpTargetConfig; } /** @@ -316,31 +473,41 @@ function activeDashboards( })); } +/** + * A stable identity for deduping targets on export - a dashboard's own + * Grafana uid, or a trace target's user-chosen id (traces have no + * Grafana-assigned uid of their own to dedupe on). + */ +function targetKey(target: JumpTargetConfig): string { + return target.type === "trace" ? `trace:${target.id}` : `dashboard:${target.uid}`; +} + /** * Combines a repo's existing checked-in config with the current user's own * config, for exporting back into the repo - unlike activeDashboards() above, * this is a real union: the point of exporting is to publish your personal - * dashboards for the rest of the repo, on top of whatever's already shared, - * not to pick one source over the other. Dashboards are deduped by uid, - * preferring the repo's own copy of a uid that appears in both (it may have - * been intentionally edited by someone else since you last synced). baseUrl - * prefers the repo's if it has one, since the merged dashboard list is - * exported as a single file with one shared baseUrl field - if your personal - * dashboards actually live under a *different* Grafana instance than the - * repo's, this merge would produce an incorrect shared baseUrl for one set of - * them; that caveat is surfaced in the exported file's header comment rather - * than silently guessed at here. + * dashboards/traces for the rest of the repo, on top of whatever's already + * shared, not to pick one source over the other. Targets are deduped by + * targetKey(), preferring the repo's own copy of a key that appears in both + * (it may have been intentionally edited by someone else since you last + * synced). baseUrl prefers the repo's if it has one, since the merged target + * list is exported as a single file with one shared baseUrl field - if your + * personal targets actually live under a *different* Grafana instance than + * the repo's, this merge would produce an incorrect shared baseUrl for one + * set of them; that caveat is surfaced in the exported file's header comment + * rather than silently guessed at here. */ function mergeConfigsForExport( repoConfig: GrafanaJumpConfig | null, personalConfig: GrafanaJumpConfig, ): GrafanaJumpConfig { const merged = repoConfig ? [...repoConfig.dashboards] : []; - const knownUids = new Set(merged.map((d) => d.uid)); - for (const dashboard of personalConfig.dashboards) { - if (!knownUids.has(dashboard.uid)) { - merged.push(dashboard); - knownUids.add(dashboard.uid); + const knownKeys = new Set(merged.map(targetKey)); + for (const target of personalConfig.dashboards) { + const key = targetKey(target); + if (!knownKeys.has(key)) { + merged.push(target); + knownKeys.add(key); } } return { @@ -366,15 +533,77 @@ function buildDashboardUrl( } /** - * Builds the Grafana jump URL for one dashboard against a resolved context. - * Assumes the dashboard is applicable (see applicableDashboards) - callers that - * skip that check will just get a link with no var- filter applied. + * Substitutes `{{fieldKey}}` placeholders in a TraceQL query template with + * values from the current context (see contextFields()). A placeholder for a + * field this page doesn't actually have (which shouldn't happen for a target + * requiredFields() already gated as applicable, but could for a stray typo + * in the query) is left untouched rather than silently blanked out, so a + * malformed query is visibly broken instead of quietly matching too much. */ -function buildJumpUrl(baseUrl: string, dashboard: DashboardConfig, context: JumpContext): string { - const key = contextVarKey(context.kind); - const varName = dashboard.varNames[key]; - const vars = varName ? { [varName]: contextFilterValue(context) } : {}; - return buildDashboardUrl(baseUrl, dashboard, vars); +function renderTemplate(template: string, fields: Partial>): string { + return template.replace(/\{\{(\w+)\}\}/g, (whole, key: string) => { + const value = isContextFieldKey(key) ? fields[key] : undefined; + return value ?? whole; + }); +} + +/** + * Builds a Grafana Explore URL running a TraceQL search against a Tempo + * datasource - the `panes` query param is the same shape Explore itself + * generates when you build a query there by hand (an object keyed by an + * arbitrary pane id, JSON-encoded into the URL). There's no way to know how + * far back a given GitHub Actions run's trace lives, so this always searches + * the last 7 days; widen the range in Grafana itself for anything older. + */ +function buildTraceExploreUrl( + baseUrl: string, + target: TraceTarget, + fields: Partial>, +): string { + const pane = { + datasource: target.datasourceUid, + queries: [ + { + refId: "A", + queryType: "traceql", + query: renderTemplate(target.query, fields), + datasource: { uid: target.datasourceUid }, + }, + ], + range: { from: "now-7d", to: "now" }, + }; + const params = new URLSearchParams({ + schemaVersion: "1", + orgId: "1", + panes: JSON.stringify({ jump: pane }), + }); + return `${baseUrl}/explore?${params.toString()}`; +} + +/** + * Builds the Grafana jump URL for one target against a resolved context - a + * dashboard link with its own var- filters preset, or a Tempo trace search + * with its query template filled in. Assumes the target is applicable (see + * applicableDashboards) - callers that skip that check just get a link with + * whichever filters/placeholders the page happens to provide, silently + * omitted otherwise. + */ +function buildJumpUrl(baseUrl: string, target: JumpTargetConfig, context: JumpContext): string { + const fields = contextFields(context); + if (target.type === "trace") return buildTraceExploreUrl(baseUrl, target, fields); + + const vars: Record = {}; + for (const key of CONTEXT_FIELD_KEYS) { + const varName = target.varNames[key]; + const value = fields[key]; + if (varName && value) vars[varName] = value; + } + return buildDashboardUrl(baseUrl, target, vars); +} + +/** name if set, else whatever stable identifier the target has instead. */ +function targetDisplayName(target: JumpTargetConfig): string { + return target.name || (target.type === "trace" ? target.id : target.uid); } /** Human-readable label for the jump button, specific to the matched context. */ @@ -386,8 +615,14 @@ function labelForContext(context: JumpContext): string { return `Grafana: ${context.branch} CI`; case "workflow": return `Grafana: ${context.workflowFile} runs`; + case "run": + return context.jobId + ? `Grafana: run #${context.runId} / job #${context.jobId}` + : `Grafana: run #${context.runId}`; case "runner": return `Grafana: runner ${context.runnerId}`; + case "runnerGroup": + return `Grafana: runner group ${context.groupId}`; } } @@ -428,11 +663,19 @@ function tokenizeYamlLite(text: string): YamlLiteLine[] { function unquoteYamlLiteScalar(value: string): string { const trimmed = value.trim(); - const isQuoted = - trimmed.length >= 2 && - ((trimmed.startsWith('"') && trimmed.endsWith('"')) || - (trimmed.startsWith("'") && trimmed.endsWith("'"))); - return isQuoted ? trimmed.slice(1, -1) : trimmed; + if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) { + // Reverses the escaping yamlLiteScalar() applies when it double-quotes a + // value: a backslash followed by a backslash or a double quote is that + // literal character, unescaped. Single-quoted scalars (below) never get + // this treatment - yamlLiteScalar() only ever produces double-quoted + // output; single-quote support here is only for reading hand-written + // ones, which this format has no backslash-escaping convention for. + return trimmed.slice(1, -1).replace(/\\(["\\])/g, "$1"); + } + if (trimmed.length >= 2 && trimmed.startsWith("'") && trimmed.endsWith("'")) { + return trimmed.slice(1, -1); + } + return trimmed; } function isYamlLiteSeqItem(content: string): boolean { @@ -557,19 +800,24 @@ function configToYamlLite(config: GrafanaJumpConfig): string { } lines.push("dashboards:"); - for (const dashboard of config.dashboards) { - lines.push(` - name: ${yamlLiteScalar(dashboard.name)}`); - lines.push(` uid: ${yamlLiteScalar(dashboard.uid)}`); - lines.push(` slug: ${yamlLiteScalar(dashboard.slug)}`); - const varEntries = Object.entries(dashboard.varNames).filter( - (entry): entry is [string, string] => typeof entry[1] === "string" && entry[1] !== "", - ); + for (const target of config.dashboards) { + lines.push(` - type: ${yamlLiteScalar(target.type)}`); + lines.push(` name: ${yamlLiteScalar(target.name)}`); + if (target.type === "trace") { + lines.push(` id: ${yamlLiteScalar(target.id)}`); + lines.push(` datasourceUid: ${yamlLiteScalar(target.datasourceUid)}`); + lines.push(` query: ${yamlLiteScalar(target.query)}`); + continue; + } + lines.push(` uid: ${yamlLiteScalar(target.uid)}`); + lines.push(` slug: ${yamlLiteScalar(target.slug)}`); + const varEntries = CONTEXT_FIELD_KEYS.filter((key) => Boolean(target.varNames[key])); if (varEntries.length === 0) { lines.push(" varNames: {}"); } else { lines.push(" varNames:"); - for (const [key, value] of varEntries) { - lines.push(` ${key}: ${yamlLiteScalar(value)}`); + for (const key of varEntries) { + lines.push(` ${key}: ${yamlLiteScalar(target.varNames[key] as string)}`); } } } @@ -698,31 +946,76 @@ function buildCreateFileUrl(org: string, repo: string, branch: string, path: str const REPO_CONFIG_TEMPLATE = `# Config for the "GitHub Actions => Grafana jump button" userscript # (https://github.com/nsheaps/greasemonkey-scripts/tree/main/packages/github-actions-grafana-jump). -# Gives every contributor to this repo the same dashboards without each of +# Gives every contributor to this repo the same jump targets without each of # them configuring the userscript by hand. A contributor's own personal # config (set via the userscript's own "Set up Grafana jump" panel) always # takes priority over this file for any page it covers - this is only a # fallback for pages nobody has personally configured. # # baseUrl: your Grafana instance's base URL (no trailing slash). -# dashboards: one entry per dashboard you want jumpable to. For each: -# name - display label for the jump button/menu. -# uid - the dashboard's UID (Grafana dashboard settings -> JSON Model, -# or the segment right after /d/ in the dashboard's URL). -# slug - the URL slug right after the uid in the dashboard's URL. -# varNames - which of this dashboard's template variables (if any) to -# preset from the current GitHub page. Leave a field out if the -# dashboard doesn't use that kind of filter. +# dashboards: one entry per jump target. Each entry is either a Grafana +# dashboard link (type: dashboard) or a Tempo trace search (type: trace). A +# target only shows up as a jump target on pages that provide every field +# it's configured to use - not all fields are available on every page (a +# branch's Actions page has no workflow run ID, for example), so different +# targets naturally show up on different pages. +# +# type: dashboard +# name - display label for the jump button/menu. +# uid - the dashboard's UID (Grafana dashboard settings -> JSON +# Model, or the segment right after /d/ in the dashboard's +# URL). +# slug - the URL slug right after the uid in the dashboard's URL. +# varNames - which of this dashboard's template variables (if any) to +# preset from the current GitHub page. Leave a field out if +# the dashboard doesn't use that kind of filter. Available +# fields: repo, branch, prNumber, workflowName, runnerName, +# runnerGroupName, runId, jobId. +# +# type: trace +# name - display label for the jump button/menu. +# id - any string unique among your trace targets; used only +# to dedupe when exporting/merging this file, not shown +# anywhere. +# datasourceUid - the Tempo datasource's UID in Grafana (Connections -> +# Data sources -> your Tempo source -> the "uid" in its +# URL or Settings JSON). +# query - a TraceQL query with \`{{fieldKey}}\` placeholders (the +# same field names as varNames above) filled in from the +# current GitHub page - adjust the attribute names below +# (e.g. resource.github.run_id) to match however your own +# traces are tagged. baseUrl: https://grafana.example.com dashboards: - - name: CI Overview + - type: dashboard + name: Workflow runs for this repo + uid: REPLACE_WITH_DASHBOARD_UID + slug: REPLACE_WITH_DASHBOARD_SLUG + varNames: + repo: repository + - type: dashboard + name: Workflow runs on this runner uid: REPLACE_WITH_DASHBOARD_UID slug: REPLACE_WITH_DASHBOARD_SLUG varNames: - branch: branch - prNumber: pr_number - workflowName: workflow_file runnerName: runner_name + - type: dashboard + name: Workflow runs for this branch + uid: REPLACE_WITH_DASHBOARD_UID + slug: REPLACE_WITH_DASHBOARD_SLUG + varNames: + repo: repository + branch: branch + - type: trace + name: Trace for this workflow run + id: workflow-run-trace + datasourceUid: REPLACE_WITH_TEMPO_DATASOURCE_UID + query: '{resource.github.run_id="{{runId}}"}' + - type: trace + name: Span for this job + id: workflow-job-span + datasourceUid: REPLACE_WITH_TEMPO_DATASOURCE_UID + query: '{resource.github.run_id="{{runId}}" && resource.github.job_id="{{jobId}}"}' `; // --------------------------------------------------------------------------- @@ -823,7 +1116,7 @@ function openConfigModal(repoCtx: { org: string; repo: string } | null): void { // Work on a deep-ish draft copy so Cancel leaves the saved config untouched. const draft: GrafanaJumpConfig = { baseUrl: currentConfig.baseUrl, - dashboards: currentConfig.dashboards.map((d) => ({ ...d, varNames: { ...d.varNames } })), + dashboards: currentConfig.dashboards.map((d) => (d.type === "trace" ? { ...d } : { ...d, varNames: { ...d.varNames } })), }; const overlay = document.createElement("div"); @@ -852,10 +1145,11 @@ function openConfigModal(repoCtx: { org: string; repo: string } | null): void { const help = document.createElement("p"); help.textContent = - "Set your Grafana base URL and the dashboards to jump to. For each dashboard, " + - "fill in whichever template-variable names it uses (dashboard settings -> " + - "Variables) - leave the rest blank. A dashboard only shows up as a jump target " + - "on pages matching a variable name you've filled in."; + "Set your Grafana base URL and the jump targets to offer - a dashboard link, or a " + + "Tempo trace search. For a dashboard, fill in whichever template-variable names it " + + "uses (dashboard settings -> Variables); for a trace, write a TraceQL query using " + + "{{fieldKey}} placeholders. A target only shows up on a page that provides every " + + "field it references - leave fields blank/out of the query if a target doesn't need them."; help.setAttribute("style", "margin: 0 0 16px; font-size: 12px; color: #57606a;"); panel.appendChild(help); @@ -885,7 +1179,7 @@ function openConfigModal(repoCtx: { org: string; repo: string } | null): void { panel.appendChild(baseUrlInput); const dashboardsHeading = document.createElement("div"); - dashboardsHeading.textContent = "Dashboards"; + dashboardsHeading.textContent = "Jump targets"; dashboardsHeading.setAttribute("style", "font-size: 12px; font-weight: 600; margin-bottom: 8px;"); panel.appendChild(dashboardsHeading); @@ -918,9 +1212,20 @@ function openConfigModal(repoCtx: { org: string; repo: string } | null): void { parent.appendChild(wrapper); }; + const VAR_FIELDS: Array<[ContextFieldKey, string]> = [ + ["repo", "Repo name"], + ["branch", "Branch"], + ["prNumber", "PR number"], + ["workflowName", "Workflow file"], + ["runnerName", "Runner"], + ["runnerGroupName", "Runner group"], + ["runId", "Workflow run ID"], + ["jobId", "Job ID"], + ]; + const renderRows = (): void => { rowsContainer.innerHTML = ""; - draft.dashboards.forEach((dashboard, index) => { + draft.dashboards.forEach((target, index) => { const row = document.createElement("div"); row.setAttribute( "style", @@ -941,36 +1246,80 @@ function openConfigModal(repoCtx: { org: string; repo: string } | null): void { }); row.appendChild(removeButton); - textField(row, "Display name", dashboard.name, (value) => { - dashboard.name = value; - }); - textField(row, "Dashboard UID", dashboard.uid, (value) => { - dashboard.uid = value.trim(); - }); - textField(row, "Dashboard slug", dashboard.slug, (value) => { - dashboard.slug = value.trim(); + const typeLabel = document.createElement("label"); + typeLabel.textContent = "Target type"; + typeLabel.setAttribute("style", "display: block; font-size: 11px; color: #57606a; margin-bottom: 2px;"); + row.appendChild(typeLabel); + + const typeSelect = document.createElement("select"); + typeSelect.setAttribute( + "style", + "display: block; width: 100%; box-sizing: border-box; padding: 4px 6px; " + + "margin-bottom: 6px; font-size: 12px; border: 1px solid #d0d7de; border-radius: 4px; " + + "background: #fff; color: #24292f;", + ); + for (const [value, optionLabel] of [ + ["dashboard", "Grafana dashboard"], + ["trace", "Tempo trace search"], + ] as const) { + const option = document.createElement("option"); + option.value = value; + option.textContent = optionLabel; + option.selected = target.type === value; + typeSelect.appendChild(option); + } + typeSelect.addEventListener("change", () => { + const name = draft.dashboards[index].name; + draft.dashboards[index] = + typeSelect.value === "trace" + ? { type: "trace", name, id: "", datasourceUid: "", query: "" } + : { type: "dashboard", name, uid: "", slug: "", varNames: {} }; + renderRows(); }); + row.appendChild(typeSelect); - const varsHeading = document.createElement("div"); - varsHeading.textContent = "Template variable names (leave blank if not used)"; - varsHeading.setAttribute("style", "font-size: 11px; color: #57606a; margin: 8px 0 4px;"); - row.appendChild(varsHeading); + textField(row, "Display name", target.name, (value) => { + target.name = value; + }); - const varFields: Array<[keyof DashboardVarNames, string]> = [ - ["branch", "Branch"], - ["prNumber", "PR number"], - ["workflowName", "Workflow file"], - ["runnerName", "Runner"], - ]; - for (const [key, label] of varFields) { - textField(row, label, dashboard.varNames[key] ?? "", (value) => { - const trimmed = value.trim(); - if (trimmed === "") { - delete dashboard.varNames[key]; - } else { - dashboard.varNames[key] = trimmed; - } + if (target.type === "trace") { + textField(row, "Target ID (unique among your traces; only used to dedupe on export)", target.id, (value) => { + target.id = value.trim(); + }); + textField(row, "Tempo datasource UID", target.datasourceUid, (value) => { + target.datasourceUid = value.trim(); + }); + textField( + row, + "TraceQL query (use {{fieldKey}} placeholders, e.g. {{runId}})", + target.query, + (value) => { + target.query = value; + }, + ); + } else { + textField(row, "Dashboard UID", target.uid, (value) => { + target.uid = value.trim(); }); + textField(row, "Dashboard slug", target.slug, (value) => { + target.slug = value.trim(); + }); + + const varsHeading = document.createElement("div"); + varsHeading.textContent = "Template variable names (leave blank if not used)"; + varsHeading.setAttribute("style", "font-size: 11px; color: #57606a; margin: 8px 0 4px;"); + row.appendChild(varsHeading); + + for (const [key, label] of VAR_FIELDS) { + textField(row, label, target.varNames[key] ?? "", (value) => { + const trimmed = value.trim(); + if (trimmed === "") { + delete target.varNames[key]; + } else { + target.varNames[key] = trimmed; + } + }); + } } rowsContainer.appendChild(row); @@ -980,7 +1329,7 @@ function openConfigModal(repoCtx: { org: string; repo: string } | null): void { const addButton = document.createElement("button"); addButton.type = "button"; - addButton.textContent = "+ Add dashboard"; + addButton.textContent = "+ Add jump target"; addButton.setAttribute( "style", "display: block; width: 100%; padding: 8px; margin-bottom: 16px; " + @@ -988,7 +1337,7 @@ function openConfigModal(repoCtx: { org: string; repo: string } | null): void { "font-size: 12px; cursor: pointer;", ); addButton.addEventListener("click", () => { - draft.dashboards.push({ name: "", uid: "", slug: "", varNames: {} }); + draft.dashboards.push({ type: "dashboard", name: "", uid: "", slug: "", varNames: {} }); renderRows(); }); panel.appendChild(addButton); @@ -1108,7 +1457,7 @@ function renderJumpButton(context: JumpContext | null): void { const setupButton = document.createElement("button"); setupButton.type = "button"; setupButton.textContent = isConfigured(currentConfig) - ? "⚙️ No dashboard configured for this page" + ? "⚙️ No jump target configured for this page" : "⚙️ Set up Grafana jump"; setupButton.setAttribute("style", SOLO_BUTTON_STYLE); setupButton.addEventListener("click", (event) => { @@ -1124,7 +1473,8 @@ function renderJumpButton(context: JumpContext | null): void { jumpLink.setAttribute("href", buildJumpUrl(primary.baseUrl, primary.dashboard, context)); jumpLink.setAttribute("target", "_blank"); jumpLink.setAttribute("style", BUTTON_STYLE); - jumpLink.textContent = active.length > 1 ? `${label} (${primary.dashboard.name}) ↗️` : `${label} ↗️`; + jumpLink.textContent = + active.length > 1 ? `${label} (${targetDisplayName(primary.dashboard)}) ↗️` : `${label} ↗️`; container.appendChild(jumpLink); const toggle = document.createElement("button"); @@ -1135,10 +1485,10 @@ function renderJumpButton(context: JumpContext | null): void { event.stopPropagation(); const items = [ ...active.map(({ baseUrl, dashboard }) => ({ - label: `↗️ ${dashboard.name || dashboard.uid}`, + label: `↗️ ${targetDisplayName(dashboard)}`, onClick: () => window.open(buildJumpUrl(baseUrl, dashboard, context), "_blank"), })), - { label: "⚙️ Edit dashboards...", onClick: () => openConfigModal(repoContextForJump(context)) }, + { label: "⚙️ Edit jump targets...", onClick: () => openConfigModal(repoContextForJump(context)) }, ]; openMenu(container, items); }); @@ -1209,16 +1559,20 @@ if (typeof module !== "undefined" && module.exports) { parsePrContext, parseBranchContext, parseRunnerContext, + parseRunnerGroupContext, parseWorkflowContext, + parseRunContext, resolveJumpContext, extractBranchFromQuery, - contextVarKey, - contextFilterValue, + contextFields, + requiredFields, applicableDashboards, repoContextForJump, activeDashboards, mergeConfigsForExport, buildDashboardUrl, + renderTemplate, + buildTraceExploreUrl, buildJumpUrl, labelForContext, defaultConfig, diff --git a/packages/github-actions-grafana-jump/test/grafana-jump.test.js b/packages/github-actions-grafana-jump/test/grafana-jump.test.js index b634c43..35f138c 100644 --- a/packages/github-actions-grafana-jump/test/grafana-jump.test.js +++ b/packages/github-actions-grafana-jump/test/grafana-jump.test.js @@ -13,16 +13,20 @@ const { parsePrContext, parseBranchContext, parseRunnerContext, + parseRunnerGroupContext, parseWorkflowContext, + parseRunContext, resolveJumpContext, extractBranchFromQuery, - contextVarKey, - contextFilterValue, + contextFields, + requiredFields, applicableDashboards, repoContextForJump, activeDashboards, mergeConfigsForExport, buildDashboardUrl, + renderTemplate, + buildTraceExploreUrl, buildJumpUrl, labelForContext, defaultConfig, @@ -106,6 +110,18 @@ test("parseRunnerContext returns null off a runner detail page", () => { assert.equal(parseRunnerContext("/organizations/oura/settings/actions/runner-groups/1"), null); }); +test("parseRunnerGroupContext matches an org's runner group detail page", () => { + assert.deepEqual( + parseRunnerGroupContext("/organizations/oura/settings/actions/runner-groups/3"), + { kind: "runnerGroup", org: "oura", groupId: "3" }, + ); +}); + +test("parseRunnerGroupContext returns null off a runner group detail page", () => { + assert.equal(parseRunnerGroupContext("/organizations/oura/settings/actions/runners/3"), null); + assert.equal(parseRunnerGroupContext("/oura/some-repo/settings/actions"), null); +}); + test("parseWorkflowContext matches a workflow's own page", () => { assert.deepEqual(parseWorkflowContext("/oura/some-repo/actions/workflows/ci.yml"), { kind: "workflow", @@ -125,6 +141,27 @@ test("parseWorkflowContext returns null off a workflow page", () => { assert.equal(parseWorkflowContext("/oura/some-repo/actions"), null); }); +test("parseRunContext matches a run's overview page and a specific job within it", () => { + assert.deepEqual(parseRunContext("/oura/some-repo/actions/runs/123456"), { + kind: "run", + org: "oura", + repo: "some-repo", + runId: "123456", + }); + assert.deepEqual(parseRunContext("/oura/some-repo/actions/runs/123456/job/789"), { + kind: "run", + org: "oura", + repo: "some-repo", + runId: "123456", + jobId: "789", + }); +}); + +test("parseRunContext returns null off a run page", () => { + assert.equal(parseRunContext("/oura/some-repo/actions/workflows/ci.yml"), null); + assert.equal(parseRunContext("/oura/some-repo/actions"), null); +}); + test("resolveJumpContext dispatches to the right parser for each supported URL shape", () => { assert.deepEqual(resolveJumpContext("/oura/some-repo/pull/42", ""), { kind: "pr", @@ -136,31 +173,53 @@ test("resolveJumpContext dispatches to the right parser for each supported URL s resolveJumpContext("/oura/some-repo/actions/workflows/ci.yml", ""), { kind: "workflow", org: "oura", repo: "some-repo", workflowFile: "ci.yml" }, ); + assert.deepEqual( + resolveJumpContext("/oura/some-repo/actions/runs/9/job/1", ""), + { kind: "run", org: "oura", repo: "some-repo", runId: "9", jobId: "1" }, + ); assert.deepEqual( resolveJumpContext("/organizations/oura/settings/actions/runners/9", ""), { kind: "runner", scope: "org", org: "oura", runnerId: "9" }, ); + assert.deepEqual( + resolveJumpContext("/organizations/oura/settings/actions/runner-groups/2", ""), + { kind: "runnerGroup", org: "oura", groupId: "2" }, + ); assert.equal(resolveJumpContext("/oura/some-repo/issues/1", ""), null); }); -test("contextVarKey maps each context kind to its DashboardVarNames field", () => { - assert.equal(contextVarKey("pr"), "prNumber"); - assert.equal(contextVarKey("branch"), "branch"); - assert.equal(contextVarKey("workflow"), "workflowName"); - assert.equal(contextVarKey("runner"), "runnerName"); -}); - -test("contextFilterValue extracts the raw filter value per context kind", () => { - assert.equal(contextFilterValue({ kind: "pr", org: "o", repo: "r", prNumber: "42" }), "42"); - assert.equal(contextFilterValue({ kind: "branch", org: "o", repo: "r", branch: "main" }), "main"); - assert.equal( - contextFilterValue({ kind: "workflow", org: "o", repo: "r", workflowFile: "ci.yml" }), - "ci.yml", +test("contextFields exposes every page-provided field per context kind", () => { + assert.deepEqual(contextFields({ kind: "pr", org: "o", repo: "r", prNumber: "42" }), { + repo: "r", + prNumber: "42", + }); + assert.deepEqual(contextFields({ kind: "branch", org: "o", repo: "r", branch: "main" }), { + repo: "r", + branch: "main", + }); + assert.deepEqual( + contextFields({ kind: "workflow", org: "o", repo: "r", workflowFile: "ci.yml" }), + { repo: "r", workflowName: "ci.yml" }, ); - assert.equal( - contextFilterValue({ kind: "runner", scope: "repo", org: "o", runnerId: "9" }), - "9", + assert.deepEqual(contextFields({ kind: "run", org: "o", repo: "r", runId: "9" }), { + repo: "r", + runId: "9", + }); + assert.deepEqual( + contextFields({ kind: "run", org: "o", repo: "r", runId: "9", jobId: "1" }), + { repo: "r", runId: "9", jobId: "1" }, + ); + assert.deepEqual( + contextFields({ kind: "runner", scope: "repo", org: "o", repo: "r", runnerId: "5" }), + { repo: "r", runnerName: "5" }, ); + assert.deepEqual( + contextFields({ kind: "runner", scope: "org", org: "o", runnerId: "5" }), + { runnerName: "5" }, + ); + assert.deepEqual(contextFields({ kind: "runnerGroup", org: "o", groupId: "3" }), { + runnerGroupName: "3", + }); }); test("defaultConfig starts empty and unconfigured", () => { @@ -170,7 +229,7 @@ test("defaultConfig starts empty and unconfigured", () => { assert.equal(isConfigured(config), false); }); -test("isConfigured requires both a base URL and at least one dashboard", () => { +test("isConfigured requires both a base URL and at least one target", () => { assert.equal(isConfigured({ baseUrl: "", dashboards: [] }), false); assert.equal( isConfigured({ baseUrl: "https://g.example.com", dashboards: [] }), @@ -179,7 +238,7 @@ test("isConfigured requires both a base URL and at least one dashboard", () => { assert.equal( isConfigured({ baseUrl: "https://g.example.com", - dashboards: [{ name: "d", uid: "u", slug: "s", varNames: {} }], + dashboards: [{ type: "dashboard", name: "d", uid: "u", slug: "s", varNames: {} }], }), true, ); @@ -210,36 +269,139 @@ test("normalizeConfig trims strings, drops empty varNames, and drops dashboards assert.deepEqual(result, { baseUrl: "https://g.example.com", dashboards: [ - { name: "My Dashboard", uid: "abc123", slug: "my-dash", varNames: { branch: "br" } }, + { type: "dashboard", name: "My Dashboard", uid: "abc123", slug: "my-dash", varNames: { branch: "br" } }, + ], + }); +}); + +test("normalizeConfig treats an entry with no `type` field as a dashboard (the format's original shape)", () => { + const result = normalizeConfig({ + baseUrl: "https://g.example.com", + dashboards: [{ name: "legacy", uid: "u1", slug: "s1", varNames: { branch: "br" } }], + }); + assert.deepEqual(result.dashboards, [ + { type: "dashboard", name: "legacy", uid: "u1", slug: "s1", varNames: { branch: "br" } }, + ]); +}); + +test("normalizeConfig reads a trace target, trimming and collapsing an embedded newline in its query", () => { + const result = normalizeConfig({ + baseUrl: "https://g.example.com", + dashboards: [ + { + type: "trace", + name: " Run trace ", + id: " run-trace ", + datasourceUid: " tempo-uid ", + query: '{resource.github.run_id="{{runId}}"}\n && foo="bar"', + }, ], }); + assert.deepEqual(result.dashboards, [ + { + type: "trace", + name: "Run trace", + id: "run-trace", + datasourceUid: "tempo-uid", + query: '{resource.github.run_id="{{runId}}"} && foo="bar"', + }, + ]); }); -test("applicableDashboards only returns dashboards with a varName for the context's field", () => { +test("normalizeConfig drops a trace target missing id, datasourceUid, or query", () => { + const base = { type: "trace", name: "x", id: "i", datasourceUid: "d", query: "{{runId}}" }; + for (const missing of ["id", "datasourceUid", "query"]) { + const result = normalizeConfig({ + baseUrl: "https://g.example.com", + dashboards: [{ ...base, [missing]: "" }], + }); + assert.deepEqual(result.dashboards, [], `expected a trace with no ${missing} to be dropped`); + } +}); + +test("requiredFields reads a dashboard's configured varNames entries", () => { + assert.deepEqual( + requiredFields({ type: "dashboard", name: "", uid: "u", slug: "s", varNames: { branch: "b", runId: "r" } }), + ["branch", "runId"], + ); + assert.deepEqual( + requiredFields({ type: "dashboard", name: "", uid: "u", slug: "s", varNames: {} }), + [], + ); +}); + +test("requiredFields reads a trace target's {{placeholder}} references, ignoring unknown ones", () => { + assert.deepEqual( + requiredFields({ + type: "trace", + name: "", + id: "i", + datasourceUid: "d", + query: '{resource.github.run_id="{{runId}}" && resource.github.job_id="{{jobId}}"}', + }), + ["runId", "jobId"], + ); + assert.deepEqual( + requiredFields({ type: "trace", name: "", id: "i", datasourceUid: "d", query: '{foo="{{notARealField}}"}' }), + [], + ); +}); + +test("applicableDashboards requires every one of a target's configured fields to be present", () => { const config = { baseUrl: "https://g.example.com", dashboards: [ - { name: "branch-only", uid: "u1", slug: "s1", varNames: { branch: "br" } }, - { name: "pr-and-branch", uid: "u2", slug: "s2", varNames: { branch: "br", prNumber: "pr" } }, - { name: "runner-only", uid: "u3", slug: "s3", varNames: { runnerName: "runner" } }, + { type: "dashboard", name: "branch-only", uid: "u1", slug: "s1", varNames: { branch: "br" } }, + { + type: "dashboard", + name: "repo-and-branch", + uid: "u2", + slug: "s2", + varNames: { repo: "repository", branch: "br" }, + }, + { type: "dashboard", name: "runner-only", uid: "u3", slug: "s3", varNames: { runnerName: "runner" } }, + { type: "dashboard", name: "no-vars", uid: "u4", slug: "s4", varNames: {} }, + { + type: "trace", + name: "run-trace", + id: "t1", + datasourceUid: "tempo", + query: '{resource.run_id="{{runId}}"}', + }, + { + type: "trace", + name: "job-span", + id: "t2", + datasourceUid: "tempo", + query: '{resource.run_id="{{runId}}" && resource.job_id="{{jobId}}"}', + }, ], }; const branchContext = { kind: "branch", org: "o", repo: "r", branch: "main" }; assert.deepEqual( applicableDashboards(config, branchContext).map((d) => d.name), - ["branch-only", "pr-and-branch"], + ["branch-only", "repo-and-branch"], ); const prContext = { kind: "pr", org: "o", repo: "r", prNumber: "1" }; - assert.deepEqual(applicableDashboards(config, prContext).map((d) => d.name), ["pr-and-branch"]); + assert.deepEqual(applicableDashboards(config, prContext), []); - const runnerContext = { kind: "runner", scope: "repo", org: "o", runnerId: "1" }; + const runnerContext = { kind: "runner", scope: "repo", org: "o", repo: "r", runnerId: "1" }; assert.deepEqual( applicableDashboards(config, runnerContext).map((d) => d.name), ["runner-only"], ); + const runContext = { kind: "run", org: "o", repo: "r", runId: "9" }; + assert.deepEqual(applicableDashboards(config, runContext).map((d) => d.name), ["run-trace"]); + + const jobContext = { kind: "run", org: "o", repo: "r", runId: "9", jobId: "1" }; + assert.deepEqual( + applicableDashboards(config, jobContext).map((d) => d.name), + ["run-trace", "job-span"], + ); + const workflowContext = { kind: "workflow", org: "o", repo: "r", workflowFile: "ci.yml" }; assert.deepEqual(applicableDashboards(config, workflowContext), []); }); @@ -272,8 +434,18 @@ test("buildDashboardUrl supports multiple variables and omits the query string w assert.equal(withoutVars, "https://g.example.com/d/abc123/my-dashboard"); }); -test("buildJumpUrl applies the dashboard's own varName for the context's field", () => { +test("renderTemplate substitutes known {{fieldKey}} placeholders and leaves unknown ones untouched", () => { + assert.equal( + renderTemplate('{a="{{runId}}" && b="{{jobId}}"}', { runId: "9", jobId: "1" }), + '{a="9" && b="1"}', + ); + assert.equal(renderTemplate('{a="{{bogus}}"}', { runId: "9" }), '{a="{{bogus}}"}'); + assert.equal(renderTemplate('{a="{{runId}}"}', {}), '{a="{{runId}}"}'); +}); + +test("buildJumpUrl applies every configured varName the context has a value for", () => { const dashboard = { + type: "dashboard", name: "CI", uid: "abc123", slug: "ci-dashboard", @@ -298,7 +470,7 @@ test("buildJumpUrl applies the dashboard's own varName for the context's field", }); test("buildJumpUrl omits the var- filter entirely when the dashboard has no matching varName", () => { - const dashboard = { name: "CI", uid: "abc123", slug: "ci-dashboard", varNames: {} }; + const dashboard = { type: "dashboard", name: "CI", uid: "abc123", slug: "ci-dashboard", varNames: {} }; const url = buildJumpUrl("https://g.example.com", dashboard, { kind: "runner", scope: "repo", @@ -308,6 +480,43 @@ test("buildJumpUrl omits the var- filter entirely when the dashboard has no matc assert.equal(url, "https://g.example.com/d/abc123/ci-dashboard"); }); +test("buildJumpUrl builds a Tempo Explore search for a trace target", () => { + const trace = { + type: "trace", + name: "Run trace", + id: "t1", + datasourceUid: "tempo-uid", + query: '{resource.github.run_id="{{runId}}"}', + }; + const url = buildJumpUrl("https://g.example.com", trace, { + kind: "run", + org: "o", + repo: "r", + runId: "42", + }); + const parsed = new URL(url); + assert.equal(`${parsed.origin}${parsed.pathname}`, "https://g.example.com/explore"); + assert.equal(parsed.searchParams.get("schemaVersion"), "1"); + assert.equal(parsed.searchParams.get("orgId"), "1"); + const panes = JSON.parse(parsed.searchParams.get("panes")); + assert.equal(panes.jump.datasource, "tempo-uid"); + assert.equal(panes.jump.queries[0].queryType, "traceql"); + assert.equal(panes.jump.queries[0].query, '{resource.github.run_id="42"}'); +}); + +test("buildTraceExploreUrl fills in the query template from the given fields", () => { + const trace = { + type: "trace", + name: "Job span", + id: "t2", + datasourceUid: "tempo-uid", + query: '{resource.run_id="{{runId}}" && resource.job_id="{{jobId}}"}', + }; + const url = buildTraceExploreUrl("https://g.example.com", trace, { runId: "9", jobId: "1" }); + const panes = JSON.parse(new URL(url).searchParams.get("panes")); + assert.equal(panes.jump.queries[0].query, '{resource.run_id="9" && resource.job_id="1"}'); +}); + test("labelForContext produces a distinct human-readable label per context kind", () => { assert.equal( labelForContext({ kind: "pr", org: "oura", repo: "r", prNumber: "42" }), @@ -321,10 +530,22 @@ test("labelForContext produces a distinct human-readable label per context kind" labelForContext({ kind: "workflow", org: "oura", repo: "r", workflowFile: "ci.yml" }), "Grafana: ci.yml runs", ); + assert.equal( + labelForContext({ kind: "run", org: "oura", repo: "r", runId: "9" }), + "Grafana: run #9", + ); + assert.equal( + labelForContext({ kind: "run", org: "oura", repo: "r", runId: "9", jobId: "1" }), + "Grafana: run #9 / job #1", + ); assert.equal( labelForContext({ kind: "runner", scope: "org", org: "oura", runnerId: "9" }), "Grafana: runner 9", ); + assert.equal( + labelForContext({ kind: "runnerGroup", org: "oura", groupId: "3" }), + "Grafana: runner group 3", + ); }); test("repoContextForJump extracts {org, repo} for repo-scoped contexts", () => { @@ -340,27 +561,32 @@ test("repoContextForJump extracts {org, repo} for repo-scoped contexts", () => { repoContextForJump({ kind: "workflow", org: "o", repo: "r", workflowFile: "ci.yml" }), { org: "o", repo: "r" }, ); + assert.deepEqual( + repoContextForJump({ kind: "run", org: "o", repo: "r", runId: "9" }), + { org: "o", repo: "r" }, + ); assert.deepEqual( repoContextForJump({ kind: "runner", scope: "repo", org: "o", repo: "r", runnerId: "9" }), { org: "o", repo: "r" }, ); }); -test("repoContextForJump returns null for an org-scoped runner context", () => { +test("repoContextForJump returns null for org-scoped runner and runnerGroup contexts", () => { assert.equal( repoContextForJump({ kind: "runner", scope: "org", org: "o", runnerId: "9" }), null, ); + assert.equal(repoContextForJump({ kind: "runnerGroup", org: "o", groupId: "3" }), null); }); test("activeDashboards prefers the personal config outright when it has anything applicable", () => { const personal = { baseUrl: "https://personal.example.com", - dashboards: [{ name: "mine", uid: "p1", slug: "mine", varNames: { branch: "br" } }], + dashboards: [{ type: "dashboard", name: "mine", uid: "p1", slug: "mine", varNames: { branch: "br" } }], }; const repo = { baseUrl: "https://repo.example.com", - dashboards: [{ name: "theirs", uid: "r1", slug: "theirs", varNames: { branch: "br" } }], + dashboards: [{ type: "dashboard", name: "theirs", uid: "r1", slug: "theirs", varNames: { branch: "br" } }], }; const context = { kind: "branch", org: "o", repo: "r", branch: "main" }; assert.deepEqual(activeDashboards(personal, repo, context), [ @@ -372,7 +598,7 @@ test("activeDashboards falls back to the repo config when personal has nothing a const personal = { baseUrl: "https://personal.example.com", dashboards: [] }; const repo = { baseUrl: "https://repo.example.com", - dashboards: [{ name: "theirs", uid: "r1", slug: "theirs", varNames: { branch: "br" } }], + dashboards: [{ type: "dashboard", name: "theirs", uid: "r1", slug: "theirs", varNames: { branch: "br" } }], }; const context = { kind: "branch", org: "o", repo: "r", branch: "main" }; assert.deepEqual(activeDashboards(personal, repo, context), [ @@ -386,31 +612,46 @@ test("activeDashboards returns nothing when neither config nor a null repo confi assert.deepEqual(activeDashboards(personal, null, context), []); }); -test("mergeConfigsForExport unions dashboards, deduped by uid, preferring the repo's copy", () => { +test("mergeConfigsForExport unions targets, deduped by targetKey, preferring the repo's copy", () => { const repo = { baseUrl: "https://repo.example.com", dashboards: [ - { name: "repo-only", uid: "r1", slug: "repo-only", varNames: { branch: "br" } }, - { name: "shared (repo version)", uid: "shared", slug: "shared", varNames: {} }, + { type: "dashboard", name: "repo-only", uid: "r1", slug: "repo-only", varNames: { branch: "br" } }, + { type: "dashboard", name: "shared (repo version)", uid: "shared", slug: "shared", varNames: {} }, + { type: "trace", name: "repo trace (repo version)", id: "trace1", datasourceUid: "tempo", query: "{{runId}}" }, ], }; const personal = { baseUrl: "https://personal.example.com", dashboards: [ - { name: "personal-only", uid: "p1", slug: "personal-only", varNames: {} }, - { name: "shared (personal version)", uid: "shared", slug: "shared", varNames: { prNumber: "pr" } }, + { type: "dashboard", name: "personal-only", uid: "p1", slug: "personal-only", varNames: {} }, + { + type: "dashboard", + name: "shared (personal version)", + uid: "shared", + slug: "shared", + varNames: { prNumber: "pr" }, + }, + { type: "trace", name: "repo trace (personal version)", id: "trace1", datasourceUid: "tempo", query: "x" }, + { type: "trace", name: "personal-only trace", id: "trace2", datasourceUid: "tempo", query: "y" }, ], }; assert.deepEqual(mergeConfigsForExport(repo, personal), { baseUrl: "https://repo.example.com", - dashboards: [repo.dashboards[0], repo.dashboards[1], personal.dashboards[0]], + dashboards: [ + repo.dashboards[0], + repo.dashboards[1], + repo.dashboards[2], + personal.dashboards[0], + personal.dashboards[3], + ], }); }); test("mergeConfigsForExport falls back to the personal baseUrl when there is no repo config", () => { const personal = { baseUrl: "https://personal.example.com", - dashboards: [{ name: "mine", uid: "p1", slug: "mine", varNames: {} }], + dashboards: [{ type: "dashboard", name: "mine", uid: "p1", slug: "mine", varNames: {} }], }; assert.deepEqual(mergeConfigsForExport(null, personal), { baseUrl: "https://personal.example.com", @@ -424,7 +665,8 @@ test("parseYamlLite reads a baseUrl and a sequence of dashboard mappings with ne "", "baseUrl: https://grafana.example.com", "dashboards:", - " - name: CI Overview", + " - type: dashboard", + " name: CI Overview", " uid: abc123", " slug: ci-overview", " varNames:", @@ -436,6 +678,7 @@ test("parseYamlLite reads a baseUrl and a sequence of dashboard mappings with ne baseUrl: "https://grafana.example.com", dashboards: [ { + type: "dashboard", name: "CI Overview", uid: "abc123", slug: "ci-overview", @@ -450,17 +693,35 @@ test("parseYamlLite unquotes single- and double-quoted scalars", () => { assert.deepEqual(parseYamlLite(text), { name: "quoted value", slug: "also quoted" }); }); -test("parseYamlLite round-trips through configToYamlLite for a config with multiple dashboards", () => { +test("parseYamlLite unescapes backslash-escaped quotes/backslashes inside a double-quoted scalar", () => { + const text = 'query: "{a=\\"b\\"} \\\\ done"'; + assert.deepEqual(parseYamlLite(text), { query: '{a="b"} \\ done' }); +}); + +test("parseYamlLite reads a single-quoted TraceQL query containing embedded double quotes verbatim", () => { + const text = 'query: \'{resource.github.run_id="{{runId}}"}\''; + assert.deepEqual(parseYamlLite(text), { query: '{resource.github.run_id="{{runId}}"}' }); +}); + +test("parseYamlLite round-trips through configToYamlLite for a config with dashboard and trace targets", () => { const config = { baseUrl: "https://grafana.example.com", dashboards: [ { + type: "dashboard", name: "CI Overview", uid: "abc123", slug: "ci-overview", varNames: { branch: "branch", prNumber: "pr_number" }, }, - { name: "No vars", uid: "def456", slug: "no-vars", varNames: {} }, + { type: "dashboard", name: "No vars", uid: "def456", slug: "no-vars", varNames: {} }, + { + type: "trace", + name: "Job span", + id: "job-span", + datasourceUid: "tempo-uid", + query: '{resource.run_id="{{runId}}" && resource.job_id="{{jobId}}"}', + }, ], }; assert.deepEqual(normalizeConfig(parseYamlLite(configToYamlLite(config))), config); @@ -469,7 +730,9 @@ test("parseYamlLite round-trips through configToYamlLite for a config with multi test("configToYamlLite quotes scalars that would otherwise be misread", () => { const config = { baseUrl: "https://grafana.example.com", - dashboards: [{ name: "- looks like a list item", uid: "u1", slug: "s1", varNames: {} }], + dashboards: [ + { type: "dashboard", name: "- looks like a list item", uid: "u1", slug: "s1", varNames: {} }, + ], }; const yaml = configToYamlLite(config); assert.match(yaml, /name: "- looks like a list item"/);