feat: tune visual of web app admin panel

This commit is contained in:
3252a8
2026-05-12 09:59:15 +03:00
parent fe976022ec
commit c8d4b3565c
8 changed files with 766 additions and 91 deletions
+115 -1
View File
@@ -285,6 +285,86 @@ def _write_tariffs_config_file(path: Path, config: TariffsConfig) -> None:
path.write_text(payload, encoding="utf-8")
def _panel_node_uuid_key(node: Dict[str, Any]) -> str:
uid = node.get("nodeUuid") or node.get("node_uuid") or node.get("uuid") or node.get("id")
return str(uid).strip().lower() if uid else ""
def _panel_node_users_online(node: Dict[str, Any]) -> Optional[int]:
uo = node.get("usersOnline")
if uo is None:
uo = node.get("users_online")
if uo is None:
uo = node.get("onlineUsers") or node.get("online_users")
if uo is None:
mg = node.get("metricGroups")
if isinstance(mg, dict):
uo = mg.get("onlineUsers") or mg.get("online_users")
if uo is None:
return None
try:
return int(uo)
except (TypeError, ValueError):
return None
def _panel_nodes_online_by_uuid(nodes_payload: Any) -> Dict[str, int]:
"""Build node_uuid(lower) -> usersOnline from GET /system/stats/nodes payload."""
out: Dict[str, int] = {}
raw_list: Optional[List[Any]] = None
if isinstance(nodes_payload, list):
raw_list = nodes_payload
elif isinstance(nodes_payload, dict):
raw_list = nodes_payload.get("nodes")
if raw_list is None:
raw_list = nodes_payload.get("items") or nodes_payload.get("data")
if not isinstance(raw_list, list):
return out
for n in raw_list:
if not isinstance(n, dict):
continue
key = _panel_node_uuid_key(n)
if not key:
continue
online = _panel_node_users_online(n)
if online is not None:
out[key] = online
return out
def _enrich_bandwidth_nodes_with_online(
bw: Any,
online_by_uuid: Dict[str, int],
online_by_name: Optional[Dict[str, int]] = None,
) -> None:
"""Attach usersOnline to topNodes/series (UUID and optional node name)."""
if not isinstance(bw, dict):
return
if not online_by_uuid and not online_by_name:
return
for key in ("topNodes", "series"):
arr = bw.get(key)
if not isinstance(arr, list):
continue
for item in arr:
if not isinstance(item, dict):
continue
if item.get("usersOnline") is not None:
continue
uid = item.get("uuid") or item.get("nodeUuid") or item.get("node_uuid")
if uid and online_by_uuid:
hit = online_by_uuid.get(str(uid).strip().lower())
if hit is not None:
item["usersOnline"] = hit
continue
if online_by_name:
nm = item.get("name")
if nm and isinstance(nm, str):
hitn = online_by_name.get(nm.strip().lower())
if hitn is not None:
item["usersOnline"] = hitn
# ─── Routes ────────────────────────────────────────────────────────
@@ -323,7 +403,41 @@ async def admin_stats_route(request: web.Request) -> web.Response:
try:
system = await panel_service.get_system_stats()
bandwidth = await panel_service.get_bandwidth_stats()
payload["panel"] = {"system": system or {}, "bandwidth": bandwidth or {}}
panel_body: Dict[str, Any] = {
"system": system or {},
"bandwidth": bandwidth or {},
}
try:
nodes = await panel_service.get_nodes_statistics()
panel_body["nodes"] = nodes or {}
except Exception as exc_nodes: # pragma: no cover - optional endpoint
logger.debug("Panel nodes stats unavailable: %s", exc_nodes)
panel_body["nodes"] = {}
try:
today = datetime.now(timezone.utc).date()
start_d = today - timedelta(days=7)
nodes_bw = await panel_service.get_nodes_bandwidth_usage(
start=start_d.isoformat(),
end=today.isoformat(),
top_nodes_limit=64,
)
panel_body["nodes_bandwidth"] = nodes_bw or {}
except Exception as exc_nb: # pragma: no cover - optional endpoint
logger.debug("Panel nodes bandwidth range unavailable: %s", exc_nb)
panel_body["nodes_bandwidth"] = {}
try:
online_map = _panel_nodes_online_by_uuid(panel_body.get("nodes"))
lookups = await panel_service.get_nodes_online_lookups()
for k, v in lookups.get("byUuid", {}).items():
online_map[k] = v
_enrich_bandwidth_nodes_with_online(
panel_body.get("nodes_bandwidth"),
online_map,
lookups.get("byName") or {},
)
except Exception as exc_merge: # pragma: no cover
logger.debug("Panel nodes online merge skipped: %s", exc_merge)
payload["panel"] = panel_body
except Exception as exc:
logger.debug("Panel stats unavailable: %s", exc)
payload["panel"] = {"error": "unavailable"}
@@ -268,6 +268,9 @@
function setAdminLanguageMenuOpen(open) {
adminLanguageMenuOpen = Boolean(open);
clearAdminLanguageClickGuard();
// Desktop doesn't need the click-guard overlay and it can block
// option clicks in portaled select content.
if (!isCompact) return;
if (adminLanguageMenuOpen) {
adminLanguageClickGuard = true;
adminLanguageClickGuardArmTimer = window.setTimeout(() => {
@@ -319,7 +322,7 @@
{#if sidebarOpen}
<button type="button" class="admin-sidebar-backdrop" aria-label={at("close_menu", {}, "Закрыть меню")} on:click={() => (sidebarOpen = false)}></button>
{/if}
{#if adminLanguageMenuOpen || adminLanguageClickGuard}
{#if isCompact && (adminLanguageMenuOpen || adminLanguageClickGuard)}
<button
class="language-select-guard"
class:language-select-guard--armed={adminLanguageClickGuardArmed}
@@ -1,7 +1,8 @@
<script>
import { Activity, Radio, Server, TrendingDown, TrendingUp } from "$components/ui/icons.js";
import { Activity, Radio, Server, TrendingDown, TrendingUp, Zap } from "$components/ui/icons.js";
import { getContext, onMount } from "svelte";
import { fmtTrafficBytes } from "../../lib/admin/format.js";
import Badge from "$components/ui/badge.svelte";
import * as Card from "$components/ui/card/index.js";
import {
@@ -38,6 +39,10 @@
panelPayload && !panelPayload.error ? parsePanelSystem(panelPayload) : null;
$: panelBw =
panelPayload && !panelPayload.error ? parsePanelBandwidth(panelPayload) : null;
$: panelNodeTraffic =
panelPayload && !panelPayload.error ? parsePanelNodeTraffic(panelPayload) : null;
const PANEL_NODE_TILE_LIMIT = 10;
$: dailySeries = Array.isArray(fin.daily_series) ? fin.daily_series : [];
$: revenueKpis = computeRevenueKpis(fin, dailySeries);
@@ -62,6 +67,13 @@
const memUsed = Number(mem.used) || 0;
const memPct = memTotal > 0 ? (memUsed / memTotal) * 100 : null;
const nodes = system.nodes || {};
const cpuRaw =
system.cpu?.usage ??
system.cpu?.usedPercent ??
system.cpu?.percent ??
system.cpuUsage ??
system.cpuLoad;
const cpuPct = Number(cpuRaw);
return {
onlineNow: onlineStats.onlineNow ?? 0,
active: statusCounts.ACTIVE ?? 0,
@@ -71,6 +83,7 @@
totalPanelUsers: u.totalUsers ?? 0,
nodesOnline: nodes.totalOnline != null ? nodes.totalOnline : null,
memPct,
cpuPct: Number.isFinite(cpuPct) ? cpuPct : null,
};
}
@@ -84,6 +97,302 @@
return { week, month };
}
function panelRowBytes(row) {
if (!row || typeof row !== "object") return 0;
const total = Number(row.total);
if (Number.isFinite(total) && total > 0) return total;
const up = Number(
row.uploadBytes ?? row.uplinkBytes ?? row.uplink ?? row.up ?? row.upload,
);
const down = Number(
row.downloadBytes ?? row.downlinkBytes ?? row.downlink ?? row.down ?? row.download,
);
const sum = (Number.isFinite(up) ? up : 0) + (Number.isFinite(down) ? down : 0);
return sum > 0 ? sum : 0;
}
/** Remnawave node metrics: inboundsStats / outboundsStats use uplink+downlink per tag. */
function sumDirectionPair(item) {
if (!item || typeof item !== "object") return 0;
const combined = Number(item.total ?? item.bytes ?? item.value);
if (Number.isFinite(combined) && combined > 0) return combined;
const up = Number(
item.uplink ?? item.upload ?? item.uploadBytes ?? item.up ?? item.tx ?? item.sent,
);
const down = Number(
item.downlink ?? item.download ?? item.downloadBytes ?? item.down ?? item.rx ?? item.received,
);
return (Number.isFinite(up) ? up : 0) + (Number.isFinite(down) ? down : 0);
}
function sumTaggedStatsList(arr) {
if (!Array.isArray(arr)) return 0;
return arr.reduce((acc, item) => acc + sumDirectionPair(item), 0);
}
/** Traffic bytes for one node record from GET /system/stats/nodes (current panel shape). */
function trafficBytesFromNodeRecord(node) {
if (!node || typeof node !== "object") return 0;
let b =
sumTaggedStatsList(node.inboundsStats) +
sumTaggedStatsList(node.outboundsStats) +
sumTaggedStatsList(node.inbounds_stats) +
sumTaggedStatsList(node.outbounds_stats);
if (b <= 0) b = panelRowBytes(node);
const life = Number(
node.totalBytesLifetime ?? node.totalBytes ?? node.bytesLifetime ?? node.totalTrafficBytes,
);
if (b <= 0 && Number.isFinite(life) && life > 0) b = life;
return b;
}
function isNodeMetricsShape(row) {
if (!row || typeof row !== "object") return false;
return (
Array.isArray(row.inboundsStats) ||
Array.isArray(row.outboundsStats) ||
Array.isArray(row.inbounds_stats) ||
Array.isArray(row.outbounds_stats)
);
}
function panelRowLabel(row) {
if (!row || typeof row !== "object") return "—";
for (const k of ["nodeName", "node_name", "name", "nodeRemark", "remark", "label", "title"]) {
const v = row[k];
if (v != null && String(v).trim()) return String(v).trim();
}
const u = row.nodeUuid ?? row.node_uuid ?? row.uuid;
if (u) return `${String(u).slice(0, 8)}…`;
return "—";
}
function nodeRecordUuid(row) {
if (!row || typeof row !== "object") return "";
const u = row.nodeUuid ?? row.node_uuid ?? row.uuid ?? row.id;
return u != null ? String(u) : "";
}
function nodeRecordDisplayName(row) {
if (!row || typeof row !== "object") return "";
for (const k of ["nodeName", "node_name", "name", "label", "title", "hostname"]) {
const v = row[k];
if (v != null && String(v).trim()) return String(v).trim();
}
return "";
}
function nodeRecordUsersOnline(row) {
if (!row || typeof row !== "object") return null;
const raw =
row.usersOnline ??
row.users_online ??
row.onlineUsers ??
row.online_users ??
row.onlineUserCount ??
row.online_user_count ??
row.connectedUsers ??
row.connected_users ??
row.onlineNow;
const n = Number(raw);
if (Number.isFinite(n)) return n;
const mg = row.metricGroups;
if (mg && typeof mg === "object") {
const v = Number(mg.onlineUsers ?? mg.online_users);
if (Number.isFinite(v)) return v;
}
return null;
}
/** Node list shapes from GET /system/stats/nodes (varies by panel version). */
function extractPanelNodesList(raw) {
if (!raw) return [];
if (Array.isArray(raw)) return raw;
if (typeof raw !== "object") return [];
if (Array.isArray(raw.nodes)) return raw.nodes;
if (Array.isArray(raw.items)) return raw.items;
if (Array.isArray(raw.data)) return raw.data;
if (Array.isArray(raw.response)) return raw.response;
return [];
}
/** UUID + display name -> online count from panel node metrics. */
function buildNodeOnlineLookup(panel) {
const byUuid = new Map();
const byName = new Map();
const list = extractPanelNodesList(panel?.nodes);
for (const node of list) {
if (!node || typeof node !== "object") continue;
const online = nodeRecordUsersOnline(node);
if (online == null) continue;
const id = nodeRecordUuid(node);
if (id) byUuid.set(id.toLowerCase(), online);
const nm = nodeRecordDisplayName(node);
if (nm) byName.set(nm.toLowerCase(), online);
}
return { byUuid, byName };
}
function formatTrafficCell(bytes, row, stringHint) {
if (bytes > 0) return fmtTrafficBytes(bytes);
const cur = row?.current;
if (typeof cur === "string" && cur.trim()) return cur.trim();
if (typeof stringHint === "string" && stringHint.trim()) return stringHint.trim();
if (isNodeMetricsShape(row) && !bytes) return fmtTrafficBytes(0);
return "—";
}
function buildNodeMetricsRows(nodes) {
return nodes
.filter((n) => n && typeof n === "object")
.map((node) => {
const bytes = trafficBytesFromNodeRecord(node);
const uid = nodeRecordUuid(node);
return {
label: panelRowLabel(node),
value: formatTrafficCell(bytes, node, ""),
sort: bytes,
uuid: uid || null,
online: nodeRecordUsersOnline(node),
};
})
.sort((a, b) => b.sort - a.sort);
}
/** Aggregate legacy arrays (daily rows per node, etc.). */
function aggregatePanelNodeRows(rows) {
if (!Array.isArray(rows) || !rows.length) return [];
const map = new Map();
for (const row of rows) {
if (!row || typeof row !== "object") continue;
const key = String(
row.nodeUuid ?? row.node_uuid ?? row.uuid ?? row.nodeName ?? row.name ?? panelRowLabel(row),
);
const prev = map.get(key) || { label: panelRowLabel(row), bytes: 0, stringHint: "" };
const add = isNodeMetricsShape(row) ? trafficBytesFromNodeRecord(row) : panelRowBytes(row);
prev.bytes += add;
const cur = row.current;
if (typeof cur === "string" && cur.trim()) prev.stringHint = cur.trim();
prev.label = panelRowLabel(row) || prev.label;
map.set(key, prev);
}
return [...map.values()]
.map((x) => ({
label: x.label,
value:
x.bytes > 0
? fmtTrafficBytes(x.bytes)
: x.stringHint && String(x.stringHint).trim()
? String(x.stringHint).trim()
: "—",
sort: x.bytes,
uuid: null,
online: null,
}))
.sort((a, b) => b.sort - a.sort);
}
function bandwidthRowUuid(n) {
if (!n || typeof n !== "object") return "";
const u = n.uuid ?? n.nodeUuid ?? n.node_uuid ?? n.id;
return u != null ? String(u) : "";
}
function attachNodeOnlineToRows(rows, lookup) {
if (!Array.isArray(rows) || !lookup) return rows;
const { byUuid, byName } = lookup;
if (!byUuid.size && !byName.size) return rows;
return rows.map((r) => {
let o = r.online;
if (o == null && r.uuid) {
const hit = byUuid.get(String(r.uuid).toLowerCase());
if (hit != null) o = hit;
}
if (o == null && r.label && typeof r.label === "string") {
const hit = byName.get(r.label.trim().toLowerCase());
if (hit != null) o = hit;
}
if (o != null) return { ...r, online: o };
return r;
});
}
/** Panel analytics: GET /bandwidth-stats/nodes — totals per node for the selected range (bytes). */
function parseNodesBandwidthTop(panel) {
const nb = panel?.nodes_bandwidth;
if (!nb || typeof nb !== "object") return null;
const top = nb.topNodes;
if (Array.isArray(top) && top.length) {
const rows = top.map((n) => {
const total = Number(n?.total ?? n?.bytes ?? 0);
const uuid = bandwidthRowUuid(n);
const label =
(typeof n?.name === "string" && n.name.trim()) ||
(typeof n?.nodeName === "string" && n.nodeName.trim()) ||
(uuid ? `${uuid.slice(0, 8)}…` : "—");
const directOn = Number(n?.usersOnline ?? n?.users_online ?? n?.onlineUsers);
const onlineInit = Number.isFinite(directOn) ? directOn : null;
return {
label,
value: total > 0 ? fmtTrafficBytes(total) : fmtTrafficBytes(0),
sort: total,
uuid: uuid || null,
online: onlineInit,
};
});
return { seven: rows.sort((a, b) => b.sort - a.sort) };
}
const series = nb.series;
if (Array.isArray(series) && series.length) {
const rows = series.map((s) => {
const total = Number(s?.total ?? 0);
const uuid = bandwidthRowUuid(s);
const label =
(typeof s?.name === "string" && s.name.trim()) ||
(typeof s?.nodeName === "string" && s.nodeName.trim()) ||
(uuid ? `${uuid.slice(0, 8)}…` : "—");
const directOn = Number(s?.usersOnline ?? s?.users_online ?? s?.onlineUsers);
const onlineInit = Number.isFinite(directOn) ? directOn : null;
return {
label,
value: total > 0 ? fmtTrafficBytes(total) : fmtTrafficBytes(0),
sort: total,
uuid: uuid || null,
online: onlineInit,
};
});
return { seven: rows.sort((a, b) => b.sort - a.sort) };
}
return null;
}
function parsePanelNodeTraffic(panel) {
const onlineLookup = buildNodeOnlineLookup(panel);
const fromBw = parseNodesBandwidthTop(panel);
if (fromBw?.seven?.length) return { seven: attachNodeOnlineToRows(fromBw.seven, onlineLookup) };
const raw = panel?.nodes;
if (raw == null) return { seven: [] };
if (Array.isArray(raw)) {
if (raw.length && isNodeMetricsShape(raw[0])) {
return { seven: attachNodeOnlineToRows(buildNodeMetricsRows(raw), onlineLookup) };
}
return { seven: attachNodeOnlineToRows(aggregatePanelNodeRows(raw), onlineLookup) };
}
if (typeof raw === "object") {
if (Array.isArray(raw.nodes) && raw.nodes.length) {
return { seven: attachNodeOnlineToRows(buildNodeMetricsRows(raw.nodes), onlineLookup) };
}
if (Array.isArray(raw.lastSevenDays) && raw.lastSevenDays.length) {
return { seven: attachNodeOnlineToRows(aggregatePanelNodeRows(raw.lastSevenDays), onlineLookup) };
}
}
return { seven: [] };
}
function computeRevenueKpis(financial, series) {
const amounts = series.map((p) => Number(p.amount) || 0);
const n = amounts.length;
@@ -353,61 +662,106 @@
<p class="admin-muted" style="margin:0;">{at("stats_panel_unavailable_detail", {}, "")}</p>
{:else if panelMetrics}
<Card.Root>
<Card.Content>
<div class="admin-dashboard-panel-metrics">
<div class="admin-dashboard-panel-metric">
<span><Radio size={12} /> {at("stats_panel_online", {}, "")}</span>
<strong>{panelMetrics.onlineNow}</strong>
</div>
<div class="admin-dashboard-panel-metric">
<span>{at("stats_panel_active", {}, "")}</span>
<strong>{panelMetrics.active}</strong>
</div>
<div class="admin-dashboard-panel-metric">
<span>{at("stats_panel_expired", {}, "")}</span>
<strong>{panelMetrics.expired}</strong>
</div>
<div class="admin-dashboard-panel-metric">
<span>{at("stats_panel_disabled", {}, "")}</span>
<strong>{panelMetrics.disabled}</strong>
</div>
<div class="admin-dashboard-panel-metric">
<span>{at("stats_panel_limited", {}, "")}</span>
<strong>{panelMetrics.limited}</strong>
</div>
<div class="admin-dashboard-panel-metric">
<span><Activity size={12} /> {at("stats_panel_total_users", {}, "")}</span>
<strong>{panelMetrics.totalPanelUsers}</strong>
</div>
{#if panelMetrics.nodesOnline != null}
<div class="admin-dashboard-panel-metric">
<span><Server size={12} /> {at("stats_panel_nodes_online", {}, "")}</span>
<strong>{panelMetrics.nodesOnline}</strong>
<Card.Content class="admin-cn-card-content admin-panel-dash-card">
<div class="admin-panel-dash">
<div class="admin-panel-dash-tiles" role="group" aria-label={at("stats_section_panel", {}, "")}>
<div class="admin-panel-dash-tile">
<div class="admin-panel-dash-tile-label">
<span class="admin-panel-dash-ico" aria-hidden="true"><Radio size={12} /></span>
{at("stats_panel_online", {}, "")}
</div>
<div class="admin-panel-dash-tile-value">{panelMetrics.onlineNow}</div>
</div>
{/if}
{#if panelMetrics.memPct != null}
<div class="admin-dashboard-panel-metric">
<span>{at("stats_panel_memory", {}, "")}</span>
<strong>{panelMetrics.memPct.toFixed(1)}%</strong>
<div class="admin-panel-dash-tile">
<div class="admin-panel-dash-tile-label">{at("stats_panel_active", {}, "")}</div>
<div class="admin-panel-dash-tile-value">{panelMetrics.active}</div>
</div>
<div class="admin-panel-dash-tile">
<div class="admin-panel-dash-tile-label">
<span class="admin-panel-dash-ico" aria-hidden="true"><Activity size={12} /></span>
{at("stats_panel_total_users", {}, "")}
</div>
<div class="admin-panel-dash-tile-value">{panelMetrics.totalPanelUsers}</div>
</div>
<div class="admin-panel-dash-tile">
<div class="admin-panel-dash-tile-label">{at("stats_panel_expired", {}, "")}</div>
<div class="admin-panel-dash-tile-value">{panelMetrics.expired}</div>
</div>
<div class="admin-panel-dash-tile">
<div class="admin-panel-dash-tile-label">{at("stats_panel_disabled", {}, "")}</div>
<div class="admin-panel-dash-tile-value">{panelMetrics.disabled}</div>
</div>
<div class="admin-panel-dash-tile">
<div class="admin-panel-dash-tile-label">{at("stats_panel_limited", {}, "")}</div>
<div class="admin-panel-dash-tile-value">{panelMetrics.limited}</div>
</div>
{#if panelMetrics.nodesOnline != null}
<div class="admin-panel-dash-tile">
<div class="admin-panel-dash-tile-label">
<span class="admin-panel-dash-ico" aria-hidden="true"><Server size={12} /></span>
{at("stats_panel_nodes_online", {}, "")}
</div>
<div class="admin-panel-dash-tile-value">{panelMetrics.nodesOnline}</div>
</div>
{/if}
{#if panelMetrics.memPct != null}
<div class="admin-panel-dash-tile">
<div class="admin-panel-dash-tile-label">{at("stats_panel_memory", {}, "")}</div>
<div class="admin-panel-dash-tile-value">{panelMetrics.memPct.toFixed(1)}%</div>
</div>
{/if}
{#if panelMetrics.cpuPct != null}
<div class="admin-panel-dash-tile">
<div class="admin-panel-dash-tile-label">
<span class="admin-panel-dash-ico" aria-hidden="true"><Zap size={12} /></span>
{at("stats_panel_cpu", {}, "")}
</div>
<div class="admin-panel-dash-tile-value">{panelMetrics.cpuPct.toFixed(1)}%</div>
</div>
{/if}
{#if panelBw?.week != null}
<div class="admin-panel-dash-tile admin-panel-dash-tile--wide">
<div class="admin-panel-dash-tile-label">{at("stats_panel_bw_week", {}, "")}</div>
<div class="admin-panel-dash-tile-value admin-panel-dash-tile-value--sm">{panelBw.week}</div>
</div>
{/if}
{#if panelBw?.month != null}
<div class="admin-panel-dash-tile admin-panel-dash-tile--wide">
<div class="admin-panel-dash-tile-label">{at("stats_panel_bw_month", {}, "")}</div>
<div class="admin-panel-dash-tile-value admin-panel-dash-tile-value--sm">{panelBw.month}</div>
</div>
{/if}
</div>
{#if panelNodeTraffic?.seven?.length}
<div class="admin-panel-dash-nodes">
<div class="admin-panel-dash-nodes-head">
<h3 class="admin-panel-dash-nodes-title">{at("stats_panel_inner_nodes", {}, "")}</h3>
<p class="admin-panel-dash-nodes-hint">{at("stats_panel_inner_nodes_hint", {}, "")}</p>
</div>
<div class="admin-panel-dash-nodes-grid">
{#each panelNodeTraffic.seven.slice(0, PANEL_NODE_TILE_LIMIT) as node}
<div class="admin-panel-dash-node">
<div class="admin-panel-dash-node-name">{node.label}</div>
<div class="admin-panel-dash-node-value">{node.value}</div>
{#if node.online != null}
<div class="admin-panel-dash-node-meta">
{at("stats_panel_node_users_online", { count: node.online }, "")}
</div>
{/if}
</div>
{/each}
</div>
{#if panelNodeTraffic.seven.length > PANEL_NODE_TILE_LIMIT}
<p class="admin-panel-dash-nodes-more">
{at("stats_panel_nodes_overflow", { count: panelNodeTraffic.seven.length - PANEL_NODE_TILE_LIMIT }, "")}
</p>
{/if}
</div>
{:else if panelPayload?.nodes && typeof panelPayload.nodes === "object" && Object.keys(panelPayload.nodes).length > 0}
<p class="admin-panel-dash-nodes-empty">{at("stats_panel_nodes_empty", {}, "")}</p>
{/if}
</div>
{#if panelBw && (panelBw.week != null || panelBw.month != null)}
<dl class="admin-dashboard-panel-bandwidth">
{#if panelBw.week != null}
<div>
<dt>{at("stats_panel_bw_week", {}, "")}</dt>
<dd>{panelBw.week}</dd>
</div>
{/if}
{#if panelBw.month != null}
<div>
<dt>{at("stats_panel_bw_month", {}, "")}</dt>
<dd>{panelBw.month}</dd>
</div>
{/if}
</dl>
{/if}
</Card.Content>
</Card.Root>
{/if}
+143 -36
View File
@@ -588,61 +588,168 @@
white-space: nowrap;
}
.admin-dashboard-panel-card {
border: 1px solid var(--admin-border);
background: var(--admin-card-bg);
border-radius: 12px;
box-shadow: var(--admin-card-shadow);
padding: 16px 18px;
.admin-panel-dash-card {
padding-top: 14px;
padding-bottom: 14px;
}
.admin-panel-dash {
display: grid;
gap: 14px;
gap: 18px;
min-width: 0;
}
.admin-dashboard-panel-metrics {
display: flex;
flex-wrap: wrap;
gap: 12px 20px;
.admin-panel-dash-tiles {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
padding: 12px;
border-radius: 12px;
border: 1px solid var(--admin-border);
background: color-mix(in srgb, var(--admin-bg) 65%, var(--admin-surface-2));
}
.admin-dashboard-panel-metric {
display: flex;
flex-direction: column;
gap: 2px;
.admin-panel-dash-tile {
display: grid;
gap: 6px;
padding: 12px 12px 11px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--admin-border) 85%, transparent);
background: color-mix(in srgb, var(--admin-surface) 92%, var(--admin-bg));
min-width: 0;
}
.admin-dashboard-panel-metric strong {
font-size: 18px;
font-weight: 700;
color: var(--admin-text);
letter-spacing: -0.02em;
.admin-panel-dash-tile--wide {
grid-column: 1 / -1;
}
.admin-dashboard-panel-metric span {
.admin-panel-dash-tile-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--admin-muted);
min-width: 0;
}
.admin-panel-dash-ico {
display: inline-flex;
color: color-mix(in srgb, var(--accent) 82%, var(--admin-muted));
flex-shrink: 0;
}
.admin-panel-dash-ico :global(svg) {
display: block;
}
.admin-panel-dash-tile-value {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.1;
color: color-mix(in srgb, var(--accent) 22%, var(--admin-text));
font-variant-numeric: tabular-nums;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-panel-dash-tile-value--sm {
font-size: 16px;
font-weight: 650;
white-space: normal;
line-height: 1.25;
}
.admin-panel-dash-nodes {
display: grid;
gap: 10px;
min-width: 0;
}
.admin-panel-dash-nodes-head {
display: grid;
gap: 4px;
padding: 0 2px;
}
.admin-panel-dash-nodes-title {
margin: 0;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--admin-text);
}
.admin-panel-dash-nodes-hint {
margin: 0;
font-size: 11px;
line-height: 1.35;
color: var(--admin-muted);
}
.admin-dashboard-panel-bandwidth {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
.admin-panel-dash-nodes-grid {
display: grid;
gap: 8px;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
max-height: 240px;
overflow-y: auto;
padding: 10px;
border-radius: 12px;
border: 1px solid var(--admin-border);
background: color-mix(in srgb, var(--admin-bg) 65%, var(--admin-surface-2));
}
.admin-panel-dash-node {
display: grid;
gap: 4px;
padding: 10px 11px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--admin-border) 85%, transparent);
background: color-mix(in srgb, var(--admin-surface) 92%, var(--admin-bg));
min-width: 0;
}
.admin-panel-dash-node-name {
font-size: 12px;
font-weight: 600;
color: var(--admin-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-panel-dash-node-value {
font-size: 15px;
font-weight: 700;
color: color-mix(in srgb, var(--accent) 28%, var(--admin-text));
font-variant-numeric: tabular-nums;
}
.admin-panel-dash-node-meta {
font-size: 11px;
font-weight: 600;
color: var(--admin-muted);
font-variant-numeric: tabular-nums;
}
.admin-panel-dash-nodes-more {
margin: 0;
font-size: 11px;
color: var(--admin-muted);
padding: 0 4px;
}
.admin-panel-dash-nodes-empty {
margin: 0;
font-size: 12px;
color: var(--admin-muted);
}
.admin-dashboard-panel-bandwidth dt {
font-weight: 600;
color: var(--admin-text);
margin: 0;
}
.admin-dashboard-panel-bandwidth dd {
margin: 0;
padding: 8px 4px 0;
}
.admin-sync-strip {