Skip to content

Commit 845184c

Browse files
committed
release: 1.3.3 — pagination, host network UI, TUN footgun fix, bulk-action guards
Nodes pagination + filtering (the headline) - GET /api/nodes/page paginated endpoint with filters (subscription_id, local, protocol, enabled, online, group, search) and direction (asc/desc, default desc = newest IDs first). Returns { items, total, limit, offset } envelope. Legacy GET /api/nodes stays for reorder / export / circle-scheduler callers that need the unbounded set. - Frontend Nodes page rewritten: NodeFilterPopup component (Source / Protocol / Status / Group axes), Pagination component (10/50/100 page sizes persisted to localStorage, sparse page strip with ellipsis), sort-direction toggle. Drag-to-reorder re-gated: works on visible subset ≤100 regardless of filters (was tied to global total, blocked drag on any filtered view). - Pinned active-node card at top — surfaces "which Node am I routing through" when filters/pagination would otherwise hide it. Uses dedicated useActiveNode hook so a 1k-row query isn't needed just to find one row. - Confirm-modal guard on Test All / Speed All when nodes > 50 (mirrors default page size) — prevents accidental hours-long sequential sweeps with no abort path. Host network configuration (UI-driven) - New GET /api/network/state, /apply, /confirm, /rollback endpoints + supporting core/network_config.py + core/network_apply.py (manager detection across NetworkManager / systemd-networkd / ifupdown / dhcpcd). - New HostNetworkSection on Settings page lets the operator set static IP / gateway / DNS without SSH — addresses the "PiTun points at itself as default gateway" footgun. - install.sh learnt to wire host network config on first deploy. - New scripts/make-offline-bundle.sh helper for air-gapped installs. TUN/Both inbound-mode footgun fix (closes backlog item) - Dashboard Network Mode: TUN and Both buttons rendered as disabled with N/A badge + tooltip explaining that vanilla XTLS/Xray-core doesn't support `protocol: tun` (sing-box feature). Prevents the silent-brick when user picks TUN. - Backend validation: empty-stderr case now produces a useful diagnostic (xray version + inbound_mode + generated inbound protocols) instead of the cryptic "(empty stderr)" message. - PATCH /api/system/settings: inbound_mode changes pre-flighted via xray -test on a tmp config. Validation failure rolls back the DB row + returns 400 with the detailed diagnostic. The UI no longer "lies" about the runtime state. Stack - 23 files changed, ~3.8K lines net. 83/83 focused backend tests pass (pagination, system, xui_api, uri_formatter). - Frontend build clean (tsc + Vite). 1.3.2 → 1.3.3 bump.
1 parent 3fdb998 commit 845184c

23 files changed

Lines changed: 3799 additions & 105 deletions

