+
+
+
+
+ {at("stats_panel_online", {}, "")}
+
+
{panelMetrics.onlineNow}
- {/if}
- {#if panelMetrics.memPct != null}
-
-
{at("stats_panel_memory", {}, "")}
-
{panelMetrics.memPct.toFixed(1)}%
+
+
{at("stats_panel_active", {}, "")}
+
{panelMetrics.active}
+
+
+
+ {at("stats_panel_total_users", {}, "")}
+
+
{panelMetrics.totalPanelUsers}
+
+
+
{at("stats_panel_expired", {}, "")}
+
{panelMetrics.expired}
+
+
+
{at("stats_panel_disabled", {}, "")}
+
{panelMetrics.disabled}
+
+
+
{at("stats_panel_limited", {}, "")}
+
{panelMetrics.limited}
+
+ {#if panelMetrics.nodesOnline != null}
+
+
+
+ {at("stats_panel_nodes_online", {}, "")}
+
+
{panelMetrics.nodesOnline}
+
+ {/if}
+ {#if panelMetrics.memPct != null}
+
+
{at("stats_panel_memory", {}, "")}
+
{panelMetrics.memPct.toFixed(1)}%
+
+ {/if}
+ {#if panelMetrics.cpuPct != null}
+
+
+
+ {at("stats_panel_cpu", {}, "")}
+
+
{panelMetrics.cpuPct.toFixed(1)}%
+
+ {/if}
+ {#if panelBw?.week != null}
+
+
{at("stats_panel_bw_week", {}, "")}
+
{panelBw.week}
+
+ {/if}
+ {#if panelBw?.month != null}
+
+
{at("stats_panel_bw_month", {}, "")}
+
{panelBw.month}
+
+ {/if}
+
+
+ {#if panelNodeTraffic?.seven?.length}
+
+
+
{at("stats_panel_inner_nodes", {}, "")}
+
{at("stats_panel_inner_nodes_hint", {}, "")}
+
+
+ {#each panelNodeTraffic.seven.slice(0, PANEL_NODE_TILE_LIMIT) as node}
+
+
{node.label}
+
{node.value}
+ {#if node.online != null}
+
+ {at("stats_panel_node_users_online", { count: node.online }, "")}
+
+ {/if}
+
+ {/each}
+
+ {#if panelNodeTraffic.seven.length > PANEL_NODE_TILE_LIMIT}
+
+ {at("stats_panel_nodes_overflow", { count: panelNodeTraffic.seven.length - PANEL_NODE_TILE_LIMIT }, "")}
+
+ {/if}
+
+ {:else if panelPayload?.nodes && typeof panelPayload.nodes === "object" && Object.keys(panelPayload.nodes).length > 0}
+
{at("stats_panel_nodes_empty", {}, "")}
{/if}
- {#if panelBw && (panelBw.week != null || panelBw.month != null)}
-
- {#if panelBw.week != null}
-
-
- {at("stats_panel_bw_week", {}, "")}
- - {panelBw.week}
-
- {/if}
- {#if panelBw.month != null}
-
-
- {at("stats_panel_bw_month", {}, "")}
- - {panelBw.month}
-
- {/if}
-
- {/if}
{/if}
diff --git a/bot/app/web/frontend/src/styles/admin.css b/bot/app/web/frontend/src/styles/admin.css
index 55f1097..24f035f 100644
--- a/bot/app/web/frontend/src/styles/admin.css
+++ b/bot/app/web/frontend/src/styles/admin.css
@@ -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 {
diff --git a/bot/services/panel_api_service.py b/bot/services/panel_api_service.py
index fec2452..20b3d07 100644
--- a/bot/services/panel_api_service.py
+++ b/bot/services/panel_api_service.py
@@ -556,6 +556,32 @@ class PanelApiService:
return response_data.get("response")
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]]:
endpoint = f"/bandwidth-stats/users/{user_uuid}"
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)
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]]:
"""Get nodes statistics"""
response_data = await self._request("GET", "/system/stats/nodes", log_full_response=False)
diff --git a/locales/en.json b/locales/en.json
index 3090f75..193aaec 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -942,6 +942,12 @@
"admin_stats_panel_memory": "Memory",
"admin_stats_panel_bw_week": "Traffic 7 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_processed": "Processed: {users} users, {subs} subscriptions",
"admin_stats_queue_users": " users",
diff --git a/locales/ru.json b/locales/ru.json
index 7fd34cf..696c6cc 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -942,6 +942,12 @@
"admin_stats_panel_memory": "Память",
"admin_stats_panel_bw_week": "Трафик 7 дней",
"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_processed": "Обработано: {users} польз., {subs} подписок",
"admin_stats_queue_users": " польз.",