feat(privacy): surface the customer-data scan above the hardware report
- Move the "Customer data" panel to the top of the data section (above the chart iframe); header + customer guess always visible, findings table collapsed by default and expandable. - Add a chart top-notice (above Board/CPUs) summarizing the scan via the viewer's standard NoticeTitle/NoticeBody - interim until chart custom panels. - Allowlist ieisystem.com (IEI = Inspur brand infrastructure). - Add chart-custom-panels-spec.md: a reusable, versioned contract proposal for host-supplied panels across every app embedding reanimator/chart. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
4a4910f207
commit
fb0b0e3c55
@@ -0,0 +1,246 @@
|
|||||||
|
# ТЗ: Custom Panels for Reanimator Chart
|
||||||
|
|
||||||
|
**Status:** proposal / draft for review
|
||||||
|
**Requesting app:** LOGPile (customer-data / privacy scan panel)
|
||||||
|
**Applies to:** `reanimator/chart` viewer module, all embedding apps
|
||||||
|
**Note:** authored in the LOGPile repo; to be moved into the `reanimator/chart`
|
||||||
|
repo when accepted. Paths below (`docs/`, `bible-local/`) are chart-repo-relative.
|
||||||
|
|
||||||
|
## 1. Problem
|
||||||
|
|
||||||
|
`chart` renders one Reanimator JSON snapshot as a fixed set of hardware sections
|
||||||
|
(`board`, `firmware`, `cpus`, ...). Embedding apps increasingly need to show their
|
||||||
|
own presentational blocks alongside that data - LOGPile wants a "Customer data"
|
||||||
|
panel above `cpus`; other apps have asked for build/QA notes, RMA context,
|
||||||
|
collection warnings, links back to a ticket.
|
||||||
|
|
||||||
|
Today the only lever is `RenderOptions.NoticeTitle` / `NoticeBody` - a single
|
||||||
|
top-of-page title + one paragraph. It cannot carry a table, cannot be positioned,
|
||||||
|
and there is only one of it. Apps work around this by post-processing the chart
|
||||||
|
HTML or forking the templates, which breaks on every `chart` update.
|
||||||
|
|
||||||
|
`chart` is embedded in several apps, so the mechanism must be **standardized and
|
||||||
|
reusable**, not LOGPile-specific.
|
||||||
|
|
||||||
|
## 2. Goals
|
||||||
|
|
||||||
|
- A stable, versioned data contract for host-supplied panels.
|
||||||
|
- Panels can be placed relative to the built-in sections (e.g. before `cpus`).
|
||||||
|
- Reuse the existing section-card / kv-table / data-table presentation and CSS -
|
||||||
|
no new heavy UI, no client framework.
|
||||||
|
- Works through every integration path: in-process `RenderHTML*`, embedded
|
||||||
|
`POST /render`, and standalone file upload.
|
||||||
|
- Preserves the "read-only, schema-preserving, does not compute" contract
|
||||||
|
(see `bible-local/decisions/2026-03-15-read-only-schema-preserving-viewer.md`).
|
||||||
|
- Backward compatible: no field / key present -> byte-identical output.
|
||||||
|
|
||||||
|
## 3. Non-goals
|
||||||
|
|
||||||
|
- Interactivity beyond what built-in sections already have (severity filter).
|
||||||
|
- Panels influencing or overriding built-in section data.
|
||||||
|
- Arbitrary HTML/CSS/JS injection from the host.
|
||||||
|
- Per-viewer persistence, collapse state sync, theming knobs.
|
||||||
|
- Replacing `platform_config` or the hardware sections.
|
||||||
|
|
||||||
|
## 4. Data contract
|
||||||
|
|
||||||
|
### 4.1 Envelope
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"panels": [ Panel, ... ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`version` is the panels contract version (`chart` exposes
|
||||||
|
`viewer.PanelsContractVersion`). `chart` renders a known major version; an
|
||||||
|
unknown major version renders each panel's `title` plus a muted
|
||||||
|
"panel format vX not supported by this viewer" line (never a silent drop).
|
||||||
|
|
||||||
|
### 4.2 Panel
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|-------|------|----------|-------|
|
||||||
|
| `id` | string | yes | Stable slug, `[a-z0-9-]+`, unique within the set. Rendered as the card's DOM id, prefixed `ext-` to avoid collision with built-in section ids. |
|
||||||
|
| `title` | string | yes | Card header text. |
|
||||||
|
| `placement` | string | no | `top` (default), `bottom`, `before:<section-id>`, `after:<section-id>`. `<section-id>` is a built-in section id (`board`, `firmware`, `cpus`, `memory`, `storage`, `pcie_devices`, `power_supplies`, `licenses`, `sensors`, `event_logs`, `platform_config`) or another panel's rendered id. |
|
||||||
|
| `severity` | string | no | `""` \| `info` \| `warning` \| `critical`. Drives the card's left-accent color only, reusing existing status colors. |
|
||||||
|
| `blocks` | Block[] | yes | 1..N content blocks, rendered top to bottom. |
|
||||||
|
| `collapsed` | bool | no | Initial collapsed state (default expanded). Uses the same collapse affordance as built-in cards if/when present; otherwise ignored. |
|
||||||
|
|
||||||
|
### 4.3 Block
|
||||||
|
|
||||||
|
Tagged union on `kind`:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{ "kind": "text", "text": "one or more paragraphs, \n splits paragraphs" }
|
||||||
|
|
||||||
|
{ "kind": "keyvalue", "rows": [ { "key": "Customer", "value": "acme.ru" }, ... ] }
|
||||||
|
|
||||||
|
{ "kind": "list", "items": [ "line 1", "line 2", ... ] }
|
||||||
|
|
||||||
|
{ "kind": "links", "links": [ { "label": "Ticket OPS-649", "href": "https://..." } ] }
|
||||||
|
|
||||||
|
{ "kind": "table",
|
||||||
|
"columns": [ "severity", "category", "location", "match" ],
|
||||||
|
"rows": [
|
||||||
|
{ "cells": { "category": "domain", "location": "resolv.conf:4", "match": "acme.ru" },
|
||||||
|
"severity": "warning" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Table block details:
|
||||||
|
- `columns` is the display order. A `severity` column renders as the existing
|
||||||
|
icon-only status badge; all other cells render as text (multi-line on `\n`).
|
||||||
|
- `rows[].severity` (optional) feeds row coloring and the severity filter, exactly
|
||||||
|
like built-in table sections.
|
||||||
|
- No sorting/pagination in v1. The existing per-table severity filter applies.
|
||||||
|
|
||||||
|
Unknown `kind` -> a muted "unsupported block kind: X" line. Never dropped.
|
||||||
|
|
||||||
|
### 4.4 Escaping / safety
|
||||||
|
|
||||||
|
- Every string is HTML-escaped by the template (`html/template`), as today.
|
||||||
|
- `links[].href`: only `http`, `https`, or root-relative (`/...`) accepted;
|
||||||
|
anything else is rendered as inert text. `rel="noopener noreferrer"`,
|
||||||
|
`target="_top"`.
|
||||||
|
- Total injected payload is size-capped (proposal: 256 KiB after JSON decode);
|
||||||
|
over the cap -> panels ignored + one top notice "custom panels omitted (too large)".
|
||||||
|
- Panel count cap (proposal: 32).
|
||||||
|
|
||||||
|
## 5. Delivery paths
|
||||||
|
|
||||||
|
The same envelope reaches `chart` three ways; all are merged (options win over
|
||||||
|
snapshot key on `id` conflict):
|
||||||
|
|
||||||
|
1. **In-process** - `viewer.RenderOptions.Panels []viewer.Panel`
|
||||||
|
(and a matching `viewer.RenderHTMLWithOptions` already exists). Type-safe,
|
||||||
|
preferred for embedders that server-render (LOGPile does).
|
||||||
|
|
||||||
|
2. **Embedded HTTP** - `POST /render` accepts an optional part:
|
||||||
|
- multipart field `panels` (JSON), or
|
||||||
|
- request header `X-Chart-Panels: base64(json)` for the raw-JSON body case.
|
||||||
|
|
||||||
|
3. **Snapshot-embedded** - reserved top-level key `"_chart_panels"` in the
|
||||||
|
snapshot JSON (underscore prefix = explicitly not Reanimator payload).
|
||||||
|
`chart` already tolerates unknown top-level keys; this one is consumed and
|
||||||
|
not shown in Snapshot Metadata.
|
||||||
|
|
||||||
|
`NoticeTitle` / `NoticeBody` stay as a convenience shorthand, internally
|
||||||
|
converted to a single `{id:"notice", placement:"top", severity:"info",
|
||||||
|
blocks:[{kind:"text",...}]}` panel. Documented as legacy-but-supported.
|
||||||
|
|
||||||
|
## 6. Rendering rules
|
||||||
|
|
||||||
|
- Panels render as `section-card section-card-full` with an optional
|
||||||
|
`section-card--sev-{severity}` accent class (new CSS, ~6 lines).
|
||||||
|
- Placement resolution order: build the ordered list of built-in sections, then
|
||||||
|
for each panel insert at its anchor. `before:/after:` an unknown id -> fall
|
||||||
|
back to `top` and emit nothing visible (no error). `top` = after Snapshot
|
||||||
|
Metadata, before the first section. `bottom` = after the last section, before
|
||||||
|
the error/empty panels.
|
||||||
|
- Panel-to-panel anchors are resolved in input order (a panel can anchor to an
|
||||||
|
earlier panel's id).
|
||||||
|
- Section nav (if present) lists panels alongside built-in sections using
|
||||||
|
`title` + `id`.
|
||||||
|
|
||||||
|
## 7. Public API additions (`viewer` package)
|
||||||
|
|
||||||
|
```go
|
||||||
|
const PanelsContractVersion = "1.0"
|
||||||
|
|
||||||
|
type Panel struct {
|
||||||
|
ID string
|
||||||
|
Title string
|
||||||
|
Placement string // "", "top", "bottom", "before:<id>", "after:<id>"
|
||||||
|
Severity string // "", "info", "warning", "critical"
|
||||||
|
Collapsed bool
|
||||||
|
Blocks []Block
|
||||||
|
}
|
||||||
|
|
||||||
|
type Block struct {
|
||||||
|
Kind string // "text" | "keyvalue" | "list" | "links" | "table"
|
||||||
|
Text string `json:",omitempty"`
|
||||||
|
Rows []KeyValue `json:",omitempty"` // keyvalue
|
||||||
|
Items []string `json:",omitempty"` // list
|
||||||
|
Links []Link `json:",omitempty"`
|
||||||
|
Table *TableBlock `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KeyValue struct{ Key, Value string }
|
||||||
|
type Link struct{ Label, Href string }
|
||||||
|
type TableBlock struct {
|
||||||
|
Columns []string
|
||||||
|
Rows []TableBlockRow
|
||||||
|
}
|
||||||
|
type TableBlockRow struct {
|
||||||
|
Cells map[string]string
|
||||||
|
Severity string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderOptions gains:
|
||||||
|
type RenderOptions struct {
|
||||||
|
// ... existing fields ...
|
||||||
|
Panels []Panel
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
JSON tags on these types are the wire contract for paths 2 and 3.
|
||||||
|
|
||||||
|
## 8. Backward compatibility & versioning
|
||||||
|
|
||||||
|
- No panels supplied anywhere -> output unchanged (golden test).
|
||||||
|
- `chart` module version: minor bump (feature, no breaking change).
|
||||||
|
- Contract version `1.0`; `chart` accepts `1.x`, rejects `2.x` gracefully.
|
||||||
|
- Existing `NoticeTitle/Body` behavior unchanged.
|
||||||
|
|
||||||
|
## 9. Testing requirements
|
||||||
|
|
||||||
|
- Placement: `top`, `bottom`, `before:cpus`, `after:storage`, unknown target,
|
||||||
|
panel-anchored-to-panel, multiple panels same anchor (stable order).
|
||||||
|
- Each block kind renders; `keyvalue` and `table` reuse existing table CSS classes.
|
||||||
|
- `table` severity column -> icon badge; row severity -> filter + row class.
|
||||||
|
- Escaping: `<script>`, quotes, and a `javascript:` href are inert.
|
||||||
|
- Unknown block kind and unknown contract major -> visible muted note, not dropped.
|
||||||
|
- `id` collision with `cpus` -> rendered id is `ext-cpus`, anchor still works.
|
||||||
|
- Size cap and count cap -> panels omitted + top notice.
|
||||||
|
- Merge precedence across the three delivery paths.
|
||||||
|
- Golden HTML for a representative multi-block panel.
|
||||||
|
|
||||||
|
## 10. Docs to update
|
||||||
|
|
||||||
|
- `docs/embedding.md` - new "Custom panels" section with a full example.
|
||||||
|
- `bible-local/decisions/` - new decision record (custom panels, why a bounded
|
||||||
|
block vocabulary instead of raw HTML).
|
||||||
|
- `bible-local/architecture/ui-information-architecture.md` - panels in the page
|
||||||
|
structure and section order.
|
||||||
|
- `bible-local/architecture/data-model.md` - the envelope and Block union.
|
||||||
|
|
||||||
|
## 11. LOGPile migration (after this ships)
|
||||||
|
|
||||||
|
Replace the interim `privacyNotice` -> `NoticeTitle/Body` shim in
|
||||||
|
`internal/server/handlers.go` with:
|
||||||
|
|
||||||
|
```go
|
||||||
|
opts.Panels = []viewer.Panel{{
|
||||||
|
ID: "privacy",
|
||||||
|
Title: "Customer data",
|
||||||
|
Placement: "before:cpus",
|
||||||
|
Severity: privacySeverity(scan), // warning if any high, else info
|
||||||
|
Blocks: []viewer.Block{
|
||||||
|
{Kind: "keyvalue", Rows: customerRows(scan)},
|
||||||
|
{Kind: "table", Table: findingsTable(scan)},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
The separate below-iframe "Customer data" panel in the LOGPile UI can then be
|
||||||
|
retired, since the same content renders inside the chart.
|
||||||
|
|
||||||
|
## 12. Estimated effort
|
||||||
|
|
||||||
|
- `chart`: types + JSON contract, placement resolver, template blocks, CSS
|
||||||
|
accent, merge logic, tests, docs - ~2 focused days.
|
||||||
|
- LOGPile migration - ~half a day.
|
||||||
@@ -23,8 +23,14 @@ via `parser.PrivacyScanEnabled()`.
|
|||||||
## Output
|
## Output
|
||||||
|
|
||||||
- `GET /api/privacy-scan` - the `PrivacyScan` object, or `{"loaded": false}`.
|
- `GET /api/privacy-scan` - the `PrivacyScan` object, or `{"loaded": false}`.
|
||||||
- UI: the "Customer data" panel (`#privacy-section`), collapsible, hidden when
|
- UI: the "Customer data" panel (`#privacy-section`) at the top of the data
|
||||||
there are no findings and no customer guess.
|
section, above the chart iframe. Header + customer guess are always shown; the
|
||||||
|
findings table is collapsed by default and expands on click. Hidden entirely
|
||||||
|
when there are no findings and no customer guess.
|
||||||
|
- The chart viewer also gets a top notice (above Board/CPUs) summarizing the
|
||||||
|
scan, via the standard `viewer.RenderOptions.NoticeTitle/NoticeBody`
|
||||||
|
(`privacyNotice` in `internal/server/handlers.go`) - interim until the chart
|
||||||
|
"custom panels" mechanism ships (see `chart-custom-panels-spec.md`).
|
||||||
- `privacy_report.json` in the raw-export ZIP (`GET /api/export/json`), omitted
|
- `privacy_report.json` in the raw-export ZIP (`GET /api/export/json`), omitted
|
||||||
when the scan produced nothing.
|
when the scan produced nothing.
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ var (
|
|||||||
"libssh.org", "rsyslog.com", "adiscon.com", "redhat.com", "kernel.org",
|
"libssh.org", "rsyslog.com", "adiscon.com", "redhat.com", "kernel.org",
|
||||||
"megarac.com", "ami.com", "commond.com",
|
"megarac.com", "ami.com", "commond.com",
|
||||||
"oasis-open.org", "w3.org", "xmlsoap.org", "purl.org", "ietf.org",
|
"oasis-open.org", "w3.org", "xmlsoap.org", "purl.org", "ietf.org",
|
||||||
"inspur.com", "inspurcloud.com", "inservice-iq.com", "kaytus.com",
|
"inspur.com", "inspurcloud.com", "inservice-iq.com", "ieisystem.com", "kaytus.com",
|
||||||
"jd.com", "jd.local", "jdcloud.com", "in-addr.arpa", "ip6.arpa", "arpa",
|
"jd.com", "jd.local", "jdcloud.com", "in-addr.arpa", "ip6.arpa", "arpa",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,13 @@ func (s *Server) handleChartCurrent(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
html, err := chartviewer.RenderHTMLWithOptions(snapshotBytes, title, chartviewer.RenderOptions{})
|
opts := chartviewer.RenderOptions{}
|
||||||
|
if nt, nb := privacyNotice(result.PrivacyScan); nt != "" {
|
||||||
|
opts.NoticeTitle = nt
|
||||||
|
opts.NoticeBody = nb
|
||||||
|
}
|
||||||
|
|
||||||
|
html, err := chartviewer.RenderHTMLWithOptions(snapshotBytes, title, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.htmlError(w, "failed to render chart: "+err.Error(), http.StatusInternalServerError)
|
s.htmlError(w, "failed to render chart: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -95,6 +101,68 @@ func (s *Server) handleChartCurrent(w http.ResponseWriter, r *http.Request) {
|
|||||||
_, _ = w.Write(rewriteChartStaticPaths(html))
|
_, _ = w.Write(rewriteChartStaticPaths(html))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// privacyNotice renders the customer-data scan summary as the chart viewer's
|
||||||
|
// top notice panel (above the Board/CPUs sections). Uses the viewer's standard
|
||||||
|
// NoticeTitle/NoticeBody mechanism - no chart changes. Full findings stay in the
|
||||||
|
// "Customer data" panel and GET /api/privacy-scan.
|
||||||
|
func privacyNotice(scan *models.PrivacyScan) (title, body string) {
|
||||||
|
if scan == nil || (scan.Summary.Total == 0 && len(scan.Customers) == 0) {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
title = "Customer data detected"
|
||||||
|
if len(scan.Customers) > 0 {
|
||||||
|
title += " - likely " + scan.Customers[0].Domain
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "%d finding(s)", scan.Summary.Total)
|
||||||
|
sev := make([]string, 0, 3)
|
||||||
|
if scan.Summary.High > 0 {
|
||||||
|
sev = append(sev, fmt.Sprintf("%d high", scan.Summary.High))
|
||||||
|
}
|
||||||
|
if scan.Summary.Medium > 0 {
|
||||||
|
sev = append(sev, fmt.Sprintf("%d medium", scan.Summary.Medium))
|
||||||
|
}
|
||||||
|
if scan.Summary.Low > 0 {
|
||||||
|
sev = append(sev, fmt.Sprintf("%d low", scan.Summary.Low))
|
||||||
|
}
|
||||||
|
if len(sev) > 0 {
|
||||||
|
fmt.Fprintf(&b, " (%s)", strings.Join(sev, ", "))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, " across %d file(s).", scan.FilesScanned)
|
||||||
|
if cats := topCategories(scan.Summary.ByCategory, 4); len(cats) > 0 {
|
||||||
|
fmt.Fprintf(&b, " Categories: %s.", strings.Join(cats, ", "))
|
||||||
|
}
|
||||||
|
b.WriteString(` Review the "Customer data" panel and sanitize this dump before sharing it.`)
|
||||||
|
return title, b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func topCategories(byCategory map[string]int, n int) []string {
|
||||||
|
type kv struct {
|
||||||
|
k string
|
||||||
|
v int
|
||||||
|
}
|
||||||
|
items := make([]kv, 0, len(byCategory))
|
||||||
|
for k, v := range byCategory {
|
||||||
|
items = append(items, kv{k, v})
|
||||||
|
}
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
if items[i].v != items[j].v {
|
||||||
|
return items[i].v > items[j].v
|
||||||
|
}
|
||||||
|
return items[i].k < items[j].k
|
||||||
|
})
|
||||||
|
out := make([]string, 0, n)
|
||||||
|
for i, it := range items {
|
||||||
|
if i >= n {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out = append(out, it.k)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func currentReanimatorSnapshotBytes(result *models.AnalysisResult) ([]byte, error) {
|
func currentReanimatorSnapshotBytes(result *models.AnalysisResult) ([]byte, error) {
|
||||||
reanimatorData, err := exporter.ConvertToReanimator(result)
|
reanimatorData, err := exporter.ConvertToReanimator(result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+22
-8
@@ -1484,16 +1484,30 @@ async function loadPrivacyScan() {
|
|||||||
rows.appendChild(tr);
|
rows.appendChild(tr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const findingsBox = document.getElementById('privacy-findings');
|
||||||
|
const toggle = document.getElementById('privacy-toggle');
|
||||||
|
if (findings.length === 0) {
|
||||||
|
if (findingsBox) findingsBox.style.display = 'none';
|
||||||
|
if (toggle) toggle.style.visibility = 'hidden';
|
||||||
|
} else {
|
||||||
|
if (toggle) {
|
||||||
|
toggle.style.visibility = '';
|
||||||
|
toggle.textContent = privacyCollapsed ? '▼' : '▲';
|
||||||
|
}
|
||||||
|
if (findingsBox) findingsBox.style.display = privacyCollapsed ? 'none' : '';
|
||||||
|
}
|
||||||
|
|
||||||
section.classList.remove('hidden');
|
section.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
let privacyCollapsed = false;
|
// The header + customer summary stay visible; only the findings table collapses.
|
||||||
|
let privacyCollapsed = true;
|
||||||
function togglePrivacy() {
|
function togglePrivacy() {
|
||||||
const body = document.getElementById('privacy-body');
|
const box = document.getElementById('privacy-findings');
|
||||||
const toggle = document.getElementById('privacy-toggle');
|
const toggle = document.getElementById('privacy-toggle');
|
||||||
if (!body) return;
|
if (!box || !document.getElementById('privacy-rows').children.length) return;
|
||||||
privacyCollapsed = !privacyCollapsed;
|
privacyCollapsed = !privacyCollapsed;
|
||||||
body.style.display = privacyCollapsed ? 'none' : '';
|
box.style.display = privacyCollapsed ? 'none' : '';
|
||||||
toggle.textContent = privacyCollapsed ? '▼' : '▲';
|
toggle.textContent = privacyCollapsed ? '▼' : '▲';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1621,11 +1635,11 @@ async function clearData() {
|
|||||||
if (privacyRows) privacyRows.innerHTML = '';
|
if (privacyRows) privacyRows.innerHTML = '';
|
||||||
const privacyCustomer = document.getElementById('privacy-customer');
|
const privacyCustomer = document.getElementById('privacy-customer');
|
||||||
if (privacyCustomer) privacyCustomer.innerHTML = '';
|
if (privacyCustomer) privacyCustomer.innerHTML = '';
|
||||||
privacyCollapsed = false;
|
privacyCollapsed = true;
|
||||||
const privacyBody = document.getElementById('privacy-body');
|
const privacyFindings = document.getElementById('privacy-findings');
|
||||||
if (privacyBody) privacyBody.style.display = '';
|
if (privacyFindings) privacyFindings.style.display = 'none';
|
||||||
const privacyToggle = document.getElementById('privacy-toggle');
|
const privacyToggle = document.getElementById('privacy-toggle');
|
||||||
if (privacyToggle) privacyToggle.textContent = '▲';
|
if (privacyToggle) { privacyToggle.textContent = '▼'; privacyToggle.style.visibility = ''; }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to clear data:', err);
|
console.error('Failed to clear data:', err);
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-21
@@ -159,6 +159,29 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="data-section" class="hidden">
|
<section id="data-section" class="hidden">
|
||||||
|
<section id="privacy-section" class="parse-errors-section hidden">
|
||||||
|
<div class="parse-errors-header" onclick="togglePrivacy()">
|
||||||
|
<span id="privacy-title">Customer data</span>
|
||||||
|
<span id="privacy-toggle" class="parse-errors-toggle">▼</span>
|
||||||
|
</div>
|
||||||
|
<div id="privacy-body" class="parse-errors-body">
|
||||||
|
<div id="privacy-customer" class="privacy-customer"></div>
|
||||||
|
<div id="privacy-findings" class="privacy-findings" style="display:none">
|
||||||
|
<table class="parse-errors-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Severity</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Location</th>
|
||||||
|
<th>Match</th>
|
||||||
|
<th>Hint</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="privacy-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section class="viewer-panel">
|
<section class="viewer-panel">
|
||||||
<div class="audit-viewer-shell">
|
<div class="audit-viewer-shell">
|
||||||
<iframe
|
<iframe
|
||||||
@@ -190,27 +213,6 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section id="privacy-section" class="parse-errors-section hidden">
|
|
||||||
<div class="parse-errors-header" onclick="togglePrivacy()">
|
|
||||||
<span id="privacy-title">Customer data</span>
|
|
||||||
<span id="privacy-toggle" class="parse-errors-toggle">▲</span>
|
|
||||||
</div>
|
|
||||||
<div id="privacy-body" class="parse-errors-body">
|
|
||||||
<div id="privacy-customer" class="privacy-customer"></div>
|
|
||||||
<table class="parse-errors-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Severity</th>
|
|
||||||
<th>Category</th>
|
|
||||||
<th>Location</th>
|
|
||||||
<th>Match</th>
|
|
||||||
<th>Hint</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="privacy-rows"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user