* release: 0.53.0 — Tier B trackers + admin UI bugfix Closes #259 (init resume sentinel), #260 (startup parquet-lock sweep), #261 (materialized schema uses local parquet, not BQ), #265 (admin tables apostrophe → HTML-entity escape). Tracker notes: #262 closed as obsolete (pre-empted by 0.51.0 changes), #266 left open pending UX clarification. * fix(init): move resume sentinel from .agnes/ to .claude/ The clean-install integration test (test_clean_install_integration.py) forbids creating .agnes/ in the workspace root via its forbidden_unconditional list — that path is reserved for ~/.agnes/ in the user's HOME (marketplace clone, CA bundle). .claude/ is already created by agnes init for settings.json + hooks, so dropping init-complete next to those keeps the resume sentinel consistent with the rest of Claude Code's workspace surface and lets the clean-install assertions pass. Issue #259. * docs(changelog): point #259 entry at new .claude/init-complete path Follows the sentinel move from .agnes/ → .claude/ to keep the changelog in sync with what 0.53.0 actually ships.
3464 lines
161 KiB
HTML
3464 lines
161 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Table Management - {{ config.INSTANCE_NAME }}</title>
|
||
{% if not config.THEME_FONT_URL %}
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||
{% endif %}
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='style-custom.css') }}">
|
||
<style>
|
||
:root {
|
||
/* Colors - Design System */
|
||
--primary: #0073D1;
|
||
--primary-light: rgba(0, 115, 209, 0.1);
|
||
--text-primary: #1A253C;
|
||
--text-secondary: #6B7280;
|
||
--background: #F5F7FA;
|
||
--surface: #FFFFFF;
|
||
--border: #E5E7EB;
|
||
--border-light: #F3F4F6;
|
||
--success: #10B77F;
|
||
--success-light: rgba(16, 183, 127, 0.1);
|
||
--warning: #F59F0A;
|
||
--error: #EA580C;
|
||
--error-light: rgba(234, 88, 12, 0.1);
|
||
|
||
/* Typography */
|
||
--font-primary: 'Inter', system-ui, sans-serif;
|
||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||
|
||
/* Shadows */
|
||
--shadow-sm: rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
|
||
--shadow-md: rgba(0, 0, 0, 0.1) 0px 4px 6px -1px;
|
||
}
|
||
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family: var(--font-primary);
|
||
font-size: 14px;
|
||
color: var(--text-primary);
|
||
background: var(--background);
|
||
line-height: 1.5;
|
||
}
|
||
|
||
/* ── Header (dashboard-style) ── */
|
||
.header {
|
||
background: var(--surface);
|
||
border-bottom: 1px solid var(--border);
|
||
padding: 0 32px;
|
||
height: 72px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 100;
|
||
}
|
||
|
||
.header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
}
|
||
|
||
.header-back {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 6px;
|
||
color: var(--text-secondary);
|
||
text-decoration: none;
|
||
transition: all 0.15s ease;
|
||
}
|
||
|
||
.header-back:hover {
|
||
background: var(--border-light);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.header-logo-group {
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
gap: 2px;
|
||
}
|
||
|
||
.header-logo svg {
|
||
display: block;
|
||
}
|
||
|
||
.header-subtitle {
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
color: var(--text-secondary);
|
||
letter-spacing: 0.4px;
|
||
text-transform: uppercase;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.header-right {
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
/* ── Page Title ── */
|
||
.page-title {
|
||
max-width: 1600px;
|
||
margin: 0 auto;
|
||
padding: 32px 24px 24px;
|
||
}
|
||
|
||
.page-title h1 {
|
||
font-size: 24px;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.page-title p {
|
||
font-size: 14px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
/* ── Content Layout ── */
|
||
.content {
|
||
max-width: 1600px;
|
||
margin: 0 auto;
|
||
padding: 0 24px 32px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 24px;
|
||
}
|
||
|
||
/* ── Panel (shared card style) ── */
|
||
.panel {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.panel-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 20px 24px;
|
||
border-bottom: 1px solid var(--border-light);
|
||
}
|
||
|
||
.panel-header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.panel-header-icon {
|
||
width: 36px;
|
||
height: 36px;
|
||
border-radius: 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
background: var(--primary-light);
|
||
}
|
||
|
||
.panel-title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.panel-subtitle {
|
||
font-size: 13px;
|
||
color: var(--text-secondary);
|
||
margin-top: 1px;
|
||
}
|
||
|
||
.panel-body {
|
||
padding: 20px 24px;
|
||
}
|
||
|
||
.panel-body-empty {
|
||
padding: 40px 24px;
|
||
text-align: center;
|
||
color: var(--text-secondary);
|
||
font-size: 13px;
|
||
}
|
||
|
||
/* ── Buttons ── */
|
||
.btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 8px 16px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-family: var(--font-primary);
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.15s ease;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.btn-primary {
|
||
background: var(--primary);
|
||
color: white;
|
||
}
|
||
|
||
.btn-primary:hover {
|
||
background: #005FA8;
|
||
}
|
||
|
||
.btn-primary:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.btn-secondary {
|
||
background: var(--border-light);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.btn-secondary:hover {
|
||
background: var(--border);
|
||
}
|
||
|
||
.btn-danger {
|
||
background: var(--error-light);
|
||
color: var(--error);
|
||
}
|
||
|
||
.btn-danger:hover {
|
||
background: rgba(234, 88, 12, 0.2);
|
||
}
|
||
|
||
.btn-sm {
|
||
padding: 5px 10px;
|
||
font-size: 12px;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.btn-icon {
|
||
width: 28px;
|
||
height: 28px;
|
||
padding: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 6px;
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
color: var(--text-secondary);
|
||
transition: all 0.15s ease;
|
||
}
|
||
|
||
.btn-icon:hover {
|
||
background: var(--border-light);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.btn-icon.danger:hover {
|
||
background: var(--error-light);
|
||
color: var(--error);
|
||
}
|
||
|
||
/* ── Badges ── */
|
||
.badge {
|
||
flex-shrink: 0;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
border-radius: 6px;
|
||
padding: 3px 8px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.badge-registered {
|
||
color: #065F46;
|
||
background: #D1FAE5;
|
||
}
|
||
|
||
.badge-available {
|
||
color: var(--primary);
|
||
background: var(--primary-light);
|
||
}
|
||
|
||
/* ── Spinner ── */
|
||
.spinner {
|
||
display: inline-block;
|
||
width: 18px;
|
||
height: 18px;
|
||
border: 2px solid var(--border);
|
||
border-top-color: var(--primary);
|
||
border-radius: 50%;
|
||
animation: spin 0.6s linear infinite;
|
||
}
|
||
|
||
@keyframes spin {
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
.spinner-lg {
|
||
width: 32px;
|
||
height: 32px;
|
||
border-width: 3px;
|
||
}
|
||
|
||
/* ── Loading state ── */
|
||
.loading-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 40px 24px;
|
||
color: var(--text-secondary);
|
||
font-size: 13px;
|
||
}
|
||
|
||
/* ── Notification toast ── */
|
||
.toast {
|
||
position: fixed;
|
||
top: 84px;
|
||
right: 24px;
|
||
z-index: 200;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
box-shadow: var(--shadow-md);
|
||
padding: 12px 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
font-size: 13px;
|
||
transform: translateX(120%);
|
||
transition: transform 0.3s ease;
|
||
max-width: 360px;
|
||
}
|
||
|
||
.toast.visible {
|
||
transform: translateX(0);
|
||
}
|
||
|
||
.toast-success {
|
||
border-left: 3px solid var(--success);
|
||
}
|
||
|
||
.toast-error {
|
||
border-left: 3px solid var(--error);
|
||
}
|
||
|
||
.toast-icon {
|
||
width: 20px;
|
||
height: 20px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* ── Bucket accordion ── */
|
||
.bucket-group {
|
||
border-top: 1px solid var(--border-light);
|
||
}
|
||
|
||
.bucket-group:first-child {
|
||
border-top: none;
|
||
}
|
||
|
||
.bucket-trigger {
|
||
width: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 12px 24px;
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
font-family: var(--font-primary);
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: var(--text-primary);
|
||
text-align: left;
|
||
transition: background 0.1s ease;
|
||
}
|
||
|
||
.bucket-trigger:hover {
|
||
background: var(--border-light);
|
||
}
|
||
|
||
.bucket-chevron {
|
||
width: 16px;
|
||
height: 16px;
|
||
color: var(--text-secondary);
|
||
transition: transform 0.2s ease;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.bucket-trigger.expanded .bucket-chevron {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
.bucket-count {
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
color: var(--text-secondary);
|
||
background: var(--border-light);
|
||
padding: 1px 7px;
|
||
border-radius: 9999px;
|
||
margin-left: auto;
|
||
}
|
||
|
||
.bucket-content {
|
||
display: none;
|
||
}
|
||
|
||
.bucket-content.expanded {
|
||
display: block;
|
||
}
|
||
|
||
/* ── Table row (discovery results) ── */
|
||
.table-item {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 10px 24px 10px 50px;
|
||
border-top: 1px solid var(--border-light);
|
||
gap: 12px;
|
||
transition: background 0.1s ease;
|
||
}
|
||
|
||
.table-item:hover {
|
||
background: rgba(243, 244, 246, 0.5);
|
||
}
|
||
|
||
.table-item-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.table-item-name {
|
||
font-weight: 500;
|
||
font-family: var(--font-mono);
|
||
font-size: 13px;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.table-item-meta {
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
margin-top: 1px;
|
||
display: flex;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.table-item-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* ── Registry table ── */
|
||
.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 {
|
||
text-align: left;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: var(--text-secondary);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.4px;
|
||
padding: 10px 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
background: var(--background);
|
||
}
|
||
|
||
.registry-table td {
|
||
padding: 12px 16px;
|
||
font-size: 13px;
|
||
border-bottom: 1px solid var(--border-light);
|
||
vertical-align: middle;
|
||
}
|
||
|
||
.registry-table tr:last-child td {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.registry-table tr:hover td {
|
||
background: rgba(243, 244, 246, 0.5);
|
||
}
|
||
|
||
.registry-table .col-id {
|
||
font-family: var(--font-mono);
|
||
font-size: 12px;
|
||
max-width: 220px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.registry-table .col-actions {
|
||
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 ── */
|
||
.modal-overlay {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
z-index: 1000;
|
||
padding: 40px 24px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.modal-overlay.active {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: center;
|
||
}
|
||
|
||
.modal {
|
||
max-width: 560px;
|
||
width: 100%;
|
||
background: var(--surface);
|
||
border-radius: 12px;
|
||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.modal-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 20px 24px;
|
||
border-bottom: 1px solid var(--border);
|
||
background: var(--background);
|
||
}
|
||
|
||
.modal-header h2 {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.modal-close {
|
||
width: 32px;
|
||
height: 32px;
|
||
border: none;
|
||
background: none;
|
||
cursor: pointer;
|
||
border-radius: 6px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--text-secondary);
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
.modal-close:hover {
|
||
background: var(--border-light);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.modal-body {
|
||
padding: 24px;
|
||
}
|
||
|
||
.modal-footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 10px;
|
||
padding: 16px 24px;
|
||
border-top: 1px solid var(--border);
|
||
background: var(--background);
|
||
}
|
||
|
||
/* ── Form ── */
|
||
.form-group {
|
||
margin-bottom: 18px;
|
||
}
|
||
|
||
.form-group:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.form-label {
|
||
display: block;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--text-primary);
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.form-label .optional {
|
||
font-weight: 400;
|
||
color: var(--text-secondary);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.form-input,
|
||
.form-select,
|
||
.form-textarea {
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
font-family: var(--font-primary);
|
||
font-size: 13px;
|
||
color: var(--text-primary);
|
||
background: var(--surface);
|
||
transition: border-color 0.15s;
|
||
}
|
||
|
||
.form-input:focus,
|
||
.form-select:focus,
|
||
.form-textarea:focus {
|
||
outline: none;
|
||
border-color: var(--primary);
|
||
box-shadow: 0 0 0 3px rgba(0, 115, 209, 0.1);
|
||
}
|
||
|
||
.form-input[readonly] {
|
||
background: var(--border-light);
|
||
color: var(--text-secondary);
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.form-textarea {
|
||
min-height: 80px;
|
||
resize: vertical;
|
||
}
|
||
|
||
.form-select {
|
||
cursor: pointer;
|
||
appearance: none;
|
||
background-image: url("data:image/svg+xml,%3Csvg width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||
background-repeat: no-repeat;
|
||
background-position: right 12px center;
|
||
padding-right: 32px;
|
||
}
|
||
|
||
.form-hint {
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
margin-top: 4px;
|
||
}
|
||
|
||
/* ── Footer ── */
|
||
.footer {
|
||
text-align: center;
|
||
padding: 24px;
|
||
color: var(--text-secondary);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.footer a {
|
||
color: var(--primary);
|
||
text-decoration: none;
|
||
}
|
||
|
||
.footer a:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
/* ── Responsive ── */
|
||
@media (max-width: 640px) {
|
||
.header {
|
||
padding: 0 16px;
|
||
}
|
||
|
||
.page-title {
|
||
padding: 24px 16px 16px;
|
||
}
|
||
|
||
.content {
|
||
padding: 0 16px 24px;
|
||
}
|
||
|
||
.panel-header {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 12px;
|
||
}
|
||
|
||
.table-item {
|
||
padding-left: 24px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.table-item-meta {
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.registry-table .col-id {
|
||
max-width: 160px;
|
||
}
|
||
|
||
.modal {
|
||
margin: 16px;
|
||
}
|
||
}
|
||
|
||
/* ── Tab nav (Phase D) ── */
|
||
.tab-nav {
|
||
display: flex;
|
||
gap: 4px;
|
||
border-bottom: 1px solid var(--border);
|
||
margin-bottom: 16px;
|
||
}
|
||
.tab {
|
||
padding: 8px 16px;
|
||
background: transparent;
|
||
border: 0;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
font-size: inherit;
|
||
color: var(--text-secondary);
|
||
}
|
||
.tab[aria-selected="true"] {
|
||
border-bottom: 2px solid var(--primary);
|
||
color: var(--text-primary);
|
||
font-weight: 600;
|
||
}
|
||
.tab-content {
|
||
padding: 16px 0;
|
||
}
|
||
.tab-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 16px;
|
||
}
|
||
.tab-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin-bottom: 16px;
|
||
}
|
||
</style>
|
||
{% include '_theme.html' %}
|
||
</head>
|
||
<body data-source-type="{{ data_source_type }}">
|
||
|
||
<!-- ═══════════════ HEADER ═══════════════ -->
|
||
{% include '_app_header.html' %}
|
||
|
||
<!-- ═══════════════ PAGE TITLE ═══════════════ -->
|
||
<div class="page-title">
|
||
<h1>Table Management</h1>
|
||
<p>Discover, register, and manage data tables from your source</p>
|
||
</div>
|
||
|
||
<!-- ═══════════════ CONTENT ═══════════════ -->
|
||
<div class="content">
|
||
|
||
<section id="cacheWarmupCard" class="card" style="margin-bottom: 20px;">
|
||
<header class="card-header" style="display: flex; justify-content: space-between; align-items: center;">
|
||
<h2>Cache freshness</h2>
|
||
<button class="btn btn-secondary" id="cacheWarmupRunBtn" onclick="cacheWarmupRun()">
|
||
Re-warm all
|
||
</button>
|
||
</header>
|
||
<div class="card-body">
|
||
<div id="cacheWarmupProgress" style="margin-bottom: 8px;">
|
||
<span id="cacheWarmupSummary">Loading…</span>
|
||
</div>
|
||
<progress id="cacheWarmupBar" max="100" value="0" style="width: 100%; display: none;"></progress>
|
||
<details style="margin-top: 8px;">
|
||
<summary style="cursor: pointer; user-select: none;">Show log</summary>
|
||
<pre id="cacheWarmupLog" style="background: #0a0a0a; color: #dcdcdc; font-family: ui-monospace, Menlo, monospace; font-size: 12px; padding: 8px; max-height: 240px; overflow-y: auto; margin-top: 8px; border-radius: 4px;"></pre>
|
||
</details>
|
||
</div>
|
||
</section>
|
||
|
||
{# Phase D: tab-split scaffold. Per-connector tabs (BigQuery /
|
||
Keboola / Jira) replace the single mixed form. Each tab has its
|
||
own Register button + listing div + (later) form modals. The
|
||
initial active tab matches data_source.type from instance.yaml;
|
||
the operator can still switch tabs to manage a secondary source.
|
||
|
||
Phase E moves the BQ form into #tab-content-bigquery; Phase F
|
||
builds the Keboola form inside #tab-content-keboola. For now
|
||
the existing Jinja-branched panels below stay in place. #}
|
||
{% set initial_tab = data_source_type if data_source_type in ['bigquery', 'keboola', 'jira'] else 'keboola' %}
|
||
|
||
<nav class="tab-nav" role="tablist">
|
||
<button data-tab="bigquery" aria-selected="{{ 'true' if initial_tab == 'bigquery' else 'false' }}" class="tab" onclick="switchTab('bigquery')">BigQuery</button>
|
||
<button data-tab="keboola" aria-selected="{{ 'true' if initial_tab == 'keboola' else 'false' }}" class="tab" onclick="switchTab('keboola')">Keboola</button>
|
||
<button data-tab="jira" aria-selected="{{ 'true' if initial_tab == 'jira' else 'false' }}" class="tab" onclick="switchTab('jira')">Jira</button>
|
||
</nav>
|
||
|
||
<section id="tab-content-bigquery" class="tab-content"
|
||
style="display: {% if initial_tab == 'bigquery' %}block{% else %}none{% endif %};">
|
||
<div class="tab-actions">
|
||
<button id="bqRegisterBtn" class="btn btn-primary"
|
||
onclick="openRegisterModal('bigquery')">Register BigQuery table</button>
|
||
</div>
|
||
<div id="bqTableListing"></div>
|
||
|
||
<!-- ── BigQuery Register Modal (Phase E relocation) ── -->
|
||
<div class="modal-overlay" id="registerBqModal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<h2>Register BigQuery Table</h2>
|
||
<button class="modal-close" onclick="closeRegisterBqModal()">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
{# Two orthogonal questions: (1) live vs synced, (2) when synced,
|
||
whole table vs custom SQL. Visibility classes:
|
||
bq-access-live — only when accessMode='live'
|
||
bq-access-synced — only when accessMode='synced'
|
||
bq-source-table — only when accessMode='live' OR
|
||
(accessMode='synced' AND syncMode='whole')
|
||
bq-source-custom — only when accessMode='synced' AND syncMode='custom'
|
||
Backend payload: live → query_mode='remote'; synced/whole →
|
||
query_mode='materialized' with auto-built SELECT *; synced/custom
|
||
→ query_mode='materialized' with admin SQL. Server auto-detects
|
||
BASE TABLE vs VIEW at register time, so the UI doesn't ask. #}
|
||
<div class="form-group">
|
||
<label class="form-label">How should analysts access this data?</label>
|
||
<div class="bq-access-radio-group" style="display:flex; gap:12px; margin-top:6px;">
|
||
<label style="flex:1; padding:12px; border:1px solid var(--border); border-radius:8px; cursor:pointer;">
|
||
<input type="radio" name="bqAccessMode" value="live" checked onchange="onBqAccessModeChange()">
|
||
<strong>Live from BigQuery</strong>
|
||
<div style="font-size:12px; color:var(--text-secondary); margin-top:4px;">
|
||
Each analyst query goes straight to BQ. Always current.
|
||
Latency ≈ seconds; 0 disk on the analyst machine; cost =
|
||
bytes scanned per query. Best for huge tables or when
|
||
freshness matters.
|
||
</div>
|
||
</label>
|
||
<label style="flex:1; padding:12px; border:1px solid var(--border); border-radius:8px; cursor:pointer;">
|
||
<input type="radio" name="bqAccessMode" value="synced" onchange="onBqAccessModeChange()">
|
||
<strong>Synced locally</strong>
|
||
<div style="font-size:12px; color:var(--text-secondary); margin-top:4px;">
|
||
Agnes runs a SELECT on a schedule and ships a parquet
|
||
to analysts. Analyst-side latency <100 ms; disk =
|
||
snapshot size. Best when analysts hit the same data
|
||
often and speed beats freshness.
|
||
</div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="form-group bq-access-synced" style="display:none;">
|
||
<label class="form-label">What to sync?</label>
|
||
<div class="bq-sync-radio-group" style="display:flex; gap:12px; margin-top:6px;">
|
||
<label style="flex:1; padding:12px; border:1px solid var(--border); border-radius:8px; cursor:pointer;">
|
||
<input type="radio" name="bqSyncMode" value="whole" checked onchange="onBqSyncModeChange()">
|
||
<strong>Whole table</strong>
|
||
<div style="font-size:12px; color:var(--text-secondary); margin-top:4px;">
|
||
Agnes runs <code>SELECT *</code> automatically. No SQL
|
||
required. Disk + sync cost = full table size.
|
||
</div>
|
||
</label>
|
||
<label style="flex:1; padding:12px; border:1px solid var(--border); border-radius:8px; cursor:pointer;">
|
||
<input type="radio" name="bqSyncMode" value="custom" onchange="onBqSyncModeChange()">
|
||
<strong>Custom query</strong>
|
||
<div style="font-size:12px; color:var(--text-secondary); margin-top:4px;">
|
||
You write the SELECT — filter, project, or aggregate
|
||
before the sync. Cuts disk + cost; cap via
|
||
<code>max_bytes_per_materialize</code> guardrail.
|
||
</div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="form-group bq-source-table">
|
||
<label class="form-label" for="bqDataset">
|
||
Dataset
|
||
<button type="button" class="btn btn-secondary btn-sm"
|
||
onclick="discoverBqDatasets()" style="float:right; margin-top:-3px;">
|
||
Discover
|
||
</button>
|
||
</label>
|
||
<input type="text" class="form-input" id="bqDataset" list="bqDatasetList" placeholder="e.g. analytics">
|
||
<datalist id="bqDatasetList"></datalist>
|
||
<div class="form-hint">BigQuery dataset name (no project prefix — read from instance.yaml).
|
||
Click <strong>Discover</strong> to populate the autocomplete from the BQ project's dataset list.</div>
|
||
</div>
|
||
<div class="form-group bq-source-table">
|
||
<label class="form-label" for="bqSourceTable">
|
||
Source Table / View
|
||
<button type="button" class="btn btn-secondary btn-sm"
|
||
onclick="discoverBqTables()" style="float:right; margin-top:-3px;">
|
||
List tables
|
||
</button>
|
||
</label>
|
||
<input type="text" class="form-input" id="bqSourceTable" list="bqTableList" placeholder="e.g. orders">
|
||
<datalist id="bqTableList"></datalist>
|
||
<div class="form-hint">Table or view name within the dataset. Click
|
||
<strong>List tables</strong> after filling Dataset to populate autocomplete.
|
||
<br><strong>Live access:</strong> BASE TABLEs query via
|
||
<code>bq."dataset"."table"</code> (Storage Read API; predicate pushdown).
|
||
VIEWs and MATERIALIZED_VIEWs query via the BQ jobs API (full-scan estimate;
|
||
cost-guarded by <code>bq_max_scan_bytes</code>).
|
||
<code>agnes query --remote</code> works for both.
|
||
<br><strong>Synced access:</strong> handles both table and view transparently
|
||
— the scheduler runs <code>SELECT *</code> through the jobs API and writes a
|
||
parquet.</div>
|
||
</div>
|
||
<div class="form-group bq-source-custom" style="display:none;">
|
||
<label class="form-label" for="bqSourceQuery">
|
||
SQL
|
||
<button type="button" class="btn btn-secondary btn-sm"
|
||
onclick="prefillFromTable()" style="float:right; margin-top:-3px;"
|
||
title="Prefill SELECT * FROM `project.dataset.table` so you only edit the WHERE / projection">
|
||
Use table as base
|
||
</button>
|
||
</label>
|
||
<textarea class="form-textarea" id="bqSourceQuery" rows="8"
|
||
placeholder="SELECT date, SUM(revenue) AS revenue FROM `project.dataset.orders` WHERE date >= DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY) GROUP BY 1"></textarea>
|
||
<div class="form-hint">
|
||
SELECT statement, no trailing semicolon. Native BQ identifiers
|
||
(<code>`project.dataset.table`</code>) recommended — DuckDB three-part
|
||
names like <code>bq."ds"."t"</code> work for the COPY but disable the
|
||
cost guardrail's BQ dry-run.
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label" for="bqViewName">View Name</label>
|
||
<input type="text" class="form-input" id="bqViewName" placeholder="orders_90d">
|
||
<div class="form-hint">Name analysts use to query the data (e.g.
|
||
<code>SELECT * FROM orders_90d</code>). Required for Custom query; defaults
|
||
to the source table for the other modes.</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label" for="bqDescription">Description <span class="optional">(optional)</span></label>
|
||
<textarea class="form-textarea" id="bqDescription" placeholder="Brief description of the table contents..."></textarea>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label" for="bqFolder">Folder <span class="optional">(optional)</span></label>
|
||
<input type="text" class="form-input" id="bqFolder" placeholder="e.g. crm, finance, marketing">
|
||
<div class="form-hint">Logical grouping for catalog organization</div>
|
||
</div>
|
||
<div class="form-group bq-access-synced" style="display:none;">
|
||
<label class="form-label" for="bqSyncSchedule">Sync Schedule <span class="optional">(optional, default <code>every 1h</code>)</span></label>
|
||
<input type="text" class="form-input" id="bqSyncSchedule" placeholder="every 6h">
|
||
<div class="form-hint">
|
||
How often Agnes refreshes the local copy. Examples:
|
||
<code>every 15m</code>, <code>every 6h</code>,
|
||
<code>daily 03:00</code>, <code>daily 07:00,13:00,18:00</code> (UTC).
|
||
</div>
|
||
</div>
|
||
<div class="form-group" id="bqPrecheckSummary" style="display:none;">
|
||
<div class="form-label">Source check</div>
|
||
<div class="form-hint" id="bqPrecheckSummaryText"></div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeRegisterBqModal()">Cancel</button>
|
||
<button class="btn btn-primary" id="registerBqSubmitBtn" onclick="registerBqTable()">
|
||
Register Table
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── BigQuery Edit Modal (C2 — physically inside the BQ tab,
|
||
mirror of #registerBqModal placement) ── -->
|
||
<div class="modal-overlay" id="editBqModal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<h2>Edit BigQuery Table</h2>
|
||
<button class="modal-close" onclick="closeEditBqModal()">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-group">
|
||
<label class="form-label" for="editBqTableId">Table ID</label>
|
||
<input type="text" class="form-input" id="editBqTableId" readonly>
|
||
<div class="form-hint">Slugified id, immutable. Source type:
|
||
<strong id="editBqSourceTypeBadge">bigquery</strong></div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">How should analysts access this data?
|
||
<a href="docs/admin/query-modes.md" target="_blank" title="When to use which mode" style="margin-left: 6px; text-decoration: none; cursor: help;">?</a>
|
||
</label>
|
||
<div style="display:flex; gap:12px; margin-top:6px;">
|
||
<label style="flex:1; padding:10px; border:1px solid var(--border); border-radius:8px; cursor:pointer;">
|
||
<input type="radio" name="editBqAccessMode" value="live" onchange="onEditBqAccessModeChange()">
|
||
<strong>Live from BigQuery</strong>
|
||
<div style="font-size:12px; color:var(--text-secondary); margin-top:4px;">
|
||
Each query goes to BQ. No local copy.
|
||
</div>
|
||
</label>
|
||
<label style="flex:1; padding:10px; border:1px solid var(--border); border-radius:8px; cursor:pointer;">
|
||
<input type="radio" name="editBqAccessMode" value="synced" onchange="onEditBqAccessModeChange()">
|
||
<strong>Synced locally</strong>
|
||
<div style="font-size:12px; color:var(--text-secondary); margin-top:4px;">
|
||
Scheduled SELECT → parquet, queried locally.
|
||
</div>
|
||
</label>
|
||
</div>
|
||
<div class="form-hint" id="editBqModeWarning" style="display:none;
|
||
color:#EA580C;background:rgba(234,88,12,.08);padding:8px;border-radius:6px;margin-top:8px;">
|
||
<!-- Filled by onEditBqAccessModeChange() when switching
|
||
modes on an existing row — warns about parquet
|
||
drop / scheduling impact. -->
|
||
</div>
|
||
</div>
|
||
<div class="form-group bq-edit-access-synced" style="display:none;">
|
||
<label class="form-label">What to sync?</label>
|
||
<div style="display:flex; gap:12px; margin-top:6px;">
|
||
<label style="flex:1; padding:10px; border:1px solid var(--border); border-radius:8px; cursor:pointer;">
|
||
<input type="radio" name="editBqSyncMode" value="whole" onchange="onEditBqSyncModeChange()">
|
||
<strong>Whole table</strong>
|
||
<div style="font-size:12px; color:var(--text-secondary); margin-top:4px;">
|
||
<code>SELECT *</code> on a schedule.
|
||
</div>
|
||
</label>
|
||
<label style="flex:1; padding:10px; border:1px solid var(--border); border-radius:8px; cursor:pointer;">
|
||
<input type="radio" name="editBqSyncMode" value="custom" onchange="onEditBqSyncModeChange()">
|
||
<strong>Custom query</strong>
|
||
<div style="font-size:12px; color:var(--text-secondary); margin-top:4px;">
|
||
Filter / aggregate before sync.
|
||
</div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="form-group bq-edit-source-table" style="display:none;">
|
||
<label class="form-label" for="editBqDataset">
|
||
Dataset
|
||
<button type="button" class="btn btn-secondary btn-sm"
|
||
onclick="discoverBqDatasets('editBqDatasetList')"
|
||
style="float:right; margin-top:-3px;">
|
||
Discover
|
||
</button>
|
||
</label>
|
||
<input type="text" class="form-input" id="editBqDataset"
|
||
list="editBqDatasetList" placeholder="e.g. analytics">
|
||
<datalist id="editBqDatasetList"></datalist>
|
||
</div>
|
||
<div class="form-group bq-edit-source-table" style="display:none;">
|
||
<label class="form-label" for="editBqSourceTable">
|
||
Source Table / View
|
||
<button type="button" class="btn btn-secondary btn-sm"
|
||
onclick="discoverBqTables('editBqDataset', 'editBqTableList')"
|
||
style="float:right; margin-top:-3px;">
|
||
List tables
|
||
</button>
|
||
</label>
|
||
<input type="text" class="form-input" id="editBqSourceTable"
|
||
list="editBqTableList" placeholder="e.g. orders">
|
||
<datalist id="editBqTableList"></datalist>
|
||
<div class="form-hint">Table or view name within the dataset.
|
||
<br><strong>Live access:</strong> BASE TABLEs query via
|
||
<code>bq."dataset"."table"</code> (Storage Read API; predicate pushdown).
|
||
VIEWs and MATERIALIZED_VIEWs query via the BQ jobs API (full-scan estimate;
|
||
cost-guarded by <code>bq_max_scan_bytes</code>).
|
||
<code>agnes query --remote</code> works for both.
|
||
<br><strong>Synced access:</strong> handles both transparently — the
|
||
scheduler runs <code>SELECT *</code> through the jobs API and writes a
|
||
parquet.</div>
|
||
</div>
|
||
<div class="form-group bq-edit-source-custom" style="display:none;">
|
||
<label class="form-label" for="editBqSourceQuery">
|
||
SQL
|
||
<button type="button" class="btn btn-secondary btn-sm"
|
||
onclick="prefillFromTable('editBqSourceQuery')"
|
||
style="float:right; margin-top:-3px;"
|
||
title="Prefill SELECT * FROM `project.dataset.table` so you only edit the WHERE / projection">
|
||
Use table as base
|
||
</button>
|
||
</label>
|
||
<textarea class="form-textarea" id="editBqSourceQuery" rows="8"></textarea>
|
||
<div class="form-hint">SELECT statement, no trailing semicolon. Native BQ
|
||
identifiers recommended for the cost guardrail to engage.</div>
|
||
</div>
|
||
<div class="form-group bq-edit-access-synced" style="display:none;">
|
||
<label class="form-label" for="editBqSyncSchedule">Sync Schedule
|
||
<span class="optional">(optional)</span></label>
|
||
<input type="text" class="form-input" id="editBqSyncSchedule" placeholder="every 6h">
|
||
<div class="form-hint">How often Agnes refreshes the local copy.
|
||
<code>every 15m</code>, <code>every 6h</code>,
|
||
<code>daily 03:00</code> (UTC).</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label" for="editBqDescription">Description <span class="optional">(optional)</span></label>
|
||
<textarea class="form-textarea" id="editBqDescription" placeholder="Brief description of the table contents..."></textarea>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label" for="editBqFolder">Folder <span class="optional">(optional)</span></label>
|
||
<input type="text" class="form-input" id="editBqFolder" placeholder="e.g. crm, finance, marketing">
|
||
<div class="form-hint">Logical grouping for catalog organization (does not affect storage).</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeEditBqModal()">Cancel</button>
|
||
<button class="btn btn-primary" id="editBqSubmitBtn" onclick="saveBqTabEdit()">
|
||
Save Changes
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="tab-content-keboola" class="tab-content"
|
||
style="display: {% if initial_tab == 'keboola' %}block{% else %}none{% endif %};">
|
||
<div class="tab-actions">
|
||
<button id="kbRegisterBtn" class="btn btn-primary"
|
||
onclick="openRegisterModal('keboola')">Register Keboola table</button>
|
||
</div>
|
||
<div id="kbTableListing"></div>
|
||
|
||
<!-- ── Keboola Register Modal (Phase F1) ── -->
|
||
<div class="modal-overlay" id="registerKeboolaModal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<h2>Register Keboola Table</h2>
|
||
<button class="modal-close" onclick="closeRegisterKeboolaModal()">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
{# Three sync-mode radios:
|
||
- whole / custom → query_mode='materialized' (DuckDB Keboola
|
||
extension; whole synthesizes SELECT *, custom uses admin SQL)
|
||
- direct → query_mode='local' (Storage API SDK, supports
|
||
v26 sync strategies: incremental/partitioned + where_filters) #}
|
||
<div class="form-group">
|
||
<label class="form-label">What to sync?</label>
|
||
<div class="bq-sync-radio-group" style="display:flex; gap:12px; margin-top:6px; flex-wrap:wrap;">
|
||
<label style="flex:1; min-width:200px; padding:12px; border:1px solid var(--border); border-radius:8px; cursor:pointer;">
|
||
<input type="radio" name="kbSyncMode" value="whole" checked onchange="onKbSyncModeChange()">
|
||
<strong>Whole table (extension)</strong>
|
||
<div style="font-size:12px; color:var(--text-secondary); margin-top:4px;">
|
||
DuckDB Keboola extension pulls the full table on
|
||
each tick. Fastest path; full overwrite each run.
|
||
</div>
|
||
</label>
|
||
<label style="flex:1; min-width:200px; padding:12px; border:1px solid var(--border); border-radius:8px; cursor:pointer;">
|
||
<input type="radio" name="kbSyncMode" value="direct" onchange="onKbSyncModeChange()">
|
||
<strong>Direct extract (Storage API)</strong>
|
||
<div style="font-size:12px; color:var(--text-secondary); margin-top:4px;">
|
||
Storage API SDK. Supports incremental sync
|
||
(changedSince + PK merge), partitioned files,
|
||
and server-side <code>where_filters</code>.
|
||
</div>
|
||
</label>
|
||
<label style="flex:1; min-width:200px; padding:12px; border:1px solid var(--border); border-radius:8px; cursor:pointer;">
|
||
<input type="radio" name="kbSyncMode" value="custom" onchange="onKbSyncModeChange()">
|
||
<strong>Custom SQL</strong>
|
||
<div style="font-size:12px; color:var(--text-secondary); margin-top:4px;">
|
||
Pre-aggregate or filter with your own SELECT
|
||
(e.g. last 30 days only, per-day rollup).
|
||
</div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label" for="kbViewName">View name (analyst-visible)</label>
|
||
<input type="text" class="form-input" id="kbViewName"
|
||
placeholder="e.g. orders_recent">
|
||
</div>
|
||
|
||
{# Discover/List tables backend currently routes by instance's data_source.type
|
||
ignoring the `source` query param. Hiding the buttons on non-Keboola instances
|
||
prevents wrong-shape responses; inputs stay for manual entry. Future fix: make
|
||
/api/admin/discover-tables accept ?source=keboola and remove this guard. #}
|
||
<div class="form-group kb-source-table">
|
||
<label class="form-label" for="kbBucket">
|
||
Bucket
|
||
{% if data_source_type == 'keboola' %}
|
||
<button type="button" class="btn btn-secondary btn-sm"
|
||
onclick="discoverKeboolaBuckets('kbBucketList')"
|
||
style="float:right; margin-top:-3px;">Discover</button>
|
||
{% endif %}
|
||
</label>
|
||
<input type="text" class="form-input" id="kbBucket"
|
||
list="kbBucketList" placeholder="e.g. in.c-sales">
|
||
<datalist id="kbBucketList"></datalist>
|
||
</div>
|
||
<div class="form-group kb-source-table">
|
||
<label class="form-label" for="kbSourceTable">
|
||
Source Table
|
||
{% if data_source_type == 'keboola' %}
|
||
<button type="button" class="btn btn-secondary btn-sm"
|
||
onclick="discoverKeboolaTables('kbBucket', 'kbTableList')"
|
||
style="float:right; margin-top:-3px;">List tables</button>
|
||
{% endif %}
|
||
</label>
|
||
<input type="text" class="form-input" id="kbSourceTable"
|
||
list="kbTableList" placeholder="e.g. orders">
|
||
<datalist id="kbTableList"></datalist>
|
||
</div>
|
||
<div class="form-group kb-source-custom" style="display:none;">
|
||
<label class="form-label" for="kbSourceQuery">
|
||
SQL
|
||
{% if data_source_type == 'keboola' %}
|
||
<button type="button" class="btn btn-secondary btn-sm"
|
||
onclick="prefillFromKeboolaTable('kbSourceQuery')"
|
||
style="float:right; margin-top:-3px;"
|
||
title="Prefill SELECT * FROM kbc.bucket.table so you only edit the WHERE / projection">
|
||
Use table as base
|
||
</button>
|
||
{% endif %}
|
||
</label>
|
||
<textarea class="form-textarea" id="kbSourceQuery" rows="8"></textarea>
|
||
<div class="form-hint">SELECT against <code>kbc."bucket"."table"</code>.
|
||
Result is materialized to parquet and distributed via
|
||
<code>agnes pull</code>.</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label" for="kbSyncSchedule">Sync Schedule
|
||
<span class="optional">(optional, default <code>every 1h</code>)</span></label>
|
||
<input type="text" class="form-input" id="kbSyncSchedule" placeholder="every 6h">
|
||
<div class="form-hint">
|
||
How often Agnes refreshes the local copy. Examples:
|
||
<code>every 15m</code>, <code>every 6h</code>,
|
||
<code>daily 03:00</code>, <code>daily 07:00,13:00,18:00</code> (UTC).
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label" for="kbDescription">Description
|
||
<span class="optional">(optional)</span></label>
|
||
<textarea class="form-textarea" id="kbDescription"
|
||
placeholder="Brief description of the table contents..."></textarea>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label" for="kbFolder">Folder
|
||
<span class="optional">(optional)</span></label>
|
||
<input type="text" class="form-input" id="kbFolder"
|
||
placeholder="e.g. crm, finance, marketing">
|
||
</div>
|
||
|
||
<details class="form-group">
|
||
<summary>Advanced (optional)</summary>
|
||
<div class="form-group" style="margin-top:8px;">
|
||
<label class="form-label" for="kbPrimaryKey">Primary Key</label>
|
||
<input type="text" class="form-input" id="kbPrimaryKey"
|
||
placeholder="e.g. id">
|
||
<div class="form-hint">Comma-separated list. Required for
|
||
Direct extract → Incremental (used as the dedup key on
|
||
delta merge). Auto-filled from the Keboola source when
|
||
available.</div>
|
||
</div>
|
||
</details>
|
||
|
||
<!-- v26 Direct-extract sync-strategy panel — visible only when
|
||
"Direct extract (Storage API)" is selected. Field visibility
|
||
within the panel further branches on the sync_strategy
|
||
dropdown (incremental / partitioned). -->
|
||
<div class="form-group kb-direct-only" style="display:none; padding:12px; border:1px solid var(--border); border-radius:8px; background:var(--background);">
|
||
<h3 style="margin:0 0 12px 0; font-size:14px;">Direct extract — sync strategy</h3>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label" for="kbStrategy">Sync strategy</label>
|
||
<select class="form-select" id="kbStrategy" onchange="onKbStrategyChange()">
|
||
<option value="full_refresh">Full refresh — pull entire table each tick</option>
|
||
<option value="incremental">Incremental — pull rows changed since last sync</option>
|
||
<option value="partitioned">Partitioned — per-partition files, per-month/day/year</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group kb-strategy-incremental kb-strategy-partitioned" style="display:none;">
|
||
<label class="form-label" for="kbIncrementalWindowDays">Incremental window (days)
|
||
<span class="optional">(default 7)</span></label>
|
||
<input type="number" class="form-input" id="kbIncrementalWindowDays" min="0" placeholder="7">
|
||
<div class="form-hint">Backtrack window applied to last_sync timestamp on each tick.
|
||
Higher = more reliable on late-arriving rows; lower = less data per tick.</div>
|
||
</div>
|
||
<div class="form-group kb-strategy-incremental kb-strategy-partitioned" style="display:none;">
|
||
<label class="form-label" for="kbMaxHistoryDays">Max history (days)
|
||
<span class="optional">(first sync only, default unbounded)</span></label>
|
||
<input type="number" class="form-input" id="kbMaxHistoryDays" min="1" placeholder="365">
|
||
<div class="form-hint">Cap on how far back the first-ever sync goes. Multi-year tables
|
||
without this can OOM at write — set 90/180/365 for safety.</div>
|
||
</div>
|
||
|
||
<div class="form-group kb-strategy-partitioned" style="display:none;">
|
||
<label class="form-label" for="kbPartitionBy">Partition by column <strong>(required)</strong></label>
|
||
<input type="text" class="form-input" id="kbPartitionBy" placeholder="e.g. event_date">
|
||
<div class="form-hint">Date / timestamp column whose value drives the partition key.
|
||
Rows with NULL or unparseable values are dropped (logged warning).</div>
|
||
</div>
|
||
<div class="form-group kb-strategy-partitioned" style="display:none;">
|
||
<label class="form-label" for="kbPartitionGranularity">Granularity</label>
|
||
<select class="form-select" id="kbPartitionGranularity">
|
||
<option value="month">Month — YYYY_MM.parquet (default)</option>
|
||
<option value="day">Day — YYYY_MM_DD.parquet</option>
|
||
<option value="year">Year — YYYY.parquet</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group kb-strategy-partitioned" style="display:none;">
|
||
<label class="form-label" for="kbInitialLoadChunkDays">Initial-load chunk size (days)
|
||
<span class="optional">(default 30)</span></label>
|
||
<input type="number" class="form-input" id="kbInitialLoadChunkDays" min="1" placeholder="30">
|
||
<div class="form-hint">First-sync chunked load step. Smaller = more API calls, less
|
||
memory per chunk. Larger = fewer calls, more memory.</div>
|
||
</div>
|
||
|
||
<div class="form-group kb-strategy-not-incremental" style="display:none;">
|
||
<label class="form-label" for="kbWhereFilters">Where filters
|
||
<span class="optional">(JSON array, optional)</span></label>
|
||
<textarea class="form-textarea" id="kbWhereFilters" rows="6"
|
||
placeholder='[{"column": "date", "operator": "ge", "values": ["{{last_3_months}}"]}]'></textarea>
|
||
<div class="form-hint">
|
||
Server-side row filter. Operators: <code>eq, ne, gt, ge, lt, le</code>.
|
||
Date placeholders resolved at sync time:
|
||
<code>{{ '{{today}}' }}</code>,
|
||
<code>{{ '{{last_week}}' }}</code>,
|
||
<code>{{ '{{last_month}}' }}</code>,
|
||
<code>{{ '{{last_2_months}}' }}</code>,
|
||
<code>{{ '{{last_3_months}}' }}</code>,
|
||
<code>{{ '{{last_6_months}}' }}</code>,
|
||
<code>{{ '{{last_year}}' }}</code>,
|
||
<code>{{ '{{last_2_years}}' }}</code>,
|
||
<code>{{ '{{start_of_3_months_ago}}' }}</code>.
|
||
Not compatible with Incremental strategy.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeRegisterKeboolaModal()">Cancel</button>
|
||
<button class="btn btn-primary" id="registerKeboolaSubmitBtn"
|
||
onclick="registerKeboolaTable()">Register</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Keboola Edit Modal (Phase F2) ── -->
|
||
<div class="modal-overlay" id="editKeboolaModal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<h2>Edit Keboola Table</h2>
|
||
<button class="modal-close" onclick="closeEditKeboolaModal()">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-group">
|
||
<label class="form-label" for="editKbTableId">Table ID</label>
|
||
<input type="text" class="form-input" id="editKbTableId" readonly>
|
||
<div class="form-hint">Slugified id, immutable.</div>
|
||
</div>
|
||
|
||
{# Three sync-mode radios (mirror of Register). #}
|
||
<div class="form-group">
|
||
<label class="form-label">What to sync?</label>
|
||
<div class="bq-sync-radio-group" style="display:flex; gap:12px; margin-top:6px; flex-wrap:wrap;">
|
||
<label style="flex:1; min-width:200px; padding:10px; border:1px solid var(--border); border-radius:8px; cursor:pointer;">
|
||
<input type="radio" name="editKbSyncMode" value="whole"
|
||
onchange="onEditKbSyncModeChange()">
|
||
<strong>Whole table (extension)</strong>
|
||
<div style="font-size:12px; color:var(--text-secondary); margin-top:4px;">
|
||
DuckDB Keboola extension; full overwrite each tick.
|
||
</div>
|
||
</label>
|
||
<label style="flex:1; min-width:200px; padding:10px; border:1px solid var(--border); border-radius:8px; cursor:pointer;">
|
||
<input type="radio" name="editKbSyncMode" value="direct"
|
||
onchange="onEditKbSyncModeChange()">
|
||
<strong>Direct extract (Storage API)</strong>
|
||
<div style="font-size:12px; color:var(--text-secondary); margin-top:4px;">
|
||
Storage API SDK. Supports incremental, partitioned,
|
||
<code>where_filters</code>.
|
||
</div>
|
||
</label>
|
||
<label style="flex:1; min-width:200px; padding:10px; border:1px solid var(--border); border-radius:8px; cursor:pointer;">
|
||
<input type="radio" name="editKbSyncMode" value="custom"
|
||
onchange="onEditKbSyncModeChange()">
|
||
<strong>Custom SQL</strong>
|
||
<div style="font-size:12px; color:var(--text-secondary); margin-top:4px;">
|
||
Pre-aggregate or filter with your own SELECT.
|
||
</div>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
{# Discover/List tables backend currently routes by instance's data_source.type
|
||
ignoring the `source` query param. Hiding the buttons on non-Keboola instances
|
||
prevents wrong-shape responses; inputs stay for manual entry. Future fix: make
|
||
/api/admin/discover-tables accept ?source=keboola and remove this guard. #}
|
||
<div class="form-group editkb-source-table">
|
||
<label class="form-label" for="editKbBucket">
|
||
Bucket
|
||
{% if data_source_type == 'keboola' %}
|
||
<button type="button" class="btn btn-secondary btn-sm"
|
||
onclick="discoverKeboolaBuckets('editKbBucketList')"
|
||
style="float:right; margin-top:-3px;">Discover</button>
|
||
{% endif %}
|
||
</label>
|
||
<input type="text" class="form-input" id="editKbBucket"
|
||
list="editKbBucketList" placeholder="e.g. in.c-sales">
|
||
<datalist id="editKbBucketList"></datalist>
|
||
</div>
|
||
<div class="form-group editkb-source-table">
|
||
<label class="form-label" for="editKbSourceTable">
|
||
Source Table
|
||
{% if data_source_type == 'keboola' %}
|
||
<button type="button" class="btn btn-secondary btn-sm"
|
||
onclick="discoverKeboolaTables('editKbBucket', 'editKbTableList')"
|
||
style="float:right; margin-top:-3px;">List tables</button>
|
||
{% endif %}
|
||
</label>
|
||
<input type="text" class="form-input" id="editKbSourceTable"
|
||
list="editKbTableList" placeholder="e.g. orders">
|
||
<datalist id="editKbTableList"></datalist>
|
||
</div>
|
||
<div class="form-group editkb-source-custom" style="display:none;">
|
||
<label class="form-label" for="editKbSourceQuery">
|
||
SQL
|
||
{% if data_source_type == 'keboola' %}
|
||
<button type="button" class="btn btn-secondary btn-sm"
|
||
onclick="prefillFromKeboolaTable('editKbSourceQuery')"
|
||
style="float:right; margin-top:-3px;"
|
||
title="Prefill SELECT * FROM kbc.bucket.table so you only edit the WHERE / projection">
|
||
Use table as base
|
||
</button>
|
||
{% endif %}
|
||
</label>
|
||
<textarea class="form-textarea" id="editKbSourceQuery" rows="8"></textarea>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label" for="editKbSyncSchedule">Sync Schedule
|
||
<span class="optional">(optional)</span></label>
|
||
<input type="text" class="form-input" id="editKbSyncSchedule" placeholder="every 6h">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label" for="editKbDescription">Description
|
||
<span class="optional">(optional)</span></label>
|
||
<textarea class="form-textarea" id="editKbDescription"></textarea>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label" for="editKbFolder">Folder
|
||
<span class="optional">(optional)</span></label>
|
||
<input type="text" class="form-input" id="editKbFolder">
|
||
</div>
|
||
|
||
<details class="form-group">
|
||
<summary>Advanced (optional)</summary>
|
||
<div class="form-group" style="margin-top:8px;">
|
||
<label class="form-label" for="editKbPrimaryKey">Primary Key</label>
|
||
<input type="text" class="form-input" id="editKbPrimaryKey"
|
||
placeholder="e.g. id">
|
||
<div class="form-hint">Comma-separated list. Required for
|
||
Direct extract → Incremental.</div>
|
||
</div>
|
||
</details>
|
||
|
||
<!-- v26 Direct-extract sync-strategy panel — mirror of Register. -->
|
||
<div class="form-group editkb-direct-only" style="display:none; padding:12px; border:1px solid var(--border); border-radius:8px; background:var(--background);">
|
||
<h3 style="margin:0 0 12px 0; font-size:14px;">Direct extract — sync strategy</h3>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label" for="editKbStrategy">Sync strategy</label>
|
||
<select class="form-select" id="editKbStrategy" onchange="onEditKbStrategyChange()">
|
||
<option value="full_refresh">Full refresh — pull entire table each tick</option>
|
||
<option value="incremental">Incremental — pull rows changed since last sync</option>
|
||
<option value="partitioned">Partitioned — per-partition files, per-month/day/year</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group editkb-strategy-incremental editkb-strategy-partitioned" style="display:none;">
|
||
<label class="form-label" for="editKbIncrementalWindowDays">Incremental window (days)
|
||
<span class="optional">(default 7)</span></label>
|
||
<input type="number" class="form-input" id="editKbIncrementalWindowDays" min="0" placeholder="7">
|
||
</div>
|
||
<div class="form-group editkb-strategy-incremental editkb-strategy-partitioned" style="display:none;">
|
||
<label class="form-label" for="editKbMaxHistoryDays">Max history (days)
|
||
<span class="optional">(first sync only)</span></label>
|
||
<input type="number" class="form-input" id="editKbMaxHistoryDays" min="1" placeholder="365">
|
||
</div>
|
||
|
||
<div class="form-group editkb-strategy-partitioned" style="display:none;">
|
||
<label class="form-label" for="editKbPartitionBy">Partition by column <strong>(required)</strong></label>
|
||
<input type="text" class="form-input" id="editKbPartitionBy" placeholder="e.g. event_date">
|
||
</div>
|
||
<div class="form-group editkb-strategy-partitioned" style="display:none;">
|
||
<label class="form-label" for="editKbPartitionGranularity">Granularity</label>
|
||
<select class="form-select" id="editKbPartitionGranularity">
|
||
<option value="month">Month (default)</option>
|
||
<option value="day">Day</option>
|
||
<option value="year">Year</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group editkb-strategy-partitioned" style="display:none;">
|
||
<label class="form-label" for="editKbInitialLoadChunkDays">Initial-load chunk size (days)
|
||
<span class="optional">(default 30)</span></label>
|
||
<input type="number" class="form-input" id="editKbInitialLoadChunkDays" min="1" placeholder="30">
|
||
</div>
|
||
|
||
<div class="form-group editkb-strategy-not-incremental" style="display:none;">
|
||
<label class="form-label" for="editKbWhereFilters">Where filters
|
||
<span class="optional">(JSON array, optional)</span></label>
|
||
<textarea class="form-textarea" id="editKbWhereFilters" rows="6"
|
||
placeholder='[{"column": "date", "operator": "ge", "values": ["{{last_3_months}}"]}]'></textarea>
|
||
<div class="form-hint">
|
||
Operators: <code>eq, ne, gt, ge, lt, le</code>. Date placeholders
|
||
(<code>{{ '{{today}}' }}</code>, <code>{{ '{{last_3_months}}' }}</code>, etc.) resolved at
|
||
sync time. Not compatible with Incremental.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeEditKeboolaModal()">Cancel</button>
|
||
<button class="btn btn-primary" id="editKeboolaSubmitBtn"
|
||
onclick="saveKeboolaTabEdit()">Save Changes</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="tab-content-jira" class="tab-content"
|
||
style="display: {% if initial_tab == 'jira' %}block{% else %}none{% endif %};">
|
||
<p class="hint" style="margin-bottom: 16px;">Jira tables are populated by webhooks.
|
||
To register a new Jira webhook integration, see
|
||
<code>docs/connectors/jira.md</code>.</p>
|
||
<div id="jiraTableListing"></div>
|
||
</section>
|
||
|
||
{# Legacy out-of-tab panels (BQ Register card, Keboola Discovery card,
|
||
shared Registered Tables wrapper) removed — each tab now owns its
|
||
own header (with Register button) and listing div. The Refresh
|
||
action is implicit: registration / edit / delete flows already
|
||
call loadRegistry(), which re-renders all three per-tab listings. #}
|
||
|
||
</div>
|
||
|
||
{# C3: legacy #registerModal removed. The Phase E #registerBqModal
|
||
(inside #tab-content-bigquery) and Phase F #registerKeboolaModal
|
||
(inside #tab-content-keboola) own the Register flows now. The
|
||
data-source-type marker moved to <body> so DATA_SOURCE_TYPE still
|
||
has somewhere to read from. #}
|
||
|
||
<!-- ═══════════════ EDIT MODAL (legacy fallback — Keboola-only fields
|
||
remaining; the BQ Edit modal moved into #tab-content-bigquery as
|
||
#editBqModal in C2; the Keboola Edit modal is #editKeboolaModal
|
||
in #tab-content-keboola) ═══════════════ -->
|
||
<div class="modal-overlay" id="editModal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<h2>Edit Table</h2>
|
||
<button class="modal-close" onclick="closeEditModal()">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-group">
|
||
<label class="form-label" for="editTableId">Table ID</label>
|
||
<input type="text" class="form-input" id="editTableId" readonly>
|
||
<div class="form-hint">Slugified id, immutable. Source type:
|
||
<strong id="editSourceTypeBadge">—</strong></div>
|
||
</div>
|
||
|
||
<!-- Keboola/Jira fallback fields. The richer Keboola modal
|
||
lives at #editKeboolaModal; this remains the catch-all
|
||
for any source_type that's neither bigquery nor keboola
|
||
(e.g. jira). -->
|
||
<div class="form-group keboola-edit-only">
|
||
<label class="form-label" for="editStrategy">Sync Strategy</label>
|
||
<select class="form-select" id="editStrategy">
|
||
<option value="full_refresh">Full Refresh</option>
|
||
<option value="incremental">Incremental</option>
|
||
<option value="partitioned">Partitioned</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group keboola-edit-only">
|
||
<label class="form-label" for="editPrimaryKey">Primary Key</label>
|
||
<input type="text" class="form-input" id="editPrimaryKey" placeholder="e.g. id">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label" for="editDescription">Description <span class="optional">(optional)</span></label>
|
||
<textarea class="form-textarea" id="editDescription" placeholder="Brief description of the table contents..."></textarea>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label" for="editFolder">Folder <span class="optional">(optional)</span></label>
|
||
<input type="text" class="form-input" id="editFolder" placeholder="e.g. crm, finance, marketing">
|
||
<div class="form-hint">Logical grouping for catalog organization (does not affect storage).</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeEditModal()">Cancel</button>
|
||
<button class="btn btn-primary" id="editSubmitBtn" onclick="saveTableEdit()">
|
||
Save Changes
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══════════════ TOAST ═══════════════ -->
|
||
<div class="toast" id="toast">
|
||
<div class="toast-icon" id="toastIcon"></div>
|
||
<span id="toastMessage"></span>
|
||
</div>
|
||
|
||
<!-- ═══════════════ FOOTER ═══════════════ -->
|
||
<footer class="footer">
|
||
<a href="{{ url_for('dashboard') }}">Back to Dashboard</a>
|
||
</footer>
|
||
<script>
|
||
/* ═══════════════════════════════════════════════════════════════
|
||
Admin Tables - JavaScript
|
||
═══════════════════════════════════════════════════════════════ */
|
||
|
||
// ── Tab nav (Phase D) ───────────────────────────────────────
|
||
function switchTab(tab) {
|
||
document.querySelectorAll('.tab').forEach(function(b) {
|
||
b.setAttribute('aria-selected', b.dataset.tab === tab ? 'true' : 'false');
|
||
});
|
||
document.querySelectorAll('.tab-content').forEach(function(c) {
|
||
c.style.display = (c.id === ('tab-content-' + tab)) ? 'block' : 'none';
|
||
});
|
||
history.replaceState(null, '', '#' + tab);
|
||
}
|
||
|
||
function getActiveTabFromHash() {
|
||
var hash = window.location.hash.replace(/^#/, '');
|
||
if (hash === 'bigquery' || hash === 'keboola' || hash === 'jira') {
|
||
return hash;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
(function initTabFromHash() {
|
||
var t = getActiveTabFromHash();
|
||
if (t) switchTab(t);
|
||
})();
|
||
|
||
// State
|
||
let registryData = null;
|
||
let registryVersion = null;
|
||
let currentEditTableId = null;
|
||
|
||
// ── Toast notification ──────────────────────────────────────
|
||
|
||
function showToast(message, type) {
|
||
var toast = document.getElementById('toast');
|
||
var icon = document.getElementById('toastIcon');
|
||
var msg = document.getElementById('toastMessage');
|
||
|
||
toast.className = 'toast toast-' + type;
|
||
msg.textContent = message;
|
||
|
||
if (type === 'success') {
|
||
icon.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#10B77F" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>';
|
||
} else {
|
||
icon.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#EA580C" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>';
|
||
}
|
||
|
||
// Show
|
||
requestAnimationFrame(function() {
|
||
toast.classList.add('visible');
|
||
});
|
||
|
||
// Hide after 4 seconds
|
||
setTimeout(function() {
|
||
toast.classList.remove('visible');
|
||
}, 4000);
|
||
}
|
||
|
||
// ── Format helpers ──────────────────────────────────────────
|
||
|
||
function formatNumber(n) {
|
||
if (n == null) return '-';
|
||
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
||
if (n >= 1000) return n.toLocaleString();
|
||
return String(n);
|
||
}
|
||
|
||
function formatSize(bytes) {
|
||
if (bytes == null || bytes === 0) return '-';
|
||
if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(1) + ' GB';
|
||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||
return bytes + ' B';
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
if (!str) return '';
|
||
var div = document.createElement('div');
|
||
div.textContent = str;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
/**
|
||
* Escape a string for safe inclusion inside a single- OR double-quoted
|
||
* HTML attribute. Unlike `escapeHtml` (which goes through textContent →
|
||
* innerHTML and only escapes `<`/`>`/`&`), this also escapes both quote
|
||
* characters so the value can't break out of the attribute. Issue #265.
|
||
*/
|
||
function escapeHtmlAttr(str) {
|
||
if (str === null || str === undefined) return '';
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
// 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, ' |