Replace inline JS with sigil-web-ui fragment streaming
Migrate Vigil from JSON SSE + client-side JS rendering to sigil-web-ui's HTML fragment streaming pattern. The server now sends pre-rendered HTML fragments via sse-morph events, and the client uses sigil-web-ui's thin JS library for DOM updates.
Key changes: - views.sgl: Remove ~110 lines of inline JavaScript, extract reusable component renderers (service card, status badge, log line, etc.), add CSS-based log filtering, scrollable check history, data-sg-* declarative attributes for SSE auto-connect and auto-scroll - web.sgl: Switch SSE events from JSON to sse-morph with pre-rendered HTML, add sigil-web-ui JS route, add log filter endpoint returning SSE batch responses, extract shared entry-morph-events helper - logs.sgl: Broadcast raw log entries instead of pre-formatted SSE; the SSE handler transforms them into sse-morph events - main.sgl: Pass state to notify-status-change for entry lookup
src/vigil/logs.sgl | 10 +++---
src/vigil/main.sgl | 2 +-
src/vigil/views.sgl | 319 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------------------------------------------------------------------------------
src/vigil/web.sgl | 119 ++++++++++++++++++++++++++++++++++++++++++++++------------------
4 files changed, 224 insertions(+), 226 deletions(-)src/vigil/logs.sglmodified
;;;;;; Reads structured log output (JSON lines or plain text) from;;; configured file sources using tail -f. Stores entries in a;;; per-service ring buffer and broadcasts new entries via SSE.;;; per-service ring buffer and broadcasts new entries.;;; The SSE handler in (vigil web) transforms entries into;;; sse-morph events with pre-rendered HTML fragments.(define-library (vigil logs) (import (sigil core) (sigil async) (sigil time) (sigil channels) (sigil http response) (vigil state)) (export ;; Log entry (bc (svc-broadcast svc))) (set-car! box new-entries) (set-car! cnt-box new-count) (broadcast-send bc (sse-event "log" (json-encode (log-entry->dict entry)))))))) ;; Broadcast the raw entry; the SSE handler transforms it (broadcast-send bc entry))))) ;; ============================================================ ;; File Tailersrc/vigil/main.sglmodified
(current-error-port)) (go (check-loop svc st interval (lambda (name result old-status) (notify-status-change bc name result) (notify-status-change bc st name result) (alerter-send! alerter name result old-status))))) services) ;; Start log tailerssrc/vigil/views.sglmodified
;;;;;; Server-rendered SXML page templates for Vigil's web interface.;;; Uses sigil-web-styles for all styling via theme-stylesheet.;;; Uses sigil-web-ui for live updates via SSE fragment streaming.;;;;;; Component renderers (render-service-card, render-log-line, etc.);;; are reused for both initial page render AND SSE live updates.;;;;;; Note: Uses explicit list construction instead of unquote-splicing;;; due to compiled module limitation (see sigil-compiled-module-gotchas). (sigil sxml) (sigil time) (sigil web styles) (sigil web ui) (vigil state) (vigil logs)) (export render-dashboard-page render-service-page render-not-found-page vigil-css) vigil-css ;; Component renderers for SSE reuse render-service-card render-log-line render-history-row render-status-badge render-status-details render-filter-toolbar) (begin.log-filter-warn.active { background: var(--color-warning); border-color: var(--color-warning); color: #000; }.log-filter-info.active { background: var(--color-success); border-color: var(--color-success); color: #000; }.log-filter-debug.active { background: var(--color-text-muted); border-color: var(--color-text-muted); color: #000; }.log-scroll-toggle { margin-left: auto; }") (define vigil-css-3 ".check-history-scroll { max-height: 20rem; overflow-y: auto; border: 1px solid var(--color-border); border-radius: 6px; }.check-history-scroll .table { margin: 0; }.check-history-scroll thead { position: sticky; top: 0; background: var(--color-bg, #0d0d1a); z-index: 1; }#log-viewer { overflow: hidden; }.log-container { background: var(--color-bg-inset, #1a1a2e); border: 1px solid var(--color-border); border-radius: 6px; height: 500px; overflow-y: auto; font-family: monospace; font-size: 0.8125rem; line-height: 1.5; }.log-line { padding: 0.125rem 0.75rem; border-bottom: 1px solid var(--color-border); white-space: pre-wrap; word-break: break-all; }.log-time { color: var(--color-text-muted); margin-right: 0.5rem; font-size: 0.75rem; }.log-msg { color: var(--color-text); }.log-empty { padding: 2rem; text-align: center; color: var(--color-text-muted); }") ;; CSS-based log filtering: when a filter class is on the container, ;; hide non-matching log lines via CSS (no JavaScript needed) (define vigil-css-4 ".log-container.filter-error .log-line:not(.log-line-error) { display: none; }.log-container.filter-warn .log-line:not(.log-line-warn) { display: none; }.log-container.filter-info .log-line:not(.log-line-info) { display: none; }.log-container.filter-debug .log-line:not(.log-line-debug) { display: none; }.sg-sse-connector { display: none; }") (define vigil-supplemental-css (string-append vigil-css-1 vigil-css-2 vigil-css-3)) (string-append vigil-css-1 vigil-css-2 vigil-css-3 vigil-css-4)) ;; Generate the full stylesheet from the design system + app-specific styles (define vigil-css ;; Layout ;; ============================================================ (define (render-page title body-sxml) (sxml->html (list 'html (list 'head '(meta (@ (charset "utf-8"))) '(meta (@ (name "viewport") (content "width=device-width, initial-scale=1"))) (list 'title (string-append title " - Vigil")) '(link (@ (rel "stylesheet") (href "/style.css")))) (append (list 'body '(header (@ (class "header")) (a (@ (href "/") (class "header-logo")) "vigil") (nav (@ (class "header-nav")) (a (@ (href "/")) "Dashboard")))) (list (cons 'main (cons '(@ (class "main")) body-sxml)) '(footer (@ (class "footer")) "Crafted with " (a (@ (href "https://usesigil.org")) "Sigil")) (sse-script)))))) (define (sse-script) (list 'script sse-js)) ;; sse-urls: list of SSE endpoint URLs to auto-connect (define (render-page title body-sxml sse-urls) (let ((head-scripts (sigil-web-ui-head))) (sxml->html (list 'html (append (list 'head '(meta (@ (charset "utf-8"))) '(meta (@ (name "viewport") (content "width=device-width, initial-scale=1"))) (list 'title (string-append title " - Vigil")) '(link (@ (rel "stylesheet") (href "/style.css")))) head-scripts) (append (list 'body '(header (@ (class "header")) (a (@ (href "/") (class "header-logo")) "vigil") (nav (@ (class "header-nav")) (a (@ (href "/")) "Dashboard")))) (map (lambda (url) (list 'div (list '@ (list 'class "sg-sse-connector") (list 'data-sg-sse url)))) sse-urls) (list (cons 'main (cons '(@ (class "main")) body-sxml)) '(footer (@ (class "footer")) "Crafted with " (a (@ (href "https://usesigil.org")) "Sigil")))))))) ;; ============================================================ ;; Dashboard Page (list 'h1 '(@ (class "page-title")) "Services") (cons 'div (cons '(@ (class "service-grid")) (map render-service-card entries))))))) (map render-service-card entries))))) (list "/sse/status"))) (define (render-service-card entry) (let* ((name (service-entry-name entry)) (define (render-service-page entry log-entries) (let* ((name (service-entry-name entry)) (status (service-entry-status entry)) (status-str (symbol->string status)) (latency (service-entry-last-latency entry)) (uptime (service-entry-uptime entry)) (history (service-entry-history entry)) (last-change (service-entry-last-change entry))) (history (service-entry-history entry))) (render-page name (list (list 'h1 '(@ (class "page-title")) name) (list 'div '(@ (class "status-panel")) (list 'div (list '@ (list 'class (string-append "status-badge status-" status-str))) status-str) (list 'div '(@ (class "status-details")) (list 'div (string-append "Uptime: " (format-uptime uptime))) (list 'div (string-append "Latency: " (format-latency latency))) (list 'div (string-append "Last change: " (if (> last-change 0) (format-datetime last-change) "never"))))) (render-status-badge entry) (render-status-details entry)) (list 'div '(@ (class "check-history")) (list 'h2 "Recent Checks") (if (null? history) (list 'p "No checks recorded yet.") (cons 'table (cons '(@ (class "table")) (cons '(thead (tr (th "Status") (th "Latency") (th "Error") (th "Time"))) (list (cons 'tbody (map render-history-row (take-up-to history 20))))))))) (render-log-viewer name log-entries))))) (list 'div '(@ (class "check-history-scroll")) (cons 'table (cons '(@ (class "table")) (cons '(thead (tr (th "Status") (th "Latency") (th "Error") (th "Time"))) (list (cons 'tbody (cons (list '@ (list 'id (string-append "check-history-body-" name))) (map render-history-row (take-up-to history 20))))))))))) (render-log-viewer name log-entries)) (list "/sse/status" (string-append "/sse/service/" name "/logs"))))) ;; ============================================================ ;; Status Components (reused for SSE updates) ;; ============================================================ (define (render-status-badge entry) (let* ((name (service-entry-name entry)) (status-str (symbol->string (service-entry-status entry)))) (list 'div (list '@ (list 'id (string-append "status-badge-" name)) (list 'class (string-append "status-badge status-" status-str))) status-str))) (define (render-status-details entry) (let* ((name (service-entry-name entry)) (latency (service-entry-last-latency entry)) (uptime (service-entry-uptime entry)) (last-change (service-entry-last-change entry))) (list 'div (list '@ (list 'id (string-append "status-details-" name)) (list 'class "status-details")) (list 'div (string-append "Uptime: " (format-uptime uptime))) (list 'div (string-append "Latency: " (format-latency latency))) (list 'div (string-append "Last change: " (if (> last-change 0) (format-datetime last-change) "never")))))) ;; ============================================================ ;; Log Viewer (define (render-log-viewer name log-entries) (list 'div (list '@ (list 'class "log-viewer") (list 'id "log-viewer") (list 'data-service name)) (list 'id "log-viewer")) (list 'h2 "Logs") (list 'div '(@ (class "log-toolbar")) (list 'button '(@ (class "log-filter active") (data-level "all") (onclick "filterLogs('all')")) "All") (list 'button '(@ (class "log-filter log-filter-error") (data-level "error") (onclick "filterLogs('error')")) "Error") (list 'button '(@ (class "log-filter log-filter-warn") (data-level "warn") (onclick "filterLogs('warn')")) "Warn") (list 'button '(@ (class "log-filter log-filter-info") (data-level "info") (onclick "filterLogs('info')")) "Info") (list 'button '(@ (class "log-filter log-filter-debug") (data-level "debug") (onclick "filterLogs('debug')")) "Debug") (list 'button '(@ (class "log-filter log-scroll-toggle active") (id "scroll-toggle") (onclick "toggleAutoScroll()")) "Auto-scroll")) (list 'div '(@ (class "log-container") (id "log-container")) (render-filter-toolbar name "all") (list 'div (list '@ (list 'class "log-container") (list 'id "log-container") (list 'data-sg-auto-scroll "bottom")) (if (null? log-entries) (list 'div '(@ (class "log-empty") (id "log-empty")) "Waiting for log entries...") (cons '(@ (id "log-lines")) (map render-log-line log-entries))))))) ;; Render the filter toolbar with the given active level (define (render-filter-toolbar name active-level) (list 'div (list '@ (list 'class "log-toolbar") (list 'id "log-toolbar")) (render-filter-button name "all" active-level "" "All") (render-filter-button name "error" active-level "log-filter-error" "Error") (render-filter-button name "warn" active-level "log-filter-warn" "Warn") (render-filter-button name "info" active-level "log-filter-info" "Info") (render-filter-button name "debug" active-level "log-filter-debug" "Debug"))) (define (render-filter-button name level active-level extra-class label) (let ((is-active (string=? level active-level)) (base-class (if (string=? extra-class "") "log-filter" (string-append "log-filter " extra-class)))) (list 'button (list '@ (list 'class (if is-active (string-append base-class " active") base-class)) (list 'data-sg-action (string-append "/service/" name "/filter/" level)) (list 'data-sg-method "get")) label))) (define (render-log-line entry) (let ((level (log-entry-level entry)) (msg (log-entry-message entry)) (list (list 'h1 '(@ (class "page-title")) "Service Not Found") (list 'p (string-append "No service named \"" name "\" found.")) (list 'p (list 'a '(@ (href "/")) "Back to dashboard"))))) (list 'p (list 'a '(@ (href "/")) "Back to dashboard"))) '())) ;; ============================================================ ;; Helpers (string-append (number->string ms) "ms") "---")) ;; ============================================================ ;; SSE JavaScript ;; ============================================================ (define sse-js-1 "var es = new EventSource('/sse/status');es.addEventListener('status', function(e) { var d = JSON.parse(e.data); var card = document.getElementById('svc-' + d.name); if (card) { var a = {up:'success',down:'error',degraded:'warning'}; card.className = 'card card-accent accent-' + (a[d.status]||'primary'); var sts = card.querySelector('.service-status'); if (sts) sts.lastChild.textContent = ' ' + d.status; var lat = card.querySelector('.service-latency'); if (lat) lat.textContent = d.latency > 0 ? d.latency + 'ms' : '---'; } var badge = document.querySelector('.status-badge'); var panel = document.querySelector('.status-details'); if (badge && panel) { var svcName = document.querySelector('.page-title'); if (svcName && svcName.textContent === d.name) { badge.className = 'status-badge status-' + d.status; badge.textContent = d.status; var details = panel.querySelectorAll('div'); if (details[1]) details[1].textContent = 'Latency: ' + (d.latency > 0 ? d.latency + 'ms' : '---'); var tbody = document.querySelector('.check-history tbody'); if (tbody) { var tr = document.createElement('tr'); var ts = d.timestamp; var timeStr = '---'; if (ts > 0) { var date = new Date(ts * 1000); timeStr = date.getFullYear() + '-' + ('0'+(date.getMonth()+1)).slice(-2) + '-' + ('0'+date.getDate()).slice(-2) + ' ' + ('0'+date.getHours()).slice(-2) + ':' + ('0'+date.getMinutes()).slice(-2) + ':' + ('0'+date.getSeconds()).slice(-2); } tr.innerHTML = '<td><span class=\"status-dot dot-' + d.status + '\"></span> ' + d.status + '</td>' + '<td>' + (d.latency > 0 ? d.latency + 'ms' : '---') + '</td>' + '<td>' + (d.error || '') + '</td>' + '<td>' + timeStr + '</td>'; tbody.insertBefore(tr, tbody.firstChild); if (tbody.children.length > 20) tbody.removeChild(tbody.lastChild); } } }});es.onerror = function(e) { console.error('SSE status error:', e); };") (define sse-js-2 "var logViewer = document.getElementById('log-viewer');if (logViewer) { var svcName = logViewer.getAttribute('data-service'); var logEs = new EventSource('/sse/service/' + svcName + '/logs'); var autoScroll = true; var activeFilter = 'all'; var container = document.getElementById('log-container');") (define sse-js-3 " logEs.addEventListener('log', function(e) { var d = JSON.parse(e.data); var empty = document.getElementById('log-empty'); if (empty) empty.remove(); var lines = document.getElementById('log-lines'); if (!lines) { lines = document.createElement('div'); lines.id = 'log-lines'; container.appendChild(lines); } var el = document.createElement('div'); el.className = 'log-line log-line-' + d.level; el.setAttribute('data-level', d.level);") (define sse-js-4 " var ts = d.timestamp; var timeStr = ''; if (typeof ts === 'string' && ts.length >= 19) { timeStr = ts.substring(11, 19); } else if (typeof ts === 'number' && ts > 0) { var date = new Date(ts * 1000); timeStr = ('0'+date.getHours()).slice(-2)+':'+('0'+date.getMinutes()).slice(-2)+':'+('0'+date.getSeconds()).slice(-2); } el.innerHTML = '<span class=\"log-time\">' + timeStr + '</span>' + '<span class=\"log-level log-level-' + d.level + '\">' + d.level + '</span>' + '<span class=\"log-msg\">' + escapeHtml(d.message) + '</span>'; if (activeFilter !== 'all' && d.level !== activeFilter) el.style.display = 'none'; lines.appendChild(el); if (autoScroll) container.scrollTop = container.scrollHeight; });") (define sse-js-5 " logEs.onerror = function(e) { console.error('SSE log error:', e); };}function escapeHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML;}function filterLogs(level) { activeFilter = level; var btns = document.querySelectorAll('.log-filter:not(.log-scroll-toggle)'); btns.forEach(function(b) { b.classList.toggle('active', b.getAttribute('data-level') === level); }); var lines = document.querySelectorAll('.log-line'); lines.forEach(function(l) { l.style.display = (level === 'all' || l.getAttribute('data-level') === level) ? '' : 'none'; });}function toggleAutoScroll() { autoScroll = !autoScroll; document.getElementById('scroll-toggle').classList.toggle('active', autoScroll); if (autoScroll) { var c = document.getElementById('log-container'); c.scrollTop = c.scrollHeight; }}") (define sse-js (string-append sse-js-1 sse-js-2 sse-js-3 sse-js-4 sse-js-5)) ))src/vigil/web.sglmodified
;;;;;; Provides the HTTP interface for the monitoring dashboard,;;; including HTML pages, JSON API, and SSE live updates.;;;;;; SSE endpoints send sigil-web-ui fragment events (sse-morph);;; with pre-rendered HTML instead of JSON. The same template;;; functions from (vigil views) are used for initial page render;;; and live SSE updates.(define-library (vigil web) (import (sigil core) (sigil string) (sigil math) (sigil json) (sigil sxml) (sigil channels) (sigil time) (sigil io) (sigil http server) (sigil web routes) (sigil web middleware) (sigil web ui) (vigil config) (vigil state) (vigil logs) (define (make-status-broadcast) (make-broadcast)) (define (notify-status-change bc name result) ;; Send an SSE event to all connected dashboard clients (broadcast-send bc (sse-event "status" (json-encode (dict name: name status: (symbol->string (check-record-status result)) latency: (check-record-latency result) error: (or (check-record-error result) "") timestamp: (check-record-timestamp result)))))) ;; Build sse-morph events for a service entry's live-updating components. ;; Targets both dashboard cards and detail page elements; ;; the client silently ignores targets that don't exist on the page. (define (entry-morph-events entry) (let ((name (service-entry-name entry))) (string-append (sse-morph target: (string-append "#svc-" name) mode: "replace" content: (render-service-card entry)) (sse-morph target: (string-append "#status-badge-" name) mode: "replace" content: (render-status-badge entry)) (sse-morph target: (string-append "#status-details-" name) mode: "replace" content: (render-status-details entry))))) (define (notify-status-change bc st name result) (let ((entry (state-get-entry st name))) (when entry (broadcast-send bc (string-append (entry-morph-events entry) (sse-morph target: (string-append "#check-history-body-" name) mode: "prepend" content: (render-history-row result))))))) ;; ============================================================ ;; Application Setup (route method: GET pattern: "/style.css" handler: css-handler) ;; Sigil Web UI client library (route method: GET pattern: "/js/sigil-web-ui.js" handler: (sigil-web-ui-handler)) ;; Log filter (returns SSE batch to update filter state) (route method: GET pattern: "/service/:name/filter/:level" handler: (filter-handler)) ;; SSE endpoints (route method: GET pattern: "/sse/status" handler: (sse-status-handler st bc)) body: vigil-css))) ;; ============================================================ ;; SSE Handler ;; Log Filter Handler ;; ============================================================ ;; Returns an SSE batch response that: ;; 1. Re-renders the filter toolbar with correct active state ;; 2. Adds/removes CSS filter class on the log container (define (filter-handler) (lambda (req) (let* ((name (path-param req "name")) (level (path-param req "level")) (remove-classes "filter-error filter-warn filter-info filter-debug") (add-class (if (string=? level "all") #f (string-append "filter-" level)))) (sse-response-batch (sse-morph target: "#log-toolbar" mode: "replace" content: (render-filter-toolbar name level)) (sse-class target: "#log-container" remove: remove-classes add: add-class))))) ;; ============================================================ ;; SSE Status Handler ;; ============================================================ (define (sse-status-handler st bc) (lambda (req) (http-response/sse-broadcast bc (lambda (msg) msg) ;; messages are pre-formatted SSE strings (lambda (msg) msg) on-connect: (lambda () ;; Send current state of all services on connect (let ((entries (state-all-entries st))) (if (null? entries) #f (let ((port (open-output-string))) (for-each (lambda (entry) (display (sse-event "status" (json-encode (service-entry->dict entry))) port)) (display (entry-morph-events entry) port)) entries) (get-output-string port)))))))) (let* ((name (path-param req "name")) (bc (and log-st (log-state-get-broadcast log-st name)))) (if bc (http-response/sse-broadcast bc (lambda (msg) msg) on-connect: (lambda () ;; Send buffered log entries on connect (let ((entries (log-state-get-entries log-st name))) (if (null? entries) #f (let ((port (open-output-string))) (for-each (lambda (entry) (display (sse-event "log" (json-encode (log-entry->dict entry))) port)) entries) (get-output-string port)))))) (let ((first-entry? (list #t))) (http-response/sse-broadcast bc (lambda (entry) (let ((morph (sse-morph target: "#log-lines" mode: "append" content: (render-log-line entry)))) (if (car first-entry?) (begin (set-car! first-entry? #f) (string-append (sse-remove target: "#log-empty") morph)) morph))) on-connect: (lambda () (let ((entries (log-state-get-entries log-st name))) (if (null? entries) #f (begin (set-car! first-entry? #f) (sse-morph target: "#log-lines" mode: "inner" content: (map render-log-line entries)))))))) (http-response/json 404 (json-encode (dict error: "no log source configured")))))))