Merge pull request #198 from keboola/zs/admin-tables-description-clamp
fix(admin/tables): keep row Actions reachable + sanitize description escapes
This commit is contained in:
commit
9649f42b99
6 changed files with 499 additions and 14 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -10,6 +10,16 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.38.2] — 2026-05-06
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
||||||
|
|
@ -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):
|
def _normalize_primary_key(v):
|
||||||
"""Coerce a string primary_key to ``[v]`` for backward compatibility.
|
"""Coerce a string primary_key to ``[v]`` for backward compatibility.
|
||||||
|
|
||||||
|
|
@ -1273,6 +1298,13 @@ class RegisterTableRequest(BaseModel):
|
||||||
def _coerce_primary_key(cls, v):
|
def _coerce_primary_key(cls, v):
|
||||||
return _normalize_primary_key(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")
|
@field_validator("source_type", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _validate_source_type(cls, v):
|
def _validate_source_type(cls, v):
|
||||||
|
|
@ -1676,6 +1708,13 @@ class UpdateTableRequest(BaseModel):
|
||||||
def _coerce_primary_key(cls, v):
|
def _coerce_primary_key(cls, v):
|
||||||
return _normalize_primary_key(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
|
# Duplicated from RegisterTableRequest — Pydantic v2 validators don't
|
||||||
# inherit cleanly across unrelated BaseModel classes; a shared mixin
|
# inherit cleanly across unrelated BaseModel classes; a shared mixin
|
||||||
# would be overkill for two fields.
|
# would be overkill for two fields.
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@
|
||||||
|
|
||||||
/* ── Page Title ── */
|
/* ── Page Title ── */
|
||||||
.page-title {
|
.page-title {
|
||||||
max-width: 1000px;
|
max-width: 1600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 32px 24px 24px;
|
padding: 32px 24px 24px;
|
||||||
}
|
}
|
||||||
|
|
@ -133,7 +133,7 @@
|
||||||
|
|
||||||
/* ── Content Layout ── */
|
/* ── Content Layout ── */
|
||||||
.content {
|
.content {
|
||||||
max-width: 1000px;
|
max-width: 1600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 24px 32px;
|
padding: 0 24px 32px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -473,6 +473,17 @@
|
||||||
.registry-table {
|
.registry-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
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 {
|
.registry-table th {
|
||||||
|
|
@ -505,15 +516,105 @@
|
||||||
.registry-table .col-id {
|
.registry-table .col-id {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
max-width: 280px;
|
max-width: 220px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.registry-table .col-actions {
|
.registry-table .col-actions {
|
||||||
width: 80px;
|
width: 120px;
|
||||||
text-align: right;
|
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 ── */
|
/* ── Modal overlay ── */
|
||||||
|
|
@ -1544,6 +1645,24 @@
|
||||||
return div.innerHTML;
|
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, ' | ||||||