From 7ec9b418329c2bfe7e5ada4048a37f6f54eb506a Mon Sep 17 00:00:00 2001 From: Matteo Mattiuzzi Date: Wed, 29 Jul 2026 11:35:52 +0000 Subject: [PATCH 1/9] feat: add parsEO dashboard (filename builder + schema tools) --- tools/parseo-dashboard/README.md | 37 ++ tools/parseo-dashboard/dashboard.html | 550 ++++++++++++++++++++++++++ 2 files changed, 587 insertions(+) create mode 100644 tools/parseo-dashboard/README.md create mode 100644 tools/parseo-dashboard/dashboard.html diff --git a/tools/parseo-dashboard/README.md b/tools/parseo-dashboard/README.md new file mode 100644 index 00000000..193f8d42 --- /dev/null +++ b/tools/parseo-dashboard/README.md @@ -0,0 +1,37 @@ +# parsEO Dashboard + +Web-based dashboard for the parsEO CLMS Filename API. + +## Setup + +The parsEO API runs as a Docker container on **nucleus** at `http://nucleus:8000`. + +```bash +cd ~/parseo-api +docker compose up -d +``` + +## API Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/health` | GET | Health check | +| `/api/info` | GET | parsEO version info | +| `/api/families` | GET | List all schema families | +| `/api/families/{family}/versions` | GET | List schema versions | +| `/api/families/{family}/schemas/{version}` | GET | Get schema JSON | +| `/api/assemble` | POST | Assemble filename from fields | +| `/api/parse` | POST | Parse a filename into fields | +| `/api/validate` | POST | Validate a schema JSON | +| `/api/cache/clear` | POST | Clear parsEO cache | + +## Usage + +Open `dashboard.html` in a browser. Set the API URL to `http://nucleus:8000` (or the host where the API runs). + +## Files + +- `dashboard.html` — Single-page dashboard app (dark theme) +- `api.py` — FastAPI server (in `~/parseo-api/` on nucleus) +- `Dockerfile` — Docker build (in `~/parseo-api/`) +- `docker-compose.yml` — Run configuration (in `~/parseo-api/`) \ No newline at end of file diff --git a/tools/parseo-dashboard/dashboard.html b/tools/parseo-dashboard/dashboard.html new file mode 100644 index 00000000..74b3f016 --- /dev/null +++ b/tools/parseo-dashboard/dashboard.html @@ -0,0 +1,550 @@ + + + + + +parsEO Dashboard — CLMS Filename Tool + + + + +
+

parsEO Dashboard

+ CLMS Filename Tool +
+
+ + Connecting… + +
+
+
+ +
+ + + + +
+ +
+
+
Filename
+
Parse
+
Schema
+
Validate
+
+ + +
+
+ ASSEMBLED FILENAME + Select a family, fill fields, and assemble. + +
+
+
+ + +
+
+ + +
+ +
+
Parsed fields will appear here.
+
+
+ + +
+
Select a family and version to view the schema.
+
+ + +
+
+ + +
+ +
+
+
+ + + +
+
+ + + + \ No newline at end of file From 3552e8266c6775e3801c998fb991a9b898b8bdd5 Mon Sep 17 00:00:00 2001 From: Matteo Mattiuzzi Date: Wed, 29 Jul 2026 12:02:56 +0000 Subject: [PATCH 2/9] fix: use relative API URL, add fetch error handling, debounce, XSS escaping --- tools/parseo-dashboard/dashboard.html | 40 +++++++++++++++++---------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/tools/parseo-dashboard/dashboard.html b/tools/parseo-dashboard/dashboard.html index 74b3f016..407a67d9 100644 --- a/tools/parseo-dashboard/dashboard.html +++ b/tools/parseo-dashboard/dashboard.html @@ -133,7 +133,7 @@

parsEO Dashboard

API Connection

- +
@@ -216,6 +216,18 @@

Schema Info

let currentFamily = ''; let currentVersion = ''; let currentSchema = null; +let assembleDebounce = null; + +// Safe HTML escaping +function esc(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } + +// Fetch helper that checks response.ok before parsing JSON +function apiJSON(url, opts) { + return fetch(url, opts).then(r => { + if (!r.ok) return r.json().then(d => Promise.reject(d.detail || r.statusText)); + return r.json(); + }); +} function connectAPI() { API_BASE = document.getElementById('api-url').value.replace(/\/+$/, ''); @@ -225,8 +237,7 @@

Schema Info

