The desktop app's runner shows itself everywhere a person looks for it. Its settings window has a Runner section: whether it runs jobs while g1t is open, what it is doing now, and when it isn't running why and the fix, with the fix's buttons (Open Docker, Sign in, Choose workspace, Restart, View log and the rest); how jobs run, sandboxed, in Docker, or directly on this computer, each with plainly what it can reach; the workspace it runs for; this computer's name, the runner's version, View on g1t, and the runner's recent log.
The site registers it with nothing typed. On every page inside the app, the page tells the app who is signed in, the workspaces they own and the one they are in; when the app asks, the page makes a registration token for that workspace at POST /-/desktop/runner, owners only and same origin, and hands it straight to the app, never showing it. Someone who isn't an owner of the workspace is told so, with a way to choose another. A workspace's Runners page lists its Desktops: every person's computer with the app, with the state, why and the fix in the app's own words, which each app reports as it changes and every few minutes while g1t is open. One whose g1t is closed says g1t is closed on that computer, not a bare Offline, here and in the runners list for a runner labelled desktop. Inside the app, this computer leads the list, live, with its fix buttons. The Your desktop card is no longer marked as coming. The actions service keeps the reports in a new desktop_runners table (migration 0018), and a runner now records who made the token that registered it.
| 225 | 225 | .foot { | |
| 226 | 226 | text-align: center; | |
| 227 | 227 | } | |
| 228 | + | code { | |
| 229 | + | font: 0.8125rem ui-monospace, SFMono-Regular, Consolas, monospace; | |
| 230 | + | } | |
| 231 | + | /* The runner's state: a dot in its tone, what it is doing, and when it | |
| 232 | + | isn't running, why and the fix, with its buttons. */ | |
| 233 | + | .diagnosis { | |
| 234 | + | display: flex; | |
| 235 | + | align-items: flex-start; | |
| 236 | + | gap: 0.75rem; | |
| 237 | + | margin: 0.25rem 0 0.5rem; | |
| 238 | + | padding: 0.75rem; | |
| 239 | + | border: 1px solid var(--line); | |
| 240 | + | border-radius: 10px; | |
| 241 | + | background: var(--surface); | |
| 242 | + | } | |
| 243 | + | .diagnosis-text { | |
| 244 | + | display: flex; | |
| 245 | + | flex-direction: column; | |
| 246 | + | gap: 0.25rem; | |
| 247 | + | min-width: 0; | |
| 248 | + | flex: 1; | |
| 249 | + | font-size: 0.8125rem; | |
| 250 | + | } | |
| 251 | + | .diagnosis-text strong { | |
| 252 | + | font-size: 0.875rem; | |
| 253 | + | font-weight: 500; | |
| 254 | + | } | |
| 255 | + | .diagnosis-text .buttons { | |
| 256 | + | display: flex; | |
| 257 | + | flex-wrap: wrap; | |
| 258 | + | gap: 0.5rem; | |
| 259 | + | margin-top: 0.25rem; | |
| 260 | + | } | |
| 261 | + | .diagnosis-text .buttons:empty { | |
| 262 | + | display: none; | |
| 263 | + | } | |
| 264 | + | .dot { | |
| 265 | + | flex: none; | |
| 266 | + | width: 8px; | |
| 267 | + | height: 8px; | |
| 268 | + | margin-top: 0.4rem; | |
| 269 | + | border-radius: 50%; | |
| 270 | + | background: var(--muted); | |
| 271 | + | } | |
| 272 | + | .dot.ok { | |
| 273 | + | background: #4ade80; | |
| 274 | + | } | |
| 275 | + | .dot.working { | |
| 276 | + | background: #60a5fa; | |
| 277 | + | } | |
| 278 | + | .dot.warning { | |
| 279 | + | background: #fbbf24; | |
| 280 | + | } | |
| 281 | + | .dot.problem { | |
| 282 | + | background: var(--error); | |
| 283 | + | } | |
| 284 | + | .warning-note { | |
| 285 | + | color: #fbbf24; | |
| 286 | + | } | |
| 287 | + | details.log { | |
| 288 | + | padding: 0.75rem 0; | |
| 289 | + | } | |
| 290 | + | details.log summary { | |
| 291 | + | color: var(--muted); | |
| 292 | + | font-size: 0.8125rem; | |
| 293 | + | } | |
| 294 | + | details.log pre { | |
| 295 | + | max-height: 12rem; | |
| 296 | + | overflow: auto; | |
| 297 | + | margin: 0.5rem 0; | |
| 298 | + | padding: 0.5rem; | |
| 299 | + | border-radius: 8px; | |
| 300 | + | background: #050507; | |
| 301 | + | color: var(--muted); | |
| 302 | + | font: 0.75rem/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; | |
| 303 | + | white-space: pre-wrap; | |
| 304 | + | overflow-wrap: anywhere; | |
| 305 | + | user-select: text; | |
| 306 | + | } |
| 44 | 44 | </label> | |
| 45 | 45 | </section> | |
| 46 | 46 | ||
| 47 | + | <section aria-labelledby="runner-heading" id="runner-section"> | |
| 48 | + | <h2 id="runner-heading">Runner</h2> | |
| 49 | + | <label class="setting"> | |
| 50 | + | <span | |
| 51 | + | ><strong>Run jobs on this computer while g1t is open</strong | |
| 52 | + | ><span class="muted">Your workspace's <code>runs-on: self-hosted</code> jobs run here, at no cost, while g1t is open.</span></span | |
| 53 | + | > | |
| 54 | + | <input type="checkbox" role="switch" id="runner" /> | |
| 55 | + | </label> | |
| 56 | + | <!-- What it is doing; when it isn't running, why and how to fix it (src/runner/diagnosis.rs). --> | |
| 57 | + | <div class="diagnosis" id="runner-diagnosis" aria-live="polite"> | |
| 58 | + | <span class="dot" id="runner-dot" aria-hidden="true"></span> | |
| 59 | + | <span class="diagnosis-text"> | |
| 60 | + | <strong id="runner-title">Runner</strong> | |
| 61 | + | <span class="muted" id="runner-cause"></span> | |
| 62 | + | <span id="runner-fix"></span> | |
| 63 | + | <span class="buttons" id="runner-actions"></span> | |
| 64 | + | </span> | |
| 65 | + | <button type="button" id="runner-toggle">Stop</button> | |
| 66 | + | </div> | |
| 67 | + | <label class="setting"> | |
| 68 | + | <span><strong>How jobs run</strong><span class="muted" id="runner-backend-note"> </span></span> | |
| 69 | + | <select id="runnerBackend"> | |
| 70 | + | <option value="sandboxed">Sandboxed (recommended)</option> | |
| 71 | + | <option value="docker">In Docker</option> | |
| 72 | + | <option value="host">Directly on this computer</option> | |
| 73 | + | </select> | |
| 74 | + | </label> | |
| 75 | + | <label class="setting"> | |
| 76 | + | <span><strong>Workspace</strong><span class="muted" id="runner-workspace-note">The workspace whose jobs it runs. Only a workspace's owners can add runners to it.</span></span> | |
| 77 | + | <select id="runnerWorkspace"></select> | |
| 78 | + | </label> | |
| 79 | + | <div class="setting"> | |
| 80 | + | <span><strong id="runner-machine">This computer</strong><span class="muted" id="runner-version"> </span></span> | |
| 81 | + | <span class="buttons"><button type="button" id="runner-view">View on g1t</button></span> | |
| 82 | + | </div> | |
| 83 | + | <details class="log"> | |
| 84 | + | <summary>Recent log</summary> | |
| 85 | + | <pre id="runner-log">Nothing yet.</pre> | |
| 86 | + | <button type="button" id="runner-open-log">Open the whole log</button> | |
| 87 | + | </details> | |
| 88 | + | </section> | |
| 89 | + | ||
| 47 | 90 | <section aria-labelledby="notifications-heading"> | |
| 48 | 91 | <h2 id="notifications-heading">Notifications</h2> | |
| 49 | 92 | <label class="setting"> |
| 2 | 2 | // the moment it is made. Everything it asks is a command in | |
| 3 | 3 | // src/bridge.rs, answered only to this window. | |
| 4 | 4 | (function () { | |
| 5 | − | var SWITCHES = ["startHidden", "closeToTray", "notifications", "flash", "badge", "systemTitleBar", "autoUpdate"]; | |
| 5 | + | var SWITCHES = ["startHidden", "closeToTray", "notifications", "flash", "badge", "systemTitleBar", "autoUpdate", "runner"]; | |
| 6 | + | // What each way of running jobs can reach, said plainly (src/runner/backend.rs). | |
| 7 | + | var BACKENDS = { | |
| 8 | + | sandboxed: { text: "The runner and every job run in a sandbox of their own. Nothing a job does reaches this computer.", warn: false }, | |
| 9 | + | docker: { | |
| 10 | + | text: "Each job runs in a container of its own, but the runner controls this computer's Docker, which can reach your files. Not a sandbox.", | |
| 11 | + | warn: true, | |
| 12 | + | }, | |
| 13 | + | host: { | |
| 14 | + | text: "Jobs run directly on this computer, as you: they can read, change and delete anything your account can. For jobs that need this system itself, such as building a Windows installer.", | |
| 15 | + | warn: true, | |
| 16 | + | }, | |
| 17 | + | }; | |
| 6 | 18 | var current = null; | |
| 7 | 19 | ||
| 8 | 20 | function byId(id) { | |
| ⋯ | |||
| 57 | 69 | byId("version").textContent = "g1t " + view.version; | |
| 58 | 70 | if (!checking) updateStatus(view); | |
| 59 | 71 | byId("server").textContent = hostOf(view.server); | |
| 72 | + | renderRunner(view.runnerView); | |
| 60 | 73 | } | |
| 61 | 74 | ||
| 62 | 75 | function settingsOf() { | |
| ⋯ | |||
| 65 | 78 | settings[key] = byId(key).checked; | |
| 66 | 79 | }); | |
| 67 | 80 | settings.zoom = Number(byId("zoom").value) || 1; | |
| 81 | + | settings.runnerBackend = byId("runnerBackend").value || "sandboxed"; | |
| 82 | + | settings.runnerWorkspace = byId("runnerWorkspace").value || null; | |
| 68 | 83 | return settings; | |
| 69 | 84 | } | |
| 70 | 85 | ||
| 86 | + | // ── The runner ────────────────────────────────────────────────────── | |
| 87 | + | // Its diagnosis, from src/runner/diagnosis.rs, as the tray and the | |
| 88 | + | // site's Runners page show it; its buttons are the fixes. | |
| 89 | + | function option(value, text) { | |
| 90 | + | var item = document.createElement("option"); | |
| 91 | + | item.value = value; | |
| 92 | + | item.textContent = text; | |
| 93 | + | return item; | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | function renderRunner(runner) { | |
| 97 | + | if (!runner) return; | |
| 98 | + | var diagnosis = runner.diagnosis; | |
| 99 | + | byId("runner-dot").className = "dot " + diagnosis.tone; | |
| 100 | + | byId("runner-title").textContent = diagnosis.title; | |
| 101 | + | byId("runner-cause").textContent = diagnosis.cause || ""; | |
| 102 | + | byId("runner-fix").textContent = diagnosis.fix || ""; | |
| 103 | + | var actions = byId("runner-actions"); | |
| 104 | + | actions.textContent = ""; | |
| 105 | + | diagnosis.actions.forEach(function (action, at) { | |
| 106 | + | var button = document.createElement("button"); | |
| 107 | + | button.type = "button"; | |
| 108 | + | button.textContent = action.label; | |
| 109 | + | if (at === 0) button.className = "primary"; | |
| 110 | + | button.addEventListener("click", function () { | |
| 111 | + | // Choosing is done here, in this window. | |
| 112 | + | if (action.id === "choose_workspace") return byId("runnerWorkspace").focus(); | |
| 113 | + | if (action.id === "choose_backend") return byId("runnerBackend").focus(); | |
| 114 | + | invoke("runner_control", { action: action.id }).catch(fail); | |
| 115 | + | }); | |
| 116 | + | actions.appendChild(button); | |
| 117 | + | }); | |
| 118 | + | byId("runner").checked = !!runner.enabled; | |
| 119 | + | var on = runner.enabled && !runner.paused; | |
| 120 | + | byId("runner-toggle").textContent = on ? "Stop" : "Start"; | |
| 121 | + | byId("runner-toggle").dataset.action = on ? "stop" : "start"; | |
| 122 | + | ||
| 123 | + | var backend = byId("runnerBackend"); | |
| 124 | + | backend.value = runner.backend; | |
| 125 | + | var about = BACKENDS[runner.backend] || BACKENDS.sandboxed; | |
| 126 | + | byId("runner-backend-note").textContent = about.text; | |
| 127 | + | byId("runner-backend-note").className = about.warn ? "warning-note" : "muted"; | |
| 128 | + | ||
| 129 | + | var workspaces = byId("runnerWorkspace"); | |
| 130 | + | workspaces.textContent = ""; | |
| 131 | + | var known = runner.workspaces.slice(); | |
| 132 | + | var listed = known.some(function (w) { | |
| 133 | + | return w.slug === runner.workspace; | |
| 134 | + | }); | |
| 135 | + | if (runner.workspace && !listed) known.unshift({ slug: runner.workspace, name: null }); | |
| 136 | + | known.forEach(function (w) { | |
| 137 | + | workspaces.appendChild(option(w.slug, w.name ? w.name + " (" + w.slug + ")" : w.slug)); | |
| 138 | + | }); | |
| 139 | + | if (known.length === 0) workspaces.appendChild(option("", "Open g1t, signed in, to choose")); | |
| 140 | + | workspaces.value = runner.workspace || ""; | |
| 141 | + | workspaces.disabled = known.length === 0; | |
| 142 | + | ||
| 143 | + | byId("runner-machine").textContent = "This computer: " + runner.machine; | |
| 144 | + | byId("runner-version").textContent = runner.version ? "g1t-runner " + runner.version : " "; | |
| 145 | + | byId("runner-view").hidden = !runner.link; | |
| 146 | + | byId("runner-log").textContent = runner.log.length ? runner.log.join("\n") : "Nothing yet."; | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | byId("runnerBackend").addEventListener("change", save); | |
| 150 | + | byId("runnerWorkspace").addEventListener("change", save); | |
| 151 | + | byId("runner-toggle").addEventListener("click", function (event) { | |
| 152 | + | invoke("runner_control", { action: event.target.dataset.action || "start" }).catch(fail); | |
| 153 | + | }); | |
| 154 | + | byId("runner-view").addEventListener("click", function () { | |
| 155 | + | invoke("runner_control", { action: "view_runners" }).catch(fail); | |
| 156 | + | }); | |
| 157 | + | byId("runner-open-log").addEventListener("click", function () { | |
| 158 | + | invoke("runner_control", { action: "view_log" }).catch(fail); | |
| 159 | + | }); | |
| 160 | + | ||
| 71 | 161 | function save() { | |
| 72 | 162 | invoke("save_settings", { settings: settingsOf() }).then(render, function (error) { | |
| 73 | 163 | fail(error); | |
| ⋯ | |||
| 152 | 242 | if (events && typeof events.listen === "function") { | |
| 153 | 243 | events.listen("g1t-check-updates", check).catch(function () {}); | |
| 154 | 244 | events | |
| 245 | + | .listen("g1t-runner-status", function (event) { | |
| 246 | + | if (current) current.runnerView = event.payload; | |
| 247 | + | renderRunner(event.payload); | |
| 248 | + | }) | |
| 249 | + | .catch(function () {}); | |
| 250 | + | events | |
| 155 | 251 | .listen("g1t-update-ready", function (event) { | |
| 156 | 252 | if (!checking && typeof event.payload === "string") ready(event.payload); | |
| 157 | 253 | }) | |
| 53 | 53 | .catch(function () {}); | |
| 54 | 54 | } | |
| 55 | 55 | ||
| 56 | + | // An app event, to a listener; returns what stops listening. | |
| 57 | + | function on(name, listener) { | |
| 58 | + | var events = window.__TAURI__ && window.__TAURI__.event; | |
| 59 | + | if (!events || typeof events.listen !== "function") return function () {}; | |
| 60 | + | var stop = null; | |
| 61 | + | var stopped = false; | |
| 62 | + | events | |
| 63 | + | .listen(name, function (event) { | |
| 64 | + | try { | |
| 65 | + | listener(event && event.payload); | |
| 66 | + | } catch (error) { | |
| 67 | + | console.warn("g1t desktop: a " + name + " listener failed", error); | |
| 68 | + | } | |
| 69 | + | }) | |
| 70 | + | .then(function (unlisten) { | |
| 71 | + | if (stopped) unlisten(); | |
| 72 | + | else stop = unlisten; | |
| 73 | + | }) | |
| 74 | + | .catch(function () {}); | |
| 75 | + | return function () { | |
| 76 | + | stopped = true; | |
| 77 | + | if (stop) stop(); | |
| 78 | + | }; | |
| 79 | + | } | |
| 80 | + | ||
| 56 | 81 | var bridge = { | |
| 57 | 82 | version: VERSION, | |
| 58 | 83 | platform: PLATFORM, | |
| ⋯ | |||
| 101 | 126 | // Whether g1t draws the title bar, and how tall it is: 0 under the system's. | |
| 102 | 127 | // The page is below it, never under it: this says only that the bar is there. | |
| 103 | 128 | titleBarHeight: FRAME === "custom" ? 36 : 0, | |
| 129 | + | // The runner the app keeps on this computer (src/runner/mod.rs). | |
| 130 | + | runner: Object.freeze({ | |
| 131 | + | // What it is doing: its diagnosis (why it isn't running, and the | |
| 132 | + | // fix), the workspace, how jobs run, and its last log lines. | |
| 133 | + | status: function () { | |
| 134 | + | return invoke("runner_status"); | |
| 135 | + | }, | |
| 136 | + | // Who is signed in, the workspaces they own, and the one they are in; | |
| 137 | + | // resolves to the workspace a registration token is wanted for, or null. | |
| 138 | + | offer: function (offer) { | |
| 139 | + | return invoke("runner_offer", { | |
| 140 | + | signedIn: !!offer.signedIn, | |
| 141 | + | workspaces: (offer.workspaces || []).map(function (w) { | |
| 142 | + | return { slug: String(w.slug), name: w.name == null ? null : String(w.name) }; | |
| 143 | + | }), | |
| 144 | + | current: offer.current == null ? null : String(offer.current), | |
| 145 | + | }); | |
| 146 | + | }, | |
| 147 | + | // A registration token for the workspace the app asked for, or why there is none. | |
| 148 | + | register: function (answer) { | |
| 149 | + | return invoke("runner_register", { | |
| 150 | + | workspace: String(answer.workspace), | |
| 151 | + | token: answer.token == null ? null : String(answer.token), | |
| 152 | + | allowed: answer.allowed !== false, | |
| 153 | + | error: answer.error == null ? null : String(answer.error), | |
| 154 | + | }); | |
| 155 | + | }, | |
| 156 | + | // Start, stop, or a diagnosis's fix, by its id. | |
| 157 | + | act: function (action) { | |
| 158 | + | return invoke("runner_control", { action: String(action) }); | |
| 159 | + | }, | |
| 160 | + | // The app wants a registration token for a workspace. | |
| 161 | + | onWanted: function (listener) { | |
| 162 | + | return on("g1t-runner-wanted", listener); | |
| 163 | + | }, | |
| 164 | + | // The runner's status changed. | |
| 165 | + | onStatus: function (listener) { | |
| 166 | + | return on("g1t-runner-status", listener); | |
| 167 | + | }, | |
| 168 | + | }), | |
| 104 | 169 | }; | |
| 105 | 170 | Object.defineProperty(window, "g1tDesktop", { value: Object.freeze(bridge), writable: false, configurable: false }); | |
| 106 | 171 | ||
| 1 | + | import { useEffect, useRef } from "react"; | |
| 2 | + | ||
| 3 | + | import type { DesktopReport } from "@g1t/contracts"; | |
| 4 | + | ||
| 5 | + | import { type DesktopRunnerOffer, desktopRunner, reportDue, reportKey, reportOf } from "../lib/desktop-runner"; | |
| 6 | + | ||
| 7 | + | /** | |
| 8 | + | * Inside the desktop app, on every page: the link between the app's runner | |
| 9 | + | * and the site (lib/desktop-runner.ts). It tells the app who is signed in | |
| 10 | + | * and what they own, makes the registration token the app asks for, and | |
| 11 | + | * keeps the server told how the runner is. It draws nothing. | |
| 12 | + | */ | |
| 13 | + | export function DesktopRunnerLink({ offer }: { offer: DesktopRunnerOffer }) { | |
| 14 | + | const key = JSON.stringify(offer); | |
| 15 | + | const last = useRef<{ key: string; at: number } | null>(null); | |
| 16 | + | ||
| 17 | + | // Registering: the token goes straight from the server to the app. | |
| 18 | + | useEffect(() => { | |
| 19 | + | const runner = desktopRunner(); | |
| 20 | + | if (!runner) return; | |
| 21 | + | let asking: string | null = null; | |
| 22 | + | const signedIn = (JSON.parse(key) as DesktopRunnerOffer).signedIn; | |
| 23 | + | const register = async (workspace: string) => { | |
| 24 | + | // Signed out, the app says so itself, from the offer. | |
| 25 | + | if (!signedIn || asking === workspace) return; | |
| 26 | + | asking = workspace; | |
| 27 | + | try { | |
| 28 | + | const response = await fetch("/-/desktop/runner", { | |
| 29 | + | method: "POST", | |
| 30 | + | headers: { "content-type": "application/json" }, | |
| 31 | + | body: JSON.stringify({ intent: "token", workspace }), | |
| 32 | + | }); | |
| 33 | + | const answer = (await response.json().catch(() => ({}))) as { token?: string; error?: string; allowed?: boolean }; | |
| 34 | + | await runner.register( | |
| 35 | + | answer.token ? { workspace, token: answer.token } : { workspace, allowed: answer.allowed !== false, error: answer.error ?? `g1t answered ${response.status}.` }, | |
| 36 | + | ); | |
| 37 | + | } catch { | |
| 38 | + | // Offline, or the page is leaving: the app asks again in a minute. | |
| 39 | + | } finally { | |
| 40 | + | asking = null; | |
| 41 | + | } | |
| 42 | + | }; | |
| 43 | + | const stop = runner.onWanted((workspace) => void register(workspace)); | |
| 44 | + | void runner | |
| 45 | + | .offer(JSON.parse(key) as DesktopRunnerOffer) | |
| 46 | + | .then((wanted) => { | |
| 47 | + | if (wanted) void register(wanted); | |
| 48 | + | }) | |
| 49 | + | .catch(() => {}); | |
| 50 | + | return stop; | |
| 51 | + | }, [key]); | |
| 52 | + | ||
| 53 | + | // Reporting: what the Runners page shows of this computer, on each change and as a heartbeat. | |
| 54 | + | useEffect(() => { | |
| 55 | + | const runner = desktopRunner(); | |
| 56 | + | if (!runner || !offer.signedIn) return; | |
| 57 | + | const send = (report: DesktopReport | null) => { | |
| 58 | + | if (!report || !offer.workspaces.some((w) => w.slug === report.workspace)) return; | |
| 59 | + | const now = Date.now(); | |
| 60 | + | if (!reportDue(last.current, report, now)) return; | |
| 61 | + | last.current = { key: reportKey(report), at: now }; | |
| 62 | + | void fetch("/-/desktop/runner", { | |
| 63 | + | method: "POST", | |
| 64 | + | headers: { "content-type": "application/json" }, | |
| 65 | + | body: JSON.stringify({ intent: "report", ...report }), | |
| 66 | + | }).catch(() => { | |
| 67 | + | last.current = null; | |
| 68 | + | }); | |
| 69 | + | }; | |
| 70 | + | const look = () => | |
| 71 | + | void runner | |
| 72 | + | .status() | |
| 73 | + | .then((view) => send(reportOf(view))) | |
| 74 | + | .catch(() => {}); | |
| 75 | + | look(); | |
| 76 | + | const stop = runner.onStatus((view) => send(reportOf(view))); | |
| 77 | + | const beat = window.setInterval(look, 60_000); | |
| 78 | + | return () => { | |
| 79 | + | stop(); | |
| 80 | + | window.clearInterval(beat); | |
| 81 | + | }; | |
| 82 | + | }, [key]); // eslint-disable-line react-hooks/exhaustive-deps | |
| 83 | + | return null; | |
| 84 | + | } |
| 1 | + | import { Laptop } from "lucide-react"; | |
| 2 | + | import { Link } from "react-router"; | |
| 3 | + | ||
| 4 | + | import type { DesktopBackend, DesktopDiagnosis, DesktopRunner } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import { BACKEND_LABEL, type DesktopRunnerAction, desktopRunner, useDesktopRunner } from "../lib/desktop-runner"; | |
| 7 | + | import { EmptyState, TimeAgo } from "./ui"; | |
| 8 | + | import { Badge } from "./ui/badge"; | |
| 9 | + | import { Button } from "./ui/button"; | |
| 10 | + | import { Card } from "./ui/card"; | |
| 11 | + | ||
| 12 | + | /** A diagnosis's tone, as a dot. */ | |
| 13 | + | const DOT: Record<DesktopDiagnosis["tone"], string> = { | |
| 14 | + | ok: "bg-success", | |
| 15 | + | working: "bg-info", | |
| 16 | + | off: "bg-faint", | |
| 17 | + | warning: "bg-warn", | |
| 18 | + | problem: "bg-danger", | |
| 19 | + | }; | |
| 20 | + | ||
| 21 | + | type Row = { | |
| 22 | + | machine: string; | |
| 23 | + | person: string | null; | |
| 24 | + | backend: DesktopBackend; | |
| 25 | + | version: string | null; | |
| 26 | + | diagnosis: DesktopDiagnosis; | |
| 27 | + | /** This computer's fixes, which only work here. */ | |
| 28 | + | actions: DesktopRunnerAction[]; | |
| 29 | + | here: boolean; | |
| 30 | + | reportedAt: string | null; | |
| 31 | + | }; | |
| 32 | + | ||
| 33 | + | /** The desktops to list: this computer, live, first; then the others, as their apps last said, or closed. */ | |
| 34 | + | export function desktopRows(desktops: DesktopRunner[], here: ReturnType<typeof useDesktopRunner>, slug: string): Row[] { | |
| 35 | + | const rows: Row[] = []; | |
| 36 | + | if (here && here.workspace === slug) { | |
| 37 | + | rows.push({ | |
| 38 | + | machine: here.machine, | |
| 39 | + | person: null, | |
| 40 | + | backend: here.backend, | |
| 41 | + | version: here.version, | |
| 42 | + | diagnosis: here.diagnosis, | |
| 43 | + | actions: here.diagnosis.actions, | |
| 44 | + | here: true, | |
| 45 | + | reportedAt: null, | |
| 46 | + | }); | |
| 47 | + | } | |
| 48 | + | for (const desktop of desktops) { | |
| 49 | + | if (rows.some((row) => row.here && row.machine.toLowerCase() === desktop.machine.toLowerCase())) continue; | |
| 50 | + | rows.push({ | |
| 51 | + | machine: desktop.machine, | |
| 52 | + | person: desktop.person, | |
| 53 | + | backend: desktop.backend, | |
| 54 | + | version: desktop.version, | |
| 55 | + | diagnosis: desktop.open ? desktop.diagnosis : desktop.closed, | |
| 56 | + | actions: [], | |
| 57 | + | here: false, | |
| 58 | + | reportedAt: desktop.reportedAt, | |
| 59 | + | }); | |
| 60 | + | } | |
| 61 | + | return rows; | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | /** | |
| 65 | + | * The workspace's desktops: each person's computer with the g1t desktop | |
| 66 | + | * app, whose runner runs while g1t is open there. What each says, why it | |
| 67 | + | * isn't running and the fix, is the app's own words (lib/desktop-runner.ts). | |
| 68 | + | */ | |
| 69 | + | export function DesktopRunners({ desktops, slug }: { desktops: DesktopRunner[] | null; slug: string }) { | |
| 70 | + | const here = useDesktopRunner(); | |
| 71 | + | if (!desktops) return <p className="text-sm text-muted">The desktops can't be read right now.</p>; | |
| 72 | + | const rows = desktopRows(desktops, here, slug); | |
| 73 | + | if (rows.length === 0) { | |
| 74 | + | return ( | |
| 75 | + | <EmptyState title="No desktops yet"> | |
| 76 | + | The <Link to="/download" className="underline underline-offset-2">g1t desktop app</Link> runs this workspace's self-hosted jobs while it's open, sandboxed, with nothing to set up. | |
| 77 | + | Open it signed in as an owner of {slug}. | |
| 78 | + | </EmptyState> | |
| 79 | + | ); | |
| 80 | + | } | |
| 81 | + | return ( | |
| 82 | + | <Card asChild divided> | |
| 83 | + | <ul> | |
| 84 | + | {rows.map((row) => ( | |
| 85 | + | <li key={row.machine} className="flex items-start gap-3 px-4 py-3"> | |
| 86 | + | <span className={`mt-1.5 size-2 shrink-0 rounded-full ${DOT[row.diagnosis.tone]}`} aria-hidden /> | |
| 87 | + | <div className="min-w-0 grow"> | |
| 88 | + | <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> | |
| 89 | + | <Laptop size={13} className="text-muted" aria-hidden /> | |
| 90 | + | <span className="font-medium">{row.machine}</span> | |
| 91 | + | {row.here && <Badge tone="accent">This computer</Badge>} | |
| 92 | + | <Badge tone={row.backend === "sandboxed" ? "neutral" : "warn"}>{BACKEND_LABEL[row.backend]}</Badge> | |
| 93 | + | {row.person && <span className="text-xs text-faint">@{row.person}</span>} | |
| 94 | + | </div> | |
| 95 | + | <p className="mt-1 text-sm text-fg">{row.diagnosis.title}</p> | |
| 96 | + | {row.diagnosis.cause && <p className="mt-0.5 text-sm text-muted">{row.diagnosis.cause}</p>} | |
| 97 | + | {row.diagnosis.fix && <p className="mt-0.5 text-sm text-fg-soft">{row.diagnosis.fix}</p>} | |
| 98 | + | {row.actions.length > 0 && ( | |
| 99 | + | <div className="mt-2 flex flex-wrap gap-2"> | |
| 100 | + | {row.actions.map((action, at) => ( | |
| 101 | + | <Button key={action.id} size="xs" variant={at === 0 ? "secondary" : "outline"} onClick={() => void desktopRunner()?.act(action.id).catch(() => {})}> | |
| 102 | + | {action.label} | |
| 103 | + | </Button> | |
| 104 | + | ))} | |
| 105 | + | </div> | |
| 106 | + | )} | |
| 107 | + | </div> | |
| 108 | + | <div className="shrink-0 text-right text-xs text-faint"> | |
| 109 | + | {row.version && <div>g1t-runner {row.version}</div>} | |
| 110 | + | {row.reportedAt && ( | |
| 111 | + | <div className="mt-0.5"> | |
| 112 | + | Heard from <TimeAgo at={row.reportedAt} /> | |
| 113 | + | </div> | |
| 114 | + | )} | |
| 115 | + | </div> | |
| 116 | + | </li> | |
| 117 | + | ))} | |
| 118 | + | </ul> | |
| 119 | + | </Card> | |
| 120 | + | ); | |
| 121 | + | } |
| 7 | 7 | import { money } from "../lib/money"; | |
| 8 | 8 | import { type CloudWork, machineTime, perMinute } from "../lib/runners"; | |
| 9 | 9 | import type { RunnersAction, RunnersPageData } from "../lib/runners.server"; | |
| 10 | + | import { DesktopRunners } from "./desktop-runners"; | |
| 10 | 11 | import { Groups, NewRunner, RunnerRow, RunnerSettingsForm, STATUS } from "./runners"; | |
| 11 | 12 | import { EmptyState, ErrorText, TimeAgo } from "./ui"; | |
| 12 | 13 | import { Badge } from "./ui/badge"; | |
| ⋯ | |||
| 303 | 304 | )} | |
| 304 | 305 | </Section> | |
| 305 | 306 | ||
| 307 | + | <Section | |
| 308 | + | id="desktops" | |
| 309 | + | title="Desktops" | |
| 310 | + | about="Computers with the g1t desktop app, whose runner runs while g1t is open there: what each is doing, and when it isn't running, why and the fix." | |
| 311 | + | > | |
| 312 | + | <DesktopRunners desktops={data.desktops} slug={slug} /> | |
| 313 | + | </Section> | |
| 314 | + | ||
| 306 | 315 | <Section title="Where work runs" about="For every project in the workspace, unless a project says otherwise in its own settings."> | |
| 307 | 316 | <div className="grid gap-3 md:grid-cols-3"> | |
| 308 | 317 | <WhereCard icon={<Workflow size={15} />} title="Workflow jobs"> | |
| ⋯ | |||
| 315 | 324 | ? `Agent runs, checks, reviews and the merge queue run on your runners labelled ${data.settings.agentLabels.join(", ")}.` | |
| 316 | 325 | : "Agent runs, checks, reviews and the merge queue run on g1t cloud. Switch them to your runners below."} | |
| 317 | 326 | </WhereCard> | |
| 318 | − | <WhereCard icon={<Laptop size={15} />} title="Your desktop" coming> | |
| 319 | − | The desktop app will include a runner: for a logged-in browser, a simulator or a tool only your network reaches. It asks before each task. | |
| 327 | + | <WhereCard icon={<Laptop size={15} />} title="Your desktop"> | |
| 328 | + | The g1t desktop app runs a runner while it's open, sandboxed and labelled <code className="font-mono text-fg-soft">desktop</code>, for jobs that ask for{" "} | |
| 329 | + | <code className="font-mono text-fg-soft">self-hosted</code>. Each desktop is under <a href="#desktops" className="underline underline-offset-2">Desktops</a>. | |
| 320 | 330 | </WhereCard> | |
| 321 | 331 | </div> | |
| 322 | 332 | <RunnerSettingsForm | |
| ⋯ | |||
| 354 | 364 | ); | |
| 355 | 365 | } | |
| 356 | 366 | ||
| 357 | − | function WhereCard({ icon, title, coming, children }: { icon: ReactNode; title: string; coming?: boolean; children: ReactNode }) { | |
| 367 | + | function WhereCard({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) { | |
| 358 | 368 | return ( | |
| 359 | − | <div className={`rounded-2xl border p-4 ${coming ? "border-dashed border-line" : "border-line bg-surface"}`}> | |
| 369 | + | <div className="rounded-2xl border border-line bg-surface p-4"> | |
| 360 | 370 | <h3 className="flex items-center gap-2 text-sm font-medium"> | |
| 361 | 371 | <span className="text-muted">{icon}</span> | |
| 362 | 372 | {title} | |
| 363 | − | {coming && <span className="ml-auto"><Coming /></span>} | |
| 364 | 373 | </h3> | |
| 365 | 374 | <p className="mt-2 text-sm leading-relaxed text-muted">{children}</p> | |
| 366 | 375 | </div> | |
| 2 | 2 | import { useEffect, useState } from "react"; | |
| 3 | 3 | import { Form, Link } from "react-router"; | |
| 4 | 4 | ||
| 5 | − | import { type RegistrationToken, RUNNER_DOWNLOADS, RUNNER_FILES, RUNNER_IMAGE, type Runner, type RunnerGroup } from "@g1t/contracts"; | |
| 5 | + | import { DESKTOP_LABEL, type RegistrationToken, RUNNER_DOWNLOADS, RUNNER_FILES, RUNNER_IMAGE, type Runner, type RunnerGroup } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import { agentRunHref, workflowRunHref } from "../lib/runners"; | |
| 8 | 8 | import type { RunnersAction, RunnersData } from "../lib/runners.server"; | |
| ⋯ | |||
| 55 | 55 | <div className="min-w-0 grow basis-60"> | |
| 56 | 56 | <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> | |
| 57 | 57 | <span className="font-medium">{runner.name}</span> | |
| 58 | − | <span className={`text-xs font-medium ${status.text}`}>{status.label}</span> | |
| 58 | + | <span className={`text-xs font-medium ${status.text}`}> | |
| 59 | + | {/* A desktop app's runner runs while g1t is open there: offline is g1t closed. */} | |
| 60 | + | {runner.status === "offline" && runner.labels.includes(DESKTOP_LABEL) ? `g1t is closed on ${runner.name}` : status.label} | |
| 61 | + | </span> | |
| 59 | 62 | {runner.ephemeral && <Badge size="md">ephemeral</Badge>} | |
| 60 | 63 | {runner.group && <span className="text-xs text-faint">in {runner.group}</span>} | |
| 61 | 64 | </div> | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { HEARTBEAT_MS, type DesktopRunnerView, backendOf, diagnosisOf, reportDue, reportKey, reportOf } from "./desktop-runner.ts"; | |
| 5 | + | ||
| 6 | + | function view(over: Partial<DesktopRunnerView> = {}): DesktopRunnerView { | |
| 7 | + | return { | |
| 8 | + | diagnosis: { code: "no_docker", tone: "problem", title: "Not running: Docker isn't running", cause: "Docker isn't running.", fix: "Start Docker Desktop.", actions: [{ id: "open_docker", label: "Open Docker" }] }, | |
| 9 | + | closed: { code: "off", tone: "off", title: "g1t is closed on build-box", cause: null, fix: "Open g1t on build-box.", actions: [] }, | |
| 10 | + | running: false, | |
| 11 | + | version: "0.2.3", | |
| 12 | + | backend: "docker", | |
| 13 | + | workspace: "acme", | |
| 14 | + | workspaces: [{ slug: "acme", name: "Acme" }], | |
| 15 | + | enabled: true, | |
| 16 | + | paused: false, | |
| 17 | + | link: "https://g1t.sh/acme/-/runners", | |
| 18 | + | machine: "build-box", | |
| 19 | + | platform: "windows", | |
| 20 | + | log: ["2026-10-12T08:00:00Z a line"], | |
| 21 | + | ...over, | |
| 22 | + | }; | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | test("a report carries the app's words, without its buttons or log", () => { | |
| 26 | + | const report = reportOf(view()); | |
| 27 | + | assert.deepEqual(report, { | |
| 28 | + | workspace: "acme", | |
| 29 | + | machine: "build-box", | |
| 30 | + | backend: "docker", | |
| 31 | + | version: "0.2.3", | |
| 32 | + | diagnosis: { code: "no_docker", tone: "problem", title: "Not running: Docker isn't running", cause: "Docker isn't running.", fix: "Start Docker Desktop." }, | |
| 33 | + | closed: { code: "off", tone: "off", title: "g1t is closed on build-box", cause: null, fix: "Open g1t on build-box." }, | |
| 34 | + | }); | |
| 35 | + | assert.equal(reportOf(view({ workspace: null })), null); | |
| 36 | + | }); | |
| 37 | + | ||
| 38 | + | test("a report goes when what the page shows changes, and as a heartbeat", () => { | |
| 39 | + | const report = reportOf(view())!; | |
| 40 | + | const at = 1_000_000; | |
| 41 | + | assert.equal(reportDue(null, report, at), true); | |
| 42 | + | const last = { key: reportKey(report), at }; | |
| 43 | + | assert.equal(reportDue(last, report, at + 1000), false); | |
| 44 | + | assert.equal(reportDue(last, report, at + HEARTBEAT_MS), true); | |
| 45 | + | const busy = reportOf(view({ diagnosis: { ...view().diagnosis, code: "busy", tone: "working", title: "Running Build (acme/web)" } }))!; | |
| 46 | + | assert.equal(reportDue(last, busy, at + 1000), true); | |
| 47 | + | }); | |
| 48 | + | ||
| 49 | + | test("only a diagnosis and a backend the app makes are kept", () => { | |
| 50 | + | assert.deepEqual(diagnosisOf({ code: "idle", tone: "ok", title: "Waiting for jobs", cause: "Running sandboxed.", extra: 1 }), { | |
| 51 | + | code: "idle", | |
| 52 | + | tone: "ok", | |
| 53 | + | title: "Waiting for jobs", | |
| 54 | + | cause: "Running sandboxed.", | |
| 55 | + | fix: null, | |
| 56 | + | }); | |
| 57 | + | assert.equal(diagnosisOf({ code: "idle", tone: "loud", title: "x" }), null); | |
| 58 | + | assert.equal(diagnosisOf("idle"), null); | |
| 59 | + | assert.equal(backendOf("host"), "host"); | |
| 60 | + | assert.equal(backendOf("vm"), null); | |
| 61 | + | }); |
| 1 | + | /** | |
| 2 | + | * The runner the desktop app keeps on this computer, from the site's side | |
| 3 | + | * (apps/desktop/src-tauri/src/runner). The app finds out what to run for, | |
| 4 | + | * and gets its registration token, through the page: | |
| 5 | + | * | |
| 6 | + | * 1. On every page, the page tells the app who is signed in, the | |
| 7 | + | * workspaces they own (only owners add runners) and the one they are | |
| 8 | + | * in (`offer`). The app answers with the workspace it wants a | |
| 9 | + | * registration token for, if any, and asks again later (`onWanted`). | |
| 10 | + | * 2. For that workspace, the page asks the server for a token | |
| 11 | + | * (`POST /-/desktop/runner`, owners only, same origin) and hands it to | |
| 12 | + | * the app (`register`), or says why there is none. It is never shown | |
| 13 | + | * or kept here. | |
| 14 | + | * 3. Whenever the runner's state changes, and every few minutes while g1t | |
| 15 | + | * is open, the page sends the app's diagnosis to the server | |
| 16 | + | * (`report`), so the workspace's Runners page lists every person's | |
| 17 | + | * desktop with its state, why and the fix, in the app's own words. | |
| 18 | + | */ | |
| 19 | + | import { useEffect, useState } from "react"; | |
| 20 | + | ||
| 21 | + | import type { DesktopBackend, DesktopDiagnosis, DesktopReport } from "@g1t/contracts"; | |
| 22 | + | ||
| 23 | + | /** A fix, as a button: its id is the app's (`runner_control`). */ | |
| 24 | + | export type DesktopRunnerAction = { id: string; label: string }; | |
| 25 | + | ||
| 26 | + | /** What the app says of its runner (`runner::View` in the app). */ | |
| 27 | + | export type DesktopRunnerView = { | |
| 28 | + | diagnosis: DesktopDiagnosis & { actions: DesktopRunnerAction[] }; | |
| 29 | + | closed: DesktopDiagnosis & { actions: DesktopRunnerAction[] }; | |
| 30 | + | running: boolean; | |
| 31 | + | version: string | null; | |
| 32 | + | backend: DesktopBackend; | |
| 33 | + | workspace: string | null; | |
| 34 | + | workspaces: { slug: string; name: string | null }[]; | |
| 35 | + | enabled: boolean; | |
| 36 | + | paused: boolean; | |
| 37 | + | link: string | null; | |
| 38 | + | machine: string; | |
| 39 | + | platform: string; | |
| 40 | + | log: string[]; | |
| 41 | + | }; | |
| 42 | + | ||
| 43 | + | export type DesktopRunnerOffer = { signedIn: boolean; workspaces: { slug: string; name: string | null }[]; current: string | null }; | |
| 44 | + | ||
| 45 | + | /** `window.g1tDesktop.runner`, from the app's bridge.js. */ | |
| 46 | + | export type DesktopRunnerBridge = { | |
| 47 | + | status(): Promise<DesktopRunnerView>; | |
| 48 | + | offer(offer: DesktopRunnerOffer): Promise<string | null>; | |
| 49 | + | register(answer: { workspace: string; token?: string | null; allowed?: boolean; error?: string | null }): Promise<void>; | |
| 50 | + | act(action: string): Promise<void>; | |
| 51 | + | onWanted(listener: (workspace: string) => void): () => void; | |
| 52 | + | onStatus(listener: (view: DesktopRunnerView) => void): () => void; | |
| 53 | + | }; | |
| 54 | + | ||
| 55 | + | /** The bridge's runner, inside the desktop app (lib/notify-client.ts describes the rest of `window.g1tDesktop`). */ | |
| 56 | + | export function desktopRunner(): DesktopRunnerBridge | null { | |
| 57 | + | if (typeof window === "undefined") return null; | |
| 58 | + | const runner = window.g1tDesktop?.runner; | |
| 59 | + | return runner && typeof runner.status === "function" ? runner : null; | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /** This computer's runner, live, inside the desktop app; null elsewhere. */ | |
| 63 | + | export function useDesktopRunner(): DesktopRunnerView | null { | |
| 64 | + | const [view, setView] = useState<DesktopRunnerView | null>(null); | |
| 65 | + | useEffect(() => { | |
| 66 | + | const runner = desktopRunner(); | |
| 67 | + | if (!runner) return; | |
| 68 | + | let live = true; | |
| 69 | + | runner | |
| 70 | + | .status() | |
| 71 | + | .then((first) => { | |
| 72 | + | if (live) setView(first); | |
| 73 | + | }) | |
| 74 | + | .catch(() => {}); | |
| 75 | + | const stop = runner.onStatus((next) => setView(next)); | |
| 76 | + | return () => { | |
| 77 | + | live = false; | |
| 78 | + | stop(); | |
| 79 | + | }; | |
| 80 | + | }, []); | |
| 81 | + | return view; | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /** How often a desktop says it is still open, with nothing new: within the server's ten minutes. */ | |
| 85 | + | export const HEARTBEAT_MS = 4 * 60 * 1000; | |
| 86 | + | ||
| 87 | + | /** What goes to the server, without the buttons, which only work on that computer. */ | |
| 88 | + | export function reportOf(view: DesktopRunnerView): DesktopReport | null { | |
| 89 | + | if (!view.workspace) return null; | |
| 90 | + | const plain = ({ code, tone, title, cause, fix }: DesktopDiagnosis): DesktopDiagnosis => ({ code, tone, title, cause, fix }); | |
| 91 | + | return { workspace: view.workspace, machine: view.machine, backend: view.backend, version: view.version, diagnosis: plain(view.diagnosis), closed: plain(view.closed) }; | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | /** Whether a report is due: something the Runners page shows changed, or the heartbeat. */ | |
| 95 | + | export function reportDue(last: { key: string; at: number } | null, report: DesktopReport, now: number): boolean { | |
| 96 | + | if (!last) return true; | |
| 97 | + | return last.key !== reportKey(report) || now - last.at >= HEARTBEAT_MS; | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | export function reportKey(report: DesktopReport): string { | |
| 101 | + | const { diagnosis } = report; | |
| 102 | + | return [report.workspace, report.machine, report.backend, report.version, diagnosis.code, diagnosis.title, diagnosis.cause, diagnosis.fix].join("\u0000"); | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | /** A string with something in it, or null. */ | |
| 106 | + | export const text = (value: unknown): string | null => (typeof value === "string" && value.length > 0 ? value : null); | |
| 107 | + | ||
| 108 | + | const BACKENDS: DesktopBackend[] = ["sandboxed", "docker", "host"]; | |
| 109 | + | const TONES: DesktopDiagnosis["tone"][] = ["ok", "working", "off", "warning", "problem"]; | |
| 110 | + | ||
| 111 | + | export function backendOf(value: unknown): DesktopBackend | null { | |
| 112 | + | return BACKENDS.find((backend) => backend === value) ?? null; | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /** A diagnosis as the app sent it, or null when it is not one. */ | |
| 116 | + | export function diagnosisOf(value: unknown): DesktopDiagnosis | null { | |
| 117 | + | if (!value || typeof value !== "object") return null; | |
| 118 | + | const given = value as Record<string, unknown>; | |
| 119 | + | const code = text(given.code); | |
| 120 | + | const title = text(given.title); | |
| 121 | + | const tone = TONES.find((t) => t === given.tone); | |
| 122 | + | if (!code || !title || !tone) return null; | |
| 123 | + | return { code, tone, title, cause: text(given.cause), fix: text(given.fix) }; | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | /** The words for how a desktop runs jobs, as its settings say them. */ | |
| 127 | + | export const BACKEND_LABEL: Record<DesktopBackend, string> = { | |
| 128 | + | sandboxed: "Sandboxed", | |
| 129 | + | docker: "In Docker (not a sandbox)", | |
| 130 | + | host: "Directly on the computer (not a sandbox)", | |
| 131 | + | }; |
| 25 | 25 | ||
| 26 | 26 | import type { FeedCounts, FeedEvent, FeedNotification, NotifyPreferences, OwnPresence, PresenceChange, PresenceEntry, RepoChange } from "@g1t/contracts"; | |
| 27 | 27 | ||
| 28 | + | import type { DesktopRunnerBridge } from "./desktop-runner"; | |
| 28 | 29 | import { openLive } from "./live-socket"; | |
| 29 | 30 | import { dndOn, isIdle, mergePeople } from "./presence"; | |
| 30 | 31 | import { notificationArrived } from "./sound-events"; | |
| ⋯ | |||
| 81 | 82 | * // How tall the app's own title bar is, 0 under the system's. The bar | |
| 82 | 83 | * // is a web view of its own, above the page, never over it. | |
| 83 | 84 | * titleBarHeight: 36, | |
| 85 | + | * // The runner the app keeps on this computer: its status and | |
| 86 | + | * // diagnosis, who to register for, its token, and its fixes | |
| 87 | + | * // (lib/desktop-runner.ts has the details). | |
| 88 | + | * runner: { status, offer, register, act, onWanted, onStatus }, | |
| 84 | 89 | * }; | |
| 85 | 90 | * | |
| 86 | 91 | * The app also opens the site's pages for reading about g1t (pricing, | |
| ⋯ | |||
| 100 | 105 | openSettings?(): void; | |
| 101 | 106 | setSearch?(label: string, open: () => void): () => void; | |
| 102 | 107 | titleBarHeight?: number; | |
| 108 | + | runner?: DesktopRunnerBridge; | |
| 103 | 109 | }; | |
| 104 | 110 | ||
| 105 | 111 | declare global { | |
| 1 | 1 | import { env } from "cloudflare:workers"; | |
| 2 | 2 | ||
| 3 | 3 | import { | |
| 4 | + | type DesktopRunner, | |
| 4 | 5 | type RegistrationToken, | |
| 5 | 6 | type Runner, | |
| 6 | 7 | type RunnerGroup, | |
| ⋯ | |||
| 55 | 56 | prices: CloudPrice[] | null; | |
| 56 | 57 | /** Agent runs at once on g1t's cloud the plan allows; null for no cap, or unknown. */ | |
| 57 | 58 | agentCap: number | null; | |
| 59 | + | /** People's computers with the desktop app, as each app last said; null when the actions service did not answer. */ | |
| 60 | + | desktops: DesktopRunner[] | null; | |
| 58 | 61 | }; | |
| 59 | 62 | ||
| 60 | 63 | /** Active agent runs read: the work service's most in one list. */ | |
| ⋯ | |||
| 74 | 77 | export async function loadRunnersPage(workspace: string, actor: User, repositories: string[] = [], now = new Date()): Promise<RunnersPageData> { | |
| 75 | 78 | const from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString().slice(0, 10); | |
| 76 | 79 | const until = now.toISOString().slice(0, 10); | |
| 77 | − | const [base, activity, runs, report, book, entitlements] = await Promise.all([ | |
| 80 | + | const [base, activity, runs, report, book, entitlements, desktops] = await Promise.all([ | |
| 78 | 81 | loadRunners({ workspace }, actor, repositories), | |
| 79 | 82 | runners | |
| 80 | 83 | .activity(actor, workspace) | |
| ⋯ | |||
| 90 | 93 | .catch(warn("usage report")), | |
| 91 | 94 | billing.prices().catch(warn("prices")), | |
| 92 | 95 | billing.entitlements(workspace).catch(warn("entitlements")), | |
| 96 | + | runners | |
| 97 | + | .desktops(actor, workspace) | |
| 98 | + | .then((result) => (result.ok ? result.value : null)) | |
| 99 | + | .catch(warn("desktops")), | |
| 93 | 100 | ]); | |
| 94 | 101 | return { | |
| 95 | 102 | ...base, | |
| ⋯ | |||
| 98 | 105 | cost: runnerCost(report), | |
| 99 | 106 | prices: cloudPrices(book?.prices ?? null), | |
| 100 | 107 | agentCap: entitlements && entitlements.maxConcurrentAgents > 0 ? entitlements.maxConcurrentAgents : null, | |
| 108 | + | desktops, | |
| 101 | 109 | }; | |
| 102 | 110 | } | |
| 103 | 111 | ||
| 53 | 53 | import { isNeedsSignIn } from "./lib/website-token"; | |
| 54 | 54 | import { offerTicket, setLiveViaToken } from "./lib/live-socket"; | |
| 55 | 55 | import { socketTicketFor } from "./lib/socket-ticket.server"; | |
| 56 | + | import { DesktopRunnerLink } from "./components/desktop-runner-link"; | |
| 56 | 57 | import { LiveNotifications } from "./components/notifications/live-notifications"; | |
| 57 | 58 | import { THEME_COLOR, THEME_COOKIE, readTheme, themeAttribute, useThemeChoice } from "./lib/theme"; | |
| 58 | 59 | ||
| ⋯ | |||
| 461 | 462 | me={{ id: user.id, username: user.username }} | |
| 462 | 463 | /> | |
| 463 | 464 | )} | |
| 465 | + | {/* Inside the desktop app: who its runner runs for, and its registration (lib/desktop-runner.ts). */} | |
| 466 | + | {(!user || !awaitsConfirmation(user)) && ( | |
| 467 | + | <DesktopRunnerLink | |
| 468 | + | offer={{ | |
| 469 | + | signedIn: user != null, | |
| 470 | + | workspaces: (user?.workspaces ?? []).filter((m) => m.role === "owner").map((m) => ({ slug: m.slug, name: m.name ?? null })), | |
| 471 | + | current: root?.shell?.workspace?.slug ?? null, | |
| 472 | + | }} | |
| 473 | + | /> | |
| 474 | + | )} | |
| 464 | 475 | <AnalyticsConsent /> | |
| 465 | 476 | <ScrollRestoration nonce={nonce} /> | |
| 466 | 477 | <Scripts nonce={nonce} /> | |
| 75 | 75 | route("invitations", "routes/invitations.tsx"), | |
| 76 | 76 | // People to invite, as the People page's invite form searches them. | |
| 77 | 77 | route("-/people.json", "routes/people-json.ts"), | |
| 78 | + | // The desktop app's runner: its registration token, and its reports (lib/desktop-runner.ts). | |
| 79 | + | route("-/desktop/runner", "routes/desktop-runner.ts"), | |
| 78 | 80 | // Trust pages: reserved names, like the rest above. | |
| 79 | 81 | route("policies", "routes/policies.tsx"), | |
| 80 | 82 | route("policies/:policy", "routes/policy.tsx"), |
| 1 | + | /** | |
| 2 | + | * The desktop app's runner, from its page (lib/desktop-runner.ts): a | |
| 3 | + | * registration token for a workspace the person owns, which the page | |
| 4 | + | * hands to the app and never shows, and the app's reports of how its | |
| 5 | + | * runner is, kept for the workspace's Runners page. | |
| 6 | + | * | |
| 7 | + | * Signed in, same origin, and owners of the workspace only, as the | |
| 8 | + | * Runners page's own New runner is; the actions service checks again. | |
| 9 | + | * `-` is no workspace's name, so nothing else is ever found here. | |
| 10 | + | * | |
| 11 | + | * POST { "intent": "token", "workspace": "acme" } | |
| 12 | + | * → { "token": "g1trt_…", "workspace": "acme", "url": "https://g1t.sh", "expires_at": "…" } | |
| 13 | + | * → 403 { "error": "…", "allowed": false } | |
| 14 | + | * POST { "intent": "report", "workspace", "machine", "backend", "version", "diagnosis", "closed" } | |
| 15 | + | * → { "ok": true } | |
| 16 | + | */ | |
| 17 | + | import type { Route } from "./+types/desktop-runner"; | |
| 18 | + | import { backendOf, diagnosisOf, text } from "../lib/desktop-runner"; | |
| 19 | + | import { runners } from "../lib/runners.server"; | |
| 20 | + | import { assertSameOrigin, getViewer, roleIn } from "../lib/session.server"; | |
| 21 | + | ||
| 22 | + | export async function action({ request, context }: Route.ActionArgs) { | |
| 23 | + | assertSameOrigin(request); | |
| 24 | + | const headers = { "cache-control": "no-store" }; | |
| 25 | + | const viewer = getViewer(context); | |
| 26 | + | if (!viewer || (viewer.kind ?? "user") !== "user") return Response.json({ error: "Sign in to g1t." }, { status: 401, headers }); | |
| 27 | + | const body = (await request.json().catch(() => null)) as Record<string, unknown> | null; | |
| 28 | + | const workspace = text(body?.workspace)?.toLowerCase() ?? null; | |
| 29 | + | if (!body || !workspace) return Response.json({ error: "Name a workspace." }, { status: 400, headers }); | |
| 30 | + | if (roleIn(viewer, workspace) !== "owner") { | |
| 31 | + | return Response.json({ error: `You can't add runners to ${workspace}: only its owners can.`, allowed: false }, { status: 403, headers }); | |
| 32 | + | } | |
| 33 | + | switch (body.intent) { | |
| 34 | + | case "token": { | |
| 35 | + | const made = await runners.createToken(viewer, { workspace }); | |
| 36 | + | if (!made.ok) return Response.json({ error: made.error.message, allowed: true }, { status: 409, headers }); | |
| 37 | + | const token = made.value; | |
| 38 | + | return Response.json({ token: token.token, workspace: token.workspace, url: token.url, expires_at: token.expiresAt }, { headers }); | |
| 39 | + | } | |
| 40 | + | case "report": { | |
| 41 | + | const backend = backendOf(body.backend); | |
| 42 | + | const machine = text(body.machine); | |
| 43 | + | const diagnosis = diagnosisOf(body.diagnosis); | |
| 44 | + | const closed = diagnosisOf(body.closed); | |
| 45 | + | if (!backend || !machine || !diagnosis || !closed) return Response.json({ error: "That isn't a desktop's report." }, { status: 400, headers }); | |
| 46 | + | const kept = await runners.reportDesktop(viewer, { workspace, machine, backend, version: text(body.version), diagnosis, closed }); | |
| 47 | + | return kept.ok ? Response.json({ ok: true }, { headers }) : Response.json({ error: kept.error.message }, { status: 409, headers }); | |
| 48 | + | } | |
| 49 | + | default: | |
| 50 | + | return Response.json({ error: "Nothing to do." }, { status: 400, headers }); | |
| 51 | + | } | |
| 52 | + | } |
| 230 | 230 | pub id: String, | |
| 231 | 231 | } | |
| 232 | 232 | ||
| 233 | + | // --- The desktop app's runners ---------------------------------------------- | |
| 234 | + | ||
| 235 | + | /// The desktop app keeps a runner on a person's computer while it is open | |
| 236 | + | /// (apps/desktop/src-tauri/src/runner). What it says of that runner, as it | |
| 237 | + | /// says it in its tray and settings (its diagnosis.rs, the one catalogue | |
| 238 | + | /// of those words), is kept here per computer, so the workspace's Runners | |
| 239 | + | /// page lists every desktop with the same state, why, and the fix. A | |
| 240 | + | /// desktop not heard from for `DESKTOP_OPEN_WITHIN_MS` has g1t closed, and | |
| 241 | + | /// shows `closed`, which the app sent too. | |
| 242 | + | pub const DESKTOP_OPEN_WITHIN_MS: u64 = 10 * 60 * 1000; | |
| 243 | + | /// The most desktops a workspace keeps reports for. | |
| 244 | + | pub const MAX_DESKTOPS: u32 = 200; | |
| 245 | + | ||
| 246 | + | /// A diagnosis, as the app made it. | |
| 247 | + | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 248 | + | pub struct DesktopDiagnosis { | |
| 249 | + | /// `idle`, `busy`, `no_docker`, `no_sandbox`, `not_allowed`, … | |
| 250 | + | pub code: String, | |
| 251 | + | /// `ok`, `working`, `off`, `warning` or `problem`. | |
| 252 | + | pub tone: String, | |
| 253 | + | pub title: String, | |
| 254 | + | #[serde(default)] | |
| 255 | + | pub cause: Option<String>, | |
| 256 | + | #[serde(default)] | |
| 257 | + | pub fix: Option<String>, | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | /// One person's computer, as its desktop app last said. | |
| 261 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 262 | + | #[serde(rename_all = "camelCase")] | |
| 263 | + | pub struct DesktopRunner { | |
| 264 | + | pub workspace: String, | |
| 265 | + | /// The computer's name, which its runner has too. | |
| 266 | + | pub machine: String, | |
| 267 | + | /// Whose computer: their username. | |
| 268 | + | pub person: String, | |
| 269 | + | /// `sandboxed`, `docker` or `host`. | |
| 270 | + | pub backend: String, | |
| 271 | + | pub version: Option<String>, | |
| 272 | + | pub diagnosis: DesktopDiagnosis, | |
| 273 | + | /// What to show while g1t is closed on it. | |
| 274 | + | pub closed: DesktopDiagnosis, | |
| 275 | + | /// Heard from within `DESKTOP_OPEN_WITHIN_MS`: g1t is open there. | |
| 276 | + | pub open: bool, | |
| 277 | + | pub reported_at: String, | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | /// `report_desktop`: the person's own desktop app, through the site, for a | |
| 281 | + | /// workspace they own. Returns `Outcome<bool>`. | |
| 282 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 283 | + | pub struct ReportDesktopArgs { | |
| 284 | + | pub actor: User, | |
| 285 | + | pub workspace: String, | |
| 286 | + | pub machine: String, | |
| 287 | + | pub backend: String, | |
| 288 | + | #[serde(default)] | |
| 289 | + | pub version: Option<String>, | |
| 290 | + | pub diagnosis: DesktopDiagnosis, | |
| 291 | + | pub closed: DesktopDiagnosis, | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | /// `desktop_runners`: owners of the workspace. Returns `Outcome<Vec<DesktopRunner>>`. | |
| 295 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 296 | + | pub struct DesktopRunnersArgs { | |
| 297 | + | pub actor: User, | |
| 298 | + | pub workspace: String, | |
| 299 | + | } | |
| 300 | + | ||
| 233 | 301 | /// `runner_settings`: members of the workspace, or admins of the | |
| 234 | 302 | /// repository. Returns `Outcome<RunnerSettings>`. | |
| 235 | 303 | #[derive(Debug, Serialize, Deserialize)] |
| 867 | 867 | deleteGroup: (actor, workspace, id) => call("delete_runner_group", { actor, workspace, id }), | |
| 868 | 868 | settings: (actor, owner) => call("runner_settings", { actor, ...owner }), | |
| 869 | 869 | setSettings: (actor, owner, change) => call("set_runner_settings", { actor, ...owner, ...change }), | |
| 870 | + | reportDesktop: (actor, report) => call("report_desktop", { actor, ...report }), | |
| 871 | + | desktops: (actor, workspace) => call("desktop_runners", { actor, workspace }), | |
| 870 | 872 | }; | |
| 871 | 873 | } | |
| 872 | 874 |
| 123 | 123 | deleteGroup(actor: User, workspace: string, id: string): Promise<Result<boolean>>; | |
| 124 | 124 | settings(actor: User, owner: RunnersOwner): Promise<Result<RunnerSettings>>; | |
| 125 | 125 | setSettings(actor: User, owner: RunnersOwner, change: RunnerSettingsChange): Promise<Result<RunnerSettings>>; | |
| 126 | + | /** What the person's own desktop app says of its runner, in a workspace they own. */ | |
| 127 | + | reportDesktop(actor: User, report: DesktopReport): Promise<Result<boolean>>; | |
| 128 | + | /** The workspace's desktops, as each app last said. Owners. */ | |
| 129 | + | desktops(actor: User, workspace: string): Promise<Result<DesktopRunner[]>>; | |
| 126 | 130 | } | |
| 127 | 131 | ||
| 128 | 132 | /** | |
| 133 | + | * The desktop app keeps a runner on a person's computer while it is open. | |
| 134 | + | * What it says of that runner (its state, why, and the fix) comes from one | |
| 135 | + | * catalogue in the app (apps/desktop/src-tauri/src/runner/diagnosis.rs), | |
| 136 | + | * and the site shows it word for word: `closed` is what to show once g1t | |
| 137 | + | * is closed on that computer, which the app sends too. | |
| 138 | + | */ | |
| 139 | + | export type DesktopDiagnosis = { | |
| 140 | + | code: string; | |
| 141 | + | tone: "ok" | "working" | "off" | "warning" | "problem"; | |
| 142 | + | title: string; | |
| 143 | + | cause: string | null; | |
| 144 | + | fix: string | null; | |
| 145 | + | }; | |
| 146 | + | ||
| 147 | + | /** How a desktop runs jobs. */ | |
| 148 | + | export type DesktopBackend = "sandboxed" | "docker" | "host"; | |
| 149 | + | ||
| 150 | + | export type DesktopReport = { | |
| 151 | + | workspace: string; | |
| 152 | + | machine: string; | |
| 153 | + | backend: DesktopBackend; | |
| 154 | + | version: string | null; | |
| 155 | + | diagnosis: DesktopDiagnosis; | |
| 156 | + | closed: DesktopDiagnosis; | |
| 157 | + | }; | |
| 158 | + | ||
| 159 | + | /** One person's computer, as its desktop app last said. */ | |
| 160 | + | export type DesktopRunner = { | |
| 161 | + | workspace: string; | |
| 162 | + | machine: string; | |
| 163 | + | /** Whose computer: their username. */ | |
| 164 | + | person: string; | |
| 165 | + | backend: DesktopBackend; | |
| 166 | + | version: string | null; | |
| 167 | + | diagnosis: DesktopDiagnosis; | |
| 168 | + | closed: DesktopDiagnosis; | |
| 169 | + | /** Heard from in the last ten minutes: g1t is open there. */ | |
| 170 | + | open: boolean; | |
| 171 | + | reportedAt: string; | |
| 172 | + | }; | |
| 173 | + | ||
| 174 | + | /** The label every desktop app's runner has. */ | |
| 175 | + | export const DESKTOP_LABEL = "desktop"; | |
| 176 | + | ||
| 177 | + | /** | |
| 129 | 178 | * The runner's container image: `g1t-runner` and the Docker CLI, for | |
| 130 | 179 | * running it in Docker or Kubernetes. Published with each release. | |
| 131 | 180 | */ |
| 1 | + | -- What the desktop app last said of the runner it keeps on a person's | |
| 2 | + | -- computer: its diagnosis (state, why, the fix) and what to show once g1t | |
| 3 | + | -- is closed there, as JSON in the app's own words. One row per computer | |
| 4 | + | -- in a workspace, replaced at each report. | |
| 5 | + | CREATE TABLE desktop_runners ( | |
| 6 | + | workspace TEXT NOT NULL, | |
| 7 | + | machine TEXT NOT NULL, | |
| 8 | + | person TEXT NOT NULL, | |
| 9 | + | backend TEXT NOT NULL, | |
| 10 | + | version TEXT, | |
| 11 | + | diagnosis TEXT NOT NULL, | |
| 12 | + | closed TEXT NOT NULL, | |
| 13 | + | reported_at TEXT NOT NULL, | |
| 14 | + | PRIMARY KEY (workspace, machine) | |
| 15 | + | ); |
| 307 | 307 | "delete_runner_group" => reply(&service.delete_runner_group(args(body)?).await?), | |
| 308 | 308 | "runner_settings" => reply(&service.runner_settings(args(body)?).await?), | |
| 309 | 309 | "set_runner_settings" => reply(&service.set_runner_settings(args(body)?).await?), | |
| 310 | + | // The desktop app's runners, as each app says they are. | |
| 311 | + | "report_desktop" => reply(&service.report_desktop(args(body)?).await?), | |
| 312 | + | "desktop_runners" => reply(&service.desktop_runners(args(body)?).await?), | |
| 310 | 313 | // The runner's own side, through the API with its credential. | |
| 311 | 314 | "runner_register" => reply(&service.runner_register(args(body)?).await?), | |
| 312 | 315 | "runner_poll" => reply(&service.runner_poll(args(body)?).await?), |
| 15 | 15 | use g1t_contracts::billing::{ComputeKind, RecordSandboxArgs}; | |
| 16 | 16 | use g1t_contracts::repos::{Repo, RepoPath}; | |
| 17 | 17 | use g1t_contracts::runners::{ | |
| 18 | − | self as model, ACTIVITY_LIMIT, CloudJob, HandedOverTask, RunnerActivity, RunnerActivityArgs, StuckJob, StuckJobsArgs, Assignment, CancelTaskArgs, CreateRegistrationTokenArgs, DeleteRunnerGroupArgs, EnqueueTaskArgs, FinishedArgs, | |
| 18 | + | self as model, ACTIVITY_LIMIT, CloudJob, DesktopDiagnosis, DesktopRunner, DesktopRunnersArgs, ReportDesktopArgs, HandedOverTask, RunnerActivity, RunnerActivityArgs, StuckJob, StuckJobsArgs, Assignment, CancelTaskArgs, CreateRegistrationTokenArgs, DeleteRunnerGroupArgs, EnqueueTaskArgs, FinishedArgs, | |
| 19 | 19 | ListRunnersArgs, Poll, PollArgs, RegisterArgs, Registered, RegistrationToken, RemoveRunnerArgs, RemoveSelfArgs, RouteArgs, Runner, | |
| 20 | 20 | RunnerAuth, RunnerGroup, RunnerGroupsArgs, RunnerSettings, RunnerSettingsArgs, RunnerWork, RunnersOwner, SetRunnerGroupArgs, | |
| 21 | 21 | SetRunnerSettingsArgs, Wanted, | |
| ⋯ | |||
| 123 | 123 | repo: Option<String>, | |
| 124 | 124 | group_id: Option<String>, | |
| 125 | 125 | expires_at: String, | |
| 126 | + | /// Who made the token: the runners it registers are theirs. | |
| 127 | + | #[serde(default)] | |
| 128 | + | created_by: Option<String>, | |
| 126 | 129 | } | |
| 127 | 130 | ||
| 128 | 131 | #[derive(Clone, Debug, Deserialize)] | |
| ⋯ | |||
| 703 | 706 | Ok(Outcome::Ok(self.effective_runner_settings(&place.workspace, place.repo.as_ref().map(|r| r.id.as_str())).await?)) | |
| 704 | 707 | } | |
| 705 | 708 | ||
| 709 | + | /// `report_desktop`: what a person's desktop app says of its runner, | |
| 710 | + | /// in a workspace they own. Kept per computer, replaced each time. | |
| 711 | + | pub async fn report_desktop(&self, a: ReportDesktopArgs) -> Result<Outcome<bool>> { | |
| 712 | + | let owner = RunnersOwner { repo: None, workspace: Some(a.workspace.clone()) }; | |
| 713 | + | let place = check!(self.runner_place(&a.actor, &owner, true).await?); | |
| 714 | + | let machine = match valid_name(&a.machine) { | |
| 715 | + | Ok(name) => name, | |
| 716 | + | Err(problem) => return Ok(fail(FailureCode::Invalid, problem)), | |
| 717 | + | }; | |
| 718 | + | if !matches!(a.backend.as_str(), "sandboxed" | "docker" | "host") { | |
| 719 | + | return Ok(fail(FailureCode::Invalid, "A desktop runs jobs sandboxed, in Docker or on the host.")); | |
| 720 | + | } | |
| 721 | + | let clip = |text: &str, most: usize| text.chars().take(most).collect::<String>(); | |
| 722 | + | let tidy = |d: &DesktopDiagnosis| DesktopDiagnosis { | |
| 723 | + | code: clip(&d.code, 40), | |
| 724 | + | tone: clip(&d.tone, 20), | |
| 725 | + | title: clip(&d.title, 200), | |
| 726 | + | cause: d.cause.as_deref().map(|t| clip(t, 1000)), | |
| 727 | + | fix: d.fix.as_deref().map(|t| clip(t, 1000)), | |
| 728 | + | }; | |
| 729 | + | let at = now_ms(); | |
| 730 | + | let known = self | |
| 731 | + | .db | |
| 732 | + | .prepare("SELECT COUNT(*) AS n FROM desktop_runners WHERE workspace = ? AND lower(machine) <> lower(?)") | |
| 733 | + | .bind(&[place.workspace.as_str().into(), machine.as_str().into()])? | |
| 734 | + | .first::<Count>(None) | |
| 735 | + | .await? | |
| 736 | + | .map_or(0, |c| c.n); | |
| 737 | + | if known >= model::MAX_DESKTOPS { | |
| 738 | + | // The oldest report makes room: a computer long gone. | |
| 739 | + | self.db | |
| 740 | + | .prepare("DELETE FROM desktop_runners WHERE rowid IN (SELECT rowid FROM desktop_runners WHERE workspace = ? ORDER BY reported_at LIMIT 1)") | |
| 741 | + | .bind(&[place.workspace.as_str().into()])? | |
| 742 | + | .run() | |
| 743 | + | .await?; | |
| 744 | + | } | |
| 745 | + | self.db | |
| 746 | + | .prepare( | |
| 747 | + | "INSERT INTO desktop_runners (workspace, machine, person, backend, version, diagnosis, closed, reported_at) | |
| 748 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?) | |
| 749 | + | ON CONFLICT (workspace, machine) DO UPDATE SET person = ?3, backend = ?4, version = ?5, diagnosis = ?6, closed = ?7, reported_at = ?8", | |
| 750 | + | ) | |
| 751 | + | .bind(&[ | |
| 752 | + | place.workspace.as_str().into(), | |
| 753 | + | machine.as_str().into(), | |
| 754 | + | a.actor.username.as_str().into(), | |
| 755 | + | a.backend.as_str().into(), | |
| 756 | + | optional(a.version.as_deref().map(|v| clip(v, 40)).as_deref()), | |
| 757 | + | serde_json::to_string(&tidy(&a.diagnosis))?.into(), | |
| 758 | + | serde_json::to_string(&tidy(&a.closed))?.into(), | |
| 759 | + | rfc3339(at).into(), | |
| 760 | + | ])? | |
| 761 | + | .run() | |
| 762 | + | .await?; | |
| 763 | + | Ok(Outcome::Ok(true)) | |
| 764 | + | } | |
| 765 | + | ||
| 766 | + | /// `desktop_runners`: the workspace's desktops, as each app last said. | |
| 767 | + | pub async fn desktop_runners(&self, a: DesktopRunnersArgs) -> Result<Outcome<Vec<DesktopRunner>>> { | |
| 768 | + | let owner = RunnersOwner { repo: None, workspace: Some(a.workspace.clone()) }; | |
| 769 | + | let place = check!(self.runner_place(&a.actor, &owner, false).await?); | |
| 770 | + | #[derive(Deserialize)] | |
| 771 | + | struct Row { | |
| 772 | + | workspace: String, | |
| 773 | + | machine: String, | |
| 774 | + | person: String, | |
| 775 | + | backend: String, | |
| 776 | + | version: Option<String>, | |
| 777 | + | diagnosis: String, | |
| 778 | + | closed: String, | |
| 779 | + | reported_at: String, | |
| 780 | + | } | |
| 781 | + | let rows = self | |
| 782 | + | .db | |
| 783 | + | .prepare("SELECT * FROM desktop_runners WHERE workspace = ? ORDER BY lower(machine)") | |
| 784 | + | .bind(&[place.workspace.as_str().into()])? | |
| 785 | + | .all() | |
| 786 | + | .await? | |
| 787 | + | .results::<Row>()?; | |
| 788 | + | let open_since = rfc3339(now_ms().saturating_sub(model::DESKTOP_OPEN_WITHIN_MS)); | |
| 789 | + | Ok(Outcome::Ok( | |
| 790 | + | rows.into_iter() | |
| 791 | + | .map(|row| DesktopRunner { | |
| 792 | + | open: row.reported_at.as_str() > open_since.as_str(), | |
| 793 | + | diagnosis: serde_json::from_str(&row.diagnosis).unwrap_or_default(), | |
| 794 | + | closed: serde_json::from_str(&row.closed).unwrap_or_default(), | |
| 795 | + | workspace: row.workspace, | |
| 796 | + | machine: row.machine, | |
| 797 | + | person: row.person, | |
| 798 | + | backend: row.backend, | |
| 799 | + | version: row.version, | |
| 800 | + | reported_at: row.reported_at, | |
| 801 | + | }) | |
| 802 | + | .collect(), | |
| 803 | + | )) | |
| 804 | + | } | |
| 805 | + | ||
| 706 | 806 | /// `set_runner_settings`. | |
| 707 | 807 | pub async fn set_runner_settings(&self, a: SetRunnerSettingsArgs) -> Result<Outcome<RunnerSettings>> { | |
| 708 | 808 | let place = check!(self.runner_place(&a.actor, &a.owner, true).await?); | |
| ⋯ | |||
| 866 | 966 | created.as_str().into(), | |
| 867 | 967 | created.as_str().into(), | |
| 868 | 968 | created.as_str().into(), | |
| 869 | − | JsValue::NULL, | |
| 969 | + | // The person who made the token: a desktop's runner is theirs. | |
| 970 | + | optional(registration.created_by.as_deref()), | |
| 870 | 971 | optional(isolation), | |
| 871 | 972 | ])? | |
| 872 | 973 | .run() | |
Comments
Sign in to comment.