@mantiq/heartbeat 0.5.22 → 0.6.0

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 (34) 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/middleware/HeartbeatMiddleware.ts +26 -9
  26. package/src/migrations/CreateHeartbeatTables.ts +14 -0
  27. package/src/models/EntryModel.ts +1 -1
  28. package/src/storage/HeartbeatStore.ts +174 -1
  29. package/src/testing/HeartbeatFake.ts +6 -0
  30. package/src/tracing/Tracer.ts +32 -0
  31. package/src/watchers/CommandWatcher.ts +23 -0
  32. package/src/watchers/EventWatcher.ts +13 -0
  33. package/src/watchers/ScheduleWatcher.ts +1 -0
  34. package/src/widget/DebugWidget.ts +53 -30
@@ -73,3 +73,143 @@ export function formatBytes(bytes: number): string {
73
73
  if (bytes < 1_073_741_824) return `${(bytes / 1_048_576).toFixed(1)} MB`
74
74
  return `${(bytes / 1_073_741_824).toFixed(2)} GB`
75
75
  }
76
+
77
+ export function pagination(total: number, page: number, perPage: number, baseUrl: string): string {
78
+ const totalPages = Math.ceil(total / perPage)
79
+ if (totalPages <= 1) return ''
80
+
81
+ const items: string[] = []
82
+
83
+ // Previous
84
+ if (page > 1) {
85
+ items.push(`<a href="${baseUrl}${baseUrl.includes('?') ? '&' : '?'}page=${page - 1}">&laquo; Prev</a>`)
86
+ } else {
87
+ items.push('<span class="disabled">&laquo; Prev</span>')
88
+ }
89
+
90
+ // Page numbers (show max 7)
91
+ const start = Math.max(1, page - 3)
92
+ const end = Math.min(totalPages, start + 6)
93
+ for (let i = start; i <= end; i++) {
94
+ if (i === page) {
95
+ items.push(`<span class="active">${i}</span>`)
96
+ } else {
97
+ items.push(`<a href="${baseUrl}${baseUrl.includes('?') ? '&' : '?'}page=${i}">${i}</a>`)
98
+ }
99
+ }
100
+
101
+ // Next
102
+ if (page < totalPages) {
103
+ items.push(`<a href="${baseUrl}${baseUrl.includes('?') ? '&' : '?'}page=${page + 1}">Next &raquo;</a>`)
104
+ } else {
105
+ items.push('<span class="disabled">Next &raquo;</span>')
106
+ }
107
+
108
+ return `<div class="pagination">${items.join('')}</div>`
109
+ }
110
+
111
+ export function filterBar(options: {
112
+ action: string
113
+ searchPlaceholder?: string
114
+ searchValue?: string
115
+ filters?: Array<{ name: string; label: string; options: Array<{ value: string; label: string }>; selected?: string }>
116
+ }): string {
117
+ const parts: string[] = [`<form class="filter-bar" method="get" action="${escapeHtml(options.action)}">`]
118
+
119
+ if (options.searchPlaceholder) {
120
+ parts.push(`<input type="text" name="search" placeholder="${escapeHtml(options.searchPlaceholder)}" value="${escapeHtml(options.searchValue ?? '')}" />`)
121
+ }
122
+
123
+ if (options.filters) {
124
+ for (const filter of options.filters) {
125
+ parts.push(`<select name="${escapeHtml(filter.name)}" onchange="this.form.submit()">`)
126
+ parts.push(`<option value="">${escapeHtml(filter.label)}</option>`)
127
+ for (const opt of filter.options) {
128
+ const sel = opt.value === filter.selected ? ' selected' : ''
129
+ parts.push(`<option value="${escapeHtml(opt.value)}"${sel}>${escapeHtml(opt.label)}</option>`)
130
+ }
131
+ parts.push('</select>')
132
+ }
133
+ }
134
+
135
+ parts.push('<button type="submit" style="background:var(--bg-2);border:1px solid var(--border-0);border-radius:6px;padding:6px 12px;font-size:12px;color:var(--fg-1);cursor:pointer">Filter</button>')
136
+ parts.push('</form>')
137
+ return parts.join('')
138
+ }
139
+
140
+ export function breadcrumbs(items: Array<{ label: string; href?: string }>): string {
141
+ return '<div class="breadcrumbs">' + items.map((item, i) => {
142
+ if (i === items.length - 1 || !item.href) {
143
+ return `<span>${escapeHtml(item.label)}</span>`
144
+ }
145
+ return `<a href="${escapeHtml(item.href)}">${escapeHtml(item.label)}</a><span class="sep">/</span>`
146
+ }).join('') + '</div>'
147
+ }
148
+
149
+ export function emptyState(icon: string, title: string, description: string): string {
150
+ return `<div class="empty-state"><div class="icon">${icon}</div><div class="title">${escapeHtml(title)}</div><div class="desc">${escapeHtml(description)}</div></div>`
151
+ }
152
+
153
+ export function collapsibleSection(title: string, count: number, color: string, content: string, defaultOpen = true): string {
154
+ const openClass = defaultOpen ? ' open' : ''
155
+ return `<div class="collapsible">
156
+ <div class="collapsible-header${openClass}" onclick="this.classList.toggle('open');this.nextElementSibling.classList.toggle('open')">
157
+ <span class="chevron">&#9654;</span>
158
+ <span style="color:${color};font-weight:600;font-size:12px;text-transform:uppercase;letter-spacing:.04em">${escapeHtml(title)}</span>
159
+ <span style="font-size:11px;color:var(--fg-3)">${count}</span>
160
+ </div>
161
+ <div class="collapsible-body${openClass}">${content}</div>
162
+ </div>`
163
+ }
164
+
165
+ export function sqlHighlight(sql: string): string {
166
+ const keywords = /\b(SELECT|FROM|WHERE|AND|OR|NOT|IN|IS|NULL|AS|ON|JOIN|LEFT|RIGHT|INNER|OUTER|CROSS|INSERT|INTO|VALUES|UPDATE|SET|DELETE|CREATE|TABLE|ALTER|DROP|INDEX|GROUP|BY|ORDER|HAVING|LIMIT|OFFSET|UNION|ALL|DISTINCT|COUNT|SUM|AVG|MIN|MAX|LIKE|BETWEEN|EXISTS|CASE|WHEN|THEN|ELSE|END|ASC|DESC|PRIMARY|KEY|FOREIGN|REFERENCES|UNIQUE|DEFAULT|CHECK|CONSTRAINT|TRANSACTION|BEGIN|COMMIT|ROLLBACK)\b/gi
167
+ const strings = /('[^']*')/g
168
+ const nums = /\b(\d+(?:\.\d+)?)\b/g
169
+
170
+ let result = escapeHtml(sql)
171
+ result = result.replace(strings, '<span class="sql-str">$1</span>')
172
+ result = result.replace(keywords, '<span class="sql-kw">$1</span>')
173
+ result = result.replace(nums, '<span class="sql-num">$1</span>')
174
+ return result
175
+ }
176
+
177
+ export function diffView(changes: Record<string, { old: any; new: any }> | null): string {
178
+ if (!changes || Object.keys(changes).length === 0) return '<span style="color:var(--fg-3)">No changes</span>'
179
+
180
+ const rows = Object.entries(changes).map(([key, { old: oldVal, new: newVal }]) => {
181
+ const o = oldVal === null || oldVal === undefined ? 'null' : String(oldVal)
182
+ const n = newVal === null || newVal === undefined ? 'null' : String(newVal)
183
+ return `<tr><td style="font-weight:500">${escapeHtml(key)}</td><td style="color:#f87171;text-decoration:line-through">${escapeHtml(o)}</td><td style="color:#34d399">${escapeHtml(n)}</td></tr>`
184
+ })
185
+
186
+ return `<table class="tbl" style="font-size:11px"><thead><tr><th>Field</th><th>Old</th><th>New</th></tr></thead><tbody>${rows.join('')}</tbody></table>`
187
+ }
188
+
189
+ export function waterfallChart(items: Array<{ label: string; start: number; end: number; color: string }>, totalDuration: number): string {
190
+ if (items.length === 0 || totalDuration <= 0) return ''
191
+
192
+ const rows = items.map((item) => {
193
+ const left = (item.start / totalDuration) * 100
194
+ const width = Math.max(((item.end - item.start) / totalDuration) * 100, 0.5)
195
+ const dur = (item.end - item.start).toFixed(1)
196
+ return `<div class="waterfall-row">
197
+ <div class="waterfall-label" title="${escapeHtml(item.label)}">${escapeHtml(truncate(item.label, 18))}</div>
198
+ <div class="waterfall-track"><div class="waterfall-bar" style="left:${left}%;width:${width}%;background:${item.color}"></div></div>
199
+ <div class="waterfall-dur">${dur}ms</div>
200
+ </div>`
201
+ })
202
+
203
+ return `<div class="waterfall">${rows.join('')}</div>`
204
+ }
205
+
206
+ export function originBadge(originType: string, originId: string | null, basePath: string): string {
207
+ if (!originId) return '<span style="color:var(--fg-3);font-size:11px">standalone</span>'
208
+
209
+ const colors: Record<string, string> = { request: '#818cf8', command: '#fb923c', schedule: '#fbbf24', job: '#60a5fa' }
210
+ const color = colors[originType] ?? 'var(--fg-3)'
211
+ const shortId = originId.slice(0, 8)
212
+ const href = originType === 'request' ? `${basePath}/requests/${originId}` : originType === 'command' ? `${basePath}/commands/${originId}` : '#'
213
+
214
+ return `<a href="${href}" style="font-size:11px;color:${color};text-decoration:none;border:1px solid ${color};border-radius:4px;padding:1px 6px" title="${escapeHtml(originId)}">${escapeHtml(originType)}:${shortId}</a>`
215
+ }