dot.className = 'conn-dot pending'; text.textContent = 'Connecting…'; - fetch(API_BASE + '/api/health') - .then(r => r.json()) + apiJSON(API_BASE + '/api/health') .then(d => { dot.className = 'conn-dot ok'; text.textContent = 'Connected'; @@ -244,14 +255,13 @@

Schema Info

sel.disabled = true; sel.innerHTML = ''; - fetch(API_BASE + '/api/families') - .then(r => r.json()) + apiJSON(API_BASE + '/api/families') .then(d => { sel.innerHTML = ''; // Sort families const families = (d.families || []).sort(); for (const f of families) { - sel.innerHTML += ``; + sel.innerHTML += ``; } sel.disabled = false; }) @@ -274,8 +284,7 @@

Schema Info

const vlist = document.getElementById('version-list'); vlist.innerHTML = ''; - fetch(API_BASE + '/api/families/' + encodeURIComponent(family) + '/versions') - .then(r => r.json()) + apiJSON(API_BASE + '/api/families/' + encodeURIComponent(family) + '/versions') .then(d => { const versions = d.versions || []; if (versions.length === 0) { @@ -389,6 +398,12 @@

Schema Info

} function assembleFilename() { + // Debounce: wait 300ms after last input before firing + if (assembleDebounce) clearTimeout(assembleDebounce); + assembleDebounce = setTimeout(() => _doAssemble(), 300); +} + +function _doAssemble() { const preview = document.getElementById('filename-preview'); if (!currentSchema) return; @@ -413,7 +428,7 @@

Schema Info

preview.textContent = 'Assembling…'; - fetch(API_BASE + '/api/assemble', { + apiJSON(API_BASE + '/api/assemble', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -422,7 +437,6 @@

Schema Info

fields: fields }) }) - .then(r => r.json()) .then(d => { if (d.filename) { preview.textContent = d.filename; @@ -473,12 +487,11 @@

Schema Info

if (!fn) { result.textContent = 'Enter a filename to parse.'; return; } result.textContent = 'Parsing…'; - fetch(API_BASE + '/api/parse', { + apiJSON(API_BASE + '/api/parse', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: fn }) }) - .then(r => r.json()) .then(d => { if (d.parsed) { result.textContent = JSON.stringify(d.parsed, null, 2); @@ -500,12 +513,11 @@

Schema Info

results.innerHTML = ' Validating…'; - fetch(API_BASE + '/api/validate', { + apiJSON(API_BASE + '/api/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ schema: schema }) }) - .then(r => r.json()) .then(d => { let html = ''; if (d.errors && d.errors.length) { From 30462249f301a4e9f7622a2432d6c46f55cdaf21 Mon Sep 17 00:00:00 2001 From: Matteo Mattiuzzi Date: Wed, 29 Jul 2026 12:24:49 +0000 Subject: [PATCH 3/9] fix: empty API base for same-origin (SWAG proxy) --- tools/parseo-dashboard/dashboard.html | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tools/parseo-dashboard/dashboard.html b/tools/parseo-dashboard/dashboard.html index 407a67d9..87c746c9 100644 --- a/tools/parseo-dashboard/dashboard.html +++ b/tools/parseo-dashboard/dashboard.html @@ -133,7 +133,7 @@

parsEO Dashboard

API Connection

- +
@@ -554,7 +554,6 @@

Schema Info

// Auto-connect on load document.addEventListener('DOMContentLoaded', () => { - // Try localhost first, then nucleus connectAPI(); }); From 2e2767cb71f05cfdc3183123c2462c95f5847d9e Mon Sep 17 00:00:00 2001 From: Matteo Mattiuzzi Date: Wed, 29 Jul 2026 12:29:47 +0000 Subject: [PATCH 4/9] fix: remove API URL field, auto-connect on same-origin --- tools/parseo-dashboard/dashboard.html | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/tools/parseo-dashboard/dashboard.html b/tools/parseo-dashboard/dashboard.html index 87c746c9..0eedaa10 100644 --- a/tools/parseo-dashboard/dashboard.html +++ b/tools/parseo-dashboard/dashboard.html @@ -130,14 +130,7 @@

parsEO Dashboard

@@ -210,6 +232,8 @@

Schema Info

let currentVersion = ''; let currentSchema = null; let assembleDebounce = null; +let fieldValues = {}; +let fieldTreeOrder = []; // Safe HTML escaping function esc(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } @@ -322,74 +346,131 @@

Schema Info

} function buildFieldInputs(schema) { - const container = document.getElementById('field-inputs'); + const container = document.getElementById('field-tree'); const fields = schema.fields || {}; container.innerHTML = ''; document.getElementById('schema-fields').style.display = 'block'; + fieldValues = {}; const template = schema.template || ''; - // Extract field order from template const fieldOrder = [...template.matchAll(/\{(\w+)\}/g)].map(m => m[1]); const seen = new Set(); + fieldTreeOrder = []; for (const f of fieldOrder) { if (f === 'extension' || seen.has(f)) continue; seen.add(f); - const def = fields[f]; - if (!def) continue; - addFieldInput(container, f, def); + if (!fields[f]) continue; + fieldTreeOrder.push(f); } - - // Add any remaining fields not in template - for (const [name, def] of Object.entries(fields)) { - if (seen.has(name)) continue; - if (name === 'extension') continue; + for (const name of Object.keys(fields)) { + if (seen.has(name) || name === 'extension') continue; seen.add(name); - addFieldInput(container, name, def); + fieldTreeOrder.push(name); + } + if (fields.extension) fieldTreeOrder.push('extension'); + + // Pre-fill obvious single-enum / default fields so downstream rows unlock immediately + for (const name of fieldTreeOrder) { + const def = fields[name]; + if (def.enum && def.enum.length === 1) fieldValues[name] = def.enum[0]; } + const defaults = { programme: 'CLMS', type: 'R', epsg_code: '03035', variant: 'S2' }; + for (const [name, val] of Object.entries(defaults)) { + if (fields[name] && !fieldValues[name] && !fields[name].enum) fieldValues[name] = val; + } + + renderFieldTree(schema); + assembleFilename(); +} + +function renderFieldTree(schema) { + const container = document.getElementById('field-tree'); + const fields = schema.fields || {}; + container.innerHTML = ''; - // Extension - if (fields.extension) { - addFieldInput(container, 'extension', fields.extension); + for (const name of fieldTreeOrder) { + const def = fields[name]; + if (!def) continue; + container.appendChild(buildFieldRow(name, def)); } } -function addFieldInput(container, name, def) { - const div = document.createElement('div'); - div.className = 'form-row'; - const label = document.createElement('label'); - label.textContent = name.replace(/_/g, ' '); - div.appendChild(label); - - let input; - if (def.enum && def.enum.length <= 20) { - input = document.createElement('select'); - input.innerHTML = ''; +function buildFieldRow(name, def) { + const row = document.createElement('div'); + row.className = 'field-row'; + row.dataset.fieldName = name; + + const label = document.createElement('div'); + label.className = 'field-row-label'; + const labelText = document.createElement('span'); + labelText.textContent = name.replace(/_/g, ' '); + label.appendChild(labelText); + if (fieldValues[name]) { + const valSpan = document.createElement('span'); + valSpan.className = 'val'; + valSpan.textContent = fieldValues[name]; + label.appendChild(valSpan); + } + row.appendChild(label); + + if (def.enum && def.enum.length > 0 && def.enum.length <= 40) { + const chipRow = document.createElement('div'); + chipRow.className = 'chip-row'; for (const v of def.enum) { - input.innerHTML += ``; + const chip = document.createElement('span'); + chip.className = 'chip' + (fieldValues[name] === v ? ' selected' : ''); + chip.textContent = v; + chip.onclick = () => onChipClick(name, v); + chipRow.appendChild(chip); } - } else if (def.enum && def.enum.length > 20) { - input = document.createElement('input'); - input.placeholder = def.enum.slice(0, 5).join(', ') + '…'; - if (def.enum.length === 1) input.value = def.enum[0]; + row.appendChild(chipRow); } else { - input = document.createElement('input'); - if (def.pattern) { - input.placeholder = 'regex: ' + def.pattern.slice(0, 30); + const input = document.createElement('input'); + input.className = 'chip-input'; + input.value = fieldValues[name] || ''; + if (def.enum && def.enum.length > 40) { + input.placeholder = def.enum.slice(0, 5).join(', ') + '…'; + } else if (def.pattern) { + input.placeholder = 'regex: ' + def.pattern.slice(0, 40); } - // Default values for common fields - if (name === 'programme') input.value = 'CLMS'; - if (name === 'type') input.value = 'R'; - if (name === 'epsg_code') input.value = '03035'; - if (name === 'variant') input.value = 'S2'; + input.oninput = () => onChipClick(name, input.value.trim(), true); + row.appendChild(input); } - input.dataset.fieldName = name; - input.onchange = () => assembleFilename(); - input.oninput = () => assembleFilename(); - div.appendChild(input); - container.appendChild(div); + + return row; } +function onChipClick(name, value, isTextInput) { + if (fieldValues[name] === value) { + // toggle off (chip only) + if (!isTextInput) delete fieldValues[name]; + } else if (value === '') { + delete fieldValues[name]; + } else { + fieldValues[name] = value; + } + if (!isTextInput) renderFieldTree(currentSchema); + else { + // just refresh the label val marker without rebuilding the input (keeps focus) + const row = document.querySelector(`.field-row[data-field-name="${CSS.escape(name)}"] .field-row-label`); + if (row) { + row.innerHTML = ''; + const labelText = document.createElement('span'); + labelText.textContent = name.replace(/_/g, ' '); + row.appendChild(labelText); + if (fieldValues[name]) { + const valSpan = document.createElement('span'); + valSpan.className = 'val'; + valSpan.textContent = fieldValues[name]; + row.appendChild(valSpan); + } + } + } + assembleFilename(); +} + + function assembleFilename() { // Debounce: wait 300ms after last input before firing if (assembleDebounce) clearTimeout(assembleDebounce); @@ -400,14 +481,7 @@

Schema Info

const preview = document.getElementById('filename-preview'); if (!currentSchema) return; - const fields = {}; - const inputs = document.querySelectorAll('#field-inputs input, #field-inputs select'); - for (const el of inputs) { - const name = el.dataset.fieldName; - if (!name) continue; - const val = el.value.trim(); - if (val) fields[name] = val; - } + const fields = { ...fieldValues }; // Add programme from enum if not set if (!fields.programme && currentSchema.fields?.programme?.enum?.length === 1) { From 19080f940101ddb6824da713674fb85e4879ec6d Mon Sep 17 00:00:00 2001 From: Matteo Mattiuzzi Date: Wed, 29 Jul 2026 13:44:50 +0000 Subject: [PATCH 6/9] feat: add parsEO dashboard to DOCS/tools (Quarto site) --- .github/non_browsable_doc_map.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/non_browsable_doc_map.json b/.github/non_browsable_doc_map.json index ea7c0717..a33e0cf8 100644 --- a/.github/non_browsable_doc_map.json +++ b/.github/non_browsable_doc_map.json @@ -27,4 +27,4 @@ "url": "/a3e44009d6b7375f60a58b11278cbe079d93407249c0c5e1c97f00e12e98c09a.html" } ] -} \ No newline at end of file +}tools/parseo-dashboard From 208ade3b3c3ce04816e0fd0bb72708eb1a6b7d9c Mon Sep 17 00:00:00 2001 From: Matteo Mattiuzzi Date: Wed, 29 Jul 2026 13:45:50 +0000 Subject: [PATCH 7/9] feat: add parsEO dashboard to DOCS/tools with index.qmd landing page --- DOCS/tools/parseo-dashboard/dashboard.html | 641 +++++++++++++++++++++ DOCS/tools/parseo-dashboard/index.qmd | 21 + 2 files changed, 662 insertions(+) create mode 100644 DOCS/tools/parseo-dashboard/dashboard.html create mode 100644 DOCS/tools/parseo-dashboard/index.qmd diff --git a/DOCS/tools/parseo-dashboard/dashboard.html b/DOCS/tools/parseo-dashboard/dashboard.html new file mode 100644 index 00000000..c5467e9a --- /dev/null +++ b/DOCS/tools/parseo-dashboard/dashboard.html @@ -0,0 +1,641 @@ + + + + + +parsEO Dashboard — CLMS Filename Tool + + + + +
+

parsEO Dashboard

+ CLMS Filename Tool +
+
+ + Connecting… + +
+
+
+ +
+ + + + +
+ +
+
+
Filename
+
Parse
+
Schema
+
Validate
+
+ + +
+
+ ASSEMBLED FILENAME + Select a family, fill fields, and assemble. + +
+
+
+ + +
+
+ + +
+ +
+
Parsed fields will appear here.
+
+
+ + +
+
Select a family and version to view the schema.
+
+ + +
+
+ + +
+ +
+
+
+ + + +
+
+ + + + \ No newline at end of file diff --git a/DOCS/tools/parseo-dashboard/index.qmd b/DOCS/tools/parseo-dashboard/index.qmd new file mode 100644 index 00000000..24272129 --- /dev/null +++ b/DOCS/tools/parseo-dashboard/index.qmd @@ -0,0 +1,21 @@ +--- +title: "ParsEO Dashboard" +subtitle: "CLMS Filename Builder & Schema Tool" +category: tools +date: '2026-07-29' +description: "Interactive web tool for building, parsing, and validating CLMS filenames using the parsEO API." +--- + +The parsEO dashboard lets you build CLMS filenames by clicking through schema fields, parse existing filenames, and validate schema JSON files — all powered by the parsEO API. + +[Open the Dashboard →](dashboard.html) + +> The dashboard connects to `parseo.mattiuzzi.com` for API calls. Make sure you have network access to it. + +### Features + +- **Clickable chip-tree** — build filenames by selecting values pill-by-pill +- **Live preview** — see the assembled filename update instantly +- **Parse** — paste a filename and get its parsed fields +- **Validate** — paste a schema JSON and run validation checks +- **Schema browser** — explore all parsEO schema families and versions \ No newline at end of file From 5dd9f0847ec922a0c3939e81c9e31dd1aa98d4f2 Mon Sep 17 00:00:00 2001 From: Matteo Mattiuzzi Date: Wed, 29 Jul 2026 14:01:01 +0000 Subject: [PATCH 8/9] feat: parsEO dashboard as OJS Quarto dashboard (replaces standalone HTML) --- DOCS/tools/parseo-dashboard/dashboard.html | 641 --------------------- DOCS/tools/parseo-dashboard/index.qmd | 228 +++++++- tools/parseo-dashboard/README.md | 37 -- tools/parseo-dashboard/dashboard.html | 641 --------------------- 4 files changed, 214 insertions(+), 1333 deletions(-) delete mode 100644 DOCS/tools/parseo-dashboard/dashboard.html delete mode 100644 tools/parseo-dashboard/README.md delete mode 100644 tools/parseo-dashboard/dashboard.html diff --git a/DOCS/tools/parseo-dashboard/dashboard.html b/DOCS/tools/parseo-dashboard/dashboard.html deleted file mode 100644 index c5467e9a..00000000 --- a/DOCS/tools/parseo-dashboard/dashboard.html +++ /dev/null @@ -1,641 +0,0 @@ - - - - - -parsEO Dashboard — CLMS Filename Tool - - - - -
-

parsEO Dashboard

- CLMS Filename Tool -
-
- - Connecting… - -
-
-
- -
- - - - -
- -
-
-
Filename
-
Parse
-
Schema
-
Validate
-
- - -
-
- ASSEMBLED FILENAME - Select a family, fill fields, and assemble. - -
-
-
- - -
-
- - -
- -
-
Parsed fields will appear here.
-
-
- - -
-
Select a family and version to view the schema.
-
- - -
-
- - -
- -
-
-
- - - -
-
- - - - \ No newline at end of file diff --git a/DOCS/tools/parseo-dashboard/index.qmd b/DOCS/tools/parseo-dashboard/index.qmd index 24272129..efa06a9d 100644 --- a/DOCS/tools/parseo-dashboard/index.qmd +++ b/DOCS/tools/parseo-dashboard/index.qmd @@ -1,21 +1,221 @@ --- -title: "ParsEO Dashboard" -subtitle: "CLMS Filename Builder & Schema Tool" -category: tools -date: '2026-07-29' -description: "Interactive web tool for building, parsing, and validating CLMS filenames using the parsEO API." +title: "parsEO — CLMS Filename Builder" +subtitle: "Assemble, parse & validate CLMS filenames" +author: "European Environment Agency (EEA)" +date: 2026-07-29 +category: non-browsable +type: dashboard +format: + html: + toc: false + page-layout: full + smooth-scroll: true --- -The parsEO dashboard lets you build CLMS filenames by clicking through schema fields, parse existing filenames, and validate schema JSON files — all powered by the parsEO API. -[Open the Dashboard →](dashboard.html) -> The dashboard connects to `parseo.mattiuzzi.com` for API calls. Make sure you have network access to it. +```{ojs} +// ── parsEO API base ── +const API = "https://parseo.mattiuzzi.com"; -### Features +// ── State ── +const families = view(Inputs.select(new Map(), {label: "Family", value: ""})); +const versions = Mutable([]); +const currentVersion = Mutable(""); +const fieldValues = Mutable({}); +const selectedFamily = Mutable(""); +const familySchemas = Mutable({}); -- **Clickable chip-tree** — build filenames by selecting values pill-by-pill -- **Live preview** — see the assembled filename update instantly -- **Parse** — paste a filename and get its parsed fields -- **Validate** — paste a schema JSON and run validation checks -- **Schema browser** — explore all parsEO schema families and versions \ No newline at end of file +// ── Load families on startup ── +const familiesData = await fetch(API + "/api/families") + .then(r => r.ok ? r.json() : {families: []}) + .catch(() => ({families: []})); + +// Update family selector +families.value = new Map( + familiesData.families.sort().map(f => [f, f]) +); +families.dispatchEvent(new Event("input")); + +// ── When family changes, load versions ── +async function onFamilyChange(family) { + if (!family) return; + selectedFamily.value = family; + const data = await fetch(`${API}/api/families/${family}/versions`) + .then(r => r.ok ? r.json() : {versions: []}) + .catch(() => ({versions: []})); + versions.value = data.versions || []; + currentVersion.value = versions.value.length ? versions.value[versions.value.length - 1] : ""; + + // Load schema for latest version + if (currentVersion.value) { + const schema = await fetch(`${API}/api/families/${family}/schemas/${currentVersion.value}`) + .then(r => r.ok ? r.json() : null) + .catch(() => null); + if (schema) { + familySchemas.value = schema; + // Auto-fill single-enum fields + const fv = {}; + for (const [name, def] of Object.entries(schema.fields || {})) { + if (def.enum && def.enum.length === 1) fv[name] = def.enum[0]; + if (name === "programme") fv[name] = "CLMS"; + if (name === "extension") fv[name] = "tif"; + } + fieldValues.value = fv; + } + } +} + +// ── Version selector ── +const versionPicker = versions.value.length > 0 + ? Inputs.select(versions.value, {label: "Version", value: currentVersion.value}) + : html`No versions`; + +if (versionPicker.tagName === "SELECT") { + versionPicker.addEventListener("input", async () => { + currentVersion.value = versionPicker.value; + const schema = await fetch(`${API}/api/families/${selectedFamily.value}/schemas/${currentVersion.value}`) + .then(r => r.ok ? r.json() : null) + .catch(() => null); + if (schema) familySchemas.value = schema; + }); +} + +// ── Assemble filename ── +async function assemble(fields) { + if (!fields || Object.keys(fields).length === 0) return ""; + const data = await fetch(API + "/api/assemble", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({family: selectedFamily.value, version: currentVersion.value, fields}) + }).then(r => r.ok ? r.json() : null).catch(() => null); + return data?.filename || ""; +} + +// ── Render field chips ── +function renderField(name, def, values, onSelect) { + const label = name.replace(/_/g, " "); + const current = values[name] || ""; + let el; + + if (def.enum && def.enum.length <= 30) { + // Chips mode + el = html`
+
+ ${label}${current ? html` · ${current}` : ""} +
+
+ ${def.enum.map(v => html``)} +
+
`; + } else { + // Input mode + el = html`
+ + onSelect(name, e.target.value)}> +
`; + } + return el; +} + +// ── Parse tab ── +async function doParse(fn) { + if (!fn) return "Enter a filename."; + const data = await fetch(API + "/api/parse", { + method: "POST", headers: {"Content-Type": "application/json"}, + body: JSON.stringify({filename: fn}) + }).then(r => r.ok ? r.json() : null).catch(() => null); + if (!data?.parsed) return "Parse failed."; + return html`
${JSON.stringify(data.parsed, null, 2)}
`; +} + +// ── Validate tab ── +async function doValidate(json) { + let schema; + try { schema = JSON.parse(json); } catch(e) { return html`
Invalid JSON: ${e.message}
`; } + const data = await fetch(API + "/api/validate", { + method: "POST", headers: {"Content-Type": "application/json"}, + body: JSON.stringify({schema}) + }).then(r => r.ok ? r.json() : null).catch(() => null); + if (!data) return "Validation failed."; + return html`
+ ${data.valid + ? html`
\u2713 Valid (${data.errors.length} errors, ${data.warnings.length} warnings)
` + : html`
\u2717 Invalid
`} + ${data.errors.map(e => html`
\u00b7 ${e}
`)} + ${data.warnings.map(w => html`
\u00b7 ${w}
`)} +
`; +} +``` + +## Filename Builder + +
+
+
+ ${families} + ${versionPicker} +
+
+ ${familySchemas.value?.fields ? Object.entries(familySchemas.value.fields).map(([name, def]) => { + if (name === "extension") return null; + return renderField(name, def, fieldValues.value, (n, v) => { + fieldValues.value = {...fieldValues.value, [n]: v}; + assemble(fieldValues.value).then(fn => { + const el = document.getElementById("filename-preview"); + if (el) el.textContent = fn || "\u2014"; + }); + }); + }) : html`Select a family first.`} +
+
+
+
+
+
Assembled Filename
+
\u2014
+ ${familySchemas.value?.examples ? html` +
Examples
+ ${familySchemas.value.examples.slice(0,3).map(ex => html`
${ex}
`)} + ` : ""} +
+ +
+
+
Parse a Filename
+ + +
+
+ +
+
Validate a Schema
+ + +
+
+
+
+
+``` \ No newline at end of file diff --git a/tools/parseo-dashboard/README.md b/tools/parseo-dashboard/README.md deleted file mode 100644 index 193f8d42..00000000 --- a/tools/parseo-dashboard/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# parsEO Dashboard - -Web-based dashboard for the parsEO CLMS Filename API. - -## Setup - -The parsEO API runs as a Docker container on **nucleus** at `http://nucleus:8000`. - -```bash -cd ~/parseo-api -docker compose up -d -``` - -## API Endpoints - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/api/health` | GET | Health check | -| `/api/info` | GET | parsEO version info | -| `/api/families` | GET | List all schema families | -| `/api/families/{family}/versions` | GET | List schema versions | -| `/api/families/{family}/schemas/{version}` | GET | Get schema JSON | -| `/api/assemble` | POST | Assemble filename from fields | -| `/api/parse` | POST | Parse a filename into fields | -| `/api/validate` | POST | Validate a schema JSON | -| `/api/cache/clear` | POST | Clear parsEO cache | - -## Usage - -Open `dashboard.html` in a browser. Set the API URL to `http://nucleus:8000` (or the host where the API runs). - -## Files - -- `dashboard.html` — Single-page dashboard app (dark theme) -- `api.py` — FastAPI server (in `~/parseo-api/` on nucleus) -- `Dockerfile` — Docker build (in `~/parseo-api/`) -- `docker-compose.yml` — Run configuration (in `~/parseo-api/`) \ No newline at end of file diff --git a/tools/parseo-dashboard/dashboard.html b/tools/parseo-dashboard/dashboard.html deleted file mode 100644 index c9ed95cc..00000000 --- a/tools/parseo-dashboard/dashboard.html +++ /dev/null @@ -1,641 +0,0 @@ - - - - - -parsEO Dashboard — CLMS Filename Tool - - - - -
-

parsEO Dashboard

- CLMS Filename Tool -
-
- - Connecting… - -
-
-
- -
- - - - -
- -
-
-
Filename
-
Parse
-
Schema
-
Validate
-
- - -
-
- ASSEMBLED FILENAME - Select a family, fill fields, and assemble. - -
-
-
- - -
-
- - -
- -
-
Parsed fields will appear here.
-
-
- - -
-
Select a family and version to view the schema.
-
- - -
-
- - -
- -
-
-
- - - -
-
- - - - \ No newline at end of file From c5969c155d38aec8c96d3808a827abf1209adbe7 Mon Sep 17 00:00:00 2001 From: mckeea Date: Thu, 6 Aug 2026 10:34:51 +0000 Subject: [PATCH 9/9] fix: make the parsEO dashboard actually run --- .github/non_browsable_doc_map.json | 2 +- DOCS/tools/parseo-dashboard/index.qmd | 457 +++++++++++++++----------- 2 files changed, 259 insertions(+), 200 deletions(-) diff --git a/.github/non_browsable_doc_map.json b/.github/non_browsable_doc_map.json index a33e0cf8..ea7c0717 100644 --- a/.github/non_browsable_doc_map.json +++ b/.github/non_browsable_doc_map.json @@ -27,4 +27,4 @@ "url": "/a3e44009d6b7375f60a58b11278cbe079d93407249c0c5e1c97f00e12e98c09a.html" } ] -}tools/parseo-dashboard +} \ No newline at end of file diff --git a/DOCS/tools/parseo-dashboard/index.qmd b/DOCS/tools/parseo-dashboard/index.qmd index efa06a9d..f781b994 100644 --- a/DOCS/tools/parseo-dashboard/index.qmd +++ b/DOCS/tools/parseo-dashboard/index.qmd @@ -5,217 +5,276 @@ author: "European Environment Agency (EEA)" date: 2026-07-29 category: non-browsable type: dashboard -format: - html: - toc: false - page-layout: full - smooth-scroll: true --- +```{ojs} +parseoApp = { + // parsEO API base. Absolute because the technical library and the API are + // served from different hosts; set to "" if they ever share an origin. + const API = "https://parseo.mattiuzzi.com"; + const state = {family: "", version: "", schema: null, values: {}}; -```{ojs} -// ── parsEO API base ── -const API = "https://parseo.mattiuzzi.com"; - -// ── State ── -const families = view(Inputs.select(new Map(), {label: "Family", value: ""})); -const versions = Mutable([]); -const currentVersion = Mutable(""); -const fieldValues = Mutable({}); -const selectedFamily = Mutable(""); -const familySchemas = Mutable({}); - -// ── Load families on startup ── -const familiesData = await fetch(API + "/api/families") - .then(r => r.ok ? r.json() : {families: []}) - .catch(() => ({families: []})); - -// Update family selector -families.value = new Map( - familiesData.families.sort().map(f => [f, f]) -); -families.dispatchEvent(new Event("input")); - -// ── When family changes, load versions ── -async function onFamilyChange(family) { - if (!family) return; - selectedFamily.value = family; - const data = await fetch(`${API}/api/families/${family}/versions`) - .then(r => r.ok ? r.json() : {versions: []}) - .catch(() => ({versions: []})); - versions.value = data.versions || []; - currentVersion.value = versions.value.length ? versions.value[versions.value.length - 1] : ""; - - // Load schema for latest version - if (currentVersion.value) { - const schema = await fetch(`${API}/api/families/${family}/schemas/${currentVersion.value}`) - .then(r => r.ok ? r.json() : null) - .catch(() => null); - if (schema) { - familySchemas.value = schema; - // Auto-fill single-enum fields - const fv = {}; - for (const [name, def] of Object.entries(schema.fields || {})) { - if (def.enum && def.enum.length === 1) fv[name] = def.enum[0]; - if (name === "programme") fv[name] = "CLMS"; - if (name === "extension") fv[name] = "tif"; + async function getJSON(path, opts) { + const r = await fetch(API + path, opts); + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + } + + function el(tag, style, text) { + const n = document.createElement(tag); + if (style) n.style.cssText = style; + if (text != null) n.textContent = text; + return n; + } + + const CARD = "background:var(--bg2,#1a1d27);border:1px solid var(--border,#2e3148);border-radius:8px;padding:16px"; + const LABEL = "font-size:11px;color:var(--muted,#8b8fa3);text-transform:uppercase;letter-spacing:.3px"; + const FIELD = "width:100%;padding:6px 10px;background:var(--bg,#0f1117);border:1px solid var(--border,#2e3148);border-radius:6px;color:var(--text,#e4e6f0);font-size:13px;font-family:inherit"; + const BTN = "padding:6px 16px;background:var(--theme-primary,#5b8def);color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:13px"; + const MONO = "font-family:var(--mono,ui-monospace,monospace)"; + const ERR = "color:#e74c3c;font-size:13px"; + + const fail = (target, e) => + target.replaceChildren(el("div", ERR, "Could not reach the parsEO API (" + e.message + ").")); + + // ── layout ── + const root = el("div", "display:flex;gap:16px;flex-wrap:wrap;align-items:flex-start"); + const left = el("div", "flex:1 1 300px;min-width:280px;" + CARD); + const right = el("div", "flex:2 1 420px;min-width:320px;display:flex;flex-direction:column;gap:16px"); + root.append(left, right); + + const familySel = el("select", FIELD + ";margin-bottom:10px"); + const versionSel = el("select", FIELD + ";margin-bottom:10px"); + const fields = el("div"); + left.append( + el("div", LABEL + ";margin-bottom:3px", "Family"), familySel, + el("div", LABEL + ";margin-bottom:3px", "Version"), versionSel, + el("hr", "border:0;border-top:1px solid var(--border,#2e3148);margin:12px 0"), + fields + ); + + const previewCard = el("div", CARD); + const preview = el("div", MONO + ";font-size:15px;word-break:break-all;color:var(--text,#e4e6f0);min-height:24px", "—"); + const examples = el("div"); + previewCard.append(el("div", LABEL + ";margin-bottom:6px", "Assembled filename"), preview, examples); + + const parseCard = el("div", CARD + ";flex:1 1 300px"); + const parseIn = el("input", FIELD + ";" + MONO + ";font-size:12px;margin-bottom:8px"); + const parseBtn = el("button", BTN, "Parse"); + const parseOut = el("div", "margin-top:8px"); + parseIn.placeholder = "VPP_2017_S2_T32TPR-03035-010m_V101_s1_AMPL.tif"; + parseBtn.type = "button"; + parseCard.append(el("div", LABEL + ";margin-bottom:8px", "Parse a filename"), parseIn, parseBtn, parseOut); + + const validCard = el("div", CARD + ";flex:1 1 300px"); + const validIn = el("textarea", FIELD + ";" + MONO + ";font-size:11px;min-height:120px;resize:vertical;margin-bottom:8px"); + const validBtn = el("button", BTN, "Validate"); + const validOut = el("div", "margin-top:8px"); + validIn.placeholder = '{"$schema":"...","schema_id":"copernicus:clms:...", ...}'; + validBtn.type = "button"; + validCard.append(el("div", LABEL + ";margin-bottom:8px", "Validate a schema"), validIn, validBtn, validOut); + + const tools = el("div", "display:flex;gap:16px;flex-wrap:wrap"); + tools.append(parseCard, validCard); + right.append(previewCard, tools); + + // ── field editors ── + // A field is either a short enum (chips) or free text (input). `oneOf` fields + // carry their patterns in the branches rather than at the top level. + const hintFor = (def) => + def.description ? def.description + : def.pattern ? "regex: " + def.pattern + : def.oneOf ? def.oneOf.map(o => o.pattern).filter(Boolean).join(" | ") + : ""; + + function renderFields() { + fields.replaceChildren(); + if (!state.schema) { + fields.append(el("div", "color:var(--muted,#8b8fa3);font-size:13px", "Select a family first.")); + return; + } + for (const [name, def] of Object.entries(state.schema.fields || {})) { + const cur = state.values[name] || ""; + const row = el("div", "margin:8px 0"); + const head = el("div", LABEL + ";margin-bottom:3px"); + head.append(name.replace(/_/g, " ")); + if (cur) head.append(el("span", "color:var(--theme-primary,#5b8def);font-weight:600", " · " + cur)); + row.append(head); + + if (def.enum && def.enum.length <= 30) { + const wrap = el("div", "display:flex;flex-wrap:wrap;gap:4px"); + for (const v of def.enum) { + const on = v === cur; + const b = el("button", + "padding:3px 10px;border-radius:14px;cursor:pointer;font-size:12px;font-family:inherit;" + + "border:1px solid var(--border,#2e3148);" + + "background:" + (on ? "var(--theme-primary,#5b8def)" : "var(--bg2,#242736)") + ";" + + "color:" + (on ? "#fff" : "var(--text,#e4e6f0)"), v); + b.type = "button"; + b.onclick = () => setValue(name, on ? "" : v); // clicking the active chip clears it + wrap.append(b); + } + row.append(wrap); + } else { + const i = el("input", FIELD); + i.value = cur; + i.placeholder = hintFor(def); + // Re-rendering on every keystroke would steal focus, so text fields + // update the preview only. + i.oninput = () => setValue(name, i.value, false); + row.append(i); } - fieldValues.value = fv; + fields.append(row); } } -} -// ── Version selector ── -const versionPicker = versions.value.length > 0 - ? Inputs.select(versions.value, {label: "Version", value: currentVersion.value}) - : html`No versions`; - -if (versionPicker.tagName === "SELECT") { - versionPicker.addEventListener("input", async () => { - currentVersion.value = versionPicker.value; - const schema = await fetch(`${API}/api/families/${selectedFamily.value}/schemas/${currentVersion.value}`) - .then(r => r.ok ? r.json() : null) - .catch(() => null); - if (schema) familySchemas.value = schema; - }); -} + function setValue(name, v, rerender = true) { + if (v === "") delete state.values[name]; + else state.values[name] = v; + if (rerender) renderFields(); + refreshPreview(); + } -// ── Assemble filename ── -async function assemble(fields) { - if (!fields || Object.keys(fields).length === 0) return ""; - const data = await fetch(API + "/api/assemble", { - method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify({family: selectedFamily.value, version: currentVersion.value, fields}) - }).then(r => r.ok ? r.json() : null).catch(() => null); - return data?.filename || ""; -} + // ── preview ── + // Typing races the network, so a stale response must not overwrite a newer one. + let previewSeq = 0; + async function refreshPreview() { + const mine = ++previewSeq; + if (!state.family || !state.version || Object.keys(state.values).length === 0) { + preview.textContent = "—"; + return; + } + let name = "—"; + try { + const d = await getJSON("/api/assemble", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({family: state.family, version: state.version, fields: state.values}) + }); + name = d.filename || "—"; + } catch (e) { + // A half-filled form is the normal state while building and the API + // rejects it — that is a prompt to keep going, not an error to shout about. + } + if (mine === previewSeq) preview.textContent = name; + } -// ── Render field chips ── -function renderField(name, def, values, onSelect) { - const label = name.replace(/_/g, " "); - const current = values[name] || ""; - let el; - - if (def.enum && def.enum.length <= 30) { - // Chips mode - el = html`
-
- ${label}${current ? html` · ${current}` : ""} -
-
- ${def.enum.map(v => html``)} -
-
`; - } else { - // Input mode - el = html`
- - onSelect(name, e.target.value)}> -
`; + function renderExamples() { + examples.replaceChildren(); + const ex = (state.schema && state.schema.examples) || []; + if (!ex.length) return; + examples.append(el("div", LABEL + ";margin:12px 0 4px", "Examples")); + for (const x of ex.slice(0, 3)) { + examples.append(el("div", MONO + ";font-size:12px;color:var(--muted,#8b8fa3);padding:2px 0", x)); + } } - return el; -} -// ── Parse tab ── -async function doParse(fn) { - if (!fn) return "Enter a filename."; - const data = await fetch(API + "/api/parse", { - method: "POST", headers: {"Content-Type": "application/json"}, - body: JSON.stringify({filename: fn}) - }).then(r => r.ok ? r.json() : null).catch(() => null); - if (!data?.parsed) return "Parse failed."; - return html`
${JSON.stringify(data.parsed, null, 2)}
`; -} + // ── loaders ── + async function loadSchema() { + state.schema = null; + state.values = {}; + renderFields(); + renderExamples(); + preview.textContent = "—"; + if (!state.family || !state.version) return; + try { + state.schema = await getJSON( + "/api/families/" + encodeURIComponent(state.family) + + "/schemas/" + encodeURIComponent(state.version)); + } catch (e) { + fail(fields, e); + return; + } + // A single-value enum is fixed by the schema, so prefill it. + for (const [name, def] of Object.entries(state.schema.fields || {})) { + if (def.enum && def.enum.length === 1) state.values[name] = def.enum[0]; + } + renderFields(); + renderExamples(); + refreshPreview(); + } -// ── Validate tab ── -async function doValidate(json) { - let schema; - try { schema = JSON.parse(json); } catch(e) { return html`
Invalid JSON: ${e.message}
`; } - const data = await fetch(API + "/api/validate", { - method: "POST", headers: {"Content-Type": "application/json"}, - body: JSON.stringify({schema}) - }).then(r => r.ok ? r.json() : null).catch(() => null); - if (!data) return "Validation failed."; - return html`
- ${data.valid - ? html`
\u2713 Valid (${data.errors.length} errors, ${data.warnings.length} warnings)
` - : html`
\u2717 Invalid
`} - ${data.errors.map(e => html`
\u00b7 ${e}
`)} - ${data.warnings.map(w => html`
\u00b7 ${w}
`)} -
`; + async function loadVersions() { + versionSel.replaceChildren(); + state.version = ""; + if (!state.family) return loadSchema(); + let d; + try { + d = await getJSON("/api/families/" + encodeURIComponent(state.family) + "/versions"); + } catch (e) { + fail(fields, e); + return; + } + // The API returns objects ({version, status, file}), not bare strings. + const vs = (d.versions || []).map(v => (typeof v === "string" ? v : v.version)); + for (const v of vs) versionSel.append(new Option(v, v)); + state.version = vs.length ? vs[vs.length - 1] : ""; + versionSel.value = state.version; + await loadSchema(); + } + + familySel.onchange = () => { state.family = familySel.value; loadVersions(); }; + versionSel.onchange = () => { state.version = versionSel.value; loadSchema(); }; + + parseBtn.onclick = async () => { + const fn = parseIn.value.trim(); + if (!fn) { + parseOut.replaceChildren(el("div", "color:var(--muted,#8b8fa3);font-size:13px", "Enter a filename.")); + return; + } + try { + const d = await getJSON("/api/parse", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({filename: fn}) + }); + parseOut.replaceChildren( + el("pre", "font-size:12px;line-height:1.5;white-space:pre-wrap;margin:0", JSON.stringify(d.parsed, null, 2))); + } catch (e) { + parseOut.replaceChildren(el("div", ERR, "Parse failed (" + e.message + ").")); + } + }; + parseIn.onkeydown = (ev) => { if (ev.key === "Enter") parseBtn.click(); }; + + validBtn.onclick = async () => { + let schema; + try { + schema = JSON.parse(validIn.value); + } catch (e) { + validOut.replaceChildren(el("div", ERR, "Invalid JSON: " + e.message)); + return; + } + let d; + try { + d = await getJSON("/api/validate", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({schema}) + }); + } catch (e) { + validOut.replaceChildren(el("div", ERR, "Validation failed (" + e.message + ").")); + return; + } + const errors = d.errors || [], warnings = d.warnings || []; + const out = el("div"); + out.append(d.valid + ? el("div", "color:#00aa00;font-weight:600", "✓ Valid (" + errors.length + " errors, " + warnings.length + " warnings)") + : el("div", ERR + ";font-weight:600", "✗ Invalid")); + for (const e of errors) out.append(el("div", ERR + ";padding:4px 0", "· " + e)); + for (const w of warnings) out.append(el("div", "color:#e67e22;font-size:12px;padding:4px 0", "· " + w)); + validOut.replaceChildren(out); + }; + + // ── boot ── + renderFields(); + familySel.append(new Option("— select —", "")); + getJSON("/api/families") + .then(d => { + for (const f of (d.families || []).slice().sort()) familySel.append(new Option(f, f)); + }) + .catch(e => fail(left, e)); + + return root; } ``` - -## Filename Builder - -
-
-
- ${families} - ${versionPicker} -
-
- ${familySchemas.value?.fields ? Object.entries(familySchemas.value.fields).map(([name, def]) => { - if (name === "extension") return null; - return renderField(name, def, fieldValues.value, (n, v) => { - fieldValues.value = {...fieldValues.value, [n]: v}; - assemble(fieldValues.value).then(fn => { - const el = document.getElementById("filename-preview"); - if (el) el.textContent = fn || "\u2014"; - }); - }); - }) : html`Select a family first.`} -
-
-
-
-
-
Assembled Filename
-
\u2014
- ${familySchemas.value?.examples ? html` -
Examples
- ${familySchemas.value.examples.slice(0,3).map(ex => html`
${ex}
`)} - ` : ""} -
- -
-
-
Parse a Filename
- - -
-
- -
-
Validate a Schema
- - -
-
-
-
-
-``` \ No newline at end of file