diff --git a/.gitmodules b/.gitmodules index b869b07..2086bef 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "bible"] path = bible url = https://git.mchus.pro/mchus/bible.git +[submodule "internal/chart"] + path = internal/chart + url = https://git.mchus.pro/reanimator/chart.git diff --git a/bible-local/02-architecture.md b/bible-local/02-architecture.md index 0e56d67..269b0a3 100644 --- a/bible-local/02-architecture.md +++ b/bible-local/02-architecture.md @@ -12,6 +12,9 @@ Default port: `8082` +Audit result rendering is delegated to embedded `reanimator/chart`, vendored as git submodule `internal/chart`. +LOGPile remains responsible for upload, collection, parsing, normalization, and Reanimator export generation. + ## Code map ```text @@ -21,6 +24,7 @@ internal/collector/ live collection and Redfish replay internal/analyzer/ shared analysis helpers internal/parser/ archive extraction and parser dispatch internal/exporter/ CSV and Reanimator conversion +internal/chart/ vendored `reanimator/chart` viewer submodule internal/models/ stable data contracts web/ embedded UI assets ``` diff --git a/bible-local/03-api.md b/bible-local/03-api.md index 91df00e..4a6368d 100644 --- a/bible-local/03-api.md +++ b/bible-local/03-api.md @@ -6,6 +6,7 @@ - JSON responses are used unless the endpoint downloads a file - Async jobs share the same status model: `queued`, `running`, `success`, `failed`, `canceled` - Export filenames use `YYYY-MM-DD (MODEL) - SERIAL.` when board metadata exists +- Embedded chart viewer routes live under `/chart/` and return HTML/CSS, not JSON ## Input endpoints @@ -158,6 +159,17 @@ Returns registered parser metadata. Returns supported file extensions for upload and batch convert. +## Viewer endpoints + +### `GET /chart/current` + +Renders the current in-memory dataset as Reanimator HTML using embedded `reanimator/chart`. +The server first converts the current result to Reanimator JSON, then passes that snapshot to the viewer. + +### `GET /chart/static/...` + +Serves embedded `reanimator/chart` static assets. + ## Export endpoints ### `GET /api/export/csv` diff --git a/bible-local/10-decisions.md b/bible-local/10-decisions.md index 8a4e264..2085563 100644 --- a/bible-local/10-decisions.md +++ b/bible-local/10-decisions.md @@ -581,3 +581,26 @@ matter for LOGPile documentation: ingest-side serial fallback rules, canonical P coordinate. - LOGPile event export remains strictly source-derived; internal warnings such as LOGPile analysis notes do not leak into Reanimator `event_logs`. + +--- + +## ADL-030 — Audit result rendering is delegated to embedded reanimator/chart + +**Date:** 2026-03-16 +**Context:** +LOGPile already owns file upload, Redfish collection, archive parsing, normalization, and +Reanimator export. Maintaining a second host-side audit renderer for the same data created +presentation drift and duplicated UI logic. + +**Decision:** +- Use vendored `reanimator/chart` as the only audit result viewer. +- Keep LOGPile responsible for service flows: upload, live collection, batch convert, raw export, + Reanimator export, and parse-error reporting. +- Render the current dataset by converting it to Reanimator JSON and passing that snapshot to + embedded `chart` under `/chart/current`. + +**Consequences:** +- Reanimator JSON becomes the single presentation contract for the audit surface. +- The host UI becomes a service shell around the viewer instead of maintaining its own + field-by-field tabs. +- `internal/chart` must be updated explicitly as a git submodule when the viewer changes. diff --git a/go.mod b/go.mod index a72b444..5ea908c 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,7 @@ module git.mchus.pro/mchus/logpile -go 1.22 +go 1.24.0 + +require reanimator/chart v0.0.0 + +replace reanimator/chart => ./internal/chart diff --git a/internal/chart b/internal/chart new file mode 160000 index 0000000..a71f55a --- /dev/null +++ b/internal/chart @@ -0,0 +1 @@ +Subproject commit a71f55a6f927c72fc9ab3ad55785bb2475aa5759 diff --git a/internal/server/chart_view_test.go b/internal/server/chart_view_test.go new file mode 100644 index 0000000..0d89e3d --- /dev/null +++ b/internal/server/chart_view_test.go @@ -0,0 +1,69 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.mchus.pro/mchus/logpile/internal/models" +) + +func TestHandleChartCurrent_RendersCurrentReanimatorSnapshot(t *testing.T) { + s := New(Config{}) + s.SetResult(&models.AnalysisResult{ + SourceType: models.SourceTypeArchive, + Filename: "example.zip", + CollectedAt: time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC), + Hardware: &models.HardwareConfig{ + BoardInfo: models.BoardInfo{ + ProductName: "SYS-TEST", + SerialNumber: "SN123", + }, + CPUs: []models.CPU{ + { + Socket: 1, + Model: "Xeon Gold", + Cores: 32, + }, + }, + }, + }) + + req := httptest.NewRequest(http.MethodGet, "/chart/current", nil) + rec := httptest.NewRecorder() + + s.mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "SYS-TEST - SN123") { + t.Fatalf("expected chart title in body, got %q", body) + } + if !strings.Contains(body, `/chart/static/view.css`) { + t.Fatalf("expected rewritten chart static path, got %q", body) + } + if !strings.Contains(body, "Snapshot Metadata") { + t.Fatalf("expected rendered chart output, got %q", body) + } +} + +func TestHandleChartCurrent_RendersEmptyViewerWithoutResult(t *testing.T) { + s := New(Config{}) + + req := httptest.NewRequest(http.MethodGet, "/chart/current", nil) + rec := httptest.NewRecorder() + + s.mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "Snapshot Viewer") { + t.Fatalf("expected empty chart viewer, got %q", body) + } +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 8fe7842..52c5201 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -23,6 +23,7 @@ import ( "git.mchus.pro/mchus/logpile/internal/exporter" "git.mchus.pro/mchus/logpile/internal/models" "git.mchus.pro/mchus/logpile/internal/parser" + chartviewer "reanimator/chart/viewer" ) func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { @@ -47,6 +48,82 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { tmpl.Execute(w, nil) } +func (s *Server) handleChartCurrent(w http.ResponseWriter, r *http.Request) { + result := s.GetResult() + title := chartTitle(result) + if result == nil || result.Hardware == nil { + html, err := chartviewer.RenderHTML(nil, title) + if err != nil { + http.Error(w, "failed to render viewer", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(rewriteChartStaticPaths(html)) + return + } + + snapshotBytes, err := currentReanimatorSnapshotBytes(result) + if err != nil { + http.Error(w, "failed to build reanimator snapshot: "+err.Error(), http.StatusInternalServerError) + return + } + + html, err := chartviewer.RenderHTML(snapshotBytes, title) + if err != nil { + http.Error(w, "failed to render chart: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(rewriteChartStaticPaths(html)) +} + +func currentReanimatorSnapshotBytes(result *models.AnalysisResult) ([]byte, error) { + reanimatorData, err := exporter.ConvertToReanimator(result) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + encoder := json.NewEncoder(&buf) + encoder.SetIndent("", " ") + if err := encoder.Encode(reanimatorData); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func chartTitle(result *models.AnalysisResult) string { + const fallback = "LOGPile Reanimator Viewer" + if result == nil { + return fallback + } + if result.Hardware != nil { + board := result.Hardware.BoardInfo + product := strings.TrimSpace(board.ProductName) + serial := strings.TrimSpace(board.SerialNumber) + switch { + case product != "" && serial != "": + return product + " - " + serial + case product != "": + return product + case serial != "": + return serial + } + } + if host := strings.TrimSpace(result.TargetHost); host != "" { + return host + } + if filename := strings.TrimSpace(result.Filename); filename != "" { + return filename + } + return fallback +} + +func rewriteChartStaticPaths(html []byte) []byte { + return bytes.ReplaceAll(html, []byte(`href="/static/view.css"`), []byte(`href="/chart/static/view.css"`)) +} + func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, uploadMultipartMaxBytes()) if err := r.ParseMultipartForm(uploadMultipartFormMemoryBytes()); err != nil { diff --git a/internal/server/server.go b/internal/server/server.go index 470d734..55c3194 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -11,6 +11,7 @@ import ( "git.mchus.pro/mchus/logpile/internal/collector" "git.mchus.pro/mchus/logpile/internal/models" + chartviewer "reanimator/chart/viewer" ) // WebFS holds embedded web files (set by main package) @@ -64,9 +65,13 @@ func (s *Server) setupRoutes() { panic(err) } s.mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticContent)))) + s.mux.Handle("/chart/", http.StripPrefix("/chart", chartviewer.NewHandler(chartviewer.HandlerOptions{ + Title: "LOGPile Reanimator Viewer", + }))) // Pages s.mux.HandleFunc("/", s.handleIndex) + s.mux.HandleFunc("GET /chart/current", s.handleChartCurrent) // API endpoints s.mux.HandleFunc("POST /api/upload", s.handleUpload) diff --git a/web/static/css/style.css b/web/static/css/style.css index e8ace17..faa6cf2 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -17,6 +17,18 @@ header { padding: 1rem 2rem; } +.app-header-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem 2rem; + flex-wrap: wrap; +} + +.app-header-brand { + min-width: 0; +} + header h1 { font-size: 1.5rem; font-weight: 600; @@ -33,9 +45,34 @@ header p { opacity: 0.7; } +.header-log-meta { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + justify-content: flex-end; +} + +.header-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + justify-content: flex-end; +} + +.header-actions button { + border: none; + border-radius: 6px; + padding: 0.55rem 0.9rem; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; +} + main { - max-width: 1400px; - margin: 2rem auto; + width: 100%; + max-width: none; + margin: 1rem 0 2rem; padding: 0 1rem; } @@ -109,6 +146,10 @@ main { color: #2c3e50; } +#data-section { + margin: 0 -1rem; +} + .api-form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); @@ -437,18 +478,6 @@ main { } /* File Info */ -.file-info { - background: #fff; - border: 1px solid #e0e0e0; - border-radius: 8px; - padding: 1rem 1.5rem; - margin-bottom: 1.5rem; - display: flex; - gap: 2rem; - flex-wrap: wrap; - align-items: center; -} - .parser-badge, .file-name { display: flex; align-items: center; @@ -476,6 +505,28 @@ main { border-color: #81c784; } +.result-panel { + background: transparent; + border: none; + border-radius: 0; + padding: 0; + margin-bottom: 0; +} + +.audit-viewer-shell { + min-height: 60vh; + margin: 0; +} + +.audit-viewer-frame { + width: 100%; + min-height: 60vh; + border: none; + border-radius: 0; + background: #fff; + display: block; +} + /* Tabs */ .tabs { display: flex; diff --git a/web/static/js/app.js b/web/static/js/app.js index b0b65d9..4f7b473 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -5,8 +5,7 @@ document.addEventListener('DOMContentLoaded', () => { initApiSource(); initUpload(); initConvertMode(); - initTabs(); - initFilters(); + initAuditViewer(); loadParsersInfo(); loadSupportedFileTypes(); }); @@ -27,6 +26,30 @@ let isAutoUpdatingApiPort = false; let apiProbeResult = null; let apiPowerDecisionTimer = null; +function initAuditViewer() { + const frame = document.getElementById('audit-viewer-frame'); + if (!frame) { + return; + } + + frame.addEventListener('load', () => { + resizeAuditViewerFrame(); + try { + const win = frame.contentWindow; + if (win) { + win.setTimeout(resizeAuditViewerFrame, 50); + win.setTimeout(resizeAuditViewerFrame, 250); + } + } catch (err) { + console.error('Failed to schedule viewer resize:', err); + } + }); + + window.addEventListener('resize', () => { + resizeAuditViewerFrame(); + }); +} + function initSourceType() { const sourceButtons = document.querySelectorAll('.source-switch-btn'); sourceButtons.forEach(button => { @@ -1191,6 +1214,7 @@ let allSerials = []; let allParseErrors = []; let currentVendor = ''; +let auditViewerNonce = 0; // Load data from API async function loadData(vendor, filename) { @@ -1198,16 +1222,9 @@ async function loadData(vendor, filename) { document.getElementById('upload-section').classList.add('hidden'); document.getElementById('data-section').classList.remove('hidden'); document.getElementById('clear-btn').classList.remove('hidden'); - - // Update parser name and filename - const parserName = document.getElementById('parser-name'); - const fileNameElem = document.getElementById('file-name'); - if (parserName && currentVendor) { - parserName.textContent = currentVendor; - } - if (fileNameElem && filename) { - fileNameElem.textContent = filename; - } + document.getElementById('header-raw-btn').classList.remove('hidden'); + document.getElementById('header-reanimator-btn').classList.remove('hidden'); + document.getElementById('header-log-meta').classList.remove('hidden'); // Update vendor badge if exists (legacy support) const vendorBadge = document.getElementById('vendor-badge'); @@ -1216,14 +1233,40 @@ async function loadData(vendor, filename) { vendorBadge.classList.remove('hidden'); } - await Promise.all([ - loadConfig(), - loadFirmware(), - loadSensors(), - loadSerials(), - loadEvents(), - loadParseErrors() - ]); + loadAuditViewer(); +} + +function loadAuditViewer() { + const frame = document.getElementById('audit-viewer-frame'); + if (!frame) { + return; + } + auditViewerNonce += 1; + frame.style.height = '60vh'; + frame.src = `/chart/current?ts=${auditViewerNonce}`; +} + +function resizeAuditViewerFrame() { + const frame = document.getElementById('audit-viewer-frame'); + if (!frame) { + return; + } + + try { + const doc = frame.contentDocument || (frame.contentWindow && frame.contentWindow.document); + if (!doc || !doc.documentElement || !doc.body) { + return; + } + + const nextHeight = Math.max( + doc.documentElement.scrollHeight, + doc.body.scrollHeight, + 640 + ); + frame.style.height = `${nextHeight}px`; + } catch (err) { + console.error('Failed to resize audit viewer frame:', err); + } } async function loadConfig() { @@ -1936,11 +1979,19 @@ async function clearData() { document.getElementById('upload-section').classList.remove('hidden'); document.getElementById('data-section').classList.add('hidden'); document.getElementById('clear-btn').classList.add('hidden'); + document.getElementById('header-raw-btn').classList.add('hidden'); + document.getElementById('header-reanimator-btn').classList.add('hidden'); + document.getElementById('header-log-meta').classList.add('hidden'); document.getElementById('upload-status').textContent = ''; allSensors = []; allEvents = []; allSerials = []; allParseErrors = []; + currentVendor = ''; + const frame = document.getElementById('audit-viewer-frame'); + if (frame) { + frame.src = 'about:blank'; + } } catch (err) { console.error('Failed to clear data:', err); } diff --git a/web/templates/index.html b/web/templates/index.html index 171a943..ff4a648 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -8,8 +8,21 @@
-

LOGPile mchus.pro

-

Анализатор диагностических данных BMC/IPMI

+
+
+

LOGPile mchus.pro

+

Анализатор диагностических данных BMC/IPMI

+
+ +
@@ -124,142 +137,23 @@