DefStream 0.1.2__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.
Files changed (54) hide show
  1. DefStream/Broker/Configuration.py +18 -0
  2. DefStream/Broker/Connection.py +97 -0
  3. DefStream/Broker/Consumer.py +59 -0
  4. DefStream/Broker/Errors.py +14 -0
  5. DefStream/Broker/Event.py +35 -0
  6. DefStream/Broker/Implements/__init__.py +1 -0
  7. DefStream/Broker/Policies.py +25 -0
  8. DefStream/Broker/Protocol.py +45 -0
  9. DefStream/Broker/__init__.py +47 -0
  10. DefStream/Descriptor/DataObject.py +384 -0
  11. DefStream/Descriptor/Types.py +229 -0
  12. DefStream/Descriptor/Value.py +132 -0
  13. DefStream/Descriptor/__init__.py +172 -0
  14. DefStream/Inspector/Extension.py +69 -0
  15. DefStream/Inspector/Extensions/Console.html +373 -0
  16. DefStream/Inspector/Extensions/Console.py +396 -0
  17. DefStream/Inspector/Extensions/Dashboard.html +206 -0
  18. DefStream/Inspector/Extensions/Dashboard.py +114 -0
  19. DefStream/Inspector/Extensions/HealthCheck.py +92 -0
  20. DefStream/Inspector/Extensions/Metrics.py +281 -0
  21. DefStream/Inspector/Extensions/Specifications.py +160 -0
  22. DefStream/Inspector/Extensions/__init__.py +13 -0
  23. DefStream/Inspector/Inspector.py +216 -0
  24. DefStream/Inspector/Response.py +14 -0
  25. DefStream/Inspector/__init__.py +9 -0
  26. DefStream/Validator/Pattern.py +36 -0
  27. DefStream/Validator/Patterns/Array.py +30 -0
  28. DefStream/Validator/Patterns/Common.py +93 -0
  29. DefStream/Validator/Patterns/Compare.py +180 -0
  30. DefStream/Validator/Patterns/Condition.py +60 -0
  31. DefStream/Validator/Patterns/DataObject.py +85 -0
  32. DefStream/Validator/Patterns/DateTime.py +111 -0
  33. DefStream/Validator/Patterns/Number.py +43 -0
  34. DefStream/Validator/Patterns/Object.py +150 -0
  35. DefStream/Validator/Patterns/Size.py +110 -0
  36. DefStream/Validator/Patterns/String.py +85 -0
  37. DefStream/Validator/Patterns/Type.py +350 -0
  38. DefStream/Validator/Patterns/__init__.py +137 -0
  39. DefStream/Validator/Validate.py +29 -0
  40. DefStream/Validator/Validator.py +30 -0
  41. DefStream/Validator/__init__.py +11 -0
  42. DefStream/Worker/Executor.py +298 -0
  43. DefStream/Worker/Message.py +103 -0
  44. DefStream/Worker/Pool.py +203 -0
  45. DefStream/Worker/Run.py +187 -0
  46. DefStream/Worker/Specification.py +91 -0
  47. DefStream/Worker/Worker.py +321 -0
  48. DefStream/Worker/__init__.py +49 -0
  49. DefStream/__init__.py +0 -0
  50. defstream-0.1.2.dist-info/METADATA +1197 -0
  51. defstream-0.1.2.dist-info/RECORD +54 -0
  52. defstream-0.1.2.dist-info/WHEEL +5 -0
  53. defstream-0.1.2.dist-info/licenses/LICENSE +674 -0
  54. defstream-0.1.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,396 @@
