dccd 3.5.2__tar.gz → 3.6.0__tar.gz
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.
- {dccd-3.5.2 → dccd-3.6.0}/CHANGELOG.md +41 -0
- {dccd-3.5.2 → dccd-3.6.0}/PKG-INFO +1 -1
- dccd-3.6.0/dccd/interfaces/ui/static/fonts/martian-mono-600.woff2 +0 -0
- dccd-3.6.0/dccd/interfaces/ui/static/fonts/martian-mono-700.woff2 +0 -0
- dccd-3.6.0/dccd/interfaces/ui/static/fonts/spline-sans-400.woff2 +0 -0
- dccd-3.6.0/dccd/interfaces/ui/static/fonts/spline-sans-500.woff2 +0 -0
- dccd-3.6.0/dccd/interfaces/ui/static/fonts/spline-sans-600.woff2 +0 -0
- dccd-3.6.0/dccd/interfaces/ui/static/fonts/spline-sans-700.woff2 +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/ui/templates/base.html +59 -9
- dccd-3.6.0/dccd/interfaces/ui/templates/dashboard.html +296 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/ui/templates/live.html +5 -1
- {dccd-3.5.2 → dccd-3.6.0}/dccd.egg-info/PKG-INFO +1 -1
- {dccd-3.5.2 → dccd-3.6.0}/dccd.egg-info/SOURCES.txt +6 -0
- {dccd-3.5.2 → dccd-3.6.0}/pyproject.toml +6 -3
- dccd-3.5.2/dccd/interfaces/ui/templates/dashboard.html +0 -125
- {dccd-3.5.2 → dccd-3.6.0}/CLAUDE.md +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/CONTRIBUTING.md +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/LICENSE.txt +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/MANIFEST.in +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/README.md +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/__init__.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/application/__init__.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/application/config.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/application/events.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/application/jobs.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/application/monitor.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/application/operations.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/application/registry.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/application/scheduler.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/application/service_factory.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/domain/__init__.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/domain/capability.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/domain/dataset.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/domain/errors.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/domain/records.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/domain/symbol.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/domain/timeutils.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/domain/transforms.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/domain/types.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/__init__.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/api/__init__.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/api/app.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/cli/__init__.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/cli/main.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/ui/__init__.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/ui/static/favicon.svg +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/ui/static/logo.svg +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/ui/templates/config.html +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/ui/templates/data.html +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/ui/templates/historical.html +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/ui/templates/login.html +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/ui/templates/logs.html +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/ui/templates/storage.html +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/sources/__init__.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/sources/base.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/sources/binance.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/sources/bitfinex.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/sources/bitmex.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/sources/bybit.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/sources/coinbase.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/sources/kraken.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/sources/okx.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/sources/registry.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/storage/__init__.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/storage/coverage_sqlite.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/storage/parquet.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/storage/purge.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/storage/remote.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/storage/runs_sqlite.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/__init__.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/__init__.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_adapter_parsing.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_api.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_application.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_backfill_lookback.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_cli.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_client.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_coverage.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_domain.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_domain_extended.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_monitor_webhook.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_network.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_orderbook_throttle.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_purge.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_ratelimit.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_remote_sync.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_restart.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_restore.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_scheduler_hygiene.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_sources.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_storage.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_storage_extended.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_stream_end_state.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_stream_flush.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_stream_nocapability.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_transport.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_ws_subscription_honesty.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/transport/__init__.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/transport/http.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/transport/paginate.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/transport/ratelimit.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd/transport/ws.py +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd.egg-info/dependency_links.txt +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd.egg-info/entry_points.txt +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd.egg-info/requires.txt +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/dccd.egg-info/top_level.txt +0 -0
- {dccd-3.5.2 → dccd-3.6.0}/setup.cfg +0 -0
|
@@ -16,6 +16,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
16
16
|
|
|
17
17
|
### Removed
|
|
18
18
|
|
|
19
|
+
## [3.6.0] - 2026-06-13
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- The web UI **Dashboard** is now a health-first operations view: a status-chip
|
|
26
|
+
line (fresh `<24h` / coverage gaps / failures-24h / last-collection), a
|
|
27
|
+
**Needs attention** panel that surfaces failed runs (with their error reason)
|
|
28
|
+
and datasets with coverage gaps — each with a one-click **Retry/Fill gaps**
|
|
29
|
+
when a configured job matches, else a deep-link to Historical — plus a
|
|
30
|
+
compact Active-now, recent runs, and a per-exchange Data summary with
|
|
31
|
+
freshness dots. Client-side only (inventory + runs + jobs); no API change.
|
|
32
|
+
The Needs-attention panel intentionally treats OHLC coverage gaps as
|
|
33
|
+
actionable (revising the scope of #132 for the triage surface). (#157)
|
|
34
|
+
- Web UI visual refresh ("instrument panel" direction), all in `base.html`
|
|
35
|
+
tokens so every page inherits it: **self-hosted** Martian Mono (wordmark +
|
|
36
|
+
section labels) and Spline Sans (body) woff2 served from `/static/fonts`
|
|
37
|
+
(latin subset) — no external font CDN, works fully offline and leaks nothing;
|
|
38
|
+
tabular figures in tables/chips/totals, machined-panel card depth, a faint
|
|
39
|
+
top glow, and one staggered page-load reveal (`prefers-reduced-motion`
|
|
40
|
+
respected). (#158)
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- Self-hosted UI fonts are now actually packaged in the wheel: `package-data`
|
|
45
|
+
listed only `static/*` (top level), which excluded the `static/fonts/`
|
|
46
|
+
subdirectory added for the visual refresh — a pip-installed UI would 404 on
|
|
47
|
+
its `.woff2` and silently fall back to system fonts. Verified by inspecting
|
|
48
|
+
the built wheel (6 woff2 present). (#160)
|
|
49
|
+
- The **Live** page no longer hangs on "Loading…" when no stream jobs are
|
|
50
|
+
configured (a fresh install, or a backfill-only setup): the structure-change
|
|
51
|
+
guard initialised `lastSig` to `''`, which equals the signature of an empty
|
|
52
|
+
job set, so the very first `load()` matched and returned before the panes
|
|
53
|
+
ever rendered their "No … streams yet" empty state. `lastSig` now starts at
|
|
54
|
+
`null` so the first render always runs. (#159)
|
|
55
|
+
|
|
56
|
+
### Deprecated
|
|
57
|
+
|
|
58
|
+
### Removed
|
|
59
|
+
|
|
19
60
|
## [3.5.2] - 2026-06-12
|
|
20
61
|
|
|
21
62
|
### Added
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -6,11 +6,39 @@
|
|
|
6
6
|
<title>{% block title %}dccd{% endblock %} — dccd UI</title>
|
|
7
7
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
|
8
8
|
<style>
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
/* Self-hosted fonts (woff2, latin subset) — the daemon serves them from
|
|
10
|
+
/static itself, so the UI needs no external network and leaks nothing to
|
|
11
|
+
a font CDN. Display = Martian Mono (instrument-label wordmark + headings,
|
|
12
|
+
tabular by nature); body = Spline Sans. */
|
|
13
|
+
@font-face { font-family:'Martian Mono'; font-style:normal; font-weight:600;
|
|
14
|
+
font-display:swap; src:url('/static/fonts/martian-mono-600.woff2') format('woff2'); }
|
|
15
|
+
@font-face { font-family:'Martian Mono'; font-style:normal; font-weight:700;
|
|
16
|
+
font-display:swap; src:url('/static/fonts/martian-mono-700.woff2') format('woff2'); }
|
|
17
|
+
@font-face { font-family:'Spline Sans'; font-style:normal; font-weight:400;
|
|
18
|
+
font-display:swap; src:url('/static/fonts/spline-sans-400.woff2') format('woff2'); }
|
|
19
|
+
@font-face { font-family:'Spline Sans'; font-style:normal; font-weight:500;
|
|
20
|
+
font-display:swap; src:url('/static/fonts/spline-sans-500.woff2') format('woff2'); }
|
|
21
|
+
@font-face { font-family:'Spline Sans'; font-style:normal; font-weight:600;
|
|
22
|
+
font-display:swap; src:url('/static/fonts/spline-sans-600.woff2') format('woff2'); }
|
|
23
|
+
@font-face { font-family:'Spline Sans'; font-style:normal; font-weight:700;
|
|
24
|
+
font-display:swap; src:url('/static/fonts/spline-sans-700.woff2') format('woff2'); }
|
|
25
|
+
:root { --bg:#0a0d13; --fg:#e8eaed; --muted:#8a96a3; --accent:#5ea2ff;
|
|
26
|
+
--border:#222a33; --card:#131922; --ok:#3fb950; --err:#f85149;
|
|
27
|
+
--warn:#d6a132;
|
|
28
|
+
--panel-hi:rgba(255,255,255,.045);
|
|
29
|
+
--font-body:'Spline Sans', system-ui, sans-serif;
|
|
30
|
+
--font-display:'Martian Mono', ui-monospace, monospace;
|
|
31
|
+
--font-mono:'Martian Mono', ui-monospace, monospace; }
|
|
11
32
|
* { box-sizing: border-box; }
|
|
12
|
-
body { margin:0; font:14px/1.
|
|
13
|
-
|
|
33
|
+
body { margin:0; font:14px/1.55 var(--font-body); color:var(--fg);
|
|
34
|
+
/* faint cool glow off the top edge — atmosphere, not noise */
|
|
35
|
+
background:
|
|
36
|
+
radial-gradient(1100px 520px at 50% -8%, rgba(94,162,255,.06), transparent 62%),
|
|
37
|
+
var(--bg);
|
|
38
|
+
background-attachment:fixed;
|
|
39
|
+
-webkit-font-smoothing:antialiased; }
|
|
40
|
+
/* Figures line up in tables, KPIs and chips — instrument-grade legibility. */
|
|
41
|
+
table, .chip, .totals, .ver { font-variant-numeric:tabular-nums; }
|
|
14
42
|
nav { display:flex; gap:.25rem; align-items:center; flex-wrap:wrap;
|
|
15
43
|
padding:.4rem 1rem; background:var(--card);
|
|
16
44
|
border-bottom:1px solid var(--border);
|
|
@@ -18,8 +46,9 @@
|
|
|
18
46
|
nav .brand-group { display:flex; align-items:center; gap:.5rem;
|
|
19
47
|
margin-right:auto; }
|
|
20
48
|
nav .brand-group img { height:28px; width:auto; display:block; }
|
|
21
|
-
nav .brand-group .brand { font-
|
|
22
|
-
letter-spacing:.
|
|
49
|
+
nav .brand-group .brand { font-family:var(--font-display); font-size:1rem;
|
|
50
|
+
font-weight:700; letter-spacing:.18em; text-transform:uppercase;
|
|
51
|
+
color:var(--fg); }
|
|
23
52
|
nav .brand-group .ver { color:var(--muted); font-size:.78rem;
|
|
24
53
|
font-family:ui-monospace,monospace; }
|
|
25
54
|
nav a { color:var(--muted); text-decoration:none; padding:.4rem .8rem;
|
|
@@ -83,8 +112,12 @@
|
|
|
83
112
|
footer { color:var(--muted); font-size:.8rem; text-align:center;
|
|
84
113
|
padding:1.5rem 1rem; }
|
|
85
114
|
footer a { color:var(--muted); }
|
|
86
|
-
h1 { font-size:1.
|
|
87
|
-
|
|
115
|
+
h1 { font-family:var(--font-display); font-size:1.25rem; font-weight:600;
|
|
116
|
+
letter-spacing:-.01em; margin:0 0 1rem; }
|
|
117
|
+
/* Section headers read like labels stamped on an instrument panel. */
|
|
118
|
+
h2 { font-family:var(--font-display); font-size:.78rem; font-weight:600;
|
|
119
|
+
letter-spacing:.13em; text-transform:uppercase; color:var(--muted);
|
|
120
|
+
margin:1.5rem 0 .5rem; }
|
|
88
121
|
table { width:100%; border-collapse:collapse; margin:.5rem 0; }
|
|
89
122
|
th, td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--border); }
|
|
90
123
|
th { color:var(--muted); font-weight:600; }
|
|
@@ -102,7 +135,9 @@
|
|
|
102
135
|
padding:.8rem; overflow:auto; max-height:70vh; white-space:pre-wrap;
|
|
103
136
|
word-break:break-word; }
|
|
104
137
|
.card { background:var(--card); border:1px solid var(--border);
|
|
105
|
-
border-radius:8px; padding:1rem; margin:.5rem 0;
|
|
138
|
+
border-radius:8px; padding:1rem; margin:.5rem 0;
|
|
139
|
+
/* machined-panel depth: a hairline top highlight + soft drop */
|
|
140
|
+
box-shadow:inset 0 1px 0 var(--panel-hi), 0 1px 2px rgba(0,0,0,.35); }
|
|
106
141
|
.row { display:flex; gap:.5rem; align-items:center; flex-wrap:wrap; }
|
|
107
142
|
.muted { color:var(--muted); }
|
|
108
143
|
.ok { color:var(--ok); } .err { color:var(--err); }
|
|
@@ -134,6 +169,21 @@
|
|
|
134
169
|
.table-scroll { overflow-x:auto; -webkit-overflow-scrolling:touch; max-width:100%; }
|
|
135
170
|
/* Mobile / narrow viewport: bigger tap targets, tighter chrome, no page-wide
|
|
136
171
|
horizontal scroll. Desktop layout above the breakpoint is untouched. */
|
|
172
|
+
/* One orchestrated page-load: top-level sections rise + fade in, staggered.
|
|
173
|
+
Containers exist at load (JS fills them after), so this never blocks data. */
|
|
174
|
+
@keyframes rise { from { opacity:0; transform:translateY(9px); }
|
|
175
|
+
to { opacity:1; transform:none; } }
|
|
176
|
+
main > * { animation:rise .5s cubic-bezier(.2,.7,.2,1) both; }
|
|
177
|
+
main > *:nth-child(1){ animation-delay:.03s; }
|
|
178
|
+
main > *:nth-child(2){ animation-delay:.08s; }
|
|
179
|
+
main > *:nth-child(3){ animation-delay:.13s; }
|
|
180
|
+
main > *:nth-child(4){ animation-delay:.18s; }
|
|
181
|
+
main > *:nth-child(5){ animation-delay:.23s; }
|
|
182
|
+
main > *:nth-child(6){ animation-delay:.28s; }
|
|
183
|
+
main > *:nth-child(n+7){ animation-delay:.33s; }
|
|
184
|
+
@media (prefers-reduced-motion: reduce) {
|
|
185
|
+
*, *::before, *::after { animation:none !important; transition:none !important; }
|
|
186
|
+
}
|
|
137
187
|
@media (max-width: 640px) {
|
|
138
188
|
nav { padding:.4rem .6rem; gap:.15rem; }
|
|
139
189
|
nav a, nav .nav-menu-btn, nav .nav-logout button { padding:.55rem .7rem; }
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
{% block title %}Dashboard{% endblock %}
|
|
3
|
+
{% block content %}
|
|
4
|
+
<style>
|
|
5
|
+
/* Dashboard-only chrome: a health line of status chips, an attention card,
|
|
6
|
+
and a compact activity area. Everything else reuses base.html. */
|
|
7
|
+
.dash-head { display:flex; align-items:baseline; justify-content:space-between;
|
|
8
|
+
flex-wrap:wrap; gap:.5rem; }
|
|
9
|
+
.dash-head .status { font-size:.8rem; color:var(--muted); }
|
|
10
|
+
.dash-head .status .dot { display:inline-block; width:.5rem; height:.5rem;
|
|
11
|
+
border-radius:50%; background:var(--ok); margin-right:.3rem;
|
|
12
|
+
vertical-align:middle; }
|
|
13
|
+
.dash-head .status.down .dot { background:var(--err); }
|
|
14
|
+
.health { display:flex; flex-wrap:wrap; gap:.5rem; margin:.3rem 0 .5rem; }
|
|
15
|
+
.chip { display:inline-flex; align-items:center; gap:.4rem; font-size:.85rem;
|
|
16
|
+
padding:.35rem .7rem; border-radius:999px; border:1px solid var(--border);
|
|
17
|
+
background:var(--card); }
|
|
18
|
+
.chip b { font-weight:700; }
|
|
19
|
+
.chip.good { border-color:rgba(63,185,80,.4); }
|
|
20
|
+
.chip.warn { border-color:rgba(210,153,34,.45); }
|
|
21
|
+
.chip.bad { border-color:rgba(248,81,73,.45); }
|
|
22
|
+
.chip .ic { font-size:.9rem; line-height:1; }
|
|
23
|
+
.chip.good .ic { color:var(--ok); }
|
|
24
|
+
.chip.warn .ic { color:#d29922; }
|
|
25
|
+
.chip.bad .ic { color:var(--err); }
|
|
26
|
+
.totals { color:var(--muted); font-size:.82rem; margin:.1rem 0 1rem; }
|
|
27
|
+
.attn-item { display:flex; align-items:flex-start; gap:.6rem; padding:.55rem 0;
|
|
28
|
+
border-bottom:1px solid var(--border); }
|
|
29
|
+
.attn-item:last-child { border-bottom:0; }
|
|
30
|
+
.attn-item .ic { font-size:1rem; line-height:1.3; }
|
|
31
|
+
.attn-item.fail .ic { color:var(--err); }
|
|
32
|
+
.attn-item.gap .ic { color:#d29922; }
|
|
33
|
+
.attn-item .body { flex:1; min-width:0; }
|
|
34
|
+
.attn-item .body .sub { color:var(--muted); font-size:.8rem; margin-top:.1rem;
|
|
35
|
+
overflow-wrap:anywhere; }
|
|
36
|
+
.attn-item .act { flex:none; }
|
|
37
|
+
.attn-item .act a, .attn-item .act button { font-size:.8rem; }
|
|
38
|
+
.attn-item .act a { color:var(--accent); text-decoration:none; white-space:nowrap; }
|
|
39
|
+
.all-clear { color:var(--ok); }
|
|
40
|
+
</style>
|
|
41
|
+
|
|
42
|
+
<div class="dash-head">
|
|
43
|
+
<h1 style="margin:0">Dashboard</h1>
|
|
44
|
+
<span id="conn" class="status"><span class="dot"></span>auto-refresh 15s</span>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<!-- System health -->
|
|
48
|
+
<div id="health" class="health"><span class="muted">Loading…</span></div>
|
|
49
|
+
<div id="totals" class="totals"></div>
|
|
50
|
+
|
|
51
|
+
<!-- Needs attention -->
|
|
52
|
+
<h2 style="margin:.9rem 0 .3rem">Needs attention</h2>
|
|
53
|
+
<div class="card" id="attention"><span class="muted">Loading…</span></div>
|
|
54
|
+
|
|
55
|
+
<!-- Active now (collapses to a slim line when idle) -->
|
|
56
|
+
<h2 style="margin:1.1rem 0 .4rem">Active now</h2>
|
|
57
|
+
<div id="active-now"><span class="muted">Loading…</span></div>
|
|
58
|
+
|
|
59
|
+
<div class="row" style="gap:1rem;align-items:flex-start;margin-top:1rem">
|
|
60
|
+
<!-- Recent runs -->
|
|
61
|
+
<div style="flex:2;min-width:280px">
|
|
62
|
+
<div class="row" style="justify-content:space-between;align-items:center;margin-bottom:.3rem">
|
|
63
|
+
<h2 style="margin:0">Recent runs</h2>
|
|
64
|
+
<a href="/logs" style="font-size:.8rem;color:var(--muted)">All logs →</a>
|
|
65
|
+
</div>
|
|
66
|
+
<div id="runs-body"><span class="muted">Loading…</span></div>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<!-- Data -->
|
|
70
|
+
<div style="flex:1;min-width:200px">
|
|
71
|
+
<div class="row" style="justify-content:space-between;align-items:center;margin-bottom:.3rem">
|
|
72
|
+
<h2 style="margin:0">Data</h2>
|
|
73
|
+
<a href="/data" style="font-size:.8rem;color:var(--muted)">Browse →</a>
|
|
74
|
+
</div>
|
|
75
|
+
<div class="card"><div id="data-summary"><span class="muted">Loading…</span></div></div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
{% endblock %}
|
|
79
|
+
{% block scripts %}
|
|
80
|
+
<script>
|
|
81
|
+
const DAY_NS = 86_400 * 1e9;
|
|
82
|
+
|
|
83
|
+
// Build a lookup of configured jobs keyed by exchange|symbol|data_type so a
|
|
84
|
+
// failed run or a gapped dataset can be mapped back to a one-click "Run". The
|
|
85
|
+
// API's /api/jobs/run wants the *job* id (with its span suffix), which a run's
|
|
86
|
+
// spec_id and an inventory row don't carry — so we match on the tuple instead.
|
|
87
|
+
function jobIndex(jobs) {
|
|
88
|
+
const idx = {};
|
|
89
|
+
for (const j of jobs) {
|
|
90
|
+
if (j.operation !== 'backfill') continue;
|
|
91
|
+
const k = `${j.exchange}|${normSym(j.symbol)}|${j.data_type}`;
|
|
92
|
+
// Prefer a span-specific match when available, else fall back to the tuple.
|
|
93
|
+
idx[k + '|' + (j.span || '')] = j.id;
|
|
94
|
+
if (!(k in idx)) idx[k] = j.id; // first job for this tuple wins
|
|
95
|
+
}
|
|
96
|
+
return idx;
|
|
97
|
+
}
|
|
98
|
+
// Inventory pairs are "BTC-USDT"; runs/jobs symbols are "BTC/USDT". Normalise.
|
|
99
|
+
function normSym(s) { return String(s || '').replace('-', '/'); }
|
|
100
|
+
function findJob(idx, exchange, symbol, data_type, span) {
|
|
101
|
+
const k = `${exchange}|${normSym(symbol)}|${data_type}`;
|
|
102
|
+
return idx[k + '|' + (span || '')] || idx[k] || null;
|
|
103
|
+
}
|
|
104
|
+
// Deep-link to the Historical tab for a data type (where a Run lives).
|
|
105
|
+
function histLink(dt) {
|
|
106
|
+
return dt === 'trades' ? '/historical#trades' : '/historical#ohlc';
|
|
107
|
+
}
|
|
108
|
+
// Action cell: one-click Run when we can map to a configured job, else a link
|
|
109
|
+
// to Historical. Order books have no REST backfill — never offer a run there.
|
|
110
|
+
function actionCell(jobId, dt, label) {
|
|
111
|
+
if (dt === 'orderbook') return `<a href="/live">Live →</a>`;
|
|
112
|
+
if (jobId) return `<button class="ghost" style="padding:.25rem .6rem"
|
|
113
|
+
onclick="runJob('${escapeHtml(jobId)}', this)">${label}</button>`;
|
|
114
|
+
return `<a href="${histLink(dt)}">Fix →</a>`;
|
|
115
|
+
}
|
|
116
|
+
async function runJob(jobId, btn) {
|
|
117
|
+
btn.disabled = true;
|
|
118
|
+
try {
|
|
119
|
+
const r = await api('POST', '/api/jobs/run', { job_id: jobId });
|
|
120
|
+
toast(r.status === 'already-running' ? 'Already running' : 'Backfill started', 'ok');
|
|
121
|
+
setTimeout(load, 600);
|
|
122
|
+
} catch (e) { toast(e.message, 'err'); btn.disabled = false; }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function chip(kind, ic, label, value) {
|
|
126
|
+
return `<span class="chip ${kind}"><span class="ic">${ic}</span>
|
|
127
|
+
${value !== undefined ? `<b>${value}</b> ` : ''}${label}</span>`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function load() {
|
|
131
|
+
const conn = document.getElementById('conn');
|
|
132
|
+
const [runsR, streamsR, invR, jobsR] = await Promise.allSettled([
|
|
133
|
+
api('GET','/api/runs?limit=50'), api('GET','/api/streams'),
|
|
134
|
+
api('GET','/api/inventory'), api('GET','/api/jobs')]);
|
|
135
|
+
if (runsR.status !== 'fulfilled' && invR.status !== 'fulfilled') {
|
|
136
|
+
conn.className = 'status down';
|
|
137
|
+
conn.innerHTML = '<span class="dot"></span>can\'t reach API';
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
conn.className = 'status';
|
|
141
|
+
conn.innerHTML = '<span class="dot"></span>auto-refresh 15s';
|
|
142
|
+
|
|
143
|
+
const runs = runsR.status === 'fulfilled' ? (runsR.value.runs || []) : [];
|
|
144
|
+
const streams = streamsR.status === 'fulfilled' ? (streamsR.value.streams || []) : [];
|
|
145
|
+
const datasets = invR.status === 'fulfilled' ? (invR.value.datasets || []) : [];
|
|
146
|
+
const jobs = jobsR.status === 'fulfilled' ? (jobsR.value.jobs || []) : [];
|
|
147
|
+
const idx = jobIndex(jobs);
|
|
148
|
+
|
|
149
|
+
const liveStreams = streams.filter(s => s.running);
|
|
150
|
+
const activeRuns = runs.filter(r => r.state === 'running' || r.state === 'reconnecting');
|
|
151
|
+
const totRows = datasets.reduce((a,d)=>a+(d.rows||0),0);
|
|
152
|
+
const totBytes = datasets.reduce((a,d)=>a+(d.bytes||0),0);
|
|
153
|
+
const now = Date.now() * 1e6;
|
|
154
|
+
|
|
155
|
+
// ---- System health -------------------------------------------------------
|
|
156
|
+
const freshN = datasets.filter(d => d.max_ts != null && now - d.max_ts < DAY_NS).length;
|
|
157
|
+
const gapped = datasets.filter(d => (d.missing_rows || 0) > 0);
|
|
158
|
+
// Failed runs in the last 24h, most-recent-first, de-duplicated per spec so a
|
|
159
|
+
// repeatedly-failing job shows once.
|
|
160
|
+
const failed24 = [];
|
|
161
|
+
const seenFail = new Set();
|
|
162
|
+
for (const r of runs) {
|
|
163
|
+
if (r.state !== 'failed' || r.started_at == null || now - r.started_at > DAY_NS) continue;
|
|
164
|
+
if (seenFail.has(r.spec_id)) continue;
|
|
165
|
+
seenFail.add(r.spec_id); failed24.push(r);
|
|
166
|
+
}
|
|
167
|
+
// Last collection: the most recent successful run's end (truthful regardless
|
|
168
|
+
// of whether a scheduler is actually running).
|
|
169
|
+
const lastEnd = runs.filter(r => r.state === 'succeeded' && r.ended_at != null)
|
|
170
|
+
.reduce((m,r)=>Math.max(m, r.ended_at), 0);
|
|
171
|
+
|
|
172
|
+
const chips = [];
|
|
173
|
+
if (datasets.length)
|
|
174
|
+
chips.push(chip(freshN === datasets.length ? 'good' : 'warn', '●',
|
|
175
|
+
`fresh <span class="muted">(<24h)</span>`, `${freshN}/${datasets.length}`));
|
|
176
|
+
chips.push(gapped.length
|
|
177
|
+
? chip('warn', '◐', 'with gaps', gapped.length)
|
|
178
|
+
: chip('good', '✓', 'no gaps'));
|
|
179
|
+
chips.push(failed24.length
|
|
180
|
+
? chip('bad', '✗', 'failed <span class="muted">(24h)</span>', failed24.length)
|
|
181
|
+
: chip('good', '✓', 'no failures <span class="muted">(24h)</span>'));
|
|
182
|
+
if (liveStreams.length)
|
|
183
|
+
chips.push(chip('good', '◉', 'live streams', liveStreams.length));
|
|
184
|
+
if (lastEnd) chips.push(chip('good', '↻', `last collection ${fmtAge(lastEnd)}`));
|
|
185
|
+
document.getElementById('health').innerHTML = chips.join('') ||
|
|
186
|
+
'<span class="muted">No data yet.</span>';
|
|
187
|
+
|
|
188
|
+
document.getElementById('totals').innerHTML =
|
|
189
|
+
`${fmtNum(datasets.length)} datasets · ${fmtNum(totRows)} rows · ${fmtBytes(totBytes)} on disk`;
|
|
190
|
+
|
|
191
|
+
// ---- Needs attention -----------------------------------------------------
|
|
192
|
+
const items = [];
|
|
193
|
+
for (const r of failed24) {
|
|
194
|
+
const jobId = findJob(idx, r.exchange, r.symbol, r.data_type);
|
|
195
|
+
const reason = r.error ? escapeHtml(r.error) : 'no error message recorded';
|
|
196
|
+
items.push(`<div class="attn-item fail">
|
|
197
|
+
<span class="ic">✗</span>
|
|
198
|
+
<div class="body">
|
|
199
|
+
<span class="muted" style="font-size:.85em">${escapeHtml(r.exchange)}</span>
|
|
200
|
+
<strong>${escapeHtml(r.symbol)}</strong> ${escapeHtml(r.data_type)} — backfill failed
|
|
201
|
+
<div class="sub">${reason} · ${fmtAge(r.started_at)}</div>
|
|
202
|
+
</div>
|
|
203
|
+
<div class="act">${actionCell(jobId, r.data_type, 'Retry')}</div>
|
|
204
|
+
</div>`);
|
|
205
|
+
}
|
|
206
|
+
for (const d of gapped) {
|
|
207
|
+
const exp = d.expected_rows || 0, miss = d.missing_rows || 0;
|
|
208
|
+
const pct = exp > 0 ? Math.round(miss / exp * 100) : 0;
|
|
209
|
+
const jobId = findJob(idx, d.exchange, d.pair, d.data_type, d.span);
|
|
210
|
+
const spanLbl = d.span ? `${d.span}s` : '';
|
|
211
|
+
items.push(`<div class="attn-item gap">
|
|
212
|
+
<span class="ic">◐</span>
|
|
213
|
+
<div class="body">
|
|
214
|
+
<span class="muted" style="font-size:.85em">${escapeHtml(d.exchange)}</span>
|
|
215
|
+
<strong>${escapeHtml(d.pair)}</strong> ${escapeHtml(spanLbl)} — ${pct}% coverage gap
|
|
216
|
+
<div class="sub">${fmtNum(miss)} missing of ${fmtNum(exp)} expected bars</div>
|
|
217
|
+
</div>
|
|
218
|
+
<div class="act">${actionCell(jobId, d.data_type, 'Fill gaps')}</div>
|
|
219
|
+
</div>`);
|
|
220
|
+
}
|
|
221
|
+
const attEl = document.getElementById('attention');
|
|
222
|
+
if (!items.length) {
|
|
223
|
+
attEl.innerHTML = datasets.length || runs.length
|
|
224
|
+
? '<span class="all-clear">✓ All clear — nothing needs attention.</span>'
|
|
225
|
+
: '<span class="muted">Nothing collected yet. Start on <a href="/historical">Historical</a> or <a href="/live">Live</a>.</span>';
|
|
226
|
+
} else {
|
|
227
|
+
const shown = items.slice(0, 8);
|
|
228
|
+
attEl.innerHTML = shown.join('') +
|
|
229
|
+
(items.length > shown.length
|
|
230
|
+
? `<div class="muted" style="font-size:.8rem;padding-top:.5rem">+ ${items.length - shown.length} more</div>`
|
|
231
|
+
: '');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---- Active now ----------------------------------------------------------
|
|
235
|
+
const el = document.getElementById('active-now');
|
|
236
|
+
const blocks = [];
|
|
237
|
+
for (const r of activeRuns) {
|
|
238
|
+
const prog = r.progress;
|
|
239
|
+
const pct = prog && prog.total > 0 ? Math.min(100, Math.round(prog.done / prog.total * 100)) : null;
|
|
240
|
+
const rows = fmtNum(prog?.rows ?? r.rows_written ?? 0);
|
|
241
|
+
const at = (prog && prog.unit === 'time' && prog.at) ? `reached ${fmtNs(prog.at)}` : (prog ? `${prog.done}/${prog.total}` : 'starting…');
|
|
242
|
+
blocks.push(`<div class="card" style="border-left:3px solid var(--accent);padding:.6rem 1rem;margin:.3rem 0">
|
|
243
|
+
<div class="row" style="justify-content:space-between;margin-bottom:.3rem">
|
|
244
|
+
<span><span class="ok">●</span> <strong>${escapeHtml(r.operation)}</strong> ${escapeHtml(r.exchange)} ${escapeHtml(r.symbol)} ${escapeHtml(r.data_type)}</span>
|
|
245
|
+
<span class="muted" style="font-size:.82em">${at} · ${rows} rows${pct !== null ? ` · ${pct}%` : ''}</span>
|
|
246
|
+
</div>
|
|
247
|
+
${pct !== null ? `<div style="height:4px;background:var(--border);border-radius:2px"><div style="height:100%;background:var(--accent);border-radius:2px;width:${pct}%"></div></div>` : ''}
|
|
248
|
+
</div>`);
|
|
249
|
+
}
|
|
250
|
+
for (const s of liveStreams) {
|
|
251
|
+
const parts = s.id.split(':');
|
|
252
|
+
blocks.push(`<div class="card" style="border-left:3px solid var(--ok);padding:.5rem 1rem;margin:.3rem 0">
|
|
253
|
+
<span class="live-dot fresh"></span><strong>stream</strong>
|
|
254
|
+
${escapeHtml(parts[1]||'')} ${escapeHtml(parts[2]||'')} <span class="muted">${escapeHtml(parts[3]||'')}</span>
|
|
255
|
+
</div>`);
|
|
256
|
+
}
|
|
257
|
+
el.innerHTML = blocks.length ? blocks.join('')
|
|
258
|
+
: '<div class="muted" style="font-size:.85rem">Idle — no run or stream in progress right now.</div>';
|
|
259
|
+
|
|
260
|
+
// ---- Recent runs (top 8) -------------------------------------------------
|
|
261
|
+
const recent = runs.slice(0, 8);
|
|
262
|
+
const body = recent.map(r => {
|
|
263
|
+
const cls = r.state === 'succeeded' ? 'ok' : r.state === 'failed' ? 'err' : 'muted';
|
|
264
|
+
return `<tr>
|
|
265
|
+
<td><span class="muted" style="font-size:.8em">${escapeHtml(r.exchange)}</span> ${escapeHtml(r.symbol)}</td>
|
|
266
|
+
<td>${escapeHtml(r.data_type)}</td>
|
|
267
|
+
<td><span class="${cls}">${escapeHtml(r.state)}</span></td>
|
|
268
|
+
<td class="muted" style="font-size:.8em">${fmtNum(r.rows_written||0)}</td>
|
|
269
|
+
<td class="muted" style="font-size:.8em">${fmtAge(r.started_at)}</td>
|
|
270
|
+
</tr>`;
|
|
271
|
+
}).join('') || '<tr><td colspan="5" class="muted">No runs yet</td></tr>';
|
|
272
|
+
document.getElementById('runs-body').innerHTML =
|
|
273
|
+
`<table><thead><tr><th>Symbol</th><th>Type</th><th>State</th><th>Rows</th><th>Started</th></tr></thead><tbody>${body}</tbody></table>`;
|
|
274
|
+
|
|
275
|
+
// ---- Data summary by exchange (with freshness dot) -----------------------
|
|
276
|
+
if (!datasets.length) {
|
|
277
|
+
document.getElementById('data-summary').innerHTML = '<span class="muted">Empty — <a href="/historical">start a backfill</a></span>';
|
|
278
|
+
} else {
|
|
279
|
+
const byEx = {};
|
|
280
|
+
for (const d of datasets) {
|
|
281
|
+
const e = byEx[d.exchange] || (byEx[d.exchange] = { n:0, bytes:0, max:0 });
|
|
282
|
+
e.n++; e.bytes += d.bytes||0; e.max = Math.max(e.max, d.max_ts||0);
|
|
283
|
+
}
|
|
284
|
+
document.getElementById('data-summary').innerHTML =
|
|
285
|
+
Object.entries(byEx).sort().map(([ex, e]) =>
|
|
286
|
+
`<div class="row" style="justify-content:space-between;font-size:.85em;padding:.15rem 0">
|
|
287
|
+
<span><span class="live-dot ${stalenessClass(e.max)}"></span>${escapeHtml(ex)}</span>
|
|
288
|
+
<span class="muted">${e.n} · ${fmtBytes(e.bytes)}</span>
|
|
289
|
+
</div>`).join('');
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
load();
|
|
294
|
+
setInterval(load, 15000);
|
|
295
|
+
</script>
|
|
296
|
+
{% endblock %}
|
|
@@ -32,7 +32,11 @@ let JOBS = [], RUNNING = {}, INV = [], openExchanges = {};
|
|
|
32
32
|
// spec_id -> { value|bid|ask, ts(ns), live }. `live` marks a real-time SSE
|
|
33
33
|
// sample; entries without it are seeded from on-disk data (last stored point).
|
|
34
34
|
const samples = {};
|
|
35
|
-
|
|
35
|
+
// `null`, not '' — with zero stream jobs the structure signature is also '',
|
|
36
|
+
// so an '' sentinel made the very first load() match and short-circuit before
|
|
37
|
+
// renderPane ever ran, leaving both panes stuck on "Loading…". null guarantees
|
|
38
|
+
// the first load always renders (then the real signature governs later polls).
|
|
39
|
+
let lastSig = null;
|
|
36
40
|
|
|
37
41
|
function sid(s){ return String(s).replace(/[^a-zA-Z0-9]/g,'_'); }
|
|
38
42
|
function invFor(j) {
|
|
@@ -38,6 +38,12 @@ dccd/interfaces/cli/main.py
|
|
|
38
38
|
dccd/interfaces/ui/__init__.py
|
|
39
39
|
dccd/interfaces/ui/static/favicon.svg
|
|
40
40
|
dccd/interfaces/ui/static/logo.svg
|
|
41
|
+
dccd/interfaces/ui/static/fonts/martian-mono-600.woff2
|
|
42
|
+
dccd/interfaces/ui/static/fonts/martian-mono-700.woff2
|
|
43
|
+
dccd/interfaces/ui/static/fonts/spline-sans-400.woff2
|
|
44
|
+
dccd/interfaces/ui/static/fonts/spline-sans-500.woff2
|
|
45
|
+
dccd/interfaces/ui/static/fonts/spline-sans-600.woff2
|
|
46
|
+
dccd/interfaces/ui/static/fonts/spline-sans-700.woff2
|
|
41
47
|
dccd/interfaces/ui/templates/base.html
|
|
42
48
|
dccd/interfaces/ui/templates/config.html
|
|
43
49
|
dccd/interfaces/ui/templates/dashboard.html
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "dccd"
|
|
7
|
-
version = "3.
|
|
7
|
+
version = "3.6.0"
|
|
8
8
|
description = "Download Crypto Currency Data — hexagonal architecture, async-first."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -85,8 +85,11 @@ Changelog = "https://github.com/ArthurBernard/Download_Crypto_Currencies_Data/bl
|
|
|
85
85
|
include = ["dccd*"]
|
|
86
86
|
|
|
87
87
|
[tool.setuptools.package-data]
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
# `static/*` matches only the top level — nested asset dirs (e.g. self-hosted
|
|
89
|
+
# `static/fonts/*.woff2`) must be listed explicitly or the wheel ships without
|
|
90
|
+
# them and the UI 404s on its fonts.
|
|
91
|
+
"dccd.interfaces.ui" = ["templates/*.html", "templates/partials/*.html", "static/*", "static/fonts/*"]
|
|
92
|
+
"dccd.daemon.ui" = ["templates/*.html", "templates/partials/*.html", "static/*", "static/fonts/*"]
|
|
90
93
|
|
|
91
94
|
[tool.pytest.ini_options]
|
|
92
95
|
addopts = "--exitfirst -vv --capture=no --ignore=doc --ignore=examples --ignore=env --cov=dccd --cov-report=term-missing -m 'not network'"
|
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
{% extends "base.html" %}
|
|
2
|
-
{% block title %}Dashboard{% endblock %}
|
|
3
|
-
{% block content %}
|
|
4
|
-
<h1>Dashboard</h1>
|
|
5
|
-
<p class="muted" style="margin:.2rem 0 .8rem">
|
|
6
|
-
An at-a-glance view: key totals, what's running right now, the latest runs, and
|
|
7
|
-
what's stored. Use <a href="/historical">Historical</a> / <a href="/live">Live</a>
|
|
8
|
-
to collect, <a href="/data">Data</a> to inspect.
|
|
9
|
-
</p>
|
|
10
|
-
|
|
11
|
-
<!-- KPIs -->
|
|
12
|
-
<div id="kpis" class="row" style="gap:.6rem;margin-bottom:1rem"></div>
|
|
13
|
-
|
|
14
|
-
<!-- Active now -->
|
|
15
|
-
<h2 style="font-size:1.05rem;margin:.6rem 0 .3rem">Active now</h2>
|
|
16
|
-
<div class="muted" style="font-size:.82rem;margin-bottom:.4rem">Runs in progress and live streams.</div>
|
|
17
|
-
<div id="active-now"><span class="muted">Loading…</span></div>
|
|
18
|
-
|
|
19
|
-
<div class="row" style="gap:1rem;align-items:flex-start;margin-top:1rem">
|
|
20
|
-
<!-- Recent runs -->
|
|
21
|
-
<div style="flex:2;min-width:280px">
|
|
22
|
-
<div class="row" style="justify-content:space-between;align-items:center;margin-bottom:.3rem">
|
|
23
|
-
<h2 style="font-size:1.05rem;margin:0">Recent runs</h2>
|
|
24
|
-
<a href="/logs" style="font-size:.8rem;color:var(--muted)">All logs →</a>
|
|
25
|
-
</div>
|
|
26
|
-
<div id="runs-body"><span class="muted">Loading…</span></div>
|
|
27
|
-
</div>
|
|
28
|
-
|
|
29
|
-
<!-- Data -->
|
|
30
|
-
<div style="flex:1;min-width:200px">
|
|
31
|
-
<div class="row" style="justify-content:space-between;align-items:center;margin-bottom:.3rem">
|
|
32
|
-
<h2 style="font-size:1.05rem;margin:0">Data</h2>
|
|
33
|
-
<a href="/data" style="font-size:.8rem;color:var(--muted)">Browse →</a>
|
|
34
|
-
</div>
|
|
35
|
-
<div class="card"><div id="data-summary"><span class="muted">Loading…</span></div></div>
|
|
36
|
-
</div>
|
|
37
|
-
</div>
|
|
38
|
-
{% endblock %}
|
|
39
|
-
{% block scripts %}
|
|
40
|
-
<script>
|
|
41
|
-
function kpi(label, value) {
|
|
42
|
-
return `<div class="card" style="flex:1;min-width:8rem;padding:.7rem .9rem;margin:0">
|
|
43
|
-
<div style="font-size:1.4rem;font-weight:700">${value}</div>
|
|
44
|
-
<div class="muted" style="font-size:.78rem">${label}</div></div>`;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
async function load() {
|
|
48
|
-
// Fetch everything we need once — in parallel: serial awaits cost 3×RTT,
|
|
49
|
-
// which is what a remote (Tailscale/TLS) client actually feels.
|
|
50
|
-
const [runsR, streamsR, invR] = await Promise.allSettled([
|
|
51
|
-
api('GET','/api/runs?limit=50'), api('GET','/api/streams'), api('GET','/api/inventory')]);
|
|
52
|
-
const runs = runsR.status === 'fulfilled' ? (runsR.value.runs || []) : [];
|
|
53
|
-
const streams = streamsR.status === 'fulfilled' ? (streamsR.value.streams || []) : [];
|
|
54
|
-
const datasets = invR.status === 'fulfilled' ? (invR.value.datasets || []) : [];
|
|
55
|
-
|
|
56
|
-
const liveStreams = streams.filter(s => s.running);
|
|
57
|
-
const activeRuns = runs.filter(r => r.state === 'running' || r.state === 'reconnecting');
|
|
58
|
-
const totRows = datasets.reduce((a,d)=>a+(d.rows||0),0);
|
|
59
|
-
const totBytes = datasets.reduce((a,d)=>a+(d.bytes||0),0);
|
|
60
|
-
|
|
61
|
-
// KPIs
|
|
62
|
-
document.getElementById('kpis').innerHTML =
|
|
63
|
-
kpi('datasets', datasets.length) +
|
|
64
|
-
kpi('rows stored', fmtNum(totRows)) +
|
|
65
|
-
kpi('on disk', fmtBytes(totBytes)) +
|
|
66
|
-
kpi('live streams', liveStreams.length) +
|
|
67
|
-
kpi('runs in progress', activeRuns.length);
|
|
68
|
-
|
|
69
|
-
// Active now: running runs (with progress) + live streams.
|
|
70
|
-
const el = document.getElementById('active-now');
|
|
71
|
-
const blocks = [];
|
|
72
|
-
for (const r of activeRuns) {
|
|
73
|
-
const prog = r.progress;
|
|
74
|
-
const pct = prog && prog.total > 0 ? Math.min(100, Math.round(prog.done / prog.total * 100)) : null;
|
|
75
|
-
const rows = fmtNum(prog?.rows ?? r.rows_written ?? 0);
|
|
76
|
-
const at = (prog && prog.unit === 'time' && prog.at) ? `reached ${fmtNs(prog.at)}` : (prog ? `${prog.done}/${prog.total}` : 'starting…');
|
|
77
|
-
blocks.push(`<div class="card" style="border-left:3px solid var(--accent);padding:.6rem 1rem;margin:.3rem 0">
|
|
78
|
-
<div class="row" style="justify-content:space-between;margin-bottom:.3rem">
|
|
79
|
-
<span><span class="ok">●</span> <strong>${escapeHtml(r.operation)}</strong> ${escapeHtml(r.exchange)} ${escapeHtml(r.symbol)} ${escapeHtml(r.data_type)}</span>
|
|
80
|
-
<span class="muted" style="font-size:.82em">${at} · ${rows} rows${pct !== null ? ` · ${pct}%` : ''}</span>
|
|
81
|
-
</div>
|
|
82
|
-
${pct !== null ? `<div style="height:4px;background:var(--border);border-radius:2px"><div style="height:100%;background:var(--accent);border-radius:2px;width:${pct}%"></div></div>` : ''}
|
|
83
|
-
</div>`);
|
|
84
|
-
}
|
|
85
|
-
for (const s of liveStreams) {
|
|
86
|
-
const parts = s.id.split(':');
|
|
87
|
-
blocks.push(`<div class="card" style="border-left:3px solid var(--ok);padding:.5rem 1rem;margin:.3rem 0">
|
|
88
|
-
<span class="live-dot fresh"></span><strong>stream</strong>
|
|
89
|
-
${escapeHtml(parts[1]||'')} ${escapeHtml(parts[2]||'')} <span class="muted">${escapeHtml(parts[3]||'')}</span>
|
|
90
|
-
</div>`);
|
|
91
|
-
}
|
|
92
|
-
el.innerHTML = blocks.length ? blocks.join('')
|
|
93
|
-
: '<div class="card muted" style="text-align:center;padding:1.2rem">Nothing running. Start a job on <a href="/historical">Historical</a> or <a href="/live">Live</a>.</div>';
|
|
94
|
-
|
|
95
|
-
// Recent runs table
|
|
96
|
-
const recent = runs.slice(0, 12);
|
|
97
|
-
const body = recent.map(r => {
|
|
98
|
-
const cls = r.state === 'succeeded' ? 'ok' : r.state === 'failed' ? 'err' : 'muted';
|
|
99
|
-
return `<tr>
|
|
100
|
-
<td><span class="muted" style="font-size:.8em">${escapeHtml(r.exchange)}</span> ${escapeHtml(r.symbol)}</td>
|
|
101
|
-
<td>${escapeHtml(r.data_type)}</td>
|
|
102
|
-
<td><span class="${cls}">${escapeHtml(r.state)}</span></td>
|
|
103
|
-
<td class="muted" style="font-size:.8em">${fmtNum(r.rows_written||0)}</td>
|
|
104
|
-
<td class="muted" style="font-size:.8em">${fmtNs(r.started_at)}</td>
|
|
105
|
-
</tr>`;
|
|
106
|
-
}).join('') || '<tr><td colspan="5" class="muted">No runs yet</td></tr>';
|
|
107
|
-
document.getElementById('runs-body').innerHTML =
|
|
108
|
-
`<table><thead><tr><th>Symbol</th><th>Type</th><th>State</th><th>Rows</th><th>Started</th></tr></thead><tbody>${body}</tbody></table>`;
|
|
109
|
-
|
|
110
|
-
// Data summary by exchange
|
|
111
|
-
if (!datasets.length) {
|
|
112
|
-
document.getElementById('data-summary').innerHTML = '<span class="muted">Empty — <a href="/historical">start a backfill</a></span>';
|
|
113
|
-
} else {
|
|
114
|
-
const byEx = {};
|
|
115
|
-
for (const d of datasets) byEx[d.exchange] = (byEx[d.exchange] || 0) + 1;
|
|
116
|
-
document.getElementById('data-summary').innerHTML =
|
|
117
|
-
Object.entries(byEx).sort().map(([ex, n]) =>
|
|
118
|
-
`<div class="row" style="justify-content:space-between;font-size:.85em"><span>${escapeHtml(ex)}</span><span class="muted">${n} dataset(s)</span></div>`).join('');
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
load();
|
|
123
|
-
setInterval(load, 15000);
|
|
124
|
-
</script>
|
|
125
|
-
{% endblock %}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|