feat: weighted random routing + mode switching + EWMA smoothing + stale eviction

- Weighted random routing (math/rand/v2 per-goroutine ChaCha8)
- PUT /api/route/mode for runtime mode switch (best / weighted_random)
- EWMA score smoothing (configurable smoothing_factor, default 0.3)
- Periodic stale node eviction (5 min threshold, 60s interval)
- Non-blocking WS broadcast (per-client buffered channel + writePump)
- Background rate limiter cleanup goroutine
- In-memory trunk gateway cache (zero disk I/O in hot path)
- Configurable load_avg multiplier (default 50.0, backward compat)
- UI mode indicator + admin Switch button in dashboard
- 42 tests: weighted random, mode switching, EWMA, eviction, API integration
This commit is contained in:
Maksim Totmin
2026-06-25 20:42:46 +07:00
parent ff037f9c1e
commit 938c9e9362
15 changed files with 1026 additions and 65 deletions
+10
View File
@@ -198,6 +198,7 @@ export interface BalancerInfo {
route_total: number;
route_fallbacks: number;
uptime_sec: number;
balance_mode: string;
}
export function connectWebSocket(onMessage: (data: any) => void): WebSocket {
@@ -223,3 +224,12 @@ export function connectWebSocket(onMessage: (data: any) => void): WebSocket {
return ws;
}
// --- Route mode ---
export async function setBalanceMode(mode: 'best' | 'weighted_random') {
return request<{ mode: string }>('/route/mode', {
method: 'PUT',
body: JSON.stringify({ mode }),
});
}
+46 -2
View File
@@ -3,20 +3,36 @@
import { goto } from '$app/navigation';
import { onMount, onDestroy } from 'svelte';
import {
getNodes, connectWebSocket, isAuthenticated,
getNodes, connectWebSocket, isAuthenticated, setBalanceMode,
type NodeInfo, type BalancerInfo
} from '$lib/api';
import { Activity, Phone, Zap, AlertTriangle, Wifi, Server, Plug, Globe, ArrowLeftRight, Network } from 'lucide-svelte';
import { Activity, Phone, Zap, AlertTriangle, Wifi, Server, Plug, Globe, ArrowLeftRight, Network, Shuffle } from 'lucide-svelte';
let nodes: NodeInfo[] = [];
let balancer: BalancerInfo | null = null;
let ws: WebSocket | null = null;
let error: string | null = null;
let setModeBusy = false;
if (browser && !isAuthenticated()) {
goto('/login');
}
function getRole(): string | null {
if (!browser) return null;
const token = localStorage.getItem('token');
if (!token) return null;
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.role || null;
} catch {
return null;
}
}
let role = browser ? getRole() : null;
$: isAdmin = role === 'admin';
onMount(() => {
loadNodes();
ws = connectWebSocket((msg) => {
@@ -40,6 +56,19 @@
}
}
async function toggleMode() {
if (!balancer || setModeBusy) return;
const newMode = balancer.balance_mode === 'weighted_random' ? 'best' : 'weighted_random';
setModeBusy = true;
try {
await setBalanceMode(newMode);
} catch (e: any) {
error = e.message;
} finally {
setModeBusy = false;
}
}
function scoreColor(score: number): string {
if (score >= 70) return 'text-green-600';
if (score >= 40) return 'text-yellow-600';
@@ -145,6 +174,21 @@
{/if}
</span>
{/if}
<span class="opacity-30">|</span>
<span class="flex items-center gap-1 ml-auto">
<Shuffle class="h-3 w-3" />
<span class="font-medium">{balancer.balance_mode === 'weighted_random' ? 'Weighted' : 'Best'}</span>
{#if isAdmin}
<button
on:click={toggleMode}
disabled={setModeBusy}
class="ml-1 px-2 py-0.5 text-[10px] border rounded hover:bg-secondary transition-colors disabled:opacity-50"
title="Переключить режим балансировки"
>
{setModeBusy ? '...' : 'Switch'}
</button>
{/if}
</span>
</div>
{/if}