backend/app/api/network.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
"""Network configuration API (v1.3.3).
2+
3+
Endpoints for the Settings → Network page.
4+
5+
Read paths:
6+
GET /api/network/state — current snapshot
7+
GET /api/network/backups — list of saved rollback points
8+
GET /api/network/probe?ip=... — pre-apply reachability check
9+
10+
Mutation paths (gateway / DNS only — see core/network_apply for the
11+
scope rationale):
12+
POST /api/network/apply — backup + apply gateway/DNS
13+
POST /api/network/rollback — restore a backup (default: most recent)
14+
15+
Auth: every endpoint inherits the global auth dependency wired in
16+
main.py via ``app.include_router(network.router, dependencies=_auth)``.
17+
"""
18+
from __future__ import annotations
19+
20+
import logging
21+
from typing import List, Optional
22+
23+
from fastapi import APIRouter, HTTPException, Query
24+
from pydantic import BaseModel, Field
25+
26+
from app.core import network_config
27+
from app.core import network_apply
28+
29+
logger = logging.getLogger(__name__)
30+
router = APIRouter(prefix="/network", tags=["network"])
31+
32+
33+
# ── Schemas ────────────────────────────────────────────────────────────────
34+
35+
class ApplyBody(BaseModel):
36+
# Both optional — empty body is rejected in core.apply (no-op
37+
# surfaces as a 400 with a useful message). Mixing the two is
38+
# the common case: "set gateway to X, leave DNS alone".
39+
gateway: Optional[str] = Field(default=None, description="New default-route gateway IPv4")
40+
dns: Optional[List[str]] = Field(default=None, description="New DNS server list (replaces existing)")
41+
42+
43+
class RollbackBody(BaseModel):
44+
# Empty = roll back the most recent backup.
45+
backup_id: Optional[str] = Field(default=None, description="Specific backup id; defaults to newest")
46+
47+
48+
# ── Endpoints ──────────────────────────────────────────────────────────────
49+
50+
@router.get("/state")
51+
def get_network_state() -> dict:
52+
"""Current host network configuration snapshot.
53+
54+
Pure read, ~50ms. Safe to poll from the UI.
55+
"""
56+
state = network_config.read_state()
57+
logger.debug(
58+
"network state: iface=%r manager=%r mode=%r gw=%r warnings=%d",
59+
state.interface, state.manager, state.mode, state.gateway,
60+
len(state.warnings),
61+
)
62+
return state.to_dict()
63+
64+
65+
@router.get("/backups")
66+
def get_backups() -> dict:
67+
"""List saved rollback points, newest first."""
68+
items = network_apply.list_backups()
69+
return {"items": items, "count": len(items)}
70+
71+
72+
@router.delete("/backups/{backup_id}")
73+
def delete_backup(backup_id: str) -> dict:
74+
"""Delete one backup. Idempotent — gone-already is treated as ok."""
75+
try:
76+
network_apply.delete_backup(backup_id)
77+
except network_apply.NetworkApplyError as e:
78+
raise HTTPException(400, detail=str(e))
79+
return {"ok": True, "id": backup_id}
80+
81+
82+
@router.delete("/backups")
83+
def clear_backups() -> dict:
84+
"""Wipe every backup. UI confirms first."""
85+
try:
86+
removed = network_apply.delete_all_backups()
87+
except network_apply.NetworkApplyError as e:
88+
raise HTTPException(400, detail=str(e))
89+
return {"ok": True, "removed": removed}
90+
91+
92+
@router.get("/probe")
93+
def probe(ip: str = Query(..., description="Candidate gateway IPv4 address")) -> dict:
94+
"""Quick reachability check on a candidate gateway.
95+
96+
Frontend uses this BEFORE submitting an apply — refusing to even
97+
attempt the change if the new gateway can't be pinged saves the
98+
operator from a bad reapply that leaves them without internet.
99+
"""
100+
try:
101+
return network_apply.probe_gateway(ip)
102+
except network_apply.NetworkApplyError as e:
103+
raise HTTPException(400, detail=str(e))
104+
105+
106+
@router.post("/apply")
107+
def apply_changes(body: ApplyBody) -> dict:
108+
"""Apply gateway and/or DNS changes.
109+
110+
Captures a backup of the current config FIRST so /rollback can
111+
return to this state at any time. Apply itself uses ``ip route
112+
replace`` + /etc/resolv.conf rewrite for immediate effect, plus
113+
edits the persistent manager config (interfaces / nmconnection)
114+
so the change survives reboot.
115+
"""
116+
try:
117+
backup = network_apply.apply(
118+
network_apply.ApplyRequest(gateway=body.gateway, dns=body.dns),
119+
)
120+
except network_apply.NetworkApplyError as e:
121+
raise HTTPException(400, detail=str(e))
122+
except Exception as e: # noqa: BLE001
123+
logger.exception("network apply failed unexpectedly")
124+
raise HTTPException(500, detail=f"Internal error during apply: {e}")
125+
126+
new_state = network_config.read_state()
127+
return {
128+
"ok": True,
129+
"backup": {
130+
"id": backup.id,
131+
"created_at": backup.created_at,
132+
"manager": backup.manager,
133+
"live_state": backup.live_state,
134+
},
135+
"new_state": new_state.to_dict(),
136+
}
137+
138+
139+
@router.post("/rollback")
140+
def rollback(body: RollbackBody) -> dict:
141+
"""Restore a backup.
142+
143+
Default (no body or empty backup_id) → newest backup. Explicit
144+
id rolls back to that specific snapshot — useful for going
145+
further back than the immediately preceding state.
146+
"""
147+
try:
148+
backup = network_apply.rollback(body.backup_id)
149+
except network_apply.NetworkApplyError as e:
150+
raise HTTPException(400, detail=str(e))
151+
except Exception as e: # noqa: BLE001
152+
logger.exception("network rollback failed unexpectedly")
153+
raise HTTPException(500, detail=f"Internal error during rollback: {e}")
154+
155+
new_state = network_config.read_state()
156+
return {
157+
"ok": True,
158+
"restored_from": {
159+
"id": backup.id,
160+
"created_at": backup.created_at,
161+
"live_state": backup.live_state,
162+
},
163+
"new_state": new_state.to_dict(),
164+
}

backend/app/api/nodes.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import Any, Dict, List, Optional
55

66
from fastapi import APIRouter, Depends, HTTPException, Query
7+
from sqlalchemy import func
78
from sqlmodel import select
89
from sqlmodel.ext.asyncio.session import AsyncSession
910

