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
Dieser Commit ist enthalten in:
claude-dev
2026-05-09 03:07:55 +00:00
Ursprung eda60f9299
Commit 9350e4538a
3 geänderte Dateien mit 220 neuen und 35 gelöschten Zeilen

Datei anzeigen

@@ -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,

Datei anzeigen

@@ -340,21 +340,34 @@
<!-- Kundenquellen -->
<div class="section" id="sub-tenant-sources">
<div class="action-bar">
<div style="display:flex;align-items:center;gap:12px;">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<input type="text" class="search-input" id="tenantSourceSearch" placeholder="Kundenquelle suchen...">
<select class="filter-select" id="tenantFilterType" onchange="filterTenantSources()">
<option value="">Alle Typen</option>
</select>
<select class="filter-select" id="tenantFilterCategory" onchange="filterTenantSources()">
<option value="">Alle Kategorien</option>
</select>
<select class="filter-select" id="tenantFilterOrg" onchange="filterTenantSources()">
<option value="">Alle Organisationen</option>
</select>
<span class="text-secondary" id="tenantSourceCount"></span>
</div>
<button class="btn btn-primary" id="tenantBulkPromoteBtn" disabled onclick="bulkPromoteSelected()">
Ausgewählte übernehmen (0)
</button>
</div>
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Domain</th>
<th>Typ</th>
<th>Kategorie</th>
<th>Organisation</th>
<th style="width:32px;"><input type="checkbox" id="tenantSelectAll" onchange="toggleTenantSelectAll(this.checked)"></th>
<th class="sortable" data-sort="name" onclick="sortTenantSources('name')">Name <span class="sort-icon"></span></th>
<th class="sortable" data-sort="domain" onclick="sortTenantSources('domain')">Domain <span class="sort-icon"></span></th>
<th class="sortable" data-sort="source_type" onclick="sortTenantSources('source_type')">Typ <span class="sort-icon"></span></th>
<th class="sortable" data-sort="category" onclick="sortTenantSources('category')">Kategorie <span class="sort-icon"></span></th>
<th class="sortable" data-sort="org_name" onclick="sortTenantSources('org_name')">Organisation <span class="sort-icon"></span></th>
<th>Hinzugefügt von</th>
<th>Aktionen</th>
</tr>
@@ -365,18 +378,6 @@
</div>
</div>
<!-- Quellen-Health -->
<div class="section" id="sub-source-health">
<div class="action-bar">
<h2 style="font-size:16px;font-weight:600;">Quellen-Health & Vorschläge</h2>
<button class="btn btn-primary" id="runHealthCheckBtn" onclick="runHealthCheck()">Jetzt prüfen</button>
</div>
<div id="healthContent">
<div class="text-muted" style="padding:20px;">Tab auswählen um Health-Daten zu laden...</div>
</div>
</div>
</div>
<!-- Audit-Log Section -->
<div class="section" id="sec-audit">
<div class="action-bar" style="flex-wrap:wrap;gap:8px;">

Datei anzeigen

@@ -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 = '<tr><td colspan="7" class="text-muted">Keine Kundenquellen</td></tr>';
tbody.innerHTML = `<tr><td colspan="${cols}" class="text-muted">Keine Kundenquellen</td></tr>`;
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 `
<tr>
<td><input type="checkbox" class="tenant-select" data-id="${s.id}" ${checked} onchange="toggleTenantSelect(${s.id}, this.checked)"></td>
<td>${esc(s.name)}</td>
<td class="text-secondary" style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(s.url || '')}">${esc(s.domain || "-")}</td>
<td>${TYPE_LABELS[s.source_type] || s.source_type}</td>
<td>${CATEGORY_LABELS[s.category] || s.category}</td>
<td>${typeLabel(s.source_type)}</td>
<td>${categoryLabel(s.category)}</td>
<td>${esc(s.org_name || "-")}</td>
<td>${esc(s.added_by || "-")}</td>
<td>
<button class="btn btn-primary btn-small" onclick="promoteSource(${s.id}, '${esc(s.name)}')">Übernehmen</button>
</td>
</tr>
`).join("");
</tr>`;
}).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) {