diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f8ddcf..56fd2cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,16 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C ## [Unreleased] +## [0.38.3] — 2026-05-06 + +### Changed +- **Admin / Tables**: registry table now shows Source (bucket/table), Schedule, Folder, Registered by/at, and a sync-error warning icon per row. The page widens to ~1600px to accommodate. + +### Fixed +- **Admin / Tables**: long table descriptions no longer push the row's Edit / Manage access / Delete buttons off-screen. The Description column is now clamped to 2 lines with the full text available on hover and in the Edit modal. +- **Admin / Tables**: descriptions stored with shell-quoting backslash-escapes (`Don\'t`, `\n`) now render correctly. The same normalization also runs at register/update time so newly-saved descriptions are never corrupted. +- **Admin / Tables**: `scripts/fix_description_escapes.py` cleans up already-corrupted descriptions in `table_registry` (run with `--dry-run` first, then `--apply`). + ## [0.38.2] — 2026-05-06 ### Fixed diff --git a/app/api/admin.py b/app/api/admin.py index 4469299..a697197 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -88,6 +88,31 @@ def _validate_url_not_private(url: str, field_name: str = "url") -> None: ) +def _unescape_shell_quoting(s: str | None) -> str | None: + """Defensive normalization for descriptions arriving via shell-quoting tooling. + + Some operators register tables with bash/curl invocations whose quoting + injects literal backslash escapes into the payload (e.g. ``Don\\'t`` or + embedded ``\\n`` instead of real newlines). The backend would otherwise + persist those bytes verbatim and the UI would render them verbatim too. + Mirrored in JS as ``unescapeShellQuoting`` in + ``app/web/templates/admin_tables.html`` for already-stored rows. + """ + if not s: + return s + # Order matters: protect real backslashes first. + SENTINEL = "\x00" + return ( + s.replace("\\\\", SENTINEL) + .replace("\\n", "\n") + .replace("\\r", "\r") + .replace("\\t", "\t") + .replace("\\'", "'") + .replace('\\"', '"') + .replace(SENTINEL, "\\") + ) + + def _normalize_primary_key(v): """Coerce a string primary_key to ``[v]`` for backward compatibility. @@ -1273,6 +1298,13 @@ class RegisterTableRequest(BaseModel): def _coerce_primary_key(cls, v): return _normalize_primary_key(v) + @field_validator("description", mode="before") + @classmethod + def _normalize_description(cls, v): + # Defensive normalization for descriptions arriving via shell-quoting + # tooling that injects literal backslash escapes (e.g. `Don\'t`, `\n`). + return _unescape_shell_quoting(v) + @field_validator("source_type", mode="before") @classmethod def _validate_source_type(cls, v): @@ -1676,6 +1708,13 @@ class UpdateTableRequest(BaseModel): def _coerce_primary_key(cls, v): return _normalize_primary_key(v) + @field_validator("description", mode="before") + @classmethod + def _normalize_description(cls, v): + # Defensive normalization for descriptions arriving via shell-quoting + # tooling that injects literal backslash escapes (e.g. `Don\'t`, `\n`). + return _unescape_shell_quoting(v) + # Duplicated from RegisterTableRequest — Pydantic v2 validators don't # inherit cleanly across unrelated BaseModel classes; a shared mixin # would be overkill for two fields. diff --git a/app/web/templates/admin_tables.html b/app/web/templates/admin_tables.html index 082c635..2870489 100644 --- a/app/web/templates/admin_tables.html +++ b/app/web/templates/admin_tables.html @@ -114,7 +114,7 @@ /* ── Page Title ── */ .page-title { - max-width: 1000px; + max-width: 1600px; margin: 0 auto; padding: 32px 24px 24px; } @@ -133,7 +133,7 @@ /* ── Content Layout ── */ .content { - max-width: 1000px; + max-width: 1600px; margin: 0 auto; padding: 0 24px 32px; display: flex; @@ -473,6 +473,17 @@ .registry-table { width: 100%; border-collapse: collapse; + table-layout: fixed; + } + + .registry-table .col-description { + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow-wrap: anywhere; + line-height: 1.4; + color: var(--text-secondary); } .registry-table th { @@ -505,15 +516,105 @@ .registry-table .col-id { font-family: var(--font-mono); font-size: 12px; - max-width: 280px; + max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .registry-table .col-actions { - width: 80px; - text-align: right; + width: 120px; + min-width: 120px; + white-space: nowrap; + vertical-align: top; + } + + /* ── Registry table — wide layout ── */ + .registry-table .col-mode { + width: 100px; + } + + .registry-table .col-source { + width: 200px; + font-family: var(--font-mono); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-secondary); + } + + .registry-table .col-pk { + width: 100px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .registry-table .col-schedule { + width: 100px; + font-size: 12px; + color: var(--text-secondary); + } + + .registry-table .col-folder { + width: 120px; + } + + .registry-table .col-registered { + width: 160px; + font-size: 11px; + line-height: 1.4; + overflow: hidden; + } + + .registry-table .col-registered .registered-by { + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .registry-table .col-registered .registered-at { + color: var(--text-secondary); + } + + .registry-table .col-status { + width: 40px; + text-align: center; + } + + .mode-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + } + + .mode-badge.mode-local { + background: var(--success-light); + color: var(--success); + } + + .mode-badge.mode-remote { + background: var(--primary-light); + color: var(--primary); + } + + .mode-badge.mode-materialized { + background: rgba(139, 92, 246, 0.1); + color: #8B5CF6; + } + + .folder-badge { + display: inline-block; + padding: 1px 6px; + border-radius: 3px; + background: var(--background); + border: 1px solid var(--border); + font-size: 11px; + color: var(--text-secondary); } /* ── Modal overlay ── */ @@ -1544,6 +1645,24 @@ return div.innerHTML; } + // Defensive normalization for descriptions registered via shell-quoting + // tooling that injected literal backslash escapes (e.g. `Don\'t`, `\n`). + // Mirrors _unescape_shell_quoting in app/api/admin.py — applied at render + // time so already-stored corrupt rows still display readably. + function unescapeShellQuoting(s) { + if (!s) return s; + // Order matters: protect real backslashes via NUL sentinel first, + // unescape the well-known sequences, then restore real backslashes. + return s + .replace(/\\\\/g, ' ') + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\r') + .replace(/\\t/g, '\t') + .replace(/\\'/g, "'") + .replace(/\\"/g, '"') + .replace(/ /g, '\\'); + } + // C3: removed dead Discovery panel JS. The global Discovery card + // its #discoverBtn / #discoveryResults DOM hooks were removed when // the per-tab UI landed; per-tab Discover/List datalist helpers live @@ -2587,6 +2706,12 @@ }); } + function renderModeBadge(mode) { + var m = (mode || 'local').toLowerCase(); + var cls = 'mode-badge mode-' + (['local','remote','materialized'].indexOf(m) >= 0 ? m : 'local'); + return '' + escapeHtml(m) + ''; + } + function renderRegistryListing(target, tables) { if (!target) return; if (tables.length === 0) { @@ -2595,19 +2720,69 @@ } var html = '
| Table ID | '; - html += 'Mode | '; - html += 'Primary Key | '; + html += 'Table ID | '; + html += 'Mode | '; + html += 'Source | '; + html += 'Primary Key | '; + html += 'Schedule | '; + html += 'Folder | '; html += 'Description | '; + html += 'Registered | '; + html += ''; html += ' | Actions | '; html += '||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ' + escapeHtml(table.id) + ' | '; - html += '' + escapeHtml(table.query_mode || 'local') + ' | '; - html += '' + escapeHtml((table.primary_key || []).join(', ') || '-') + ' | '; - html += '' + escapeHtml(table.description || '-') + ' | '; - html += ''; + html += ' | ' + renderModeBadge(table.query_mode) + ' | '; + + // Source: bucket / source_table; em-dash when both empty (custom-SQL row). + var bucket = table.bucket || ''; + var srcTable = table.source_table || ''; + var sourceText = ''; + if (bucket && srcTable) { + sourceText = bucket + ' / ' + srcTable; + } else if (bucket || srcTable) { + sourceText = bucket || srcTable; + } + var sourceCell = sourceText ? escapeHtml(sourceText) : '—'; + html += '' + sourceCell + ' | '; + + html += '' + escapeHtml((table.primary_key || []).join(', ') || '—') + ' | '; + html += '' + escapeHtml(table.sync_schedule || '—') + ' | '; + + // Folder badge — '—' when null/empty. + if (table.folder) { + html += '' + escapeHtml(table.folder) + ' | '; + } else { + html += '— | '; + } + + var desc = unescapeShellQuoting(table.description || ''); + html += '' + escapeHtml(desc || '—') + ' | '; + + // Registered: stacked email + date (first 10 chars of ISO timestamp). + var regBy = table.registered_by || ''; + var regByDisplay = regBy; + if (regBy.length > 24 && regBy.indexOf('@') > 0) { + regByDisplay = regBy.split('@')[0]; + } + var regAt = table.registered_at ? String(table.registered_at).slice(0, 10) : ''; + html += '';
+ html += ' ' + (regByDisplay ? escapeHtml(regByDisplay) : '—') + ' ';
+ html += '' + escapeHtml(regAt || '') + ' ';
+ html += ' | ';
+
+ // Status: warning icon when the last sync errored.
+ html += ''; + if (table.last_sync_error) { + html += ''; + html += ''; + html += ''; + } + html += ' | '; + + html += '';
html += '';
@@ -2618,7 +2793,7 @@
html += '';
- html += ' |