feat: tune visual of web app admin panel
This commit is contained in:
+115
-1
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user