From 9350e4538a68b613a040c4d3e114fb865c058efb Mon Sep 17 00:00:00 2001 From: claude-dev Date: Sat, 9 May 2026 03:07:55 +0000 Subject: [PATCH] Phase 3c: Kundenquellen-Tab mit Filter + Sort + Bulk-Promote Backend - routers/sources.py: POST /api/sources/tenant/bulk-promote NEU Nimmt Liste von source_ids, promotet jede einzeln zur Grundquelle. Returns {promoted, skipped[{id,name,reason}], failed[{id,error}]}. Ueberspringt Quellen die schon Grundquellen sind oder deren URL bereits als Grundquelle existiert. Frontend - dashboard.html sub-tenant-sources: action-bar erweitert um 3 Filter-Selects (Typ, Kategorie, Org), Bulk-Promote-Button. Tabelle bekommt Checkbox-Spalte + sortable Spalten (Sort-Icons). - sources.js: tenant-Tab komplett refactored - State: tenantFilters, tenantSort, tenantSelected (Set) - applyTenantFilterAndSort: zentraler Render-Pfad mit allen Filtern + Sort - populateTenantFilters: Org-Liste aus Daten, Typ/Kategorie aus META - toggleTenantSelect / toggleTenantSelectAll: Selection-Logik - bulkPromoteSelected: showConfirm -> POST -> Toast mit Ergebnis - renderTenantSources: Checkbox-Spalte, dynamische typeLabel/categoryLabel - Counter zeigt jetzt N gefiltert / Gesamt --- src/routers/sources.py | 66 +++++++++++++++++ src/static/dashboard.html | 37 +++++----- src/static/js/sources.js | 152 +++++++++++++++++++++++++++++++++----- 3 files changed, 220 insertions(+), 35 deletions(-) diff --git a/src/routers/sources.py b/src/routers/sources.py index 8c70966..df78cb7 100644 --- a/src/routers/sources.py +++ b/src/routers/sources.py @@ -231,6 +231,72 @@ async def promote_to_global( + + + +class BulkPromoteRequest(BaseModel): + source_ids: list[int] + + +@router.post("/tenant/bulk-promote") +async def bulk_promote_tenant_sources( + data: BulkPromoteRequest, + request: Request, + admin: dict = Depends(get_current_admin), + db: aiosqlite.Connection = Depends(db_dependency), +): + """Mehrere Tenant-Quellen auf einen Schlag zur Grundquelle befoerdern. + + Returns: + promoted: int - Anzahl erfolgreich promoter Quellen + skipped: list - {id, name, reason} fuer uebersprungene + failed: list - {id, error} fuer Fehler + """ + promoted = 0 + skipped = [] + failed = [] + + for sid in data.source_ids: + try: + cur = await db.execute("SELECT * FROM sources WHERE id = ?", (sid,)) + row = await cur.fetchone() + if not row: + failed.append({"id": sid, "error": "nicht gefunden"}) + continue + if row["tenant_id"] is None: + skipped.append({"id": sid, "name": row["name"], "reason": "bereits Grundquelle"}) + continue + before = dict(row) + + if row["url"]: + cur = await db.execute( + "SELECT id FROM sources WHERE url = ? AND tenant_id IS NULL", + (row["url"],), + ) + if await cur.fetchone(): + skipped.append({"id": sid, "name": row["name"], + "reason": "URL bereits als Grundquelle vorhanden"}) + continue + + await db.execute( + "UPDATE sources SET tenant_id = NULL, added_by = 'system' WHERE id = ?", + (sid,), + ) + cur = await db.execute("SELECT * FROM sources WHERE id = ?", (sid,)) + after = dict(await cur.fetchone()) + await log_action( + db, admin, get_client_ip(request), + action="update", resource_type="source", resource_id=sid, + before=before, after=after, + ) + promoted += 1 + except Exception as e: + failed.append({"id": sid, "error": str(e)}) + + await db.commit() + return {"promoted": promoted, "skipped": skipped, "failed": failed} + + @router.post("/discover") async def discover_source_endpoint( url: str, diff --git a/src/static/dashboard.html b/src/static/dashboard.html index 705858a..3666736 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -340,21 +340,34 @@
-
+
+ + +
+
- - - - - + + + + + + @@ -365,18 +378,6 @@ - -
-
-

Quellen-Health & Vorschläge

- -
-
-
Tab auswählen um Health-Daten zu laden...
-
-
- -
diff --git a/src/static/js/sources.js b/src/static/js/sources.js index 0407727..9d3fdbf 100644 --- a/src/static/js/sources.js +++ b/src/static/js/sources.js @@ -3,6 +3,11 @@ let globalSourcesCache = []; let tenantSourcesCache = []; +// Phase 3c: Tenant-Tab State +let tenantFilters = { search: "", type: "", category: "", org: "" }; +let tenantSort = { field: "org_name", asc: true }; +let tenantSelected = new Set(); + let editingSourceId = null; let globalSortField = "category"; let globalSortAsc = true; @@ -246,47 +251,160 @@ function confirmDeleteGlobalSource(id, name) { async function loadTenantSources() { try { tenantSourcesCache = await API.get("/api/sources/tenant"); - renderTenantSources(tenantSourcesCache); + tenantSelected.clear(); + populateTenantFilters(); + applyTenantFilterAndSort(); } catch (err) { console.error("Kundenquellen laden fehlgeschlagen:", err); + showToast("Kundenquellen konnten nicht geladen werden", "error"); + } +} + +function populateTenantFilters() { + // Typ + Kategorie aus META, Org aus Cache + if (window.META && window.META.types) { + populateSelect(document.getElementById("tenantFilterType"), + window.META.types.filter(t => t.key !== "excluded"), "Alle Typen"); + } + if (window.META && window.META.categories) { + populateSelect(document.getElementById("tenantFilterCategory"), + window.META.categories, "Alle Kategorien"); + } + // Org-Liste aus den Daten extrahieren (eindeutig) + const orgs = Array.from(new Set(tenantSourcesCache.map(s => s.org_name).filter(Boolean))).sort(); + populateSelect( + document.getElementById("tenantFilterOrg"), + orgs.map(o => ({ key: o, label: o })), + "Alle Organisationen", + ); +} + +function applyTenantFilterAndSort() { + const q = (tenantFilters.search || "").toLowerCase(); + let filtered = tenantSourcesCache.filter(s => { + if (q && !( + (s.name || "").toLowerCase().includes(q) + || (s.domain || "").toLowerCase().includes(q) + || (s.org_name || "").toLowerCase().includes(q) + || (s.url || "").toLowerCase().includes(q) + )) return false; + if (tenantFilters.type && s.source_type !== tenantFilters.type) return false; + if (tenantFilters.category && s.category !== tenantFilters.category) return false; + if (tenantFilters.org && s.org_name !== tenantFilters.org) return false; + return true; + }); + + filtered.sort((a, b) => { + const va = String(a[tenantSort.field] ?? "").toLowerCase(); + const vb = String(b[tenantSort.field] ?? "").toLowerCase(); + const cmp = va.localeCompare(vb, "de"); + return tenantSort.asc ? cmp : -cmp; + }); + + renderTenantSources(filtered); + // Sort-Icons aktualisieren + document.querySelectorAll("#sub-tenant-sources th.sortable .sort-icon").forEach(el => el.textContent = ""); + const active = document.querySelector(`#sub-tenant-sources th.sortable[data-sort="${tenantSort.field}"] .sort-icon`); + if (active) active.textContent = tenantSort.asc ? " \u25B2" : " \u25BC"; +} + +function filterTenantSources() { + tenantFilters.search = (document.getElementById("tenantSourceSearch")?.value || "").trim(); + tenantFilters.type = document.getElementById("tenantFilterType")?.value || ""; + tenantFilters.category = document.getElementById("tenantFilterCategory")?.value || ""; + tenantFilters.org = document.getElementById("tenantFilterOrg")?.value || ""; + applyTenantFilterAndSort(); +} + +function sortTenantSources(field) { + if (tenantSort.field === field) tenantSort.asc = !tenantSort.asc; + else { tenantSort.field = field; tenantSort.asc = true; } + applyTenantFilterAndSort(); +} + +function toggleTenantSelectAll(checked) { + document.querySelectorAll("#tenantSourceTable input.tenant-select").forEach(cb => { + cb.checked = checked; + const id = parseInt(cb.dataset.id); + if (checked) tenantSelected.add(id); else tenantSelected.delete(id); + }); + updateBulkButton(); +} + +function toggleTenantSelect(id, checked) { + id = parseInt(id); + if (checked) tenantSelected.add(id); else tenantSelected.delete(id); + updateBulkButton(); + // Header-Checkbox anpassen + const visible = document.querySelectorAll("#tenantSourceTable input.tenant-select").length; + const checkedVisible = document.querySelectorAll("#tenantSourceTable input.tenant-select:checked").length; + const all = document.getElementById("tenantSelectAll"); + if (all) all.checked = visible > 0 && visible === checkedVisible; +} + +function updateBulkButton() { + const btn = document.getElementById("tenantBulkPromoteBtn"); + if (!btn) return; + const n = tenantSelected.size; + btn.disabled = n === 0; + btn.textContent = `Ausgewählte übernehmen (${n})`; +} + +async function bulkPromoteSelected() { + if (tenantSelected.size === 0) return; + const ids = Array.from(tenantSelected); + const ok = await showConfirm( + "Ausgewählte als Grundquelle übernehmen", + `Sollen ${ids.length} Kundenquelle(n) als Grundquelle übernommen werden? Sie werden dann für alle Monitore verfügbar.`, + ); + if (!ok) return; + try { + const result = await API.post("/api/sources/tenant/bulk-promote", { source_ids: ids }); + let msg = `${result.promoted} übernommen`; + if (result.skipped && result.skipped.length) msg += `, ${result.skipped.length} übersprungen`; + if (result.failed && result.failed.length) msg += `, ${result.failed.length} Fehler`; + showToast(msg, result.failed && result.failed.length ? "warning" : "success"); + tenantSelected.clear(); + await loadTenantSources(); + } catch (err) { + showToast("Bulk-Promote fehlgeschlagen: " + err.message, "error"); } } function renderTenantSources(sources) { const tbody = document.getElementById("tenantSourceTable"); + const cols = 8; if (sources.length === 0) { - tbody.innerHTML = '
'; + tbody.innerHTML = ``; + document.getElementById("tenantSourceCount").textContent = `0 / ${tenantSourcesCache.length} Kundenquellen`; + updateBulkButton(); return; } - tbody.innerHTML = sources.map((s) => ` + tbody.innerHTML = sources.map((s) => { + const checked = tenantSelected.has(s.id) ? "checked" : ""; + return ` + - - + + - - `).join(""); + `; + }).join(""); - document.getElementById("tenantSourceCount").textContent = `${sources.length} Kundenquellen`; + document.getElementById("tenantSourceCount").textContent = `${sources.length} / ${tenantSourcesCache.length} Kundenquellen`; + updateBulkButton(); } // Suche Kundenquellen document.addEventListener("DOMContentLoaded", () => { const el = document.getElementById("tenantSourceSearch"); - if (el) { - el.addEventListener("input", () => { - const q = el.value.toLowerCase(); - const filtered = tenantSourcesCache.filter((s) => - s.name.toLowerCase().includes(q) || (s.domain || "").toLowerCase().includes(q) || (s.org_name || "").toLowerCase().includes(q) - ); - renderTenantSources(filtered); - }); - } + if (el) el.addEventListener("input", () => filterTenantSources()); }); function promoteSource(id, name) {
NameDomainTypKategorieOrganisationName Domain Typ Kategorie Organisation Hinzugefügt von Aktionen
Keine Kundenquellen
Keine Kundenquellen
${esc(s.name)} ${esc(s.domain || "-")}${TYPE_LABELS[s.source_type] || s.source_type}${CATEGORY_LABELS[s.category] || s.category}${typeLabel(s.source_type)}${categoryLabel(s.category)} ${esc(s.org_name || "-")} ${esc(s.added_by || "-")}