restore bps manager

Kim committed Mar 17, 2026 at 18:21 UTC 37dbf33b9883ac3097adb236a8fa300eafbb9526
12 files changed +248 -30
cmd/relay-server/admin.go
+27 -6
@@ -248,7 +248,25 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
248 }
249 writeOK()
250 case "bps":
251 - utils.WriteAPIError(w, http.StatusNotImplemented, types.APIErrorCodeFeatureUnavailable, "bps control is not enabled in this build")
251 + switch r.Method {
252 + case http.MethodPost:
253 + var req types.AdminBPSRequest
254 + if err := utils.DecodeJSONBody(w, r, &req, 1<<16); err != nil {
255 + utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "invalid request body")
256 + return
257 + }
258 + if req.BPS <= 0 {
259 + utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "bps must be greater than zero")
260 + return
261 + }
262 + runtime.BPSManager().SetLeaseBPS(leaseID, req.BPS)
263 + case http.MethodDelete:
264 + runtime.BPSManager().DeleteLeaseBPS(leaseID)
265 + default:
266 + methodNotAllowed()
267 + return
268 + }
269 + writeOK()
270 case "approve":
271 approver := runtime.Approver()
272 switch r.Method {
@@ -371,11 +389,12 @@ func saveAdminState(runtime *policy.Runtime) {
389 }
390
391 type persistedAdminState struct {
374 - ApprovalMode string `json:"approval_mode"`
375 - ApprovedLeases []string `json:"approved_leases,omitempty"`
376 - DeniedLeases []string `json:"denied_leases,omitempty"`
377 - BannedLeases []string `json:"banned_leases,omitempty"`
378 - BannedIPs []string `json:"banned_ips,omitempty"`
392 + ApprovalMode string `json:"approval_mode"`
393 + ApprovedLeases []string `json:"approved_leases,omitempty"`
394 + DeniedLeases []string `json:"denied_leases,omitempty"`
395 + BannedLeases []string `json:"banned_leases,omitempty"`
396 + BannedIPs []string `json:"banned_ips,omitempty"`
397 + LeaseBPS map[string]int64 `json:"lease_bps,omitempty"`
398 }
399
400 func persistedStateFromRuntime(runtime *policy.Runtime) persistedAdminState {
@@ -386,6 +405,7 @@ func persistedStateFromRuntime(runtime *policy.Runtime) persistedAdminState {
405 DeniedLeases: approver.DeniedLeases(),
406 BannedLeases: runtime.BannedLeases(),
407 BannedIPs: runtime.IPFilter().BannedIPs(),
408 + LeaseBPS: runtime.BPSManager().LeaseBPSLimits(),
409 }
410 }
411
@@ -401,6 +421,7 @@ func (s persistedAdminState) apply(runtime *policy.Runtime) error {
421 runtime.Approver().SetDecisions(s.ApprovedLeases, s.DeniedLeases)
422 runtime.SetBannedLeases(s.BannedLeases)
423 runtime.IPFilter().SetBannedIPs(s.BannedIPs)
424 + runtime.BPSManager().SetLeaseBPSLimits(s.LeaseBPS)
425 return nil
426 }
427
cmd/relay-server/frontend.go
+1
@@ -261,6 +261,7 @@ func (f *Frontend) publicLeaseSnapshots() []types.Lease {
261 }
262
263 snapshot.ClientIP = ""
264 + snapshot.BPS = 0
265 snapshot.IsApproved = false
266 snapshot.IsBanned = false
267 snapshot.IsDenied = false
frontend/src/components/ServerCard.tsx
+1 -1
@@ -188,7 +188,7 @@ export function ServerCard({
188 };
189
190 const formatStepLabel = (value: number): string => {
191 - if (value === 0) return "∞";
191 + if (value === 0) return "unl";
192 if (value >= 1000000) return `${value / 1000000}M`;
193 if (value >= 1000) return `${value / 1000}K`;
194 return value.toString();
frontend/src/hooks/useAdmin.test.ts
+61
@@ -6,6 +6,11 @@ import { useAdmin } from "@/hooks/useAdmin";
6 import { API_PATHS, adminLeasePath, encodeLeaseID } from "@/lib/apiPaths";
7 import { APIClientError, apiClient } from "@/lib/apiClient";
8
9 +type DeferredAdminSnapshot = {
10 + leases: ServerData[];
11 + approval_mode: "auto" | "manual";
12 +};
13 +
14 vi.mock("@/hooks/useList", () => ({
15 useList: vi.fn(() => ({
16 searchQuery: "",
@@ -45,6 +50,7 @@ function buildLease(peer: string): ServerData {
50 LastSeenAt: "2026-03-03T00:00:00Z",
51 ID: peer,
52 Name: "relay-1",
53 + BPS: 1024,
54 ClientIP: "203.0.113.10",
55 Hostname: "relay.example.com",
56 Metadata: {
@@ -99,6 +105,7 @@ describe("useAdmin", () => {
105 expect(result.current.approvalMode).toBe("auto");
106 expect(result.current.servers[0]?.peerId).toBe("peer-a");
107 expect(result.current.servers[0]?.isBanned).toBe(true);
108 + expect(result.current.servers[0]?.bps).toBe(1024);
109 });
110
111 it("surfaces fetchData API errors", async () => {
@@ -166,6 +173,60 @@ describe("useAdmin", () => {
173 );
174 });
175
176 + it("posts bps updates to the lease action route", async () => {
177 + const { result } = renderHook(() => useAdmin());
178 + await waitForLoaded(result);
179 +
180 + await act(async () => {
181 + await result.current.handleBPSChange("peer-a", 4096);
182 + });
183 +
184 + expect(mockPost).toHaveBeenCalledWith(
185 + adminLeasePath(encodeLeaseID("peer-a"), "bps"),
186 + { bps: 4096 },
187 + );
188 + });
189 +
190 + it("keeps loading false while refreshing bps in the background", async () => {
191 + let getCalls = 0;
192 + let resolveRefresh:
193 + | ((value: DeferredAdminSnapshot | PromiseLike<DeferredAdminSnapshot>) => void)
194 + | undefined;
195 +
196 + mockGet.mockImplementation((path: string) => {
197 + if (path !== API_PATHS.admin.snapshot) {
198 + throw new Error(`Unexpected GET path: ${path}`);
199 + }
200 + getCalls++;
201 + if (getCalls === 1) {
202 + return Promise.resolve({
203 + leases: [buildLease("peer-a")],
204 + approval_mode: "auto",
205 + } as never);
206 + }
207 + return new Promise<DeferredAdminSnapshot>((resolve) => {
208 + resolveRefresh = resolve;
209 + }) as never;
210 + });
211 +
212 + const { result } = renderHook(() => useAdmin());
213 + await waitForLoaded(result);
214 +
215 + let pending: Promise<void> | undefined;
216 + await act(async () => {
217 + pending = result.current.handleBPSChange("peer-a", 2048);
218 + await Promise.resolve();
219 + expect(result.current.loading).toBe(false);
220 + resolveRefresh?.({
221 + leases: [{ ...buildLease("peer-a"), BPS: 2048 }],
222 + approval_mode: "auto",
223 + });
224 + await pending;
225 + });
226 +
227 + expect(result.current.servers[0]?.bps).toBe(2048);
228 + });
229 +
230 it("bulk deny posts deduped lease IDs to action routes", async () => {
231 const { result } = renderHook(() => useAdmin());
232 await waitForLoaded(result);
frontend/src/hooks/useAdmin.ts
+36 -19
@@ -92,7 +92,7 @@ function toAdminServer(
92 firstSeen: row.FirstSeenAt || undefined,
93 peerId: row.ID,
94 isBanned: row.IsBanned || false,
95 - bps: 0,
95 + bps: row.BPS || 0,
96 isApproved: row.IsApproved || false,
97 isDenied: row.IsDenied || false,
98 ip: row.ClientIP || "",
@@ -149,14 +149,11 @@ export function useAdmin() {
149
150 const fetchData = async () => {
151 setError("");
152 - setLoading(true);
152
153 try {
154 applySnapshot(await loadAdminSnapshot());
155 } catch (err: unknown) {
156 setError(toAdminErrorMessage(err, "Failed to load admin data"));
158 - } finally {
159 - setLoading(false);
157 }
158 };
159
@@ -243,23 +240,43 @@ export function useAdmin() {
240 const handleBanStatus = (peerId: string, isBan: boolean) =>
241 runAdminAction(() => updateLeaseAction(peerId, "ban", isBan));
242
246 - const handleBPSChange = (peerId: string, bps: number) =>
247 - runAdminAction(async () => {
248 - if (!peerId) {
249 - throw new Error("Missing lease ID");
250 - }
251 - const encodedLeaseID = encodeLeaseID(peerId);
252 - const normalizedBPS = Math.trunc(bps);
253 - if (!Number.isFinite(normalizedBPS) || normalizedBPS <= 0) {
254 - await apiClient.delete<LeaseActionResult>(
255 - adminLeasePath(encodedLeaseID, "bps")
243 + const handleBPSChange = async (peerId: string, bps: number) => {
244 + if (!peerId) {
245 + throw new Error("Missing lease ID");
246 + }
247 +
248 + const encodedLeaseID = encodeLeaseID(peerId);
249 + const normalizedBPS = Math.max(0, Math.trunc(bps));
250 + const previousBPS = serverData.find((row) => row.ID === peerId)?.BPS ?? 0;
251 +
252 + setServerData((prev) =>
253 + prev.map((row) =>
254 + row.ID === peerId ? { ...row, BPS: normalizedBPS } : row
255 + )
256 + );
257 +
258 + try {
259 + await runAdminAction(async () => {
260 + if (!Number.isFinite(normalizedBPS) || normalizedBPS <= 0) {
261 + await apiClient.delete<LeaseActionResult>(
262 + adminLeasePath(encodedLeaseID, "bps")
263 + );
264 + return;
265 + }
266 + await apiClient.post<LeaseActionResult>(
267 + adminLeasePath(encodedLeaseID, "bps"),
268 + { bps: normalizedBPS }
269 );
257 - return;
258 - }
259 - await apiClient.post<LeaseActionResult>(adminLeasePath(encodedLeaseID, "bps"), {
260 - bps: normalizedBPS,
270 });
262 - });
271 + } catch (err) {
272 + setServerData((prev) =>
273 + prev.map((row) =>
274 + row.ID === peerId ? { ...row, BPS: previousBPS } : row
275 + )
276 + );
277 + throw err;
278 + }
279 + };
280
281 const handleApprovalModeChange = async (mode: ApprovalMode) => {
282 await runAdminAction(async () => {
frontend/src/hooks/useSSRData.ts
+1
@@ -14,6 +14,7 @@ export interface ServerData {
14 LastSeenAt: string;
15 ID: string;
16 Name: string;
17 + BPS?: number;
18 ClientIP: string;
19 Hostname: string;
20 Metadata: unknown;
frontend/src/pages/Admin.tsx
+9 -2
@@ -10,6 +10,7 @@ export function Admin() {
10 const { isAuthenticated, isLoading: authLoading, logout } = useAuth();
11
12 const {
13 + servers,
14 filteredServers,
15 availableTags,
16 searchQuery,
@@ -28,6 +29,7 @@ export function Admin() {
29 handleBanFilterChange,
30 handleToggleFavorite,
31 handleBanStatus,
32 + handleBPSChange,
33 handleApprovalModeChange,
34 handleApproveStatus,
35 handleDenyStatus,
@@ -57,8 +59,12 @@ export function Admin() {
59 return null; // Will redirect
60 }
61
60 - if (loading) return <div className="p-8 text-foreground">Loading...</div>;
61 - if (error) return <div className="p-8 text-red-500">Error: {error}</div>;
62 + if (loading && servers.length === 0) {
63 + return <div className="p-8 text-foreground">Loading...</div>;
64 + }
65 + if (error && servers.length === 0) {
66 + return <div className="p-8 text-red-500">Error: {error}</div>;
67 + }
68
69 return (
70 <SsgoiTransition id="admin">
@@ -82,6 +88,7 @@ export function Admin() {
88 approvalMode={approvalMode}
89 onBanFilterChange={handleBanFilterChange}
90 onBanStatusChange={handleBanStatus}
91 + onBPSChange={handleBPSChange}
92 onApprovalModeChange={handleApprovalModeChange}
93 onApproveStatusChange={handleApproveStatus}
94 onDenyStatusChange={handleDenyStatus}
portal/lease.go
+1
@@ -212,6 +212,7 @@ func (r *leaseRegistry) Snapshot(record *leaseRecord) types.Lease {
212 snapshot := record.Lease
213 snapshot.Metadata = snapshot.Metadata.Copy()
214 clientIP := record.ClientIP
215 + snapshot.BPS = r.policy.BPSManager().LeaseBPS(record.ID)
216 snapshot.Ready = record.Broker.ReadyCount()
217 snapshot.IsApproved = r.policy.EffectiveApproval(record.ID)
218 snapshot.IsBanned = r.policy.IsLeaseBanned(record.ID)
portal/policy/bps_manager.go new
+90
@@ -0,0 +1,90 @@
1 +package policy
2 +
3 +import (
4 + "strings"
5 + "sync"
6 +)
7 +
8 +type BPSManager struct {
9 + leaseBPS map[string]int64
10 + mu sync.RWMutex
11 +}
12 +
13 +func NewBPSManager() *BPSManager {
14 + return &BPSManager{
15 + leaseBPS: make(map[string]int64),
16 + }
17 +}
18 +
19 +func (m *BPSManager) LeaseBPS(leaseID string) int64 {
20 + if m == nil {
21 + return 0
22 + }
23 +
24 + m.mu.RLock()
25 + defer m.mu.RUnlock()
26 + return m.leaseBPS[strings.TrimSpace(leaseID)]
27 +}
28 +
29 +func (m *BPSManager) SetLeaseBPS(leaseID string, bps int64) {
30 + if m == nil {
31 + return
32 + }
33 +
34 + leaseID = strings.TrimSpace(leaseID)
35 + if leaseID == "" {
36 + return
37 + }
38 +
39 + m.mu.Lock()
40 + defer m.mu.Unlock()
41 + if bps <= 0 {
42 + delete(m.leaseBPS, leaseID)
43 + return
44 + }
45 + m.leaseBPS[leaseID] = bps
46 +}
47 +
48 +func (m *BPSManager) DeleteLeaseBPS(leaseID string) {
49 + if m == nil {
50 + return
51 + }
52 +
53 + m.mu.Lock()
54 + defer m.mu.Unlock()
55 + delete(m.leaseBPS, strings.TrimSpace(leaseID))
56 +}
57 +
58 +func (m *BPSManager) LeaseBPSLimits() map[string]int64 {
59 + if m == nil {
60 + return nil
61 + }
62 +
63 + m.mu.RLock()
64 + defer m.mu.RUnlock()
65 +
66 + out := make(map[string]int64, len(m.leaseBPS))
67 + for leaseID, bps := range m.leaseBPS {
68 + out[leaseID] = bps
69 + }
70 + return out
71 +}
72 +
73 +func (m *BPSManager) SetLeaseBPSLimits(limits map[string]int64) {
74 + if m == nil {
75 + return
76 + }
77 +
78 + next := make(map[string]int64, len(limits))
79 + for leaseID, bps := range limits {
80 + leaseID = strings.TrimSpace(leaseID)
81 + if leaseID == "" || bps <= 0 {
82 + continue
83 + }
84 + next[leaseID] = bps
85 + }
86 +
87 + m.mu.Lock()
88 + m.leaseBPS = next
89 + m.mu.Unlock()
90 +}
portal/policy/runtime.go
+16 -2
@@ -7,6 +7,7 @@ import (
7
8 type Runtime struct {
9 approver *Approver
10 + bpsManager *BPSManager
11 ipFilter *IPFilter
12 bannedLeases map[string]struct{}
13 mu sync.RWMutex
@@ -15,6 +16,7 @@ type Runtime struct {
16 func NewRuntime() *Runtime {
17 return &Runtime{
18 approver: NewApprover(),
19 + bpsManager: NewBPSManager(),
20 ipFilter: NewIPFilter(),
21 bannedLeases: make(map[string]struct{}),
22 }
@@ -34,6 +36,13 @@ func (r *Runtime) IPFilter() *IPFilter {
36 return r.ipFilter
37 }
38
39 +func (r *Runtime) BPSManager() *BPSManager {
40 + if r == nil {
41 + return nil
42 + }
43 + return r.bpsManager
44 +}
45 +
46 func (r *Runtime) BanLease(leaseID string) {
47 if r == nil {
48 return
@@ -127,8 +136,13 @@ func (r *Runtime) IsLeaseRoutable(leaseID string) bool {
136 }
137
138 func (r *Runtime) ForgetLease(leaseID string) {
130 - if r == nil || r.ipFilter == nil {
139 + if r == nil {
140 return
141 }
133 - r.ipFilter.RemoveLeaseIP(leaseID)
142 + if r.ipFilter != nil {
143 + r.ipFilter.RemoveLeaseIP(leaseID)
144 + }
145 + if r.bpsManager != nil {
146 + r.bpsManager.DeleteLeaseBPS(leaseID)
147 + }
148 }
types/api.go
+4
@@ -113,3 +113,7 @@ type AdminApprovalModeRequest struct {
113 type AdminApprovalModeResponse struct {
114 ApprovalMode string `json:"approval_mode"`
115 }
116 +
117 +type AdminBPSRequest struct {
118 + BPS int64 `json:"bps"`
119 +}
types/lease.go
+1
@@ -26,6 +26,7 @@ type Lease struct {
26 LastSeenAt time.Time
27 ID string
28 Name string
29 + BPS int64
30 ClientIP string
31 Hostname string
32 Metadata LeaseMetadata