Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>AWS GPU Capacity</title> | |
| <style> | |
| @import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap"); | |
| :root { | |
| color-scheme: light dark; | |
| --page: #f3f5f2; | |
| --surface: #ffffff; | |
| --surface-subtle: #f7f8f6; | |
| --surface-strong: #eef1ed; | |
| --ink: #15211d; | |
| --muted: #66736d; | |
| --muted-strong: #48554f; | |
| --line: #d9dfda; | |
| --line-strong: #c4cdc6; | |
| --accent: #214b42; | |
| --accent-soft: #e8efec; | |
| --ok: #14744a; | |
| --ok-soft: #e9f4ed; | |
| --ko: #b3453c; | |
| --ko-soft: #f7ecea; | |
| --warn: #966916; | |
| --warn-soft: #f7f1e5; | |
| --na: #7d8782; | |
| --na-soft: #f0f2f0; | |
| --row-hover: #fafbf9; | |
| --shadow: 0 18px 42px rgba(21, 33, 29, 0.08); | |
| --panel-shadow: -18px 0 42px rgba(15, 24, 20, 0.14); | |
| } | |
| @media (prefers-color-scheme: dark) { | |
| :root { | |
| --page: #111613; | |
| --surface: #171d19; | |
| --surface-subtle: #1c231f; | |
| --surface-strong: #222b25; | |
| --ink: #edf1ee; | |
| --muted: #99a59e; | |
| --muted-strong: #bdc8c1; | |
| --line: #29322d; | |
| --line-strong: #39453e; | |
| --accent: #d6e5dd; | |
| --accent-soft: #202c27; | |
| --ok: #6fc48e; | |
| --ok-soft: #18291f; | |
| --ko: #e18b83; | |
| --ko-soft: #2c201f; | |
| --warn: #d9b468; | |
| --warn-soft: #2d281d; | |
| --na: #9ba59f; | |
| --na-soft: #202622; | |
| --row-hover: #1a211d; | |
| --shadow: 0 20px 44px rgba(0, 0, 0, 0.24); | |
| --panel-shadow: -20px 0 44px rgba(0, 0, 0, 0.34); | |
| } | |
| } | |
| * { | |
| box-sizing: border-box; | |
| letter-spacing: 0; | |
| } | |
| html { | |
| min-height: 100%; | |
| } | |
| body { | |
| min-height: 100vh; | |
| margin: 0; | |
| background: var(--page); | |
| color: var(--ink); | |
| font: 14px/1.45 "IBM Plex Sans", "Avenir Next", sans-serif; | |
| } | |
| button, | |
| input { | |
| font: inherit; | |
| } | |
| a { | |
| color: var(--accent); | |
| text-decoration-color: var(--line-strong); | |
| text-underline-offset: 2px; | |
| } | |
| a:hover { | |
| text-decoration-color: currentColor; | |
| } | |
| button { | |
| color: var(--ink); | |
| } | |
| button:focus-visible, | |
| input:focus-visible { | |
| outline: 2px solid var(--accent); | |
| outline-offset: 2px; | |
| } | |
| .app { | |
| width: min(calc(100% - 48px), 1600px); | |
| margin: 0 auto; | |
| padding: 28px 0 36px; | |
| } | |
| .topbar { | |
| display: flex; | |
| align-items: flex-start; | |
| justify-content: space-between; | |
| gap: 32px; | |
| margin-bottom: 24px; | |
| } | |
| h1 { | |
| margin: 0; | |
| color: var(--ink); | |
| font-size: 29px; | |
| font-weight: 600; | |
| line-height: 1.12; | |
| } | |
| .subtitle { | |
| max-width: 640px; | |
| margin: 8px 0 0; | |
| color: var(--muted); | |
| font-size: 14px; | |
| } | |
| #authline { | |
| display: flex; | |
| align-items: center; | |
| justify-content: flex-end; | |
| min-height: 36px; | |
| color: var(--muted); | |
| font-size: 13px; | |
| text-align: right; | |
| } | |
| .session-pill { | |
| display: inline-flex; | |
| align-items: center; | |
| min-height: 36px; | |
| padding: 0 12px; | |
| border: 1px solid var(--line); | |
| border-radius: 6px; | |
| background: var(--surface); | |
| color: var(--muted-strong); | |
| white-space: nowrap; | |
| } | |
| .summary-band { | |
| display: grid; | |
| grid-template-columns: repeat(5, minmax(0, 1fr)); | |
| margin-bottom: 18px; | |
| overflow: hidden; | |
| border: 1px solid var(--line); | |
| border-radius: 8px; | |
| background: var(--surface); | |
| box-shadow: var(--shadow); | |
| } | |
| .summary-item { | |
| min-width: 0; | |
| padding: 15px 18px 14px; | |
| border-right: 1px solid var(--line); | |
| } | |
| .summary-item:last-child { | |
| border-right: 0; | |
| } | |
| .summary-label { | |
| display: block; | |
| margin-bottom: 5px; | |
| color: var(--muted); | |
| font-size: 11px; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| } | |
| .summary-value { | |
| display: block; | |
| color: var(--ink); | |
| font-size: 23px; | |
| font-weight: 600; | |
| line-height: 1.15; | |
| } | |
| .summary-value.ok { | |
| color: var(--ok); | |
| } | |
| .summary-value.ko { | |
| color: var(--ko); | |
| } | |
| .summary-value.warn { | |
| color: var(--warn); | |
| } | |
| .summary-note { | |
| display: block; | |
| margin-top: 4px; | |
| overflow: hidden; | |
| color: var(--muted); | |
| font-size: 12px; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .toolbar { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 16px; | |
| margin-bottom: 14px; | |
| } | |
| .toolbar-left, | |
| .toolbar-right { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .search-field { | |
| display: flex; | |
| align-items: center; | |
| width: 246px; | |
| height: 38px; | |
| padding: 0 11px; | |
| border: 1px solid var(--line); | |
| border-radius: 6px; | |
| background: var(--surface); | |
| transition: border-color 0.15s ease; | |
| } | |
| .search-field:focus-within { | |
| border-color: var(--line-strong); | |
| } | |
| .search-field span { | |
| margin-right: 8px; | |
| color: var(--muted); | |
| font-size: 12px; | |
| font-weight: 500; | |
| } | |
| .search-field input { | |
| min-width: 0; | |
| width: 100%; | |
| border: 0; | |
| background: transparent; | |
| color: var(--ink); | |
| outline: 0; | |
| font-size: 13px; | |
| } | |
| .search-field input::placeholder { | |
| color: var(--muted); | |
| } | |
| .toggle { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| height: 38px; | |
| padding: 0 11px; | |
| border: 1px solid var(--line); | |
| border-radius: 6px; | |
| background: var(--surface); | |
| color: var(--muted-strong); | |
| cursor: pointer; | |
| font-size: 13px; | |
| white-space: nowrap; | |
| } | |
| .toggle input { | |
| width: 14px; | |
| height: 14px; | |
| margin: 0; | |
| accent-color: var(--accent); | |
| } | |
| #addform { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| #addtype { | |
| width: 254px; | |
| height: 38px; | |
| padding: 0 12px; | |
| border: 1px solid var(--line); | |
| border-radius: 6px; | |
| background: var(--surface); | |
| color: var(--ink); | |
| font-size: 13px; | |
| } | |
| #addtype::placeholder { | |
| color: var(--muted); | |
| } | |
| .button { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| min-height: 38px; | |
| padding: 0 13px; | |
| border: 1px solid var(--line-strong); | |
| border-radius: 6px; | |
| background: var(--surface); | |
| color: var(--ink); | |
| cursor: pointer; | |
| font-size: 13px; | |
| font-weight: 500; | |
| transition: background 0.15s ease, border-color 0.15s ease; | |
| } | |
| .button:hover:not(:disabled) { | |
| border-color: var(--accent); | |
| background: var(--accent-soft); | |
| } | |
| .button.primary { | |
| border-color: var(--accent); | |
| background: var(--accent); | |
| color: var(--surface); | |
| } | |
| .button.primary:hover:not(:disabled) { | |
| background: var(--ink); | |
| } | |
| .button:disabled { | |
| opacity: 0.55; | |
| cursor: wait; | |
| } | |
| .matrix-shell { | |
| overflow: hidden; | |
| border: 1px solid var(--line); | |
| border-radius: 8px; | |
| background: var(--surface); | |
| box-shadow: var(--shadow); | |
| } | |
| .matrix-header { | |
| display: flex; | |
| align-items: flex-start; | |
| justify-content: space-between; | |
| gap: 24px; | |
| padding: 18px 20px 16px; | |
| border-bottom: 1px solid var(--line); | |
| } | |
| .matrix-title { | |
| margin: 0; | |
| font-size: 15px; | |
| font-weight: 600; | |
| } | |
| .matrix-copy { | |
| margin: 3px 0 0; | |
| color: var(--muted); | |
| font-size: 12px; | |
| } | |
| .legend { | |
| display: flex; | |
| flex-wrap: wrap; | |
| justify-content: flex-end; | |
| gap: 8px 14px; | |
| max-width: 660px; | |
| color: var(--muted); | |
| font-size: 12px; | |
| } | |
| .legend-item { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| white-space: nowrap; | |
| } | |
| .signal { | |
| display: inline-block; | |
| flex: none; | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| background: var(--na); | |
| } | |
| .signal.available { | |
| background: var(--ok); | |
| } | |
| .signal.partial { | |
| border: 1px solid var(--ok); | |
| background: linear-gradient(90deg, var(--ok) 50%, transparent 50%); | |
| } | |
| .signal.unavailable { | |
| background: var(--ko); | |
| } | |
| .signal.quota, | |
| .signal.forbidden { | |
| background: var(--warn); | |
| } | |
| .signal.unsupported, | |
| .signal.not_offered, | |
| .signal.error, | |
| .signal.never { | |
| background: var(--na); | |
| } | |
| .matrix-scroll { | |
| min-height: 430px; | |
| max-height: calc(100vh - 346px); | |
| overflow: auto; | |
| } | |
| .matrix { | |
| width: max-content; | |
| min-width: 100%; | |
| border-collapse: separate; | |
| border-spacing: 0; | |
| table-layout: fixed; | |
| } | |
| .matrix .type-col { | |
| width: 248px; | |
| } | |
| .matrix .region-col { | |
| width: 116px; | |
| } | |
| .matrix .coverage-col { | |
| width: 106px; | |
| } | |
| .matrix .action-col { | |
| width: 84px; | |
| } | |
| .matrix th, | |
| .matrix td { | |
| border-bottom: 1px solid var(--line); | |
| } | |
| .matrix thead th { | |
| background: var(--surface-subtle); | |
| color: var(--muted-strong); | |
| font-weight: 500; | |
| } | |
| .scope-row th { | |
| position: sticky; | |
| top: 0; | |
| z-index: 5; | |
| height: 34px; | |
| padding: 0 12px; | |
| border-bottom: 1px solid var(--line); | |
| color: var(--muted); | |
| font-size: 11px; | |
| font-weight: 600; | |
| text-align: left; | |
| text-transform: uppercase; | |
| } | |
| .scope-row .region-group-head { | |
| border-left: 1px solid var(--line); | |
| } | |
| .scope-row .coverage-head { | |
| border-left: 1px solid var(--line); | |
| } | |
| .region-row th { | |
| position: sticky; | |
| top: 34px; | |
| z-index: 5; | |
| height: 60px; | |
| padding: 9px 10px 8px; | |
| border-left: 1px solid var(--line); | |
| text-align: left; | |
| } | |
| .type-head { | |
| position: sticky ; | |
| left: 0; | |
| z-index: 7 ; | |
| padding-left: 20px ; | |
| vertical-align: bottom; | |
| } | |
| .coverage-head, | |
| .action-head { | |
| vertical-align: bottom; | |
| } | |
| .region-name { | |
| display: block; | |
| color: var(--ink); | |
| font: 500 12px/1.2 "IBM Plex Mono", monospace; | |
| white-space: nowrap; | |
| } | |
| .region-summary { | |
| display: block; | |
| margin-top: 5px; | |
| color: var(--muted); | |
| font-size: 11px; | |
| white-space: nowrap; | |
| } | |
| .region-summary strong { | |
| color: var(--muted-strong); | |
| font-weight: 500; | |
| } | |
| .family-row th { | |
| height: 34px; | |
| padding: 0 20px; | |
| background: var(--surface-strong); | |
| color: var(--muted-strong); | |
| font-size: 11px; | |
| font-weight: 600; | |
| text-align: left; | |
| text-transform: uppercase; | |
| } | |
| .family-count { | |
| margin-left: 8px; | |
| color: var(--muted); | |
| font-weight: 500; | |
| text-transform: none; | |
| } | |
| .data-row th, | |
| .data-row td { | |
| height: 62px; | |
| background: var(--surface); | |
| transition: background 0.15s ease; | |
| } | |
| .data-row:hover th, | |
| .data-row:hover td { | |
| background: var(--row-hover); | |
| } | |
| .type-cell { | |
| position: sticky; | |
| left: 0; | |
| z-index: 2; | |
| padding: 9px 14px 9px 20px; | |
| border-right: 1px solid var(--line); | |
| text-align: left; | |
| } | |
| .type-name { | |
| display: block; | |
| color: var(--ink); | |
| font: 500 13px/1.25 "IBM Plex Mono", monospace; | |
| white-space: nowrap; | |
| } | |
| .gpu-meta { | |
| display: block; | |
| max-width: 205px; | |
| margin-top: 3px; | |
| overflow: hidden; | |
| color: var(--muted); | |
| font-size: 11px; | |
| font-weight: 400; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .matrix-cell { | |
| padding: 0; | |
| border-left: 1px solid var(--line); | |
| } | |
| .cell-button { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: flex-start; | |
| justify-content: center; | |
| gap: 3px; | |
| width: 100%; | |
| height: 61px; | |
| padding: 8px 10px; | |
| border: 0; | |
| background: transparent; | |
| cursor: pointer; | |
| text-align: left; | |
| } | |
| .cell-button:hover { | |
| background: var(--surface-subtle); | |
| } | |
| .matrix-cell.available .cell-button, | |
| .matrix-cell.partial .cell-button { | |
| background: var(--ok-soft); | |
| } | |
| .matrix-cell.unavailable .cell-button { | |
| background: var(--ko-soft); | |
| } | |
| .matrix-cell.quota .cell-button, | |
| .matrix-cell.forbidden .cell-button { | |
| background: var(--warn-soft); | |
| } | |
| .matrix-cell.unsupported .cell-button, | |
| .matrix-cell.not_offered .cell-button, | |
| .matrix-cell.error .cell-button { | |
| background: var(--na-soft); | |
| } | |
| .cell-main { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| min-width: 0; | |
| } | |
| .cell-value { | |
| color: var(--muted-strong); | |
| font: 500 13px/1.1 "IBM Plex Mono", monospace; | |
| white-space: nowrap; | |
| } | |
| .matrix-cell.available .cell-value, | |
| .matrix-cell.partial .cell-value { | |
| color: var(--ok); | |
| } | |
| .matrix-cell.unavailable .cell-value { | |
| color: var(--ko); | |
| } | |
| .matrix-cell.quota .cell-value, | |
| .matrix-cell.forbidden .cell-value { | |
| color: var(--warn); | |
| } | |
| .matrix-cell.unsupported .cell-value, | |
| .matrix-cell.not_offered .cell-value, | |
| .matrix-cell.error .cell-value, | |
| .matrix-cell.never .cell-value { | |
| color: var(--na); | |
| } | |
| .cell-age { | |
| color: var(--muted); | |
| font-size: 11px; | |
| white-space: nowrap; | |
| } | |
| .matrix-cell.stale .cell-main, | |
| .matrix-cell.stale .cell-age { | |
| opacity: 0.5; | |
| } | |
| .matrix-cell.probing .cell-button { | |
| cursor: wait; | |
| } | |
| .matrix-cell.probing .cell-main, | |
| .matrix-cell.probing .cell-age { | |
| animation: pulse 1s ease-in-out infinite; | |
| } | |
| @keyframes pulse { | |
| 50% { | |
| opacity: 0.35; | |
| } | |
| } | |
| .coverage-cell { | |
| padding: 0 12px; | |
| border-left: 1px solid var(--line); | |
| } | |
| .coverage-value { | |
| display: block; | |
| color: var(--ink); | |
| font: 500 13px/1.2 "IBM Plex Mono", monospace; | |
| white-space: nowrap; | |
| } | |
| .coverage-label { | |
| display: block; | |
| margin-top: 3px; | |
| color: var(--muted); | |
| font-size: 11px; | |
| white-space: nowrap; | |
| } | |
| .action-cell { | |
| padding: 0 10px; | |
| text-align: right; | |
| } | |
| .row-probe { | |
| height: 30px; | |
| padding: 0 10px; | |
| border: 1px solid var(--line); | |
| border-radius: 5px; | |
| background: transparent; | |
| color: var(--muted-strong); | |
| cursor: pointer; | |
| font-size: 12px; | |
| font-weight: 500; | |
| } | |
| .row-probe:hover:not(:disabled) { | |
| border-color: var(--accent); | |
| color: var(--ink); | |
| } | |
| .row-probe:disabled { | |
| opacity: 0.45; | |
| cursor: wait; | |
| } | |
| .empty-filter { | |
| display: none; | |
| padding: 64px 20px; | |
| color: var(--muted); | |
| text-align: center; | |
| } | |
| .empty-filter.visible { | |
| display: block; | |
| } | |
| #backdrop { | |
| position: fixed; | |
| inset: 0; | |
| z-index: 9; | |
| display: none; | |
| background: rgba(14, 20, 17, 0.24); | |
| } | |
| #backdrop.open { | |
| display: block; | |
| } | |
| #panel { | |
| position: fixed; | |
| top: 0; | |
| right: 0; | |
| z-index: 10; | |
| width: 438px; | |
| max-width: 94vw; | |
| height: 100vh; | |
| overflow-y: auto; | |
| border-left: 1px solid var(--line); | |
| background: var(--surface); | |
| box-shadow: var(--panel-shadow); | |
| transform: translateX(102%); | |
| transition: transform 0.2s ease-out; | |
| } | |
| #panel.open { | |
| transform: translateX(0); | |
| } | |
| .panel-inner { | |
| padding: 25px 24px 28px; | |
| } | |
| .panel-head { | |
| position: relative; | |
| padding-right: 42px; | |
| } | |
| .panel-kicker { | |
| margin-bottom: 7px; | |
| color: var(--muted); | |
| font-size: 11px; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| } | |
| #panel h2 { | |
| margin: 0; | |
| color: var(--ink); | |
| font: 500 18px/1.25 "IBM Plex Mono", monospace; | |
| } | |
| #p-gpu { | |
| margin-top: 5px; | |
| color: var(--muted); | |
| font-size: 12px; | |
| } | |
| #panel .close { | |
| position: absolute; | |
| top: -4px; | |
| right: 0; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| width: 34px; | |
| height: 34px; | |
| border: 1px solid var(--line); | |
| border-radius: 6px; | |
| background: transparent; | |
| color: var(--muted-strong); | |
| cursor: pointer; | |
| font-size: 20px; | |
| line-height: 1; | |
| } | |
| #panel .close:hover { | |
| border-color: var(--accent); | |
| color: var(--ink); | |
| } | |
| .panel-section { | |
| margin-top: 28px; | |
| } | |
| .section-head { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 12px; | |
| margin-bottom: 12px; | |
| } | |
| .section-label { | |
| color: var(--muted); | |
| font-size: 11px; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| } | |
| #p-age { | |
| color: var(--muted); | |
| font-size: 12px; | |
| } | |
| #p-summary { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 10px; | |
| min-height: 48px; | |
| margin-bottom: 14px; | |
| padding: 0 14px; | |
| border: 1px solid var(--line); | |
| border-radius: 6px; | |
| background: var(--surface-subtle); | |
| } | |
| .panel-status { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| color: var(--muted-strong); | |
| font-weight: 500; | |
| } | |
| .panel-count { | |
| color: var(--muted); | |
| font: 500 12px/1.2 "IBM Plex Mono", monospace; | |
| white-space: nowrap; | |
| } | |
| #p-azs { | |
| border-top: 1px solid var(--line); | |
| } | |
| .azrow { | |
| display: flex; | |
| align-items: flex-start; | |
| justify-content: space-between; | |
| gap: 16px; | |
| min-height: 38px; | |
| padding: 10px 0; | |
| border-bottom: 1px solid var(--line); | |
| font-size: 13px; | |
| } | |
| .az-name { | |
| color: var(--ink); | |
| font: 500 12px/1.35 "IBM Plex Mono", monospace; | |
| white-space: nowrap; | |
| } | |
| .az-detail { | |
| min-width: 0; | |
| color: var(--muted-strong); | |
| text-align: right; | |
| } | |
| .az-status { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .az-error { | |
| display: block; | |
| margin-top: 2px; | |
| overflow: hidden; | |
| color: var(--muted); | |
| font: 400 11px/1.3 "IBM Plex Mono", monospace; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| #p-probe { | |
| width: 100%; | |
| margin-top: 16px; | |
| } | |
| .ranges { | |
| display: flex; | |
| gap: 6px; | |
| } | |
| .ranges button { | |
| height: 28px; | |
| padding: 0 9px; | |
| border: 1px solid var(--line); | |
| border-radius: 5px; | |
| background: transparent; | |
| color: var(--muted-strong); | |
| cursor: pointer; | |
| font-size: 12px; | |
| } | |
| .ranges button.active { | |
| border-color: var(--accent); | |
| background: var(--accent-soft); | |
| color: var(--ink); | |
| } | |
| #chartwrap { | |
| position: relative; | |
| min-height: 170px; | |
| padding-top: 6px; | |
| } | |
| #chartwrap svg { | |
| display: block; | |
| width: 100%; | |
| height: auto; | |
| } | |
| #tooltip { | |
| position: absolute; | |
| z-index: 11; | |
| display: none; | |
| padding: 6px 8px; | |
| border: 1px solid var(--line); | |
| border-radius: 5px; | |
| background: var(--surface); | |
| box-shadow: 0 8px 20px rgba(15, 24, 20, 0.12); | |
| color: var(--muted-strong); | |
| font-size: 11px; | |
| pointer-events: none; | |
| white-space: nowrap; | |
| } | |
| .empty { | |
| padding: 20px 0; | |
| color: var(--muted); | |
| font-size: 13px; | |
| } | |
| @media (max-width: 1120px) { | |
| .summary-band { | |
| grid-template-columns: repeat(3, minmax(0, 1fr)); | |
| } | |
| .summary-item:nth-child(3) { | |
| border-right: 0; | |
| } | |
| .summary-item:nth-child(n + 4) { | |
| border-top: 1px solid var(--line); | |
| } | |
| .toolbar { | |
| align-items: flex-start; | |
| flex-direction: column; | |
| } | |
| .toolbar-right { | |
| width: 100%; | |
| } | |
| #addform { | |
| width: 100%; | |
| } | |
| #addtype { | |
| flex: 1; | |
| width: auto; | |
| } | |
| .matrix-scroll { | |
| max-height: none; | |
| min-height: 0; | |
| } | |
| } | |
| @media (max-width: 720px) { | |
| .app { | |
| width: min(calc(100% - 28px), 1600px); | |
| padding-top: 18px; | |
| } | |
| .topbar { | |
| flex-direction: column; | |
| gap: 14px; | |
| margin-bottom: 18px; | |
| } | |
| h1 { | |
| font-size: 24px; | |
| } | |
| #authline { | |
| justify-content: flex-start; | |
| text-align: left; | |
| } | |
| .summary-band { | |
| grid-template-columns: 1fr 1fr; | |
| } | |
| .summary-item { | |
| border-right: 1px solid var(--line); | |
| border-top: 0; | |
| } | |
| .summary-item:nth-child(even) { | |
| border-right: 0; | |
| } | |
| .summary-item:nth-child(n + 3) { | |
| border-top: 1px solid var(--line); | |
| } | |
| .summary-item:last-child { | |
| grid-column: 1 / -1; | |
| border-right: 0; | |
| } | |
| .toolbar-left { | |
| align-items: stretch; | |
| flex-direction: column; | |
| width: 100%; | |
| } | |
| .search-field, | |
| .toggle { | |
| width: 100%; | |
| } | |
| .matrix-header { | |
| flex-direction: column; | |
| gap: 12px; | |
| padding: 16px 14px 14px; | |
| } | |
| .legend { | |
| justify-content: flex-start; | |
| } | |
| .type-head { | |
| padding-left: 14px ; | |
| } | |
| .type-cell, | |
| .family-row th { | |
| padding-left: 14px; | |
| } | |
| #panel { | |
| width: 100%; | |
| max-width: 100%; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <main class="app"> | |
| <header class="topbar"> | |
| <div> | |
| <h1>AWS GPU Capacity</h1> | |
| <p class="subtitle">Near real-time GPU availability by instance type and region.</p> | |
| </div> | |
| <div id="authline"></div> | |
| </header> | |
| <section class="summary-band" id="summary" aria-label="Capacity summary"></section> | |
| <div class="toolbar"> | |
| <div class="toolbar-left"> | |
| <label class="search-field"> | |
| <span>Filter</span> | |
| <input id="filter" type="search" placeholder="g5, p5, A10G..."> | |
| </label> | |
| <label class="toggle"> | |
| <input id="available-only" type="checkbox"> | |
| <span>Available only</span> | |
| </label> | |
| </div> | |
| <div class="toolbar-right"> | |
| <form id="addform"> | |
| <input id="addtype" placeholder="Probe another size, e.g. g5.12xlarge"> | |
| <button class="button primary" type="submit">Probe</button> | |
| </form> | |
| </div> | |
| </div> | |
| <section class="matrix-shell" aria-labelledby="matrix-title"> | |
| <div class="matrix-header"> | |
| <div> | |
| <h2 class="matrix-title" id="matrix-title">Capacity matrix</h2> | |
| <p class="matrix-copy" id="matrix-copy"></p> | |
| </div> | |
| <div class="legend" aria-label="Status legend"> | |
| <span class="legend-item"><span class="signal available"></span>Available</span> | |
| <span class="legend-item"><span class="signal partial"></span>Partial AZ coverage</span> | |
| <span class="legend-item"><span class="signal unavailable"></span>No capacity</span> | |
| <span class="legend-item"><span class="signal quota"></span>Quota or denied</span> | |
| <span class="legend-item"><span class="signal unsupported"></span>N.O. / n/s / stale</span> | |
| </div> | |
| </div> | |
| <div class="matrix-scroll"> | |
| <table class="matrix" id="grid"></table> | |
| <div class="empty-filter" id="empty-filter">No instance type matches the current filters.</div> | |
| </div> | |
| </section> | |
| </main> | |
| <div id="backdrop"></div> | |
| <aside id="panel" aria-hidden="true"> | |
| <div class="panel-inner"> | |
| <header class="panel-head"> | |
| <div class="panel-kicker">Region detail</div> | |
| <h2 id="p-title"></h2> | |
| <div id="p-gpu"></div> | |
| <button class="close" type="button" title="Close" aria-label="Close">×</button> | |
| </header> | |
| <section class="panel-section"> | |
| <div class="section-head"> | |
| <div class="section-label">Current status</div> | |
| <div id="p-age"></div> | |
| </div> | |
| <div id="p-summary"></div> | |
| <div id="p-azs"></div> | |
| <button class="button primary" id="p-probe" type="button">Probe region now</button> | |
| </section> | |
| <section class="panel-section"> | |
| <div class="section-head"> | |
| <div class="section-label">Available AZs over time</div> | |
| <div class="ranges" id="p-ranges"> | |
| <button type="button" data-hours="24">24h</button> | |
| <button type="button" data-hours="48" class="active">48h</button> | |
| <button type="button" data-hours="168">7d</button> | |
| </div> | |
| </div> | |
| <div id="chartwrap"><div id="tooltip"></div></div> | |
| </section> | |
| </div> | |
| </aside> | |
| <script> | |
| let META = null; | |
| let ME = { can_probe: false, authenticated: false }; | |
| let PANEL = { type: null, region: null, hours: 48 }; | |
| let VIEW = { query: "", availableOnly: false }; | |
| const PROBING = new Set(); | |
| const STATUS_COLOR = { | |
| available: "var(--ok)", | |
| partial: "var(--ok)", | |
| unavailable: "var(--ko)", | |
| quota: "var(--warn)", | |
| forbidden: "var(--warn)", | |
| }; | |
| function esc(value) { | |
| return String(value ?? "") | |
| .replace(/&/g, "&") | |
| .replace(/</g, "<") | |
| .replace(/>/g, ">") | |
| .replace(/"/g, """) | |
| .replace(/'/g, "'"); | |
| } | |
| function fmtAge(ts) { | |
| if (!ts) return "never"; | |
| const s = Math.max(0, Math.round(Date.now() / 1000 - ts)); | |
| if (s < 90) return s + "s ago"; | |
| if (s < 5400) return Math.round(s / 60) + "m ago"; | |
| if (s < 172800) return Math.round(s / 3600) + "h ago"; | |
| return Math.round(s / 86400) + "d ago"; | |
| } | |
| function isStale(result) { | |
| return Boolean(result && result.checked_at && Date.now() / 1000 - result.checked_at > META.stale_after_seconds); | |
| } | |
| function azCounts(result) { | |
| const azs = Object.entries((result && result.azs) || {}); | |
| return { | |
| azs, | |
| available: azs.filter(([, az]) => az.status === "available").length, | |
| total: azs.length, | |
| }; | |
| } | |
| function humanStatus(status) { | |
| return { | |
| available: "Available", | |
| partial: "Partial availability", | |
| unavailable: "No capacity", | |
| quota: "Quota blocked", | |
| forbidden: "Access denied", | |
| unsupported: "No ODCR support", | |
| not_offered: "Not offered", | |
| error: "Probe error", | |
| never: "Never probed", | |
| }[status] || status; | |
| } | |
| function cellModel(result) { | |
| if (!result) { | |
| return { | |
| cls: "never", | |
| status: "never", | |
| label: "-", | |
| age: "Never probed", | |
| title: "Never probed", | |
| detail: "Never probed", | |
| count: "", | |
| }; | |
| } | |
| const counts = azCounts(result); | |
| const status = result.status === "available" && counts.available < counts.total ? "partial" : result.status; | |
| const label = { | |
| available: `${counts.available}/${counts.total}`, | |
| partial: `${counts.available}/${counts.total}`, | |
| unavailable: counts.total ? `0/${counts.total}` : "none", | |
| quota: "quota", | |
| forbidden: "denied", | |
| unsupported: "n/s", | |
| not_offered: "N.O.", | |
| error: "error", | |
| }[status] || status; | |
| const title = counts.azs.map(([az, value]) => | |
| `${az}: ${value.status}${value.error ? " (" + value.error + ")" : ""}` | |
| ).join("\n") || (result.status === "not_offered" ? "Not offered in this region" : result.error || result.status); | |
| const count = counts.total ? `${counts.available}/${counts.total} AZ` : ""; | |
| return { | |
| cls: status + (isStale(result) ? " stale" : ""), | |
| status, | |
| label, | |
| age: fmtAge(result.checked_at), | |
| title, | |
| detail: humanStatus(status), | |
| count, | |
| }; | |
| } | |
| function familyLabel(instanceType) { | |
| const base = instanceType.split(".")[0]; | |
| const match = base.match(/^([a-z]+)(\d+)/); | |
| if (!match) return base.toUpperCase(); | |
| if (match[1] === "g") return "G" + match[2]; | |
| if (match[1] === "gr") return "G" + match[2]; | |
| if (match[1] === "p") return "P series"; | |
| return base.toUpperCase(); | |
| } | |
| function regionGroups(regions) { | |
| const defs = [ | |
| { label: "United States", test: region => region.startsWith("us-") }, | |
| { label: "Europe", test: region => region.startsWith("eu-") }, | |
| { label: "Other", test: () => true }, | |
| ]; | |
| const remaining = new Set(regions); | |
| return defs.map(def => { | |
| const items = regions.filter(region => remaining.has(region) && def.test(region)); | |
| items.forEach(region => remaining.delete(region)); | |
| return { label: def.label, regions: items }; | |
| }).filter(group => group.regions.length); | |
| } | |
| function filteredTypes(data) { | |
| const query = VIEW.query.trim().toLowerCase(); | |
| return data.instance_types.filter(type => { | |
| const gpu = (data.gpu_info || {})[type]; | |
| const haystack = `${type} ${gpu ? `${gpu.name} ${gpu.count} ${gpu.memory_gb}` : ""}`.toLowerCase(); | |
| if (query && !haystack.includes(query)) return false; | |
| if (VIEW.availableOnly) { | |
| return data.regions.some(region => ((data.state[type] || {})[region] || {}).status === "available"); | |
| } | |
| return true; | |
| }); | |
| } | |
| function groupedTypes(types) { | |
| const groups = []; | |
| types.forEach(type => { | |
| const label = familyLabel(type); | |
| let group = groups.find(item => item.label === label); | |
| if (!group) { | |
| group = { label, types: [] }; | |
| groups.push(group); | |
| } | |
| group.types.push(type); | |
| }); | |
| return groups; | |
| } | |
| function probeKey(type, region) { | |
| return `${type}::${region || "*"}`; | |
| } | |
| function isProbing(type, region) { | |
| return PROBING.has(probeKey(type, region)) || PROBING.has(probeKey(type, null)); | |
| } | |
| function renderSummary(data) { | |
| const results = []; | |
| data.instance_types.forEach(type => { | |
| data.regions.forEach(region => { | |
| const result = (data.state[type] || {})[region]; | |
| if (result) results.push(result); | |
| }); | |
| }); | |
| const available = results.filter(result => result.status === "available").length; | |
| const partial = results.filter(result => { | |
| const counts = azCounts(result); | |
| return result.status === "available" && counts.available < counts.total; | |
| }).length; | |
| const unavailable = results.filter(result => result.status === "unavailable").length; | |
| const stale = results.filter(result => isStale(result)).length; | |
| const newest = Math.max(0, ...results.map(result => result.checked_at || 0)); | |
| const total = data.instance_types.length * data.regions.length; | |
| const metrics = [ | |
| { label: "Available signals", value: available, note: "type-region pairs", tone: "ok" }, | |
| { label: "Partial coverage", value: partial, note: "usable in some AZs", tone: partial ? "warn" : "" }, | |
| { label: "No capacity", value: unavailable, note: "probed cells", tone: unavailable ? "ko" : "" }, | |
| { label: "Stale results", value: stale, note: `older than ${Math.round(data.stale_after_seconds / 60)}m`, tone: stale ? "warn" : "" }, | |
| { label: "Latest probe", value: newest ? fmtAge(newest) : "-", note: `${results.length}/${total} cells covered`, tone: "" }, | |
| ]; | |
| document.getElementById("summary").innerHTML = metrics.map(metric => ` | |
| <div class="summary-item"> | |
| <span class="summary-label">${esc(metric.label)}</span> | |
| <span class="summary-value ${metric.tone}">${esc(metric.value)}</span> | |
| <span class="summary-note">${esc(metric.note)}</span> | |
| </div> | |
| `).join(""); | |
| } | |
| function regionSummary(data, region, types) { | |
| const results = types.map(type => (data.state[type] || {})[region]).filter(Boolean); | |
| return { | |
| available: results.filter(result => result.status === "available").length, | |
| known: results.length, | |
| }; | |
| } | |
| function coverageSummary(data, type) { | |
| const results = data.regions.map(region => (data.state[type] || {})[region]).filter(Boolean); | |
| return { | |
| available: results.filter(result => result.status === "available").length, | |
| known: results.length, | |
| }; | |
| } | |
| function renderMatrix(data) { | |
| const visibleTypes = filteredTypes(data); | |
| const groups = regionGroups(data.regions); | |
| const displayRegions = groups.flatMap(group => group.regions); | |
| const typeGroups = groupedTypes(visibleTypes); | |
| const table = document.getElementById("grid"); | |
| const empty = document.getElementById("empty-filter"); | |
| const copy = document.getElementById("matrix-copy"); | |
| copy.textContent = `${visibleTypes.length} of ${data.instance_types.length} instance types across ${data.regions.length} regions`; | |
| const colgroup = ` | |
| <colgroup> | |
| <col class="type-col"> | |
| ${displayRegions.map(() => '<col class="region-col">').join("")} | |
| <col class="coverage-col"> | |
| <col class="action-col"> | |
| </colgroup> | |
| `; | |
| const header = ` | |
| <thead> | |
| <tr class="scope-row"> | |
| <th class="type-head" scope="col" rowspan="2">Instance type</th> | |
| ${groups.map(group => `<th class="region-group-head" scope="colgroup" colspan="${group.regions.length}">${esc(group.label)}</th>`).join("")} | |
| <th class="coverage-head" scope="col" rowspan="2">Regions</th> | |
| <th class="action-head" scope="col" rowspan="2"></th> | |
| </tr> | |
| <tr class="region-row"> | |
| ${displayRegions.map(region => { | |
| const summary = regionSummary(data, region, visibleTypes); | |
| return `<th scope="col"> | |
| <span class="region-name">${esc(region)}</span> | |
| <span class="region-summary"><strong>${summary.available}</strong> avail${summary.known ? ` / ${summary.known}` : ""}</span> | |
| </th>`; | |
| }).join("")} | |
| </tr> | |
| </thead> | |
| `; | |
| const body = typeGroups.map(group => ` | |
| <tr class="family-row"> | |
| <th scope="rowgroup" colspan="${displayRegions.length + 3}"> | |
| ${esc(group.label)}<span class="family-count">${group.types.length} type${group.types.length === 1 ? "" : "s"}</span> | |
| </th> | |
| </tr> | |
| ${group.types.map(type => { | |
| const gpu = (data.gpu_info || {})[type]; | |
| const coverage = coverageSummary(data, type); | |
| const busy = isProbing(type, null); | |
| return `<tr class="data-row"> | |
| <th class="type-cell" scope="row"> | |
| <span class="type-name">${esc(type)}</span> | |
| <span class="gpu-meta">${gpu ? `${esc(gpu.count)}x ${esc(gpu.name)} ${esc(gpu.memory_gb)} GB` : "GPU metadata unavailable"}</span> | |
| </th> | |
| ${displayRegions.map(region => { | |
| const result = (data.state[type] || {})[region]; | |
| const model = cellModel(result); | |
| const probing = isProbing(type, region); | |
| const aria = `${type} in ${region}: ${model.detail}${model.count ? `, ${model.count}` : ""}, ${model.age}`; | |
| return `<td class="matrix-cell ${esc(model.cls)}${probing ? " probing" : ""}" data-type="${esc(type)}" data-region="${esc(region)}"> | |
| <button class="cell-button" type="button" data-type="${esc(type)}" data-region="${esc(region)}" title="${esc(model.title)}" aria-label="${esc(aria)}"> | |
| <span class="cell-main"><span class="signal ${esc(model.status)}"></span><span class="cell-value">${esc(model.label)}</span></span> | |
| <span class="cell-age">${esc(model.age)}</span> | |
| </button> | |
| </td>`; | |
| }).join("")} | |
| <td class="coverage-cell"> | |
| <span class="coverage-value">${coverage.available}/${data.regions.length}</span> | |
| <span class="coverage-label">${coverage.known ? `${coverage.known} checked` : "not checked"}</span> | |
| </td> | |
| <td class="action-cell"> | |
| ${ME.can_probe ? `<button class="row-probe" type="button" data-type="${esc(type)}" ${busy ? "disabled" : ""}>Probe</button>` : ""} | |
| </td> | |
| </tr>`; | |
| }).join("")} | |
| `).join(""); | |
| table.innerHTML = colgroup + header + `<tbody>${body}</tbody>`; | |
| empty.classList.toggle("visible", visibleTypes.length === 0); | |
| table.style.display = visibleTypes.length ? "" : "none"; | |
| } | |
| function render(data) { | |
| META = data; | |
| renderSummary(data); | |
| renderMatrix(data); | |
| document.getElementById("addform").style.display = ME.can_probe ? "" : "none"; | |
| } | |
| async function refresh() { | |
| const response = await fetch("/api/state"); | |
| render(await response.json()); | |
| } | |
| async function probe(type, region) { | |
| const key = probeKey(type, region); | |
| if (PROBING.has(key)) return; | |
| PROBING.add(key); | |
| if (META) renderMatrix(META); | |
| try { | |
| await fetch("/api/probe", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(region ? { instance_type: type, region } : { instance_type: type }), | |
| }); | |
| } finally { | |
| PROBING.delete(key); | |
| await refresh(); | |
| if (PANEL.type === type && document.getElementById("panel").classList.contains("open")) { | |
| renderPanelStatus(); | |
| loadChart(); | |
| } | |
| } | |
| } | |
| async function openPanel(type, region) { | |
| PANEL.type = type; | |
| PANEL.region = region; | |
| document.getElementById("p-title").textContent = `${type} / ${region}`; | |
| const gpu = (META.gpu_info || {})[type]; | |
| document.getElementById("p-gpu").textContent = gpu ? `${gpu.count}x ${gpu.name} ${gpu.memory_gb} GB` : ""; | |
| renderPanelStatus(); | |
| document.getElementById("p-probe").style.display = ME.can_probe ? "" : "none"; | |
| document.getElementById("panel").classList.add("open"); | |
| document.getElementById("panel").setAttribute("aria-hidden", "false"); | |
| document.getElementById("backdrop").classList.add("open"); | |
| await loadChart(); | |
| } | |
| function renderPanelStatus() { | |
| const result = (META.state[PANEL.type] || {})[PANEL.region]; | |
| const age = document.getElementById("p-age"); | |
| const summary = document.getElementById("p-summary"); | |
| const box = document.getElementById("p-azs"); | |
| if (!result) { | |
| age.textContent = ""; | |
| summary.innerHTML = `<span class="panel-status"><span class="signal never"></span>Never probed</span>`; | |
| box.innerHTML = '<div class="empty">No current result for this region.</div>'; | |
| return; | |
| } | |
| const model = cellModel(result); | |
| const counts = azCounts(result); | |
| age.textContent = fmtAge(result.checked_at); | |
| summary.innerHTML = ` | |
| <span class="panel-status"><span class="signal ${esc(model.status)}"></span>${esc(model.detail)}</span> | |
| <span class="panel-count">${esc(model.count || "")}</span> | |
| `; | |
| box.innerHTML = counts.azs.length | |
| ? counts.azs.map(([az, value]) => ` | |
| <div class="azrow"> | |
| <span class="az-name">${esc(az)}</span> | |
| <span class="az-detail"> | |
| <span class="az-status"><span class="signal ${esc(value.status)}"></span>${esc(humanStatus(value.status))}</span> | |
| ${value.error ? `<span class="az-error">${esc(value.error)}</span>` : ""} | |
| </span> | |
| </div> | |
| `).join("") | |
| : `<div class="empty">${result.status === "not_offered" ? "Not offered in this region." : esc(humanStatus(result.status)) + "."}</div>`; | |
| } | |
| async function loadChart() { | |
| const wrap = document.getElementById("chartwrap"); | |
| [...wrap.querySelectorAll("svg, .empty")].forEach(node => node.remove()); | |
| const response = await fetch(`/api/history/${encodeURIComponent(PANEL.type)}?region=${encodeURIComponent(PANEL.region)}&hours=${PANEL.hours}`); | |
| const series = (await response.json()).series; | |
| if (!series.length) { | |
| wrap.insertAdjacentHTML("beforeend", '<div class="empty">No history yet for this cell.</div>'); | |
| return; | |
| } | |
| drawChart(wrap, series); | |
| } | |
| function drawChart(wrap, series) { | |
| const W = 390, H = 168, L = 28, R = 8, T = 10, B = 24; | |
| const now = Date.now() / 1000; | |
| const t0 = now - PANEL.hours * 3600; | |
| const yMax = Math.max(1, ...series.map(point => point.total)); | |
| const x = time => L + (time - t0) / (now - t0) * (W - L - R); | |
| const y = value => T + (1 - value / yMax) * (H - T - B); | |
| let gridLines = ""; | |
| let yLabels = ""; | |
| const yStep = yMax > 8 ? 2 : 1; | |
| for (let value = 0; value <= yMax; value += yStep) { | |
| gridLines += `<line x1="${L}" y1="${y(value)}" x2="${W - R}" y2="${y(value)}" stroke="var(--line)" stroke-width="1"/>`; | |
| yLabels += `<text x="${L - 6}" y="${y(value) + 3}" text-anchor="end" font-size="9" fill="var(--muted)">${value}</text>`; | |
| } | |
| let xLabels = ""; | |
| for (let index = 0; index <= 3; index++) { | |
| const time = t0 + (now - t0) * index / 3; | |
| const date = new Date(time * 1000); | |
| const label = PANEL.hours <= 48 | |
| ? date.toTimeString().slice(0, 5) | |
| : `${date.getDate()}/${date.getMonth() + 1} ${date.getHours()}h`; | |
| const anchor = index === 0 ? "start" : index === 3 ? "end" : "middle"; | |
| xLabels += `<text x="${x(time)}" y="${H - 6}" text-anchor="${anchor}" font-size="9" fill="var(--muted)">${label}</text>`; | |
| } | |
| const gap = Math.max(45 * 60, (now - t0) / 30); | |
| let path = ""; | |
| series.forEach((point, index) => { | |
| const px = x(point.t); | |
| const py = y(point.available); | |
| if (index === 0 || point.t - series[index - 1].t > gap) path += `M${px.toFixed(1)},${py.toFixed(1)}`; | |
| else path += `H${px.toFixed(1)}V${py.toFixed(1)}`; | |
| }); | |
| const dots = series.map((point, index) => | |
| `<circle cx="${x(point.t).toFixed(1)}" cy="${y(point.available).toFixed(1)}" r="3.5" data-i="${index}" | |
| fill="${STATUS_COLOR[point.status] || "var(--na)"}" stroke="var(--surface)" stroke-width="2"/>` | |
| ).join(""); | |
| wrap.insertAdjacentHTML("beforeend", ` | |
| <svg viewBox="0 0 ${W} ${H}" role="img" aria-label="Available AZs over time"> | |
| ${gridLines}${yLabels}${xLabels} | |
| <path d="${path}" fill="none" stroke="var(--muted-strong)" stroke-width="2"/> | |
| ${dots} | |
| </svg> | |
| `); | |
| const svg = wrap.querySelector("svg"); | |
| const tooltip = document.getElementById("tooltip"); | |
| svg.addEventListener("mousemove", event => { | |
| const rect = svg.getBoundingClientRect(); | |
| const mouseX = (event.clientX - rect.left) / rect.width * W; | |
| let best = null; | |
| let distance = Infinity; | |
| series.forEach((point, index) => { | |
| const currentDistance = Math.abs(x(point.t) - mouseX); | |
| if (currentDistance < distance) { | |
| distance = currentDistance; | |
| best = index; | |
| } | |
| }); | |
| if (best === null) return; | |
| const point = series[best]; | |
| const date = new Date(point.t * 1000); | |
| tooltip.innerHTML = `${esc(date.toLocaleString([], { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }))} · <b>${esc(point.available)}/${esc(point.total)} AZ</b> · ${esc(humanStatus(point.status))}`; | |
| tooltip.style.display = "block"; | |
| const tooltipX = x(point.t) / W * rect.width; | |
| tooltip.style.left = Math.min(tooltipX + 8, rect.width - tooltip.offsetWidth - 4) + "px"; | |
| tooltip.style.top = (y(point.available) / H * rect.height - 34) + "px"; | |
| svg.querySelectorAll("circle").forEach(circle => circle.setAttribute("r", circle.dataset.i == best ? 5 : 3.5)); | |
| }); | |
| svg.addEventListener("mouseleave", () => { | |
| tooltip.style.display = "none"; | |
| svg.querySelectorAll("circle").forEach(circle => circle.setAttribute("r", 3.5)); | |
| }); | |
| } | |
| function closePanel() { | |
| document.getElementById("panel").classList.remove("open"); | |
| document.getElementById("panel").setAttribute("aria-hidden", "true"); | |
| document.getElementById("backdrop").classList.remove("open"); | |
| PANEL.type = null; | |
| PANEL.region = null; | |
| } | |
| document.getElementById("grid").addEventListener("click", event => { | |
| const cell = event.target.closest(".cell-button"); | |
| if (cell) return openPanel(cell.dataset.type, cell.dataset.region); | |
| const button = event.target.closest(".row-probe"); | |
| if (button) return probe(button.dataset.type, null); | |
| }); | |
| document.getElementById("filter").addEventListener("input", event => { | |
| VIEW.query = event.target.value; | |
| if (META) renderMatrix(META); | |
| }); | |
| document.getElementById("available-only").addEventListener("change", event => { | |
| VIEW.availableOnly = event.target.checked; | |
| if (META) renderMatrix(META); | |
| }); | |
| document.getElementById("addform").addEventListener("submit", event => { | |
| event.preventDefault(); | |
| const type = document.getElementById("addtype").value.trim().toLowerCase(); | |
| if (type) probe(type, null); | |
| }); | |
| document.querySelector("#panel .close").addEventListener("click", closePanel); | |
| document.getElementById("backdrop").addEventListener("click", closePanel); | |
| document.addEventListener("keydown", event => { | |
| if (event.key === "Escape") closePanel(); | |
| }); | |
| document.getElementById("p-probe").addEventListener("click", async event => { | |
| event.target.disabled = true; | |
| try { | |
| await probe(PANEL.type, PANEL.region); | |
| } finally { | |
| event.target.disabled = false; | |
| } | |
| }); | |
| document.getElementById("p-ranges").addEventListener("click", event => { | |
| const button = event.target.closest("button[data-hours]"); | |
| if (!button) return; | |
| PANEL.hours = +button.dataset.hours; | |
| document.querySelectorAll("#p-ranges button").forEach(item => item.classList.toggle("active", item === button)); | |
| loadChart(); | |
| }); | |
| async function loadMe() { | |
| ME = await (await fetch("/api/me")).json(); | |
| const line = document.getElementById("authline"); | |
| if (ME.can_probe) { | |
| line.innerHTML = ME.username | |
| ? `<span class="session-pill">Signed in as <b> ${esc(ME.username)}</b> · <a href="/logout">logout</a></span>` | |
| : '<span class="session-pill">Probe access enabled</span>'; | |
| } else if (ME.authenticated) { | |
| line.innerHTML = '<span class="session-pill">Read-only access</span>'; | |
| } else { | |
| line.innerHTML = '<span class="session-pill">Read-only · <a href="/login/huggingface" target="_blank">Sign in with HF</a></span>'; | |
| } | |
| } | |
| loadMe().then(refresh); | |
| setInterval(refresh, 30000); | |
| </script> | |
| </body> | |
| </html> | |