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:
@@ -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,
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren