Promote develop → main (2026-07-25 22:22 UTC) #16
@@ -1401,3 +1401,37 @@ details.adv-section .adv-hint { grid-column: 1 / -1; font-size: 12px; color: var
|
||||
.card-link:hover, .card-link:focus-visible { border-color: var(--accent); }
|
||||
.card-link .card-link-chevron { color: var(--text-muted); transition: color 0.15s ease, transform 0.15s ease; }
|
||||
.card-link:hover .card-link-chevron { color: var(--accent); transform: translateX(2px); }
|
||||
|
||||
/* === Org-Detail als durchgehende Seite (statt Unterreitern) === */
|
||||
.detail-panel.active { display: flex; flex-direction: column; }
|
||||
#orgDetailTabs { order: 1; position: sticky; top: 61px; background: var(--bg-primary); z-index: 50; }
|
||||
.org-block { margin-top: 26px; scroll-margin-top: 120px; }
|
||||
#sub-tokens { order: 2; }
|
||||
#sub-users { order: 3; }
|
||||
#sub-org-licenses { order: 4; }
|
||||
#sub-settings { order: 5; }
|
||||
.org-block-title {
|
||||
font-size: 16px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* === Kontingent-Spalte in der Org-Tabelle === */
|
||||
.org-row { cursor: pointer; }
|
||||
.mini-bar {
|
||||
width: 90px;
|
||||
height: 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-tertiary);
|
||||
overflow: hidden;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.mini-bar-fill { height: 100%; background: var(--success); border-radius: 3px; }
|
||||
.mini-bar-fill.warning { background: var(--warning); }
|
||||
.mini-bar-fill.critical { background: var(--danger); }
|
||||
|
||||
/* === Klickbare Eintraege in der Ablauf-Liste === */
|
||||
.expiring-item-link { cursor: pointer; border-radius: var(--radius); transition: background 0.15s ease; }
|
||||
.expiring-item-link:hover, .expiring-item-link:focus-visible { background: var(--bg-hover); }
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<title>AegisSight Monitor-Verwaltung</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="apple-touch-icon" href="/static/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=20260725b">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=20260725c">
|
||||
<script>(function(){var t=localStorage.getItem('portal_theme');if(t==='light')document.documentElement.setAttribute('data-theme','light');try{var a=JSON.parse(localStorage.getItem('osint_a11y')||'{}');Object.keys(a).forEach(function(k){if(a[k])document.documentElement.setAttribute('data-a11y-'+k,'true');});}catch(e){}})()</script>
|
||||
|
||||
<style>
|
||||
@@ -51,10 +51,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/><path d="M10 18h4"/></svg>
|
||||
Organisationen
|
||||
</button>
|
||||
<button class="nav-tab" data-section="licenses">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 18v3c0 .6.4 1 1 1h4v-3h3v-3h2l1.4-1.4a6.5 6.5 0 1 0-4-4Z"/><circle cx="16.5" cy="7.5" r=".5"/></svg>
|
||||
Lizenzen
|
||||
</button>
|
||||
<button class="nav-tab" data-section="sources">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1"/></svg>
|
||||
Quellen
|
||||
@@ -70,7 +66,7 @@
|
||||
<div class="stats-grid" id="statsGrid"></div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
|
||||
<div class="card card-link" onclick="gotoSection('licenses')" role="link" tabindex="0" title="Zu den Lizenzen">
|
||||
<div class="card card-link" onclick="gotoSection('orgs')" role="link" tabindex="0" title="Zu den Organisationen (Klick auf einen Eintrag öffnet die Organisation)">
|
||||
<div class="card-header">
|
||||
<h2>Bald ablaufende Lizenzen</h2>
|
||||
<svg class="card-link-chevron" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
@@ -137,6 +133,7 @@
|
||||
<th>Slug</th>
|
||||
<th>Nutzer</th>
|
||||
<th>Lizenz</th>
|
||||
<th>Kontingent</th>
|
||||
<th>Status</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
@@ -152,15 +149,19 @@
|
||||
<button class="detail-back" id="orgBackBtn">← Zurück</button>
|
||||
<div id="orgDetailHeader"></div>
|
||||
|
||||
<!-- Alles auf einer Seite: die Pillen scrollen nur zu den Bereichen,
|
||||
sie schalten nichts mehr um. Anzeige-Reihenfolge steuert CSS (order),
|
||||
Verbrauch steht optisch zuerst. -->
|
||||
<div class="nav-tabs mt-16" id="orgDetailTabs">
|
||||
<button class="nav-tab active" data-subtab="users">Nutzer</button>
|
||||
<button class="nav-tab active" data-subtab="tokens">Verbrauch</button>
|
||||
<button class="nav-tab" data-subtab="users">Nutzer</button>
|
||||
<button class="nav-tab" data-subtab="org-licenses">Lizenzen</button>
|
||||
<button class="nav-tab" data-subtab="settings">Einstellungen</button>
|
||||
<button class="nav-tab" data-subtab="tokens">Verbrauch</button>
|
||||
</div>
|
||||
|
||||
<!-- Users Sub-Tab -->
|
||||
<div class="section active" id="sub-users">
|
||||
<!-- Bereich: Nutzer -->
|
||||
<div class="org-block" id="sub-users">
|
||||
<h2 class="org-block-title">Nutzer</h2>
|
||||
<div class="action-bar">
|
||||
<span class="text-secondary" id="userLimitInfo"></span>
|
||||
<button class="btn btn-primary" id="newUserBtn"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>Nutzer anlegen</button>
|
||||
@@ -184,8 +185,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Licenses Sub-Tab -->
|
||||
<div class="section" id="sub-org-licenses">
|
||||
<!-- Bereich: Lizenzen -->
|
||||
<div class="org-block" id="sub-org-licenses">
|
||||
<h2 class="org-block-title">Lizenzen</h2>
|
||||
<div class="action-bar">
|
||||
<span></span>
|
||||
<button class="btn btn-primary" id="newLicenseBtn"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>Neue Lizenz</button>
|
||||
@@ -209,8 +211,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Sub-Tab -->
|
||||
<div class="section" id="sub-settings">
|
||||
<!-- Bereich: Einstellungen -->
|
||||
<div class="org-block" id="sub-settings">
|
||||
<h2 class="org-block-title">Einstellungen</h2>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form id="orgEditForm">
|
||||
@@ -242,7 +245,8 @@
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="section" id="sub-tokens">
|
||||
<div class="org-block" id="sub-tokens">
|
||||
<h2 class="org-block-title">Verbrauch & Kontingent</h2>
|
||||
<div class="token-overview">
|
||||
<!-- Kundensicht: das, was der Kunde im Monitor auch sieht -->
|
||||
<h3 style="margin-bottom:8px;">Kundensicht (Credits)</h3>
|
||||
@@ -398,34 +402,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Licenses Section (global overview) -->
|
||||
<div class="section" id="sec-licenses">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>Bald ablaufende Lizenzen</h2>
|
||||
<select id="expiringDays" style="background: var(--bg-primary); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text-primary); padding: 4px 8px; font-size: 13px;">
|
||||
<option value="14">14 Tage</option>
|
||||
<option value="30" selected>30 Tage</option>
|
||||
<option value="90">90 Tage</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Organisation</th>
|
||||
<th>Typ</th>
|
||||
<th>Max Nutzer</th>
|
||||
<th>Läuft ab</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="expiringTable"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sources Section -->
|
||||
<div class="section" id="sec-sources">
|
||||
<div class="nav-tabs" id="sourceSubTabs">
|
||||
@@ -1241,7 +1217,7 @@
|
||||
</div>
|
||||
|
||||
<script src="/static/js/a11y.js?v=20260725a"></script>
|
||||
<script src="/static/js/app.js?v=20260725c"></script>
|
||||
<script src="/static/js/app.js?v=20260725d"></script>
|
||||
<script src="/static/js/sources.js?v=20260522x2"></script>
|
||||
<script src="/static/js/x-scraper.js?v=20260522a"></script>
|
||||
<script src="/static/js/source-health.js?v=20260509l"></script>
|
||||
|
||||
@@ -137,21 +137,20 @@ function setupNavTabs() {
|
||||
document.getElementById(`sec-${section}`).classList.add("active");
|
||||
|
||||
if (section === "dashboard") loadTranslationStatus();
|
||||
if (section === "licenses") loadExpiringLicenses();
|
||||
if (section === "audit" && typeof loadAudit === "function") loadAudit();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setupOrgDetailTabs() {
|
||||
// Das Org-Detail ist eine durchgehende Seite, die Pillen scrollen nur noch
|
||||
// zum jeweiligen Bereich, statt Unterreiter umzuschalten.
|
||||
document.querySelectorAll("#orgDetailTabs .nav-tab").forEach(tab => {
|
||||
tab.addEventListener("click", () => {
|
||||
const subtab = tab.dataset.subtab;
|
||||
document.querySelectorAll("#orgDetailTabs .nav-tab").forEach(t => t.classList.remove("active"));
|
||||
tab.classList.add("active");
|
||||
document.querySelectorAll("#orgDetail > .section").forEach(s => s.classList.remove("active"));
|
||||
document.getElementById(`sub-${subtab}`).classList.add("active");
|
||||
if (subtab === 'tokens' && currentOrgId) loadOrgTokenUsage(currentOrgId);
|
||||
const block = document.getElementById(`sub-${tab.dataset.subtab}`);
|
||||
if (block) block.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -180,7 +179,7 @@ async function loadDashboard() {
|
||||
<div class="stat-value">${stats.users.total}</div>
|
||||
<div class="stat-sub">${stats.users.active} aktiv</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card-link" data-goto="licenses" role="link" tabindex="0" title="Zu den Lizenzen">
|
||||
<div class="stat-card stat-card-link" data-goto="orgs" role="link" tabindex="0" title="Lizenzen werden je Organisation verwaltet">
|
||||
<div class="stat-label">Aktive Lizenzen</div>
|
||||
<div class="stat-value">${stats.licenses.active}</div>
|
||||
</div>
|
||||
@@ -198,7 +197,8 @@ async function loadDashboard() {
|
||||
expList.innerHTML = '<li class="text-muted" style="padding: 8px 0;">Keine ablaufenden Lizenzen</li>';
|
||||
} else {
|
||||
expList.innerHTML = stats.expiring_licenses.map(l => `
|
||||
<li class="expiring-item">
|
||||
<li class="expiring-item expiring-item-link" role="link" tabindex="0" title="Organisation öffnen"
|
||||
onclick="event.stopPropagation(); switchToOrg(${l.organization_id});">
|
||||
<span>${esc(l.org_name)} <span class="badge badge-${l.license_type}">${l.license_type}</span></span>
|
||||
<span class="expiring-date">${formatDate(l.valid_until)}</span>
|
||||
</li>
|
||||
@@ -226,30 +226,57 @@ async function loadDashboard() {
|
||||
}
|
||||
|
||||
// --- Organizations ---
|
||||
let orgUsageMap = {}; // org_id -> Kontingent-Daten aus /api/token-usage/overview
|
||||
|
||||
async function loadOrgs() {
|
||||
try {
|
||||
orgsCache = await API.get("/api/orgs");
|
||||
// Kontingent-Stand parallel holen, damit die Tabelle ihn direkt zeigt
|
||||
const [orgs, overview] = await Promise.all([
|
||||
API.get("/api/orgs"),
|
||||
API.get("/api/token-usage/overview").catch(() => []),
|
||||
]);
|
||||
orgsCache = orgs;
|
||||
orgUsageMap = {};
|
||||
(overview || []).forEach(o => { orgUsageMap[o.org_id] = o; });
|
||||
renderOrgTable(orgsCache);
|
||||
} catch (err) {
|
||||
console.error("Orgs laden fehlgeschlagen:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Kontingent-Zelle: Verbraucht/Verfuegbar mit Mini-Balken, ∞ bei unlimitiert
|
||||
function orgUsageCell(orgId) {
|
||||
const u = orgUsageMap[orgId];
|
||||
if (!u) return '<span class="text-muted">-</span>';
|
||||
if (u.unlimited_budget) return '<span class="text-muted" title="Unbegrenztes Budget">∞</span>';
|
||||
const avail = u.credits_available || 0;
|
||||
if (!avail) return '<span class="text-muted" title="Kein Kontingent hinterlegt">-</span>';
|
||||
const used = Math.round(u.credits_used || 0);
|
||||
const pct = Math.min(100, Math.round((used / avail) * 100));
|
||||
const cls = pct > 80 ? "critical" : (pct > 50 ? "warning" : "");
|
||||
return `
|
||||
<div title="${used.toLocaleString('de-DE')} von ${Math.round(avail).toLocaleString('de-DE')} Credits verbraucht (${pct}%)">
|
||||
<span style="font-size:12px;">${used.toLocaleString('de-DE')} / ${Math.round(avail).toLocaleString('de-DE')}</span>
|
||||
<div class="mini-bar"><div class="mini-bar-fill ${cls}" style="width:${pct}%"></div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderOrgTable(orgs) {
|
||||
const tbody = document.getElementById("orgTable");
|
||||
if (orgs.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="text-muted">Keine Organisationen</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-muted">Keine Organisationen</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = orgs.map(o => `
|
||||
<tr>
|
||||
<tr class="org-row" onclick="openOrg(${o.id})" title="Organisation öffnen">
|
||||
<td><a href="#" class="text-accent" style="text-decoration: none;" onclick="openOrg(${o.id}); return false;">${esc(o.name)}</a></td>
|
||||
<td class="text-secondary">${esc(o.slug)}</td>
|
||||
<td>${o.user_count}</td>
|
||||
<td><span class="badge badge-${o.license_type || 'none'}">${o.license_type || "Keine"}</span></td>
|
||||
<td>${orgUsageCell(o.id)}</td>
|
||||
<td><span class="badge badge-${o.is_active ? 'active' : 'inactive'}">${o.is_active ? "Aktiv" : "Inaktiv"}</span></td>
|
||||
<td>
|
||||
<button class="btn btn-secondary btn-small" onclick="openOrg(${o.id})">Details</button>
|
||||
<button class="btn btn-secondary btn-small" onclick="event.stopPropagation(); openOrg(${o.id})">Details</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
@@ -275,17 +302,17 @@ async function openOrg(orgId) {
|
||||
document.getElementById("orgListView").style.display = "none";
|
||||
document.getElementById("orgDetail").classList.add("active");
|
||||
|
||||
// Reset to users tab
|
||||
// Erste Pille aktiv (Verbrauch), Ansicht nach oben
|
||||
document.querySelectorAll("#orgDetailTabs .nav-tab").forEach(t => t.classList.remove("active"));
|
||||
document.querySelector('#orgDetailTabs .nav-tab[data-subtab="users"]').classList.add("active");
|
||||
document.querySelectorAll("#orgDetail > .section").forEach(s => s.classList.remove("active"));
|
||||
document.getElementById("sub-users").classList.add("active");
|
||||
const firstPill = document.querySelector("#orgDetailTabs .nav-tab");
|
||||
if (firstPill) firstPill.classList.add("active");
|
||||
window.scrollTo({ top: 0 });
|
||||
|
||||
try {
|
||||
const org = await API.get(`/api/orgs/${orgId}`);
|
||||
document.getElementById("orgDetailHeader").innerHTML = `
|
||||
<h2>${esc(org.name)} <span class="badge badge-${org.is_active ? 'active' : 'inactive'}" style="font-size: 12px; vertical-align: middle;">${org.is_active ? "Aktiv" : "Inaktiv"}</span></h2>
|
||||
<div class="text-secondary mt-8">Slug: ${esc(org.slug)} | Nutzer: ${org.user_count} | Lizenz: ${org.license_type || "Keine"}</div>
|
||||
<div class="text-secondary mt-8">Slug: ${esc(org.slug)} | Nutzer: ${org.user_count} | Lizenz: ${org.license_type || "Keine"} | Kontingent: <span id="orgKpiCredits">wird geladen …</span></div>
|
||||
`;
|
||||
|
||||
document.getElementById("editOrgName").value = org.name;
|
||||
@@ -293,8 +320,10 @@ async function openOrg(orgId) {
|
||||
const langEl = document.getElementById("editOrgLanguage");
|
||||
if (langEl) langEl.value = org.output_language || "de";
|
||||
|
||||
// Alle Bereiche sofort laden, es gibt keine Unterreiter mehr
|
||||
loadOrgUsers(orgId);
|
||||
loadOrgLicenses(orgId);
|
||||
loadOrgTokenUsage(orgId);
|
||||
} catch (err) {
|
||||
console.error("Org laden fehlgeschlagen:", err);
|
||||
}
|
||||
@@ -449,7 +478,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
await API.put(`/api/licenses/${_extendLicenseId}/extend?days=${days}`);
|
||||
closeModal("modalExtendLicense");
|
||||
if (currentOrgId) loadOrgLicenses(currentOrgId);
|
||||
loadExpiringLicenses();
|
||||
loadDashboard();
|
||||
} catch (err) {
|
||||
if (errEl) { errEl.textContent = err.message; errEl.style.display = "block"; }
|
||||
}
|
||||
@@ -473,7 +502,11 @@ function confirmRevokeLicense(licId) {
|
||||
|
||||
// --- Expiring Licenses (global view) ---
|
||||
async function loadExpiringLicenses() {
|
||||
const days = document.getElementById("expiringDays").value;
|
||||
// Der globale Lizenzen-Reiter wurde entfernt, die Ablauf-Liste lebt auf dem
|
||||
// Dashboard. Falls die Elemente fehlen, ist hier nichts zu tun.
|
||||
const daysEl = document.getElementById("expiringDays");
|
||||
if (!daysEl || !document.getElementById("expiringTable")) return;
|
||||
const days = daysEl.value;
|
||||
try {
|
||||
const licenses = await API.get(`/api/licenses/expiring?days=${days}`);
|
||||
const tbody = document.getElementById("expiringTable");
|
||||
@@ -974,6 +1007,16 @@ async function loadOrgTokenUsage(orgId) {
|
||||
|
||||
fillBudgetForm(budget);
|
||||
|
||||
// Kontingent-Kurzinfo im Org-Kopf
|
||||
const kpi = document.getElementById("orgKpiCredits");
|
||||
if (kpi) {
|
||||
if (unlimited) kpi.textContent = "unbegrenzt";
|
||||
else if (budget.credits_available) {
|
||||
const pct = budget.credits_percent_used != null ? ` (${budget.credits_percent_used.toFixed(0)}% verbraucht)` : "";
|
||||
kpi.textContent = `${Math.round(budget.credits_used || 0).toLocaleString("de-DE")} von ${Math.round(budget.credits_available).toLocaleString("de-DE")} Credits${pct}`;
|
||||
} else kpi.textContent = "kein Kontingent";
|
||||
}
|
||||
|
||||
const tbody = document.getElementById('tokenMonthlyTable');
|
||||
if (tbody) {
|
||||
tbody.innerHTML = monthly.map(function(m) {
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren