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 @@
-
+
+
+
+
+
- | Name |
- Domain |
- Typ |
- Kategorie |
- Organisation |
+ |
+ Name |
+ Domain |
+ Typ |
+ Kategorie |
+ Organisation |
Hinzugefügt von |
Aktionen |
@@ -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 = '
| Keine Kundenquellen |
';
+ tbody.innerHTML = `| Keine Kundenquellen |
`;
+ 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 `
+ |
${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 || "-")} |
|
-
- `).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) {