* feat(unified-stack): Browse + My Stack + Recipes + RBAC matrix (v49–v55)
Squash of 94 commits spanning the v49 → v55 unified-stack rewrite.
Full per-feature breakdown lives in CHANGELOG.md under [Unreleased].
Major buckets:
* v49 schema — first-class user_groups + user_group_members +
resource_grants; admin can CRUD groups and grants; Google
Workspace nightly sync writes into the new tables.
* v49 data_packages — admin-curated bundles of tables, RBAC-gated,
first-class section on /catalog Browse + My Stack.
* v49 memory_domains — row-backed (replaces hardcoded VALID_DOMAINS
enum); admin can CRUD; grants follow the same shape as tables and
packages.
* v50 cover_image_url + admin sidebar collapsibles + per-row Mode
tooltip + admin queue domain badges + admin "+ New Item" seed flow.
* v51 lifecycle status (prod/poc/coming-soon/draft) + category +
palette swatches on admin modals.
* v52 per-table detail page /catalog/t/<id>.
* v53 Recipes — admin-curated SQL templates as a second tab on
/catalog with full Edit/Delete admin affordances.
* v54 soft-delete (deleted_at) + Undo toast for packages, memory
domains, and recipes; hard_delete() retained as escape hatch.
* v55 Recipes RBAC — ResourceType.RECIPE registered, inline Group
Access matrix on Create + Edit Recipe modals (mirrors the Memory
Domain pattern).
* Activity Center per-resource filter (resource_prefix LIKE-anchored
on audit_log.resource); admin nav g+letter keyboard shortcuts;
loadAdminTablesLayout N+1 → single endpoint; /api/memory 30s
page-level cache.
* CI hardening — Keboola legacy tests pytest.importorskip; perf-
smoke threshold widened to stop cold-cache flake.
5002 tests passing, 35 skipped.
* feat(p2 backlog): Cmd-K palette + suggest-a-domain + nightly E2E + v55 schema
10-item P2 sweep on top of the unified-stack squash. New behaviour:
* Cmd-K admin command palette (base.html) — fuzzy-search overlay over
admin + user-facing routes. Arrows/Enter to navigate, Esc to close.
* Stack-tabs digit shortcuts — 1/2/3 switch Browse / My Stack /
Recipes on /catalog + /corporate-memory.
* Friendlier non-admin empty state on /corporate-memory, plus a
"Suggest a domain" CTA → POST /api/memory-domain-suggestions, admin
queue with approve/reject. Backed by a new memory_domain_suggestions
table (schema v55).
* /admin/corporate-memory 7-tab strip grouped under Moderation /
Catalog parent labels.
* Bulk-assign table → package dropdown annotates each option with
"(N of M tables already in)" so the existing distribution is visible
before picking a target.
* GET /api/memory + /tree accept is_required filter; admin status
dropdowns route the "Required" sentinel onto it (status no longer
holds 'mandatory' post-v49, so the old dropdown returned nothing).
* chip-input.js is now opt-in per template via {% block extra_scripts %}
instead of loaded globally on every page from base.html.
* Edit-modal close helpers consolidated onto _closeEditModalById();
docs the per-source-type modal architecture decision.
* New .github/workflows/e2e-nightly.yml runs agent-browser smoke
scripts (scripts/e2e/smoke_*.sh) against a docker-compose stack
nightly at 04:30 UTC; failures open an agent-browser-nightly issue.
5012 tests passing, 35 skipped.
* fix(visual audit): 6 page regressions on memory + data-package surfaces
agent-browser walkthrough of every memory + data-package page in the PR
turned up 6 real bugs. Fixes:
1. Admin memory modals were dead. Duplicate `let _cmdNewDomainId`
declarations from the deprecated step-2 RBAC stubs in
admin_corporate_memory.html collided with the live state vars
declared earlier in the same <script> → SyntaxError on parse →
the entire second script block silently failed → every inline
onclick= handler defined there (`+ New Memory Domain`, Edit, etc.)
was a no-op. Removed the duplicate stubs.
2. /catalog/t/<table_id> + /catalog/r/<slug> rendered unstyled.
Both templates injected their CSS via {% block head %} but
base.html exposes {% block head_extra %} — wrong block name
meant <style> rules never reached the rendered HTML. Renamed
to head_extra. Hero card, section cards, dark SQL block, proper
full-width inputs all now render as designed.
3. L49 leak — "MANDATORY" KPI label + "Make Mandatory" row buttons
on /admin/corporate-memory still used the old word. Renamed to
"Required" / "Mark as Required" so UI matches the data model
(v49 split moved the Required tier onto the orthogonal
is_required boolean; status no longer holds 'mandatory').
4. Activity Center Resource dropdown didn't know the v55
`memory_domain_suggestion:` namespace — added it.
5. Tab strip on /admin/corporate-memory wrapped text 2× per button
on narrow viewports after the L50 MODERATION/CATALOG group
labels pushed total width past most viewports. Switched the
strip to flex-wrap:nowrap + overflow-x:auto with
white-space:nowrap + flex-shrink:0 on every direct child so the
tabs stay one row and slide horizontally when they overflow.
5012 tests passing, 35 skipped.
* rebase-cleanup: align with main's 0.54.25-27 API design + comment fix
Three follow-on fixes after rebasing onto origin/main (0.54.27):
* admin_tables.html: dropped a stray nested ``{% if data_source_type
== 'keboola' %}`` around ``prefillFromKeboolaTable`` (main never had
it; the outer Phase F2 guard already covers it) and reworded a JS
comment that contained literal ``{% %}`` tokens which Jinja was
parsing as a real tag → unbalanced if/endif → 30 template render
failures across the suite.
* /api/stack/subscription/{type}/{id}: DELETE now returns 204 instead
of 200 per the 0.54.26 design rules. CLI client + parity tests
updated to accept 2xx / assert 204.
* Memory-domain suggestion approve/reject paths added to
``_VERB_PATH_ALLOWLIST`` — they are pending → approved/rejected
state-machine transitions (approve also creates the real
memory_domains row as a side effect), so the RPC shape is
intentional rather than a missed PATCH refactor.
5035 tests passing, 35 skipped.
* fix(catalog_table_detail): real polish pass — hero glyph, dedup pills, rows/size meta, scoped sync CTA
The previous fix only got the block-name typo so the existing CSS rendered.
The actual layout was still wireframe-tier on close inspection:
* No cover glyph in the hero (a flat white card with title + meta line);
data-package + memory-domain detail pages both have a colored icon
square. Restored parity — table.icon emoji if set, otherwise initials
on a colored square using table.color.
* "INTERNAL" pill rendered twice for agnes_audit etc. — the mode pill
and the source-type pill happened to be identical strings. Now skip
the source pill when it matches the mode (`internal == internal`).
* Bucket / source_table code chip showed `Agnes Internal.audit_log` for
internal rows — meaningless to a user. Hidden when source_type is
internal.
* `pairs_well_with` admin input was a comma-separated `<input>` always
visible. Wrapped all 4 sections in an Edit-on-demand toggle: read-
only display by default, "+ Add" / "Edit" button on the right edge
of each section header reveals the inline form, Cancel hides it.
* "Trigger sync now" was a cramped link squashed into the empty-state
flex row (visible as `Tr…` overflow before). Promoted to a proper
btn-primary button under the empty-state copy. Hidden entirely for
internal tables (which are server-managed — no upstream to pull).
* Hero meta now surfaces row count + payload size (when sync_state has
them) + last sync timestamp on a single line — was missing from the
original.
* Mode pills colored by tier (local=green, remote=amber, materialized=
blue, internal=gray) so the basic fact about a table reads at a
glance, not from upper-cased ALL-CAPS text alone.
* tests(v56): TDD baseline for extended data-packages content + per-table docs
68 failing tests across 8 files spec the v56 surface before any
implementation lands:
* test_schema_v55_to_v56_migration.py — schema bump, additive ALTERs
on data_packages + table_registry, idempotency, sequential-upgrade
preservation
* test_data_packages_repo_v56.py — repo create/update/get/list for
owner_name, owner_team, tags, long_description, when_to_use,
when_not_to_use, example_questions (JSON list round-trip, empty
defaults, partial-update preservation)
* test_table_registry_v56_docs.py — update_docs for grain, platforms,
partition_col, history, gotchas; preserves v52 docs columns
* test_api_data_packages_v56.py — PUT/POST/GET for all new fields,
field-level validation (tag count, bullet length, description size),
virtual badge derivation (curated/new)
* test_api_registry_docs_v56.py — PATCH /api/admin/registry/{id}/docs
for v56 fields, validation, RBAC unchanged
* test_web_catalog_package_detail_v56.py — /catalog/p/<slug> rewrite
asserts on rendered owner line, tag pills, badges, What it is,
Use it when, Skip it when, Example questions, per-table extended
detail in collapsible row, key-gotcha distinctness, admin-only Edit
* test_web_stack_card_v56_metadata.py — Browse-grid card additions
(owner chip, tag chips, badges) without breaking back-compat for
rows missing the new fields
* test_data_packages_no_vendor_content.py — CI guard: scans app/ +
src/ + cli/ + config/ + scripts/ for Groupon-specific tokens from
the colleague's spec MD; fails if any leak into OSS surfaces
* test_db_schema_version.py — bumped 55 → 56 with rationale
Plus updates schema-version assertion to 56. Implementation lands in
subsequent commits (schema migration → repo → API → templates).
* feat(v56): schema + repo for extended data-packages content
Schema additions (ALTER ADD COLUMN IF NOT EXISTS — additive + idempotent):
* data_packages: owner_name, owner_team, tags, long_description,
when_to_use, when_not_to_use, example_questions (JSON-as-VARCHAR for
the lists)
* table_registry: grain, platforms, partition_col, history, gotchas
(extends the v52 sample_questions / things_to_know / pairs_well_with
docs surface with structured per-table content)
Repo extensions:
* DataPackagesRepository.create + update accept the new fields with
the same Optional-is-no-op contract as v51 (pass an empty list to
clear a JSON column)
* _decode_row decodes the new JSON-list columns to Python lists; NULL
rounds back to [] so callers don't branch
* TableRegistryRepository.update_docs grew the v56 fields alongside
the existing v52 ones — single PATCH can write either tier
atomically
* TableRegistryRepository._decode_row picks up platforms + gotchas in
the same NULL-tolerant decoder
22 repo + migration tests passing. API + UI land in subsequent commits.
* feat(v56): API surface for extended data-packages + per-table docs
CreateDataPackageRequest + UpdateDataPackageRequest grew the v56 fields
(owner_name, owner_team, tags, long_description, when_to_use,
when_not_to_use, example_questions) with per-field validators that
match the Foundry spec checklist:
* tags: ≤8 entries × ≤30 chars
* long_description: ≤4000 chars
* use/skip: ≤8 bullets × ≤200 chars
* example_questions: ≤12 × ≤200 chars
_serialize emits all v56 fields plus a virtual ``badges`` list derived
server-side at render time (no DB column needed): "curated" when the
creator is in the Admin group, "new" within 30 days of created_at.
Backdating created_at or admin-status changes pick up automatically.
PATCH /api/admin/registry/{id}/docs extended with v56 structured
per-table fields (grain, platforms, partition_col, history, gotchas).
gotchas: list of {key: bool, body: str} Pydantic models with the same
≤8 cap; first key=true entry becomes the Key gotcha on the rendered
package detail page. PATCH echoes the fresh state so callers can
re-render without a second GET.
26 API tests passing (16 data-packages + 10 registry-docs).
* feat(v56): /catalog/p/<slug> rewrite + Browse-grid card augmentation
The third (and final) v56 commit lights up the UI surfaces backed by
the schema + API commits earlier in this PR:
* /catalog/p/<slug> template rebuilt around the Foundry spec's
section ladder — hero (icon + name + badges + owner + tags +
description + meta + Add-to-stack), "What it is" markdown body,
paired "Use it when / Skip it when" panels, "Tables in this
package" with collapsible per-table extended detail (grain /
platforms / partition_col / history / gotchas + sample questions),
and an "Example questions you can ask Claude" prompt panel. Each
section guarded by ``{% if pkg.<field> %}`` — empty content fields
hide the section entirely (no "No X yet" placeholder noise on the
public-facing drilldown).
* router catalog_package_detail hydrates per-table v56 fields onto
the tables list + derives the virtual badges (curated / new)
server-side from creator-in-Admin + 30-day created_at.
* StackResolver.ResourceEntry grew owner_name / owner_team / tags /
badges; _fetch_entries pulls the v56 columns + computes badges
once per fetch using a single Admin-group SELECT.
* _data_package_entry_dict adapter passes the new fields through to
the macro; tags are merged source-type pills + admin-authored
category tags per the spec convention.
* _stack_card.html renders the v56 badges (top-left, data-badge=
hooks) + the owner chip (data-card-owner hook) without breaking
back-compat — pre-v56 rows render unchanged.
* Admin PUT handler strips the v56 docs fields from the
read-modify-write merged dict so register() doesn't blow up
with the now-larger row shape (same pattern as the v52 docs
fields stripping).
5115 tests passing (+98 v56 + 18 fixed regressions from the merged-
register PUT path), 35 skipped.
* fix(rbac): Edit-on-package + Group-access 'required' persistence + CI vendor guard
Three related bugs reported on the merged-with-main branch:
1. Clicking Edit on a Data Package card landed on /admin/tables with
a `#<pkg.id>` hash that nothing listened to — admin saw the global
table listing, not the editor for that specific package. Added a
`?edit_package=<pkg_id>` query-param handler in admin_tables.html
(analog to the existing `?edit=<table_id>` and `?assign_to=<pkg_id>`
patterns) that calls openEditDataPackageModal on DOMContentLoaded
after a 250ms layout settle. Updated the package-detail Edit link
to use the new query param.
2. Setting Group Access to 'required' didn't persist — re-opening
the modal showed 'available'. Root cause was the v49
``resource_grants.requirement`` enum existing in the DB but the
POST /api/admin/grants endpoint not surfacing it: ``CreateGrantRequest``
declared only group_id + resource_type + resource_id, so Pydantic
silently dropped the matrix's ``requirement: 'required'`` payload
and the new row landed at the DB column default ('available').
Plumbed ``requirement`` through ``CreateGrantRequest`` →
``ResourceGrantsRepository.create`` so the value persists in one
round-trip. Plus a UNIQUE-constraint race in the matrix
diff-apply: DELETE-old + POST-new ran in parallel via
``Promise.allSettled``, so POST could fire first and trip the
unique check before DELETE freed the slot. Switched to sequential
(await all deletes; then await all writes) across all three
matrices (Edit Data Package, Edit Memory Domain, Edit Recipe).
3. CI vendor-content guard ``test_no_groupon_specific_strings_in_oss``
tripped on two of my own docstrings: a "Foundry Data team" mention
in two src/db.py comments + an ``s1_session_landings`` example in
cli/skills/agnes-table-registration.md. Rephrased the comments to
"extended-descriptions admin spec" and replaced the example with
a generic ``events_daily`` table name.
5164 tests passing, 35 skipped (+4 regression tests pinning the POST
/api/admin/grants requirement contract). Vendor guard back to green.
* fix(catalog): admin Browse path drops v58 card fields
The /catalog and /memory admin god-mode branch built ResourceEntry
instances inline from pkg_repo.list() / domains_repo.list() and skipped
owner_name, owner_team, tags, and derived badges (curated/new). Visible
symptom: a package with an owner + tags rendered with the v56 chrome
for non-admin viewers but as a bare card for admins.
Adds StackResolver.browse_admin(user_id, resource_type) — admin god-mode
Browse that walks the full table but routes through the same
_fetch_entries enrichment pass as browse(), so admin + non-admin Browse
stay visually consistent. Both /catalog and /corporate-memory routes
switch to it.
Regression test in tests/test_stack_resolver_browse_admin.py covers:
owner/tags propagation, new/curated badge derivation, in_stack from
admin subscriptions, all-packages-regardless-of-grants, and the
ValueError for unsupported resource types.
* fix(catalog): three /catalog tab-strip UX bugs
1. Required Remove → red toast
browse_admin passed empty required_ids to _fetch_entries, so the
admin's own required grants surfaced as 'available' and the macro
rendered an actionable Remove button that POST /unsubscribe 400'd
on. Now derives required_ids from the admin's own groups so
Required packages render with the disabled "In stack (required)"
button. Regression test in test_stack_resolver_browse_admin.py.
2. Remove green-toasts but card stays until refresh
The My-Stack empty-state placeholder was only emitted server-side
when stack_entries was empty at render time. Removing the last
card left the tab completely blank — users read that as "Remove
didn't work, let me refresh". Both grid + empty-state are now
always rendered with one of them initially hidden; the JS swaps
visibility on add/remove instead of injecting DOM. Same fix in
/corporate-memory.
3. "What are Recipes?" + ambiguous (admin) suffix
Recipes tab now carries its own curator-block explainer (the
shared one was moved inside Browse view so it doesn't bleed
across tabs). The grey "(admin)" suffix becomes a yellow
.admin-only-hint chip with a title tooltip — visibility hint is
now unambiguous: yellow chip = "only you see this", non-admins
don't see the affordance at all.
* schema: renumber v51..v58 → v52..v59 to make room for main's v51
Main 0.54.29 introduced a NEW v51 (table_registry.bq_fqn — issue #343)
that releases ahead of this branch. The unified-stack chain v51..v58
shifts up by one so main's v51 stays as the released schema and ours
become v52..v59. Function names, internal version bumps, dispatch
ladder thresholds, and the migration-test references all move
together. Subsequent merge with main lands the bq_fqn column at the
freed v51 slot.
* fix(seed): seed admin lands in BOTH Admin AND Everyone groups
The LOCAL_DEV_MODE / SEED_ADMIN_EMAIL bootstrap only added the seed
user to Admin. Everyone-scoped grants — the canonical "every-user-
sees-this" pattern for Required onboarding — didn't surface for the
seed admin's own /catalog because they weren't in Everyone. Symptom:
admin grants a Required-tier package to Everyone, then sees it on
/catalog still rendered with an "Add to stack" button (because the
admin's resolved required_ids was empty for that package).
The dual-membership keeps Admin (authorization) and Everyone
(default-grant target) intentionally separate per the design comment
on UserRepository.create — every membership remains traceable to a
concrete row, just now with a system_seed row in Everyone too. Both
INSERTs go through UserGroupMembersRepository.add_member which is
idempotent on (user_id, group_id), so re-fires on every lifespan
startup don't duplicate rows.
Regression test in test_main_seed_admin_everyone.py.
* style: unify admin-only hints across marketplace + memory detail pages
Replaces three stale ``(admin)`` parentheticals with the same yellow
``admin-only`` chip introduced for /catalog tab actions. Same tooltip
copy ("Visible only to admins — analysts won't see this …") so the
visibility hint is unmistakable wherever it appears:
- Hard delete on marketplace_plugin_detail (admin-only destructive
action — same gating as the original suffix conveyed).
- Hard delete on marketplace_item_detail (same).
- Edit link on memory_domain_detail (title-attr only before; now a
visible chip too).
Non-admin viewers never saw these affordances — the gates are
unchanged. Pure styling pass for consistency.
* fix(catalog): exclude soft-deleted data packages + memory domains from Browse
``StackResolver._fetch_entries`` and ``browse_admin`` were querying
data_packages / memory_domains without a ``deleted_at IS NULL`` guard.
A package soft-deleted via /admin/* (v54 soft-delete contract) stayed
visible on /catalog and /memory until either an Undo or a hard delete
— directly contradicting the soft-delete UX which is supposed to
remove the affordance immediately and only retain the row for the
Undo window.
The repository accessors (DataPackagesRepository.list,
MemoryDomainsRepository.list, list_packages_of_table, etc.) already
filter deleted rows; this commit brings the resolver's direct SQL in
line with that contract.
Regression test in test_stack_resolver_browse_admin.py.
* fix(catalog): Add/Remove updates full card chrome, not just button
The previous _applyStackChange flipped only the footer button label —
the card border (.is-in-stack class), top-right "In stack" badge, and
button color class (--add / --remove) stayed at their server-rendered
state. After Add the user saw the button checkmark but the rest of
the card still looked like "available, not in stack". They read this
as "the change didn't take — let me refresh".
This commit makes the optimistic update mirror what the server-side
macro renders for the new state:
* ``c.classList.toggle('is-in-stack', becameInStack)`` — flips the
border + visual state class.
* Top-right ``.stack-card__req-badge--instack`` badge is injected on
Add, removed on Remove (skipped when ``data-requirement='required'``
— that slot is owned by the Required badge).
* Button text is "Remove" / "+ Add to stack" matching the macro
(was "✓ In stack" which was visually nice but inconsistent).
* Button color class --add / --remove swaps so the destructive Remove
tint kicks in immediately.
The clone-into-My-Stack path applies the same updates so the new card
in My Stack reads identically to a server-rendered in_stack card.
Mirrored in /corporate-memory.
* fix(memory): four Devin-review bugs on /memory drill-down + manifest
PR #333 Devin review surfaced four real bugs that ship a broken
/memory experience even though the unit tests passed.
1. Manifest md5 omits is_required + content (app/api/sync.py:836-840)
_build_memory_domains_section hashed only (id|title|status) per
item. _build_per_domain_markdown routes items between "## Required"
and "## Approved" by is_required and embeds full content — so an
admin edit of either dimension left the manifest md5 unchanged,
`agnes pull` skipped the re-fetch, and the analyst kept a stale
bundle.md. Now both fields participate in the hash.
2. required_count always 0 (src/repositories/memory_domains.py)
list_items_of_domain only SELECTed (id, title, status) so the
`it.get("is_required")` in the manifest builder always evaluated
to None → required_count = 0 regardless of actual state. The
manifest builder advertised a count it could never compute. Now
projects is_required + content too (required by fix 1 anyway).
3. Vote URL 404 (memory_domain_detail.html:289-290)
Constructed `/api/memory/items/{id}/vote` but the route is
`/api/memory/{id}/vote`. Every upvote/downvote button was a
silent no-op.
4. Dismiss/undismiss URL + method both wrong (memory_domain_detail.html:296-305)
Constructed `/api/memory/items/{id}/dismiss` (extra /items/) and
/undismiss (no such route — undismiss is DELETE on /dismiss).
Both buttons silently 404'd. Now POST + DELETE on
`/api/memory/{id}/dismiss` per app/api/memory.py:635/675.
* fix: multi-agent reviewer findings — vendor-token scrubs + manifest md5 predicate + soft-delete filter
Three reviewer findings from the multi-agent review on PR #333,
fixed in-place per CLAUDE.md issue-economy rule.
Reviewer-rules (Important — vendor-agnostic OSS):
- app/main.py:218 comment: replaced 'foundryai-prod' with generic
'a customer prod instance' phrasing. Public OSS repo must not
carry customer-specific tokens (CLAUDE.md § Project conventions).
- tests/test_table_registry_v56_docs.py:70 fixture string:
replaced "user_brand_affiliation = 'groupon'" with 'acme' on
the same rule.
Reviewer-architecture (closes still-unresolved Devin 🚩 ANALYSIS):
- app/api/sync.py _build_memory_domains_section: md5 hash loop now
filters items to the SAME predicate the bundle renderer uses
(is_required OR status='approved'). Pre-fix the hash iterated ALL
items but _build_per_domain_markdown only rendered the union of
required items + approved-non-required items — so an admin edit
to a pending/rejected non-required item flipped the md5 against
an identical-bytes bundle, triggering a wasteful re-fetch on
every analyst's next 'agnes pull'. The earlier commit fixed the
hash-input fields (is_required + content); this closes the
set-of-items asymmetry Devin separately flagged.
Reviewer-RBAC (minor cleanup):
- app/resource_types.py _data_package_blocks and _memory_domain_blocks
now filter 'WHERE deleted_at IS NULL' (v54 soft-delete column) so
the /admin/access UI doesn't surface soft-deleted entities as
grantable. Mirrors the existing filter on _recipe_blocks. No
security leak pre-fix (resolver double-filters and re-checks at
serve time), just UI cleanliness.
- app/services/stack_resolver.py add_to_stack: docstring note
added explaining that authorization is enforced at the API layer
(app/api/stack.py can_access gate), not at the resolver. The
initial review suggested adding a defensive 403 here, but that
broke 5 existing tests that legitimately call add_to_stack
directly without setting up grants first; the docstring captures
the contract instead. stack() already intersects subscriptions
with current available_ids on every read, so a 'zombie' row from
a misuse never leaks into the user-facing manifest.
* release: 0.55.0 — unified Browse + My Stack (Data Packages + Memory), schema v48→v59, 3 BREAKING
5805 lines
283 KiB
HTML
5805 lines
283 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title %}Table Management - {{ config.INSTANCE_NAME }}{% endblock %}
|
||
|
||
{% block body_attrs %}data-source-type="{{ data_source_type }}"{% endblock %}
|
||
|
||
{% block head_extra %}
|
||
<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">
|
||
<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;
|
||
}
|
||
|
||
.mode-badge.mode-internal {
|
||
background: rgba(15, 118, 110, 0.1);
|
||
color: #0F766E;
|
||
}
|
||
|
||
.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);
|
||
/* BLOCKER fix (UX review #1): cap height at 90vh + scroll
|
||
* the body so Create + Edit Data Package modals don't push
|
||
* the Submit footer off-screen on 720px viewports. Flex
|
||
* layout keeps header + footer pinned and grows the body. */
|
||
max-height: 90vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
.modal .modal-body {
|
||
overflow-y: auto;
|
||
flex: 1 1 auto;
|
||
}
|
||
.modal .modal-header,
|
||
.modal .modal-footer {
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
.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>
|
||
{% endblock %}
|
||
|
||
{% block layout %}
|
||
<!-- ═══════════════ PAGE TITLE ═══════════════ -->
|
||
<div class="page-title">
|
||
<h1>Tables & Data Packages</h1>
|
||
<p>Group registered tables into Data Packages so analysts can opt-in to bundles.</p>
|
||
</div>
|
||
|
||
<!-- ═══════════════ CONTENT ═══════════════ -->
|
||
<div class="content">
|
||
|
||
{# ─── Top action bar (package-centric, replaces connector-tab nav) ───
|
||
Single row of high-level actions. The "+ Register new table"
|
||
dropdown picks a connector → opens the matching modal (BQ /
|
||
Keboola / Jira) instead of letting the connector drive the
|
||
page layout. #}
|
||
<section id="adminTablesActionBar" class="card" style="margin-bottom: 16px;">
|
||
<div class="card-body" style="display:flex; gap:8px; flex-wrap:wrap; align-items:center; padding:12px 16px;">
|
||
<div style="position:relative;">
|
||
<button id="registerNewTableBtn" class="btn btn-primary" type="button"
|
||
onclick="toggleRegisterNewTableMenu(event)">
|
||
+ Register new table ▾
|
||
</button>
|
||
<div id="registerNewTableMenu"
|
||
style="display:none; position:absolute; top:100%; left:0; margin-top:4px;
|
||
background:var(--surface); border:1px solid var(--border); border-radius:8px;
|
||
box-shadow:var(--shadow-md); min-width:220px; z-index:20; padding:6px;">
|
||
<button class="btn btn-secondary" type="button" data-register-source="bigquery"
|
||
style="display:block; width:100%; text-align:left; margin-bottom:4px;"
|
||
onclick="closeRegisterNewTableMenu(); openRegisterModal('bigquery')">
|
||
BigQuery
|
||
</button>
|
||
<button class="btn btn-secondary" type="button" data-register-source="keboola"
|
||
style="display:block; width:100%; text-align:left; margin-bottom:4px;"
|
||
onclick="closeRegisterNewTableMenu(); openRegisterModal('keboola')">
|
||
Keboola
|
||
</button>
|
||
<a class="btn btn-secondary" type="button"
|
||
href="docs/connectors/jira.md"
|
||
style="display:block; width:100%; text-align:left; text-decoration:none;"
|
||
onclick="closeRegisterNewTableMenu()">
|
||
Jira (webhook-driven — see docs)
|
||
</a>
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-secondary" type="button" onclick="groupTablesByBucket()"
|
||
title="One-click: create one Data Package per distinct bucket and assign its tables">
|
||
Group tables by bucket
|
||
</button>
|
||
<button class="btn btn-secondary" type="button" onclick="openBulkAssignModal('')">
|
||
Bulk assign tables
|
||
</button>
|
||
<button class="btn btn-secondary" type="button" onclick="openCreateDataPackageModal('', null)">
|
||
+ New Data Package
|
||
</button>
|
||
<span style="flex:1;"></span>
|
||
<details id="cacheWarmupCard" style="margin:0;">
|
||
<summary style="cursor:pointer; user-select:none; font-size:13px; color:var(--text-secondary);">
|
||
<span id="cacheWarmupSummary">Cache freshness — loading…</span>
|
||
</summary>
|
||
<div style="margin-top:8px; padding:12px; background:var(--background); border-radius:8px;">
|
||
<div id="cacheWarmupProgress" style="margin-bottom:8px;"></div>
|
||
<progress id="cacheWarmupBar" max="100" value="0" style="width:100%; display:none;"></progress>
|
||
<button class="btn btn-secondary btn-sm" id="cacheWarmupRunBtn" onclick="cacheWarmupRun()"
|
||
style="margin-top:8px;">
|
||
Re-warm all
|
||
</button>
|
||
<details id="cacheWarmupDetails" style="margin-top:8px;">
|
||
<summary style="cursor:pointer; user-select:none; font-size:12px;">Show log</summary>
|
||
<p id="cacheWarmupHint" style="font-size:11px; color:var(--text-secondary); margin:6px 0 0;">
|
||
Live log from <em>Re-warm all</em>. Historical runs aren't persisted; check
|
||
<a href="/admin/activity">/admin/activity</a> for the audit trail.
|
||
</p>
|
||
<pre id="cacheWarmupLog" style="background:#0a0a0a; color:#dcdcdc; font-family:ui-monospace, Menlo, monospace; font-size:11px; padding:6px; max-height:200px; overflow-y:auto; margin-top:6px; border-radius:4px;"></pre>
|
||
</details>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
</section>
|
||
|
||
{# ─── Package-centric layout (replaces the connector-tab nav) ───
|
||
Hydrated client-side by loadAdminTablesLayout(): pulls both
|
||
/api/admin/data-packages and /api/admin/registry, renders one
|
||
collapsible <details> per package containing its member tables,
|
||
then an "Unpackaged tables" section for everything else.
|
||
|
||
"On the side" data-packages was the wrong framing — packages ARE
|
||
the org structure now, so every table appears under either a
|
||
package or the explicit Unpackaged bucket. Bucket / source_type
|
||
survive as inline tags but no longer drive the layout. #}
|
||
<section id="adminTablesLayout" class="card" style="margin-bottom: 16px;">
|
||
<div class="card-body" id="adminTablesLayoutBody" style="padding:16px;">
|
||
{# Search bar — UX parity with /catalog + /memory. Filters the
|
||
package details + the table rows inside them; an unpackaged
|
||
row matches by table name / source_type / bucket. Hides any
|
||
package whose name doesn't match AND has zero matching rows. #}
|
||
<div style="margin-bottom: 12px; display:flex; align-items:center; gap:8px;">
|
||
<input id="adminTablesSearch" type="search"
|
||
class="form-input"
|
||
placeholder="Filter packages and tables by name, source, or bucket…"
|
||
oninput="filterAdminTablesLayout()"
|
||
style="flex:1; max-width:520px;">
|
||
<span id="adminTablesSearchHint"
|
||
style="font-size:12px; color:var(--text-secondary);"></span>
|
||
</div>
|
||
<div id="adminTablesLayoutPackages">
|
||
<div style="padding:16px; color:var(--text-secondary); font-size:13px; text-align:center;">
|
||
Loading packages…
|
||
</div>
|
||
</div>
|
||
<div id="adminTablesLayoutUnpackaged" style="margin-top:20px;"></div>
|
||
</div>
|
||
</section>
|
||
|
||
{# Register modals (BigQuery / Keboola) live as top-level overlays,
|
||
reachable via the "+ Register new table" dropdown in the action
|
||
bar above. The connector tabs that used to drive the page layout
|
||
were dropped — every table now appears in the package-centric
|
||
layout regardless of source_type. #}
|
||
|
||
<!-- ── BigQuery Register Modal ── -->
|
||
<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>
|
||
{# v49 (Task 8.8): Data Packages chip-input field.
|
||
The submit handler (registerBqTable) is intentionally
|
||
NOT wired to forward `package_ids` to /api/admin/tables
|
||
in this pass — backend extension lives in a focused
|
||
follow-up. For now the chip-input persists chosen
|
||
packages locally and the admin can attach via
|
||
`POST /api/admin/data-packages/<id>/tables` from the
|
||
CLI / package admin UI. #}
|
||
<div class="form-group">
|
||
<label class="form-label">Data Packages <span class="optional">(optional)</span></label>
|
||
<div class="chip-input"
|
||
data-source-url="/api/admin/data-packages"
|
||
data-allow-create="true"
|
||
data-name="bq_package_ids"
|
||
data-placeholder="Type to search or create…"
|
||
data-chip-input="data_package"></div>
|
||
<div class="form-hint">Bundle this table into one or more Data Packages so analysts can opt-in to it as a group.</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>
|
||
|
||
{# Data Packages chip-input — parity with the legacy
|
||
and Keboola edit modals. Hydrated on open with
|
||
the table's current memberships; saveBqTabEdit
|
||
diffs against `_editBqOriginalPackageIds` and
|
||
emits the minimal POST/DELETE delta. #}
|
||
<div class="form-group">
|
||
<label class="form-label">Data Packages <span class="optional">(optional)</span></label>
|
||
<div class="chip-input"
|
||
id="editBqPackagesChips"
|
||
data-source-url="/api/admin/data-packages"
|
||
data-allow-create="true"
|
||
data-name="bq_edit_package_ids"
|
||
data-placeholder="Type to search or create…"
|
||
data-chip-input="data_package"></div>
|
||
<div class="form-hint">Bundle this table into one or more Data Packages so analysts can opt-in to it as a group.</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>
|
||
|
||
<!-- ── Keboola Register Modal ── -->
|
||
<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>
|
||
|
||
{# Data Packages chip-input — parity with the BQ
|
||
and legacy edit modals. Hydrated on open;
|
||
saveKeboolaTabEdit diffs vs
|
||
`_editKbOriginalPackageIds` on save. #}
|
||
<div class="form-group">
|
||
<label class="form-label">Data Packages <span class="optional">(optional)</span></label>
|
||
<div class="chip-input"
|
||
id="editKbPackagesChips"
|
||
data-source-url="/api/admin/data-packages"
|
||
data-allow-create="true"
|
||
data-name="kb_edit_package_ids"
|
||
data-placeholder="Type to search or create…"
|
||
data-chip-input="data_package"></div>
|
||
<div class="form-hint">Bundle this table into one or more Data Packages so analysts can opt-in to it as a group.</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>
|
||
|
||
</div>
|
||
|
||
{# Connector-based listings (Jira read-only hint, internal tables
|
||
section, per-tab Register buttons) lived inside the now-deleted
|
||
tab-content sections. Their content is folded into the
|
||
package-centric layout above: every table — including jira/* and
|
||
internal — appears under a Data Package or in Unpackaged tables.
|
||
|
||
Register modals (registerBqModal / registerKeboolaModal) remain in
|
||
DOM as top-level overlays and are opened from the
|
||
"+ Register new table" dropdown in the action bar. The
|
||
data-source-type marker lives on <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>
|
||
|
||
{# Data Packages chip-input — parity with the BigQuery
|
||
edit modal. saveTableEdit() diffs the chip selection
|
||
against the current membership and emits add/remove
|
||
junction calls so admins can manage package membership
|
||
without leaving the edit modal. #}
|
||
<div class="form-group">
|
||
<label class="form-label">Data Packages <span class="optional">(optional)</span></label>
|
||
<div class="chip-input"
|
||
id="editGenericPackagesChips"
|
||
data-source-url="/api/admin/data-packages"
|
||
data-allow-create="true"
|
||
data-name="legacy_package_ids"
|
||
data-placeholder="Type to search or create…"
|
||
data-chip-input="data_package"></div>
|
||
<div class="form-hint">Bundle this table into one or more Data Packages so analysts can opt-in to it as a group.</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>
|
||
|
||
<!-- ═══════════════ Create Data Package modal (Task 8.10) ═══════════════
|
||
Fired from a chip-input `chip-create` event — the chip-input emits
|
||
a CustomEvent with the typed name; the handler below opens this
|
||
modal pre-filled, POSTs to /api/admin/data-packages, and adds the
|
||
freshly-created chip back into the chip-input host via .addChip(). -->
|
||
<div class="modal-overlay" id="createDataPackageModal" style="display:none;">
|
||
<div class="modal" style="max-width:520px;">
|
||
<div class="modal-header">
|
||
<h2>Create Data Package</h2>
|
||
<button class="modal-close" onclick="closeCreateDataPackageModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-group">
|
||
<label class="form-label">Name</label>
|
||
<input id="cdp-name" type="text" class="form-input" placeholder="Sales bundle">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Slug</label>
|
||
<input id="cdp-slug" type="text" class="form-input" placeholder="sales-bundle">
|
||
<div class="form-hint">URL-safe identifier; auto-derived from Name.</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Description <span class="optional">(optional)</span></label>
|
||
<textarea id="cdp-desc" class="form-input" rows="2"></textarea>
|
||
</div>
|
||
{# v51 lifecycle + classification — drive the hero filter
|
||
checkboxes on /catalog and the eyebrow line above the
|
||
card title. Both fields are admin-facing only and have
|
||
safe defaults (status=prod, category=null). #}
|
||
<div class="form-group" style="display:flex; gap:12px;">
|
||
<div style="flex:1;">
|
||
<label class="form-label">Status</label>
|
||
<select id="cdp-status" class="form-input" style="height:38px;">
|
||
<option value="prod" selected>Prod — ready for analyst use</option>
|
||
<option value="poc">POC — try-before-you-buy</option>
|
||
<option value="coming-soon">Coming soon — visible but not usable yet</option>
|
||
<option value="draft">Draft — admin-only, hidden from analysts</option>
|
||
</select>
|
||
</div>
|
||
<div style="flex:1;">
|
||
<label class="form-label">Category <span class="optional">(optional)</span></label>
|
||
<input id="cdp-category" type="text" class="form-input"
|
||
placeholder="e.g. Sessions & Traffic">
|
||
<div class="form-hint">Eyebrow line above the card title. Keep it short and consistent.</div>
|
||
</div>
|
||
</div>
|
||
<div class="form-group" style="display:flex; gap:12px;">
|
||
<div style="flex:1;">
|
||
<label class="form-label">Icon</label>
|
||
<input id="cdp-icon" type="text" class="form-input" value="📦" maxlength="4">
|
||
</div>
|
||
<div style="flex:1;">
|
||
<label class="form-label">Color</label>
|
||
{# v51 palette row — admin clicks a swatch to set #cdp-color.
|
||
Free-form picker stays below as the escape hatch. Palette
|
||
is a vendor-neutral design-system set (teal/blue/violet/
|
||
pink/red/amber/emerald/slate) — no brand colors. #}
|
||
<div class="cf-palette-row" data-target="cdp-color"></div>
|
||
{# type=color forces the native swatch picker so admins can't
|
||
type/append a malformed hex into the field. #}
|
||
<input id="cdp-color" type="color" class="form-input" value="#e0f2fe"
|
||
style="height:32px; padding:2px; cursor:pointer;">
|
||
</div>
|
||
</div>
|
||
{# Cover image upload (v50). When set, the /catalog card renders
|
||
<img src=cover_image_url> instead of letter initials — closes
|
||
the visual gap with /marketplace cards. PNG/JPEG/GIF/WebP, 5
|
||
MiB cap server-side. #}
|
||
<div class="form-group">
|
||
<label class="form-label">Cover image <span class="optional">(optional)</span></label>
|
||
<div style="display:flex; gap:12px; align-items:center;">
|
||
<div id="cdp-cover-preview"
|
||
style="width:120px; height:72px; border-radius:8px;
|
||
border:1px solid var(--border); background:#f1f5f9;
|
||
display:flex; align-items:center; justify-content:center;
|
||
font-size:12px; color:var(--text-secondary); overflow:hidden;">
|
||
No image
|
||
</div>
|
||
<div style="flex:1;">
|
||
<input id="cdp-cover-file" type="file"
|
||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||
onchange="onCoverFilePicked(this, 'cdp')">
|
||
<input id="cdp-cover-url" type="hidden" value="">
|
||
<div class="form-hint">PNG / JPEG / GIF / WebP, max 5 MiB. Uploads on Save.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{# Inline RBAC matrix — replaces the modal-on-modal step-2 that
|
||
popped up after Create succeeded. <details> is collapsed by
|
||
default so the modal stays compact for admins who'll set
|
||
access later via the Resource Access page; opening it loads
|
||
the group list lazily. submitCreateDataPackage() reads any
|
||
chosen requirements and POSTs grants in parallel. #}
|
||
<details class="form-group" id="cdp-rbac-details"
|
||
style="border:1px solid var(--border); border-radius:8px; padding:8px 12px;">
|
||
<summary style="cursor:pointer; font-weight:600; font-size:13px; user-select:none;">
|
||
Group access
|
||
<span style="color:var(--text-secondary); font-weight:normal; font-size:12px;">(optional)</span>
|
||
</summary>
|
||
<p class="form-hint" style="margin:8px 0;">
|
||
Grant per-group access tiers — <em>available</em> shows the
|
||
package in the group's Browse; <em>required</em>
|
||
auto-installs it into the group's stack on next pull. Leave
|
||
everything blank to manage access later from
|
||
<a href="/admin/access">Resource access</a>.
|
||
</p>
|
||
<div id="cdp-rbac-rows"
|
||
style="display:flex; flex-direction:column; gap:6px; max-height:30vh; overflow-y:auto;">
|
||
<div class="loading" style="padding:6px; color:var(--text-secondary); font-size:12px;">
|
||
Click to load groups…
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeCreateDataPackageModal()">Cancel</button>
|
||
<button class="btn btn-primary" id="cdp-submit-btn" onclick="submitCreateDataPackage()">
|
||
Create & assign
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// ── Cover image upload helpers (v50) ─────────────────────────────
|
||
// Shared between Create / Edit Data Package modals. The `prefix`
|
||
// arg is the modal's id prefix (`cdp` or `edp`); same DOM-id
|
||
// convention `<prefix>-cover-file`, `<prefix>-cover-url`,
|
||
// `<prefix>-cover-preview`. Uploads on file pick (not on Save) so
|
||
// the admin gets an immediate preview + failure-feedback loop;
|
||
// the URL stashed in the hidden input rides along on the eventual
|
||
// Save POST/PUT body.
|
||
async function onCoverFilePicked(input, prefix) {
|
||
const file = input.files && input.files[0];
|
||
if (!file) return;
|
||
const previewEl = document.getElementById(prefix + '-cover-preview');
|
||
const urlEl = document.getElementById(prefix + '-cover-url');
|
||
if (file.size > 5 * 1024 * 1024) {
|
||
alert('Image is too large (max 5 MiB).');
|
||
input.value = '';
|
||
return;
|
||
}
|
||
previewEl.innerHTML = '<span style="font-size:11px;">Uploading…</span>';
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append('file', file);
|
||
const r = await fetch('/api/admin/uploads/cover-image', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
body: fd,
|
||
});
|
||
if (!r.ok) {
|
||
const detail = await r.json().catch(function() { return {}; });
|
||
alert('Upload failed: ' + (detail.detail || r.statusText));
|
||
previewEl.textContent = 'No image';
|
||
input.value = '';
|
||
return;
|
||
}
|
||
const body = await r.json();
|
||
urlEl.value = body.url;
|
||
previewEl.innerHTML = '';
|
||
var img = document.createElement('img');
|
||
img.src = body.url;
|
||
img.alt = '';
|
||
img.style.cssText = 'width:100%;height:100%;object-fit:cover;display:block;';
|
||
previewEl.appendChild(img);
|
||
} catch (e) {
|
||
alert('Network error: ' + e.message);
|
||
previewEl.textContent = 'No image';
|
||
input.value = '';
|
||
}
|
||
}
|
||
function clearCoverImage(prefix) {
|
||
// Empty string is the API contract for "remove the existing cover".
|
||
// The Save handler reads the hidden input and ships it as-is.
|
||
document.getElementById(prefix + '-cover-url').value = '';
|
||
document.getElementById(prefix + '-cover-file').value = '';
|
||
var p = document.getElementById(prefix + '-cover-preview');
|
||
p.innerHTML = 'No image';
|
||
p.dataset.cleared = '1';
|
||
}
|
||
function _renderCoverPreview(prefix, url) {
|
||
var p = document.getElementById(prefix + '-cover-preview');
|
||
if (!p) return;
|
||
if (url) {
|
||
p.innerHTML = '';
|
||
var img = document.createElement('img');
|
||
img.src = url;
|
||
img.alt = '';
|
||
img.style.cssText = 'width:100%;height:100%;object-fit:cover;display:block;';
|
||
p.appendChild(img);
|
||
} else {
|
||
p.innerHTML = 'No image';
|
||
}
|
||
}
|
||
|
||
// Track which chip-input host triggered the create flow so we can
|
||
// append the new chip back into it on success.
|
||
let _cdpHost = null;
|
||
|
||
function openCreateDataPackageModal(typed, host) {
|
||
_cdpHost = host;
|
||
document.getElementById('cdp-name').value = typed || '';
|
||
document.getElementById('cdp-slug').value =
|
||
(typed || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||
document.getElementById('cdp-desc').value = '';
|
||
document.getElementById('cdp-icon').value = '📦';
|
||
document.getElementById('cdp-color').value = '#e0f2fe';
|
||
// Reset cover image inputs on every open so a previous typed-and-
|
||
// cancelled value doesn't leak into the new package.
|
||
var coverUrl = document.getElementById('cdp-cover-url');
|
||
var coverFile = document.getElementById('cdp-cover-file');
|
||
if (coverUrl) coverUrl.value = '';
|
||
if (coverFile) coverFile.value = '';
|
||
_renderCoverPreview('cdp', '');
|
||
document.getElementById('createDataPackageModal').style.display = 'flex';
|
||
}
|
||
function closeCreateDataPackageModal() {
|
||
_cdpHost = null;
|
||
// Re-collapse + invalidate the lazy-loaded RBAC matrix so the
|
||
// next "+ New Data Package" starts with a clean, unloaded state.
|
||
const det = document.getElementById('cdp-rbac-details');
|
||
if (det) det.open = false;
|
||
_cdpRbacLoaded = false;
|
||
document.getElementById('createDataPackageModal').style.display = 'none';
|
||
}
|
||
|
||
// ── Edit Data Package modal ──────────────────────────────────────
|
||
// Opened from the Edit button on each /admin/tables package card.
|
||
// Loads pkg metadata + member tables; lets admin rename / change
|
||
// description / icon / color / delete; remove member tables inline.
|
||
async function openEditDataPackageModal(pkgId) {
|
||
document.getElementById('editDataPackageModal').style.display = 'flex';
|
||
document.getElementById('edp-id').value = pkgId;
|
||
document.getElementById('edp-tables-list').innerHTML =
|
||
'<div style="padding:12px; color:#5f6368;">Loading…</div>';
|
||
try {
|
||
const r = await fetch('/api/admin/data-packages/' + encodeURIComponent(pkgId),
|
||
{ credentials: 'same-origin' });
|
||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||
const pkg = await r.json();
|
||
document.getElementById('edp-name').value = pkg.name || '';
|
||
document.getElementById('edp-slug').value = pkg.slug || '';
|
||
document.getElementById('edp-desc').value = pkg.description || '';
|
||
document.getElementById('edp-icon').value = pkg.icon || '';
|
||
document.getElementById('edp-color').value = pkg.color || '#e0f2fe';
|
||
// v51: pre-fill status + category from the loaded package.
|
||
document.getElementById('edp-status').value = pkg.status || 'prod';
|
||
document.getElementById('edp-category').value = pkg.category || '';
|
||
// v50: hydrate cover image (URL + preview); clear the file
|
||
// input so a previous typed-and-cancelled file doesn't leak.
|
||
var coverUrl = document.getElementById('edp-cover-url');
|
||
var coverFile = document.getElementById('edp-cover-file');
|
||
var coverPreview = document.getElementById('edp-cover-preview');
|
||
if (coverPreview) delete coverPreview.dataset.cleared;
|
||
if (coverFile) coverFile.value = '';
|
||
if (coverUrl) coverUrl.value = pkg.cover_image_url || '';
|
||
_renderCoverPreview('edp', pkg.cover_image_url || '');
|
||
renderEdpTablesList(pkg.tables || []);
|
||
// Reset inline-add panel state (hidden + empty list) so it
|
||
// starts clean each open.
|
||
var addPanel = document.getElementById('edp-inline-add');
|
||
if (addPanel) addPanel.style.display = 'none';
|
||
} catch (e) {
|
||
alert('Failed to load package: ' + e.message);
|
||
closeEditDataPackageModal();
|
||
}
|
||
}
|
||
function closeEditDataPackageModal() {
|
||
// Reset the RBAC matrix the same way Create does — collapse the
|
||
// <details>, invalidate the loaded flag so the next open
|
||
// re-fetches against the (possibly different) package's grants.
|
||
const det = document.getElementById('edp-rbac-details');
|
||
if (det) det.open = false;
|
||
_edpRbacLoaded = false;
|
||
_edpRbacOriginal = {};
|
||
document.getElementById('editDataPackageModal').style.display = 'none';
|
||
}
|
||
|
||
// ── Edit DP RBAC matrix — lazy hydrate + diff-on-save ──────────
|
||
// Mirrors the Create flow's _cdpHydrate / _submit helpers but
|
||
// pre-fills the per-group dropdowns from existing grants so the
|
||
// matrix shows reality, not a blank slate. _edpRbacOriginal keeps
|
||
// a {groupId: requirement|""} snapshot taken at load time so the
|
||
// save handler can diff and only emit the minimum POST/DELETE.
|
||
let _edpRbacLoaded = false;
|
||
let _edpRbacOriginal = {};
|
||
async function _edpHydrateRbacMatrix() {
|
||
if (_edpRbacLoaded) return;
|
||
const pkgId = document.getElementById('edp-id').value;
|
||
const rowsEl = document.getElementById('edp-rbac-rows');
|
||
rowsEl.innerHTML = '<div class="loading" style="padding:6px; color:var(--text-secondary); font-size:12px;">Loading groups + grants…</div>';
|
||
try {
|
||
const [gResp, grResp] = await Promise.all([
|
||
fetch('/api/admin/groups', { credentials: 'same-origin' }),
|
||
fetch('/api/admin/grants?resource_type=data_package',
|
||
{ credentials: 'same-origin' }),
|
||
]);
|
||
if (!gResp.ok) throw new Error('groups HTTP ' + gResp.status);
|
||
if (!grResp.ok) throw new Error('grants HTTP ' + grResp.status);
|
||
const gBody = await gResp.json();
|
||
const groups = Array.isArray(gBody) ? gBody : (gBody.groups || []);
|
||
const grants = await grResp.json();
|
||
// Build group_id → requirement map for THIS package only.
|
||
// Multiple grants per (group, resource) shouldn't happen (UNIQUE
|
||
// constraint on the table) but be defensive — last write wins.
|
||
const current = {};
|
||
(grants || []).forEach(g => {
|
||
if (g.resource_id !== pkgId) return;
|
||
current[g.group_id] = g.requirement;
|
||
});
|
||
_edpRbacOriginal = Object.assign({}, current);
|
||
|
||
if (!groups.length) {
|
||
rowsEl.innerHTML = '<div style="padding:8px; color:var(--text-secondary); font-size:12px;">No groups defined yet. Create groups in <a href="/admin/access">Resource access</a> first.</div>';
|
||
return;
|
||
}
|
||
rowsEl.innerHTML = groups.map(g => {
|
||
const gid = String(g.id || g.name || '');
|
||
const gname = String(g.name || gid);
|
||
const cur = current[gid] || '';
|
||
const opt = (v, label) =>
|
||
'<option value="' + v + '"' + (cur === v ? ' selected' : '') + '>' + label + '</option>';
|
||
return (
|
||
'<div data-group-id="' + gid + '" '
|
||
+ 'style="display:flex; gap:8px; align-items:center; '
|
||
+ 'padding:6px; border:1px solid var(--border); border-radius:6px;">'
|
||
+ '<span style="flex:1;">' + gname + '</span>'
|
||
+ '<select class="edp-rbac-req" style="padding:4px; border:1px solid var(--border); border-radius:4px;">'
|
||
+ opt('', '(no grant)')
|
||
+ opt('available', 'available')
|
||
+ opt('required', 'required')
|
||
+ '</select>'
|
||
+ '</div>'
|
||
);
|
||
}).join('');
|
||
_edpRbacLoaded = true;
|
||
} catch (e) {
|
||
rowsEl.innerHTML = '<div style="padding:8px; color:var(--error); font-size:12px;">Failed to load: ' + e.message + '</div>';
|
||
}
|
||
}
|
||
document.addEventListener('toggle', (e) => {
|
||
if (e.target && e.target.id === 'edp-rbac-details' && e.target.open) {
|
||
_edpHydrateRbacMatrix();
|
||
}
|
||
}, true);
|
||
|
||
// Diff current dropdown state vs _edpRbacOriginal; emit grant
|
||
// POST / DELETE calls in parallel. Returns failure count so the
|
||
// save handler can surface it in the toast.
|
||
async function _edpDiffApplyGrants(pkgId) {
|
||
// No-op when the admin never opened the <details>; original
|
||
// snapshot is empty and dropdowns aren't in the DOM.
|
||
if (!_edpRbacLoaded) return 0;
|
||
const rows = document.querySelectorAll('#edp-rbac-rows [data-group-id]');
|
||
const calls = [];
|
||
// Track which group_ids the admin touched so deletes target the
|
||
// right grant_id. For DELETE we need to look up the grant by
|
||
// (group_id, resource_id, resource_type) — fetch fresh list to
|
||
// get ids. Cheaper to re-fetch once than store ids client-side.
|
||
const wantDelete = []; // [group_id]
|
||
const wantWrite = []; // [{group_id, requirement}]
|
||
rows.forEach(row => {
|
||
const gid = row.getAttribute('data-group-id');
|
||
const sel = row.querySelector('.edp-rbac-req');
|
||
const cur = (sel && sel.value) || '';
|
||
const orig = _edpRbacOriginal[gid] || '';
|
||
if (cur === orig) return;
|
||
if (!cur) {
|
||
// Cleared — DELETE the existing grant.
|
||
wantDelete.push(gid);
|
||
} else {
|
||
// New or changed requirement — DELETE-then-POST (resource_
|
||
// grants UNIQUE constraint forbids two rows on the same
|
||
// (group, resource, type) so we can't just upsert).
|
||
if (orig) wantDelete.push(gid);
|
||
wantWrite.push({ group_id: gid, requirement: cur });
|
||
}
|
||
});
|
||
|
||
if (!wantDelete.length && !wantWrite.length) return 0;
|
||
|
||
// Resolve grant_ids for deletes via a fresh list. /admin/grants
|
||
// doesn't take resource_id as a query filter so we fetch all
|
||
// data_package grants and filter client-side; the set is small
|
||
// enough for any realistic instance.
|
||
let grantIdByGroup = {};
|
||
if (wantDelete.length) {
|
||
const lr = await fetch('/api/admin/grants?resource_type=data_package',
|
||
{ credentials: 'same-origin' });
|
||
if (lr.ok) {
|
||
const arr = await lr.json();
|
||
arr.forEach(g => {
|
||
if (g.resource_id !== pkgId) return;
|
||
grantIdByGroup[g.group_id] = g.id;
|
||
});
|
||
}
|
||
}
|
||
// Sequential DELETE → POST. ``resource_grants`` has a UNIQUE
|
||
// constraint on (group_id, resource_type, resource_id); running
|
||
// both in parallel via ``Promise.allSettled`` raced — if the
|
||
// POST landed first, the unique check rejected it and the old
|
||
// ``available`` row stayed. Awaiting all DELETEs before any
|
||
// POST fires keeps the (group, resource) slot empty for the
|
||
// re-insert.
|
||
const deleteResults = await Promise.allSettled(
|
||
wantDelete
|
||
.map(gid => grantIdByGroup[gid])
|
||
.filter(Boolean)
|
||
.map(gid => fetch('/api/admin/grants/' + encodeURIComponent(gid),
|
||
{ method: 'DELETE', credentials: 'same-origin' }))
|
||
);
|
||
const writeResults = await Promise.allSettled(
|
||
wantWrite.map(w => fetch('/api/admin/grants', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify({
|
||
group_id: w.group_id,
|
||
resource_type: 'data_package',
|
||
resource_id: pkgId,
|
||
requirement: w.requirement,
|
||
}),
|
||
}))
|
||
);
|
||
const results = deleteResults.concat(writeResults);
|
||
return results.filter(
|
||
r => r.status === 'rejected' || (r.value && !r.value.ok)
|
||
).length;
|
||
}
|
||
function renderEdpTablesList(tables) {
|
||
const el = document.getElementById('edp-tables-list');
|
||
if (!tables.length) {
|
||
el.innerHTML = '<div style="padding:12px; color:#5f6368; font-size:13px;">'
|
||
+ 'No tables in this package yet. Click <strong>+ Add tables</strong> above.</div>';
|
||
return;
|
||
}
|
||
el.innerHTML = tables.map(function(t) {
|
||
const id = String(t.id || '');
|
||
const name = String(t.name || id);
|
||
return ''
|
||
+ '<div style="display:flex; align-items:center; justify-content:space-between; '
|
||
+ 'padding:8px 12px; border-bottom:1px solid var(--border-light, #f0f0f0);">'
|
||
+ ' <span style="font-size:13px; color:#202124;">' + escapeHtml(name) + '</span>'
|
||
+ ' <button class="btn" type="button" style="padding:3px 10px; font-size:11px; color:#b91c1c;" '
|
||
+ 'onclick="removeTableFromPackage(\'' + escapeHtmlAttr(id) + '\')">Remove</button>'
|
||
+ '</div>';
|
||
}).join('');
|
||
}
|
||
async function removeTableFromPackage(tableId) {
|
||
const pkgId = document.getElementById('edp-id').value;
|
||
if (!confirm('Remove this table from the package?')) return;
|
||
const r = await fetch('/api/admin/data-packages/' + encodeURIComponent(pkgId)
|
||
+ '/tables/' + encodeURIComponent(tableId),
|
||
{ method: 'DELETE', credentials: 'same-origin' });
|
||
if (!r.ok) { alert('Remove failed: HTTP ' + r.status); return; }
|
||
// Refresh the table list inline.
|
||
const r2 = await fetch('/api/admin/data-packages/' + encodeURIComponent(pkgId),
|
||
{ credentials: 'same-origin' });
|
||
if (r2.ok) renderEdpTablesList((await r2.json()).tables || []);
|
||
}
|
||
async function submitEditDataPackage() {
|
||
const btn = document.getElementById('edp-save-btn');
|
||
btn.disabled = true;
|
||
try {
|
||
const pkgId = document.getElementById('edp-id').value;
|
||
// v50 cover image semantics:
|
||
// - cleared via Remove button → preview has dataset.cleared, send ""
|
||
// - new image uploaded → hidden input has the new URL
|
||
// - left unchanged → hidden input still holds the
|
||
// previous URL, send it unchanged
|
||
// (no-op on the server because the
|
||
// value matches)
|
||
var coverPreview = document.getElementById('edp-cover-preview');
|
||
var coverUrl = document.getElementById('edp-cover-url').value;
|
||
var coverField = (coverPreview && coverPreview.dataset.cleared === '1')
|
||
? '' : (coverUrl || null);
|
||
const payload = {
|
||
name: document.getElementById('edp-name').value.trim(),
|
||
description: document.getElementById('edp-desc').value.trim() || null,
|
||
icon: document.getElementById('edp-icon').value.trim() || null,
|
||
color: document.getElementById('edp-color').value.trim() || null,
|
||
cover_image_url: coverField,
|
||
// v51: pass status + category through. Empty-string category
|
||
// hits the clear_category branch server-side; status uses the
|
||
// dropdown's current value (never blank).
|
||
status: document.getElementById('edp-status').value || 'prod',
|
||
category: document.getElementById('edp-category').value.trim(),
|
||
};
|
||
const r = await fetch('/api/admin/data-packages/' + encodeURIComponent(pkgId), {
|
||
method: 'PUT',
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (!r.ok) {
|
||
const detail = await r.json().catch(function() { return {}; });
|
||
alert('Save failed: ' + (detail.detail || r.statusText));
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
// Apply any RBAC matrix changes — diff vs snapshot taken at
|
||
// load time, emits only the minimum POST/DELETE pair per
|
||
// changed row. No-op when admin never opened the <details>.
|
||
const rbacFails = await _edpDiffApplyGrants(pkgId);
|
||
if (rbacFails > 0) {
|
||
if (typeof showToast === 'function') {
|
||
showToast(rbacFails + ' group access change(s) failed', 'error');
|
||
} else {
|
||
alert(rbacFails + ' group access change(s) failed.');
|
||
}
|
||
}
|
||
closeEditDataPackageModal();
|
||
if (typeof loadDataPackagesSection === 'function') loadDataPackagesSection();
|
||
} catch (e) {
|
||
alert('Network error: ' + e.message);
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
// ── Inline +Add-tables picker (v50) ─────────────────────────────
|
||
// User feedback: the legacy "+ Add tables" button closed Edit and
|
||
// opened Bulk Assign, losing the admin's mental context. The
|
||
// picker below stays inline: pulls /api/admin/registry, filters
|
||
// out tables already in this package + internal rows, lets the
|
||
// admin tick what they want, then POSTs each to the package
|
||
// without closing the modal.
|
||
let _edpInlineAddTables = [];
|
||
function toggleEdpInlineAdd() {
|
||
var panel = document.getElementById('edp-inline-add');
|
||
if (!panel) return;
|
||
if (panel.style.display === 'block') {
|
||
panel.style.display = 'none';
|
||
return;
|
||
}
|
||
panel.style.display = 'block';
|
||
loadEdpInlineAddTables();
|
||
}
|
||
async function loadEdpInlineAddTables() {
|
||
var listEl = document.getElementById('edp-inline-add-list');
|
||
var pkgId = document.getElementById('edp-id').value;
|
||
listEl.innerHTML = '<div style="padding:8px; color:var(--text-secondary); font-size:12px;">'
|
||
+ 'Loading unassigned tables…</div>';
|
||
try {
|
||
// Pull all tables and the current package member set in parallel.
|
||
var [allResp, pkgResp] = await Promise.all([
|
||
fetch('/api/admin/registry', { credentials: 'same-origin' }),
|
||
fetch('/api/admin/data-packages/' + encodeURIComponent(pkgId),
|
||
{ credentials: 'same-origin' }),
|
||
]);
|
||
if (!allResp.ok || !pkgResp.ok) {
|
||
listEl.innerHTML = '<div style="padding:8px; color:#b91c1c;">'
|
||
+ 'Failed to load tables (' + allResp.status + ' / ' + pkgResp.status + ').</div>';
|
||
return;
|
||
}
|
||
var allBody = await allResp.json();
|
||
var allTables = Array.isArray(allBody)
|
||
? allBody
|
||
: (Array.isArray(allBody.tables) ? allBody.tables : []);
|
||
var pkg = await pkgResp.json();
|
||
var memberIds = new Set((pkg.tables || []).map(function(t) { return t.id; }));
|
||
// Show only non-internal tables that aren't already in the package.
|
||
_edpInlineAddTables = allTables.filter(function(t) {
|
||
return (t.source_type || '') !== 'internal' && !memberIds.has(t.id);
|
||
});
|
||
renderEdpInlineAddList();
|
||
} catch (e) {
|
||
listEl.innerHTML = '<div style="padding:8px; color:#b91c1c;">'
|
||
+ 'Network error: ' + escapeHtml(e.message) + '</div>';
|
||
}
|
||
}
|
||
function renderEdpInlineAddList() {
|
||
var listEl = document.getElementById('edp-inline-add-list');
|
||
if (!_edpInlineAddTables.length) {
|
||
listEl.innerHTML = '<div style="padding:8px; color:var(--text-secondary); font-size:12px;">'
|
||
+ 'All non-internal tables are already in this package.</div>';
|
||
return;
|
||
}
|
||
listEl.innerHTML = _edpInlineAddTables.map(function(t) {
|
||
var id = String(t.id || '');
|
||
var name = String(t.name || id);
|
||
var st = String(t.source_type || '');
|
||
var bucket = String(t.bucket || '');
|
||
var meta = [st, bucket].filter(Boolean).join(' · ');
|
||
return ''
|
||
+ '<label data-edp-add-id="' + escapeHtmlAttr(id) + '" '
|
||
+ ' data-edp-add-name="' + escapeHtmlAttr(name.toLowerCase()) + '" '
|
||
+ ' style="display:flex; gap:8px; align-items:center; padding:4px 6px; '
|
||
+ 'border-radius:4px; cursor:pointer; font-size:13px;">'
|
||
+ ' <input type="checkbox" class="edp-add-cb" value="' + escapeHtmlAttr(id) + '">'
|
||
+ ' <span style="flex:1; font-family:var(--font-mono);">' + escapeHtml(name) + '</span>'
|
||
+ (meta ? ' <span style="font-size:11px; color:var(--text-secondary);">'
|
||
+ escapeHtml(meta) + '</span>' : '')
|
||
+ '</label>';
|
||
}).join('');
|
||
}
|
||
function filterEdpInlineAdd() {
|
||
var q = (document.getElementById('edp-inline-add-search').value || '').toLowerCase();
|
||
document.querySelectorAll('#edp-inline-add-list label[data-edp-add-id]').forEach(function(row) {
|
||
var name = row.getAttribute('data-edp-add-name') || '';
|
||
row.style.display = (!q || name.indexOf(q) !== -1) ? '' : 'none';
|
||
});
|
||
}
|
||
async function submitEdpInlineAdd() {
|
||
var pkgId = document.getElementById('edp-id').value;
|
||
var btn = document.getElementById('edp-inline-add-submit-btn');
|
||
var selected = Array.from(document.querySelectorAll('.edp-add-cb:checked'))
|
||
.map(function(cb) { return cb.value; });
|
||
if (!selected.length) {
|
||
alert('Pick at least one table to add.');
|
||
return;
|
||
}
|
||
btn.disabled = true;
|
||
try {
|
||
// POST each table_id; idempotent on the server so retrying after
|
||
// partial failure is safe. Parallel — the user-visible latency
|
||
// is the slowest individual call, not the sum.
|
||
var calls = selected.map(function(tid) {
|
||
return fetch('/api/admin/data-packages/' + encodeURIComponent(pkgId) + '/tables', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ table_id: tid }),
|
||
}).then(function(r) { return { tid: tid, ok: r.ok, status: r.status }; });
|
||
});
|
||
var results = await Promise.all(calls);
|
||
var fails = results.filter(function(r) { return !r.ok; });
|
||
if (fails.length) {
|
||
alert(fails.length + ' of ' + results.length + ' add(s) failed.');
|
||
}
|
||
// Refresh the package's table list inline (no modal close).
|
||
var r2 = await fetch('/api/admin/data-packages/' + encodeURIComponent(pkgId),
|
||
{ credentials: 'same-origin' });
|
||
if (r2.ok) renderEdpTablesList((await r2.json()).tables || []);
|
||
// Reload the inline-add candidate list so the just-added rows
|
||
// disappear (member set has changed).
|
||
await loadEdpInlineAddTables();
|
||
// Background-refresh the top-level Data Packages section so
|
||
// the new table counts show up after the user closes the modal.
|
||
if (typeof loadDataPackagesSection === 'function') loadDataPackagesSection();
|
||
} catch (e) {
|
||
alert('Network error: ' + e.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function deleteEditDataPackage() {
|
||
const pkgId = document.getElementById('edp-id').value;
|
||
const name = document.getElementById('edp-name').value.trim();
|
||
// v54: soft delete with Undo toast. confirm() removed — the
|
||
// 10s Undo on the toast is the recovery affordance; double
|
||
// safety (confirm + undo) is friction without payoff. Junction
|
||
// rows (data_package_tables) + resource_grants survive intact
|
||
// so restore brings the package back whole.
|
||
const r = await fetch('/api/admin/data-packages/' + encodeURIComponent(pkgId),
|
||
{ method: 'DELETE', credentials: 'same-origin' });
|
||
if (!r.ok) { alert('Delete failed: HTTP ' + r.status); return; }
|
||
closeEditDataPackageModal();
|
||
if (typeof loadDataPackagesSection === 'function') loadDataPackagesSection();
|
||
if (typeof window.showUndoToast === 'function') {
|
||
window.showUndoToast(
|
||
'Data Package "' + name + '" deleted.',
|
||
'/api/admin/data-packages/' + encodeURIComponent(pkgId) + '/restore',
|
||
() => {
|
||
if (typeof loadDataPackagesSection === 'function') loadDataPackagesSection();
|
||
},
|
||
);
|
||
}
|
||
}
|
||
// Auto-derive a url-safe slug from a Name string. Mirrors the
|
||
// server-side normalisation used by the seed step in _v48_to_v49.
|
||
function _deriveSlug(name) {
|
||
return (name || '').toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, '-')
|
||
.replace(/^-|-$/g, '');
|
||
}
|
||
async function submitCreateDataPackage() {
|
||
const btn = document.getElementById('cdp-submit-btn');
|
||
btn.disabled = true;
|
||
try {
|
||
const nameVal = document.getElementById('cdp-name').value.trim();
|
||
// Auto-derive slug from name when the slug field is blank —
|
||
// fixes the silent-fail-on-empty-slug bug where the form just
|
||
// stayed open with no error if the user typed Name and clicked
|
||
// Create without focusing-out of the Name input.
|
||
let slugVal = document.getElementById('cdp-slug').value.trim();
|
||
if (!slugVal && nameVal) {
|
||
slugVal = _deriveSlug(nameVal);
|
||
document.getElementById('cdp-slug').value = slugVal;
|
||
}
|
||
// v50: cover_image_url is set by onCoverFilePicked() when the
|
||
// admin selects + uploads a file. If they didn't, it stays
|
||
// empty — send null so the server doesn't see "".
|
||
var cdpCoverUrl = document.getElementById('cdp-cover-url').value;
|
||
const payload = {
|
||
name: nameVal,
|
||
slug: slugVal,
|
||
description: document.getElementById('cdp-desc').value.trim() || null,
|
||
icon: document.getElementById('cdp-icon').value.trim() || null,
|
||
color: document.getElementById('cdp-color').value.trim() || null,
|
||
cover_image_url: cdpCoverUrl || null,
|
||
// v51: lifecycle status + classification category.
|
||
status: document.getElementById('cdp-status').value || 'prod',
|
||
category: document.getElementById('cdp-category').value.trim() || null,
|
||
};
|
||
if (!payload.name || !payload.slug) {
|
||
alert('Name is required (slug auto-derives from it).');
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
const r = await fetch('/api/admin/data-packages', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (!r.ok) {
|
||
const detail = await r.json().catch(() => ({}));
|
||
alert('Failed: ' + (detail.detail || r.statusText));
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
const body = await r.json();
|
||
const newPkgId = body.id;
|
||
if (_cdpHost && _cdpHost.addChip) {
|
||
_cdpHost.addChip({ id: newPkgId, name: payload.name });
|
||
}
|
||
|
||
// Inline grants — read any per-group requirement the admin set
|
||
// in the collapsible "Group access" section and POST in parallel.
|
||
// Per-grant failures surface in the success toast; they don't
|
||
// roll back the package creation (admin can retry from
|
||
// /admin/access).
|
||
const grantFailures = await _submitCdpGrantsInline(newPkgId);
|
||
|
||
closeCreateDataPackageModal();
|
||
|
||
if (typeof loadDataPackagesSection === 'function') {
|
||
try { loadDataPackagesSection(); } catch (_) {}
|
||
}
|
||
if (typeof showToast === 'function') {
|
||
const baseMsg = 'Data Package "' + payload.name + '" created';
|
||
if (grantFailures > 0) {
|
||
showToast(baseMsg + ' — ' + grantFailures + ' grant(s) failed', 'error');
|
||
} else {
|
||
showToast(baseMsg, 'success');
|
||
}
|
||
}
|
||
} catch (e) {
|
||
alert('Network error: ' + e.message);
|
||
btn.disabled = false;
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
// Lazy-load groups into the inline RBAC matrix the first time the
|
||
// <details> is opened. Subsequent opens are no-ops. Groups are
|
||
// small + admin-only data — cache for the lifetime of the modal.
|
||
let _cdpRbacLoaded = false;
|
||
async function _cdpHydrateRbacMatrix() {
|
||
if (_cdpRbacLoaded) return;
|
||
const rowsEl = document.getElementById('cdp-rbac-rows');
|
||
rowsEl.innerHTML = '<div class="loading" style="padding:6px; color:var(--text-secondary); font-size:12px;">Loading groups…</div>';
|
||
try {
|
||
const r = await fetch('/api/admin/groups', { credentials: 'same-origin' });
|
||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||
const body = await r.json();
|
||
const groups = Array.isArray(body) ? body : (body.groups || []);
|
||
if (!groups.length) {
|
||
rowsEl.innerHTML = '<div style="padding:8px; color:var(--text-secondary); font-size:12px;">No groups defined yet. Create groups in <a href="/admin/access">Resource access</a> first.</div>';
|
||
return;
|
||
}
|
||
rowsEl.innerHTML = groups.map(g => {
|
||
const gid = String(g.id || g.name || '');
|
||
const gname = String(g.name || gid);
|
||
return (
|
||
'<div data-group-id="' + gid + '" '
|
||
+ 'style="display:flex; gap:8px; align-items:center; '
|
||
+ 'padding:6px; border:1px solid var(--border); border-radius:6px;">'
|
||
+ '<span style="flex:1;">' + gname + '</span>'
|
||
+ '<select class="cdp-rbac-req" style="padding:4px; border:1px solid var(--border); border-radius:4px;">'
|
||
+ '<option value="">(no grant)</option>'
|
||
+ '<option value="available">available</option>'
|
||
+ '<option value="required">required</option>'
|
||
+ '</select>'
|
||
+ '</div>'
|
||
);
|
||
}).join('');
|
||
_cdpRbacLoaded = true;
|
||
} catch (e) {
|
||
rowsEl.innerHTML = '<div style="padding:8px; color:var(--error); font-size:12px;">Failed to load groups: ' + e.message + '</div>';
|
||
}
|
||
}
|
||
// Wire the lazy load on first <details> open. Re-fetched if the
|
||
// admin re-opens a fresh modal (closeCreateDataPackageModal resets
|
||
// the flag).
|
||
document.addEventListener('toggle', (e) => {
|
||
if (e.target && e.target.id === 'cdp-rbac-details' && e.target.open) {
|
||
_cdpHydrateRbacMatrix();
|
||
}
|
||
}, true);
|
||
|
||
async function _submitCdpGrantsInline(pkgId) {
|
||
const rows = document.querySelectorAll('#cdp-rbac-rows [data-group-id]');
|
||
const calls = [];
|
||
rows.forEach(row => {
|
||
const gid = row.getAttribute('data-group-id');
|
||
const sel = row.querySelector('.cdp-rbac-req');
|
||
const req = sel && sel.value;
|
||
if (!req) return;
|
||
calls.push(fetch('/api/admin/grants', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify({
|
||
group_id: gid,
|
||
resource_type: 'data_package',
|
||
resource_id: pkgId,
|
||
requirement: req,
|
||
}),
|
||
}));
|
||
});
|
||
if (!calls.length) return 0;
|
||
const results = await Promise.allSettled(calls);
|
||
return results.filter(
|
||
r => r.status === 'rejected' || (r.value && !r.value.ok)
|
||
).length;
|
||
}
|
||
|
||
// ── (removed) Step-2 RBAC modal-on-modal — folded into the
|
||
// create modal's collapsible "Group access" section. Stubs kept
|
||
// for callers that may still reference these names; they're no-ops
|
||
// now. Safe to delete in a follow-up once the audit confirms no
|
||
// other call sites. ──
|
||
let _cdpNewId = null;
|
||
let _cdpNewName = null;
|
||
|
||
function openCreateDataPackageRbacModal() { /* deprecated — inlined */ }
|
||
function skipCreateDataPackageRbac() {
|
||
if (typeof loadDataPackagesSection === 'function') {
|
||
loadDataPackagesSection();
|
||
}
|
||
}
|
||
function submitCreateDataPackageRbac() { /* deprecated — inlined */ }
|
||
|
||
// Hook: chip-input on this page fires `chip-create` when the user
|
||
// selects the "+ Create new" tail row in the dropdown.
|
||
document.addEventListener('chip-create', (e) => {
|
||
const host = e.detail && e.detail.host;
|
||
if (host && host.dataset.chipInput === 'data_package') {
|
||
openCreateDataPackageModal(e.detail.typed, host);
|
||
}
|
||
});
|
||
|
||
// ── Bulk-assign tables → package ─────────────────────────
|
||
// Single round-trip per table on submit (the existing
|
||
// POST /api/admin/data-packages/{id}/tables endpoint takes one
|
||
// table_id per call) but parallel via Promise.allSettled so the
|
||
// user-visible latency is the slowest individual call rather
|
||
// than the sum. Idempotent on the server, so re-submitting after
|
||
// a partial failure only retries the ones that didn't land.
|
||
let _bulkAssignPackages = []; // [{id, name, member_table_ids:Set}]
|
||
let _bulkAssignTables = []; // [{id, name, source_type, bucket}]
|
||
|
||
/* One-click factory: read table_registry → group by `bucket` →
|
||
create one Data Package per distinct bucket (skipping any whose
|
||
slug already exists) → bulk-assign its tables. Answers the
|
||
"why do I have to package by hand what's already grouped?"
|
||
friction for admins with many registered tables.
|
||
|
||
Sequential rather than parallel because the assign-table POSTs
|
||
depend on the package_id from the create POST. ~100ms per
|
||
bucket which is fine for the handful most instances have. */
|
||
// Bucket plan (loaded once when the preview modal opens; consumed
|
||
// by submitGroupByBucket). { bucket, slug, tables[], slugExists }
|
||
var _gbbPlan = [];
|
||
|
||
function closeGroupByBucketModal() {
|
||
document.getElementById('groupByBucketPreviewModal').style.display = 'none';
|
||
}
|
||
|
||
function _gbbSlugify(s) {
|
||
return (s || '').toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, '-')
|
||
.replace(/^-|-$/g, '');
|
||
}
|
||
|
||
async function groupTablesByBucket() {
|
||
// Build the plan + render the preview modal. Replaces the old
|
||
// confirm() that just said "yes/no to ALL buckets" — admins now
|
||
// see which buckets would become packages, the row count per
|
||
// bucket, the resulting slug, and which ones would be skipped
|
||
// because a package with that slug already exists.
|
||
const rowsEl = document.getElementById('gbb-rows');
|
||
rowsEl.innerHTML = '<div class="loading" style="padding:8px; color:var(--text-secondary); font-size:13px;">Loading buckets…</div>';
|
||
document.getElementById('groupByBucketPreviewModal').style.display = 'flex';
|
||
|
||
try {
|
||
const [tableResp, pkgResp] = await Promise.all([
|
||
fetch('/api/admin/registry', { credentials: 'same-origin' }),
|
||
fetch('/api/admin/data-packages', { credentials: 'same-origin' }),
|
||
]);
|
||
if (!tableResp.ok || !pkgResp.ok) {
|
||
rowsEl.innerHTML = '<div style="padding:8px; color:var(--error); font-size:13px;">Failed to load tables or packages.</div>';
|
||
return;
|
||
}
|
||
const tables = await tableResp.json();
|
||
const existingPackages = await pkgResp.json();
|
||
const tableList = Array.isArray(tables) ? tables : (tables.items || tables.tables || []);
|
||
const existingSlugs = new Set((existingPackages || []).map(p => p.slug));
|
||
|
||
const buckets = {};
|
||
for (const t of tableList) {
|
||
const b = (t.bucket || '').trim();
|
||
if (!b || b.startsWith('agnes_')) continue;
|
||
buckets[b] = buckets[b] || [];
|
||
buckets[b].push(t);
|
||
}
|
||
const bucketNames = Object.keys(buckets).sort();
|
||
if (!bucketNames.length) {
|
||
rowsEl.innerHTML = '<div style="padding:8px; color:var(--text-secondary); font-size:13px;">No buckets to group. Register some tables first.</div>';
|
||
return;
|
||
}
|
||
|
||
_gbbPlan = bucketNames.map(function (bucket) {
|
||
const slug = _gbbSlugify(bucket);
|
||
return {
|
||
bucket: bucket,
|
||
slug: slug,
|
||
tables: buckets[bucket],
|
||
slugExists: existingSlugs.has(slug),
|
||
};
|
||
});
|
||
|
||
rowsEl.innerHTML = _gbbPlan.map(function (p, i) {
|
||
const disabled = p.slugExists;
|
||
const note = disabled
|
||
? '<span style="color:var(--warning); font-size:11px; margin-left:6px;">(slug exists — will skip)</span>'
|
||
: '';
|
||
return ''
|
||
+ '<label style="display:flex; align-items:center; gap:10px; padding:6px 8px; '
|
||
+ 'border:1px solid var(--border); border-radius:6px; '
|
||
+ 'background:' + (disabled ? 'var(--background)' : 'var(--surface)') + ';">'
|
||
+ ' <input type="checkbox" class="gbb-row" data-idx="' + i + '" '
|
||
+ (disabled ? 'disabled' : 'checked') + '>'
|
||
+ ' <span style="flex:1; font-weight:600; font-size:13px;">'
|
||
+ escapeHtml(p.bucket)
|
||
+ ' <code style="font-weight:normal; font-size:11px; color:var(--text-secondary); margin-left:6px;">' + escapeHtml(p.slug) + '</code>'
|
||
+ note
|
||
+ ' </span>'
|
||
+ ' <span style="color:var(--text-secondary); font-size:12px;">'
|
||
+ p.tables.length + ' table' + (p.tables.length === 1 ? '' : 's')
|
||
+ ' </span>'
|
||
+ '</label>';
|
||
}).join('');
|
||
} catch (e) {
|
||
rowsEl.innerHTML = '<div style="padding:8px; color:var(--error); font-size:13px;">Failed: ' + escapeHtml(e.message) + '</div>';
|
||
}
|
||
}
|
||
|
||
async function submitGroupByBucket() {
|
||
const btn = document.getElementById('gbb-submit-btn');
|
||
const checks = document.querySelectorAll('#gbb-rows input.gbb-row:checked');
|
||
if (!checks.length) {
|
||
showToast('No buckets selected.', 'error');
|
||
return;
|
||
}
|
||
btn.disabled = true;
|
||
const picks = Array.from(checks).map(function (el) {
|
||
return _gbbPlan[parseInt(el.dataset.idx, 10)];
|
||
}).filter(Boolean);
|
||
|
||
const created = [], failed = [];
|
||
for (const p of picks) {
|
||
const cResp = await fetch('/api/admin/data-packages', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
name: p.bucket,
|
||
slug: p.slug,
|
||
description: 'Auto-grouped from bucket `' + p.bucket + '` — ' + p.tables.length + ' tables.',
|
||
icon: null,
|
||
color: '#e0f2fe',
|
||
}),
|
||
});
|
||
if (!cResp.ok) { failed.push(p.bucket); continue; }
|
||
const pkg = await cResp.json();
|
||
await Promise.all(p.tables.map(function (t) {
|
||
return fetch('/api/admin/data-packages/' + encodeURIComponent(pkg.id) + '/tables', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ table_id: t.id }),
|
||
}).catch(function () { /* swallowed; count via created total */ });
|
||
}));
|
||
created.push(p.bucket);
|
||
}
|
||
|
||
btn.disabled = false;
|
||
closeGroupByBucketModal();
|
||
if (failed.length) {
|
||
showToast('Created ' + created.length + ', failed ' + failed.length + ': ' + failed.join(', '), 'error');
|
||
} else {
|
||
showToast('Created ' + created.length + ' package' + (created.length === 1 ? '' : 's'), 'success');
|
||
}
|
||
if (typeof loadDataPackagesSection === 'function') loadDataPackagesSection();
|
||
}
|
||
|
||
async function openBulkAssignModal(preselectPkgId) {
|
||
document.getElementById('bulkAssignTablesModal').style.display = 'flex';
|
||
document.getElementById('bulk-assign-search').value = '';
|
||
// Load packages + tables in parallel.
|
||
const [pkgResp, tableResp] = await Promise.all([
|
||
fetch('/api/admin/data-packages', { credentials: 'same-origin' }),
|
||
fetch('/api/admin/registry', { credentials: 'same-origin' }),
|
||
]);
|
||
const sel = document.getElementById('bulk-assign-package');
|
||
const list = document.getElementById('bulk-assign-list');
|
||
if (!pkgResp.ok) {
|
||
sel.innerHTML = '<option value="">Failed to load packages</option>';
|
||
list.innerHTML = '<div style="padding:12px; color:#b91c1c;">HTTP '
|
||
+ pkgResp.status + ' on /api/admin/data-packages</div>';
|
||
return;
|
||
}
|
||
if (!tableResp.ok) {
|
||
sel.innerHTML = '<option value="">Failed to load tables</option>';
|
||
list.innerHTML = '<div style="padding:12px; color:#b91c1c;">HTTP '
|
||
+ tableResp.status + ' on /api/admin/registry</div>';
|
||
return;
|
||
}
|
||
const pkgs = await pkgResp.json();
|
||
const tableBody = await tableResp.json();
|
||
// /api/admin/registry returns {tables, count}; defend against shape drift.
|
||
_bulkAssignTables = Array.isArray(tableBody)
|
||
? tableBody
|
||
: (Array.isArray(tableBody.tables) ? tableBody.tables : []);
|
||
// Drop internal rows — same filter the /catalog empty-state uses.
|
||
_bulkAssignTables = _bulkAssignTables.filter(function(t) {
|
||
return (t.source_type || '') !== 'internal';
|
||
});
|
||
// Fetch each package's member tables in parallel so we can pre-check
|
||
// already-assigned rows (and gray them out — server is idempotent
|
||
// but skipping the no-op call keeps the audit log clean).
|
||
const memberFetches = pkgs.map(function(p) {
|
||
return fetch('/api/admin/data-packages/' + encodeURIComponent(p.id),
|
||
{ credentials: 'same-origin' })
|
||
.then(function(r) { return r.ok ? r.json() : null; })
|
||
.catch(function() { return null; });
|
||
});
|
||
const memberBodies = await Promise.all(memberFetches);
|
||
_bulkAssignPackages = pkgs.map(function(p, i) {
|
||
const det = memberBodies[i] || {};
|
||
const ids = new Set((det.tables || []).map(function(t) { return t.id; }));
|
||
return { id: p.id, name: p.name, member_table_ids: ids };
|
||
});
|
||
// Inline "+ Create new package…" as a sentinel value. The change
|
||
// handler below intercepts __create__ and runs a tiny prompt-based
|
||
// flow (name → POST → re-list → re-select) so the user can spawn
|
||
// a target without leaving the bulk-assign workflow.
|
||
//
|
||
// v55 — also annotate each option with "(N of M tables already in)"
|
||
// so the admin sees the existing distribution before picking a
|
||
// target. M = number of visible/grantable tables in this modal;
|
||
// N = how many of those are already in that package. Surfaces the
|
||
// implicit "this package is half-full of what I'm about to assign"
|
||
// signal the admin previously had to derive by clicking through.
|
||
const totalVisible = _bulkAssignTables.length;
|
||
sel.innerHTML = '<option value="">— Choose a package —</option>'
|
||
+ '<option value="__create__" style="font-weight:600;">+ Create new package…</option>'
|
||
+ _bulkAssignPackages.map(function(p) {
|
||
let overlap = 0;
|
||
_bulkAssignTables.forEach(function(t) {
|
||
if (p.member_table_ids.has(String(t.id))) overlap++;
|
||
});
|
||
const distHint = overlap > 0
|
||
? ' (' + overlap + ' of ' + totalVisible + ' already in)'
|
||
: '';
|
||
return '<option value="' + escapeHtmlAttr(p.id) + '">'
|
||
+ escapeHtml(p.name) + escapeHtml(distHint) + '</option>';
|
||
}).join('');
|
||
sel.onchange = async function() {
|
||
if (sel.value !== '__create__') { refreshBulkAssignAvailability(); return; }
|
||
const name = prompt('Name for the new Data Package:');
|
||
if (!name || !name.trim()) { sel.value = ''; return; }
|
||
const slug = _deriveSlug(name.trim());
|
||
const r = await fetch('/api/admin/data-packages', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
name: name.trim(),
|
||
slug,
|
||
description: null,
|
||
icon: null,
|
||
color: '#e0f2fe',
|
||
}),
|
||
});
|
||
if (!r.ok) {
|
||
const detail = await r.json().catch(function() { return {}; });
|
||
alert('Create failed: ' + (detail.detail || r.statusText));
|
||
sel.value = '';
|
||
return;
|
||
}
|
||
const created = await r.json();
|
||
// Append to the in-memory list + the <select>; preserve any
|
||
// already-checked table rows by NOT re-rendering the list.
|
||
_bulkAssignPackages.push({
|
||
id: created.id, name: name.trim(),
|
||
member_table_ids: new Set(),
|
||
});
|
||
const opt = document.createElement('option');
|
||
opt.value = created.id;
|
||
opt.textContent = name.trim();
|
||
sel.appendChild(opt);
|
||
sel.value = created.id;
|
||
refreshBulkAssignAvailability();
|
||
};
|
||
if (preselectPkgId && _bulkAssignPackages.some(function(p) { return p.id === preselectPkgId; })) {
|
||
sel.value = preselectPkgId;
|
||
}
|
||
renderBulkAssignList();
|
||
refreshBulkAssignAvailability();
|
||
}
|
||
|
||
function closeBulkAssignModal() {
|
||
document.getElementById('bulkAssignTablesModal').style.display = 'none';
|
||
// Drop the ?assign_to= query param so a manual refresh doesn't
|
||
// re-open the modal — but only when it was the param that opened
|
||
// us; otherwise leave the URL untouched.
|
||
if (window.location.search.indexOf('assign_to=') !== -1) {
|
||
history.replaceState(null, '', window.location.pathname + window.location.hash);
|
||
}
|
||
}
|
||
|
||
function renderBulkAssignList() {
|
||
const list = document.getElementById('bulk-assign-list');
|
||
if (!_bulkAssignTables.length) {
|
||
list.innerHTML = '<div style="padding:12px; color:#5f6368;">'
|
||
+ 'No tables registered yet — register some via the connector tabs first.</div>';
|
||
return;
|
||
}
|
||
list.innerHTML = _bulkAssignTables.map(function(t) {
|
||
const id = String(t.id || '');
|
||
const name = String(t.name || id);
|
||
const st = String(t.source_type || '');
|
||
const bucket = String(t.bucket || '');
|
||
const meta = [st, bucket].filter(Boolean).join(' · ');
|
||
return ''
|
||
+ '<label data-table-id="' + escapeHtmlAttr(id) + '" '
|
||
+ ' data-table-name="' + escapeHtmlAttr(name.toLowerCase()) + '" '
|
||
+ ' data-table-source="' + escapeHtmlAttr(st.toLowerCase()) + '" '
|
||
+ ' style="display:flex; gap:8px; align-items:center; padding:6px; border-radius:4px; cursor:pointer;">'
|
||
+ ' <input type="checkbox" class="bulk-assign-cb" value="' + escapeHtmlAttr(id) + '">'
|
||
+ ' <span style="flex:1; font-family:var(--font-mono);">' + escapeHtml(name) + '</span>'
|
||
+ (meta ? ' <span style="font-size:12px; color:#5f6368;">' + escapeHtml(meta) + '</span>' : '')
|
||
+ '</label>';
|
||
}).join('');
|
||
}
|
||
|
||
// Re-render the per-row "already assigned" hints whenever the
|
||
// target package changes — keeps the UI in sync with reality
|
||
// without a re-fetch (member sets were cached at open time).
|
||
function refreshBulkAssignAvailability() {
|
||
const pkgId = document.getElementById('bulk-assign-package').value;
|
||
const pkg = _bulkAssignPackages.find(function(p) { return p.id === pkgId; });
|
||
const members = pkg ? pkg.member_table_ids : new Set();
|
||
document.querySelectorAll('#bulk-assign-list label').forEach(function(row) {
|
||
const tid = row.getAttribute('data-table-id');
|
||
const cb = row.querySelector('.bulk-assign-cb');
|
||
const already = members.has(tid);
|
||
// Wipe any existing badge before re-applying.
|
||
const oldBadge = row.querySelector('.bulk-assign-badge');
|
||
if (oldBadge) oldBadge.remove();
|
||
if (already) {
|
||
cb.checked = true;
|
||
cb.disabled = true;
|
||
row.style.opacity = '0.5';
|
||
const badge = document.createElement('span');
|
||
badge.className = 'bulk-assign-badge';
|
||
badge.textContent = 'already in package';
|
||
badge.style.cssText = 'font-size:11px; color:#10b77f; padding:2px 6px; '
|
||
+ 'border:1px solid #10b77f33; border-radius:10px;';
|
||
row.appendChild(badge);
|
||
} else {
|
||
cb.disabled = false;
|
||
row.style.opacity = '';
|
||
// Only auto-uncheck rows that we previously force-checked —
|
||
// don't clobber the user's manual selection on package switch.
|
||
}
|
||
});
|
||
}
|
||
|
||
function filterBulkAssignList() {
|
||
const q = (document.getElementById('bulk-assign-search').value || '').toLowerCase();
|
||
document.querySelectorAll('#bulk-assign-list label').forEach(function(row) {
|
||
if (!q) { row.style.display = ''; return; }
|
||
const name = row.getAttribute('data-table-name') || '';
|
||
const src = row.getAttribute('data-table-source') || '';
|
||
row.style.display = (name.includes(q) || src.includes(q)) ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
function toggleAllBulkAssign(checked) {
|
||
document.querySelectorAll('#bulk-assign-list label').forEach(function(row) {
|
||
if (row.style.display === 'none') return;
|
||
const cb = row.querySelector('.bulk-assign-cb');
|
||
if (cb && !cb.disabled) cb.checked = checked;
|
||
});
|
||
}
|
||
|
||
async function submitBulkAssign() {
|
||
const pkgId = document.getElementById('bulk-assign-package').value;
|
||
if (!pkgId) {
|
||
alert('Pick a target Data Package first.');
|
||
return;
|
||
}
|
||
const pkg = _bulkAssignPackages.find(function(p) { return p.id === pkgId; });
|
||
const alreadyMembers = pkg ? pkg.member_table_ids : new Set();
|
||
const tableIds = Array.from(
|
||
document.querySelectorAll('#bulk-assign-list .bulk-assign-cb:checked')
|
||
)
|
||
.map(function(cb) { return cb.value; })
|
||
// Skip the disabled-already-member rows — the server would no-op,
|
||
// but avoiding the call keeps audit_log noise down.
|
||
.filter(function(id) { return !alreadyMembers.has(id); });
|
||
if (!tableIds.length) {
|
||
alert('Nothing to assign — pick at least one table not already in the package.');
|
||
return;
|
||
}
|
||
const btn = document.getElementById('bulk-assign-submit-btn');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Assigning ' + tableIds.length + '…';
|
||
try {
|
||
const calls = tableIds.map(function(tid) {
|
||
return fetch('/api/admin/data-packages/' + encodeURIComponent(pkgId) + '/tables', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify({ table_id: tid }),
|
||
}).then(function(r) { return { tid: tid, ok: r.ok, status: r.status }; })
|
||
.catch(function(e) { return { tid: tid, ok: false, status: 0, err: String(e) }; });
|
||
});
|
||
const results = await Promise.all(calls);
|
||
const fails = results.filter(function(r) { return !r.ok; });
|
||
if (fails.length) {
|
||
alert(fails.length + ' of ' + results.length + ' failed. '
|
||
+ 'First failure: ' + fails[0].tid + ' (HTTP ' + fails[0].status + ').');
|
||
}
|
||
closeBulkAssignModal();
|
||
// Refresh the top-level grid so newly-bundled package counts update.
|
||
if (typeof loadDataPackagesSection === 'function') {
|
||
loadDataPackagesSection();
|
||
}
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = 'Assign selected';
|
||
}
|
||
}
|
||
|
||
// ── Auto-open Bulk Assign on ?assign_to=<pkg_id> ────────
|
||
// Lands here from the /catalog "0 tables — assign some →" CTA.
|
||
// Wait one tick so the loadDataPackagesSection hydrator has a
|
||
// chance to populate the package grid first (cosmetic — keeps
|
||
// the page background populated when the modal opens).
|
||
function initBulkAssignFromQuery() {
|
||
try {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const pid = params.get('assign_to');
|
||
if (pid) {
|
||
setTimeout(function() { openBulkAssignModal(pid); }, 50);
|
||
}
|
||
} catch (e) { /* no-op */ }
|
||
}
|
||
document.addEventListener('DOMContentLoaded', initBulkAssignFromQuery);
|
||
</script>
|
||
|
||
{# (removed) Create Data Package RBAC step-2 modal — the per-group
|
||
Available|Required matrix is now an inline collapsible section
|
||
inside #createDataPackageModal (#cdp-rbac-details). The modal-on-
|
||
modal pattern was confusing per user feedback. #}
|
||
|
||
<!-- ═══════════════ Bulk-assign tables → package ═══════════════
|
||
Replaces the 50-clicks workflow ("edit each table individually
|
||
and add it via the chip-input inside its modal") with one
|
||
dropdown + multi-select submit. Driven by the "Bulk assign
|
||
tables" button on the Data Packages section header AND by the
|
||
/catalog "0 tables — assign some →" CTA which lands here with
|
||
?assign_to=<pkg_id> (see initBulkAssignFromQuery). -->
|
||
<!-- ═══════════════ Edit Data Package modal ═══════════════
|
||
Opened from the Edit button on each card in the Data Packages
|
||
section. Lets admin rename + change description / icon / color,
|
||
see + remove member tables, and delete the package. -->
|
||
<div class="modal-overlay" id="editDataPackageModal" style="display:none;">
|
||
<div class="modal" style="max-width:680px;">
|
||
<div class="modal-header">
|
||
<h2>Edit Data Package</h2>
|
||
<button class="modal-close" onclick="closeEditDataPackageModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<input type="hidden" id="edp-id">
|
||
<div class="form-group">
|
||
<label class="form-label">Name</label>
|
||
<input id="edp-name" type="text" class="form-input">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Slug</label>
|
||
<input id="edp-slug" type="text" class="form-input" disabled
|
||
style="opacity:0.6;" title="Slug is permanent — used in URLs and grants">
|
||
<div class="form-hint">Slug is permanent.</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Description</label>
|
||
<textarea id="edp-desc" class="form-input" rows="2"></textarea>
|
||
</div>
|
||
<div class="form-group" style="display:flex; gap:12px;">
|
||
<div style="flex:1;">
|
||
<label class="form-label">Status</label>
|
||
<select id="edp-status" class="form-input" style="height:38px;">
|
||
<option value="prod">Prod — ready for analyst use</option>
|
||
<option value="poc">POC — try-before-you-buy</option>
|
||
<option value="coming-soon">Coming soon — visible but not usable yet</option>
|
||
<option value="draft">Draft — admin-only, hidden from analysts</option>
|
||
</select>
|
||
</div>
|
||
<div style="flex:1;">
|
||
<label class="form-label">Category <span class="optional">(optional)</span></label>
|
||
<input id="edp-category" type="text" class="form-input"
|
||
placeholder="e.g. Sessions & Traffic">
|
||
</div>
|
||
</div>
|
||
<div class="form-group" style="display:flex; gap:12px;">
|
||
<div style="flex:1;">
|
||
<label class="form-label">Icon</label>
|
||
<input id="edp-icon" type="text" class="form-input" maxlength="4">
|
||
</div>
|
||
<div style="flex:1;">
|
||
<label class="form-label">Color</label>
|
||
<div class="cf-palette-row" data-target="edp-color"></div>
|
||
<input id="edp-color" type="color" class="form-input"
|
||
style="height:32px; padding:2px; cursor:pointer;">
|
||
</div>
|
||
</div>
|
||
{# Cover image upload (v50). Preview shows the current cover when
|
||
one is set, with an inline Remove button that sends ""
|
||
(empty-string-means-clear contract in the PUT endpoint). #}
|
||
<div class="form-group">
|
||
<label class="form-label">Cover image <span class="optional">(optional)</span></label>
|
||
<div style="display:flex; gap:12px; align-items:center;">
|
||
<div id="edp-cover-preview"
|
||
style="width:120px; height:72px; border-radius:8px;
|
||
border:1px solid var(--border); background:#f1f5f9;
|
||
display:flex; align-items:center; justify-content:center;
|
||
font-size:12px; color:var(--text-secondary); overflow:hidden;">
|
||
No image
|
||
</div>
|
||
<div style="flex:1;">
|
||
<input id="edp-cover-file" type="file"
|
||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||
onchange="onCoverFilePicked(this, 'edp')">
|
||
<input id="edp-cover-url" type="hidden" value="">
|
||
<div class="form-hint">
|
||
PNG / JPEG / GIF / WebP, max 5 MiB.
|
||
<button type="button" class="btn btn-secondary btn-sm"
|
||
style="margin-left:6px; padding:2px 8px; font-size:11px;"
|
||
onclick="clearCoverImage('edp')">Remove image</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{# v50: inline +Add-tables picker. The old "Add tables" button
|
||
closed this Edit modal and opened Bulk Assign, losing the
|
||
admin's mental context. The picker below stays inline: a
|
||
multi-select of unassigned tables + an Add-selected button
|
||
that POSTs without closing the modal. The legacy Bulk Assign
|
||
modal stays available from the section header outside the
|
||
Edit modal. #}
|
||
<div class="form-group" style="border-top:1px solid var(--border); padding-top:14px; margin-top:14px;">
|
||
<label class="form-label" style="display:flex; align-items:center; justify-content:space-between;">
|
||
<span>Tables in this package</span>
|
||
<button class="btn btn-secondary" type="button" style="padding:4px 10px; font-size:12px;"
|
||
onclick="toggleEdpInlineAdd()">
|
||
+ Add tables
|
||
</button>
|
||
</label>
|
||
<div id="edp-inline-add" style="display:none; margin-bottom:8px; padding:8px;
|
||
border:1px dashed var(--border); border-radius:6px; background:var(--background);">
|
||
<div style="display:flex; gap:6px; margin-bottom:6px;">
|
||
<input id="edp-inline-add-search" type="search" class="form-input"
|
||
placeholder="Filter unassigned tables…"
|
||
style="flex:1;" oninput="filterEdpInlineAdd()">
|
||
</div>
|
||
<div id="edp-inline-add-list"
|
||
style="max-height:180px; overflow-y:auto; padding:4px;
|
||
background:var(--surface); border:1px solid var(--border-light);
|
||
border-radius:4px;">
|
||
<div style="padding:8px; color:var(--text-secondary); font-size:12px;">
|
||
Loading unassigned tables…
|
||
</div>
|
||
</div>
|
||
<div style="display:flex; justify-content:flex-end; gap:6px; margin-top:6px;">
|
||
<button class="btn btn-secondary btn-sm" type="button"
|
||
onclick="toggleEdpInlineAdd()">Cancel</button>
|
||
<button class="btn btn-primary btn-sm" type="button"
|
||
id="edp-inline-add-submit-btn"
|
||
onclick="submitEdpInlineAdd()">Add selected</button>
|
||
</div>
|
||
</div>
|
||
<div id="edp-tables-list" class="card-body" style="padding:0; max-height:240px; overflow-y:auto; border:1px solid var(--border); border-radius:8px;"></div>
|
||
</div>
|
||
|
||
{# Inline Group Access matrix — admin can mark this package as
|
||
available / required for each group right from the edit
|
||
modal (mirroring the Create flow). Lazy-loaded on first
|
||
open of the <details>; pre-filled from the package's
|
||
existing resource_grants rows so the dropdowns reflect
|
||
reality. On save, diffs vs the original snapshot and
|
||
emits POST / DELETE /api/admin/grants to bring the server
|
||
in sync. Closes the "Data Package nikde nejde nastavit
|
||
required" user-reported gap. #}
|
||
<details class="form-group" id="edp-rbac-details"
|
||
style="border:1px solid var(--border); border-radius:8px; padding:8px 12px;">
|
||
<summary style="cursor:pointer; font-weight:600; font-size:13px; user-select:none;">
|
||
Group access
|
||
<span style="color:var(--text-secondary); font-weight:normal; font-size:12px;">— mark required / available per group</span>
|
||
</summary>
|
||
<p class="form-hint" style="margin:8px 0;">
|
||
<em>required</em> auto-installs the package into the group's
|
||
stack on next pull; <em>available</em> lets the group see + opt
|
||
in from /catalog; <em>(no grant)</em> hides it. Changes apply
|
||
when you click <strong>Save changes</strong>.
|
||
</p>
|
||
<div id="edp-rbac-rows"
|
||
style="display:flex; flex-direction:column; gap:6px; max-height:30vh; overflow-y:auto;">
|
||
<div class="loading" style="padding:6px; color:var(--text-secondary); font-size:12px;">
|
||
Click to load groups…
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
<div class="modal-footer" style="justify-content:space-between;">
|
||
<button class="btn" type="button" style="color:#b91c1c; border-color:#fca5a5;"
|
||
onclick="deleteEditDataPackage()">Delete package</button>
|
||
<div style="display:flex; gap:8px;">
|
||
<button class="btn btn-secondary" onclick="closeEditDataPackageModal()">Cancel</button>
|
||
<button class="btn btn-primary" id="edp-save-btn" onclick="submitEditDataPackage()">Save changes</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{# Group-by-bucket preview modal — replaces the old confirm(). Lets
|
||
the admin see each distinct bucket, its table count, the resulting
|
||
slug, and whether it would be skipped because a package with that
|
||
slug already exists. Uncheck rows to opt out individually. #}
|
||
<div class="modal-overlay" id="groupByBucketPreviewModal" style="display:none;">
|
||
<div class="modal" style="max-width:640px;">
|
||
<div class="modal-header">
|
||
<h2>Create a Data Package per bucket</h2>
|
||
<button class="modal-close" onclick="closeGroupByBucketModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<p class="form-hint" style="margin-bottom:12px;">
|
||
One package will be created per checked bucket below.
|
||
Existing tables in any package are left in place (the junction
|
||
INSERT is idempotent).
|
||
</p>
|
||
<div id="gbb-rows"
|
||
style="display:flex; flex-direction:column; gap:6px; max-height:50vh; overflow-y:auto;
|
||
border:1px solid var(--border); border-radius:8px; padding:8px;">
|
||
<div class="loading" style="padding:8px; color:var(--text-secondary); font-size:13px;">
|
||
Loading buckets…
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeGroupByBucketModal()">Cancel</button>
|
||
<button class="btn btn-primary" id="gbb-submit-btn"
|
||
onclick="submitGroupByBucket()">Create checked</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-overlay" id="bulkAssignTablesModal" style="display:none;">
|
||
<div class="modal" style="max-width:680px;">
|
||
<div class="modal-header">
|
||
<h2>Bulk assign tables to package</h2>
|
||
<button class="modal-close" onclick="closeBulkAssignModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-group">
|
||
<label class="form-label" for="bulk-assign-package">Target Data Package</label>
|
||
<select id="bulk-assign-package" class="form-input"
|
||
style="width:100%;"
|
||
onchange="refreshBulkAssignAvailability()">
|
||
<option value="">— Loading packages —</option>
|
||
</select>
|
||
<div class="form-hint">Pick the package the selected tables should be bundled into.</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Tables</label>
|
||
<div style="display:flex; gap:8px; align-items:center; margin:6px 0;">
|
||
<input id="bulk-assign-search" type="search" class="form-input"
|
||
placeholder="Filter tables by name / source…"
|
||
style="flex:1;" oninput="filterBulkAssignList()">
|
||
<button type="button" class="btn btn-secondary btn-sm"
|
||
onclick="toggleAllBulkAssign(true)">Select visible</button>
|
||
<button type="button" class="btn btn-secondary btn-sm"
|
||
onclick="toggleAllBulkAssign(false)">Clear</button>
|
||
</div>
|
||
<div id="bulk-assign-list"
|
||
style="border:1px solid var(--border); border-radius:6px;
|
||
max-height:50vh; overflow-y:auto; padding:6px;">
|
||
<div style="padding:12px; color:#5f6368;">Loading tables…</div>
|
||
</div>
|
||
<div class="form-hint" id="bulk-assign-hint">
|
||
Already-assigned tables are checked + disabled so re-submitting
|
||
this modal is a no-op against them.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeBulkAssignModal()">Cancel</button>
|
||
<button class="btn btn-primary" id="bulk-assign-submit-btn"
|
||
onclick="submitBulkAssign()">
|
||
Assign selected
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block scripts %}
|
||
<script>
|
||
/* ═══════════════════════════════════════════════════════════════
|
||
Admin Tables - JavaScript
|
||
═══════════════════════════════════════════════════════════════ */
|
||
|
||
// ── Register-new-table dropdown ─────────────────────────────
|
||
// Replaces the per-connector tab nav. The user picks the connector
|
||
// here only to choose which register modal to open; otherwise the
|
||
// page is package-centric and source_type is just an inline tag.
|
||
function toggleRegisterNewTableMenu(evt) {
|
||
if (evt) evt.stopPropagation();
|
||
var menu = document.getElementById('registerNewTableMenu');
|
||
if (!menu) return;
|
||
menu.style.display = (menu.style.display === 'block') ? 'none' : 'block';
|
||
}
|
||
function closeRegisterNewTableMenu() {
|
||
var menu = document.getElementById('registerNewTableMenu');
|
||
if (menu) menu.style.display = 'none';
|
||
}
|
||
document.addEventListener('click', function(e) {
|
||
var btn = document.getElementById('registerNewTableBtn');
|
||
var menu = document.getElementById('registerNewTableMenu');
|
||
if (!btn || !menu) return;
|
||
if (btn.contains(e.target) || menu.contains(e.target)) return;
|
||
menu.style.display = 'none';
|
||
});
|
||
|
||
// 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, ' |