aggregate_api 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aggregate_api/__init__.py +41 -0
- aggregate_api/__main__.py +154 -0
- aggregate_api/app.py +206 -0
- aggregate_api/audit.py +395 -0
- aggregate_api/bounds.py +331 -0
- aggregate_api/cache.py +319 -0
- aggregate_api/capability.py +823 -0
- aggregate_api/completion.py +219 -0
- aggregate_api/config.py +363 -0
- aggregate_api/cors.py +61 -0
- aggregate_api/examples.py +620 -0
- aggregate_api/layer_pricing.py +840 -0
- aggregate_api/library.py +94 -0
- aggregate_api/library_notes.py +96 -0
- aggregate_api/models.py +1407 -0
- aggregate_api/net.py +281 -0
- aggregate_api/pnl.py +101 -0
- aggregate_api/pricing.py +778 -0
- aggregate_api/resources.py +257 -0
- aggregate_api/routes/__init__.py +8 -0
- aggregate_api/routes/decl.py +327 -0
- aggregate_api/routes/examples.py +82 -0
- aggregate_api/routes/meta.py +282 -0
- aggregate_api/routes/objects.py +4119 -0
- aggregate_api/routes/status.py +466 -0
- aggregate_api/serializers.py +565 -0
- aggregate_api/sessions.py +353 -0
- aggregate_api/static/aggregate-api-logo-512.png +0 -0
- aggregate_api/static/aggregate-api-logo.png +0 -0
- aggregate_api/static/aggregate-api-trim.png +0 -0
- aggregate_api/static/android-chrome-192x192.png +0 -0
- aggregate_api/static/android-chrome-512x512.png +0 -0
- aggregate_api/static/apple-touch-icon.png +0 -0
- aggregate_api/static/assets/bootstrap-icons-BeopsB42.woff +0 -0
- aggregate_api/static/assets/bootstrap-icons-mSm7cUeB.woff2 +0 -0
- aggregate_api/static/assets/bootstrap-ohb1VZ53.js +5 -0
- aggregate_api/static/assets/codemirror-h62DHGGa.js +14 -0
- aggregate_api/static/assets/csv-grid.worker-DKzHGXac.js +4 -0
- aggregate_api/static/assets/echarts-B7o9sc00.js +40 -0
- aggregate_api/static/assets/echarts-gl-DG1Uf6wE.js +4282 -0
- aggregate_api/static/assets/lite-CUlcD8p4.css +1 -0
- aggregate_api/static/assets/lite-Dd2TnT4M.js +1 -0
- aggregate_api/static/assets/main-Bxhxa55v.css +9 -0
- aggregate_api/static/assets/main-CmoEiPit.js +9 -0
- aggregate_api/static/assets/tables-BHCF7qIF.js +8 -0
- aggregate_api/static/assets/tables-CxvajLr7.css +1 -0
- aggregate_api/static/favicon-16x16.png +0 -0
- aggregate_api/static/favicon-32x32.png +0 -0
- aggregate_api/static/favicon.ico +0 -0
- aggregate_api/static/index.html +912 -0
- aggregate_api/static/lite.html +83 -0
- aggregate_api/static/logo.png +0 -0
- aggregate_api/static/site.webmanifest +14 -0
- aggregate_api/static/sw.js +78 -0
- aggregate_api/status.py +536 -0
- aggregate_api/status_page.html +546 -0
- aggregate_api/tables.py +316 -0
- aggregate_api-1.0.0.dist-info/METADATA +187 -0
- aggregate_api-1.0.0.dist-info/RECORD +63 -0
- aggregate_api-1.0.0.dist-info/WHEEL +5 -0
- aggregate_api-1.0.0.dist-info/entry_points.txt +2 -0
- aggregate_api-1.0.0.dist-info/licenses/LICENSE +28 -0
- aggregate_api-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"""The operator's view: ``GET /v1/status`` and the page that renders it.
|
|
2
|
+
|
|
3
|
+
Two routes and one gate. ``/v1/status`` is the contract, a JSON document that
|
|
4
|
+
is scriptable, curl'able from the VPS, and the thing a future monitor would
|
|
5
|
+
poll. ``/v1/status/page`` is one self-contained HTML file that fetches it and
|
|
6
|
+
re-renders on a timer. Both are read only, both are private, and neither
|
|
7
|
+
appears in the OpenAPI schema.
|
|
8
|
+
|
|
9
|
+
Private by construction, three layers
|
|
10
|
+
-------------------------------------
|
|
11
|
+
|
|
12
|
+
None of the three is trusted alone, and the first needs no trust in this file
|
|
13
|
+
at all.
|
|
14
|
+
|
|
15
|
+
1. **The public Caddy block answers ``/v1/status*`` with 404**, beside the
|
|
16
|
+
``/docs`` and ``/openapi.json`` matcher already there. The app is never
|
|
17
|
+
reached, so nothing here can expose the page. One prefix covers both routes
|
|
18
|
+
and every future one, which is why they live under ``/v1`` together.
|
|
19
|
+
2. **This module's** :func:`require_private`, for the case where a Caddy block
|
|
20
|
+
is edited, reloaded wrong, or a second front door appears. Deny by default:
|
|
21
|
+
see ``net.py`` for why the peer address cannot be the test and why the
|
|
22
|
+
forwarded chain is read from the last element.
|
|
23
|
+
3. **An explicit zone header**, ``X-Aggapi-Zone: private``, set by the VPN Caddy
|
|
24
|
+
block and stripped by the public one. Off by default (author ruling,
|
|
25
|
+
2026-08-18); ``AGGAPI_STATUS_REQUIRE_ZONE_HEADER=true`` turns it on. It is
|
|
26
|
+
the only layer that survives a mistake in the CIDR list, so it ships built
|
|
27
|
+
and documented rather than unwritten.
|
|
28
|
+
|
|
29
|
+
Refusal is 404 and not 403, so the page's existence is not advertised, and it is
|
|
30
|
+
logged at WARNING: a refusal on a correctly configured box means something
|
|
31
|
+
changed.
|
|
32
|
+
|
|
33
|
+
Read only, always
|
|
34
|
+
-----------------
|
|
35
|
+
|
|
36
|
+
**No route under ``/v1/status`` may build, evict, clear, or mutate anything.**
|
|
37
|
+
Every number is read off a live structure or queried from the audit log. A
|
|
38
|
+
"clear the cache" button would be a separate plan with its own gate, and it is
|
|
39
|
+
not this one. ``tests/test_status.py`` asserts the invariant by snapshotting the
|
|
40
|
+
cache and the counters around a request.
|
|
41
|
+
|
|
42
|
+
What it costs
|
|
43
|
+
-------------
|
|
44
|
+
|
|
45
|
+
Tens of milliseconds, and flat in the age of the deployment. The audit queries
|
|
46
|
+
are windowed, indexed, limited, and share one connection; the cache sizes are
|
|
47
|
+
arithmetic on ``log2`` rather than a walk into numpy
|
|
48
|
+
(:func:`aggregate_api.status.estimated_bytes` says why); the resource block
|
|
49
|
+
never blocks on a CPU interval. Measured on the Windows development box at 25 to
|
|
50
|
+
40 ms, of which ``psutil`` is about 17: reading process memory and handle counts
|
|
51
|
+
is markedly more expensive on Windows than the ``/proc`` reads it does on the
|
|
52
|
+
Linux VPS, so the deployed number is smaller than the development one. Against a
|
|
53
|
+
ten second refresh either is free.
|
|
54
|
+
|
|
55
|
+
``generated_in_ms`` rides in the payload so the page reports its own cost and a
|
|
56
|
+
regression is self evident rather than inferred.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
import logging
|
|
62
|
+
import platform
|
|
63
|
+
import sys
|
|
64
|
+
import time
|
|
65
|
+
from collections import Counter
|
|
66
|
+
from datetime import datetime, timezone
|
|
67
|
+
from importlib.metadata import version as _pkg_version
|
|
68
|
+
from importlib.resources import files
|
|
69
|
+
from pathlib import Path
|
|
70
|
+
|
|
71
|
+
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
|
72
|
+
from fastapi.responses import HTMLResponse
|
|
73
|
+
|
|
74
|
+
from .. import models
|
|
75
|
+
from .. import resources as resource_block
|
|
76
|
+
from .. import status as status_state
|
|
77
|
+
from ..audit import AuditLog
|
|
78
|
+
from ..cache import ObjectCache
|
|
79
|
+
from ..config import Settings, get_settings
|
|
80
|
+
from ..library import get_underwriter
|
|
81
|
+
from ..net import is_private_request
|
|
82
|
+
from ..sessions import SessionRegistry
|
|
83
|
+
from . import objects as objects_routes
|
|
84
|
+
|
|
85
|
+
log = logging.getLogger(__name__)
|
|
86
|
+
|
|
87
|
+
router = APIRouter()
|
|
88
|
+
|
|
89
|
+
#: Layer three's header and the value it must carry. The VPN Caddy block sets
|
|
90
|
+
#: it; the public block deletes any inbound one, so it cannot be forged.
|
|
91
|
+
ZONE_HEADER = "X-Aggapi-Zone"
|
|
92
|
+
ZONE_PRIVATE = "private"
|
|
93
|
+
|
|
94
|
+
#: Windows the build panel reports over. An hour answers "what is happening
|
|
95
|
+
#: now" and a day answers "what has this box been doing", and two numbers side
|
|
96
|
+
#: by side say more than either: a busy hour inside a quiet day is a session,
|
|
97
|
+
#: and a quiet hour inside a busy day is one that just ended.
|
|
98
|
+
BUILD_WINDOWS = (("hour", 3600.0), ("day", 86400.0))
|
|
99
|
+
|
|
100
|
+
#: What the oversight charter's state snapshot records, so the page can say when
|
|
101
|
+
#: the library has moved. Not a check and not a floor: a mismatch is news, not
|
|
102
|
+
#: an error, and the direction it moves in is the useful part. See
|
|
103
|
+
#: ``T:/worktrees/CLAUDE.md``, "State snapshot".
|
|
104
|
+
CHARTER_EXHIBITS = 12
|
|
105
|
+
CHARTER_CHARTS = 8
|
|
106
|
+
|
|
107
|
+
#: How much of a cached program the payload carries. Same limit the key-scope
|
|
108
|
+
#: buffer uses, so one pasted portfolio cannot push everything else off the
|
|
109
|
+
#: page, and the page offers an expand control for the rest.
|
|
110
|
+
PROGRAM_CHARS = status_state.PROGRAM_CHARS
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def require_private(request: Request,
|
|
114
|
+
settings: Settings = Depends(get_settings)) -> str:
|
|
115
|
+
"""Admit a request only if it demonstrably came from a private origin.
|
|
116
|
+
|
|
117
|
+
Parameters
|
|
118
|
+
----------
|
|
119
|
+
request : starlette.requests.Request
|
|
120
|
+
settings : Settings
|
|
121
|
+
For ``private_cidrs`` and ``status_require_zone_header``.
|
|
122
|
+
|
|
123
|
+
Returns
|
|
124
|
+
-------
|
|
125
|
+
str
|
|
126
|
+
The address the request was admitted on, so a route can report it.
|
|
127
|
+
|
|
128
|
+
Raises
|
|
129
|
+
------
|
|
130
|
+
HTTPException
|
|
131
|
+
404 for anything not admitted.
|
|
132
|
+
|
|
133
|
+
Notes
|
|
134
|
+
-----
|
|
135
|
+
**Deny by default.** The verdict comes from
|
|
136
|
+
:func:`aggregate_api.net.is_private_request`, which answers False for an
|
|
137
|
+
address it could not establish as readily as for one outside the list, so
|
|
138
|
+
there is no path where an unexpected shape falls through to allowed.
|
|
139
|
+
|
|
140
|
+
**404 rather than 403.** A 403 confirms the route exists, which is
|
|
141
|
+
information this page should not give away; a 404 is what an unmounted
|
|
142
|
+
route would say. The refusal is logged and buffered instead, where the
|
|
143
|
+
operator who is entitled to know can see it.
|
|
144
|
+
|
|
145
|
+
**The single-hop assumption is load bearing.** Exactly one trusted proxy
|
|
146
|
+
sits in front of this app, so the last forwarded element is the address
|
|
147
|
+
Caddy observed. Add a second proxy and that index is wrong and the gate
|
|
148
|
+
opens. ``net.py``'s module docstring and ``human-hints.md`` both carry this;
|
|
149
|
+
it is repeated here because this is the function it would break.
|
|
150
|
+
"""
|
|
151
|
+
allowed, address = is_private_request(request, settings.private_cidrs)
|
|
152
|
+
reason = None if allowed else "address not in AGGAPI_PRIVATE_CIDRS"
|
|
153
|
+
if allowed and settings.status_require_zone_header:
|
|
154
|
+
zone = (request.headers.get(ZONE_HEADER) or "").strip().lower()
|
|
155
|
+
if zone != ZONE_PRIVATE:
|
|
156
|
+
allowed, reason = False, f"missing or wrong {ZONE_HEADER}"
|
|
157
|
+
if not allowed:
|
|
158
|
+
status_state.record_refusal(address, request.url.path)
|
|
159
|
+
log.warning("status route refused: address=%s path=%s reason=%s",
|
|
160
|
+
address, request.url.path, reason)
|
|
161
|
+
raise HTTPException(status_code=404, detail="Not Found")
|
|
162
|
+
return address
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _recipe_count(uw) -> int | None:
|
|
166
|
+
"""How many recipes a base holds, or ``None`` if it will not say.
|
|
167
|
+
|
|
168
|
+
Notes
|
|
169
|
+
-----
|
|
170
|
+
``len(uw._recipes)`` is a private attribute of a stable-tier library class,
|
|
171
|
+
taken deliberately and recorded in the oversight charter's tolerated list
|
|
172
|
+
(author ruling, 2026-08-18). The public route is ``Underwriter.recipes``,
|
|
173
|
+
which builds a pandas frame per call, and this page calls this once for the
|
|
174
|
+
process base and once per live session on every refresh. The read is
|
|
175
|
+
guarded, so a rename upstream costs one field reading ``None`` rather than a
|
|
176
|
+
broken route.
|
|
177
|
+
"""
|
|
178
|
+
if uw is None:
|
|
179
|
+
return None
|
|
180
|
+
try:
|
|
181
|
+
return len(uw._recipes)
|
|
182
|
+
except (AttributeError, TypeError):
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _loaded_underwriter():
|
|
187
|
+
"""The process recipe base, but only if something has already loaded it.
|
|
188
|
+
|
|
189
|
+
Returns
|
|
190
|
+
-------
|
|
191
|
+
aggregate.underwriter.Underwriter or None
|
|
192
|
+
``None`` when nothing in this process has resolved the library yet.
|
|
193
|
+
|
|
194
|
+
Notes
|
|
195
|
+
-----
|
|
196
|
+
**Reporting on the library must not cause the library to be read.**
|
|
197
|
+
:func:`aggregate_api.library.get_underwriter` loads on first call, which
|
|
198
|
+
takes about two seconds for a custom ``.agg``, so a status route that simply
|
|
199
|
+
called it would make the first page load on a cold process pay for the read,
|
|
200
|
+
and would report a "boot library load time" that its own request had caused.
|
|
201
|
+
A page that changes what it measures is not an instrument.
|
|
202
|
+
|
|
203
|
+
``lru_cache`` publishes ``cache_info()``, so "has anything loaded this yet"
|
|
204
|
+
is answerable without touching it. Before the first build the page says the
|
|
205
|
+
library is not loaded, which is a true and useful thing to say.
|
|
206
|
+
"""
|
|
207
|
+
if get_underwriter.cache_info().currsize == 0:
|
|
208
|
+
return None
|
|
209
|
+
return get_underwriter()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _identity(settings: Settings) -> dict:
|
|
213
|
+
"""Versions, interpreter, process and library, the version-skew block.
|
|
214
|
+
|
|
215
|
+
Notes
|
|
216
|
+
-----
|
|
217
|
+
Both package versions come from ``importlib.metadata``, which reports what
|
|
218
|
+
was recorded when the editable install was built rather than what
|
|
219
|
+
``pyproject.toml`` says now. That is the standing trap in both repos'
|
|
220
|
+
CLAUDE.md: a library bump without ``uv sync --extra dev`` leaves the server
|
|
221
|
+
reporting stale versions indefinitely. Showing both beside the process start
|
|
222
|
+
time is what turns that from an hour of confusion into a glance.
|
|
223
|
+
"""
|
|
224
|
+
facts = status_state.process_facts()
|
|
225
|
+
uw = _loaded_underwriter()
|
|
226
|
+
recipes = _recipe_count(uw)
|
|
227
|
+
return {
|
|
228
|
+
"version": _pkg_version("aggregate_api"),
|
|
229
|
+
"aggregate_version": _pkg_version("aggregate"),
|
|
230
|
+
"tables_version": _pkg_version("greater-tables"),
|
|
231
|
+
"python": sys.version.split()[0],
|
|
232
|
+
"platform": platform.platform(terse=True),
|
|
233
|
+
"host": settings.host,
|
|
234
|
+
"port": settings.port,
|
|
235
|
+
"library": settings.library or "aggregate bundled library.agg",
|
|
236
|
+
"library_loaded": uw is not None,
|
|
237
|
+
"library_recipes": recipes,
|
|
238
|
+
**facts,
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _settings_block(settings: Settings) -> dict:
|
|
243
|
+
"""The live config knobs, so a deploy's actual settings are readable.
|
|
244
|
+
|
|
245
|
+
Notes
|
|
246
|
+
-----
|
|
247
|
+
Every field here is a knob an operator sets, and none of them is a secret:
|
|
248
|
+
the api has no credentials to leak. ``private_cidrs`` is echoed as text
|
|
249
|
+
rather than as parsed networks so what is shown is what was configured,
|
|
250
|
+
which is the form a typo is visible in.
|
|
251
|
+
"""
|
|
252
|
+
return {
|
|
253
|
+
"log2_cap": settings.log2_cap,
|
|
254
|
+
"log2_default": settings.log2_default,
|
|
255
|
+
"build_timeout_s": settings.build_timeout_s,
|
|
256
|
+
"cache_max": settings.cache_max,
|
|
257
|
+
"session_max": settings.session_max,
|
|
258
|
+
"session_ttl_s": settings.session_ttl_s,
|
|
259
|
+
"max_chart_detail": settings.max_chart_detail,
|
|
260
|
+
"cors_origins": settings.cors_origins,
|
|
261
|
+
"audit_db": settings.audit_db,
|
|
262
|
+
"serve_spa": settings.serve_spa,
|
|
263
|
+
"private_cidrs": settings.private_cidrs_raw,
|
|
264
|
+
"status_require_zone_header": settings.status_require_zone_header,
|
|
265
|
+
"status_refresh_s": settings.status_refresh_s,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _cache_block(cache: ObjectCache) -> dict:
|
|
270
|
+
"""Counters plus a row per entry, LRU first so the next eviction reads first."""
|
|
271
|
+
block = cache.stats()
|
|
272
|
+
rows = []
|
|
273
|
+
for oid, entry in cache.items():
|
|
274
|
+
program, truncated = status_state.truncate(entry.decl, PROGRAM_CHARS)
|
|
275
|
+
rows.append({
|
|
276
|
+
"id": oid,
|
|
277
|
+
"kind": entry.kind,
|
|
278
|
+
"name": entry.name,
|
|
279
|
+
"log2": entry.log2,
|
|
280
|
+
"bs": entry.bs,
|
|
281
|
+
"created_at": entry.created_at.isoformat(timespec="seconds"),
|
|
282
|
+
"notes": len(entry.notes),
|
|
283
|
+
"estimated_bytes": status_state.estimated_bytes(entry.obj, entry.log2),
|
|
284
|
+
"program": program,
|
|
285
|
+
"truncated": truncated,
|
|
286
|
+
})
|
|
287
|
+
block["rows"] = rows
|
|
288
|
+
return block
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _watch_block(settings: Settings, cache: ObjectCache, audit: AuditLog) -> dict:
|
|
292
|
+
"""The small things that earn their place, section 4.7 of the plan.
|
|
293
|
+
|
|
294
|
+
Notes
|
|
295
|
+
-----
|
|
296
|
+
Four items, each answering a question nothing else in this process surfaces.
|
|
297
|
+
|
|
298
|
+
**Notes volume.** How many cached objects carry a library warning, and which
|
|
299
|
+
warning is most common. A spike means the library started saying something
|
|
300
|
+
new about ordinary programs, which is exactly the ripple the oversight
|
|
301
|
+
charter asks each side to check for and which no route reports.
|
|
302
|
+
|
|
303
|
+
**Capability drift.** The live exhibit and chart registry counts against the
|
|
304
|
+
numbers the charter's state snapshot records. A library bump that registers
|
|
305
|
+
a new exhibit announces itself here rather than being noticed when somebody
|
|
306
|
+
goes looking for a tab.
|
|
307
|
+
|
|
308
|
+
**Static build freshness.** Whether the SPA bundle is mounted and how old
|
|
309
|
+
its ``index.html`` is. The two-stage build is a documented trap, and "the
|
|
310
|
+
deploy did not rebuild the web app" is otherwise diagnosed by confusion.
|
|
311
|
+
|
|
312
|
+
**Dead config.** A standing slot for settings that are read by nothing. It
|
|
313
|
+
is empty today, and the slot is kept so the next one is noticed.
|
|
314
|
+
"""
|
|
315
|
+
entries = [entry for _, entry in cache.items()]
|
|
316
|
+
notes = Counter(note for entry in entries for note in entry.notes)
|
|
317
|
+
try:
|
|
318
|
+
from aggregate.charts import CHARTS
|
|
319
|
+
from aggregate.exhibits import EXHIBITS
|
|
320
|
+
|
|
321
|
+
registries = {
|
|
322
|
+
"exhibits": len(EXHIBITS),
|
|
323
|
+
"charts": len(CHARTS),
|
|
324
|
+
"charter_exhibits": CHARTER_EXHIBITS,
|
|
325
|
+
"charter_charts": CHARTER_CHARTS,
|
|
326
|
+
"drifted": len(EXHIBITS) != CHARTER_EXHIBITS or len(CHARTS) != CHARTER_CHARTS,
|
|
327
|
+
}
|
|
328
|
+
except Exception as exc: # noqa: BLE001 (a provisional module, by charter)
|
|
329
|
+
registries = {"unavailable": str(exc)}
|
|
330
|
+
return {
|
|
331
|
+
"notes": {
|
|
332
|
+
"entries_with_notes": sum(1 for entry in entries if entry.notes),
|
|
333
|
+
"distinct": len(notes),
|
|
334
|
+
"most_common": [{"note": text, "count": count}
|
|
335
|
+
for text, count in notes.most_common(5)],
|
|
336
|
+
},
|
|
337
|
+
"registries": registries,
|
|
338
|
+
"static": _static_freshness(settings),
|
|
339
|
+
"audit_db_bytes": audit.size_bytes(),
|
|
340
|
+
"unused_settings": [],
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _static_freshness(settings: Settings) -> dict:
|
|
345
|
+
"""Whether the SPA bundle is mounted, and when its ``index.html`` was written.
|
|
346
|
+
|
|
347
|
+
Notes
|
|
348
|
+
-----
|
|
349
|
+
The import of ``_resolve_static_dir`` is function-local because ``app.py``
|
|
350
|
+
imports this module to mount it, so a module-level import would be a cycle.
|
|
351
|
+
Reimplementing the three lines instead would be a second copy of a
|
|
352
|
+
precedence order that has already changed once, which is the worse of the
|
|
353
|
+
two.
|
|
354
|
+
"""
|
|
355
|
+
from ..app import _resolve_static_dir
|
|
356
|
+
|
|
357
|
+
directory = _resolve_static_dir(settings)
|
|
358
|
+
if directory is None:
|
|
359
|
+
return {"mounted": False, "reason": "serve_spa is off or no directory set"}
|
|
360
|
+
index = Path(directory) / "index.html"
|
|
361
|
+
try:
|
|
362
|
+
stat = index.stat()
|
|
363
|
+
except OSError:
|
|
364
|
+
return {"mounted": False, "directory": str(directory),
|
|
365
|
+
"reason": "no index.html; the web build has not run"}
|
|
366
|
+
built = datetime.fromtimestamp(stat.st_mtime, timezone.utc)
|
|
367
|
+
return {
|
|
368
|
+
"mounted": True,
|
|
369
|
+
"directory": str(directory),
|
|
370
|
+
"index_built_at": built.isoformat(timespec="seconds"),
|
|
371
|
+
"index_age_s": round(time.time() - stat.st_mtime, 1),
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@router.get("/status", response_model=models.StatusResponse, include_in_schema=False)
|
|
376
|
+
def status(response: Response,
|
|
377
|
+
address: str = Depends(require_private),
|
|
378
|
+
settings: Settings = Depends(get_settings),
|
|
379
|
+
cache: ObjectCache = Depends(objects_routes._get_cache),
|
|
380
|
+
audit: AuditLog = Depends(objects_routes._get_audit),
|
|
381
|
+
sessions: SessionRegistry = Depends(objects_routes._get_sessions)) -> dict:
|
|
382
|
+
"""Everything the operator's page shows, as JSON.
|
|
383
|
+
|
|
384
|
+
Notes
|
|
385
|
+
-----
|
|
386
|
+
``no-store`` rather than a short max-age. The whole document is a snapshot
|
|
387
|
+
of a moving process, and a cached copy of it is worse than no copy: an
|
|
388
|
+
operator reading a thirty second old queue depth would draw the wrong
|
|
389
|
+
conclusion and have no way to tell.
|
|
390
|
+
|
|
391
|
+
The session baseline is the reference underwriter's recipe count, passed to
|
|
392
|
+
:meth:`aggregate_api.sessions.SessionRegistry.rows` so each session's column
|
|
393
|
+
reads what that session declared rather than the library plus what it
|
|
394
|
+
declared.
|
|
395
|
+
"""
|
|
396
|
+
started = time.monotonic()
|
|
397
|
+
response.headers["Cache-Control"] = "no-store"
|
|
398
|
+
baseline = _recipe_count(_loaded_underwriter()) or 0
|
|
399
|
+
session_block = sessions.stats()
|
|
400
|
+
session_block["rows"] = [
|
|
401
|
+
{**row, "session_id": status_state.short_session_id(row["session_id"])}
|
|
402
|
+
for row in sessions.rows(baseline=baseline)
|
|
403
|
+
]
|
|
404
|
+
builds = {"live": status_state.build_state()}
|
|
405
|
+
builds.update(audit.window_summaries(BUILD_WINDOWS))
|
|
406
|
+
# The slowest-build rows come from the audit log, which stores full session
|
|
407
|
+
# ids on purpose: it is the permanent record. This payload is not, so they
|
|
408
|
+
# are shortened on the way out, and the shortening happens in exactly the
|
|
409
|
+
# three places a session id can reach the wire.
|
|
410
|
+
for label, _ in BUILD_WINDOWS:
|
|
411
|
+
for row in builds[label]["slowest"]:
|
|
412
|
+
row["session_id"] = status_state.short_session_id(row["session_id"])
|
|
413
|
+
payload = {
|
|
414
|
+
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
415
|
+
"generated_in_ms": 0.0,
|
|
416
|
+
"identity": _identity(settings),
|
|
417
|
+
"settings": _settings_block(settings),
|
|
418
|
+
"sessions": session_block,
|
|
419
|
+
"cache": _cache_block(cache),
|
|
420
|
+
"chart_cache": status_state.cache_state(
|
|
421
|
+
"chart",
|
|
422
|
+
len(objects_routes._chart_cache), objects_routes._CHART_CACHE_MAX),
|
|
423
|
+
"exhibit_cache": status_state.cache_state(
|
|
424
|
+
"exhibit",
|
|
425
|
+
len(objects_routes._exhibit_cache),
|
|
426
|
+
objects_routes._EXHIBIT_CACHE_MAX),
|
|
427
|
+
"builds": builds,
|
|
428
|
+
"key_scope": status_state.key_scope_state(),
|
|
429
|
+
"resources": resource_block.snapshot(settings.audit_db),
|
|
430
|
+
"gate": {**status_state.gate_state(), "admitted_on": address},
|
|
431
|
+
"watch": _watch_block(settings, cache, audit),
|
|
432
|
+
}
|
|
433
|
+
payload["generated_in_ms"] = round((time.monotonic() - started) * 1000, 2)
|
|
434
|
+
return payload
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
@router.get("/status/page", include_in_schema=False)
|
|
438
|
+
def status_page(_: str = Depends(require_private),
|
|
439
|
+
settings: Settings = Depends(get_settings)) -> Response:
|
|
440
|
+
"""The operator's page: one self-contained file, no build step.
|
|
441
|
+
|
|
442
|
+
Notes
|
|
443
|
+
-----
|
|
444
|
+
**It must not go through Vite and must not live in** ``static/``. The web
|
|
445
|
+
build wipes that directory on every run, so anything placed there is deleted
|
|
446
|
+
by the next deploy, and a status page whose delivery depends on the pipeline
|
|
447
|
+
it exists to report on cannot report on that pipeline failing. So it is one
|
|
448
|
+
HTML file with inline CSS and inline JS, shipped inside the package and
|
|
449
|
+
served by this route.
|
|
450
|
+
|
|
451
|
+
The precedent is ``routes/meta.py``'s ``_ASSETS`` allow-list, whose comment
|
|
452
|
+
reads "Deliberately not a StaticFiles mount". Same argument, one file, and
|
|
453
|
+
no path from the request reaches the filesystem at all.
|
|
454
|
+
|
|
455
|
+
The refresh interval is substituted into the page rather than fetched,
|
|
456
|
+
because the page's first job on a slow box is to render, and one fewer round
|
|
457
|
+
trip before it can is worth a string replace.
|
|
458
|
+
"""
|
|
459
|
+
body = _page_html().replace("__REFRESH_MS__",
|
|
460
|
+
str(int(settings.status_refresh_s * 1000)))
|
|
461
|
+
return HTMLResponse(content=body, headers={"Cache-Control": "no-store"})
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _page_html() -> str:
|
|
465
|
+
"""Read the packaged page. Not cached: it is small, and a reload should show edits."""
|
|
466
|
+
return (files("aggregate_api") / "status_page.html").read_text(encoding="utf-8")
|