Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.
20 files+2371−3230/20 viewed
| 9 | 9 | --accent: #b6a8ff; | |
| 10 | 10 | --accent-ink: #15112e; | |
| 11 | 11 | --error: #ff8a8a; | |
| 12 | + | /* States, as dots and labels: running, on its way, needs a look, failing. */ | |
| 13 | + | --ok: #4ade80; | |
| 14 | + | --working: #60a5fa; | |
| 15 | + | --warning: #fbbf24; | |
| 16 | + | --raised: #1e1e24; | |
| 17 | + | --line-strong: #34343c; | |
| 12 | 18 | color-scheme: dark; | |
| 13 | 19 | } | |
| 14 | 20 | * { | |
| ⋯ | |||
| 69 | 75 | overflow-y: auto; | |
| 70 | 76 | } | |
| 71 | 77 | main { | |
| 72 | − | max-width: 36rem; | |
| 78 | + | max-width: 38rem; | |
| 73 | 79 | margin: 0 auto; | |
| 74 | 80 | padding: 1.5rem 1.25rem 1.25rem; | |
| 75 | 81 | } | |
| ⋯ | |||
| 261 | 267 | .diagnosis-text .buttons:empty { | |
| 262 | 268 | display: none; | |
| 263 | 269 | } | |
| 264 | − | /* With the sandbox, each kind of job it runs: Linux jobs, Windows jobs. */ | |
| 265 | − | .capabilities { | |
| 266 | − | display: flex; | |
| 267 | − | flex-direction: column; | |
| 268 | − | gap: 0.5rem; | |
| 269 | − | margin: 0.5rem 0 0; | |
| 270 | − | padding: 0.5rem 0 0; | |
| 271 | − | border-top: 1px solid var(--line); | |
| 272 | − | list-style: none; | |
| 273 | − | } | |
| 274 | − | .capabilities[hidden] { | |
| 270 | + | .diagnosis[hidden] { | |
| 275 | 271 | display: none; | |
| 276 | 272 | } | |
| 277 | − | .capabilities li { | |
| 278 | − | display: flex; | |
| 279 | − | align-items: flex-start; | |
| 280 | − | gap: 0.5rem; | |
| 281 | − | } | |
| 282 | − | .capabilities .dot { | |
| 283 | − | margin-top: 0.3rem; | |
| 284 | − | } | |
| 285 | 273 | .dot { | |
| 286 | 274 | flex: none; | |
| 287 | 275 | width: 8px; | |
| ⋯ | |||
| 291 | 279 | background: var(--muted); | |
| 292 | 280 | } | |
| 293 | 281 | .dot.ok { | |
| 294 | − | background: #4ade80; | |
| 282 | + | background: var(--ok); | |
| 295 | 283 | } | |
| 296 | 284 | .dot.working { | |
| 297 | − | background: #60a5fa; | |
| 285 | + | background: var(--working); | |
| 298 | 286 | } | |
| 299 | 287 | .dot.warning { | |
| 300 | − | background: #fbbf24; | |
| 288 | + | background: var(--warning); | |
| 301 | 289 | } | |
| 302 | 290 | .dot.problem { | |
| 303 | 291 | background: var(--error); | |
| 304 | 292 | } | |
| 305 | 293 | .warning-note { | |
| 306 | − | color: #fbbf24; | |
| 294 | + | color: var(--warning); | |
| 295 | + | } | |
| 296 | + | .visually-hidden { | |
| 297 | + | position: absolute; | |
| 298 | + | width: 1px; | |
| 299 | + | height: 1px; | |
| 300 | + | margin: -1px; | |
| 301 | + | padding: 0; | |
| 302 | + | overflow: hidden; | |
| 303 | + | clip: rect(0 0 0 0); | |
| 304 | + | white-space: nowrap; | |
| 305 | + | border: 0; | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | /* Sandboxed, each kind of job it runs, side by side: Linux jobs, Windows | |
| 309 | + | jobs. Two to a row where they fit, one under the other where they don't. */ | |
| 310 | + | .kinds { | |
| 311 | + | display: grid; | |
| 312 | + | grid-template-columns: repeat(auto-fit, minmax(15.5rem, 1fr)); | |
| 313 | + | gap: 0.75rem; | |
| 314 | + | margin: 0.5rem 0 0.75rem; | |
| 315 | + | } | |
| 316 | + | .kinds[hidden] { | |
| 317 | + | display: none; | |
| 318 | + | } | |
| 319 | + | .kinds.single { | |
| 320 | + | grid-template-columns: 1fr; | |
| 321 | + | } | |
| 322 | + | .kind { | |
| 323 | + | display: flex; | |
| 324 | + | flex-direction: column; | |
| 325 | + | gap: 0.625rem; | |
| 326 | + | min-width: 0; | |
| 327 | + | padding: 0.875rem; | |
| 328 | + | border: 1px solid var(--line); | |
| 329 | + | border-radius: 12px; | |
| 330 | + | background: var(--surface); | |
| 331 | + | transition: border-color 0.2s; | |
| 332 | + | } | |
| 333 | + | /* A kind that needs a look says so with its edge as well as its label. */ | |
| 334 | + | .kind[data-tone="problem"] { | |
| 335 | + | border-color: rgba(255, 138, 138, 0.35); | |
| 336 | + | } | |
| 337 | + | .kind[data-tone="warning"] { | |
| 338 | + | border-color: rgba(251, 191, 36, 0.3); | |
| 339 | + | } | |
| 340 | + | /* Its icon, and beside it its name over its state. */ | |
| 341 | + | .kind-head { | |
| 342 | + | display: flex; | |
| 343 | + | align-items: center; | |
| 344 | + | gap: 0.75rem; | |
| 345 | + | } | |
| 346 | + | .kind-icon { | |
| 347 | + | flex: none; | |
| 348 | + | display: grid; | |
| 349 | + | place-items: center; | |
| 350 | + | width: 40px; | |
| 351 | + | height: 40px; | |
| 352 | + | border-radius: 10px; | |
| 353 | + | background: var(--raised); | |
| 354 | + | color: var(--fg); | |
| 355 | + | } | |
| 356 | + | .kind.off .kind-icon { | |
| 357 | + | color: var(--muted); | |
| 358 | + | } | |
| 359 | + | .kind-name { | |
| 360 | + | display: flex; | |
| 361 | + | flex-direction: column; | |
| 362 | + | align-items: flex-start; | |
| 363 | + | gap: 0.25rem; | |
| 364 | + | min-width: 0; | |
| 365 | + | flex: 1 1 auto; | |
| 366 | + | } | |
| 367 | + | .kind-name h3 { | |
| 368 | + | margin: 0; | |
| 369 | + | font-size: 0.9375rem; | |
| 370 | + | font-weight: 600; | |
| 371 | + | letter-spacing: -0.005em; | |
| 372 | + | } | |
| 373 | + | .kind-about { | |
| 374 | + | margin: 0; | |
| 375 | + | color: var(--muted); | |
| 376 | + | font-size: 0.8125rem; | |
| 377 | + | line-height: 1.4; | |
| 378 | + | } | |
| 379 | + | /* Its state, as a label: a dot in its tone, and a few words. */ | |
| 380 | + | .pill { | |
| 381 | + | display: inline-flex; | |
| 382 | + | align-items: center; | |
| 383 | + | gap: 0.375rem; | |
| 384 | + | flex: none; | |
| 385 | + | padding: 0.0625rem 0.5rem 0.0625rem 0.4375rem; | |
| 386 | + | border: 1px solid var(--line-strong); | |
| 387 | + | border-radius: 999px; | |
| 388 | + | color: var(--muted); | |
| 389 | + | font-size: 0.75rem; | |
| 390 | + | font-weight: 500; | |
| 391 | + | line-height: 1.4; | |
| 392 | + | white-space: nowrap; | |
| 393 | + | } | |
| 394 | + | .pill-dot { | |
| 395 | + | width: 6px; | |
| 396 | + | height: 6px; | |
| 397 | + | border-radius: 50%; | |
| 398 | + | background: currentColor; | |
| 399 | + | } | |
| 400 | + | .pill[data-tone="ok"] { | |
| 401 | + | border-color: rgba(74, 222, 128, 0.35); | |
| 402 | + | background: rgba(74, 222, 128, 0.1); | |
| 403 | + | color: var(--ok); | |
| 404 | + | } | |
| 405 | + | .pill[data-tone="working"] { | |
| 406 | + | border-color: rgba(96, 165, 250, 0.35); | |
| 407 | + | background: rgba(96, 165, 250, 0.1); | |
| 408 | + | color: var(--working); | |
| 409 | + | } | |
| 410 | + | .pill[data-tone="warning"] { | |
| 411 | + | border-color: rgba(251, 191, 36, 0.35); | |
| 412 | + | background: rgba(251, 191, 36, 0.1); | |
| 413 | + | color: var(--warning); | |
| 414 | + | } | |
| 415 | + | .pill[data-tone="problem"] { | |
| 416 | + | border-color: rgba(255, 138, 138, 0.4); | |
| 417 | + | background: rgba(255, 138, 138, 0.1); | |
| 418 | + | color: var(--error); | |
| 419 | + | } | |
| 420 | + | /* On its way (running a job, starting, downloading): the dot breathes. */ | |
| 421 | + | .pill.live .pill-dot { | |
| 422 | + | animation: breathe 1.6s ease-in-out infinite; | |
| 423 | + | } | |
| 424 | + | @keyframes breathe { | |
| 425 | + | 50% { | |
| 426 | + | opacity: 0.35; | |
| 427 | + | } | |
| 428 | + | } | |
| 429 | + | .kind-body { | |
| 430 | + | display: flex; | |
| 431 | + | flex-direction: column; | |
| 432 | + | gap: 0.25rem; | |
| 433 | + | font-size: 0.8125rem; | |
| 434 | + | line-height: 1.45; | |
| 435 | + | } | |
| 436 | + | .kind-body[hidden] { | |
| 437 | + | display: none; | |
| 438 | + | } | |
| 439 | + | .kind-body p { | |
| 440 | + | margin: 0; | |
| 441 | + | } | |
| 442 | + | .kind-headline { | |
| 443 | + | color: var(--fg); | |
| 444 | + | font-size: 0.875rem; | |
| 445 | + | font-weight: 500; | |
| 446 | + | } | |
| 447 | + | .kind-cause { | |
| 448 | + | color: var(--muted); | |
| 449 | + | } | |
| 450 | + | .kind-fix { | |
| 451 | + | color: var(--fg); | |
| 452 | + | } | |
| 453 | + | .kind-body code, | |
| 454 | + | .diagnosis-text code { | |
| 455 | + | padding: 0.0625rem 0.3125rem; | |
| 456 | + | border-radius: 5px; | |
| 457 | + | background: var(--raised); | |
| 458 | + | color: var(--fg); | |
| 459 | + | font-size: 0.75rem; | |
| 460 | + | overflow-wrap: anywhere; | |
| 461 | + | user-select: text; | |
| 462 | + | } | |
| 463 | + | .kind-job { | |
| 464 | + | display: flex; | |
| 465 | + | flex-direction: column; | |
| 466 | + | gap: 0.125rem; | |
| 467 | + | padding: 0.5rem 0.625rem; | |
| 468 | + | border-radius: 8px; | |
| 469 | + | background: var(--raised); | |
| 470 | + | } | |
| 471 | + | .kind-job-label { | |
| 472 | + | color: var(--muted); | |
| 473 | + | font-size: 0.6875rem; | |
| 474 | + | font-weight: 600; | |
| 475 | + | letter-spacing: 0.05em; | |
| 476 | + | text-transform: uppercase; | |
| 477 | + | } | |
| 478 | + | .kind-job-what { | |
| 479 | + | min-width: 0; | |
| 480 | + | overflow-wrap: anywhere; | |
| 481 | + | } | |
| 482 | + | .kind-job-what a { | |
| 483 | + | color: var(--fg); | |
| 484 | + | font-weight: 500; | |
| 485 | + | } | |
| 486 | + | .kind-job-what a:hover { | |
| 487 | + | color: var(--accent); | |
| 488 | + | } | |
| 489 | + | .kind-progress { | |
| 490 | + | display: flex; | |
| 491 | + | align-items: center; | |
| 492 | + | gap: 0.625rem; | |
| 493 | + | } | |
| 494 | + | .kind-progress .bar { | |
| 495 | + | flex: 1; | |
| 496 | + | height: 6px; | |
| 497 | + | overflow: hidden; | |
| 498 | + | border-radius: 999px; | |
| 499 | + | background: var(--line); | |
| 500 | + | } | |
| 501 | + | .kind-progress .bar span { | |
| 502 | + | display: block; | |
| 503 | + | height: 100%; | |
| 504 | + | border-radius: 999px; | |
| 505 | + | background: var(--accent); | |
| 506 | + | transition: width 0.4s ease; | |
| 507 | + | } | |
| 508 | + | .kind-progress .small { | |
| 509 | + | min-width: 2.5rem; | |
| 510 | + | text-align: right; | |
| 511 | + | font-variant-numeric: tabular-nums; | |
| 512 | + | } | |
| 513 | + | /* What it is on g1t: there to read, never in the way. */ | |
| 514 | + | .kind-details { | |
| 515 | + | display: grid; | |
| 516 | + | gap: 0.125rem; | |
| 517 | + | margin: 0; | |
| 518 | + | padding-top: 0.5rem; | |
| 519 | + | border-top: 1px solid var(--line); | |
| 520 | + | font-size: 0.75rem; | |
| 521 | + | } | |
| 522 | + | .kind-details[hidden] { | |
| 523 | + | display: none; | |
| 524 | + | } | |
| 525 | + | .kind-details div { | |
| 526 | + | display: flex; | |
| 527 | + | align-items: baseline; | |
| 528 | + | gap: 0.5rem; | |
| 529 | + | min-width: 0; | |
| 530 | + | } | |
| 531 | + | .kind-details dt { | |
| 532 | + | flex: none; | |
| 533 | + | width: 4.25rem; | |
| 534 | + | color: var(--muted); | |
| 535 | + | } | |
| 536 | + | .kind-details dd { | |
| 537 | + | display: flex; | |
| 538 | + | align-items: baseline; | |
| 539 | + | flex-wrap: wrap; | |
| 540 | + | gap: 0 0.5rem; | |
| 541 | + | min-width: 0; | |
| 542 | + | margin: 0; | |
| 543 | + | color: var(--fg); | |
| 544 | + | overflow-wrap: anywhere; | |
| 545 | + | user-select: text; | |
| 546 | + | } | |
| 547 | + | button.link { | |
| 548 | + | display: inline-flex; | |
| 549 | + | align-items: center; | |
| 550 | + | gap: 0.25rem; | |
| 551 | + | padding: 0; | |
| 552 | + | border: 0; | |
| 553 | + | background: none; | |
| 554 | + | color: var(--fg); | |
| 555 | + | font-size: inherit; | |
| 556 | + | font-weight: 400; | |
| 557 | + | text-decoration: underline; | |
| 558 | + | text-decoration-color: var(--line-strong); | |
| 559 | + | text-underline-offset: 3px; | |
| 560 | + | } | |
| 561 | + | button.link:hover { | |
| 562 | + | color: var(--accent); | |
| 563 | + | text-decoration-color: currentColor; | |
| 564 | + | } | |
| 565 | + | button.link svg { | |
| 566 | + | flex: none; | |
| 567 | + | color: var(--muted); | |
| 568 | + | } | |
| 569 | + | .kind-actions { | |
| 570 | + | display: flex; | |
| 571 | + | flex-wrap: wrap; | |
| 572 | + | align-items: center; | |
| 573 | + | gap: 0.5rem; | |
| 574 | + | margin-top: auto; | |
| 575 | + | } | |
| 576 | + | .kind-actions[hidden] { | |
| 577 | + | display: none; | |
| 578 | + | } | |
| 579 | + | .kind-actions .end { | |
| 580 | + | margin-left: auto; | |
| 581 | + | } | |
| 582 | + | button.quiet { | |
| 583 | + | background: transparent; | |
| 584 | + | color: var(--muted); | |
| 585 | + | } | |
| 586 | + | button.quiet:hover { | |
| 587 | + | color: var(--fg); | |
| 588 | + | } | |
| 589 | + | @media (prefers-reduced-motion: reduce) { | |
| 590 | + | .pill.live .pill-dot { | |
| 591 | + | animation: none; | |
| 592 | + | } | |
| 593 | + | .kind, | |
| 594 | + | .kind-progress .bar span { | |
| 595 | + | transition: none; | |
| 596 | + | } | |
| 307 | 597 | } | |
| 308 | 598 | details.log { | |
| 309 | 599 | padding: 0.75rem 0; | |
| 53 | 53 | > | |
| 54 | 54 | <input type="checkbox" role="switch" id="runner" /> | |
| 55 | 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"> | |
| 56 | + | <!-- | |
| 57 | + | What affects the whole runner (signed out, no workspace, turned off, | |
| 58 | + | the sandbox down): why, and how to fix it (src/runner/diagnosis.rs). | |
| 59 | + | Hidden when the cards below say it all. | |
| 60 | + | --> | |
| 61 | + | <div class="diagnosis" id="runner-diagnosis" role="status" aria-live="polite" hidden> | |
| 58 | 62 | <span class="dot" id="runner-dot" aria-hidden="true"></span> | |
| 59 | 63 | <span class="diagnosis-text"> | |
| 60 | 64 | <strong id="runner-title">Runner</strong> | |
| 61 | 65 | <span class="muted" id="runner-cause"></span> | |
| 62 | 66 | <span id="runner-fix"></span> | |
| 63 | 67 | <span class="buttons" id="runner-actions"></span> | |
| 64 | − | <!-- Sandboxed: each kind of job it runs, Linux and Windows, on its own line. --> | |
| 65 | − | <ul class="capabilities" id="runner-capabilities" hidden></ul> | |
| 66 | 68 | </span> | |
| 67 | − | <button type="button" id="runner-toggle">Stop</button> | |
| 68 | 69 | </div> | |
| 70 | + | <!-- Sandboxed: each kind of job it runs, a card of its own, started and stopped on its own. --> | |
| 71 | + | <div class="kinds" id="runner-kinds" role="list" aria-label="Kinds of job this computer runs" hidden></div> | |
| 69 | 72 | <label class="setting"> | |
| 70 | 73 | <span><strong>How jobs run</strong><span class="muted" id="runner-backend-note"> </span></span> | |
| 71 | 74 | <select id="runnerBackend"> |
| 80 | 80 | settings.zoom = Number(byId("zoom").value) || 1; | |
| 81 | 81 | settings.runnerBackend = byId("runnerBackend").value || "sandboxed"; | |
| 82 | 82 | settings.runnerWorkspace = byId("runnerWorkspace").value || null; | |
| 83 | + | // Which kinds of job are on is changed by their cards' Start and Stop, and kept as it is here. | |
| 84 | + | if (current && current.runnerCapabilities) settings.runnerCapabilities = current.runnerCapabilities; | |
| 83 | 85 | return settings; | |
| 84 | 86 | } | |
| 85 | 87 | ||
| ⋯ | |||
| 113 | 115 | return button; | |
| 114 | 116 | } | |
| 115 | 117 | ||
| 116 | − | function span(className, text) { | |
| 117 | − | var item = document.createElement("span"); | |
| 118 | + | function element(tag, className, text) { | |
| 119 | + | var item = document.createElement(tag); | |
| 118 | 120 | if (className) item.className = className; | |
| 119 | − | item.textContent = text; | |
| 121 | + | if (text != null) item.textContent = text; | |
| 120 | 122 | return item; | |
| 121 | 123 | } | |
| 122 | 124 | ||
| 123 | − | // Each kind of job the sandbox runs, when there is more than one: Linux | |
| 124 | − | // jobs can run while Windows jobs cannot. | |
| 125 | − | function renderCapabilities(capabilities) { | |
| 126 | − | var list = byId("runner-capabilities"); | |
| 127 | − | list.textContent = ""; | |
| 128 | − | list.hidden = !capabilities || capabilities.length < 2; | |
| 129 | − | if (list.hidden) return; | |
| 130 | − | capabilities.forEach(function (capability) { | |
| 131 | − | var item = document.createElement("li"); | |
| 132 | − | item.appendChild(span("dot " + capability.tone, "")); | |
| 133 | − | var text = span("diagnosis-text", ""); | |
| 134 | − | var title = document.createElement("strong"); | |
| 135 | − | title.textContent = capability.label + ": " + capability.title; | |
| 136 | − | text.appendChild(title); | |
| 137 | − | if (capability.cause) text.appendChild(span("muted", capability.cause)); | |
| 138 | − | if (capability.fix) text.appendChild(span("", capability.fix)); | |
| 139 | − | var buttons = span("buttons", ""); | |
| 140 | − | capability.actions.forEach(function (action) { | |
| 141 | − | buttons.appendChild(actionButton(action, false)); | |
| 125 | + | // The catalogue's sentences name commands in backticks: `wsl --install`. | |
| 126 | + | // Said here as code, which can be selected and copied. | |
| 127 | + | function prose(into, text) { | |
| 128 | + | into.textContent = ""; | |
| 129 | + | String(text || "") | |
| 130 | + | .split("`") | |
| 131 | + | .forEach(function (part, at) { | |
| 132 | + | if (!part) return; | |
| 133 | + | into.appendChild(at % 2 ? element("code", null, part) : document.createTextNode(part)); | |
| 134 | + | }); | |
| 135 | + | return into; | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // ── Each kind of job, a card of its own ─────────────────────────────── | |
| 139 | + | // Linux jobs and Windows jobs (macOS jobs once they are released): what | |
| 140 | + | // each is doing, why one isn't running and its fix, and its own Start or | |
| 141 | + | // Stop, which leaves the other as it is. The words are the app's | |
| 142 | + | // (CapabilityLine in src/runner/diagnosis.rs); the card only lays them out. | |
| 143 | + | var SVG = "http://www.w3.org/2000/svg"; | |
| 144 | + | // Plain line icons, 16px on a 24px grid: a terminal for Linux jobs, a | |
| 145 | + | // window for Windows jobs, a laptop for macOS jobs. | |
| 146 | + | var ICONS = { | |
| 147 | + | linux: ["M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z", "M7 10l3 2.5L7 15", "M12.5 15h4"], | |
| 148 | + | windows: ["M4 4h16a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1z", "M3 9h18", "M6.5 6.5h.01", "M9 6.5h.01"], | |
| 149 | + | macos: ["M5 6a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v9H5z", "M2.5 18.5h19", "M3.5 18.5L5 15h14l1.5 3.5"], | |
| 150 | + | }; | |
| 151 | + | // How each runner keeps a job from this computer, said plainly. | |
| 152 | + | var ISOLATION = { "nested-container": "A fresh container in the VM", vm: "A VM of its own", container: "A container of its own", host: "None" }; | |
| 153 | + | var BUSY = { busy: true, starting: true, downloading: true, updating: true }; | |
| 154 | + | var cards = {}; | |
| 155 | + | var pending = {}; | |
| 156 | + | ||
| 157 | + | // A small arrow: the link goes out to g1t. | |
| 158 | + | function outward() { | |
| 159 | + | var svg = document.createElementNS(SVG, "svg"); | |
| 160 | + | svg.setAttribute("viewBox", "0 0 24 24"); | |
| 161 | + | svg.setAttribute("width", "11"); | |
| 162 | + | svg.setAttribute("height", "11"); | |
| 163 | + | svg.setAttribute("fill", "none"); | |
| 164 | + | svg.setAttribute("stroke", "currentColor"); | |
| 165 | + | svg.setAttribute("stroke-width", "2"); | |
| 166 | + | svg.setAttribute("stroke-linecap", "round"); | |
| 167 | + | svg.setAttribute("aria-hidden", "true"); | |
| 168 | + | var path = document.createElementNS(SVG, "path"); | |
| 169 | + | path.setAttribute("d", "M7 17L17 7M9 7h8v8"); | |
| 170 | + | svg.appendChild(path); | |
| 171 | + | return svg; | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | function icon(id) { | |
| 175 | + | var svg = document.createElementNS(SVG, "svg"); | |
| 176 | + | svg.setAttribute("viewBox", "0 0 24 24"); | |
| 177 | + | svg.setAttribute("width", "16"); | |
| 178 | + | svg.setAttribute("height", "16"); | |
| 179 | + | svg.setAttribute("fill", "none"); | |
| 180 | + | svg.setAttribute("stroke", "currentColor"); | |
| 181 | + | svg.setAttribute("stroke-width", "1.75"); | |
| 182 | + | svg.setAttribute("stroke-linecap", "round"); | |
| 183 | + | svg.setAttribute("stroke-linejoin", "round"); | |
| 184 | + | svg.setAttribute("aria-hidden", "true"); | |
| 185 | + | (ICONS[id] || ICONS.linux).forEach(function (d) { | |
| 186 | + | var path = document.createElementNS(SVG, "path"); | |
| 187 | + | path.setAttribute("d", d); | |
| 188 | + | svg.appendChild(path); | |
| 189 | + | }); | |
| 190 | + | return svg; | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // A card's parts, made once and filled in on every status. | |
| 194 | + | function makeCard(kind) { | |
| 195 | + | var card = element("article", "kind"); | |
| 196 | + | card.setAttribute("role", "listitem"); | |
| 197 | + | card.setAttribute("aria-labelledby", "kind-" + kind.id + "-title"); | |
| 198 | + | var head = element("header", "kind-head"); | |
| 199 | + | var badge = element("span", "kind-icon"); | |
| 200 | + | badge.appendChild(icon(kind.id)); | |
| 201 | + | var name = element("span", "kind-name"); | |
| 202 | + | var title = element("h3", null, kind.label); | |
| 203 | + | title.id = "kind-" + kind.id + "-title"; | |
| 204 | + | name.appendChild(title); | |
| 205 | + | var pill = element("span", "pill"); | |
| 206 | + | // Its state is said when it changes, as "Linux jobs: Waiting for jobs". | |
| 207 | + | pill.setAttribute("role", "status"); | |
| 208 | + | pill.setAttribute("aria-live", "polite"); | |
| 209 | + | pill.setAttribute("aria-atomic", "true"); | |
| 210 | + | var said = element("span", "visually-hidden", kind.label + ": "); | |
| 211 | + | pill.appendChild(said); | |
| 212 | + | pill.appendChild(element("span", "pill-dot")); | |
| 213 | + | pill.appendChild(element("span", "pill-text")); | |
| 214 | + | name.appendChild(pill); | |
| 215 | + | head.appendChild(badge); | |
| 216 | + | head.appendChild(name); | |
| 217 | + | card.appendChild(head); | |
| 218 | + | card.appendChild(element("p", "kind-about")); | |
| 219 | + | var body = element("div", "kind-body"); | |
| 220 | + | card.appendChild(body); | |
| 221 | + | var details = element("dl", "kind-details"); | |
| 222 | + | card.appendChild(details); | |
| 223 | + | var actions = element("div", "kind-actions"); | |
| 224 | + | card.appendChild(actions); | |
| 225 | + | return { root: card, pill: pill, about: card.querySelector(".kind-about"), body: body, details: details, actions: actions, signature: "" }; | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | // What it is running, with where to see it on g1t. | |
| 229 | + | function jobRow(job) { | |
| 230 | + | var row = element("div", "kind-job"); | |
| 231 | + | row.appendChild(element("span", "kind-job-label", job.kind === "agent" ? "Agent work" : "Running")); | |
| 232 | + | var what = element("span", "kind-job-what"); | |
| 233 | + | if (job.link) { | |
| 234 | + | var link = element("a", null, job.name); | |
| 235 | + | link.href = job.link; | |
| 236 | + | what.appendChild(link); | |
| 237 | + | } else { | |
| 238 | + | what.appendChild(element("strong", null, job.name)); | |
| 239 | + | } | |
| 240 | + | if (job.repo) what.appendChild(element("span", "muted", " in " + job.repo)); | |
| 241 | + | row.appendChild(what); | |
| 242 | + | return row; | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | function progressRow(kind) { | |
| 246 | + | var row = element("div", "kind-progress"); | |
| 247 | + | var bar = element("div", "bar"); | |
| 248 | + | bar.setAttribute("role", "progressbar"); | |
| 249 | + | bar.setAttribute("aria-valuemin", "0"); | |
| 250 | + | bar.setAttribute("aria-valuemax", "100"); | |
| 251 | + | bar.setAttribute("aria-valuenow", String(kind.progress)); | |
| 252 | + | bar.setAttribute("aria-label", kind.label + ": the sandbox's image"); | |
| 253 | + | var fill = element("span"); | |
| 254 | + | fill.style.width = Math.max(2, Math.min(100, kind.progress)) + "%"; | |
| 255 | + | bar.appendChild(fill); | |
| 256 | + | row.appendChild(bar); | |
| 257 | + | row.appendChild(element("span", "muted small", kind.progress + "%")); | |
| 258 | + | return row; | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | function detail(list, term, value, given) { | |
| 262 | + | if (!value) return; | |
| 263 | + | var row = element("div"); | |
| 264 | + | row.appendChild(element("dt", null, term)); | |
| 265 | + | var dd = element("dd", null, given ? null : value); | |
| 266 | + | if (given) dd.appendChild(given); | |
| 267 | + | row.appendChild(dd); | |
| 268 | + | list.appendChild(row); | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | // Start or Stop for one kind of job, said for which, and what it is doing meanwhile. | |
| 272 | + | function toggleButton(kind, onFirst) { | |
| 273 | + | var on = kind.enabled; | |
| 274 | + | var waiting = pending[kind.id]; | |
| 275 | + | var button = element("button", on ? "quiet" : onFirst ? "primary" : null); | |
| 276 | + | button.type = "button"; | |
| 277 | + | button.textContent = waiting ? (on ? "Stopping…" : "Starting…") : on ? "Stop" : "Start"; | |
| 278 | + | button.setAttribute("aria-label", (on ? "Stop " : "Start ") + kind.label); | |
| 279 | + | button.disabled = !!waiting; | |
| 280 | + | button.addEventListener("click", function () { | |
| 281 | + | pending[kind.id] = true; | |
| 282 | + | renderKinds(lastKinds); | |
| 283 | + | invoke("runner_control", { action: on ? "stop" : "start", capability: kind.id }) | |
| 284 | + | .catch(fail) | |
| 285 | + | .then(function () { | |
| 286 | + | // The status that follows says how it is; until then it says what was asked. | |
| 287 | + | setTimeout(function () { | |
| 288 | + | delete pending[kind.id]; | |
| 289 | + | renderKinds(lastKinds); | |
| 290 | + | }, 1500); | |
| 291 | + | }); | |
| 292 | + | }); | |
| 293 | + | return button; | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | function fillCard(card, kind, runner) { | |
| 297 | + | var root = card.root; | |
| 298 | + | root.dataset.state = kind.state; | |
| 299 | + | root.dataset.tone = kind.tone; | |
| 300 | + | root.classList.toggle("off", !kind.enabled); | |
| 301 | + | card.pill.dataset.tone = kind.tone; | |
| 302 | + | card.pill.classList.toggle("live", !!BUSY[kind.state]); | |
| 303 | + | // Said again only when it changes, so a screen reader hears each change once. | |
| 304 | + | var text = card.pill.querySelector(".pill-text"); | |
| 305 | + | if (text.textContent !== kind.title) text.textContent = kind.title; | |
| 306 | + | if (card.about.textContent !== kind.about) card.about.textContent = kind.about; | |
| 307 | + | ||
| 308 | + | // What changes the buttons and the words is drawn again only when it changes, | |
| 309 | + | // so a button keeps its focus while the status ticks. | |
| 310 | + | var signature = JSON.stringify([kind, !!pending[kind.id], runner.link]); | |
| 311 | + | if (signature === card.signature) return; | |
| 312 | + | card.signature = signature; | |
| 313 | + | ||
| 314 | + | var body = card.body; | |
| 315 | + | body.textContent = ""; | |
| 316 | + | if (kind.job) body.appendChild(jobRow(kind.job)); | |
| 317 | + | if (kind.state === "downloading" && kind.progress != null) body.appendChild(progressRow(kind)); | |
| 318 | + | if (kind.headline) body.appendChild(element("p", "kind-headline", kind.headline)); | |
| 319 | + | if (kind.cause) body.appendChild(prose(element("p", "kind-cause"), kind.cause)); | |
| 320 | + | if (kind.fix) body.appendChild(prose(element("p", "kind-fix"), kind.fix)); | |
| 321 | + | body.hidden = !body.firstChild; | |
| 322 | + | ||
| 323 | + | var details = card.details; | |
| 324 | + | details.textContent = ""; | |
| 325 | + | // Its name on g1t opens the workspace's Runners page, where it is listed. | |
| 326 | + | var named = null; | |
| 327 | + | if (kind.runner && runner.link) { | |
| 328 | + | named = element("button", "link", kind.runner); | |
| 329 | + | named.type = "button"; | |
| 330 | + | named.setAttribute("aria-label", "View " + kind.runner + " on g1t"); | |
| 331 | + | named.appendChild(outward()); | |
| 332 | + | named.addEventListener("click", function () { | |
| 333 | + | invoke("runner_control", { action: "view_runners" }).catch(fail); | |
| 142 | 334 | }); | |
| 143 | − | text.appendChild(buttons); | |
| 144 | − | item.appendChild(text); | |
| 145 | − | list.appendChild(item); | |
| 335 | + | } | |
| 336 | + | detail(details, "On g1t", kind.runner, named); | |
| 337 | + | detail(details, "Version", kind.version ? "g1t-runner " + kind.version : null); | |
| 338 | + | detail(details, "Isolation", ISOLATION[kind.isolation] || kind.isolation); | |
| 339 | + | details.hidden = !details.firstChild; | |
| 340 | + | ||
| 341 | + | var actions = card.actions; | |
| 342 | + | actions.textContent = ""; | |
| 343 | + | // Its first fix stands out when it is the person's to do; while g1t | |
| 344 | + | // sees to it (waiting for the release's images), none does. | |
| 345 | + | var urgent = kind.tone === "problem" || kind.tone === "warning"; | |
| 346 | + | kind.actions.forEach(function (action, at) { | |
| 347 | + | actions.appendChild(actionButton(action, at === 0 && urgent && kind.state !== "waiting")); | |
| 348 | + | }); | |
| 349 | + | if (kind.can_toggle) { | |
| 350 | + | var toggle = toggleButton(kind, kind.actions.length === 0); | |
| 351 | + | toggle.classList.add("end"); | |
| 352 | + | actions.appendChild(toggle); | |
| 353 | + | } | |
| 354 | + | actions.hidden = !actions.firstChild; | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | var lastKinds = null; | |
| 358 | + | ||
| 359 | + | function renderKinds(runner) { | |
| 360 | + | lastKinds = runner; | |
| 361 | + | var list = byId("runner-kinds"); | |
| 362 | + | var kinds = (runner && runner.diagnosis.capabilities) || []; | |
| 363 | + | list.hidden = kinds.length === 0; | |
| 364 | + | var seen = {}; | |
| 365 | + | kinds.forEach(function (kind, at) { | |
| 366 | + | seen[kind.id] = true; | |
| 367 | + | var card = cards[kind.id] || (cards[kind.id] = makeCard(kind)); | |
| 368 | + | if (list.children[at] !== card.root) list.insertBefore(card.root, list.children[at] || null); | |
| 369 | + | fillCard(card, kind, runner); | |
| 146 | 370 | }); | |
| 371 | + | Object.keys(cards).forEach(function (id) { | |
| 372 | + | if (seen[id]) return; | |
| 373 | + | cards[id].root.remove(); | |
| 374 | + | delete cards[id]; | |
| 375 | + | }); | |
| 376 | + | list.classList.toggle("single", kinds.length === 1); | |
| 147 | 377 | } | |
| 148 | 378 | ||
| 149 | 379 | function renderRunner(runner) { | |
| 150 | 380 | if (!runner) return; | |
| 151 | 381 | var diagnosis = runner.diagnosis; | |
| 382 | + | // The whole runner's line, when the cards don't already say it. | |
| 383 | + | var cardsSayIt = diagnosis.in_capabilities && diagnosis.capabilities && diagnosis.capabilities.length > 0; | |
| 384 | + | byId("runner-diagnosis").hidden = !!cardsSayIt; | |
| 152 | 385 | byId("runner-dot").className = "dot " + diagnosis.tone; | |
| 153 | 386 | byId("runner-title").textContent = diagnosis.title; | |
| 154 | − | byId("runner-cause").textContent = diagnosis.cause || ""; | |
| 155 | − | byId("runner-fix").textContent = diagnosis.fix || ""; | |
| 387 | + | prose(byId("runner-cause"), diagnosis.cause); | |
| 388 | + | prose(byId("runner-fix"), diagnosis.fix); | |
| 156 | 389 | var actions = byId("runner-actions"); | |
| 157 | 390 | actions.textContent = ""; | |
| 158 | 391 | diagnosis.actions.forEach(function (action, at) { | |
| 159 | 392 | actions.appendChild(actionButton(action, at === 0)); | |
| 160 | 393 | }); | |
| 161 | − | renderCapabilities(diagnosis.capabilities); | |
| 394 | + | renderKinds(runner); | |
| 162 | 395 | byId("runner").checked = !!runner.enabled; | |
| 163 | − | var on = runner.enabled && !runner.paused; | |
| 164 | − | byId("runner-toggle").textContent = on ? "Stop" : "Start"; | |
| 165 | − | byId("runner-toggle").dataset.action = on ? "stop" : "start"; | |
| 166 | 396 | ||
| 167 | 397 | var backend = byId("runnerBackend"); | |
| 168 | 398 | backend.value = runner.backend; | |
| ⋯ | |||
| 192 | 422 | ||
| 193 | 423 | byId("runnerBackend").addEventListener("change", save); | |
| 194 | 424 | byId("runnerWorkspace").addEventListener("change", save); | |
| 195 | − | byId("runner-toggle").addEventListener("click", function (event) { | |
| 196 | − | invoke("runner_control", { action: event.target.dataset.action || "start" }).catch(fail); | |
| 197 | − | }); | |
| 198 | 425 | byId("runner-view").addEventListener("click", function () { | |
| 199 | 426 | invoke("runner_control", { action: "view_runners" }).catch(fail); | |
| 200 | 427 | }); | |
| ⋯ | |||
| 288 | 515 | events | |
| 289 | 516 | .listen("g1t-runner-status", function (event) { | |
| 290 | 517 | if (current) current.runnerView = event.payload; | |
| 518 | + | pending = {}; | |
| 291 | 519 | renderRunner(event.payload); | |
| 292 | 520 | }) | |
| 293 | 521 | .catch(function () {}); | |
| 153 | 153 | error: answer.error == null ? null : String(answer.error), | |
| 154 | 154 | }); | |
| 155 | 155 | }, | |
| 156 | − | // Start, stop, or a diagnosis's fix, by its id. | |
| 157 | − | act: function (action) { | |
| 158 | − | return invoke("runner_control", { action: String(action) }); | |
| 156 | + | // Start, stop, or a diagnosis's fix, by its id; Start and Stop of one | |
| 157 | + | // kind of job alone (`linux`, `windows`) with its id as `capability`. | |
| 158 | + | act: function (action, capability) { | |
| 159 | + | var args = { action: String(action) }; | |
| 160 | + | if (capability != null) args.capability = String(capability); | |
| 161 | + | return invoke("runner_control", args); | |
| 159 | 162 | }, | |
| 160 | 163 | // The app wants a registration token for a workspace. | |
| 161 | 164 | onWanted: function (listener) { |
| 289 | 289 | } | |
| 290 | 290 | ||
| 291 | 291 | /// A button for the runner: Start, Stop, or a diagnosis's fix | |
| 292 | − | /// (runner/diagnosis.rs), from settings or the site's card. | |
| 292 | + | /// (runner/diagnosis.rs), from settings or the site's card. With a kind | |
| 293 | + | /// of job (`capability`: `linux`, `windows`), Start and Stop are its alone. | |
| 293 | 294 | #[tauri::command] | |
| 294 | − | pub fn runner_control(app: AppHandle, webview: Webview, action: String) -> Result<(), String> { | |
| 295 | + | pub fn runner_control(app: AppHandle, webview: Webview, action: String, capability: Option<String>) -> Result<(), String> { | |
| 295 | 296 | from_site(&app, &webview).or_else(|_| from_settings(&webview))?; | |
| 296 | − | runner::act(&app, &action) | |
| 297 | + | runner::act(&app, &action, capability.as_deref()) | |
| 297 | 298 | } | |
| 298 | 299 | ||
| 299 | 300 | #[cfg(test)] |
| 11 | 11 | //! | |
| 12 | 12 | //! The sandbox (launcher.rs) says why something is wrong in its own | |
| 13 | 13 | //! sentences. Those this catalogue knows (WSL not installed, virtualization | |
| 14 | − | //! off, Windows Sandbox off, …) are said in its words, with a fix and its | |
| 15 | − | //! buttons; any other is shown as the launcher wrote it. With the sandbox, | |
| 16 | − | //! each kind of job it runs (Linux jobs, Windows jobs) has a line of its | |
| 17 | − | //! own as well (`capabilities`), since one can run while the other cannot. | |
| 14 | + | //! off, Windows Sandbox off, a release without the sandbox's images, …) | |
| 15 | + | //! are said in its words, with a fix and its buttons; any other is shown as | |
| 16 | + | //! the launcher wrote it. With the sandbox, each kind of job it runs (Linux | |
| 17 | + | //! jobs, Windows jobs) is a card of its own (`capabilities`), with its own | |
| 18 | + | //! state, cause, fix and Start or Stop, since one can run while the other | |
| 19 | + | //! cannot, or is stopped. The whole runner's diagnosis then only says what | |
| 20 | + | //! the cards don't (`in_capabilities` is false): signed out, no workspace, | |
| 21 | + | //! not allowed, no launcher, the whole sandbox down. | |
| 18 | 22 | ||
| 19 | 23 | use serde::Serialize; | |
| 20 | 24 | ||
| ⋯ | |||
| 29 | 33 | Off, | |
| 30 | 34 | /// The person pressed Stop. | |
| 31 | 35 | Stopped, | |
| 36 | + | /// Sandboxed, the person stopped every kind of job there is here. | |
| 37 | + | KindsStopped, | |
| 32 | 38 | Starting, | |
| 33 | 39 | /// Waiting for the site to make a registration token. | |
| 34 | 40 | Registering, | |
| ⋯ | |||
| 54 | 60 | NoVfkit, | |
| 55 | 61 | /// Linux, with neither Sysbox in Docker nor KVM. | |
| 56 | 62 | NoSysbox, | |
| 63 | + | /// The newest release is published without the sandbox's images yet. | |
| 64 | + | WaitingForImage, | |
| 57 | 65 | /// The sandbox's image couldn't be downloaded, or isn't the release's. | |
| 58 | 66 | ImageFailed, | |
| 59 | 67 | /// The sandbox can't be made here, for a reason in the launcher's words. | |
| ⋯ | |||
| 97 | 105 | /// What to do about it. | |
| 98 | 106 | pub fix: Option<String>, | |
| 99 | 107 | pub actions: Vec<Action>, | |
| 100 | − | /// With the sandbox: each kind of job it runs, on its own line. | |
| 108 | + | /// With the sandbox: each kind of job it runs, as a card of its own. | |
| 101 | 109 | #[serde(skip_serializing_if = "Vec::is_empty")] | |
| 102 | 110 | pub capabilities: Vec<CapabilityLine>, | |
| 111 | + | /// The cards say all of this, each for its kind of job: a page that | |
| 112 | + | /// shows them shows no line of the whole runner's as well. | |
| 113 | + | #[serde(skip_serializing_if = "is_false")] | |
| 114 | + | pub in_capabilities: bool, | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | fn is_false(value: &bool) -> bool { | |
| 118 | + | !*value | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | /// How one kind of job is, as its card's label says it. | |
| 122 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] | |
| 123 | + | #[serde(rename_all = "snake_case")] | |
| 124 | + | pub enum KindState { | |
| 125 | + | /// Running a job. | |
| 126 | + | Busy, | |
| 127 | + | /// Waiting for jobs. | |
| 128 | + | Idle, | |
| 129 | + | Starting, | |
| 130 | + | /// Downloading the sandbox's image, which `progress` says how far. | |
| 131 | + | Downloading, | |
| 132 | + | Updating, | |
| 133 | + | /// Waiting for something g1t sees to by itself, such as the release's images. | |
| 134 | + | Waiting, | |
| 135 | + | /// Stopped: by the person (`enabled` is false), or with the whole runner. | |
| 136 | + | Stopped, | |
| 137 | + | /// This computer can't run it, and `cause` says why. | |
| 138 | + | Unavailable, | |
| 139 | + | /// It should run and doesn't: `cause` and `fix`. | |
| 140 | + | Problem, | |
| 103 | 141 | } | |
| 104 | 142 | ||
| 105 | 143 | /// One kind of job the sandbox runs, `linux` or `windows` (or `macos`), | |
| ⋯ | |||
| 110 | 148 | /// `Linux jobs`. | |
| 111 | 149 | pub label: String, | |
| 112 | 150 | pub tone: Tone, | |
| 113 | − | /// `Waiting for jobs`, `Not available`. | |
| 151 | + | pub state: KindState, | |
| 152 | + | /// Its state in a few words, for its label: `Waiting for jobs`, `Not available`. | |
| 114 | 153 | pub title: String, | |
| 154 | + | /// What it is, in a line: `A Linux VM of its own, each job in a fresh container`. | |
| 155 | + | pub about: String, | |
| 156 | + | /// What is wrong, in a few words, over its cause: `WSL isn't installed`. | |
| 157 | + | pub headline: Option<String>, | |
| 115 | 158 | pub cause: Option<String>, | |
| 116 | 159 | pub fix: Option<String>, | |
| 117 | 160 | pub actions: Vec<Action>, | |
| 161 | + | /// On in settings: the person hasn't stopped it. | |
| 162 | + | pub enabled: bool, | |
| 163 | + | /// This computer can run it. | |
| 164 | + | pub available: bool, | |
| 165 | + | /// It can be started and stopped on its own here (the launcher can). | |
| 166 | + | pub can_toggle: bool, | |
| 167 | + | /// What it is running now. | |
| 168 | + | pub job: Option<JobLine>, | |
| 169 | + | /// How far its image has downloaded, in percent. | |
| 170 | + | pub progress: Option<u8>, | |
| 171 | + | /// How it keeps jobs from the computer: `nested-container`, `vm`. | |
| 172 | + | pub isolation: Option<String>, | |
| 173 | + | /// Its runner's name on g1t: `build-box-linux`. | |
| 174 | + | pub runner: Option<String>, | |
| 175 | + | /// Its runner's version. | |
| 176 | + | pub version: Option<String>, | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | /// The job a kind of job is running, with where to see it on g1t. | |
| 180 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize)] | |
| 181 | + | pub struct JobLine { | |
| 182 | + | /// `workflow` or `agent`. | |
| 183 | + | pub kind: String, | |
| 184 | + | pub name: String, | |
| 185 | + | pub repo: String, | |
| 186 | + | /// The repository's Actions (or Agents) page, where its run is. | |
| 187 | + | pub link: Option<String>, | |
| 118 | 188 | } | |
| 119 | 189 | ||
| 120 | 190 | /// What the words are filled in with. | |
| ⋯ | |||
| 130 | 200 | pub wait_seconds: Option<u64>, | |
| 131 | 201 | /// The server's host: `g1t.sh`. | |
| 132 | 202 | pub server: String, | |
| 203 | + | /// The server's address, for links: `https://g1t.sh`. | |
| 204 | + | pub site: String, | |
| 133 | 205 | pub platform: String, | |
| 134 | 206 | /// What the sandbox says to do, for a cause in its own words. | |
| 135 | 207 | pub fix: Option<String>, | |
| 136 | 208 | /// How the sandbox is, when jobs run sandboxed. | |
| 137 | 209 | pub sandbox: Option<Status>, | |
| 210 | + | /// The kinds of job the person stopped in settings. | |
| 211 | + | pub off: Vec<String>, | |
| 212 | + | /// The sandbox is up, or on its way: a kind of job that should run | |
| 213 | + | /// and doesn't is failing, rather than stopped with everything else. | |
| 214 | + | pub live: bool, | |
| 215 | + | /// The kinds of job a launcher command is starting now. | |
| 216 | + | pub starting: Vec<String>, | |
| 217 | + | /// How far the sandbox's image has downloaded, while it does. | |
| 218 | + | pub download: Option<launcher::Download>, | |
| 219 | + | /// The person stopped the whole runner, or turned it off. | |
| 220 | + | pub held: bool, | |
| 138 | 221 | } | |
| 139 | 222 | ||
| 140 | 223 | const START: Action = Action { id: "start", label: "Start" }; | |
| 141 | 224 | const TURN_ON: Action = Action { id: "start", label: "Turn on" }; | |
| 142 | 225 | const RETRY: Action = Action { id: "retry", label: "Retry" }; | |
| 226 | + | const CHECK_NOW: Action = Action { id: "retry", label: "Check now" }; | |
| 143 | 227 | const RESTART: Action = Action { id: "restart", label: "Restart" }; | |
| 144 | 228 | const SIGN_IN: Action = Action { id: "sign_in", label: "Sign in" }; | |
| 145 | 229 | const CHOOSE_WORKSPACE: Action = Action { id: "choose_workspace", label: "Choose workspace" }; | |
| ⋯ | |||
| 160 | 244 | Some(text.into()) | |
| 161 | 245 | } | |
| 162 | 246 | ||
| 247 | + | /// A wait, said: `30 seconds`, `5 minutes`. | |
| 248 | + | fn said_wait(seconds: u64) -> String { | |
| 249 | + | match seconds { | |
| 250 | + | 0..=1 => "a moment".into(), | |
| 251 | + | 2..=89 => format!("{seconds} seconds"), | |
| 252 | + | _ => format!("{} minutes", seconds.div_ceil(60)), | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | ||
| 163 | 256 | /// The catalogue. | |
| 164 | 257 | pub fn diagnose(code: Code, facts: &Facts) -> Diagnosis { | |
| 165 | 258 | let workspace = facts.workspace.as_deref().unwrap_or("your workspace"); | |
| ⋯ | |||
| 179 | 272 | None, | |
| 180 | 273 | vec![START], | |
| 181 | 274 | ), | |
| 275 | + | Code::KindsStopped => ( | |
| 276 | + | Tone::Off, | |
| 277 | + | "Not running: every kind of job is stopped".into(), | |
| 278 | + | some("You stopped each kind of job this computer can run."), | |
| 279 | + | some("Start one to run jobs here again."), | |
| 280 | + | vec![], | |
| 281 | + | ), | |
| 182 | 282 | Code::Starting => (Tone::Working, "Starting".into(), detail, None, vec![]), | |
| 183 | 283 | Code::Registering => ( | |
| 184 | 284 | Tone::Working, | |
| ⋯ | |||
| 212 | 312 | Tone::Problem, | |
| 213 | 313 | format!("Couldn't register with {workspace}"), | |
| 214 | 314 | detail.or_else(|| some("g1t didn't make a registration for this computer.")), | |
| 215 | − | some("It tries again by itself in a few minutes."), | |
| 315 | + | some("g1t asks again by itself in a few minutes; Retry asks now. Its log says more."), | |
| 216 | 316 | vec![RETRY, VIEW_LOG], | |
| 217 | 317 | ), | |
| 218 | 318 | Code::Idle => ( | |
| ⋯ | |||
| 246 | 346 | Tone::Warning, | |
| 247 | 347 | "Not running: this g1t-runner has no sandbox".into(), | |
| 248 | 348 | some("The g1t-runner g1t downloaded is older than its sandbox launcher, so it can't make a sandbox."), | |
| 249 | − | some("g1t looks for a newer one by itself; Retry looks now. Or choose to run jobs in Docker or directly on this computer: neither is a sandbox, and settings say what each can reach."), | |
| 349 | + | some("g1t looks for a newer one by itself every few minutes; Retry looks now. Or choose to run jobs in Docker or directly on this computer: neither is a sandbox, and settings say what each can reach."), | |
| 250 | 350 | vec![RETRY, CHOOSE_BACKEND], | |
| 251 | 351 | ), | |
| 252 | 352 | Code::NoWsl => ( | |
| ⋯ | |||
| 288 | 388 | some("Install Sysbox 0.7 or later, or turn on virtualization and install QEMU so /dev/kvm exists. The runner starts by itself once one is there."), | |
| 289 | 389 | vec![INSTALL_SYSBOX, CHOOSE_BACKEND], | |
| 290 | 390 | ), | |
| 291 | − | Code::ImageFailed => ( | |
| 292 | − | Tone::Problem, | |
| 293 | − | "Not running: the sandbox's image didn't download".into(), | |
| 294 | − | detail.or_else(|| some("The sandbox's image couldn't be downloaded, or wasn't the release's, so it wasn't used.")), | |
| 295 | − | some("It's only ever used once its signature and SHA-256 are the release's. g1t tries again by itself; check the connection, or report it if it keeps happening."), | |
| 296 | − | vec![RETRY, REPORT], | |
| 297 | − | ), | |
| 391 | + | Code::WaitingForImage => { | |
| 392 | + | let (release, image) = detail.as_deref().and_then(launcher::missing_images).map_or((None, None), |(release, image)| (Some(release), image)); | |
| 393 | + | let release = release.map_or_else(|| "The newest g1t-runner".to_owned(), |release| format!("g1t-runner {release}")); | |
| 394 | + | ( | |
| 395 | + | Tone::Warning, | |
| 396 | + | "Waiting for the sandbox's image".into(), | |
| 397 | + | some(match image { | |
| 398 | + | Some(image) => format!("{release} is published without the sandbox's image for this kind of computer ({image}) yet, so Linux jobs can't start."), | |
| 399 | + | None => format!("{release} is published without the sandbox's images yet, so Linux jobs can't start."), | |
| 400 | + | }), | |
| 401 | + | some("g1t checks again every few minutes and starts Linux jobs once they're out. There's nothing for you to do."), | |
| 402 | + | vec![CHECK_NOW], | |
| 403 | + | ) | |
| 404 | + | } | |
| 405 | + | Code::ImageFailed => { | |
| 406 | + | let said = detail.clone().unwrap_or_default().to_ascii_lowercase(); | |
| 407 | + | if said.contains("sha-256") || said.contains("signature") { | |
| 408 | + | ( | |
| 409 | + | Tone::Problem, | |
| 410 | + | "Not running: the sandbox's image didn't check out".into(), | |
| 411 | + | some("The sandbox's image that downloaded isn't the one g1t's release signed, so it wasn't used."), | |
| 412 | + | some(format!( | |
| 413 | + | "g1t downloads it again by itself{}. If this keeps happening, something between this computer and {} is changing downloads, such as a proxy or antivirus: report it.", | |
| 414 | + | facts.wait_seconds.map(|wait| format!(" in {}", said_wait(wait))).unwrap_or_default(), | |
| 415 | + | facts.server | |
| 416 | + | )), | |
| 417 | + | vec![RETRY, REPORT], | |
| 418 | + | ) | |
| 419 | + | } else { | |
| 420 | + | ( | |
| 421 | + | Tone::Problem, | |
| 422 | + | "Not running: the sandbox's image didn't download".into(), | |
| 423 | + | some(match &detail { | |
| 424 | + | Some(detail) => format!("The sandbox's image couldn't be downloaded: {detail}"), | |
| 425 | + | None => "The sandbox's image couldn't be downloaded.".into(), | |
| 426 | + | }), | |
| 427 | + | some(format!( | |
| 428 | + | "g1t tries again by itself{}. Check that this computer can reach {}; Retry tries now.", | |
| 429 | + | facts.wait_seconds.map(|wait| format!(" in {}", said_wait(wait))).unwrap_or_default(), | |
| 430 | + | facts.server | |
| 431 | + | )), | |
| 432 | + | vec![RETRY, VIEW_LOG], | |
| 433 | + | ) | |
| 434 | + | } | |
| 435 | + | } | |
| 298 | 436 | Code::SandboxUnavailable => ( | |
| 299 | 437 | Tone::Problem, | |
| 300 | 438 | "Not running: the sandbox can't run here".into(), | |
| ⋯ | |||
| 306 | 444 | Tone::Problem, | |
| 307 | 445 | "Not running: the sandbox failed".into(), | |
| 308 | 446 | detail.or_else(|| some("The sandbox stopped, and didn't say why.")), | |
| 309 | − | Some(match facts.wait_seconds { | |
| 310 | − | Some(wait) => format!("It starts again by itself in {wait} seconds. Retry now, or look at its log."), | |
| 311 | − | None => "Retry, or look at its log.".into(), | |
| 447 | + | some(match facts.wait_seconds { | |
| 448 | + | Some(wait) => format!("It starts again by itself in {}. If it keeps stopping, its log says why; Retry starts it now.", said_wait(wait)), | |
| 449 | + | None => "Retry starts it again; its log says why it stopped.".into(), | |
| 312 | 450 | }), | |
| 313 | 451 | vec![RETRY, VIEW_LOG], | |
| 314 | 452 | ), | |
| ⋯ | |||
| 324 | 462 | Tone::Warning, | |
| 325 | 463 | "Update failed".into(), | |
| 326 | 464 | detail, | |
| 327 | − | some("The runner keeps running the version it has. Try again, or report it if it keeps happening."), | |
| 465 | + | some("The runner keeps running the version it has, and g1t tries the update again in a few hours. Retry tries now; if it keeps failing, report it."), | |
| 328 | 466 | vec![RETRY, REPORT], | |
| 329 | 467 | ), | |
| 330 | 468 | Code::Offline => ( | |
| ⋯ | |||
| 342 | 480 | }, | |
| 343 | 481 | detail, | |
| 344 | 482 | Some(match facts.wait_seconds { | |
| 345 | − | Some(wait) => format!("It starts again by itself in {wait} seconds. Restart now, or look at its log."), | |
| 346 | − | None => "Restart it, or look at its log.".into(), | |
| 483 | + | Some(wait) => format!("It starts again by itself in {}. If it keeps stopping, its log says why; Restart starts it now.", said_wait(wait)), | |
| 484 | + | None => "Restart starts it again; its log says why it stopped.".into(), | |
| 347 | 485 | }), | |
| 348 | 486 | vec![RESTART, VIEW_LOG], | |
| 349 | 487 | ), | |
| 350 | 488 | }; | |
| 351 | 489 | let capabilities = facts.sandbox.as_ref().map(|status| capabilities(status, facts)).unwrap_or_default(); | |
| 352 | − | Diagnosis { code, tone, title, cause, fix, actions, capabilities } | |
| 490 | + | let in_capabilities = !capabilities.is_empty() | |
| 491 | + | && match code { | |
| 492 | + | Code::Idle | Code::Busy | Code::KindsStopped => true, | |
| 493 | + | Code::Starting | Code::Updating => facts.live, | |
| 494 | + | // Said by a card, for its kind of job, word for word. | |
| 495 | + | Code::NoWsl | |
| 496 | + | | Code::WslMirrored | |
| 497 | + | | Code::NoVirtualization | |
| 498 | + | | Code::NoVfkit | |
| 499 | + | | Code::NoSysbox | |
| 500 | + | | Code::WaitingForImage | |
| 501 | + | | Code::ImageFailed | |
| 502 | + | | Code::SandboxUnavailable | |
| 503 | + | | Code::SandboxFailed => cause.is_some() && capabilities.iter().any(|c| c.cause == cause), | |
| 504 | + | _ => false, | |
| 505 | + | }; | |
| 506 | + | Diagnosis { code, tone, title, cause, fix, actions, capabilities, in_capabilities } | |
| 353 | 507 | } | |
| 354 | 508 | ||
| 355 | 509 | /// What the app calls a sandbox's problem with Linux jobs, from the | |
| ⋯ | |||
| 361 | 515 | Why::NoVirtualization => Code::NoVirtualization, | |
| 362 | 516 | Why::NoVfkit => Code::NoVfkit, | |
| 363 | 517 | Why::NoSysboxOrKvm => Code::NoSysbox, | |
| 518 | + | Why::NoImages => Code::WaitingForImage, | |
| 364 | 519 | Why::Image => Code::ImageFailed, | |
| 365 | 520 | _ => Code::SandboxFailed, | |
| 366 | 521 | } | |
| ⋯ | |||
| 377 | 532 | Some((code, cause, fix)) | |
| 378 | 533 | } | |
| 379 | 534 | ||
| 380 | − | /// Each kind of job the sandbox runs, on a line of its own. | |
| 535 | + | /// Each kind of job the sandbox runs, as a card of its own. | |
| 381 | 536 | fn capabilities(status: &Status, facts: &Facts) -> Vec<CapabilityLine> { | |
| 382 | 537 | let listed = if status.capabilities.is_empty() { vec![status.linux()] } else { status.capabilities.clone() }; | |
| 383 | 538 | listed.iter().map(|capability| capability_line(status, capability, facts)).collect() | |
| 384 | 539 | } | |
| 385 | 540 | ||
| 386 | − | fn capability_line(status: &Status, capability: &launcher::Capability, facts: &Facts) -> CapabilityLine { | |
| 387 | − | let label = match capability.id.as_str() { | |
| 541 | + | /// `Linux jobs`. | |
| 542 | + | pub fn kind_label(id: &str) -> String { | |
| 543 | + | match id { | |
| 388 | 544 | "linux" => "Linux jobs".to_owned(), | |
| 389 | 545 | "windows" => "Windows jobs".to_owned(), | |
| 390 | 546 | "macos" => "macOS jobs".to_owned(), | |
| 391 | 547 | other => format!("{other} jobs"), | |
| 392 | − | }; | |
| 393 | − | let line = |tone: Tone, title: String, cause: Option<String>, fix: Option<String>, actions: Vec<Action>| CapabilityLine { | |
| 394 | − | id: capability.id.clone(), | |
| 548 | + | } | |
| 549 | + | } | |
| 550 | + | ||
| 551 | + | /// What a kind of job is, in a line, by how the sandbox makes it. | |
| 552 | + | fn about(id: &str, backend: Option<&str>) -> String { | |
| 553 | + | match (id, backend) { | |
| 554 | + | ("linux", Some("sysbox")) => "The runner under Sysbox, each job in a fresh container".into(), | |
| 555 | + | ("linux", _) => "A Linux VM of its own, each job in a fresh container".into(), | |
| 556 | + | ("windows", _) => "Each job in a Windows Sandbox, discarded after".into(), | |
| 557 | + | ("macos", _) => "Each job in a macOS VM of its own, discarded after".into(), | |
| 558 | + | _ => "Each job sandboxed".into(), | |
| 559 | + | } | |
| 560 | + | } | |
| 561 | + | ||
| 562 | + | /// A catalogue title as a card's headline: `Not running: virtualization is off` is `Virtualization is off`. | |
| 563 | + | fn headline(title: &str) -> String { | |
| 564 | + | let said = title.strip_prefix("Not running: ").unwrap_or(title); | |
| 565 | + | let mut letters = said.chars(); | |
| 566 | + | letters.next().map(|first| first.to_uppercase().chain(letters).collect()).unwrap_or_default() | |
| 567 | + | } | |
| 568 | + | ||
| 569 | + | fn capability_line(status: &Status, capability: &launcher::Capability, facts: &Facts) -> CapabilityLine { | |
| 570 | + | let id = capability.id.as_str(); | |
| 571 | + | let label = kind_label(id); | |
| 572 | + | let enabled = !facts.off.iter().any(|off| off == id); | |
| 573 | + | let per_kind = status.per_kind(); | |
| 574 | + | let mut line = CapabilityLine { | |
| 575 | + | id: id.to_owned(), | |
| 395 | 576 | label: label.clone(), | |
| 396 | − | tone, | |
| 397 | − | title, | |
| 398 | − | cause, | |
| 399 | − | fix, | |
| 400 | − | actions, | |
| 577 | + | tone: Tone::Off, | |
| 578 | + | state: KindState::Stopped, | |
| 579 | + | title: "Stopped".into(), | |
| 580 | + | about: about(id, status.backend.as_deref()), | |
| 581 | + | headline: None, | |
| 582 | + | cause: None, | |
| 583 | + | fix: None, | |
| 584 | + | actions: vec![], | |
| 585 | + | enabled, | |
| 586 | + | available: capability.available, | |
| 587 | + | // Nothing to start or stop where it can't run, nor while the whole runner is | |
| 588 | + | // stopped, whose own Start starts it (one the person stopped can be started). | |
| 589 | + | can_toggle: per_kind && (capability.available || !enabled) && !(facts.held && enabled), | |
| 590 | + | job: None, | |
| 591 | + | progress: None, | |
| 592 | + | isolation: capability.isolation.clone().filter(|_| capability.available), | |
| 593 | + | runner: capability.available.then(|| format!("{}-{id}", facts.machine)), | |
| 594 | + | version: capability.runner_version.clone(), | |
| 401 | 595 | }; | |
| 402 | − | let title = if capability.available { "Not running" } else { "Not available" }; | |
| 403 | − | if capability.id == "linux" { | |
| 404 | − | if let Some((code, cause, fix)) = sandbox_trouble(status) { | |
| 405 | − | // The same words as the whole runner's when Linux jobs are what is wrong. | |
| 406 | − | let said = diagnose(code, &Facts { detail: Some(cause), fix, wait_seconds: None, sandbox: None, ..facts.clone() }); | |
| 407 | − | return line(Tone::Problem, title.into(), said.cause, said.fix, said.actions); | |
| 596 | + | let said = |line: &mut CapabilityLine, state: KindState, tone: Tone, cause: Option<String>, fix: Option<String>, actions: Vec<Action>| { | |
| 597 | + | line.state = state; | |
| 598 | + | line.tone = tone; | |
| 599 | + | line.title = match state { | |
| 600 | + | KindState::Busy => "Running a job", | |
| 601 | + | KindState::Idle => "Waiting for jobs", | |
| 602 | + | KindState::Starting => "Starting", | |
| 603 | + | KindState::Downloading => "Downloading image", | |
| 604 | + | KindState::Updating => "Updating", | |
| 605 | + | KindState::Waiting => "Waiting for image", | |
| 606 | + | KindState::Stopped => "Stopped", | |
| 607 | + | KindState::Unavailable => "Not available", | |
| 608 | + | KindState::Problem => "Problem", | |
| 408 | 609 | } | |
| 409 | − | } else if !capability.available || capability.state == State::Error { | |
| 610 | + | .into(); | |
| 611 | + | line.cause = cause; | |
| 612 | + | line.fix = fix; | |
| 613 | + | line.actions = actions; | |
| 614 | + | }; | |
| 615 | + | let again = |what: &str| format!("g1t {what} again by itself in a few minutes; Retry {what} now."); | |
| 616 | + | let starting = facts.starting.iter().any(|kind| kind == id) || matches!(capability.state, State::Starting); | |
| 617 | + | ||
| 618 | + | // Stopped by the person: nothing is wrong with it. | |
| 619 | + | if capability.off() || (!enabled && per_kind) { | |
| 620 | + | said(&mut line, KindState::Stopped, Tone::Off, some(format!("You stopped {label} on this computer. They stay stopped until you start them.")), None, vec![]); | |
| 621 | + | return line; | |
| 622 | + | } | |
| 623 | + | // This computer can't run it, and why. | |
| 624 | + | if !capability.available { | |
| 625 | + | if id == "linux" | |
| 626 | + | && let Some((code, cause, fix)) = sandbox_trouble(status) | |
| 627 | + | { | |
| 628 | + | let whole = diagnose(code, &Facts { detail: Some(cause), fix, sandbox: None, ..facts.clone() }); | |
| 629 | + | said(&mut line, KindState::Unavailable, Tone::Warning, whole.cause, whole.fix, whole.actions); | |
| 630 | + | line.headline = Some(headline(&whole.title)); | |
| 631 | + | return line; | |
| 632 | + | } | |
| 410 | 633 | let cause = capability.cause.clone(); | |
| 411 | − | return match cause.as_deref().map(launcher::why) { | |
| 412 | − | Some(Why::WindowsSandboxOff) => line( | |
| 413 | − | Tone::Off, | |
| 414 | − | title.into(), | |
| 415 | − | some("Windows Sandbox is off, or this edition of Windows has none: Home doesn't."), | |
| 416 | − | some("Linux jobs still run. For Windows jobs, on Windows 11 Pro, Enterprise or Education, turn on Windows Sandbox in Windows features, then restart."), | |
| 634 | + | let (headline, cause, fix, actions) = match cause.as_deref().map(launcher::why) { | |
| 635 | + | Some(Why::WindowsSandboxOff) => ( | |
| 636 | + | "Windows Sandbox is off", | |
| 637 | + | some("Windows Sandbox is turned off, or this edition of Windows has none: Home doesn't."), | |
| 638 | + | some("On Windows 11 Pro, Enterprise or Education, turn on Windows Sandbox in Windows features, then restart."), | |
| 417 | 639 | vec![WINDOWS_FEATURES], | |
| 418 | 640 | ), | |
| 419 | − | Some(Why::NoWsb) => line( | |
| 420 | − | Tone::Off, | |
| 421 | − | title.into(), | |
| 641 | + | Some(Why::NoWsb) => ( | |
| 642 | + | "Windows needs an update", | |
| 422 | 643 | some("This Windows has Windows Sandbox, but not its wsb command, which came with Windows 11 24H2."), | |
| 423 | − | some("Linux jobs still run. For Windows jobs, update Windows to 24H2 or later."), | |
| 644 | + | some("Update Windows to 24H2 or later, then restart g1t."), | |
| 424 | 645 | vec![], | |
| 425 | 646 | ), | |
| 426 | − | Some(Why::MacNotReleased) => line(Tone::Off, title.into(), some("Sandboxed macOS jobs aren't released yet."), some("Linux jobs still run."), vec![]), | |
| 427 | − | _ => line(if capability.available { Tone::Problem } else { Tone::Off }, title.into(), cause, capability.fix.clone(), vec![]), | |
| 647 | + | Some(Why::MacNotReleased) => ( | |
| 648 | + | "Not released yet", | |
| 649 | + | some("Sandboxed macOS jobs aren't released yet."), | |
| 650 | + | some("They come with a g1t-runner update, which g1t downloads by itself. There's nothing for you to do."), | |
| 651 | + | vec![], | |
| 652 | + | ), | |
| 653 | + | _ => ("Can't run here", cause, capability.fix.clone(), vec![LEARN_MORE]), | |
| 428 | 654 | }; | |
| 655 | + | said(&mut line, KindState::Unavailable, Tone::Off, cause, fix, actions); | |
| 656 | + | line.headline = Some(headline.into()); | |
| 657 | + | return line; | |
| 658 | + | } | |
| 659 | + | // Linux jobs that should run and don't, in the catalogue's words. | |
| 660 | + | if id == "linux" | |
| 661 | + | && !starting | |
| 662 | + | && !matches!(capability.state, State::Updating) | |
| 663 | + | && let Some((code, cause, fix)) = sandbox_trouble(status) | |
| 664 | + | { | |
| 665 | + | let wait_seconds = if facts.live { None } else { facts.wait_seconds }; | |
| 666 | + | let whole = diagnose(code, &Facts { detail: Some(cause), fix, wait_seconds, sandbox: None, ..facts.clone() }); | |
| 667 | + | let state = if code == Code::WaitingForImage { KindState::Waiting } else { KindState::Problem }; | |
| 668 | + | said(&mut line, state, whole.tone, whole.cause, whole.fix, whole.actions); | |
| 669 | + | line.headline = Some(headline(&whole.title)); | |
| 670 | + | return line; | |
| 429 | 671 | } | |
| 672 | + | // A native kind of job whose runner didn't start, in the launcher's words. | |
| 673 | + | if id != "linux" && capability.state == State::Error && !starting { | |
| 674 | + | let cause = capability.cause.clone().or_else(|| some("Its runner didn't start, and didn't say why.")); | |
| 675 | + | said(&mut line, KindState::Problem, Tone::Problem, cause, some(again("tries")), vec![RETRY, VIEW_LOG]); | |
| 676 | + | line.headline = Some("Its runner didn't start".into()); | |
| 677 | + | return line; | |
| 678 | + | } | |
| 679 | + | let downloading = facts.download.as_ref().filter(|_| id == "linux"); | |
| 430 | 680 | match capability.state { | |
| 681 | + | _ if starting || capability.state == State::Updating => match downloading { | |
| 682 | + | Some(download) => { | |
| 683 | + | said(&mut line, KindState::Downloading, Tone::Working, some("Downloading and checking the sandbox's image, once: it takes a few minutes."), None, vec![]); | |
| 684 | + | line.progress = Some(download.percent); | |
| 685 | + | } | |
| 686 | + | None if capability.state == State::Updating => said(&mut line, KindState::Updating, Tone::Working, some("Taking the newest sandbox image; it takes jobs again when it's done."), None, vec![]), | |
| 687 | + | None if capability.state == State::Absent => said(&mut line, KindState::Starting, Tone::Working, some(format!("Registering it with {}.", facts.workspace.as_deref().unwrap_or("your workspace"))), None, vec![]), | |
| 688 | + | None => said(&mut line, KindState::Starting, Tone::Working, None, None, vec![]), | |
| 689 | + | }, | |
| 431 | 690 | State::Running => match &capability.job { | |
| 432 | − | Some(job) => line(Tone::Working, format!("Running {}", job.said()), None, None, vec![]), | |
| 433 | − | None => line(Tone::Ok, "Waiting for jobs".into(), None, None, vec![]), | |
| 691 | + | Some(job) => { | |
| 692 | + | said(&mut line, KindState::Busy, Tone::Working, None, None, vec![]); | |
| 693 | + | let page = if job.kind == "agent" { "agents" } else { "actions" }; | |
| 694 | + | let link = (!job.repo.is_empty() && !facts.site.is_empty()).then(|| format!("{}/{}/{page}", facts.site.trim_end_matches('/'), job.repo)); | |
| 695 | + | line.job = Some(JobLine { kind: job.kind.clone(), name: job.name.clone(), repo: job.repo.clone(), link }); | |
| 696 | + | } | |
| 697 | + | None => said(&mut line, KindState::Idle, Tone::Ok, None, None, vec![]), | |
| 434 | 698 | }, | |
| 435 | − | State::Starting => line(Tone::Working, "Starting".into(), None, None, vec![]), | |
| 436 | − | State::Updating => line(Tone::Working, "Updating".into(), None, None, vec![]), | |
| 437 | − | State::Stopped => line(Tone::Off, "Stopped".into(), None, None, vec![]), | |
| 438 | − | State::Absent => line(Tone::Off, "Not set up yet".into(), None, None, vec![]), | |
| 439 | − | State::Error | State::Unknown => line(Tone::Off, "Not running".into(), capability.cause.clone(), capability.fix.clone(), vec![]), | |
| 699 | + | // Up while the others run, yet not running: it stopped by itself, | |
| 700 | + | // or is to be registered. | |
| 701 | + | State::Stopped | State::Absent if facts.live && status.running() => { | |
| 702 | + | if capability.state == State::Absent { | |
| 703 | + | said(&mut line, KindState::Starting, Tone::Working, some(format!("Registering it with {}.", facts.workspace.as_deref().unwrap_or("your workspace"))), None, vec![]); | |
| 704 | + | } else { | |
| 705 | + | said(&mut line, KindState::Problem, Tone::Problem, some("Its runner stopped by itself."), some(again("starts it")), vec![RETRY, VIEW_LOG]); | |
| 706 | + | line.headline = Some("Its runner stopped".into()); | |
| 707 | + | } | |
| 708 | + | } | |
| 709 | + | State::Stopped | State::Absent => said(&mut line, KindState::Stopped, Tone::Off, None, None, vec![]), | |
| 710 | + | State::Error | State::Unknown | State::Starting | State::Updating => { | |
| 711 | + | said(&mut line, KindState::Problem, Tone::Problem, capability.cause.clone().or_else(|| status.error.clone()), capability.fix.clone(), vec![RETRY, VIEW_LOG]); | |
| 712 | + | } | |
| 713 | + | } | |
| 714 | + | // The person stopped it, and this launcher can't stop one kind alone yet. | |
| 715 | + | if !enabled && line.fix.is_none() { | |
| 716 | + | line.fix = some(format!("This g1t-runner can't stop {label} on their own; the next one can, and g1t downloads it by itself.")); | |
| 440 | 717 | } | |
| 718 | + | line | |
| 441 | 719 | } | |
| 442 | 720 | ||
| 443 | 721 | /// What the site says of this computer while g1t is closed on it. | |
| ⋯ | |||
| 450 | 728 | fix: some(format!("Open g1t on {} to run jobs there again.", facts.machine)), | |
| 451 | 729 | actions: vec![], | |
| 452 | 730 | capabilities: vec![], | |
| 731 | + | in_capabilities: false, | |
| 453 | 732 | } | |
| 454 | 733 | } | |
| 455 | 734 | ||
| ⋯ | |||
| 458 | 737 | use super::*; | |
| 459 | 738 | ||
| 460 | 739 | fn facts() -> Facts { | |
| 461 | − | Facts { workspace: Some("acme".into()), backend: "sandboxed", machine: "build-box".into(), server: "g1t.sh".into(), platform: "windows".into(), ..Facts::default() } | |
| 740 | + | Facts { | |
| 741 | + | workspace: Some("acme".into()), | |
| 742 | + | backend: "sandboxed", | |
| 743 | + | machine: "build-box".into(), | |
| 744 | + | server: "g1t.sh".into(), | |
| 745 | + | site: "https://g1t.sh".into(), | |
| 746 | + | platform: "windows".into(), | |
| 747 | + | ..Facts::default() | |
| 748 | + | } | |
| 462 | 749 | } | |
| 463 | 750 | ||
| 464 | 751 | #[test] | |
| 465 | 752 | fn every_failure_says_why_and_how_to_fix_it() { | |
| 466 | − | for code in [Code::SignedOut, Code::NoWorkspace, Code::NotAllowed, Code::RegisterFailed, Code::NoDocker, | |
| 467 | − | Code::NoLauncher, Code::NoWsl, Code::WslMirrored, Code::NoVirtualization, Code::NoVfkit, Code::NoSysbox, Code::ImageFailed, Code::SandboxUnavailable, Code::SandboxFailed, | |
| 468 | − | Code::SandboxTaken, Code::UpdateFailed, Code::Offline, Code::Crashed, | |
| 753 | + | for code in [ | |
| 754 | + | Code::SignedOut, | |
| 755 | + | Code::NoWorkspace, | |
| 756 | + | Code::NotAllowed, | |
| 757 | + | Code::RegisterFailed, | |
| 758 | + | Code::NoDocker, | |
| 759 | + | Code::NoLauncher, | |
| 760 | + | Code::NoWsl, | |
| 761 | + | Code::WslMirrored, | |
| 762 | + | Code::NoVirtualization, | |
| 763 | + | Code::NoVfkit, | |
| 764 | + | Code::NoSysbox, | |
| 765 | + | Code::WaitingForImage, | |
| 766 | + | Code::ImageFailed, | |
| 767 | + | Code::SandboxUnavailable, | |
| 768 | + | Code::SandboxFailed, | |
| 769 | + | Code::SandboxTaken, | |
| 770 | + | Code::UpdateFailed, | |
| 771 | + | Code::Offline, | |
| 772 | + | Code::Crashed, | |
| 469 | 773 | ] { | |
| 470 | 774 | let diagnosis = diagnose(code, &Facts { detail: Some("why".into()), ..facts() }); | |
| 471 | 775 | assert!(diagnosis.cause.is_some(), "{code:?} has no cause"); | |
| 472 | 776 | assert!(diagnosis.fix.is_some(), "{code:?} has no fix"); | |
| 473 | 777 | assert!(!diagnosis.actions.is_empty(), "{code:?} has no action"); | |
| 474 | 778 | assert!(matches!(diagnosis.tone, Tone::Warning | Tone::Problem), "{code:?}"); | |
| 779 | + | // No fix sends the person off to check their connection or report it | |
| 780 | + | // when that isn't what is wrong. | |
| 781 | + | let fix = diagnosis.fix.unwrap(); | |
| 782 | + | if !matches!(code, Code::Offline | Code::ImageFailed) { | |
| 783 | + | assert!(!fix.contains("check the connection") && !fix.contains("Check the connection"), "{code:?}: {fix}"); | |
| 784 | + | } | |
| 475 | 785 | } | |
| 476 | 786 | } | |
| 477 | 787 | ||
| ⋯ | |||
| 485 | 795 | assert_eq!(busy.cause.as_deref(), Some("Running sandboxed for acme on build-box.")); | |
| 486 | 796 | let crashed = diagnose(Code::Crashed, &Facts { exit_code: Some(101), wait_seconds: Some(30), ..facts() }); | |
| 487 | 797 | assert_eq!(crashed.title, "Runner stopped (exit code 101)"); | |
| 798 | + | assert_eq!(crashed.fix.as_deref(), Some("It starts again by itself in 30 seconds. If it keeps stopping, its log says why; Restart starts it now.")); | |
| 488 | 799 | assert_eq!(crashed.actions.iter().map(|a| a.id).collect::<Vec<_>>(), ["restart", "view_log"]); | |
| 489 | 800 | assert_eq!(diagnose(Code::NoDocker, &facts()).actions[0].id, "open_docker"); | |
| 490 | 801 | assert_eq!(closed(&facts()).title, "g1t is closed on build-box"); | |
| 802 | + | assert_eq!(said_wait(300), "5 minutes"); | |
| 803 | + | assert_eq!(said_wait(45), "45 seconds"); | |
| 491 | 804 | } | |
| 492 | 805 | ||
| 493 | 806 | fn status(json: &str) -> Status { | |
| ⋯ | |||
| 507 | 820 | let status = status(NO_WSL_PRO); | |
| 508 | 821 | let (code, cause, _) = sandbox_trouble(&status).unwrap(); | |
| 509 | 822 | assert_eq!(code, Code::NoWsl); | |
| 510 | − | let diagnosis = diagnose(code, &Facts { detail: Some(cause), sandbox: Some(status), ..facts() }); | |
| 823 | + | let diagnosis = diagnose(code, &Facts { detail: Some(cause), sandbox: Some(status), live: true, ..facts() }); | |
| 511 | 824 | assert_eq!(diagnosis.title, "Not running: WSL isn't installed"); | |
| 512 | 825 | assert_eq!(diagnosis.fix.as_deref(), Some("Run `wsl --install --no-distribution` in an admin terminal, then restart Windows. It works on every edition of Windows 11, Home included.")); | |
| 513 | 826 | assert_eq!(diagnosis.actions[0], COPY_WSL_INSTALL); | |
| 514 | 827 | let [linux, windows] = &diagnosis.capabilities[..] else { panic!("{:?}", diagnosis.capabilities) }; | |
| 515 | − | assert_eq!((linux.label.as_str(), linux.title.as_str(), linux.tone), ("Linux jobs", "Not available", Tone::Problem)); | |
| 828 | + | assert_eq!((linux.label.as_str(), linux.title.as_str(), linux.state), ("Linux jobs", "Not available", KindState::Unavailable)); | |
| 829 | + | assert_eq!(linux.headline.as_deref(), Some("WSL isn't installed")); | |
| 516 | 830 | assert_eq!(linux.fix, diagnosis.fix); | |
| 517 | 831 | assert_eq!(linux.actions[0].id, "copy_wsl_install"); | |
| 518 | 832 | assert_eq!((windows.label.as_str(), windows.title.as_str(), windows.tone), ("Windows jobs", "Waiting for jobs", Tone::Ok)); | |
| 833 | + | assert_eq!(windows.runner.as_deref(), Some("build-box-windows")); | |
| 834 | + | assert_eq!(windows.about, "Each job in a Windows Sandbox, discarded after"); | |
| 835 | + | // The Linux card says it all: the whole runner's line isn't shown as well. | |
| 836 | + | assert!(diagnosis.in_capabilities); | |
| 837 | + | // An older launcher can't stop one kind of job alone. | |
| 838 | + | assert!(!windows.can_toggle); | |
| 519 | 839 | } | |
| 520 | 840 | ||
| 521 | 841 | #[test] | |
| ⋯ | |||
| 524 | 844 | r#"{"schema":1,"state":"running","backend":"wsl2","runner_version":"0.3.0","launcher_version":"0.3.0", | |
| 525 | 845 | "job":{"kind":"workflow","id":"job_1","name":"build","repo":"acme/web"}, | |
| 526 | 846 | "capabilities":[ | |
| 527 | − | {"id":"linux","available":true,"state":"running","runner_version":"0.3.0","job":{"kind":"workflow","id":"job_1","name":"build","repo":"acme/web"}}, | |
| 528 | − | {"id":"windows","available":false,"state":"absent","cause":"Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).","fix":"On Windows 11 Pro…"}]}"#, | |
| 847 | + | {"id":"linux","available":true,"state":"running","runner_version":"0.3.0","isolation":"nested-container","turned_off":false,"job":{"kind":"workflow","id":"job_1","name":"build","repo":"acme/web"}}, | |
| 848 | + | {"id":"windows","available":false,"state":"absent","turned_off":false,"cause":"Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).","fix":"On Windows 11 Pro…"}]}"#, | |
| 529 | 849 | ); | |
| 530 | 850 | assert_eq!(sandbox_trouble(&status), None); | |
| 531 | − | let busy = diagnose(Code::Busy, &Facts { job: status.job().map(launcher::Job::said), sandbox: Some(status), ..facts() }); | |
| 851 | + | let busy = diagnose(Code::Busy, &Facts { job: status.job().map(launcher::Job::said), sandbox: Some(status), live: true, ..facts() }); | |
| 532 | 852 | assert_eq!(busy.title, "Running build (acme/web)"); | |
| 853 | + | assert!(busy.in_capabilities); | |
| 533 | 854 | let [linux, windows] = &busy.capabilities[..] else { panic!() }; | |
| 534 | − | assert_eq!((linux.title.as_str(), linux.tone), ("Running build (acme/web)", Tone::Working)); | |
| 535 | − | assert_eq!((windows.title.as_str(), windows.tone), ("Not available", Tone::Off)); | |
| 536 | − | assert_eq!(windows.cause.as_deref(), Some("Windows Sandbox is off, or this edition of Windows has none: Home doesn't.")); | |
| 537 | − | assert!(windows.fix.as_deref().unwrap().starts_with("Linux jobs still run.")); | |
| 855 | + | assert_eq!((linux.title.as_str(), linux.state, linux.tone), ("Running a job", KindState::Busy, Tone::Working)); | |
| 856 | + | let job = linux.job.as_ref().unwrap(); | |
| 857 | + | assert_eq!((job.name.as_str(), job.repo.as_str(), job.link.as_deref()), ("build", "acme/web", Some("https://g1t.sh/acme/web/actions"))); | |
| 858 | + | assert!(linux.can_toggle && linux.enabled); | |
| 859 | + | assert_eq!((windows.title.as_str(), windows.tone, windows.state), ("Not available", Tone::Off, KindState::Unavailable)); | |
| 860 | + | assert_eq!(windows.headline.as_deref(), Some("Windows Sandbox is off")); | |
| 861 | + | assert_eq!(windows.cause.as_deref(), Some("Windows Sandbox is turned off, or this edition of Windows has none: Home doesn't.")); | |
| 538 | 862 | assert_eq!(windows.actions[0].id, "windows_features"); | |
| 863 | + | // Nothing to start or stop where it can't run. | |
| 864 | + | assert!(!windows.can_toggle); | |
| 865 | + | assert_eq!(windows.runner, None); | |
| 866 | + | } | |
| 867 | + | ||
| 868 | + | /// 0.3.0 was published without the sandbox's images: Linux jobs wait for | |
| 869 | + | /// them, plainly, while Windows jobs run. | |
| 870 | + | const NO_IMAGES: &str = r#"{"schema":1,"state":"error","backend":"wsl2","launcher_version":"0.3.0", | |
| 871 | + | "error":"Windows jobs are sandboxed and running, but Linux jobs are not: release 0.3.0 has no sandbox images", | |
| 872 | + | "capabilities":[ | |
| 873 | + | {"id":"linux","available":true,"state":"error","isolation":"nested-container","turned_off":false}, | |
| 874 | + | {"id":"windows","available":true,"state":"running","isolation":"vm","runner_version":"0.3.0","turned_off":false}]}"#; | |
| 875 | + | ||
| 876 | + | #[test] | |
| 877 | + | fn a_release_without_images_is_waited_for_not_reported() { | |
| 878 | + | let status = status(NO_IMAGES); | |
| 879 | + | let (code, cause, _) = sandbox_trouble(&status).unwrap(); | |
| 880 | + | assert_eq!(code, Code::WaitingForImage); | |
| 881 | + | let whole = diagnose(code, &Facts { detail: Some(cause), ..facts() }); | |
| 882 | + | assert_eq!(whole.title, "Waiting for the sandbox's image"); | |
| 883 | + | assert_eq!(whole.cause.as_deref(), Some("g1t-runner 0.3.0 is published without the sandbox's images yet, so Linux jobs can't start.")); | |
| 884 | + | assert_eq!(whole.fix.as_deref(), Some("g1t checks again every few minutes and starts Linux jobs once they're out. There's nothing for you to do.")); | |
| 885 | + | assert_eq!(whole.actions, [CHECK_NOW]); | |
| 886 | + | assert!(!whole.fix.unwrap().contains("report")); | |
| 887 | + | // Windows jobs run: the runner waits for jobs, and the Linux card says what it waits for. | |
| 888 | + | let idle = diagnose(Code::Idle, &Facts { sandbox: Some(status), live: true, ..facts() }); | |
| 889 | + | assert!(idle.in_capabilities); | |
| 890 | + | let [linux, windows] = &idle.capabilities[..] else { panic!() }; | |
| 891 | + | assert_eq!((linux.state, linux.title.as_str(), linux.tone), (KindState::Waiting, "Waiting for image", Tone::Warning)); | |
| 892 | + | assert_eq!(linux.headline.as_deref(), Some("Waiting for the sandbox's image")); | |
| 893 | + | assert_eq!(windows.state, KindState::Idle); | |
| 894 | + | let image = diagnose(Code::ImageFailed, &Facts { detail: Some("the sandbox image wsl-x64.tar.gz's SHA-256 is not the release's; not using it".into()), ..facts() }); | |
| 895 | + | assert_eq!(image.title, "Not running: the sandbox's image didn't check out"); | |
| 896 | + | let fetch = diagnose(Code::ImageFailed, &Facts { detail: Some("could not fetch https://g1t.sh/x: dns error".into()), wait_seconds: Some(60), ..facts() }); | |
| 897 | + | assert_eq!(fetch.cause.as_deref(), Some("The sandbox's image couldn't be downloaded: could not fetch https://g1t.sh/x: dns error")); | |
| 898 | + | assert_eq!(fetch.fix.as_deref(), Some("g1t tries again by itself in 60 seconds. Check that this computer can reach g1t.sh; Retry tries now.")); | |
| 539 | 899 | } | |
| 540 | 900 | ||
| 541 | 901 | #[test] | |
| 902 | + | fn a_kind_the_person_stopped_says_so_and_is_not_failing() { | |
| 903 | + | let status = status( | |
| 904 | + | r#"{"schema":1,"state":"running","backend":"wsl2","capabilities":[ | |
| 905 | + | {"id":"linux","available":true,"state":"running","turned_off":false}, | |
| 906 | + | {"id":"windows","available":true,"state":"stopped","turned_off":true}]}"#, | |
| 907 | + | ); | |
| 908 | + | let idle = diagnose(Code::Idle, &Facts { sandbox: Some(status.clone()), live: true, off: vec!["windows".into()], ..facts() }); | |
| 909 | + | let [linux, windows] = &idle.capabilities[..] else { panic!() }; | |
| 910 | + | assert_eq!(linux.state, KindState::Idle); | |
| 911 | + | assert_eq!((windows.state, windows.tone, windows.enabled), (KindState::Stopped, Tone::Off, false)); | |
| 912 | + | assert_eq!(windows.cause.as_deref(), Some("You stopped Windows jobs on this computer. They stay stopped until you start them.")); | |
| 913 | + | assert!(windows.can_toggle && windows.actions.is_empty()); | |
| 914 | + | // Stopped by itself while Linux jobs run is a problem, with Retry. | |
| 915 | + | let died = launcher::parse(&serde_json::to_string(&serde_json::json!({"schema":1,"state":"running","capabilities":[ | |
| 916 | + | {"id":"linux","available":true,"state":"running","turned_off":false}, | |
| 917 | + | {"id":"windows","available":true,"state":"stopped","turned_off":false}]})).unwrap()).unwrap(); | |
| 918 | + | let idle = diagnose(Code::Idle, &Facts { sandbox: Some(died.clone()), live: true, ..facts() }); | |
| 919 | + | assert_eq!((idle.capabilities[1].state, idle.capabilities[1].actions[0].id), (KindState::Problem, "retry")); | |
| 920 | + | // With the whole runner stopped, it is only stopped. | |
| 921 | + | let stopped = diagnose(Code::Stopped, &Facts { sandbox: Some(died), live: false, held: true, ..facts() }); | |
| 922 | + | // Its own Start or Stop waits for the runner's: the line above the cards starts it. | |
| 923 | + | assert!(!stopped.capabilities[0].can_toggle); | |
| 924 | + | assert_eq!(stopped.capabilities[1].state, KindState::Stopped); | |
| 925 | + | assert!(!stopped.in_capabilities); | |
| 926 | + | } | |
| 927 | + | ||
| 928 | + | #[test] | |
| 929 | + | fn a_download_shows_how_far_it_is() { | |
| 930 | + | let status = status(r#"{"schema":1,"state":"starting","capabilities":[{"id":"linux","available":true,"state":"starting","turned_off":false},{"id":"windows","available":true,"state":"absent","turned_off":false}]}"#); | |
| 931 | + | let facts = Facts { sandbox: Some(status), live: true, starting: vec!["linux".into(), "windows".into()], download: Some(launcher::Download { key: "wsl-x64".into(), percent: 40 }), ..facts() }; | |
| 932 | + | let starting = diagnose(Code::Starting, &facts); | |
| 933 | + | assert!(starting.in_capabilities); | |
| 934 | + | let [linux, windows] = &starting.capabilities[..] else { panic!() }; | |
| 935 | + | assert_eq!((linux.state, linux.title.as_str(), linux.progress), (KindState::Downloading, "Downloading image", Some(40))); | |
| 936 | + | assert_eq!((windows.state, windows.progress), (KindState::Starting, None)); | |
| 937 | + | } | |
| 938 | + | ||
| 939 | + | #[test] | |
| 542 | 940 | fn each_launcher_trouble_has_its_words() { | |
| 543 | 941 | let trouble = |linux_available: bool, text: &str| { | |
| 544 | 942 | let json = serde_json::json!({ | |
| ⋯ | |||
| 552 | 950 | assert_eq!(trouble(false, "This machine has neither Sysbox nor KVM, so there is nothing to sandbox the runner with.").0, Code::NoSysbox); | |
| 553 | 951 | assert_eq!(trouble(false, "WSL is set to mirrored networking, in which Linux reaches this computer's own localhost.").0, Code::WslMirrored); | |
| 554 | 952 | assert_eq!(trouble(true, "the sandbox image wsl-x64.tar.gz's SHA-256 is not the release's; not using it").0, Code::ImageFailed); | |
| 953 | + | assert_eq!(trouble(true, "release 0.3.0 has no sandbox images").0, Code::WaitingForImage); | |
| 555 | 954 | // Unknown words: shown as the launcher wrote them, its fix with them. | |
| 556 | 955 | let (code, cause, fix) = trouble(false, "This user may not use /dev/kvm."); | |
| 557 | 956 | assert_eq!(code, Code::SandboxUnavailable); | |
| ⋯ | |||
| 562 | 961 | assert_eq!(code, Code::SandboxFailed); | |
| 563 | 962 | let failed = diagnose(code, &Facts { detail: Some(cause), wait_seconds: Some(60), ..facts() }); | |
| 564 | 963 | assert_eq!(failed.cause.as_deref(), Some("the sandbox did not start within 600 seconds")); | |
| 565 | − | assert_eq!(failed.fix.as_deref(), Some("It starts again by itself in 60 seconds. Retry now, or look at its log.")); | |
| 566 | − | let image = diagnose(Code::ImageFailed, &Facts { detail: Some("could not fetch https://g1t.sh/x: dns error".into()), ..facts() }); | |
| 567 | − | assert_eq!(image.cause.as_deref(), Some("could not fetch https://g1t.sh/x: dns error")); | |
| 964 | + | assert_eq!(failed.fix.as_deref(), Some("It starts again by itself in 60 seconds. If it keeps stopping, its log says why; Retry starts it now.")); | |
| 568 | 965 | assert_eq!(diagnose(Code::NoVirtualization, &Facts { platform: "macos".into(), ..facts() }).actions[0].id, "retry"); | |
| 569 | 966 | assert_eq!(diagnose(Code::NoVfkit, &facts()).actions[0].id, "copy_vfkit_install"); | |
| 570 | 967 | } | |
| ⋯ | |||
| 573 | 970 | fn a_diagnosis_without_the_sandbox_lists_no_capabilities() { | |
| 574 | 971 | let json = serde_json::to_value(diagnose(Code::Idle, &Facts { backend: "in Docker", ..facts() })).unwrap(); | |
| 575 | 972 | assert!(json.get("capabilities").is_none()); | |
| 973 | + | assert!(json.get("in_capabilities").is_none()); | |
| 576 | 974 | let sandboxed = serde_json::to_value(diagnose(Code::Idle, &Facts { sandbox: Some(status(NO_WSL_PRO)), ..facts() })).unwrap(); | |
| 577 | 975 | assert_eq!(sandboxed["capabilities"][1]["id"], "windows"); | |
| 976 | + | assert_eq!(sandboxed["capabilities"][1]["state"], "idle"); | |
| 977 | + | assert_eq!(sandboxed["in_capabilities"], true); | |
| 978 | + | // What affects everything is said once, above the cards. | |
| 979 | + | let signed_out = diagnose(Code::SignedOut, &Facts { sandbox: Some(status(NO_WSL_PRO)), ..facts() }); | |
| 980 | + | assert!(!signed_out.in_capabilities); | |
| 578 | 981 | } | |
| 579 | 982 | } | |
| 20 | 20 | //! - `down` as g1t quits or the person stops it; `down --drain` when they | |
| 21 | 21 | //! choose another way of running jobs; `down --delete` when the | |
| 22 | 22 | //! registration is no longer the one wanted. | |
| 23 | + | //! - `up --only linux` and `down --only windows` when the person starts | |
| 24 | + | //! or stops one kind of job in settings: the other is left as it is. | |
| 25 | + | //! Every `up` names the kinds that are on (`--only`), so the launcher | |
| 26 | + | //! never starts one the person stopped. A launcher from before `--only` | |
| 27 | + | //! says no `turned_off` in its status, and is never sent `down --only`, | |
| 28 | + | //! which it would read as `down`, stopping everything. | |
| 23 | 29 | //! - `update`, while nothing runs: the newest signed image, the same | |
| 24 | 30 | //! registration. | |
| 25 | 31 | //! | |
| ⋯ | |||
| 45 | 51 | pub const WSL_INSTALL: &str = "wsl --install --no-distribution"; | |
| 46 | 52 | /// What installs vfkit on a Mac. | |
| 47 | 53 | pub const VFKIT_INSTALL: &str = "brew install vfkit"; | |
| 54 | + | /// How often the app looks again for a release with the sandbox's images, | |
| 55 | + | /// while the newest is published without them. | |
| 56 | + | pub const IMAGES_AGAIN: Duration = Duration::from_secs(5 * 60); | |
| 48 | 57 | ||
| 49 | 58 | /// A sandbox's, or one capability's, state. | |
| 50 | 59 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)] | |
| ⋯ | |||
| 95 | 104 | pub cause: Option<String>, | |
| 96 | 105 | /// What to do about it, in a sentence. | |
| 97 | 106 | pub fix: Option<String>, | |
| 107 | + | /// The person stopped it (`down --only`): stopped, not failing. None | |
| 108 | + | /// from a launcher before it could stop one kind of job on its own. | |
| 109 | + | pub turned_off: Option<bool>, | |
| 98 | 110 | } | |
| 99 | 111 | ||
| 112 | + | impl Capability { | |
| 113 | + | pub fn off(&self) -> bool { | |
| 114 | + | self.turned_off == Some(true) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 100 | 118 | /// `g1t-runner sandbox status --json`. The top-level fields are the Linux | |
| 101 | 119 | /// VM's. Fields the app does not use (`resources`, `state_dir`) are left | |
| 102 | 120 | /// out, and any a newer launcher adds are ignored. | |
| ⋯ | |||
| 144 | 162 | if self.capabilities.is_empty() { self.linux().available } else { self.capabilities.iter().any(|c| c.available) } | |
| 145 | 163 | } | |
| 146 | 164 | ||
| 147 | − | /// A capability that can be had here but was never set up: registering | |
| 148 | − | /// it needs a token. | |
| 165 | + | /// A capability that can be had here, and is on, but was never set | |
| 166 | + | /// up: registering it needs a token. | |
| 149 | 167 | pub fn unregistered(&self) -> bool { | |
| 150 | − | let wanting = |c: &Capability| c.available && c.state == State::Absent; | |
| 168 | + | let wanting = |c: &Capability| c.available && !c.off() && c.state == State::Absent; | |
| 151 | 169 | if self.capabilities.is_empty() { wanting(&self.linux()) } else { self.capabilities.iter().any(wanting) } | |
| 152 | 170 | } | |
| 153 | 171 | ||
| 172 | + | /// Whether this launcher starts and stops each kind of job on its own | |
| 173 | + | /// (`--only`), which it says by saying which are turned off. | |
| 174 | + | pub fn per_kind(&self) -> bool { | |
| 175 | + | !self.capabilities.is_empty() && self.capabilities.iter().all(|c| c.turned_off.is_some()) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | /// Whether everything that could run here was turned off by the person. | |
| 179 | + | pub fn all_off(&self) -> bool { | |
| 180 | + | let available: Vec<&Capability> = self.capabilities.iter().filter(|c| c.available || c.off()).collect(); | |
| 181 | + | !available.is_empty() && available.iter().all(|c| c.off()) | |
| 182 | + | } | |
| 183 | + | ||
| 154 | 184 | /// What a runner is running, the Linux one's first. | |
| 155 | 185 | pub fn job(&self) -> Option<&Job> { | |
| 156 | 186 | self.job.as_ref().or_else(|| self.capabilities.iter().find_map(|c| c.job.as_ref())) | |
| ⋯ | |||
| 168 | 198 | /// else why the capability is unavailable. | |
| 169 | 199 | pub fn trouble(&self) -> Option<(String, Option<String>)> { | |
| 170 | 200 | let linux = self.linux(); | |
| 201 | + | // Stopped by the person: nothing is wrong with it. | |
| 202 | + | if linux.off() { | |
| 203 | + | return None; | |
| 204 | + | } | |
| 171 | 205 | if !linux.available { | |
| 172 | 206 | let cause = linux.cause.or_else(|| self.error.clone())?; | |
| 173 | 207 | return Some((cause, linux.fix)); | |
| ⋯ | |||
| 203 | 237 | line.starts_with('{') | |
| 204 | 238 | } | |
| 205 | 239 | ||
| 240 | + | /// How far the sandbox's image has downloaded, from the launcher's log. | |
| 241 | + | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 242 | + | pub struct Download { | |
| 243 | + | /// `wsl-x64`. | |
| 244 | + | pub key: String, | |
| 245 | + | pub percent: u8, | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | /// Reads `Fetching the sandbox's wsl-x64 image (612 MB)`, the start of a | |
| 249 | + | /// download, and `Fetching the sandbox's wsl-x64 image: 40% of 612 MB`, | |
| 250 | + | /// each tenth of it (the launcher's image.rs), after the log's time. | |
| 251 | + | pub fn download(line: &str) -> Option<Download> { | |
| 252 | + | let (_, rest) = line.split_once("Fetching the sandbox's ")?; | |
| 253 | + | let (key, rest) = rest.split_once(" image")?; | |
| 254 | + | let percent = match rest.strip_prefix(": ") { | |
| 255 | + | Some(said) => said.split_once('%')?.0.trim().parse::<u8>().ok()?.min(100), | |
| 256 | + | None => 0, | |
| 257 | + | }; | |
| 258 | + | Some(Download { key: key.to_owned(), percent }) | |
| 259 | + | } | |
| 260 | + | ||
| 206 | 261 | /// What `up` is told the first time, and again on every start (the | |
| 207 | 262 | /// launcher keeps what it was told, and `--token` only matters once). | |
| 208 | 263 | #[derive(Clone, PartialEq, Eq)] | |
| ⋯ | |||
| 212 | 267 | pub labels: &'a str, | |
| 213 | 268 | /// The registration token: only for a sandbox that has no registration yet. | |
| 214 | 269 | pub token: Option<&'a str>, | |
| 270 | + | /// The kinds of job to start (`--only`): those the person has on. | |
| 271 | + | pub only: Vec<&'a str>, | |
| 215 | 272 | } | |
| 216 | 273 | ||
| 217 | 274 | // Never print a token, in a log or a panic. | |
| 218 | 275 | impl std::fmt::Debug for Up<'_> { | |
| 219 | 276 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 220 | − | f.debug_struct("Up").field("url", &self.url).field("name", &self.name).field("labels", &self.labels).field("token", &self.token.map(|_| "…")).finish() | |
| 277 | + | f.debug_struct("Up") | |
| 278 | + | .field("url", &self.url) | |
| 279 | + | .field("name", &self.name) | |
| 280 | + | .field("labels", &self.labels) | |
| 281 | + | .field("token", &self.token.map(|_| "…")) | |
| 282 | + | .field("only", &self.only) | |
| 283 | + | .finish() | |
| 221 | 284 | } | |
| 222 | 285 | } | |
| 223 | 286 | ||
| ⋯ | |||
| 236 | 299 | args.push(name.into()); | |
| 237 | 300 | args.push(value.into()); | |
| 238 | 301 | } | |
| 302 | + | if !up.only.is_empty() { | |
| 303 | + | args.push("--only".into()); | |
| 304 | + | args.push(up.only.join(",").into()); | |
| 305 | + | } | |
| 239 | 306 | args | |
| 240 | 307 | } | |
| 241 | 308 | ||
| ⋯ | |||
| 268 | 335 | args | |
| 269 | 336 | } | |
| 270 | 337 | ||
| 338 | + | /// `down --only <kind>`: that kind of job stops, and stays off; the others | |
| 339 | + | /// are left as they are. Only for a launcher whose status is [`Status::per_kind`]. | |
| 340 | + | pub fn down_only_args(dir: &Path, kind: &str) -> Vec<OsString> { | |
| 341 | + | let mut args = args("down", dir); | |
| 342 | + | args.push("--only".into()); | |
| 343 | + | args.push(kind.into()); | |
| 344 | + | args | |
| 345 | + | } | |
| 346 | + | ||
| 271 | 347 | pub fn status_args(dir: &Path) -> Vec<OsString> { | |
| 272 | 348 | args("status", dir) | |
| 273 | 349 | } | |
| ⋯ | |||
| 293 | 369 | /// Runs a short launcher command to its end and reads its status, which | |
| 294 | 370 | /// it prints whether it worked or not. | |
| 295 | 371 | fn run(binary: &Path, args: Vec<OsString>) -> Result<Status, String> { | |
| 372 | + | run_done(binary, args).map(|(status, _)| status) | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | /// [`run`], and whether the command did what it was asked (its exit code). | |
| 376 | + | fn run_done(binary: &Path, args: Vec<OsString>) -> Result<(Status, bool), String> { | |
| 296 | 377 | let out = command(binary, args).output().map_err(|e| format!("g1t-runner could not start: {e}"))?; | |
| 297 | 378 | let printed = String::from_utf8_lossy(&out.stdout); | |
| 298 | 379 | match parse(&printed) { | |
| 299 | − | Ok(status) => Ok(status), | |
| 380 | + | Ok(status) => Ok((status, out.status.success())), | |
| 300 | 381 | Err(_) if !out.status.success() && !out.stderr.is_empty() => Err(String::from_utf8_lossy(&out.stderr).trim().trim_start_matches("g1t-runner: ").to_owned()), | |
| 301 | 382 | Err(why) => Err(why), | |
| 302 | 383 | } | |
| ⋯ | |||
| 307 | 388 | run(binary, status_args(dir)) | |
| 308 | 389 | } | |
| 309 | 390 | ||
| 310 | − | /// Stops the sandbox, waiting for it. | |
| 311 | − | pub fn down(binary: &Path, dir: &Path, how: Down) -> Result<(), String> { | |
| 391 | + | /// Stops the sandbox, waiting for it: how it is now. | |
| 392 | + | pub fn down(binary: &Path, dir: &Path, how: Down) -> Result<Status, String> { | |
| 393 | + | down_with(binary, down_args(dir, how)) | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | /// Stops one kind of job, and keeps it off, waiting for it. | |
| 397 | + | pub fn down_only(binary: &Path, dir: &Path, kind: &str) -> Result<Status, String> { | |
| 398 | + | down_with(binary, down_only_args(dir, kind)) | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | fn down_with(binary: &Path, args: Vec<OsString>) -> Result<Status, String> { | |
| 312 | 402 | if !binary.exists() { | |
| 313 | 403 | return Err("g1t-runner isn't installed".into()); | |
| 314 | 404 | } | |
| 315 | − | let status = run(binary, down_args(dir, how))?; | |
| 316 | − | match status.error { | |
| 317 | − | Some(error) => Err(error), | |
| 318 | − | None => Ok(()), | |
| 405 | + | match run_done(binary, args)? { | |
| 406 | + | (status, true) => Ok(status), | |
| 407 | + | (status, false) => Err(status.error.unwrap_or_else(|| "g1t-runner sandbox down failed.".into())), | |
| 319 | 408 | } | |
| 320 | 409 | } | |
| 321 | 410 | ||
| ⋯ | |||
| 330 | 419 | NoVfkit, | |
| 331 | 420 | /// Linux, with neither Sysbox in Docker nor KVM. | |
| 332 | 421 | NoSysboxOrKvm, | |
| 422 | + | /// The newest release is published without the sandbox's images (for | |
| 423 | + | /// this computer), so there is nothing to download yet. | |
| 424 | + | NoImages, | |
| 333 | 425 | /// The sandbox's image couldn't be downloaded, or isn't the release's. | |
| 334 | 426 | Image, | |
| 335 | 427 | /// g1t refused the registration token. | |
| ⋯ | |||
| 348 | 440 | Other, | |
| 349 | 441 | } | |
| 350 | 442 | ||
| 443 | + | /// The release a "has no sandbox images" sentence names, and the image | |
| 444 | + | /// it lacks when it is only this computer's: `release 0.3.0 has no | |
| 445 | + | /// sandbox image vm-arm64-disk for this machine`. | |
| 446 | + | pub fn missing_images(text: &str) -> Option<(String, Option<String>)> { | |
| 447 | + | let (before, after) = text.split_once(" has no sandbox image")?; | |
| 448 | + | let version = before.rsplit("release ").next()?.trim().to_owned(); | |
| 449 | + | let image = after.strip_prefix(' ').and_then(|rest| rest.split_whitespace().next()).map(str::to_owned); | |
| 450 | + | (!version.is_empty() && !version.contains(' ')).then_some((version, image)) | |
| 451 | + | } | |
| 452 | + | ||
| 351 | 453 | pub fn why(text: &str) -> Why { | |
| 352 | 454 | let lower = text.to_ascii_lowercase(); | |
| 353 | 455 | let has = |words: &str| lower.contains(words); | |
| ⋯ | |||
| 373 | 475 | Why::Forgotten | |
| 374 | 476 | } else if has("is not registered") { | |
| 375 | 477 | Why::Unregistered | |
| 478 | + | } else if has("has no sandbox image") { | |
| 479 | + | Why::NoImages | |
| 376 | 480 | } else if has("sandbox image") || has("sandbox's images") || has("no sandbox images") || has("sha-256") || has("signature") || has("release key") || has("could not fetch") || has("broke off") { | |
| 377 | 481 | Why::Image | |
| 378 | 482 | } else if has("windows sandbox is not turned on") { | |
| ⋯ | |||
| 515 | 619 | ("This machine has neither Sysbox nor KVM, so there is nothing to sandbox the runner with.", Why::NoSysboxOrKvm), | |
| 516 | 620 | ("the sandbox image wsl-x64.tar.gz's SHA-256 is not the release's; not using it", Why::Image), | |
| 517 | 621 | ("could not fetch https://g1t.sh/downloads/runner/0.3.0/wsl-x64.tar.gz: dns error", Why::Image), | |
| 518 | − | ("release 0.3.0 has no sandbox images", Why::Image), | |
| 622 | + | ("release 0.3.0 has no sandbox images", Why::NoImages), | |
| 623 | + | ("Windows jobs are sandboxed and running, but Linux jobs are not: release 0.3.0 has no sandbox images", Why::NoImages), | |
| 624 | + | ("release 0.3.0 has no sandbox image vm-arm64-disk for this machine", Why::NoImages), | |
| 519 | 625 | ("The registration token is not valid, or has expired.", Why::TokenRefused), | |
| 520 | 626 | ("g1t no longer knows this runner (401 unauthorized). Register it again.", Why::Forgotten), | |
| 521 | 627 | ("the sandbox's runner is not registered, and no --url and --token were given", Why::Unregistered), | |
| ⋯ | |||
| 528 | 634 | } | |
| 529 | 635 | ||
| 530 | 636 | #[test] | |
| 637 | + | fn a_kind_turned_off_is_stopped_by_the_person() { | |
| 638 | + | let status = parse( | |
| 639 | + | r#"{"schema":1,"state":"stopped","capabilities":[ | |
| 640 | + | {"id":"linux","available":true,"state":"stopped","turned_off":true}, | |
| 641 | + | {"id":"windows","available":true,"state":"absent","turned_off":false}]}"#, | |
| 642 | + | ) | |
| 643 | + | .unwrap(); | |
| 644 | + | assert!(status.per_kind()); | |
| 645 | + | assert!(status.capability("linux").unwrap().off()); | |
| 646 | + | assert!(!status.all_off()); | |
| 647 | + | assert_eq!(status.trouble(), None); | |
| 648 | + | // Only Windows, never set up, wants registering; Linux is off. | |
| 649 | + | assert!(status.unregistered()); | |
| 650 | + | let all = parse(r#"{"schema":1,"state":"stopped","capabilities":[{"id":"linux","available":true,"state":"stopped","turned_off":true},{"id":"windows","available":false,"state":"absent","turned_off":false}]}"#).unwrap(); | |
| 651 | + | assert!(all.all_off()); | |
| 652 | + | assert!(!all.unregistered()); | |
| 653 | + | // A launcher from before `--only` says nothing of it. | |
| 654 | + | assert!(!parse(RUNNING).unwrap().per_kind()); | |
| 655 | + | } | |
| 656 | + | ||
| 657 | + | #[test] | |
| 658 | + | fn a_download_says_how_far_it_is() { | |
| 659 | + | assert_eq!(download("2026-10-12T08:00:00Z Fetching the sandbox's wsl-x64 image (612 MB)"), Some(Download { key: "wsl-x64".into(), percent: 0 })); | |
| 660 | + | assert_eq!(download("2026-10-12T08:01:00Z Fetching the sandbox's wsl-x64 image: 40% of 612 MB"), Some(Download { key: "wsl-x64".into(), percent: 40 })); | |
| 661 | + | assert_eq!(download("2026-10-12T08:01:00Z Took build (acme/web)"), None); | |
| 662 | + | assert_eq!(missing_images("release 0.3.0 has no sandbox images"), Some(("0.3.0".into(), None))); | |
| 663 | + | assert_eq!(missing_images("Windows jobs are sandboxed and running, but Linux jobs are not: release 0.3.0 has no sandbox images"), Some(("0.3.0".into(), None))); | |
| 664 | + | assert_eq!(missing_images("release 0.4.0 has no sandbox image vm-arm64-disk for this machine"), Some(("0.4.0".into(), Some("vm-arm64-disk".into())))); | |
| 665 | + | assert_eq!(missing_images("could not fetch"), None); | |
| 666 | + | } | |
| 667 | + | ||
| 668 | + | #[test] | |
| 531 | 669 | fn the_commands_are_the_launchers() { | |
| 532 | 670 | let dir = PathBuf::from("data").join("runner").join(FOLDER); | |
| 533 | 671 | let text = |args: Vec<OsString>| args.iter().map(|a| a.to_string_lossy().into_owned()).collect::<Vec<_>>(); | |
| 534 | − | let first = Up { url: "https://g1t.sh", name: "build-box", labels: "desktop", token: Some("g1trt_secret") }; | |
| 672 | + | let first = Up { url: "https://g1t.sh", name: "build-box", labels: "desktop", token: Some("g1trt_secret"), only: vec!["linux", "windows"] }; | |
| 535 | 673 | let up = text(up_args(&dir, &first)); | |
| 536 | 674 | assert_eq!(&up[..4], ["sandbox", "up", "--json", "--state-dir"]); | |
| 537 | 675 | assert_eq!(up[4], dir.to_string_lossy()); | |
| 538 | 676 | // Never the token: it goes in the environment, out of the process list. | |
| 539 | − | assert_eq!(&up[5..], ["--url", "https://g1t.sh", "--name", "build-box", "--labels", "desktop"]); | |
| 677 | + | assert_eq!(&up[5..], ["--url", "https://g1t.sh", "--name", "build-box", "--labels", "desktop", "--only", "linux,windows"]); | |
| 678 | + | assert_eq!(text(down_only_args(&dir, "windows"))[5..], ["--only", "windows"]); | |
| 540 | 679 | assert!(!up.iter().any(|a| a == "--token" || a.starts_with("g1trt_"))); | |
| 541 | 680 | let mut command = command(Path::new("g1t-runner"), up_args(&dir, &first)); | |
| 542 | 681 | with_token(&mut command, first.token); | |
| 543 | 682 | assert!(command.get_envs().any(|(name, value)| name == "G1T_RUNNER_TOKEN" && value == Some(std::ffi::OsStr::new("g1trt_secret")))); | |
| 544 | 683 | assert!(!command.get_args().any(|a| a.to_string_lossy().contains("g1trt_"))); | |
| 545 | 684 | // Its name and labels as the app has them: the launcher adds `-linux` and the OS labels. | |
| 546 | − | assert!(!up.iter().any(|a| a.ends_with("-linux") || a.contains("windows"))); | |
| 547 | − | let again = text(up_args(&dir, &Up { token: None, ..first.clone() })); | |
| 685 | + | assert!(!up.iter().any(|a| a.ends_with("-linux"))); | |
| 686 | + | let again = text(up_args(&dir, &Up { token: None, only: vec![], ..first.clone() })); | |
| 687 | + | assert!(!again.iter().any(|a| a == "--only")); | |
| 548 | 688 | assert!(!again.iter().any(|a| a == "--token" || a.starts_with("g1trt_"))); | |
| 549 | 689 | assert!(!format!("{first:?}").contains("g1trt_secret")); | |
| 550 | 690 | assert_eq!(text(down_args(&dir, Down::Now))[..3], ["sandbox", "down", "--json"]); | |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…