@mantiq/heartbeat 0.5.21 → 0.5.23

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 (35) hide show
  1. package/package.json +1 -1
  2. package/src/Heartbeat.ts +15 -27
  3. package/src/HeartbeatServiceProvider.ts +66 -0
  4. package/src/contracts/Entry.ts +19 -0
  5. package/src/contracts/HeartbeatConfig.ts +2 -0
  6. package/src/dashboard/DashboardController.ts +71 -12
  7. package/src/dashboard/pages/CachePage.ts +61 -5
  8. package/src/dashboard/pages/CommandDetailPage.ts +216 -0
  9. package/src/dashboard/pages/CommandsPage.ts +72 -0
  10. package/src/dashboard/pages/EventsPage.ts +59 -6
  11. package/src/dashboard/pages/ExceptionsPage.ts +37 -6
  12. package/src/dashboard/pages/JobsPage.ts +61 -5
  13. package/src/dashboard/pages/LogsPage.ts +116 -0
  14. package/src/dashboard/pages/ModelsPage.ts +112 -0
  15. package/src/dashboard/pages/NotificationsPage.ts +87 -0
  16. package/src/dashboard/pages/OverviewPage.ts +109 -45
  17. package/src/dashboard/pages/PerformancePage.ts +151 -20
  18. package/src/dashboard/pages/QueriesPage.ts +92 -8
  19. package/src/dashboard/pages/RequestDetailPage.ts +227 -3
  20. package/src/dashboard/pages/RequestsPage.ts +90 -3
  21. package/src/dashboard/pages/SchedulesPage.ts +71 -0
  22. package/src/dashboard/shared/components.ts +140 -0
  23. package/src/dashboard/shared/layout.ts +327 -108
  24. package/src/index.ts +9 -0
  25. package/src/metrics/MetricsCollector.ts +7 -3
  26. package/src/middleware/HeartbeatMiddleware.ts +26 -9
  27. package/src/migrations/CreateHeartbeatTables.ts +14 -0
  28. package/src/models/EntryModel.ts +1 -1
  29. package/src/storage/HeartbeatStore.ts +174 -1
  30. package/src/testing/HeartbeatFake.ts +6 -0
  31. package/src/tracing/Tracer.ts +32 -0
  32. package/src/watchers/CommandWatcher.ts +23 -0
  33. package/src/watchers/EventWatcher.ts +13 -0
  34. package/src/watchers/ScheduleWatcher.ts +1 -0
  35. package/src/widget/DebugWidget.ts +53 -30
