Spaces:
Running
Running
feat: refresh capacity every five minutes and highlight recent availability
#2
by GuillaumeSalouHF HF Staff - opened
- Dockerfile +1 -1
- README.md +7 -1
- gpu_capacity/config.py +3 -0
- gpu_capacity/history.py +19 -0
- gpu_capacity/main.py +2 -0
- gpu_capacity/static/index.html +47 -5
- tests/test_recent_availability.py +93 -0
- tests/test_ui.cjs +77 -0
Dockerfile
CHANGED
|
@@ -10,7 +10,7 @@ USER user
|
|
| 10 |
|
| 11 |
ENV HOST=0.0.0.0 \
|
| 12 |
PORT=7860 \
|
| 13 |
-
AUTO_REFRESH_MINUTES=
|
| 14 |
STATE_FILE=/data/state.json
|
| 15 |
|
| 16 |
EXPOSE 7860
|
|
|
|
| 10 |
|
| 11 |
ENV HOST=0.0.0.0 \
|
| 12 |
PORT=7860 \
|
| 13 |
+
AUTO_REFRESH_MINUTES=5 \
|
| 14 |
STATE_FILE=/data/state.json
|
| 15 |
|
| 16 |
EXPOSE 7860
|
README.md
CHANGED
|
@@ -28,7 +28,7 @@ AWS_PROFILE=<profile> uv run gpu-capacity
|
|
| 28 |
```
|
| 29 |
|
| 30 |
One representative (smallest) size per GPU family, plus `g7e.48xlarge`, is swept
|
| 31 |
-
automatically when `AUTO_REFRESH_MINUTES` is set (the deployed Space uses
|
| 32 |
any other size can be probed on demand from the dashboard input or the API.
|
| 33 |
Results are cached in `state.json` and shown with their age; they fade once older than
|
| 34 |
`STALE_AFTER_SECONDS` (default 900).
|
|
@@ -81,6 +81,7 @@ the Space).
|
|
| 81 |
|---|---|
|
| 82 |
| `available` (n/m AZ) | reservation succeeded in n of m offered AZs |
|
| 83 |
| `no capacity` | AWS returned `InsufficientInstanceCapacity` everywhere |
|
|
|
|
| 84 |
| `quota` / `denied` | account quota or IAM/SCP blocks the check (not a capacity signal) |
|
| 85 |
| `n/s` | type not reservable via ODCR there (p5 → Capacity Blocks) |
|
| 86 |
| `—` | type not offered in that region |
|
|
@@ -88,6 +89,11 @@ the Space).
|
|
| 88 |
Note: results are account-specific (quotas, SCPs), which is what you want —
|
| 89 |
"can *this* account get a G5 there".
|
| 90 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
Latency: an `available` answer comes back in ~1 s per region; a `no capacity`
|
| 92 |
answer takes 10–20 s because EC2 genuinely attempts placement before returning
|
| 93 |
`InsufficientInstanceCapacity`. Probe creates run with retries disabled —
|
|
|
|
| 28 |
```
|
| 29 |
|
| 30 |
One representative (smallest) size per GPU family, plus `g7e.48xlarge`, is swept
|
| 31 |
+
automatically when `AUTO_REFRESH_MINUTES` is set (the deployed Space uses 5);
|
| 32 |
any other size can be probed on demand from the dashboard input or the API.
|
| 33 |
Results are cached in `state.json` and shown with their age; they fade once older than
|
| 34 |
`STALE_AFTER_SECONDS` (default 900).
|
|
|
|
| 81 |
|---|---|
|
| 82 |
| `available` (n/m AZ) | reservation succeeded in n of m offered AZs |
|
| 83 |
| `no capacity` | AWS returned `InsufficientInstanceCapacity` everywhere |
|
| 84 |
+
| blue / `Seen <date>` | no capacity now, but at least one AZ had capacity within the past 10 days; the date is the latest positive observation |
|
| 85 |
| `quota` / `denied` | account quota or IAM/SCP blocks the check (not a capacity signal) |
|
| 86 |
| `n/s` | type not reservable via ODCR there (p5 → Capacity Blocks) |
|
| 87 |
| `—` | type not offered in that region |
|
|
|
|
| 89 |
Note: results are account-specific (quotas, SCPs), which is what you want —
|
| 90 |
"can *this* account get a G5 there".
|
| 91 |
|
| 92 |
+
Blue is a historical hint, not current availability. Current availability stays
|
| 93 |
+
green and quota/access failures stay orange. Open a cell to see the last positive
|
| 94 |
+
observation and select the 10-day history range. This uses recorded observations
|
| 95 |
+
and does not trigger additional probes.
|
| 96 |
+
|
| 97 |
Latency: an `available` answer comes back in ~1 s per region; a `no capacity`
|
| 98 |
answer takes 10–20 s because EC2 genuinely attempts placement before returning
|
| 99 |
`InsufficientInstanceCapacity`. Probe creates run with retries disabled —
|
gpu_capacity/config.py
CHANGED
|
@@ -45,6 +45,9 @@ PROBE_END_DATE_MINUTES = 30
|
|
| 45 |
# Cached probe results older than this are flagged stale in the UI.
|
| 46 |
STALE_AFTER_SECONDS = int(os.environ.get("STALE_AFTER_SECONDS", "900"))
|
| 47 |
|
|
|
|
|
|
|
|
|
|
| 48 |
# Optional background refresh of all types/regions. 0 = disabled (probe on
|
| 49 |
# demand only). Each full sweep creates+cancels one reservation per offered AZ.
|
| 50 |
AUTO_REFRESH_MINUTES = int(os.environ.get("AUTO_REFRESH_MINUTES", "0"))
|
|
|
|
| 45 |
# Cached probe results older than this are flagged stale in the UI.
|
| 46 |
STALE_AFTER_SECONDS = int(os.environ.get("STALE_AFTER_SECONDS", "900"))
|
| 47 |
|
| 48 |
+
# Highlight unavailable cells where capacity was seen within this window.
|
| 49 |
+
RECENT_AVAILABILITY_DAYS = 10
|
| 50 |
+
|
| 51 |
# Optional background refresh of all types/regions. 0 = disabled (probe on
|
| 52 |
# demand only). Each full sweep creates+cancels one reservation per offered AZ.
|
| 53 |
AUTO_REFRESH_MINUTES = int(os.environ.get("AUTO_REFRESH_MINUTES", "0"))
|
gpu_capacity/history.py
CHANGED
|
@@ -14,6 +14,8 @@ RECORDED = {"available", "unavailable", "quota", "forbidden"}
|
|
| 14 |
|
| 15 |
# (instance_type, region) -> [[ts, available_azs, total_azs, status], ...]
|
| 16 |
_hist: dict[tuple[str, str], list] = {}
|
|
|
|
|
|
|
| 17 |
# today's raw jsonl lines, kept whole: the day file is always rewritten in
|
| 18 |
# full by a dedicated writer thread. Appending from the sweep thread is what
|
| 19 |
# froze the sweeps — O_APPEND is unreliable on the FUSE bucket mount, and a
|
|
@@ -43,6 +45,9 @@ def load():
|
|
| 43 |
_hist.setdefault((r["type"], r["region"]), []).append(
|
| 44 |
[r["t"], r["a"], r["n"], r["s"]]
|
| 45 |
)
|
|
|
|
|
|
|
|
|
|
| 46 |
rows += 1
|
| 47 |
if fn[:-6] == _today_day[0]:
|
| 48 |
_today.append(line.rstrip("\n"))
|
|
@@ -68,6 +73,9 @@ def record(instance_type: str, results: dict[str, dict]):
|
|
| 68 |
_hist.setdefault((instance_type, region), []).append(
|
| 69 |
[ts, avail, len(azs), result["status"]]
|
| 70 |
)
|
|
|
|
|
|
|
|
|
|
| 71 |
_queue.put(json.dumps({
|
| 72 |
"t": ts, "type": instance_type, "region": region,
|
| 73 |
"a": avail, "n": len(azs), "s": result["status"],
|
|
@@ -99,6 +107,17 @@ def _writer_loop():
|
|
| 99 |
log.warning("history write failed: %s", e)
|
| 100 |
|
| 101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
def series(instance_type: str, region: str, hours: float) -> list[dict]:
|
| 103 |
cutoff = time.time() - hours * 3600
|
| 104 |
with _lock:
|
|
|
|
| 14 |
|
| 15 |
# (instance_type, region) -> [[ts, available_azs, total_azs, status], ...]
|
| 16 |
_hist: dict[tuple[str, str], list] = {}
|
| 17 |
+
# Latest positive observation, indexed for the frequently polled state API.
|
| 18 |
+
_last_available: dict[tuple[str, str], int] = {}
|
| 19 |
# today's raw jsonl lines, kept whole: the day file is always rewritten in
|
| 20 |
# full by a dedicated writer thread. Appending from the sweep thread is what
|
| 21 |
# froze the sweeps — O_APPEND is unreliable on the FUSE bucket mount, and a
|
|
|
|
| 45 |
_hist.setdefault((r["type"], r["region"]), []).append(
|
| 46 |
[r["t"], r["a"], r["n"], r["s"]]
|
| 47 |
)
|
| 48 |
+
if r["s"] == "available" and r["a"] > 0:
|
| 49 |
+
key = (r["type"], r["region"])
|
| 50 |
+
_last_available[key] = max(_last_available.get(key, 0), r["t"])
|
| 51 |
rows += 1
|
| 52 |
if fn[:-6] == _today_day[0]:
|
| 53 |
_today.append(line.rstrip("\n"))
|
|
|
|
| 73 |
_hist.setdefault((instance_type, region), []).append(
|
| 74 |
[ts, avail, len(azs), result["status"]]
|
| 75 |
)
|
| 76 |
+
if result["status"] == "available" and avail > 0:
|
| 77 |
+
key = (instance_type, region)
|
| 78 |
+
_last_available[key] = max(_last_available.get(key, 0), ts)
|
| 79 |
_queue.put(json.dumps({
|
| 80 |
"t": ts, "type": instance_type, "region": region,
|
| 81 |
"a": avail, "n": len(azs), "s": result["status"],
|
|
|
|
| 107 |
log.warning("history write failed: %s", e)
|
| 108 |
|
| 109 |
|
| 110 |
+
def recent_availability(days: int) -> dict[str, dict[str, int]]:
|
| 111 |
+
now = time.time()
|
| 112 |
+
cutoff = now - days * 86400
|
| 113 |
+
recent: dict[str, dict[str, int]] = {}
|
| 114 |
+
with _lock:
|
| 115 |
+
for (instance_type, region), ts in _last_available.items():
|
| 116 |
+
if cutoff <= ts <= now:
|
| 117 |
+
recent.setdefault(instance_type, {})[region] = ts
|
| 118 |
+
return recent
|
| 119 |
+
|
| 120 |
+
|
| 121 |
def series(instance_type: str, region: str, hours: float) -> list[dict]:
|
| 122 |
cutoff = time.time() - hours * 3600
|
| 123 |
with _lock:
|
gpu_capacity/main.py
CHANGED
|
@@ -100,6 +100,8 @@ def get_state():
|
|
| 100 |
"instance_types": types,
|
| 101 |
"regions": config.REGIONS,
|
| 102 |
"stale_after_seconds": config.STALE_AFTER_SECONDS,
|
|
|
|
|
|
|
| 103 |
"gpu_info": prober.gpu_info(types),
|
| 104 |
"state": current,
|
| 105 |
}
|
|
|
|
| 100 |
"instance_types": types,
|
| 101 |
"regions": config.REGIONS,
|
| 102 |
"stale_after_seconds": config.STALE_AFTER_SECONDS,
|
| 103 |
+
"recent_availability_days": config.RECENT_AVAILABILITY_DAYS,
|
| 104 |
+
"recent_availability": history.recent_availability(config.RECENT_AVAILABILITY_DAYS),
|
| 105 |
"gpu_info": prober.gpu_info(types),
|
| 106 |
"state": current,
|
| 107 |
}
|
gpu_capacity/static/index.html
CHANGED
|
@@ -24,6 +24,8 @@
|
|
| 24 |
--ok-soft: #e9f4ed;
|
| 25 |
--ko: #b3453c;
|
| 26 |
--ko-soft: #f7ecea;
|
|
|
|
|
|
|
| 27 |
--warn: #966916;
|
| 28 |
--warn-soft: #f7f1e5;
|
| 29 |
--na: #7d8782;
|
|
@@ -50,6 +52,8 @@
|
|
| 50 |
--ok-soft: #18291f;
|
| 51 |
--ko: #e18b83;
|
| 52 |
--ko-soft: #2c201f;
|
|
|
|
|
|
|
| 53 |
--warn: #d9b468;
|
| 54 |
--warn-soft: #2d281d;
|
| 55 |
--na: #9ba59f;
|
|
@@ -412,6 +416,10 @@
|
|
| 412 |
background: var(--ko);
|
| 413 |
}
|
| 414 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
.signal.quota,
|
| 416 |
.signal.forbidden {
|
| 417 |
background: var(--warn);
|
|
@@ -621,6 +629,10 @@
|
|
| 621 |
background: var(--ko-soft);
|
| 622 |
}
|
| 623 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 624 |
.matrix-cell.quota .cell-button,
|
| 625 |
.matrix-cell.forbidden .cell-button {
|
| 626 |
background: var(--warn-soft);
|
|
@@ -654,6 +666,10 @@
|
|
| 654 |
color: var(--ko);
|
| 655 |
}
|
| 656 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 657 |
.matrix-cell.quota .cell-value,
|
| 658 |
.matrix-cell.forbidden .cell-value {
|
| 659 |
color: var(--warn);
|
|
@@ -858,6 +874,12 @@
|
|
| 858 |
font-size: 12px;
|
| 859 |
}
|
| 860 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 861 |
#p-summary {
|
| 862 |
display: flex;
|
| 863 |
align-items: center;
|
|
@@ -1146,6 +1168,7 @@
|
|
| 1146 |
<span class="legend-item"><span class="signal available"></span>Available</span>
|
| 1147 |
<span class="legend-item"><span class="signal partial"></span>Partial AZ coverage</span>
|
| 1148 |
<span class="legend-item"><span class="signal unavailable"></span>No capacity</span>
|
|
|
|
| 1149 |
<span class="legend-item"><span class="signal quota"></span>Quota or denied</span>
|
| 1150 |
<span class="legend-item"><span class="signal unsupported"></span>N.O. / n/s / stale</span>
|
| 1151 |
</div>
|
|
@@ -1173,6 +1196,7 @@
|
|
| 1173 |
<div id="p-age"></div>
|
| 1174 |
</div>
|
| 1175 |
<div id="p-summary"></div>
|
|
|
|
| 1176 |
<div id="p-azs"></div>
|
| 1177 |
<button class="button primary" id="p-probe" type="button">Probe region now</button>
|
| 1178 |
</section>
|
|
@@ -1184,6 +1208,7 @@
|
|
| 1184 |
<button type="button" data-hours="24">24h</button>
|
| 1185 |
<button type="button" data-hours="48" class="active">48h</button>
|
| 1186 |
<button type="button" data-hours="168">7d</button>
|
|
|
|
| 1187 |
</div>
|
| 1188 |
</div>
|
| 1189 |
<div id="chartwrap"><div id="tooltip"></div></div>
|
|
@@ -1250,7 +1275,14 @@ function humanStatus(status) {
|
|
| 1250 |
}[status] || status;
|
| 1251 |
}
|
| 1252 |
|
| 1253 |
-
function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1254 |
if (!result) {
|
| 1255 |
return {
|
| 1256 |
cls: "never",
|
|
@@ -1264,11 +1296,14 @@ function cellModel(result) {
|
|
| 1264 |
}
|
| 1265 |
|
| 1266 |
const counts = azCounts(result);
|
| 1267 |
-
const status = result.status === "
|
|
|
|
|
|
|
| 1268 |
const label = {
|
| 1269 |
available: `${counts.available}/${counts.total}`,
|
| 1270 |
partial: `${counts.available}/${counts.total}`,
|
| 1271 |
unavailable: counts.total ? `0/${counts.total}` : "none",
|
|
|
|
| 1272 |
quota: "quota",
|
| 1273 |
forbidden: "denied",
|
| 1274 |
unsupported: "n/s",
|
|
@@ -1284,8 +1319,8 @@ function cellModel(result) {
|
|
| 1284 |
status,
|
| 1285 |
label,
|
| 1286 |
age: fmtAge(result.checked_at),
|
| 1287 |
-
title,
|
| 1288 |
-
detail: humanStatus(status),
|
| 1289 |
count,
|
| 1290 |
};
|
| 1291 |
}
|
|
@@ -1407,6 +1442,7 @@ function renderMatrix(data) {
|
|
| 1407 |
const empty = document.getElementById("empty-filter");
|
| 1408 |
const copy = document.getElementById("matrix-copy");
|
| 1409 |
copy.textContent = `${visibleTypes.length} of ${data.instance_types.length} instance types across ${data.regions.length} regions`;
|
|
|
|
| 1410 |
|
| 1411 |
const colgroup = `
|
| 1412 |
<colgroup>
|
|
@@ -1453,7 +1489,7 @@ function renderMatrix(data) {
|
|
| 1453 |
</th>
|
| 1454 |
${displayRegions.map(region => {
|
| 1455 |
const result = (data.state[type] || {})[region];
|
| 1456 |
-
const model = cellModel(result);
|
| 1457 |
const probing = isProbing(type, region);
|
| 1458 |
const aria = `${type} in ${region}: ${model.detail}${model.count ? `, ${model.count}` : ""}, ${model.age}`;
|
| 1459 |
return `<td class="matrix-cell ${esc(model.cls)}${probing ? " probing" : ""}" data-type="${esc(type)}" data-region="${esc(region)}">
|
|
@@ -1531,6 +1567,12 @@ function renderPanelStatus() {
|
|
| 1531 |
const age = document.getElementById("p-age");
|
| 1532 |
const summary = document.getElementById("p-summary");
|
| 1533 |
const box = document.getElementById("p-azs");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1534 |
if (!result) {
|
| 1535 |
age.textContent = "";
|
| 1536 |
summary.innerHTML = `<span class="panel-status"><span class="signal never"></span>Never probed</span>`;
|
|
|
|
| 24 |
--ok-soft: #e9f4ed;
|
| 25 |
--ko: #b3453c;
|
| 26 |
--ko-soft: #f7ecea;
|
| 27 |
+
--recent: #245fb5;
|
| 28 |
+
--recent-soft: #eaf1fc;
|
| 29 |
--warn: #966916;
|
| 30 |
--warn-soft: #f7f1e5;
|
| 31 |
--na: #7d8782;
|
|
|
|
| 52 |
--ok-soft: #18291f;
|
| 53 |
--ko: #e18b83;
|
| 54 |
--ko-soft: #2c201f;
|
| 55 |
+
--recent: #8ab7fb;
|
| 56 |
+
--recent-soft: #1b293d;
|
| 57 |
--warn: #d9b468;
|
| 58 |
--warn-soft: #2d281d;
|
| 59 |
--na: #9ba59f;
|
|
|
|
| 416 |
background: var(--ko);
|
| 417 |
}
|
| 418 |
|
| 419 |
+
.signal.recent {
|
| 420 |
+
background: var(--recent);
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
.signal.quota,
|
| 424 |
.signal.forbidden {
|
| 425 |
background: var(--warn);
|
|
|
|
| 629 |
background: var(--ko-soft);
|
| 630 |
}
|
| 631 |
|
| 632 |
+
.matrix-cell.recent .cell-button {
|
| 633 |
+
background: var(--recent-soft);
|
| 634 |
+
}
|
| 635 |
+
|
| 636 |
.matrix-cell.quota .cell-button,
|
| 637 |
.matrix-cell.forbidden .cell-button {
|
| 638 |
background: var(--warn-soft);
|
|
|
|
| 666 |
color: var(--ko);
|
| 667 |
}
|
| 668 |
|
| 669 |
+
.matrix-cell.recent .cell-value {
|
| 670 |
+
color: var(--recent);
|
| 671 |
+
}
|
| 672 |
+
|
| 673 |
.matrix-cell.quota .cell-value,
|
| 674 |
.matrix-cell.forbidden .cell-value {
|
| 675 |
color: var(--warn);
|
|
|
|
| 874 |
font-size: 12px;
|
| 875 |
}
|
| 876 |
|
| 877 |
+
#p-last-available {
|
| 878 |
+
color: var(--recent);
|
| 879 |
+
font-size: 12px;
|
| 880 |
+
line-height: 1.5;
|
| 881 |
+
}
|
| 882 |
+
|
| 883 |
#p-summary {
|
| 884 |
display: flex;
|
| 885 |
align-items: center;
|
|
|
|
| 1168 |
<span class="legend-item"><span class="signal available"></span>Available</span>
|
| 1169 |
<span class="legend-item"><span class="signal partial"></span>Partial AZ coverage</span>
|
| 1170 |
<span class="legend-item"><span class="signal unavailable"></span>No capacity</span>
|
| 1171 |
+
<span class="legend-item"><span class="signal recent"></span><span id="recent-legend">Seen in past 10d; unavailable now</span></span>
|
| 1172 |
<span class="legend-item"><span class="signal quota"></span>Quota or denied</span>
|
| 1173 |
<span class="legend-item"><span class="signal unsupported"></span>N.O. / n/s / stale</span>
|
| 1174 |
</div>
|
|
|
|
| 1196 |
<div id="p-age"></div>
|
| 1197 |
</div>
|
| 1198 |
<div id="p-summary"></div>
|
| 1199 |
+
<p id="p-last-available" hidden></p>
|
| 1200 |
<div id="p-azs"></div>
|
| 1201 |
<button class="button primary" id="p-probe" type="button">Probe region now</button>
|
| 1202 |
</section>
|
|
|
|
| 1208 |
<button type="button" data-hours="24">24h</button>
|
| 1209 |
<button type="button" data-hours="48" class="active">48h</button>
|
| 1210 |
<button type="button" data-hours="168">7d</button>
|
| 1211 |
+
<button type="button" data-hours="240">10d</button>
|
| 1212 |
</div>
|
| 1213 |
</div>
|
| 1214 |
<div id="chartwrap"><div id="tooltip"></div></div>
|
|
|
|
| 1275 |
}[status] || status;
|
| 1276 |
}
|
| 1277 |
|
| 1278 |
+
function recentAvailabilityAt(type, region) {
|
| 1279 |
+
const ts = ((META.recent_availability || {})[type] || {})[region];
|
| 1280 |
+
const now = Date.now() / 1000;
|
| 1281 |
+
const cutoff = now - META.recent_availability_days * 86400;
|
| 1282 |
+
return Number.isFinite(ts) && cutoff <= ts && ts <= now ? ts : null;
|
| 1283 |
+
}
|
| 1284 |
+
|
| 1285 |
+
function cellModel(result, lastAvailableAt = null) {
|
| 1286 |
if (!result) {
|
| 1287 |
return {
|
| 1288 |
cls: "never",
|
|
|
|
| 1296 |
}
|
| 1297 |
|
| 1298 |
const counts = azCounts(result);
|
| 1299 |
+
const status = result.status === "unavailable" && lastAvailableAt ? "recent"
|
| 1300 |
+
: result.status === "available" && counts.available < counts.total ? "partial" : result.status;
|
| 1301 |
+
const lastSeen = lastAvailableAt ? new Date(lastAvailableAt * 1000) : null;
|
| 1302 |
const label = {
|
| 1303 |
available: `${counts.available}/${counts.total}`,
|
| 1304 |
partial: `${counts.available}/${counts.total}`,
|
| 1305 |
unavailable: counts.total ? `0/${counts.total}` : "none",
|
| 1306 |
+
recent: `Seen ${lastSeen?.toLocaleDateString([], { day: "numeric", month: "short" })}`,
|
| 1307 |
quota: "quota",
|
| 1308 |
forbidden: "denied",
|
| 1309 |
unsupported: "n/s",
|
|
|
|
| 1319 |
status,
|
| 1320 |
label,
|
| 1321 |
age: fmtAge(result.checked_at),
|
| 1322 |
+
title: lastSeen ? `${humanStatus(result.status)}. Last available: ${lastSeen.toLocaleString()}\n${title}` : title,
|
| 1323 |
+
detail: status === "recent" ? `No capacity now; last available ${lastSeen.toLocaleString()}` : humanStatus(status),
|
| 1324 |
count,
|
| 1325 |
};
|
| 1326 |
}
|
|
|
|
| 1442 |
const empty = document.getElementById("empty-filter");
|
| 1443 |
const copy = document.getElementById("matrix-copy");
|
| 1444 |
copy.textContent = `${visibleTypes.length} of ${data.instance_types.length} instance types across ${data.regions.length} regions`;
|
| 1445 |
+
document.getElementById("recent-legend").textContent = `Seen in past ${data.recent_availability_days}d; unavailable now`;
|
| 1446 |
|
| 1447 |
const colgroup = `
|
| 1448 |
<colgroup>
|
|
|
|
| 1489 |
</th>
|
| 1490 |
${displayRegions.map(region => {
|
| 1491 |
const result = (data.state[type] || {})[region];
|
| 1492 |
+
const model = cellModel(result, recentAvailabilityAt(type, region));
|
| 1493 |
const probing = isProbing(type, region);
|
| 1494 |
const aria = `${type} in ${region}: ${model.detail}${model.count ? `, ${model.count}` : ""}, ${model.age}`;
|
| 1495 |
return `<td class="matrix-cell ${esc(model.cls)}${probing ? " probing" : ""}" data-type="${esc(type)}" data-region="${esc(region)}">
|
|
|
|
| 1567 |
const age = document.getElementById("p-age");
|
| 1568 |
const summary = document.getElementById("p-summary");
|
| 1569 |
const box = document.getElementById("p-azs");
|
| 1570 |
+
const lastAvailableAt = recentAvailabilityAt(PANEL.type, PANEL.region);
|
| 1571 |
+
const historyNote = document.getElementById("p-last-available");
|
| 1572 |
+
historyNote.hidden = !lastAvailableAt;
|
| 1573 |
+
historyNote.textContent = lastAvailableAt
|
| 1574 |
+
? `Capacity last observed ${new Date(lastAvailableAt * 1000).toLocaleString()} (within the past ${META.recent_availability_days} days).`
|
| 1575 |
+
: "";
|
| 1576 |
if (!result) {
|
| 1577 |
age.textContent = "";
|
| 1578 |
summary.innerHTML = `<span class="panel-status"><span class="signal never"></span>Never probed</span>`;
|
tests/test_recent_availability.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import queue
|
| 3 |
+
import tempfile
|
| 4 |
+
import unittest
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from unittest.mock import patch
|
| 7 |
+
|
| 8 |
+
from gpu_capacity import history, main
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class RecentAvailabilityTests(unittest.TestCase):
|
| 12 |
+
def setUp(self):
|
| 13 |
+
self.now = 1791417600 # 2026-10-08 00:00 UTC
|
| 14 |
+
self.cutoff = self.now - 10 * 86400
|
| 15 |
+
for name in ("_hist", "_last_available"):
|
| 16 |
+
mock = patch.dict(getattr(history, name), {}, clear=True)
|
| 17 |
+
mock.start()
|
| 18 |
+
self.addCleanup(mock.stop)
|
| 19 |
+
for name, value in (("_queue", queue.Queue()), ("_today", []), ("_today_day", [""])):
|
| 20 |
+
mock = patch.object(history, name, value)
|
| 21 |
+
mock.start()
|
| 22 |
+
self.addCleanup(mock.stop)
|
| 23 |
+
clock = patch.object(history.time, "time", return_value=self.now)
|
| 24 |
+
clock.start()
|
| 25 |
+
self.addCleanup(clock.stop)
|
| 26 |
+
|
| 27 |
+
def result(self, ts, status="available", available=True):
|
| 28 |
+
return {
|
| 29 |
+
"checked_at": ts,
|
| 30 |
+
"status": status,
|
| 31 |
+
"azs": {"us-east-1a": {"status": "available" if available else "unavailable"}},
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
def test_positive_observation_survives_failures_and_out_of_order_results(self):
|
| 35 |
+
for result in (
|
| 36 |
+
self.result(self.now - 100),
|
| 37 |
+
self.result(self.now - 50, "unavailable", False),
|
| 38 |
+
self.result(self.now - 200),
|
| 39 |
+
self.result(self.now - 25, "quota", False),
|
| 40 |
+
self.result(self.now, "available", False),
|
| 41 |
+
):
|
| 42 |
+
history.record("g7e.2xlarge", {"us-east-1": result})
|
| 43 |
+
self.assertEqual(
|
| 44 |
+
history.recent_availability(10),
|
| 45 |
+
{"g7e.2xlarge": {"us-east-1": self.now - 100}},
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
def test_window_boundary_and_expiration_without_new_probes(self):
|
| 49 |
+
history._last_available.update({
|
| 50 |
+
("g7e.2xlarge", "us-east-1"): self.cutoff,
|
| 51 |
+
("g7e.2xlarge", "us-east-2"): self.cutoff - 1,
|
| 52 |
+
("g7e.48xlarge", "us-east-1"): self.now + 1,
|
| 53 |
+
})
|
| 54 |
+
self.assertEqual(
|
| 55 |
+
history.recent_availability(10),
|
| 56 |
+
{"g7e.2xlarge": {"us-east-1": self.cutoff}},
|
| 57 |
+
)
|
| 58 |
+
with patch.object(history.time, "time", return_value=self.now + 0.5):
|
| 59 |
+
self.assertEqual(history.recent_availability(10), {})
|
| 60 |
+
|
| 61 |
+
def test_restore_history_finds_latest_success_not_latest_probe(self):
|
| 62 |
+
observations = [
|
| 63 |
+
(self.now - 86400, 1, "available"),
|
| 64 |
+
(self.now - 2 * 86400, 2, "available"),
|
| 65 |
+
(self.now - 100, 0, "unavailable"),
|
| 66 |
+
(self.now - 50, 0, "available"),
|
| 67 |
+
(self.now, 1, "quota"),
|
| 68 |
+
]
|
| 69 |
+
with tempfile.TemporaryDirectory() as directory:
|
| 70 |
+
path = Path(directory) / "2026-10-07.jsonl"
|
| 71 |
+
path.write_text("\n".join(json.dumps({
|
| 72 |
+
"t": ts, "a": available, "n": 2, "s": status,
|
| 73 |
+
"type": "g7e.2xlarge", "region": "us-east-1",
|
| 74 |
+
}) for ts, available, status in observations))
|
| 75 |
+
with patch.object(history.config, "HISTORY_DIR", directory):
|
| 76 |
+
history.load()
|
| 77 |
+
self.assertEqual(
|
| 78 |
+
history.recent_availability(10),
|
| 79 |
+
{"g7e.2xlarge": {"us-east-1": self.now - 86400}},
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
def test_state_api_exposes_history_without_changing_current_status(self):
|
| 83 |
+
history.record("g7e.2xlarge", {"us-east-1": self.result(self.now - 86400)})
|
| 84 |
+
current = {"g7e.2xlarge": {"us-east-1": self.result(self.now, "unavailable", False)}}
|
| 85 |
+
with patch.object(main.state, "get", return_value=current), patch.object(main.prober, "gpu_info", return_value={}):
|
| 86 |
+
response = main.get_state()
|
| 87 |
+
self.assertEqual(response["recent_availability_days"], 10)
|
| 88 |
+
self.assertEqual(response["recent_availability"]["g7e.2xlarge"]["us-east-1"], self.now - 86400)
|
| 89 |
+
self.assertEqual(response["state"]["g7e.2xlarge"]["us-east-1"]["status"], "unavailable")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
if __name__ == "__main__":
|
| 93 |
+
unittest.main()
|
tests/test_ui.cjs
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const assert = require("node:assert/strict");
|
| 2 |
+
const fs = require("node:fs");
|
| 3 |
+
const path = require("node:path");
|
| 4 |
+
const test = require("node:test");
|
| 5 |
+
const vm = require("node:vm");
|
| 6 |
+
|
| 7 |
+
const html = fs.readFileSync(path.join(__dirname, "../gpu_capacity/static/index.html"), "utf8");
|
| 8 |
+
const script = html.match(/<script>([\s\S]*?)<\/script>/)[1]
|
| 9 |
+
.split('document.getElementById("grid").addEventListener')[0];
|
| 10 |
+
const now = Date.UTC(2026, 9, 8) / 1000;
|
| 11 |
+
|
| 12 |
+
function setup(status = "unavailable", lastSeen = now - 86400) {
|
| 13 |
+
const elements = {};
|
| 14 |
+
const context = vm.createContext({
|
| 15 |
+
Date: class extends Date { static now() { return now * 1000; } },
|
| 16 |
+
document: { getElementById(id) {
|
| 17 |
+
return elements[id] ||= { classList: { toggle() {} }, style: {}, textContent: "", innerHTML: "" };
|
| 18 |
+
} },
|
| 19 |
+
});
|
| 20 |
+
vm.runInContext(script, context);
|
| 21 |
+
const data = {
|
| 22 |
+
instance_types: ["g7e.2xlarge"], regions: ["us-east-1"], gpu_info: {},
|
| 23 |
+
stale_after_seconds: 900, recent_availability_days: 10,
|
| 24 |
+
recent_availability: { "g7e.2xlarge": { "us-east-1": lastSeen } },
|
| 25 |
+
state: { "g7e.2xlarge": { "us-east-1": {
|
| 26 |
+
status, checked_at: now - 30,
|
| 27 |
+
azs: { "us-east-1a": { status }, "us-east-1b": { status } },
|
| 28 |
+
} } },
|
| 29 |
+
};
|
| 30 |
+
vm.runInContext(`META = ${JSON.stringify(data)}; PANEL.type = "g7e.2xlarge"; PANEL.region = "us-east-1";`, context);
|
| 31 |
+
return { context, elements, run: source => vm.runInContext(source, context) };
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function model(ui) {
|
| 35 |
+
return ui.run('cellModel(META.state["g7e.2xlarge"]["us-east-1"], recentAvailabilityAt("g7e.2xlarge", "us-east-1"))');
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
test("unavailable cells with recent capacity turn blue and disclose the date", () => {
|
| 39 |
+
const ui = setup();
|
| 40 |
+
const cell = model(ui);
|
| 41 |
+
assert.equal(cell.status, "recent");
|
| 42 |
+
assert.match(cell.label, /^Seen /);
|
| 43 |
+
assert.match(cell.detail, /No capacity now; last available/);
|
| 44 |
+
ui.run("renderMatrix(META); renderPanelStatus();");
|
| 45 |
+
assert.match(ui.elements.grid.innerHTML, /matrix-cell recent/);
|
| 46 |
+
assert.match(ui.elements["p-summary"].innerHTML, /No capacity/);
|
| 47 |
+
assert.match(ui.elements["p-last-available"].textContent, /Capacity last observed/);
|
| 48 |
+
assert.equal(ui.elements["p-last-available"].hidden, false);
|
| 49 |
+
assert.match(ui.elements["recent-legend"].textContent, /10d/);
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
test("fresh capacity and quota failures retain their current colors", () => {
|
| 53 |
+
for (const status of ["available", "quota", "forbidden", "error", "unsupported", "not_offered"]) {
|
| 54 |
+
assert.equal(model(setup(status)).status, status);
|
| 55 |
+
}
|
| 56 |
+
const ui = setup("available");
|
| 57 |
+
ui.run('META.state["g7e.2xlarge"]["us-east-1"].azs["us-east-1b"].status = "unavailable"');
|
| 58 |
+
assert.equal(model(ui).status, "partial");
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
test("missing, future, or expired observations stay red, including after the time window expires", () => {
|
| 62 |
+
for (const timestamp of [null, now + 1, now - 10 * 86400 - 1]) {
|
| 63 |
+
assert.equal(model(setup("unavailable", timestamp)).status, "unavailable");
|
| 64 |
+
}
|
| 65 |
+
const ui = setup("unavailable", now - 10 * 86400);
|
| 66 |
+
assert.equal(model(ui).status, "recent");
|
| 67 |
+
ui.run(`Date.now = () => ${(now + 1) * 1000}`);
|
| 68 |
+
assert.equal(model(ui).status, "unavailable");
|
| 69 |
+
});
|
| 70 |
+
|
| 71 |
+
test("historical capacity is not counted or filtered as current availability", () => {
|
| 72 |
+
const ui = setup();
|
| 73 |
+
ui.run("VIEW.availableOnly = true;");
|
| 74 |
+
assert.equal(ui.run("filteredTypes(META).length"), 0);
|
| 75 |
+
assert.equal(ui.run('coverageSummary(META, "g7e.2xlarge").available'), 0);
|
| 76 |
+
assert.match(html, /data-hours="240">10d/);
|
| 77 |
+
});
|