1
+ from hashlib import sha256
2
+ from hmac import compare_digest
3
+ from html import escape
4
+ from importlib.resources import files
5
+ from json import (
6
+ dumps,
7
+ loads,
8
+ )
9
+ from logging import getLogger
10
+ from math import isfinite
11
+ from re import sub
12
+ from threading import BoundedSemaphore
13
+ from time import monotonic
14
+ from typing import (
15
+ Callable,
16
+ Optional,
17
+ Protocol,
18
+ TYPE_CHECKING,
19
+ )
20
+ from urllib.parse import urlsplit
21
+
22
+ from ...Broker.Connection import (
23
+ Connection,
24
+ Requestable,
25
+ TaskSender,
26
+ )
27
+ from ...Broker.Errors import RemoteError
28
+ from ...Worker.Message import (
29
+ message_body,
30
+ request,
31
+ send,
32
+ )
33
+ from ..Extension import Extension
34
+ from ..Response import Response
35
+ from .Specifications import Specifications
36
+
37
+ if TYPE_CHECKING:
38
+ from ..Inspector import Inspector
39
+
40
+ __all__ = (
41
+ 'Console',
42
+ )
43
+
44
+ logger = getLogger(__name__)
45
+
46
+
47
+ class Producer(
48
+ Connection,
49
+ TaskSender,
50
+ Protocol,
51
+ ):
52
+ pass
53
+
54
+
55
+ def json_response(
56
+ status: str,
57
+ value: dict,
58
+ ) -> Response:
59
+ return Response(
60
+ status,
61
+ dumps(
62
+ value,
63
+ ensure_ascii=False,
64
+ allow_nan=False,
65
+ ).encode('utf-8'),
66
+ 'application/json; charset=utf-8',
67
+ (
68
+ (
69
+ 'X-Content-Type-Options',
70
+ 'nosniff',
71
+ ),
72
+ ),
73
+ )
74
+
75
+
76
+ class Console(Extension):
77
+ methods = (
78
+ 'GET',
79
+ 'HEAD',
80
+ 'POST',
81
+ )
82
+
83
+ def __init__(
84
+ self,
85
+ path: str,
86
+ *,
87
+ broker: Callable[[], Producer],
88
+ token: Optional[str] = None,
89
+ timeout: float = 5,
90
+ title: str = 'DefStream',
91
+ description: Optional[str] = None,
92
+ ):
93
+ super().__init__(path)
94
+ if not callable(broker):
95
+ raise TypeError('Console broker must be a factory creating a new producer connection')
96
+ if token is not None and (not isinstance(
97
+ token,
98
+ str,
99
+ ) or len(token) < 16):
100
+ raise ValueError('Console token must contain at least 16 characters')
101
+ if isinstance(
102
+ timeout,
103
+ bool,
104
+ ) or not isfinite(timeout) or timeout <= 0:
105
+ raise ValueError('Console RPC timeout must be finite and positive')
106
+ self.broker = broker
107
+ if not isinstance(
108
+ title,
109
+ str,
110
+ ) or not title:
111
+ raise ValueError('Console title must be a nonempty string')
112
+ if description is not None and not isinstance(
113
+ description,
114
+ str,
115
+ ):
116
+ raise TypeError('Console description must be a string or None')
117
+ self.title = title
118
+ self.description = description
119
+ self.token_digest = sha256(token.encode('utf-8')).digest() if token is not None else None
120
+ self.timeout = timeout
121
+ self.slots = BoundedSemaphore(4)
122
+
123
+ def __call__(
124
+ self,
125
+ inspector: 'Inspector',
126
+ ) -> Response:
127
+ page = files(__package__).joinpath('Console.html').read_bytes()
128
+ values = {
129
+ 'title': escape(self.title),
130
+ 'description': escape(self.description) if self.description is not None else '',
131
+ }
132
+ page = sub(
133
+ r'\{\{console_(title|description)\}\}',
134
+ lambda match: values[match[1]],
135
+ page.decode('utf-8'),
136
+ ).encode('utf-8')
137
+ if self.token_digest is None:
138
+ page = page.replace(
139
+ b'data-auth-required="true"',
140
+ b'data-auth-required="false"',
141
+ )
142
+ return Response(
143
+ '200 OK',
144
+ page,
145
+ 'text/html; charset=utf-8',
146
+ (
147
+ (
148
+ 'Content-Security-Policy',
149
+ "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
150
+ ),
151
+ (
152
+ 'X-Content-Type-Options',
153
+ 'nosniff',
154
+ ),
155
+ (
156
+ 'Referrer-Policy',
157
+ 'no-referrer',
158
+ ),
159
+ ),
160
+ )
161
+
162
+ def handle(
163
+ self,
164
+ inspector: 'Inspector',
165
+ environ: dict,
166
+ ) -> Response:
167
+ method = environ['REQUEST_METHOD']
168
+ if method in (
169
+ 'GET',
170
+ 'HEAD',
171
+ ) and environ.get('HTTP_ACCEPT') != 'application/json':
172
+ return self(inspector)
173
+ authorization = environ.get(
174
+ 'HTTP_AUTHORIZATION',
175
+ '',
176
+ )
177
+ if self.token_digest is not None and (not authorization.startswith('Bearer ') or not compare_digest(
178
+ sha256(authorization[7:].encode('utf-8')).digest(),
179
+ self.token_digest,
180
+ )):
181
+ logger.warning('Inspector Console rejected an unauthorized request')
182
+ return json_response(
183
+ '401 Unauthorized',
184
+ {'error': 'A valid Bearer token is required'},
185
+ )
186
+ origin = environ.get('HTTP_ORIGIN')
187
+ if origin is not None:
188
+ try:
189
+ parsed = urlsplit(origin)
190
+ valid_origin = parsed.scheme in (
191
+ 'http',
192
+ 'https',
193
+ ) and parsed.netloc == environ.get('HTTP_HOST')
194
+ except ValueError:
195
+ valid_origin = False
196
+ if not valid_origin:
197
+ logger.warning('Inspector Console rejected a cross-origin request')
198
+ return json_response(
199
+ '403 Forbidden',
200
+ {'error': 'Cross-origin requests are not allowed'},
201
+ )
202
+ if not self.slots.acquire(blocking=False):
203
+ return json_response(
204
+ '429 Too Many Requests',
205
+ {'error': 'Too many concurrent console requests'},
206
+ )
207
+ try:
208
+ if method in (
209
+ 'GET',
210
+ 'HEAD',
211
+ ):
212
+ return self.catalog(inspector)
213
+ return self.execute(
214
+ inspector,
215
+ environ,
216
+ )
217
+ except Exception:
218
+ logger.exception('Inspector Console request failed')
219
+ return json_response(
220
+ '500 Internal Server Error',
221
+ {'error': 'Inspector console failed; consult the worker logs'},
222
+ )
223
+ finally:
224
+ self.slots.release()
225
+
226
+ def catalog(
227
+ self,
228
+ inspector: 'Inspector',
229
+ ) -> Response:
230
+ producer = self.broker()
231
+ try:
232
+ if inspector.worker is not None and producer is inspector.worker.broker:
233
+ raise ValueError('Console requires a separate producer, not the Worker connection')
234
+ rpc = isinstance(
235
+ producer,
236
+ Requestable,
237
+ )
238
+ specifications = next(
239
+ (
240
+ extension for extension in inspector.exts.values()
241
+ if isinstance(
242
+ extension,
243
+ Specifications,
244
+ )
245
+ ),
246
+ None,
247
+ )
248
+ if specifications is None:
249
+ specifications = Specifications(self.path)
250
+ document = specifications.document(inspector)
251
+ return json_response(
252
+ '200 OK',
253
+ {
254
+ 'specification': document,
255
+ 'rpc': rpc,
256
+ 'timeout': self.timeout,
257
+ },
258
+ )
259
+ finally:
260
+ if inspector.worker is None or producer is not inspector.worker.broker:
261
+ producer.close()
262
+
263
+ def execute(
264
+ self,
265
+ inspector: 'Inspector',
266
+ environ: dict,
267
+ ) -> Response:
268
+ worker = inspector.worker
269
+ if worker is None or not worker.running.is_set() or worker.stopping.is_set() or worker.finished.is_set():
270
+ return json_response(
271
+ '503 Service Unavailable',
272
+ {'error': 'Worker is not running'},
273
+ )
274
+ if environ.get(
275
+ 'CONTENT_TYPE',
276
+ '',
277
+ ).split(';')[0].strip().lower() != 'application/json':
278
+ return json_response(
279
+ '415 Unsupported Media Type',
280
+ {'error': 'Use application/json'},
281
+ )
282
+ length = environ.get(
283
+ 'CONTENT_LENGTH',
284
+ '',
285
+ )
286
+ if not length.isascii() or not length.isdecimal():
287
+ return json_response(
288
+ '411 Length Required',
289
+ {'error': 'A Content-Length is required'},
290
+ )
291
+ size = int(length)
292
+ if size > 65536:
293
+ return json_response(
294
+ '413 Payload Too Large',
295
+ {'error': 'Console requests must not exceed 64 KiB'},
296
+ )
297
+ raw = environ['wsgi.input'].read(size)
298
+ try:
299
+ if len(raw) != size:
300
+ raise ValueError('Incomplete request body')
301
+ payload = loads(raw)
302
+ if not isinstance(
303
+ payload,
304
+ dict,
305
+ ) or set(payload) != {
306
+ 'task',
307
+ 'mode',
308
+ 'body',
309
+ }:
310
+ raise ValueError('Expected task, mode and body fields')
311
+ if payload['mode'] not in (
312
+ 'send',
313
+ 'request',
314
+ ):
315
+ raise ValueError('Mode must be send or request')
316
+ body = message_body(payload['body'])
317
+ dumps(
318
+ body,
319
+ allow_nan=False,
320
+ )
321
+ task = next(
322
+ (
323
+ task for task in worker.tasks if task.__name__ == payload['task']
324
+ ),
325
+ None,
326
+ )
327
+ if task is None:
328
+ raise ValueError('Unknown registered task')
329
+ if sum(task.__name__ == payload['task'] for task in worker.tasks) != 1:
330
+ raise ValueError('Ambiguous registered task name')
331
+ except (
332
+ ValueError,
333
+ TypeError,
334
+ UnicodeDecodeError,
335
+ ) as error:
336
+ logger.warning('Inspector Console rejected invalid input')
337
+ return json_response(
338
+ '400 Bad Request',
339
+ {'error': str(error)},
340
+ )
341
+ started = monotonic()
342
+ producer = self.broker()
343
+ if producer is worker.broker:
344
+ raise ValueError('Console requires a separate producer, not the Worker connection')
345
+ try:
346
+ if payload['mode'] == 'request' and not isinstance(
347
+ producer,
348
+ Requestable,
349
+ ):
350
+ return json_response(
351
+ '501 Not Implemented',
352
+ {'error': 'This broker does not support RPC'},
353
+ )
354
+ producer.connect()
355
+ try:
356
+ if payload['mode'] == 'request':
357
+ result = request(
358
+ body,
359
+ task=task,
360
+ broker=producer,
361
+ timeout=self.timeout,
362
+ )
363
+ else:
364
+ result = send(
365
+ body,
366
+ task=task,
367
+ broker=producer,
368
+ )
369
+ except TimeoutError:
370
+ logger.warning('Inspector Console RPC timed out')
371
+ return json_response(
372
+ '504 Gateway Timeout',
373
+ {'error': 'RPC timed out; delivered work is not cancelled'},
374
+ )
375
+ except RemoteError as error:
376
+ logger.warning(
377
+ 'Inspector Console received a remote task error: %s',
378
+ error.code,
379
+ )
380
+ return json_response(
381
+ '502 Bad Gateway',
382
+ {
383
+ 'error': str(error),
384
+ 'code': error.code,
385
+ },
386
+ )
387
+ return json_response(
388
+ '200 OK',
389
+ {
390
+ 'mode': payload['mode'],
391
+ 'result': result,
392
+ 'elapsed': monotonic() - started,
393
+ },
394
+ )
395
+ finally:
396
+ producer.close()
@@ -0,0 +1,206 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>{{dashboard_title}}</title>
7
+ <style>
8
+ :root { color-scheme: light; font: 14px/1.55 system-ui, -apple-system, sans-serif; color: #263343; background: #f7f8fa; }
9
+ * { box-sizing: border-box; }
10
+ body { margin: 0; }
11
+ header { background: #fff; border-bottom: 1px solid #e1e5eb; }
12
+ .heading, main { max-width: 1440px; margin: auto; padding: 24px 32px; }
13
+ .heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
14
+ h1 { margin: 0; font-size: 22px; letter-spacing: -.6px; overflow-wrap: anywhere; }
15
+ h2 { margin: 0; font-size: 15px; }
16
+ p { margin: 6px 0 0; }
17
+ .subtitle, .muted { color: #657184; font-size: 12px; }
18
+ .subtitle:empty { display: none; }
19
+ .badge { padding: 5px 10px; border: 1px solid #c6dfcf; border-radius: 5px; color: #286f40; background: #f0faf3; font-size: 11px; white-space: nowrap; }
20
+ .toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
21
+ #notice { margin: 0; font-size: 12px; color: #536374; }
22
+ #notice[data-state="error"] { color: #ab303d; }
23
+ button { padding: 8px 14px; border: 1px solid #cdd5df; border-radius: 5px; font: 600 12px system-ui, sans-serif; background: #fff; color: #38485b; cursor: pointer; white-space: nowrap; }
24
+ button:hover { background: #edf3f8; }
25
+ select { padding: 8px; border: 1px solid #cdd5df; border-radius: 5px; background: #fff; color: #38485b; }
26
+ .controls { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
27
+ :focus-visible { outline: 3px solid #89b8ff; outline-offset: 3px; }
28
+ .cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-bottom: 24px; }
29
+ .card, .panel { min-width: 0; background: #fff; border: 1px solid #e1e5eb; border-radius: 7px; }
30
+ .card { padding: 20px; }
31
+ .label { font-size: 12px; color: #657184; }
32
+ .value { margin-top: 8px; font-size: 28px; font-weight: 650; line-height: 1.3; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
33
+ .rate { color: #27834c; }
34
+ .failure { color: #b33b45; }
35
+ .hint { color: #768295; font-size: 11px; margin-top: 8px; }
36
+ .panel-heading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; padding: 18px 20px; border-bottom: 1px solid #e1e5eb; }
37
+ .table-scroll { overflow: auto; }
38
+ table { width: 100%; border-collapse: collapse; font-size: 12px; }
39
+ th { background: #f7f9fb; color: #657184; font-weight: 600; white-space: nowrap; }
40
+ th, td { padding: 13px 18px; text-align: right; border-bottom: 1px solid #edf0f4; font-variant-numeric: tabular-nums; }
41
+ th:first-child, td:first-child { text-align: left; }
42
+ td:first-child { max-width: 400px; overflow-wrap: anywhere; font: 12px/1.7 Consolas, monospace; }
43
+ tr:last-child td { border-bottom: 0; }
44
+ #empty { padding: 24px; color: #657184; font-size: 12px; }
45
+ .notes { margin-top: 20px; color: #657184; font-size: 12px; }
46
+ .stale .cards, .stale .panel { opacity: .55; }
47
+ [hidden] { display: none !important; }
48
+ @media(max-width: 800px) {
49
+ .heading, main { padding: 20px 16px; }
50
+ .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
51
+ .card { padding: 16px; }
52
+ .value { font-size: 24px; }
53
+ }
54
+ @media(max-width: 420px) {
55
+ .cards { grid-template-columns: 1fr; }
56
+ .heading { align-items: start; }
57
+ }
58
+ </style>
59
+ </head>
60
+ <body>
61
+ <header><div class="heading">
62
+ <div><h1>{{dashboard_title}}</h1><p class="subtitle">{{dashboard_description}}</p></div>
63
+ <span class="badge">LIVE DASHBOARD</span>
64
+ </div></header>
65
+ <main>
66
+ <div class="toolbar">
67
+ <p id="notice" role="status" aria-live="polite">Connecting to worker...</p>
68
+ <div class="controls">
69
+ <label for="window">View</label><select id="window"><option value="1">Last second</option><option value="60">Last minute</option></select>
70
+ <button id="pause" type="button" aria-pressed="false">Pause updates</button>
71
+ </div>
72
+ </div>
73
+ <div class="cards">
74
+ <section class="card"><div class="label">Window completions</div><div id="rate" class="value rate">--</div><p id="window-hint" class="hint">Completed in the last fully elapsed second</p></section>
75
+ <section class="card"><div class="label">In flight</div><div id="active" class="value">--</div><p class="hint">Submitted, not yet completed</p></section>
76
+ <section class="card"><div class="label">Worker lifecycle</div><div id="state" class="value">--</div><p class="hint">Lifecycle only, not a health probe</p></section>
77
+ <section class="card"><div class="label">Succeeded</div><div id="success" class="value">--</div><p class="hint">Since worker startup</p></section>
78
+ <section class="card"><div class="label">Failed</div><div id="failure" class="value failure">--</div><p class="hint">Includes failed retry attempts</p></section>
79
+ <section class="card"><div class="label">Uptime</div><div id="uptime" class="value">--</div><p class="hint">Since Metrics initialization</p></section>
80
+ <section class="card"><div class="label">Window success / failure</div><div id="window-outcomes" class="value">--</div><p class="hint">Completed attempts in selected window</p></section>
81
+ <section class="card"><div class="label">Window failure rate</div><div id="failure-rate" class="value">--</div><p class="hint">Failed / completed attempts</p></section>
82
+ <section class="card"><div class="label">Window average time</div><div id="average" class="value">--</div><p class="hint">Includes pool wait and hooks</p></section>
83
+ <section class="card"><div class="label">Window maximum time</div><div id="maximum" class="value">--</div><p class="hint">Slowest completion in selected window</p></section>
84
+ <section class="card"><div class="label">Oldest in flight</div><div id="oldest" class="value">--</div><p class="hint">Elapsed since submission, including pool wait</p></section>
85
+ </div>
86
+ <section class="panel" aria-labelledby="tasks-title">
87
+ <div class="panel-heading"><h2 id="tasks-title">Task activity</h2><span class="muted">Current rates &amp; cumulative totals</span></div>
88
+ <div class="table-scroll"><table>
89
+ <thead><tr><th scope="col">Task</th><th scope="col">In flight</th><th scope="col">Total success</th><th scope="col">Total failure</th><th id="rate-heading" scope="col">Tasks / sec</th><th scope="col">Window avg.</th><th scope="col">Window max.</th><th scope="col">Window success</th><th scope="col">Window failure</th><th scope="col">Failure rate</th><th scope="col">Oldest in flight</th></tr></thead>
90
+ <tbody id="tasks"></tbody>
91
+ </table></div>
92
+ <p id="empty" hidden>No tasks registered.</p>
93
+ </section>
94
+ <p class="notes">Refreshes about every second. Windows end at the last fully elapsed monotonic second (less than one second behind live totals).
95
+ Last minute counts actual completions in 60 seconds, not a multiplied per-second estimate. Startup windows include only data since startup.
96
+ Counts represent execution attempts, not unique messages or confirmed ACKs. Average time includes pool wait and hooks.
97
+ This page shows one worker only, with no persistent history.</p>
98
+ </main>
99
+ <script>
100
+ 'use strict';
101
+ const el = id => document.getElementById(id);
102
+ const number = new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 });
103
+ let latest = null;
104
+ let timer = null;
105
+ let pending = false;
106
+ let paused = false;
107
+ const milliseconds = value => number.format(value * 1000) + ' ms';
108
+ const failureRate = window => window.success + window.failure ? number.format(window.failure / (window.success + window.failure) * 100) + '%' : '--';
109
+ function duration(seconds) {
110
+ const total = Math.floor(seconds);
111
+ return [Math.floor(total / 3600), Math.floor(total % 3600 / 60), total % 60].map(value => String(value).padStart(2, '0')).join(':');
112
+ }
113
+ function render(snapshot) {
114
+ const seconds = el('window').value || '1';
115
+ const unit = seconds === '60' ? 'min' : 'sec';
116
+ const totals = { active: 0, success: 0, failure: 0 };
117
+ const window = { success: 0, failure: 0, duration: 0, max_duration: 0 };
118
+ let oldest = 0;
119
+ const rows = Object.entries(snapshot.tasks).map(([name, task]) => {
120
+ for (const key of Object.keys(totals)) totals[key] += task[key];
121
+ const period = task.windows[seconds];
122
+ for (const key of ['success', 'failure', 'duration']) window[key] += period[key];
123
+ window.max_duration = Math.max(window.max_duration, period.max_duration);
124
+ oldest = Math.max(oldest, task.oldest_active);
125
+ const count = period.success + period.failure;
126
+ const row = document.createElement('tr');
127
+ const values = [name, number.format(task.active), number.format(task.success), number.format(task.failure),
128
+ number.format(count), count ? milliseconds(period.duration / count) : '--',
129
+ count ? milliseconds(period.max_duration) : '--', number.format(period.success), number.format(period.failure),
130
+ failureRate(period), task.active ? milliseconds(task.oldest_active) : '--'];
131
+ values.forEach((value, index) => {
132
+ const cell = document.createElement('td');
133
+ if (index >= 4) cell.className = 'task-rate';
134
+ cell.textContent = value;
135
+ row.append(cell);
136
+ });
137
+ return row;
138
+ });
139
+ el('tasks').replaceChildren(...rows);
140
+ el('empty').hidden = rows.length > 0;
141
+ for (const key of Object.keys(totals)) el(key).textContent = number.format(totals[key]);
142
+ const count = window.success + window.failure;
143
+ el('rate').textContent = number.format(count) + ' /' + unit;
144
+ el('window-hint').textContent = 'Completed in the last fully elapsed ' + (seconds === '60' ? '60 seconds' : 'second');
145
+ el('rate-heading').textContent = 'Tasks / ' + unit;
146
+ el('window-outcomes').textContent = number.format(window.success) + ' / ' + number.format(window.failure);
147
+ el('failure-rate').textContent = failureRate(window);
148
+ el('average').textContent = count ? milliseconds(window.duration / count) : '--';
149
+ el('maximum').textContent = count ? milliseconds(window.max_duration) : '--';
150
+ el('oldest').textContent = totals.active ? milliseconds(oldest) : '--';
151
+ el('state').textContent = snapshot.running ? 'Running' : 'Not running';
152
+ el('uptime').textContent = duration(snapshot.uptime);
153
+ latest = snapshot;
154
+ }
155
+ function stale() {
156
+ latest = null;
157
+ document.body.classList.add('stale');
158
+ for (const id of ['rate', 'window-outcomes', 'failure-rate', 'average', 'maximum', 'oldest']) el(id).textContent = '--';
159
+ for (const cell of document.querySelectorAll('.task-rate')) cell.textContent = '--';
160
+ }
161
+ el('window').onchange = () => { if (latest) render(latest); };
162
+ async function refresh() {
163
+ if (pending || paused || document.hidden) return;
164
+ pending = true;
165
+ const controller = new AbortController();
166
+ const timeout = setTimeout(() => controller.abort(), 5000);
167
+ try {
168
+ const response = await fetch(window.location.pathname, {
169
+ headers: { Accept: 'application/json' }, credentials: 'omit', redirect: 'error', cache: 'no-store', signal: controller.signal
170
+ });
171
+ if (!response.ok) throw new Error('HTTP ' + response.status);
172
+ const snapshot = await response.json();
173
+ if (paused || document.hidden) return;
174
+ render(snapshot);
175
+ document.body.classList.remove('stale');
176
+ el('notice').dataset.state = 'ready';
177
+ el('notice').textContent = (paused ? 'Paused. Last updated ' : 'Live. Updated ') + new Date().toLocaleTimeString();
178
+ } catch (error) {
179
+ stale();
180
+ el('notice').dataset.state = 'error';
181
+ el('notice').textContent = 'Disconnected - displayed totals are stale. ' + (error.name === 'AbortError' ? 'Request timed out.' : error.message);
182
+ } finally {
183
+ clearTimeout(timeout);
184
+ pending = false;
185
+ if (!paused && !document.hidden) timer = setTimeout(refresh, 1000);
186
+ }
187
+ }
188
+ el('pause').onclick = () => {
189
+ paused = !paused;
190
+ clearTimeout(timer);
191
+ el('pause').textContent = paused ? 'Resume updates' : 'Pause updates';
192
+ el('pause').setAttribute('aria-pressed', String(paused));
193
+ if (paused) {
194
+ stale();
195
+ el('notice').textContent = 'Paused - displayed data is not updating.';
196
+ } else refresh();
197
+ };
198
+ document.addEventListener('visibilitychange', () => {
199
+ clearTimeout(timer);
200
+ if (document.hidden) stale();
201
+ if (!document.hidden) refresh();
202
+ });
203
+ refresh();
204
+ </script>
205
+ </body>
206
+ </html>