@@ -11,23 +11,56 @@ export function renderLayout(options: {
11
11
  }): string {
12
12
  const { title, activePage, basePath, content } = options
13
13
 
14
- const pages = [
15
- { key: 'overview', label: 'Overview', icon: ICONS.grid },
16
- { key: 'requests', label: 'Requests', icon: ICONS.arrow },
17
- { key: 'queries', label: 'Queries', icon: ICONS.db },
18
- { key: 'exceptions', label: 'Exceptions', icon: ICONS.alert },
19
- { key: 'jobs', label: 'Jobs', icon: ICONS.layers },
20
- { key: 'cache', label: 'Cache', icon: ICONS.box },
21
- { key: 'events', label: 'Events', icon: ICONS.zap },
22
- { key: 'mail', label: 'Mail', icon: ICONS.mail },
23
- { key: 'performance', label: 'Performance', icon: ICONS.activity },
14
+ const sections = [
15
+ {
16
+ label: '',
17
+ items: [
18
+ { key: 'overview', label: 'Overview', icon: ICONS.grid },
19
+ { key: 'performance', label: 'Performance', icon: ICONS.activity },
20
+ ],
21
+ },
22
+ {
23
+ label: 'Inspect',
24
+ items: [
25
+ { key: 'requests', label: 'Requests', icon: ICONS.arrow },
26
+ { key: 'queries', label: 'Queries', icon: ICONS.db },
27
+ { key: 'exceptions', label: 'Exceptions', icon: ICONS.alert },
28
+ { key: 'logs', label: 'Logs', icon: ICONS.file },
29
+ ],
30
+ },
31
+ {
32
+ label: 'Services',
33
+ items: [
34
+ { key: 'jobs', label: 'Jobs', icon: ICONS.layers },
35
+ { key: 'cache', label: 'Cache', icon: ICONS.box },
36
+ { key: 'events', label: 'Events', icon: ICONS.zap },
37
+ { key: 'mail', label: 'Mail', icon: ICONS.mail },
38
+ { key: 'notifications', label: 'Notifications', icon: ICONS.bell },
39
+ ],
40
+ },
41
+ {
42
+ label: 'System',
43
+ items: [
44
+ { key: 'models', label: 'Models', icon: ICONS.cube },
45
+ { key: 'schedules', label: 'Schedules', icon: ICONS.clock },
46
+ { key: 'commands', label: 'Commands', icon: ICONS.terminal },
47
+ ],
48
+ },
24
49
  ]
25
50
 
26
- const nav = pages
27
- .map((p) => {
28
- const cls = p.key === activePage ? ' class="active"' : ''
29
- const href = p.key === 'overview' ? basePath : `${basePath}/${p.key}`
30
- return `<a href="${href}"${cls}>${p.icon}<span>${p.label}</span></a>`
51
+ const nav = sections
52
+ .map((section) => {
53
+ const heading = section.label
54
+ ? `<div class="nav-section">${section.label}</div>`
55
+ : ''
56
+ const links = section.items
57
+ .map((p) => {
58
+ const cls = p.key === activePage ? ' class="active"' : ''
59
+ const href = p.key === 'overview' ? basePath : `${basePath}/${p.key}`
60
+ return `<a href="${href}"${cls}>${p.icon}<span>${p.label}</span></a>`
61
+ })
62
+ .join('\n ')
63
+ return heading + links
31
64
  })
32
65
  .join('\n ')
33
66
 
@@ -42,18 +75,21 @@ export function renderLayout(options: {
42
75
  <body>
43
76
  <aside class="sidebar">
44
77
  <div class="brand">
45
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
46
- <span>Heartbeat</span>
78
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
79
+ <span>heartbeat</span>
47
80
  </div>
48
81
  <nav>${nav}</nav>
49
82
  <div class="sidebar-footer">
50
- <span class="sidebar-brand-footer"><span style="color:var(--accent)">●</span> mantiq</span>
83
+ <div class="sidebar-brand-footer">
84
+ <span class="dot">.</span>mantiq
85
+ </div>
86
+ <button class="theme-btn" onclick="toggleTheme()" title="Toggle theme">${ICONS.moon}</button>
51
87
  </div>
52
88
  </aside>
53
89
  <main>
54
90
  <div class="topbar">
55
91
  <h1 class="page-title">${title}</h1>
56
- <button class="theme-btn" onclick="toggleTheme()" title="Toggle theme">${ICONS.moon}</button>
92
+ <button class="refresh-toggle" onclick="toggleAutoRefresh()" title="Toggle auto-refresh (R)">${ICONS.refresh} Auto</button>
57
93
  </div>
58
94
  ${content}
59
95
  </main>
@@ -70,6 +106,23 @@ export function renderLayout(options: {
70
106
  document.documentElement.setAttribute('data-theme',n);
71
107
  localStorage.setItem('hb-theme',n);
72
108
  }
109
+ var _autoRefresh = false;
110
+ var _refreshTimer = null;
111
+ function toggleAutoRefresh() {
112
+ _autoRefresh = !_autoRefresh;
113
+ var btn = document.querySelector('.refresh-toggle');
114
+ if (btn) btn.classList.toggle('active', _autoRefresh);
115
+ if (_autoRefresh) {
116
+ _refreshTimer = setInterval(function() { location.reload(); }, 5000);
117
+ } else {
118
+ clearInterval(_refreshTimer);
119
+ _refreshTimer = null;
120
+ }
121
+ }
122
+ document.addEventListener('keydown', function(e) {
123
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.tagName === 'TEXTAREA') return;
124
+ if (e.key === 'r') toggleAutoRefresh();
125
+ });
73
126
  </script>
74
127
  </body>
75
128
  </html>`
@@ -88,164 +141,330 @@ const ICONS = {
88
141
  activity: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>`,
89
142
  mail: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>`,
90
143
  moon: `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>`,
144
+ file: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>`,
145
+ cube: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>`,
146
+ clock: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`,
147
+ terminal: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>`,
148
+ bell: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>`,
149
+ refresh: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>`,
91
150
  }
92
151
 
93
152
  // ── CSS ──────────────────────────────────────────────────────────────────────
94
153
 
95
154
  const CSS = `
155
+ /* ── Animations ──────────────────────────────────────────────────────────── */
156
+ @keyframes fadeIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
157
+ @keyframes pulse-glow{0%,100%{opacity:.6}50%{opacity:1}}
158
+ @keyframes shimmer{0%{background-position:-200% 0}100%{background-position:200% 0}}
159
+ @keyframes slide-up{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
160
+
161
+ /* ── Theme — Solid emerald, no transparency ─────────────────────────────── */
96
162
  :root,[data-theme="light"]{
97
- --bg-0:#fff;--bg-1:#f9fafb;--bg-2:#f3f4f6;--bg-3:#e5e7eb;
98
- --fg-0:#111827;--fg-1:#374151;--fg-2:#6b7280;--fg-3:#9ca3af;
99
- --border:#e5e7eb;--ring:rgba(16,185,129,.25);
100
- --accent:#10b981;--accent-soft:rgba(16,185,129,.1);
101
- --green:#16a34a;--green-soft:rgba(22,163,74,.1);
102
- --amber:#d97706;--amber-soft:rgba(217,119,6,.1);
103
- --red:#dc2626;--red-soft:rgba(220,38,38,.1);
104
- --blue:#2563eb;--blue-soft:rgba(37,99,235,.1);
163
+ --bg-0:#fafafa;--bg-1:#f5f5f4;--bg-2:#e7e5e4;--bg-3:#d6d3d1;
164
+ --fg-0:#0c0a09;--fg-1:#1c1917;--fg-2:#78716c;--fg-3:#a8a29e;
165
+ --border:#e7e5e4;--border-2:#d6d3d1;
166
+ --accent:#059669;--accent-hover:#047857;--accent-text:#047857;
167
+ --green:#059669;--green-soft:#ecfdf5;
168
+ --amber:#b45309;--amber-soft:#fffbeb;
169
+ --red:#dc2626;--red-soft:#fef2f2;
170
+ --blue:#2563eb;--blue-soft:#eff6ff;
171
+ --card-bg:#ffffff;--card-border:#e7e5e4;
172
+
173
+ --mono:'SF Mono',ui-monospace,'Cascadia Mono','JetBrains Mono',Menlo,monospace;
174
+ --radius:10px;
105
175
  }
106
176
  [data-theme="dark"]{
107
- --bg-0:#0a0a0b;--bg-1:#111113;--bg-2:#1a1a1d;--bg-3:#27272a;
108
- --fg-0:#fafafa;--fg-1:#d4d4d8;--fg-2:#71717a;--fg-3:#52525b;
109
- --border:#27272a;--ring:rgba(52,211,153,.3);
110
- --accent:#34d399;--accent-soft:rgba(52,211,153,.1);
111
- --green:#4ade80;--green-soft:rgba(74,222,128,.08);
112
- --amber:#fbbf24;--amber-soft:rgba(251,191,36,.08);
113
- --red:#f87171;--red-soft:rgba(248,113,113,.08);
114
- --blue:#60a5fa;--blue-soft:rgba(96,165,250,.08);
177
+ --bg-0:#09090b;--bg-1:#0c0c0e;--bg-2:#18181b;--bg-3:#27272a;
178
+ --fg-0:#fafafa;--fg-1:#e4e4e7;--fg-2:#a1a1aa;--fg-3:#52525b;
179
+ --border:#27272a;--border-2:#3f3f46;
180
+ --accent:#10b981;--accent-hover:#34d399;--accent-text:#34d399;
181
+ --green:#10b981;--green-soft:#0c1f17;
182
+ --amber:#f59e0b;--amber-soft:#1a1508;
183
+ --red:#ef4444;--red-soft:#1f0c0c;
184
+ --blue:#3b82f6;--blue-soft:#0c1425;
185
+ --card-bg:#0c0c0e;--card-border:#27272a;
186
+
187
+ --mono:'SF Mono',ui-monospace,'Cascadia Mono','JetBrains Mono',Menlo,monospace;
188
+ --radius:10px;
115
189
  }
190
+
191
+ /* ── Reset ───────────────────────────────────────────────────────────────── */
116
192
  *{margin:0;padding:0;box-sizing:border-box}
117
193
  body{
118
- font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',sans-serif;
119
- font-size:14px;line-height:1.5;color:var(--fg-0);background:var(--bg-0);
194
+ font-family:var(--mono);
195
+ font-size:13px;line-height:1.6;color:var(--fg-0);background:var(--bg-0);
120
196
  display:flex;min-height:100vh;-webkit-font-smoothing:antialiased;
197
+ font-feature-settings:'liga' 1,'calt' 1;
121
198
  }
199
+ ::selection{background:var(--hover-bg);color:var(--fg-0)}
200
+ ::-webkit-scrollbar{width:5px;height:5px}
201
+ ::-webkit-scrollbar-track{background:transparent}
202
+ ::-webkit-scrollbar-thumb{background:var(--border);border-radius:10px}
203
+ ::-webkit-scrollbar-thumb:hover{background:var(--fg-3)}
122
204
 
123
- /* Sidebar */
205
+ /* ── Sidebar ─────────────────────────────────────────────────────────────── */
124
206
  .sidebar{
125
- width:200px;background:var(--bg-1);border-right:1px solid var(--border);
207
+ width:200px;background:var(--bg-0);border-right:1px solid var(--border);
126
208
  display:flex;flex-direction:column;position:fixed;top:0;bottom:0;z-index:10;
127
209
  }
128
210
  .brand{
129
- padding:16px 14px;font-size:14px;font-weight:600;color:var(--fg-0);
130
- display:flex;align-items:center;gap:8px;letter-spacing:-.02em;
211
+ padding:18px 16px 14px;font-size:12px;font-weight:700;color:var(--fg-0);
212
+ display:flex;align-items:center;gap:7px;letter-spacing:-.02em;
213
+ font-family:var(--mono);border-bottom:1px solid var(--border);
131
214
  }
132
215
  .brand svg{color:var(--accent)}
133
- .sidebar nav{padding:4px 6px;flex:1}
216
+ .sidebar nav{padding:6px 0;flex:1;overflow-y:auto}
217
+ .nav-section{
218
+ font-size:9px;font-weight:700;color:var(--fg-3);
219
+ text-transform:uppercase;letter-spacing:.1em;
220
+ padding:16px 16px 4px;font-family:var(--mono);
221
+ }
134
222
  .sidebar nav a{
135
223
  display:flex;align-items:center;gap:8px;
136
- padding:6px 10px;border-radius:6px;margin:1px 0;
137
- color:var(--fg-2);text-decoration:none;font-size:13px;
138
- transition:color .1s,background .1s;
224
+ padding:7px 16px;margin:0;
225
+ color:var(--fg-2);text-decoration:none;font-size:11px;font-weight:500;
226
+ transition:color .12s;
227
+ font-family:var(--mono);
228
+ border-left:2px solid transparent;
139
229
  }
140
230
  .sidebar nav a span{flex:1}
141
- .sidebar nav a svg{flex-shrink:0;opacity:.6}
142
- .sidebar nav a:hover{color:var(--fg-0);background:var(--bg-2)}
143
- .sidebar nav a:hover svg{opacity:1}
144
- .sidebar nav a.active{color:var(--fg-0);background:var(--bg-2);font-weight:500}
231
+ .sidebar nav a svg{flex-shrink:0;opacity:.35;transition:opacity .12s}
232
+ .sidebar nav a:hover{color:var(--fg-0)}
233
+ .sidebar nav a:hover svg{opacity:.6}
234
+ .sidebar nav a.active{
235
+ color:var(--accent);font-weight:600;
236
+ border-left-color:var(--accent);
237
+ }
145
238
  .sidebar nav a.active svg{opacity:1;color:var(--accent)}
146
239
  .sidebar-footer{
147
- border-top:1px solid var(--border);padding:10px 14px;
240
+ border-top:1px solid var(--border);padding:14px 16px;
148
241
  display:flex;align-items:center;justify-content:space-between;
149
242
  }
150
243
  .sidebar-brand-footer{
151
- font-size:11px;font-weight:500;color:var(--fg-3);opacity:.4;
152
- font-family:'SF Mono',ui-monospace,'Cascadia Mono',Menlo,monospace;
244
+ font-size:12px;font-weight:700;color:var(--fg-3);
245
+ font-family:var(--mono);letter-spacing:-.02em;
153
246
  }
247
+ .sidebar-brand-footer .dot{color:var(--accent);font-weight:800}
154
248
  .theme-btn{
155
249
  all:unset;color:var(--fg-3);cursor:pointer;display:flex;align-items:center;
156
- padding:4px;border-radius:4px;
250
+ padding:6px;border-radius:8px;border:1px solid var(--border);
251
+ transition:all .2s;
157
252
  }
158
- .theme-btn:hover{color:var(--fg-1)}
253
+ .theme-btn:hover{color:var(--accent);border-color:var(--accent)}
159
254
 
160
- /* Main */
161
- main{margin-left:200px;flex:1;padding:24px 28px;max-width:1200px;position:relative}
162
- .topbar{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}
163
- .page-title{font-size:18px;font-weight:600;letter-spacing:-.02em;color:var(--fg-0);margin:0}
255
+ /* ── Main ────────────────────────────────────────────────────────────────── */
256
+ main{
257
+ margin-left:200px;flex:1;padding:24px 28px;max-width:1200px;position:relative;
258
+ animation:fadeIn .3s ease-out;
259
+ }
260
+ .topbar{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px}
261
+ .page-title{
262
+ font-size:20px;font-weight:700;letter-spacing:-.03em;color:var(--fg-0);margin:0;
263
+ font-family:var(--mono);
264
+ }
164
265
 
165
- /* Cards */
266
+ /* ── Cards — Raised Apple-like ───────────────────────────────────────────── */
166
267
  .card{
167
- background:var(--bg-1);border:1px solid var(--border);
168
- border-radius:8px;padding:14px 16px;margin-bottom:14px;
268
+ background:var(--card-bg);border:1px solid var(--card-border);
269
+ border-radius:var(--radius);padding:18px 20px;margin-bottom:16px;
270
+ transition:border-color .2s;
169
271
  }
272
+ .card:hover{border-color:var(--border-2)}
170
273
  .card-title{
171
- font-size:11px;font-weight:500;color:var(--fg-3);
172
- text-transform:uppercase;letter-spacing:.06em;margin-bottom:10px;
274
+ font-size:10px;font-weight:700;color:var(--fg-3);
275
+ text-transform:uppercase;letter-spacing:.1em;margin-bottom:12px;
276
+ font-family:var(--mono);
173
277
  }
174
278
 
175
- /* Stat grid */
176
- .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:10px;margin-bottom:18px}
177
- .stat{background:var(--bg-1);border:1px solid var(--border);border-radius:8px;padding:14px 16px}
178
- .stat-label{font-size:11px;font-weight:500;color:var(--fg-3);text-transform:uppercase;letter-spacing:.06em}
179
- .stat-val{font-size:24px;font-weight:600;letter-spacing:-.03em;margin-top:4px;color:var(--fg-0);font-variant-numeric:tabular-nums}
180
- .stat-sub{font-size:11px;color:var(--fg-3);margin-top:2px}
279
+ /* ── Stats — Neon glow on hover ──────────────────────────────────────────── */
280
+ .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px;margin-bottom:20px}
281
+ .stat{
282
+ background:var(--card-bg);border:1px solid var(--card-border);
283
+ border-radius:var(--radius);padding:16px 18px;
284
+ transition:all .25s cubic-bezier(.4,0,.2,1);
285
+ animation:slide-up .4s ease-out both;
286
+ }
287
+ .stat:hover{border-color:var(--accent)}
288
+ .stat-label{
289
+ font-size:10px;font-weight:700;color:var(--fg-3);
290
+ text-transform:uppercase;letter-spacing:.1em;
291
+ font-family:var(--mono);
292
+ }
293
+ .stat-val{
294
+ font-size:28px;font-weight:800;letter-spacing:-.04em;margin-top:6px;
295
+ color:var(--fg-0);font-variant-numeric:tabular-nums;
296
+ font-family:var(--mono);
297
+ }
298
+ .stat-sub{font-size:10px;color:var(--fg-3);margin-top:3px;font-family:var(--mono)}
181
299
 
182
- /* Tables */
183
- table{width:100%;border-collapse:collapse;font-size:13px}
300
+ /* ── Tables — Clean monospace ────────────────────────────────────────────── */
301
+ table{width:100%;border-collapse:collapse;font-size:12px;font-family:var(--mono)}
184
302
  thead th{
185
- text-align:left;padding:6px 10px;font-size:11px;font-weight:500;
186
- color:var(--fg-3);text-transform:uppercase;letter-spacing:.04em;
303
+ text-align:left;padding:8px 12px;font-size:10px;font-weight:700;
304
+ color:var(--fg-3);text-transform:uppercase;letter-spacing:.08em;
187
305
  border-bottom:1px solid var(--border);
188
306
  }
189
- tbody td{padding:8px 10px;border-bottom:1px solid var(--border);color:var(--fg-1);vertical-align:middle}
307
+ tbody td{
308
+ padding:10px 12px;border-bottom:1px solid var(--border);
309
+ color:var(--fg-1);vertical-align:middle;
310
+ transition:background .15s;
311
+ }
190
312
  tbody tr:last-child td{border-bottom:none}
191
313
  tbody tr:hover td{background:var(--bg-2)}
314
+ tbody tr{animation:fadeIn .3s ease-out both}
315
+ tbody tr:nth-child(1){animation-delay:.02s}
316
+ tbody tr:nth-child(2){animation-delay:.04s}
317
+ tbody tr:nth-child(3){animation-delay:.06s}
318
+ tbody tr:nth-child(4){animation-delay:.08s}
319
+ tbody tr:nth-child(5){animation-delay:.1s}
192
320
 
193
- /* Badges */
321
+ /* ── Badges — Pill-shaped with glow ──────────────────────────────────────── */
194
322
  .b{
195
- display:inline-flex;align-items:center;padding:1px 7px;border-radius:4px;
196
- font-size:11px;font-weight:500;line-height:18px;white-space:nowrap;
323
+ display:inline-flex;align-items:center;padding:2px 10px;border-radius:100px;
324
+ font-size:11px;font-weight:600;line-height:18px;white-space:nowrap;
325
+ font-family:var(--mono);letter-spacing:.01em;
326
+ transition:all .2s;
197
327
  }
198
- .b-green{background:var(--green-soft);color:var(--green)}
199
- .b-amber{background:var(--amber-soft);color:var(--amber)}
200
- .b-red{background:var(--red-soft);color:var(--red)}
201
- .b-blue{background:var(--blue-soft);color:var(--blue)}
202
- .b-mute{background:var(--bg-3);color:var(--fg-2)}
328
+ .b-green{background:var(--green-soft);color:var(--green);border:1px solid var(--border)}
329
+ .b-amber{background:var(--amber-soft);color:var(--amber);border:1px solid var(--border)}
330
+ .b-red{background:var(--red-soft);color:var(--red);border:1px solid var(--border)}
331
+ .b-blue{background:var(--blue-soft);color:var(--blue);border:1px solid var(--border)}
332
+ .b-mute{background:var(--bg-2);color:var(--fg-2);border:1px solid var(--border)}
203
333
 
204
- /* Tabs */
334
+ /* ── Tabs ────────────────────────────────────────────────────────────────── */
205
335
  .tabs{display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:0}
206
336
  .tabs input[type="radio"]{display:none}
207
337
  .tabs label{
208
- padding:8px 16px;font-size:13px;font-weight:500;color:var(--fg-3);
338
+ padding:10px 18px;font-size:12px;font-weight:600;color:var(--fg-3);
209
339
  cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;
210
- transition:color .1s,border-color .1s;
340
+ transition:all .2s;font-family:var(--mono);
211
341
  }
212
342
  .tabs label:hover{color:var(--fg-1)}
213
- .tabs input[type="radio"]:checked+label{color:var(--fg-0);border-bottom-color:var(--accent)}
214
- .tab-panel{display:none;padding:14px 0 0}
343
+ .tabs input[type="radio"]:checked+label{
344
+ color:var(--accent-text);border-bottom-color:var(--accent);
345
+ }
346
+ .tab-panel{display:none;padding:16px 0 0;animation:fadeIn .2s ease-out}
215
347
  .tab-panel.active{display:block}
216
348
 
217
- /* Detail meta */
218
- .meta-grid{
219
- display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:0;
220
- }
221
- .meta-item{padding:10px 14px;border-bottom:1px solid var(--border)}
349
+ /* ── Detail meta ─────────────────────────────────────────────────────────── */
350
+ .meta-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:0}
351
+ .meta-item{padding:12px 16px;border-bottom:1px solid var(--border)}
222
352
  .meta-item:last-child{border-bottom:none}
223
- .meta-label{font-size:11px;font-weight:500;color:var(--fg-3);text-transform:uppercase;letter-spacing:.04em;margin-bottom:2px}
224
- .meta-value{font-size:13px;color:var(--fg-1);word-break:break-all}
353
+ .meta-label{
354
+ font-size:10px;font-weight:700;color:var(--fg-3);
355
+ text-transform:uppercase;letter-spacing:.08em;margin-bottom:4px;
356
+ font-family:var(--mono);
357
+ }
358
+ .meta-value{font-size:12px;color:var(--fg-1);word-break:break-all;font-family:var(--mono)}
225
359
 
226
- /* Copyable bar */
360
+ /* ── Copy ────────────────────────────────────────────────────────────────── */
227
361
  .copy-bar{
228
362
  display:flex;align-items:center;gap:8px;
229
- background:var(--bg-2);border:1px solid var(--border);border-radius:6px;
230
- padding:8px 12px;margin-bottom:14px;
363
+ background:var(--bg-2);border:1px solid var(--border);border-radius:var(--radius);
364
+ padding:10px 14px;margin-bottom:16px;
231
365
  }
232
- .copy-bar code{flex:1;font-size:12px;color:var(--fg-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
366
+ .copy-bar code{flex:1;font-size:11px;color:var(--fg-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
233
367
  .copy-btn{
234
368
  all:unset;cursor:pointer;color:var(--fg-3);display:flex;align-items:center;
235
- padding:3px 6px;border-radius:4px;font-size:11px;gap:4px;flex-shrink:0;
236
- transition:color .1s,background .1s;
369
+ padding:4px 8px;border-radius:8px;font-size:11px;gap:4px;flex-shrink:0;
370
+ transition:all .2s;border:1px solid transparent;font-family:var(--mono);
237
371
  }
238
- .copy-btn:hover{color:var(--fg-1);background:var(--bg-3)}
239
- .copy-btn.copied{color:var(--green)}
372
+ .copy-btn:hover{color:var(--accent);border-color:var(--border)}
373
+ .copy-btn.copied{color:var(--green);border-color:var(--green)}
240
374
 
241
- /* Utilities */
242
- .mono{font-family:'SF Mono',ui-monospace,'Cascadia Mono',Menlo,monospace;font-size:12px}
375
+ /* ── Utilities ───────────────────────────────────────────────────────────── */
376
+ .mono{font-family:var(--mono);font-size:11px}
243
377
  .trunc{max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;vertical-align:middle}
244
378
  .muted{color:var(--fg-2)}
245
379
  .dim{color:var(--fg-3)}
246
- .sm{font-size:12px}
247
- .empty{text-align:center;padding:32px 16px;color:var(--fg-3);font-size:13px}
380
+ .sm{font-size:11px}
381
+ .empty{text-align:center;padding:40px 16px;color:var(--fg-3);font-size:12px}
248
382
  .flex-row{display:flex;gap:16px;align-items:center;flex-wrap:wrap}
249
- .mt{margin-top:14px}
250
- .mb{margin-bottom:14px}
383
+ .mt{margin-top:16px}
384
+ .mb{margin-bottom:16px}
385
+
386
+ /* ── Pagination — Pill buttons ───────────────────────────────────────────── */
387
+ .pagination{display:flex;gap:4px;align-items:center;margin-top:20px;justify-content:center}
388
+ .pagination a,.pagination span{
389
+ padding:5px 12px;border-radius:100px;font-size:11px;font-weight:600;
390
+ text-decoration:none;color:var(--fg-3);border:1px solid var(--border);
391
+ transition:all .2s;font-family:var(--mono);
392
+ }
393
+ .pagination a:hover{color:var(--accent);border-color:var(--accent)}
394
+ .pagination .active{background:var(--accent);color:var(--bg-0);border-color:var(--accent)}
395
+ .pagination .disabled{opacity:.3;pointer-events:none}
396
+
397
+ /* ── Filter bar ──────────────────────────────────────────────────────────── */
398
+ .filter-bar{display:flex;gap:8px;align-items:center;margin-bottom:18px;flex-wrap:wrap}
399
+ .filter-bar input,.filter-bar select{
400
+ background:var(--bg-1);border:1px solid var(--border);border-radius:var(--radius);
401
+ padding:8px 12px;font-size:11px;color:var(--fg-1);outline:none;
402
+ font-family:var(--mono);transition:all .2s;
403
+ }
404
+ .filter-bar input:focus,.filter-bar select:focus{border-color:var(--accent)}
405
+ .filter-bar input{min-width:200px}
406
+ .filter-bar select{min-width:100px}
407
+
408
+ /* ── Breadcrumbs ─────────────────────────────────────────────────────────── */
409
+ .breadcrumbs{display:flex;gap:6px;align-items:center;font-size:11px;color:var(--fg-3);margin-bottom:14px;font-family:var(--mono)}
410
+ .breadcrumbs a{color:var(--fg-2);text-decoration:none;transition:color .15s}
411
+ .breadcrumbs a:hover{color:var(--accent)}
412
+ .breadcrumbs .sep{color:var(--fg-3);opacity:.5}
413
+
414
+ /* ── Collapsible ─────────────────────────────────────────────────────────── */
415
+ .collapsible{border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;margin-bottom:8px;transition:border-color .2s}
416
+ .collapsible:hover{border-color:var(--border-2)}
417
+ .collapsible-header{
418
+ display:flex;align-items:center;gap:8px;padding:12px 16px;
419
+ cursor:pointer;background:var(--bg-1);user-select:none;
420
+ transition:background .15s;
421
+ }
422
+ .collapsible-header:hover{background:var(--bg-2)}
423
+ .collapsible-header .chevron{transition:transform .2s cubic-bezier(.4,0,.2,1);font-size:10px;color:var(--fg-3)}
424
+ .collapsible-header.open .chevron{transform:rotate(90deg)}
425
+ .collapsible-body{padding:0 16px 12px;display:none;animation:fadeIn .2s ease-out}
426
+ .collapsible-body.open{display:block}
427
+
428
+ /* ── Waterfall ───────────────────────────────────────────────────────────── */
429
+ .waterfall{display:flex;flex-direction:column;gap:4px}
430
+ .waterfall-row{display:flex;align-items:center;gap:8px;font-size:10px;font-family:var(--mono);animation:fadeIn .3s ease-out both}
431
+ .waterfall-label{width:110px;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--fg-2);flex-shrink:0}
432
+ .waterfall-track{flex:1;height:18px;background:var(--bg-2);border-radius:100px;position:relative;overflow:hidden}
433
+ .waterfall-bar{position:absolute;height:100%;border-radius:100px;min-width:3px;opacity:.8;transition:opacity .2s}
434
+ .waterfall-row:hover .waterfall-bar{opacity:1}
435
+ .waterfall-dur{width:60px;text-align:right;color:var(--fg-3);flex-shrink:0}
436
+
437
+ /* ── SQL ─────────────────────────────────────────────────────────────────── */
438
+ .sql-kw{color:#a78bfa;font-weight:700}
439
+ .sql-str{color:var(--accent)}
440
+ .sql-num{color:#fbbf24}
441
+ .sql-fn{color:#7dd3fc}
442
+
443
+ /* ── Auto-refresh ────────────────────────────────────────────────────────── */
444
+ .refresh-toggle{
445
+ background:none;border:1px solid var(--border);border-radius:100px;
446
+ padding:5px 12px;cursor:pointer;color:var(--fg-3);font-size:11px;
447
+ display:flex;align-items:center;gap:5px;font-family:var(--mono);font-weight:600;
448
+ transition:all .2s;
449
+ }
450
+ .refresh-toggle:hover{border-color:var(--fg-3);color:var(--fg-2)}
451
+ .refresh-toggle.active{
452
+ border-color:var(--accent);color:var(--accent);
453
+ }
454
+
455
+ /* ── Empty state ─────────────────────────────────────────────────────────── */
456
+ .empty-state{text-align:center;padding:56px 24px;color:var(--fg-3)}
457
+ .empty-state .icon{font-size:36px;margin-bottom:14px;opacity:.4}
458
+ .empty-state .title{font-size:13px;font-weight:700;color:var(--fg-2);margin-bottom:4px;font-family:var(--mono)}
459
+ .empty-state .desc{font-size:11px;font-family:var(--mono)}
460
+
461
+ /* ── Links ───────────────────────────────────────────────────────────────── */
462
+ a{color:var(--accent);transition:color .15s}
463
+ a:hover{color:var(--accent-text)}
464
+
465
+ /* ── Responsive ──────────────────────────────────────────────────────────── */
466
+ @media(max-width:768px){
467
+ .sidebar{display:none}
468
+ main{margin-left:0;padding:16px}
469
+ }
251
470
  `
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export { HeartbeatServiceProvider } from './HeartbeatServiceProvider.ts'
5
5
  // ── Contracts ───────────────────────────────────────────────────────────────
6
6
  export type {
7
7
  EntryType,
8
+ OriginType,
8
9
  PendingEntry,
9
10
  HeartbeatEntry,
10
11
  RequestEntryContent,
@@ -16,6 +17,7 @@ export type {
16
17
  ModelEntryContent,
17
18
  LogEntryContent,
18
19
  ScheduleEntryContent,
20
+ CommandEntryContent,
19
21
  SpanStatus,
20
22
  StoredSpan,
21
23
  ExceptionGroup,
@@ -44,6 +46,7 @@ export { ModelWatcher } from './watchers/ModelWatcher.ts'
44
46
  export { LogWatcher } from './watchers/LogWatcher.ts'
45
47
  export { ScheduleWatcher } from './watchers/ScheduleWatcher.ts'
46
48
  export { MailWatcher } from './watchers/MailWatcher.ts'
49
+ export { CommandWatcher } from './watchers/CommandWatcher.ts'
47
50
 
48
51
  // ── Widget ──────────────────────────────────────────────────────────────────
49
52
  export { renderWidget } from './widget/DebugWidget.ts'
@@ -85,6 +88,12 @@ export { HeartbeatFake } from './testing/HeartbeatFake.ts'
85
88
 
86
89
  // ── Dashboard ───────────────────────────────────────────────────────────────
87
90
  export { DashboardController } from './dashboard/DashboardController.ts'
91
+ export { renderLogsPage } from './dashboard/pages/LogsPage.ts'
92
+ export { renderModelsPage } from './dashboard/pages/ModelsPage.ts'
93
+ export { renderSchedulesPage } from './dashboard/pages/SchedulesPage.ts'
94
+ export { renderCommandsPage } from './dashboard/pages/CommandsPage.ts'
95
+ export { renderCommandDetailPage } from './dashboard/pages/CommandDetailPage.ts'
96
+ export { renderNotificationsPage } from './dashboard/pages/NotificationsPage.ts'
88
97
 
89
98
  // ── Middleware ──────────────────────────────────────────────────────────
90
99
  export { HeartbeatMiddleware } from './middleware/HeartbeatMiddleware.ts'
@@ -138,9 +138,12 @@ export class MetricsCollector {
138
138
  const now = Date.now()
139
139
  const bucket = Math.floor(now / 60_000) // 1-minute buckets
140
140
 
141
+ // Snapshot current counters so new increments during the write are not lost
142
+ const counterSnapshot = new Map(this.counters)
143
+
141
144
  try {
142
145
  // Flush counters
143
- for (const [key, value] of this.counters) {
146
+ for (const [key, value] of counterSnapshot) {
144
147
  const { name, tags } = this.parseMetricKey(key)
145
148
  await this.store.insertMetric(name, 'counter', value, tags, 60, bucket)
146
149
  }
@@ -159,10 +162,11 @@ export class MetricsCollector {
159
162
  await this.store.insertMetric(name, 'histogram', avg, tags, 60, bucket)
160
163
  }
161
164
 
162
- // Reset counters after flush
165
+ // Reset counters only after successful write — if the write failed,
166
+ // data is preserved for the next flush attempt.
163
167
  this.counters.clear()
164
168
  } catch {
165
- // swallow
169
+ // Write failed — counters are preserved for the next flush attempt
166
170
  }
167
171
  }
168
172