feat: tune visual of web app admin panel
This commit is contained in:
@@ -33,12 +33,16 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
|
|||||||
- [Развертывание](docs/deployment.md) - Docker Compose, reverse proxy, Nginx, Caddy, вебхуки и запуск из образа.
|
- [Развертывание](docs/deployment.md) - Docker Compose, reverse proxy, Nginx, Caddy, вебхуки и запуск из образа.
|
||||||
- [Миграция с remnawave-tg-shop](docs/migration-to-minishop.md) - перенос данных из прежнего стека.
|
- [Миграция с remnawave-tg-shop](docs/migration-to-minishop.md) - перенос данных из прежнего стека.
|
||||||
|
|
||||||
|
## Совместимость
|
||||||
|
|
||||||
|
Интеграция с API панели Remnawave (вебхуки, пользователи, подписки, статистика в админке и т.д.) **протестирована** на панели Remnawave версии **`> 2.7.0`**. Более старые версии могут работать частично или не работать из‑за изменений в API.
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
||||||
Требования:
|
Требования:
|
||||||
|
|
||||||
- Docker и Docker Compose;
|
- Docker и Docker Compose;
|
||||||
- рабочая панель Remnawave;
|
- рабочая панель Remnawave версии **`> 2.7.0`** (см. раздел «Совместимость»);
|
||||||
- токен Telegram-бота;
|
- токен Telegram-бота;
|
||||||
- параметры хотя бы одного платежного провайдера.
|
- параметры хотя бы одного платежного провайдера.
|
||||||
|
|
||||||
|
|||||||
+115
-1
@@ -285,6 +285,86 @@ def _write_tariffs_config_file(path: Path, config: TariffsConfig) -> None:
|
|||||||
path.write_text(payload, encoding="utf-8")
|
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 ────────────────────────────────────────────────────────
|
# ─── Routes ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -323,7 +403,41 @@ async def admin_stats_route(request: web.Request) -> web.Response:
|
|||||||
try:
|
try:
|
||||||
system = await panel_service.get_system_stats()
|
system = await panel_service.get_system_stats()
|
||||||
bandwidth = await panel_service.get_bandwidth_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:
|
except Exception as exc:
|
||||||
logger.debug("Panel stats unavailable: %s", exc)
|
logger.debug("Panel stats unavailable: %s", exc)
|
||||||
payload["panel"] = {"error": "unavailable"}
|
payload["panel"] = {"error": "unavailable"}
|
||||||
|
|||||||
@@ -268,6 +268,9 @@
|
|||||||
function setAdminLanguageMenuOpen(open) {
|
function setAdminLanguageMenuOpen(open) {
|
||||||
adminLanguageMenuOpen = Boolean(open);
|
adminLanguageMenuOpen = Boolean(open);
|
||||||
clearAdminLanguageClickGuard();
|
clearAdminLanguageClickGuard();
|
||||||
|
// Desktop doesn't need the click-guard overlay and it can block
|
||||||
|
// option clicks in portaled select content.
|
||||||
|
if (!isCompact) return;
|
||||||
if (adminLanguageMenuOpen) {
|
if (adminLanguageMenuOpen) {
|
||||||
adminLanguageClickGuard = true;
|
adminLanguageClickGuard = true;
|
||||||
adminLanguageClickGuardArmTimer = window.setTimeout(() => {
|
adminLanguageClickGuardArmTimer = window.setTimeout(() => {
|
||||||
@@ -319,7 +322,7 @@
|
|||||||
{#if sidebarOpen}
|
{#if sidebarOpen}
|
||||||
<button type="button" class="admin-sidebar-backdrop" aria-label={at("close_menu", {}, "Закрыть меню")} on:click={() => (sidebarOpen = false)}></button>
|
<button type="button" class="admin-sidebar-backdrop" aria-label={at("close_menu", {}, "Закрыть меню")} on:click={() => (sidebarOpen = false)}></button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if adminLanguageMenuOpen || adminLanguageClickGuard}
|
{#if isCompact && (adminLanguageMenuOpen || adminLanguageClickGuard)}
|
||||||
<button
|
<button
|
||||||
class="language-select-guard"
|
class="language-select-guard"
|
||||||
class:language-select-guard--armed={adminLanguageClickGuardArmed}
|
class:language-select-guard--armed={adminLanguageClickGuardArmed}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script>
|
<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 { getContext, onMount } from "svelte";
|
||||||
|
|
||||||
|
import { fmtTrafficBytes } from "../../lib/admin/format.js";
|
||||||
import Badge from "$components/ui/badge.svelte";
|
import Badge from "$components/ui/badge.svelte";
|
||||||
import * as Card from "$components/ui/card/index.js";
|
import * as Card from "$components/ui/card/index.js";
|
||||||
import {
|
import {
|
||||||
@@ -38,6 +39,10 @@
|
|||||||
panelPayload && !panelPayload.error ? parsePanelSystem(panelPayload) : null;
|
panelPayload && !panelPayload.error ? parsePanelSystem(panelPayload) : null;
|
||||||
$: panelBw =
|
$: panelBw =
|
||||||
panelPayload && !panelPayload.error ? parsePanelBandwidth(panelPayload) : null;
|
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 : [];
|
$: dailySeries = Array.isArray(fin.daily_series) ? fin.daily_series : [];
|
||||||
$: revenueKpis = computeRevenueKpis(fin, dailySeries);
|
$: revenueKpis = computeRevenueKpis(fin, dailySeries);
|
||||||
@@ -62,6 +67,13 @@
|
|||||||
const memUsed = Number(mem.used) || 0;
|
const memUsed = Number(mem.used) || 0;
|
||||||
const memPct = memTotal > 0 ? (memUsed / memTotal) * 100 : null;
|
const memPct = memTotal > 0 ? (memUsed / memTotal) * 100 : null;
|
||||||
const nodes = system.nodes || {};
|
const nodes = system.nodes || {};
|
||||||
|
const cpuRaw =
|
||||||
|
system.cpu?.usage ??
|
||||||
|
system.cpu?.usedPercent ??
|
||||||
|
system.cpu?.percent ??
|
||||||
|
system.cpuUsage ??
|
||||||
|
system.cpuLoad;
|
||||||
|
const cpuPct = Number(cpuRaw);
|
||||||
return {
|
return {
|
||||||
onlineNow: onlineStats.onlineNow ?? 0,
|
onlineNow: onlineStats.onlineNow ?? 0,
|
||||||
active: statusCounts.ACTIVE ?? 0,
|
active: statusCounts.ACTIVE ?? 0,
|
||||||
@@ -71,6 +83,7 @@
|
|||||||
totalPanelUsers: u.totalUsers ?? 0,
|
totalPanelUsers: u.totalUsers ?? 0,
|
||||||
nodesOnline: nodes.totalOnline != null ? nodes.totalOnline : null,
|
nodesOnline: nodes.totalOnline != null ? nodes.totalOnline : null,
|
||||||
memPct,
|
memPct,
|
||||||
|
cpuPct: Number.isFinite(cpuPct) ? cpuPct : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +97,302 @@
|
|||||||
return { week, month };
|
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) {
|
function computeRevenueKpis(financial, series) {
|
||||||
const amounts = series.map((p) => Number(p.amount) || 0);
|
const amounts = series.map((p) => Number(p.amount) || 0);
|
||||||
const n = amounts.length;
|
const n = amounts.length;
|
||||||
@@ -353,61 +662,106 @@
|
|||||||
<p class="admin-muted" style="margin:0;">{at("stats_panel_unavailable_detail", {}, "")}</p>
|
<p class="admin-muted" style="margin:0;">{at("stats_panel_unavailable_detail", {}, "")}</p>
|
||||||
{:else if panelMetrics}
|
{:else if panelMetrics}
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Content>
|
<Card.Content class="admin-cn-card-content admin-panel-dash-card">
|
||||||
<div class="admin-dashboard-panel-metrics">
|
<div class="admin-panel-dash">
|
||||||
<div class="admin-dashboard-panel-metric">
|
<div class="admin-panel-dash-tiles" role="group" aria-label={at("stats_section_panel", {}, "")}>
|
||||||
<span><Radio size={12} /> {at("stats_panel_online", {}, "")}</span>
|
<div class="admin-panel-dash-tile">
|
||||||
<strong>{panelMetrics.onlineNow}</strong>
|
<div class="admin-panel-dash-tile-label">
|
||||||
</div>
|
<span class="admin-panel-dash-ico" aria-hidden="true"><Radio size={12} /></span>
|
||||||
<div class="admin-dashboard-panel-metric">
|
{at("stats_panel_online", {}, "")}
|
||||||
<span>{at("stats_panel_active", {}, "")}</span>
|
</div>
|
||||||
<strong>{panelMetrics.active}</strong>
|
<div class="admin-panel-dash-tile-value">{panelMetrics.onlineNow}</div>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
<div class="admin-panel-dash-tile">
|
||||||
{#if panelMetrics.memPct != null}
|
<div class="admin-panel-dash-tile-label">{at("stats_panel_active", {}, "")}</div>
|
||||||
<div class="admin-dashboard-panel-metric">
|
<div class="admin-panel-dash-tile-value">{panelMetrics.active}</div>
|
||||||
<span>{at("stats_panel_memory", {}, "")}</span>
|
|
||||||
<strong>{panelMetrics.memPct.toFixed(1)}%</strong>
|
|
||||||
</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}
|
{/if}
|
||||||
</div>
|
</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.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -588,61 +588,168 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-dashboard-panel-card {
|
.admin-panel-dash-card {
|
||||||
border: 1px solid var(--admin-border);
|
padding-top: 14px;
|
||||||
background: var(--admin-card-bg);
|
padding-bottom: 14px;
|
||||||
border-radius: 12px;
|
}
|
||||||
box-shadow: var(--admin-card-shadow);
|
|
||||||
padding: 16px 18px;
|
.admin-panel-dash {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 18px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-dashboard-panel-metrics {
|
.admin-panel-dash-tiles {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
gap: 10px;
|
||||||
gap: 12px 20px;
|
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 {
|
.admin-panel-dash-tile {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
gap: 6px;
|
||||||
gap: 2px;
|
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;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-dashboard-panel-metric strong {
|
.admin-panel-dash-tile--wide {
|
||||||
font-size: 18px;
|
grid-column: 1 / -1;
|
||||||
font-weight: 700;
|
|
||||||
color: var(--admin-text);
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-dashboard-panel-metric span {
|
.admin-panel-dash-tile-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
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);
|
color: var(--admin-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-dashboard-panel-bandwidth {
|
.admin-panel-dash-nodes-grid {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
gap: 8px;
|
||||||
gap: 8px 16px;
|
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;
|
font-size: 12px;
|
||||||
color: var(--admin-muted);
|
color: var(--admin-muted);
|
||||||
}
|
padding: 8px 4px 0;
|
||||||
|
|
||||||
.admin-dashboard-panel-bandwidth dt {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--admin-text);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-dashboard-panel-bandwidth dd {
|
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-sync-strip {
|
.admin-sync-strip {
|
||||||
|
|||||||
@@ -556,6 +556,32 @@ class PanelApiService:
|
|||||||
return response_data.get("response")
|
return response_data.get("response")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def get_nodes_bandwidth_usage(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
start: str,
|
||||||
|
end: str,
|
||||||
|
top_nodes_limit: int = 64,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Per-node usage for a date range (Remnawave GET /bandwidth-stats/nodes).
|
||||||
|
|
||||||
|
Query dates are calendar dates (YYYY-MM-DD), same as the panel UI analytics.
|
||||||
|
Response includes topNodes[{ uuid, name, countryCode, total }, ...] where total is bytes.
|
||||||
|
"""
|
||||||
|
response_data = await self._request(
|
||||||
|
"GET",
|
||||||
|
"/bandwidth-stats/nodes",
|
||||||
|
params={
|
||||||
|
"start": start,
|
||||||
|
"end": end,
|
||||||
|
"topNodesLimit": top_nodes_limit,
|
||||||
|
},
|
||||||
|
log_full_response=False,
|
||||||
|
)
|
||||||
|
if response_data and not response_data.get("error") and "response" in response_data:
|
||||||
|
return response_data.get("response")
|
||||||
|
return None
|
||||||
|
|
||||||
async def get_user_bandwidth_stats(self, user_uuid: str) -> Optional[Dict[str, Any]]:
|
async def get_user_bandwidth_stats(self, user_uuid: str) -> Optional[Dict[str, Any]]:
|
||||||
endpoint = f"/bandwidth-stats/users/{user_uuid}"
|
endpoint = f"/bandwidth-stats/users/{user_uuid}"
|
||||||
response_data = await self._request("GET", endpoint, log_full_response=False)
|
response_data = await self._request("GET", endpoint, log_full_response=False)
|
||||||
@@ -700,6 +726,61 @@ class PanelApiService:
|
|||||||
logging.error("Failed to remove users from squad %s. Response: %s", squad_uuid, response_data)
|
logging.error("Failed to remove users from squad %s. Response: %s", squad_uuid, response_data)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def get_nodes_online_lookups(self) -> Dict[str, Dict[str, int]]:
|
||||||
|
"""Live ``usersOnline`` per node from ``GET /nodes`` (node directory).
|
||||||
|
|
||||||
|
Newer panels expose Prometheus-style metrics under ``/system/stats/nodes``
|
||||||
|
(``nodes: [{ usersOnline, ... }]``). Older/alternate builds only return
|
||||||
|
historical rows (e.g. ``lastSevenDays``) without live counts. The node
|
||||||
|
directory response always includes ``usersOnline`` and ``uuid``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``{"byUuid": {uuid_lower: int}, "byName": {name_lower: int}}``
|
||||||
|
"""
|
||||||
|
by_uuid: Dict[str, int] = {}
|
||||||
|
by_name: Dict[str, int] = {}
|
||||||
|
page_size = 100
|
||||||
|
start = 0
|
||||||
|
while True:
|
||||||
|
response_data = await self._request(
|
||||||
|
"GET",
|
||||||
|
"/nodes",
|
||||||
|
params={"size": page_size, "start": start},
|
||||||
|
log_full_response=False,
|
||||||
|
)
|
||||||
|
if not response_data or response_data.get("error"):
|
||||||
|
break
|
||||||
|
resp = response_data.get("response")
|
||||||
|
batch: List[Dict[str, Any]] = []
|
||||||
|
if isinstance(resp, list):
|
||||||
|
batch = [x for x in resp if isinstance(x, dict)]
|
||||||
|
elif isinstance(resp, dict):
|
||||||
|
inner = resp.get("nodes") or resp.get("items") or []
|
||||||
|
batch = [x for x in inner if isinstance(x, dict)]
|
||||||
|
if not batch:
|
||||||
|
break
|
||||||
|
for n in batch:
|
||||||
|
uid = n.get("uuid") or n.get("nodeUuid") or n.get("node_uuid")
|
||||||
|
uo = n.get("usersOnline")
|
||||||
|
if uo is None:
|
||||||
|
uo = n.get("users_online")
|
||||||
|
if uo is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
val = int(uo)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if uid:
|
||||||
|
by_uuid[str(uid).strip().lower()] = val
|
||||||
|
name = n.get("name")
|
||||||
|
if name and isinstance(name, str) and name.strip():
|
||||||
|
by_name[name.strip().lower()] = val
|
||||||
|
if len(batch) < page_size:
|
||||||
|
break
|
||||||
|
start += page_size
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
return {"byUuid": by_uuid, "byName": by_name}
|
||||||
|
|
||||||
async def get_nodes_statistics(self) -> Optional[Dict[str, Any]]:
|
async def get_nodes_statistics(self) -> Optional[Dict[str, Any]]:
|
||||||
"""Get nodes statistics"""
|
"""Get nodes statistics"""
|
||||||
response_data = await self._request("GET", "/system/stats/nodes", log_full_response=False)
|
response_data = await self._request("GET", "/system/stats/nodes", log_full_response=False)
|
||||||
|
|||||||
@@ -942,6 +942,12 @@
|
|||||||
"admin_stats_panel_memory": "Memory",
|
"admin_stats_panel_memory": "Memory",
|
||||||
"admin_stats_panel_bw_week": "Traffic 7 days",
|
"admin_stats_panel_bw_week": "Traffic 7 days",
|
||||||
"admin_stats_panel_bw_month": "Traffic 30 days",
|
"admin_stats_panel_bw_month": "Traffic 30 days",
|
||||||
|
"admin_stats_panel_inner_nodes": "Per node (7 days)",
|
||||||
|
"admin_stats_panel_inner_nodes_hint": "Traffic from bandwidth API; online users from node metrics, sorted by traffic",
|
||||||
|
"admin_stats_panel_node_users_online": "Online now: {count}",
|
||||||
|
"admin_stats_panel_cpu": "CPU",
|
||||||
|
"admin_stats_panel_nodes_empty": "No per-node breakdown from the panel",
|
||||||
|
"admin_stats_panel_nodes_overflow": "+{count} more",
|
||||||
"admin_stats_sync_last": "Last run",
|
"admin_stats_sync_last": "Last run",
|
||||||
"admin_stats_sync_processed": "Processed: {users} users, {subs} subscriptions",
|
"admin_stats_sync_processed": "Processed: {users} users, {subs} subscriptions",
|
||||||
"admin_stats_queue_users": " users",
|
"admin_stats_queue_users": " users",
|
||||||
|
|||||||
@@ -942,6 +942,12 @@
|
|||||||
"admin_stats_panel_memory": "Память",
|
"admin_stats_panel_memory": "Память",
|
||||||
"admin_stats_panel_bw_week": "Трафик 7 дней",
|
"admin_stats_panel_bw_week": "Трафик 7 дней",
|
||||||
"admin_stats_panel_bw_month": "Трафик 30 дней",
|
"admin_stats_panel_bw_month": "Трафик 30 дней",
|
||||||
|
"admin_stats_panel_inner_nodes": "По нодам (7 дней)",
|
||||||
|
"admin_stats_panel_inner_nodes_hint": "Трафик из bandwidth API; онлайн — метрики нод, сортировка по трафику",
|
||||||
|
"admin_stats_panel_node_users_online": "Сейчас онлайн: {count}",
|
||||||
|
"admin_stats_panel_cpu": "CPU",
|
||||||
|
"admin_stats_panel_nodes_empty": "Панель не вернула разбивку по нодам",
|
||||||
|
"admin_stats_panel_nodes_overflow": "и ещё {count}",
|
||||||
"admin_stats_sync_last": "Последняя",
|
"admin_stats_sync_last": "Последняя",
|
||||||
"admin_stats_sync_processed": "Обработано: {users} польз., {subs} подписок",
|
"admin_stats_sync_processed": "Обработано: {users} польз., {subs} подписок",
|
||||||
"admin_stats_queue_users": " польз.",
|
"admin_stats_queue_users": " польз.",
|
||||||
|
|||||||
Reference in New Issue
Block a user