@@ -18,6 +19,7 @@
1819
NodeCreate,
1920
NodeImportRequest,
2021
NodeImportResponse,
22+
NodePage,
2123
NodeRead,
2224
NodeUpdate,
2325
SpeedTestResult,
@@ -105,6 +107,111 @@ async def list_nodes(
105107
return list((await session.exec(stmt)).all())
106108

107109

110+
@router.get("/page", response_model=NodePage)
111+
async def list_nodes_paginated(
112+
# Pagination. `limit=0` is a deliberate escape hatch for "give me
113+
# the whole filtered set" — used by bulk-export and tests. The UI
114+
# always sends a positive limit.
115+
limit: int = Query(50, ge=0, le=500),
116+
offset: int = Query(0, ge=0),
117+
# Filters
118+
subscription_id: Optional[int] = Query(
119+
None, description="Match nodes belonging to this subscription"
120+
),
121+
local: Optional[bool] = Query(
122+
None,
123+
description=(
124+
"When True, match only nodes WITHOUT a subscription "
125+
"(`subscription_id IS NULL`). Mutually exclusive with "
126+
"`subscription_id` — if both are provided, `subscription_id` "
127+
"wins (more specific). Used by the UI to show 'local nodes "
128+
"only' separately from any registered subscription."
129+
),
130+
),
131+
protocol: Optional[str] = Query(
132+
None, description="Filter by protocol (vless/vmess/trojan/ss/wg/...)"
133+
),
134+
enabled: Optional[bool] = Query(None),
135+
online: Optional[bool] = Query(
136+
None, description="Match `is_online` flag from last healthcheck"
137+
),
138+
group: Optional[str] = Query(None),
139+
search: Optional[str] = Query(
140+
None, description="Substring match on node.name (case-insensitive)"
141+
),
142+
direction: str = Query(
143+
"desc",
144+
pattern="^(asc|desc)$",
145+
description=(
146+
"Sort direction for the `id` tiebreaker. `desc` (default) "
147+
"shows newest-added nodes first — natural for subscription "
148+
"imports where the operator wants to see what just landed. "
149+
"`asc` reverses to oldest-first. `Node.order` stays the "
150+
"primary sort key either way so drag-to-reorder still wins."
151+
),
152+
),
153+
session: AsyncSession = Depends(get_session),
154+
) -> NodePage:
155+
"""Paginated + filtered Node listing (since v1.3.3).
156+
157+
Needed because a single subscription can pull 1000+ nodes — the
158+
legacy unbounded endpoint left the UI rendering 1000 cards in one
159+
pass, which was unusable on a Raspberry Pi-served UI.
160+
161+
Filters compose via AND. `total` is the count BEFORE pagination so
162+
the UI can render "Showing 51–100 of 1256". The stable sort order
163+
(`Node.order` then `Node.id`) is preserved so paging through a
164+
reorderable list works without jumping.
165+
"""
166+
base_filters = []
167+
if subscription_id is not None:
168+
base_filters.append(Node.subscription_id == subscription_id)
169+
elif local is True:
170+
# Only applied when `subscription_id` wasn't passed — they
171+
# describe overlapping axes and the explicit subscription id
172+
# always wins.
173+
base_filters.append(Node.subscription_id.is_(None)) # type: ignore[union-attr]
174+
if protocol is not None:
175+
base_filters.append(Node.protocol == protocol)
176+
if enabled is not None:
177+
base_filters.append(Node.enabled == enabled)
178+
if online is not None:
179+
base_filters.append(Node.is_online == online)
180+
if group is not None:
181+
base_filters.append(Node.group == group)
182+
if search:
183+
# Case-insensitive substring match on `name`. SQLite's LIKE is
184+
# case-insensitive by default for ASCII; for the small chance
185+
# the operator named a node in Cyrillic we explicitly LOWER both
186+
# sides so the filter still matches in practice.
187+
pattern = f"%{search.lower()}%"
188+
base_filters.append(func.lower(Node.name).like(pattern))
189+
190+
# Total count (unpaginated). Using a count(*) keyed on id avoids
191+
# pulling every Node row into memory just to size the result.
192+
count_stmt = select(func.count(Node.id))
193+
for f in base_filters:
194+
count_stmt = count_stmt.where(f)
195+
total = (await session.exec(count_stmt)).one() or 0
196+
197+
# Paged result. Limit=0 = no LIMIT clause (escape hatch — see
198+
# docstring); otherwise the normal limit/offset combo. The `order`
199+
# column is always the primary sort axis so manual drag-to-reorder
200+
# placement wins; `id` is just the tiebreaker for rows with equal
201+
# `order` values (subscription imports leave that field at 0).
202+
id_axis = Node.id.desc() if direction == "desc" else Node.id.asc() # type: ignore[union-attr]
203+
stmt = select(Node).order_by(Node.order, id_axis)
204+
for f in base_filters:
205+
stmt = stmt.where(f)
206+
if limit > 0:
207+
stmt = stmt.limit(limit).offset(offset)
208+
elif offset > 0:
209+
stmt = stmt.offset(offset)
210+
items = list((await session.exec(stmt)).all())
211+
212+
return NodePage(items=items, total=int(total), limit=limit, offset=offset)
213+
214+
108215
@router.post("", response_model=NodeRead, status_code=201)
109216
async def create_node(data: NodeCreate, session: AsyncSession = Depends(get_session)):
110217
node = Node(**data.model_dump())

0 commit comments

Comments
 (0)