@bakery-framework/plugin-dashboard 1.0.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.
- package/LICENSE +19 -0
- package/README.md +44 -0
- package/package.json +42 -0
- package/src/authorize.ts +76 -0
- package/src/client/dashboard.ts +165 -0
- package/src/client/parts/database.ts +1176 -0
- package/src/client/parts/effects.ts +327 -0
- package/src/client/parts/logs.ts +132 -0
- package/src/client/parts/sessions.ts +382 -0
- package/src/client/parts/stats.ts +957 -0
- package/src/client/parts/utils.ts +237 -0
- package/src/components/DBBrowser.tsx +365 -0
- package/src/components/LogsPanel.tsx +35 -0
- package/src/components/SessionsPanel.tsx +90 -0
- package/src/components/StatsPanel.tsx +265 -0
- package/src/components/TopPagesPanel.tsx +57 -0
- package/src/endpoints/database.ts +211 -0
- package/src/endpoints/sessions.ts +59 -0
- package/src/index.ts +42 -0
- package/src/paths.ts +14 -0
- package/src/setup.ts +268 -0
- package/src/shell.tsx +137 -0
- package/src/test-fixtures.ts +55 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
export let refreshShimmerCache = () => {}
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Both effects below run on import. Without this guard, importing anything that
|
|
5
|
+
* transitively reaches this file outside a browser throws on the first `window`
|
|
6
|
+
* access — which made every module in `client/parts/` untestable, including the
|
|
7
|
+
* hand-built HTML in `database.ts` that has produced every XSS found in this
|
|
8
|
+
* repo. A DOM effect with no DOM is a no-op, not an error.
|
|
9
|
+
*/
|
|
10
|
+
const HAS_DOM = typeof window !== 'undefined' && typeof document !== 'undefined'
|
|
11
|
+
|
|
12
|
+
;(function initDashboardShimmer() {
|
|
13
|
+
if (!HAS_DOM) return
|
|
14
|
+
if (!window.matchMedia('(hover: hover) and (pointer: fine)').matches) return
|
|
15
|
+
|
|
16
|
+
let cachedCards: {
|
|
17
|
+
el: HTMLElement
|
|
18
|
+
width: number
|
|
19
|
+
height: number
|
|
20
|
+
pageLeft: number
|
|
21
|
+
pageTop: number
|
|
22
|
+
}[] = []
|
|
23
|
+
const mouse = { x: -9999, y: -9999 }
|
|
24
|
+
let rafId = 0
|
|
25
|
+
|
|
26
|
+
refreshShimmerCache = function updateRects() {
|
|
27
|
+
const cards = Array.from(
|
|
28
|
+
document.querySelectorAll('.glass-effect'),
|
|
29
|
+
) as HTMLElement[]
|
|
30
|
+
|
|
31
|
+
cachedCards = cards.map(el => {
|
|
32
|
+
const rect = el.getBoundingClientRect()
|
|
33
|
+
return {
|
|
34
|
+
el,
|
|
35
|
+
width: rect.width,
|
|
36
|
+
height: rect.height,
|
|
37
|
+
pageLeft: rect.left + window.scrollX,
|
|
38
|
+
pageTop: rect.top + window.scrollY,
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The observer below fires for every subtree mutation on the page, and
|
|
45
|
+
* `refreshShimmerCache` reads `getBoundingClientRect` once per glass card —
|
|
46
|
+
* a forced synchronous layout. The log console appends one node per
|
|
47
|
+
* websocket frame, each in its own task, so a busy server bought one full
|
|
48
|
+
* layout pass per log line. Coalescing into a frame makes that cost
|
|
49
|
+
* proportional to frames, not to mutations.
|
|
50
|
+
*/
|
|
51
|
+
let refreshRafId = 0
|
|
52
|
+
function scheduleRefresh() {
|
|
53
|
+
if (refreshRafId) return
|
|
54
|
+
refreshRafId = requestAnimationFrame(() => {
|
|
55
|
+
refreshRafId = 0
|
|
56
|
+
refreshShimmerCache()
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
window.addEventListener('resize', scheduleRefresh, { passive: true })
|
|
61
|
+
|
|
62
|
+
const observer = new MutationObserver(scheduleRefresh)
|
|
63
|
+
observer.observe(document.body, {
|
|
64
|
+
childList: true,
|
|
65
|
+
subtree: true,
|
|
66
|
+
attributes: true,
|
|
67
|
+
attributeFilter: ['class'],
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
refreshShimmerCache()
|
|
71
|
+
|
|
72
|
+
function updateStyles() {
|
|
73
|
+
const scrollX = window.scrollX
|
|
74
|
+
const scrollY = window.scrollY
|
|
75
|
+
|
|
76
|
+
for (let i = 0; i < cachedCards.length; i++) {
|
|
77
|
+
const c = cachedCards[i]
|
|
78
|
+
const left = c.pageLeft - scrollX
|
|
79
|
+
const top = c.pageTop - scrollY
|
|
80
|
+
|
|
81
|
+
const rx = mouse.x - left
|
|
82
|
+
const ry = mouse.y - top
|
|
83
|
+
|
|
84
|
+
const closestX = Math.max(left, Math.min(mouse.x, left + c.width))
|
|
85
|
+
const closestY = Math.max(top, Math.min(mouse.y, top + c.height))
|
|
86
|
+
const dx = mouse.x - closestX
|
|
87
|
+
const dy = mouse.y - closestY
|
|
88
|
+
const dist = Math.sqrt(dx * dx + dy * dy)
|
|
89
|
+
|
|
90
|
+
if (dist < 200) {
|
|
91
|
+
c.el.style.setProperty('--mouse-x', `${rx}px`)
|
|
92
|
+
c.el.style.setProperty('--mouse-y', `${ry}px`)
|
|
93
|
+
} else {
|
|
94
|
+
c.el.style.removeProperty('--mouse-x')
|
|
95
|
+
c.el.style.removeProperty('--mouse-y')
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
rafId = 0
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
window.addEventListener(
|
|
102
|
+
'pointermove',
|
|
103
|
+
e => {
|
|
104
|
+
mouse.x = e.clientX
|
|
105
|
+
mouse.y = e.clientY
|
|
106
|
+
if (!rafId) rafId = requestAnimationFrame(updateStyles)
|
|
107
|
+
},
|
|
108
|
+
{ passive: true },
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
window.addEventListener(
|
|
112
|
+
'scroll',
|
|
113
|
+
() => {
|
|
114
|
+
if (mouse.x === -9999) return
|
|
115
|
+
if (!rafId) rafId = requestAnimationFrame(updateStyles)
|
|
116
|
+
},
|
|
117
|
+
{ passive: true },
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
window.addEventListener('mouseleave', () => {
|
|
121
|
+
mouse.x = -9999
|
|
122
|
+
mouse.y = -9999
|
|
123
|
+
if (!rafId) rafId = requestAnimationFrame(updateStyles)
|
|
124
|
+
})
|
|
125
|
+
})()
|
|
126
|
+
|
|
127
|
+
;(function initDotPattern() {
|
|
128
|
+
if (!HAS_DOM) return
|
|
129
|
+
|
|
130
|
+
const canvas = document.createElement('canvas')
|
|
131
|
+
canvas.id = 'dot-pattern-canvas'
|
|
132
|
+
canvas.style.cssText = [
|
|
133
|
+
'position: fixed',
|
|
134
|
+
'inset: 0',
|
|
135
|
+
'width: 100%',
|
|
136
|
+
'height: 100%',
|
|
137
|
+
'z-index: -1',
|
|
138
|
+
'pointer-events: none',
|
|
139
|
+
].join(';')
|
|
140
|
+
document.documentElement.prepend(canvas)
|
|
141
|
+
|
|
142
|
+
const ctx = canvas.getContext('2d')!
|
|
143
|
+
const DOT_SPACING = 28
|
|
144
|
+
const DOT_RADIUS = 1.1
|
|
145
|
+
const GLOW_RADIUS = 130
|
|
146
|
+
|
|
147
|
+
interface Dot {
|
|
148
|
+
ox: number
|
|
149
|
+
oy: number
|
|
150
|
+
x: number
|
|
151
|
+
y: number
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let dots: Dot[] = []
|
|
155
|
+
const mouse = { x: -9999, y: -9999 }
|
|
156
|
+
let isAnimating = false
|
|
157
|
+
let isPressed = false
|
|
158
|
+
/**
|
|
159
|
+
* The loop below keeps requesting frames while the pointer is anywhere on
|
|
160
|
+
* the page, which on a hidden tab is pure waste — and this console is the
|
|
161
|
+
* kind of page that lives in a background tab for hours. Ported from the
|
|
162
|
+
* example app's copy of this effect, which had the throttle where this one
|
|
163
|
+
* did not; the two files are separate copies on purpose (a plugin's client
|
|
164
|
+
* bundle and an app's script cannot share a module), so a fix to one is
|
|
165
|
+
* worth checking against the other.
|
|
166
|
+
*/
|
|
167
|
+
let isTabVisible = !document.hidden
|
|
168
|
+
|
|
169
|
+
function initDots() {
|
|
170
|
+
dots = []
|
|
171
|
+
const cols = Math.ceil(canvas.width / DOT_SPACING) + 1
|
|
172
|
+
const rows = Math.ceil(canvas.height / DOT_SPACING) + 1
|
|
173
|
+
|
|
174
|
+
for (let r = 0; r < rows; r++) {
|
|
175
|
+
for (let c = 0; c < cols; c++) {
|
|
176
|
+
const ox = c * DOT_SPACING
|
|
177
|
+
const oy = r * DOT_SPACING
|
|
178
|
+
dots.push({ ox, oy, x: ox, y: oy })
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function resize() {
|
|
184
|
+
canvas.width = window.innerWidth
|
|
185
|
+
canvas.height = window.innerHeight
|
|
186
|
+
initDots()
|
|
187
|
+
if (!window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
|
|
188
|
+
draw()
|
|
189
|
+
} else {
|
|
190
|
+
if (!isAnimating) {
|
|
191
|
+
isAnimating = true
|
|
192
|
+
requestAnimationFrame(draw)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
interface DotTarget {
|
|
198
|
+
x: number
|
|
199
|
+
y: number
|
|
200
|
+
alpha: number
|
|
201
|
+
radius: number
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function computeDotTarget(dot: Dot): DotTarget {
|
|
205
|
+
const dx = mouse.x - dot.ox
|
|
206
|
+
const dy = mouse.y - dot.oy
|
|
207
|
+
const dist = Math.sqrt(dx * dx + dy * dy)
|
|
208
|
+
|
|
209
|
+
let targetX = dot.ox
|
|
210
|
+
let targetY = dot.oy
|
|
211
|
+
let alpha = 0.07
|
|
212
|
+
let radius = DOT_RADIUS
|
|
213
|
+
|
|
214
|
+
if (dist < GLOW_RADIUS) {
|
|
215
|
+
const t = 1 - dist / GLOW_RADIUS
|
|
216
|
+
alpha = 0.07 + t * 0.35
|
|
217
|
+
radius = DOT_RADIUS + t * 0.9
|
|
218
|
+
|
|
219
|
+
if (dist > 0.01) {
|
|
220
|
+
const force = t * (isPressed ? 32 : 18)
|
|
221
|
+
if (isPressed) {
|
|
222
|
+
targetX = dot.ox + (dx / dist) * force
|
|
223
|
+
targetY = dot.oy + (dy / dist) * force
|
|
224
|
+
} else {
|
|
225
|
+
targetX = dot.ox - (dx / dist) * force
|
|
226
|
+
targetY = dot.oy - (dy / dist) * force
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { x: targetX, y: targetY, alpha, radius }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function moveDot(dot: Dot, target: DotTarget): boolean {
|
|
235
|
+
const dx = target.x - dot.x
|
|
236
|
+
const dy = target.y - dot.y
|
|
237
|
+
if (Math.abs(dx) > 0.01 || Math.abs(dy) > 0.01) {
|
|
238
|
+
dot.x += dx * 0.15
|
|
239
|
+
dot.y += dy * 0.15
|
|
240
|
+
return true
|
|
241
|
+
}
|
|
242
|
+
dot.x = target.x
|
|
243
|
+
dot.y = target.y
|
|
244
|
+
return false
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function draw() {
|
|
248
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
|
249
|
+
|
|
250
|
+
let needsMoreFrames = false
|
|
251
|
+
|
|
252
|
+
for (let i = 0; i < dots.length; i++) {
|
|
253
|
+
const dot = dots[i]
|
|
254
|
+
const target = computeDotTarget(dot)
|
|
255
|
+
if (moveDot(dot, target)) needsMoreFrames = true
|
|
256
|
+
|
|
257
|
+
ctx.beginPath()
|
|
258
|
+
ctx.arc(dot.x, dot.y, target.radius, 0, Math.PI * 2)
|
|
259
|
+
ctx.fillStyle = `rgba(255, 255, 255, ${target.alpha})`
|
|
260
|
+
ctx.fill()
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (
|
|
264
|
+
window.matchMedia('(hover: hover) and (pointer: fine)').matches &&
|
|
265
|
+
isTabVisible
|
|
266
|
+
) {
|
|
267
|
+
if (mouse.x !== -9999 || needsMoreFrames) {
|
|
268
|
+
requestAnimationFrame(draw)
|
|
269
|
+
} else {
|
|
270
|
+
isAnimating = false
|
|
271
|
+
}
|
|
272
|
+
} else {
|
|
273
|
+
// Hidden tab: stop the loop and let visibilitychange restart it, or the
|
|
274
|
+
// flag would stay true and the resume below would never fire.
|
|
275
|
+
isAnimating = false
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
|
|
280
|
+
window.addEventListener('pointermove', e => {
|
|
281
|
+
mouse.x = e.clientX
|
|
282
|
+
mouse.y = e.clientY
|
|
283
|
+
if (!isAnimating) {
|
|
284
|
+
isAnimating = true
|
|
285
|
+
requestAnimationFrame(draw)
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
window.addEventListener('mouseleave', () => {
|
|
290
|
+
mouse.x = -9999
|
|
291
|
+
mouse.y = -9999
|
|
292
|
+
isPressed = false
|
|
293
|
+
if (!isAnimating) {
|
|
294
|
+
isAnimating = true
|
|
295
|
+
requestAnimationFrame(draw)
|
|
296
|
+
}
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
window.addEventListener('pointerdown', () => {
|
|
300
|
+
isPressed = true
|
|
301
|
+
if (!isAnimating) {
|
|
302
|
+
isAnimating = true
|
|
303
|
+
requestAnimationFrame(draw)
|
|
304
|
+
}
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
window.addEventListener('pointerup', () => {
|
|
308
|
+
isPressed = false
|
|
309
|
+
if (!isAnimating) {
|
|
310
|
+
isAnimating = true
|
|
311
|
+
requestAnimationFrame(draw)
|
|
312
|
+
}
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
document.addEventListener('visibilitychange', () => {
|
|
317
|
+
isTabVisible = !document.hidden
|
|
318
|
+
if (isTabVisible && !isAnimating) {
|
|
319
|
+
isAnimating = true
|
|
320
|
+
requestAnimationFrame(draw)
|
|
321
|
+
}
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
window.addEventListener('resize', resize)
|
|
325
|
+
resize()
|
|
326
|
+
draw()
|
|
327
|
+
})()
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { colorizeHtml, getWebSocketUrl } from './utils'
|
|
2
|
+
|
|
3
|
+
export let logsWs: WebSocket | null = null
|
|
4
|
+
export let logsPaused = false
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The console keeps the most recent rows only. It previously appended
|
|
8
|
+
* without ever removing, so a long-lived dashboard tab on a chatty server
|
|
9
|
+
* grew the DOM without bound — and every append also triggered a shimmer
|
|
10
|
+
* cache refresh, so the per-line cost climbed with the backlog.
|
|
11
|
+
*/
|
|
12
|
+
const MAX_LOG_ROWS = 500
|
|
13
|
+
|
|
14
|
+
function appendLogRow(cEl: HTMLElement, row: HTMLElement) {
|
|
15
|
+
cEl.appendChild(row)
|
|
16
|
+
while (cEl.childElementCount > MAX_LOG_ROWS) {
|
|
17
|
+
cEl.removeChild(cEl.firstElementChild!)
|
|
18
|
+
}
|
|
19
|
+
scrollConsoleToBottom(cEl)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function scrollConsoleToBottom(cEl: HTMLElement) {
|
|
23
|
+
const scrollCheck = document.getElementById(
|
|
24
|
+
'logs-autoscroll',
|
|
25
|
+
) as HTMLInputElement | null
|
|
26
|
+
if (scrollCheck?.checked) {
|
|
27
|
+
cEl.scrollTop = cEl.scrollHeight
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getLogLevelColor(level: string): string {
|
|
32
|
+
if (level === 'WARN') return '#f59e0b'
|
|
33
|
+
if (level === 'ERROR' || level === 'FATAL') return '#ef4444'
|
|
34
|
+
if (level === 'DEBUG') return '#a855f7'
|
|
35
|
+
return '#34d399'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function renderLogEntry(cEl: HTMLElement, parsed: any) {
|
|
39
|
+
const timestamp = new Date(
|
|
40
|
+
parsed.timestamp || Date.now(),
|
|
41
|
+
).toLocaleTimeString()
|
|
42
|
+
const level = (parsed.level || 'info').toUpperCase()
|
|
43
|
+
const by = parsed.by || 'global'
|
|
44
|
+
const payload = parsed.payload || ''
|
|
45
|
+
|
|
46
|
+
const levelColor = getLogLevelColor(level)
|
|
47
|
+
|
|
48
|
+
const logRow = document.createElement('div')
|
|
49
|
+
logRow.style.padding = '0.15rem 0'
|
|
50
|
+
logRow.style.borderBottom = '1px solid rgba(255, 255, 255, 0.02)'
|
|
51
|
+
|
|
52
|
+
// `level` and `by` arrive over the websocket. LiveReloadHandler rebroadcasts
|
|
53
|
+
// client_log frames from any connected client, so both are untrusted —
|
|
54
|
+
// `payload` was already escaped by colorizeHtml, these were not.
|
|
55
|
+
logRow.innerHTML = `
|
|
56
|
+
<span style="color: var(--text-secondary); margin-right: 0.5rem;">[${escapeHTML(String(timestamp))}]</span>
|
|
57
|
+
<span style="color: ${levelColor}; font-weight: bold; margin-right: 0.5rem;">[${escapeHTML(String(level))}]</span>
|
|
58
|
+
<span style="color: #60a5fa; font-weight: 500; margin-right: 0.5rem;">${escapeHTML(String(by))}:</span>
|
|
59
|
+
<span style="color: #f1f5f9; white-space: pre-wrap;">${colorizeHtml(payload)}</span>
|
|
60
|
+
`
|
|
61
|
+
|
|
62
|
+
appendLogRow(cEl, logRow)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function renderRawLogEntry(cEl: HTMLElement, rawData: string) {
|
|
66
|
+
const logRow = document.createElement('div')
|
|
67
|
+
logRow.style.color = '#cbd5e1'
|
|
68
|
+
logRow.innerText = rawData
|
|
69
|
+
appendLogRow(cEl, logRow)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function initLogsWebSocket() {
|
|
73
|
+
if (logsWs && logsWs.readyState === WebSocket.OPEN) return
|
|
74
|
+
|
|
75
|
+
const consoleEl = document.getElementById('logs-console')
|
|
76
|
+
if (!consoleEl) return
|
|
77
|
+
consoleEl.innerHTML =
|
|
78
|
+
'<div style="color: var(--text-secondary);">Connecting to server log stream...</div>'
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
logsWs = new WebSocket(getWebSocketUrl('/_livereload'))
|
|
82
|
+
|
|
83
|
+
logsWs.onopen = () => {
|
|
84
|
+
consoleEl.innerHTML =
|
|
85
|
+
'<div style="color: var(--accent-green); display: flex; align-items: center; gap: 0.25rem;"><iconify-icon icon="lucide:check-circle-2" style="font-size: 1.1rem;"></iconify-icon><span>Connected to logs pipeline. Listening for events...</span></div>'
|
|
86
|
+
logsWs?.send(JSON.stringify({ type: 'subscribe_logger' }))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
logsWs.onmessage = event => {
|
|
90
|
+
if (logsPaused) return
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(event.data)
|
|
94
|
+
if (parsed.type === 'server_log' || parsed.type === 'client_log') {
|
|
95
|
+
renderLogEntry(consoleEl, parsed)
|
|
96
|
+
}
|
|
97
|
+
} catch (_e) {
|
|
98
|
+
renderRawLogEntry(consoleEl, event.data)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
logsWs.onclose = () => {
|
|
103
|
+
const logRow = document.createElement('div')
|
|
104
|
+
logRow.style.color = '#f59e0b'
|
|
105
|
+
logRow.innerHTML =
|
|
106
|
+
'<span style="display: flex; align-items: center; gap: 0.25rem;"><iconify-icon icon="lucide:alert-triangle" style="font-size: 1rem;"></iconify-icon><span>Logs pipeline disconnected. Reconnecting in 3s...</span></span>'
|
|
107
|
+
consoleEl.appendChild(logRow)
|
|
108
|
+
setTimeout(initLogsWebSocket, 3000)
|
|
109
|
+
}
|
|
110
|
+
} catch (_err) {
|
|
111
|
+
consoleEl.innerHTML =
|
|
112
|
+
'<div style="color: var(--accent-red);">Failed to establish log stream connection.</div>'
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function toggleLogsPlay() {
|
|
117
|
+
logsPaused = !logsPaused
|
|
118
|
+
const btn = document.getElementById('btn-logs-play')
|
|
119
|
+
if (btn) {
|
|
120
|
+
btn.innerHTML = logsPaused
|
|
121
|
+
? '<iconify-icon icon="lucide:play" style="font-size: 1.1rem;"></iconify-icon><span>Resume</span>'
|
|
122
|
+
: '<iconify-icon icon="lucide:pause" style="font-size: 1.1rem;"></iconify-icon><span>Pause</span>'
|
|
123
|
+
btn.classList.toggle('btn-success', logsPaused)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function clearLogs() {
|
|
128
|
+
const consoleEl = document.getElementById('logs-console')
|
|
129
|
+
if (consoleEl)
|
|
130
|
+
consoleEl.innerHTML =
|
|
131
|
+
'<div style="color: var(--text-secondary);">Console cleared.</div>'
|
|
132
|
+
}
|