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.
Files changed (107) hide show
  1. {dccd-3.5.2 → dccd-3.6.0}/CHANGELOG.md +41 -0
  2. {dccd-3.5.2 → dccd-3.6.0}/PKG-INFO +1 -1
  3. dccd-3.6.0/dccd/interfaces/ui/static/fonts/martian-mono-600.woff2 +0 -0
  4. dccd-3.6.0/dccd/interfaces/ui/static/fonts/martian-mono-700.woff2 +0 -0
  5. dccd-3.6.0/dccd/interfaces/ui/static/fonts/spline-sans-400.woff2 +0 -0
  6. dccd-3.6.0/dccd/interfaces/ui/static/fonts/spline-sans-500.woff2 +0 -0
  7. dccd-3.6.0/dccd/interfaces/ui/static/fonts/spline-sans-600.woff2 +0 -0
  8. dccd-3.6.0/dccd/interfaces/ui/static/fonts/spline-sans-700.woff2 +0 -0
  9. {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/ui/templates/base.html +59 -9
  10. dccd-3.6.0/dccd/interfaces/ui/templates/dashboard.html +296 -0
  11. {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/ui/templates/live.html +5 -1
  12. {dccd-3.5.2 → dccd-3.6.0}/dccd.egg-info/PKG-INFO +1 -1
  13. {dccd-3.5.2 → dccd-3.6.0}/dccd.egg-info/SOURCES.txt +6 -0
  14. {dccd-3.5.2 → dccd-3.6.0}/pyproject.toml +6 -3
  15. dccd-3.5.2/dccd/interfaces/ui/templates/dashboard.html +0 -125
  16. {dccd-3.5.2 → dccd-3.6.0}/CLAUDE.md +0 -0
  17. {dccd-3.5.2 → dccd-3.6.0}/CONTRIBUTING.md +0 -0
  18. {dccd-3.5.2 → dccd-3.6.0}/LICENSE.txt +0 -0
  19. {dccd-3.5.2 → dccd-3.6.0}/MANIFEST.in +0 -0
  20. {dccd-3.5.2 → dccd-3.6.0}/README.md +0 -0
  21. {dccd-3.5.2 → dccd-3.6.0}/dccd/__init__.py +0 -0
  22. {dccd-3.5.2 → dccd-3.6.0}/dccd/application/__init__.py +0 -0
  23. {dccd-3.5.2 → dccd-3.6.0}/dccd/application/config.py +0 -0
  24. {dccd-3.5.2 → dccd-3.6.0}/dccd/application/events.py +0 -0
  25. {dccd-3.5.2 → dccd-3.6.0}/dccd/application/jobs.py +0 -0
  26. {dccd-3.5.2 → dccd-3.6.0}/dccd/application/monitor.py +0 -0
  27. {dccd-3.5.2 → dccd-3.6.0}/dccd/application/operations.py +0 -0
  28. {dccd-3.5.2 → dccd-3.6.0}/dccd/application/registry.py +0 -0
  29. {dccd-3.5.2 → dccd-3.6.0}/dccd/application/scheduler.py +0 -0
  30. {dccd-3.5.2 → dccd-3.6.0}/dccd/application/service_factory.py +0 -0
  31. {dccd-3.5.2 → dccd-3.6.0}/dccd/domain/__init__.py +0 -0
  32. {dccd-3.5.2 → dccd-3.6.0}/dccd/domain/capability.py +0 -0
  33. {dccd-3.5.2 → dccd-3.6.0}/dccd/domain/dataset.py +0 -0
  34. {dccd-3.5.2 → dccd-3.6.0}/dccd/domain/errors.py +0 -0
  35. {dccd-3.5.2 → dccd-3.6.0}/dccd/domain/records.py +0 -0
  36. {dccd-3.5.2 → dccd-3.6.0}/dccd/domain/symbol.py +0 -0
  37. {dccd-3.5.2 → dccd-3.6.0}/dccd/domain/timeutils.py +0 -0
  38. {dccd-3.5.2 → dccd-3.6.0}/dccd/domain/transforms.py +0 -0
  39. {dccd-3.5.2 → dccd-3.6.0}/dccd/domain/types.py +0 -0
  40. {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/__init__.py +0 -0
  41. {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/api/__init__.py +0 -0
  42. {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/api/app.py +0 -0
  43. {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/cli/__init__.py +0 -0
  44. {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/cli/main.py +0 -0
  45. {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/ui/__init__.py +0 -0
  46. {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/ui/static/favicon.svg +0 -0
  47. {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/ui/static/logo.svg +0 -0
  48. {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/ui/templates/config.html +0 -0
  49. {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/ui/templates/data.html +0 -0
  50. {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/ui/templates/historical.html +0 -0
  51. {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/ui/templates/login.html +0 -0
  52. {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/ui/templates/logs.html +0 -0
  53. {dccd-3.5.2 → dccd-3.6.0}/dccd/interfaces/ui/templates/storage.html +0 -0
  54. {dccd-3.5.2 → dccd-3.6.0}/dccd/sources/__init__.py +0 -0
  55. {dccd-3.5.2 → dccd-3.6.0}/dccd/sources/base.py +0 -0
  56. {dccd-3.5.2 → dccd-3.6.0}/dccd/sources/binance.py +0 -0
  57. {dccd-3.5.2 → dccd-3.6.0}/dccd/sources/bitfinex.py +0 -0
  58. {dccd-3.5.2 → dccd-3.6.0}/dccd/sources/bitmex.py +0 -0
  59. {dccd-3.5.2 → dccd-3.6.0}/dccd/sources/bybit.py +0 -0
  60. {dccd-3.5.2 → dccd-3.6.0}/dccd/sources/coinbase.py +0 -0
  61. {dccd-3.5.2 → dccd-3.6.0}/dccd/sources/kraken.py +0 -0
  62. {dccd-3.5.2 → dccd-3.6.0}/dccd/sources/okx.py +0 -0
  63. {dccd-3.5.2 → dccd-3.6.0}/dccd/sources/registry.py +0 -0
  64. {dccd-3.5.2 → dccd-3.6.0}/dccd/storage/__init__.py +0 -0
  65. {dccd-3.5.2 → dccd-3.6.0}/dccd/storage/coverage_sqlite.py +0 -0
  66. {dccd-3.5.2 → dccd-3.6.0}/dccd/storage/parquet.py +0 -0
  67. {dccd-3.5.2 → dccd-3.6.0}/dccd/storage/purge.py +0 -0
  68. {dccd-3.5.2 → dccd-3.6.0}/dccd/storage/remote.py +0 -0
  69. {dccd-3.5.2 → dccd-3.6.0}/dccd/storage/runs_sqlite.py +0 -0
  70. {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/__init__.py +0 -0
  71. {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/__init__.py +0 -0
  72. {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_adapter_parsing.py +0 -0
  73. {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_api.py +0 -0
  74. {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_application.py +0 -0
  75. {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_backfill_lookback.py +0 -0
  76. {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_cli.py +0 -0
  77. {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_client.py +0 -0
  78. {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_coverage.py +0 -0
  79. {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_domain.py +0 -0
  80. {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_domain_extended.py +0 -0
  81. {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_monitor_webhook.py +0 -0
  82. {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_network.py +0 -0
  83. {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_orderbook_throttle.py +0 -0
  84. {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_purge.py +0 -0
  85. {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_ratelimit.py +0 -0
  86. {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_remote_sync.py +0 -0
  87. {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_restart.py +0 -0
  88. {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_restore.py +0 -0
  89. {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_scheduler_hygiene.py +0 -0
  90. {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_sources.py +0 -0
  91. {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_storage.py +0 -0
  92. {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_storage_extended.py +0 -0
  93. {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_stream_end_state.py +0 -0
  94. {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_stream_flush.py +0 -0
  95. {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_stream_nocapability.py +0 -0
  96. {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_transport.py +0 -0
  97. {dccd-3.5.2 → dccd-3.6.0}/dccd/tests/v3/test_ws_subscription_honesty.py +0 -0
  98. {dccd-3.5.2 → dccd-3.6.0}/dccd/transport/__init__.py +0 -0
  99. {dccd-3.5.2 → dccd-3.6.0}/dccd/transport/http.py +0 -0
  100. {dccd-3.5.2 → dccd-3.6.0}/dccd/transport/paginate.py +0 -0
  101. {dccd-3.5.2 → dccd-3.6.0}/dccd/transport/ratelimit.py +0 -0
  102. {dccd-3.5.2 → dccd-3.6.0}/dccd/transport/ws.py +0 -0
  103. {dccd-3.5.2 → dccd-3.6.0}/dccd.egg-info/dependency_links.txt +0 -0
  104. {dccd-3.5.2 → dccd-3.6.0}/dccd.egg-info/entry_points.txt +0 -0
  105. {dccd-3.5.2 → dccd-3.6.0}/dccd.egg-info/requires.txt +0 -0
  106. {dccd-3.5.2 → dccd-3.6.0}/dccd.egg-info/top_level.txt +0 -0
  107. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dccd
3
- Version: 3.5.2
3
+ Version: 3.6.0
4
4
  Summary: Download Crypto Currency Data — hexagonal architecture, async-first.
5
5
  Author-email: Arthur Bernard <arthur.bernard.92@gmail.com>
6
6
  License: MIT
@@ -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
- :root { --bg:#0f1419; --fg:#e6e6e6; --muted:#8b98a5; --accent:#4a9eff;
10
- --border:#2a3038; --card:#161b22; --ok:#3fb950; --err:#f85149; }
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.5 system-ui, sans-serif; background:var(--bg);
13
- color:var(--fg); }
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-size:1.1rem; font-weight:700;
22
- letter-spacing:.02em; color:var(--fg); }
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.3rem; margin:0 0 1rem; }
87
- h2 { font-size:1.05rem; margin:1.5rem 0 .5rem; }
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">(&lt;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
- let lastSig = '';
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) {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dccd
3
- Version: 3.5.2
3
+ Version: 3.6.0
4
4
  Summary: Download Crypto Currency Data — hexagonal architecture, async-first.
5
5
  Author-email: Arthur Bernard <arthur.bernard.92@gmail.com>
6
6
  License: MIT
@@ -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.5.2"
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
- "dccd.interfaces.ui" = ["templates/*.html", "templates/partials/*.html", "static/*"]
89
- "dccd.daemon.ui" = ["templates/*.html", "templates/partials/*.html", "static/*"]
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