first commit

This commit is contained in:
Maksim Totmin
2026-06-25 14:27:41 +07:00
commit 44f5fa88e5
44 changed files with 8664 additions and 0 deletions
+2311
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "pulse-lets-go-web",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev --port 5173",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/kit": "^2.60.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"svelte": "^5.50.0",
"tailwindcss": "^4.3.0",
"@tailwindcss/vite": "^4.3.0",
"typescript": "^5.7.0"
},
"dependencies": {
"clsx": "^2.1.0",
"lucide-svelte": "^1.0.0",
"svelte-sonner": "^1.0.0",
"tailwind-merge": "^3.0.0"
}
}
+29
View File
@@ -0,0 +1,29 @@
@import 'tailwindcss';
@theme {
--color-background: oklch(0.98 0 0);
--color-foreground: oklch(0.15 0 0);
--color-card: oklch(1 0 0);
--color-card-foreground: oklch(0.15 0 0);
--color-primary: oklch(0.55 0.2 260);
--color-primary-foreground: oklch(0.98 0 0);
--color-secondary: oklch(0.96 0 0);
--color-secondary-foreground: oklch(0.15 0 0);
--color-muted: oklch(0.96 0 0);
--color-muted-foreground: oklch(0.5 0 0);
--color-destructive: oklch(0.55 0.2 25);
--color-destructive-foreground: oklch(0.98 0 0);
--color-border: oklch(0.9 0 0);
--color-ring: oklch(0.55 0.2 260);
--radius: 0.5rem;
}
body {
font-family: system-ui, -apple-system, sans-serif;
}
@layer base {
* {
border-color: var(--color-border);
}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📞</text></svg>" />
<title>pulse-lets-go</title>
%sveltekit.head%
</head>
<body class="min-h-screen bg-background text-foreground">
%sveltekit.body%
</body>
</html>
+209
View File
@@ -0,0 +1,209 @@
// API client для pulse-lets-go backend.
const API_BASE = '/api';
function getToken(): string | null {
if (typeof localStorage === 'undefined') return null;
return localStorage.getItem('token');
}
function getRefreshToken(): string | null {
if (typeof localStorage === 'undefined') return null;
return localStorage.getItem('refreshToken');
}
async function refreshAccessToken(): Promise<string | null> {
const refresh = getRefreshToken();
if (!refresh) return null;
try {
const res = await fetch(`${API_BASE}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${refresh}` },
});
if (!res.ok) return null;
const data = await res.json();
localStorage.setItem('token', data.access_token);
localStorage.setItem('refreshToken', data.refresh_token);
return data.access_token;
} catch {
return null;
}
}
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
let token = getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
let res = await fetch(`${API_BASE}${url}`, { ...options, headers });
// Пробуем обновить токен при 401
if (res.status === 401 && token) {
const newToken = await refreshAccessToken();
if (newToken) {
headers['Authorization'] = `Bearer ${newToken}`;
res = await fetch(`${API_BASE}${url}`, { ...options, headers });
}
}
if (res.status === 204) return undefined as T;
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || 'API error');
}
return res.json();
}
// --- Auth ---
export async function login(username: string, password: string) {
const data = await request<{ access_token: string; refresh_token: string; expires_in: number }>(
'/auth/login',
{ method: 'POST', body: JSON.stringify({ username, password }) }
);
localStorage.setItem('token', data.access_token);
localStorage.setItem('refreshToken', data.refresh_token);
return data;
}
export function isAuthenticated(): boolean {
return !!getToken();
}
export function logout() {
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
}
// --- Nodes ---
export interface NodeInfo {
node_id: string;
ts: number;
status: string;
active_calls: number;
max_calls: number;
idle_cpu: number;
load_avg: number;
call_failure_rate: number;
disabled: boolean;
disabled_reason: string;
score: number;
lethal_reason: string;
is_stale: boolean;
seconds_ago: number;
}
export async function getNodes(): Promise<NodeInfo[]> {
return request<NodeInfo[]>('/nodes');
}
export async function toggleNode(nodeId: string, disabled: boolean, reason: string = '') {
return request<NodeInfo>(`/nodes/${encodeURIComponent(nodeId)}/toggle`, {
method: 'PUT',
body: JSON.stringify({ disabled, reason }),
});
}
export interface NodeSnapshot {
active_calls: number;
max_calls: number;
idle_cpu: number;
load_avg: number;
call_failure_rate: number;
score: number;
ts: number;
}
export async function getNodeMetrics(nodeId: string): Promise<{ node_id: string; snapshots: NodeSnapshot[]; count: number }> {
return request(`/nodes/${encodeURIComponent(nodeId)}/metrics`);
}
// --- Trunks ---
export interface Trunk {
id: string;
name: string;
type: 'ingress' | 'balance' | 'fallback';
node_id: string;
gateway: string;
codecs: string[];
context: string;
enabled: boolean;
description: string;
created_at: string;
updated_at: string;
}
export async function getTrunks(typeFilter?: string): Promise<Trunk[]> {
const q = typeFilter ? `?type=${typeFilter}` : '';
return request<Trunk[]>(`/trunks${q}`);
}
export async function createTrunk(data: Partial<Trunk>) {
return request<Trunk>('/trunks', { method: 'POST', body: JSON.stringify(data) });
}
export async function updateTrunk(id: string, data: Partial<Trunk>) {
return request<Trunk>(`/trunks/${encodeURIComponent(id)}`, { method: 'PUT', body: JSON.stringify(data) });
}
export async function deleteTrunk(id: string) {
return request<void>(`/trunks/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
// --- Users ---
export interface UserInfo {
id: string;
username: string;
role: string;
created_at: string;
updated_at: string;
}
export async function getUsers(): Promise<UserInfo[]> {
return request<UserInfo[]>('/users');
}
export async function createUser(data: { username: string; password: string; role: string }) {
return request('/users', { method: 'POST', body: JSON.stringify(data) });
}
export async function updateUser(id: string, data: { username?: string; password?: string; role?: string }) {
return request(`/users/${encodeURIComponent(id)}`, { method: 'PUT', body: JSON.stringify(data) });
}
export async function deleteUser(id: string) {
return request<void>(`/users/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
// --- WebSocket ---
export function connectWebSocket(onMessage: (data: any) => void): WebSocket {
const token = getToken();
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${window.location.host}/ws/metrics?token=${token}`);
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
onMessage(data);
} catch (e) {
console.error('WS parse error:', e);
}
};
ws.onclose = () => {
// Переподключение через 5 секунд
setTimeout(() => {
if (getToken()) connectWebSocket(onMessage);
}, 5000);
};
return ws;
}
+66
View File
@@ -0,0 +1,66 @@
<script lang="ts">
import { browser } from '$app/environment';
import { page } from '$app/stores';
import '../app.css';
import { Toaster } from 'svelte-sonner';
import { LogIn, LayoutDashboard, Phone, Cable, Users } from 'lucide-svelte';
let token: string | null = browser ? localStorage.getItem('token') : null;
let user: { username: string; role: string } | null = token
? (() => { try { return JSON.parse(atob(token.split('.')[1])); } catch { return null; } })()
: null;
function logout() {
if (browser) {
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
}
token = null;
user = null;
}
$: pathname = browser ? window.location.pathname : '';
$: isLogin = pathname === '/login';
</script>
<Toaster position="top-right" />
{#if !isLogin}
<div class="flex min-h-screen">
<!-- Sidebar -->
<aside class="w-64 border-r bg-card flex flex-col">
<div class="p-4 border-b">
<h1 class="text-lg font-bold text-primary">pulse-lets-go</h1>
<p class="text-xs text-muted-foreground">Telephone Balancer</p>
</div>
<nav class="flex-1 p-3 space-y-1">
<a href="/" class="flex items-center gap-2 px-3 py-2 rounded-md text-sm {pathname === '/' ? 'bg-primary text-primary-foreground' : 'hover:bg-secondary'}">
<LayoutDashboard class="h-4 w-4" /> Дашборд
</a>
<a href="/nodes" class="flex items-center gap-2 px-3 py-2 rounded-md text-sm {pathname === '/nodes' ? 'bg-primary text-primary-foreground' : 'hover:bg-secondary'}">
<Phone class="h-4 w-4" /> Ноды
</a>
<a href="/trunks" class="flex items-center gap-2 px-3 py-2 rounded-md text-sm {pathname === '/trunks' ? 'bg-primary text-primary-foreground' : 'hover:bg-secondary'}">
<Cable class="h-4 w-4" /> Транки
</a>
<a href="/users" class="flex items-center gap-2 px-3 py-2 rounded-md text-sm {pathname === '/users' ? 'bg-primary text-primary-foreground' : 'hover:bg-secondary'}">
<Users class="h-4 w-4" /> Пользователи
</a>
</nav>
<div class="p-3 border-t text-sm text-muted-foreground">
<span>{user?.username ?? '--'}</span>
<span class="mx-1">({user?.role ?? '--'})</span>
<button on:click={logout} class="ml-2 text-destructive hover:underline text-xs">Выйти</button>
</div>
</aside>
<!-- Main content -->
<main class="flex-1 p-6">
<slot />
</main>
</div>
{:else}
<slot />
{/if}
+188
View File
@@ -0,0 +1,188 @@
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { onMount, onDestroy } from 'svelte';
import {
getNodes, connectWebSocket, isAuthenticated,
type NodeInfo
} from '$lib/api';
import { Activity, Phone, Zap, AlertTriangle, Wifi, Server } from 'lucide-svelte';
let nodes: NodeInfo[] = [];
let ws: WebSocket | null = null;
let error: string | null = null;
if (browser && !isAuthenticated()) {
goto('/login');
}
onMount(() => {
loadNodes();
ws = connectWebSocket((msg) => {
if (msg.type === 'nodes_update') {
nodes = msg.payload;
}
});
});
onDestroy(() => {
ws?.close();
});
async function loadNodes() {
try {
nodes = await getNodes();
} catch (e: any) {
error = e.message;
}
}
function scoreColor(score: number): string {
if (score >= 70) return 'text-green-600';
if (score >= 40) return 'text-yellow-600';
if (score >= 0) return 'text-orange-600';
return 'text-red-600';
}
function scoreBg(score: number): string {
if (score >= 70) return 'bg-green-50 border-green-200';
if (score >= 40) return 'bg-yellow-50 border-yellow-200';
if (score >= 0) return 'bg-orange-50 border-orange-200';
return 'bg-red-50 border-red-200';
}
function nodeStatus(n: NodeInfo): string {
if (n.disabled) return 'disabled';
if (n.is_stale) return 'stale';
if (n.status !== 'ok') return 'degraded';
if (n.score < 0) return 'unhealthy';
return 'healthy';
}
function statusColor(s: string): string {
switch (s) {
case 'healthy': return 'bg-green-500';
case 'degraded': return 'bg-yellow-500';
default: return 'bg-red-500';
}
}
$: healthyCount = nodes.filter(n => n.score >= 0 && !n.disabled && !n.is_stale).length;
$: totalCalls = nodes.reduce((s, n) => s + n.active_calls, 0);
$: avgScore = nodes.length > 0 ? nodes.reduce((s, n) => s + n.score, 0) / nodes.length : 0;
$: unhealthyList = nodes.filter(n => n.score < 0 || n.disabled || n.is_stale);
</script>
{#if error}
<div class="p-4 border border-red-200 bg-red-50 text-red-700 rounded-md">
{error}
</div>
{/if}
<!-- Summary Cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="border rounded-lg p-4 bg-card">
<div class="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<Server class="h-4 w-4" /> Всего нод
</div>
<div class="text-2xl font-bold">{nodes.length}</div>
</div>
<div class="border rounded-lg p-4 bg-card">
<div class="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<Activity class="h-4 w-4" /> Здоровых
</div>
<div class="text-2xl font-bold text-green-600">{healthyCount}</div>
</div>
<div class="border rounded-lg p-4 bg-card">
<div class="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<Phone class="h-4 w-4" /> Активных звонков
</div>
<div class="text-2xl font-bold">{totalCalls}</div>
</div>
<div class="border rounded-lg p-4 bg-card">
<div class="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<Zap class="h-4 w-4" /> Средний score
</div>
<div class="text-2xl font-bold {scoreColor(avgScore)}">{avgScore.toFixed(1)}</div>
</div>
</div>
<!-- Unhealthy Alerts -->
{#if unhealthyList.length > 0}
<div class="mb-6">
<h2 class="flex items-center gap-2 text-sm font-semibold text-destructive mb-2">
<AlertTriangle class="h-4 w-4" /> Проблемные ноды ({unhealthyList.length})
</h2>
<div class="space-y-1">
{#each unhealthyList as n}
<div class="text-sm text-muted-foreground flex items-center gap-2">
<span class="w-2 h-2 rounded-full {statusColor(nodeStatus(n))}"></span>
<span class="font-mono">{n.node_id}</span>
<span class="text-destructive">score={n.score.toFixed(0)}</span>
{#if n.lethal_reason}
<span class="text-red-500">{n.lethal_reason}</span>
{/if}
{#if n.disabled}
<span class="text-orange-500">— disabled: {n.disabled_reason}</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- Node Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{#each nodes as n (n.node_id)}
<div class="border rounded-lg p-4 {scoreBg(n.score)}">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full {statusColor(nodeStatus(n))}"></span>
<span class="font-semibold">{n.node_id}</span>
</div>
<span class="text-lg font-bold {scoreColor(n.score)}">
{n.score.toFixed(0)}
</span>
</div>
<div class="grid grid-cols-2 gap-2 text-xs">
<div>
<span class="text-muted-foreground">Звонки:</span>
<span class="font-mono ml-1">{n.active_calls}/{n.max_calls}</span>
</div>
<div>
<span class="text-muted-foreground">CPU idle:</span>
<span class="font-mono ml-1">{n.idle_cpu.toFixed(0)}%</span>
</div>
<div>
<span class="text-muted-foreground">Load:</span>
<span class="font-mono ml-1">{n.load_avg.toFixed(2)}</span>
</div>
<div>
<span class="text-muted-foreground">Fail:</span>
<span class="font-mono ml-1">{n.call_failure_rate.toFixed(1)}%</span>
</div>
<div class="col-span-2">
<span class="text-muted-foreground">Status:</span>
<span class="ml-1">{nodeStatus(n)}</span>
<span class="text-muted-foreground ml-2">
{n.seconds_ago.toFixed(0)}с назад
</span>
{#if n.is_stale}
<span class="text-red-500 ml-1">STALE</span>
{/if}
</div>
</div>
</div>
{/each}
{#if nodes.length === 0}
<div class="col-span-full text-center py-12 text-muted-foreground">
<Wifi class="h-12 w-12 mx-auto mb-4 opacity-20" />
<p>Нет зарегистрированных нод. Ожидание метрик через NATS...</p>
</div>
{/if}
</div>
+80
View File
@@ -0,0 +1,80 @@
<script lang="ts">
import { login, isAuthenticated } from '$lib/api';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { toast } from 'svelte-sonner';
let username = '';
let password = '';
let loading = false;
if (browser && isAuthenticated()) {
goto('/');
}
async function handleLogin(e: Event) {
e.preventDefault();
if (!username || !password) {
toast.error('Заполните имя и пароль');
return;
}
loading = true;
try {
await login(username, password);
toast.success('Вход выполнен');
goto('/');
} catch (err: any) {
toast.error(err.message || 'Ошибка входа');
} finally {
loading = false;
}
}
</script>
<div class="min-h-screen flex items-center justify-center bg-secondary">
<div class="w-full max-w-sm p-8 bg-card rounded-lg border shadow-sm">
<div class="text-center mb-6">
<span class="text-4xl">📞</span>
<h1 class="text-xl font-bold mt-2">pulse-lets-go</h1>
<p class="text-sm text-muted-foreground">Telephone Balancer</p>
</div>
<form on:submit={handleLogin} class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium mb-1">Имя пользователя</label>
<input
id="username"
type="text"
bind:value={username}
class="w-full px-3 py-2 border rounded-md text-sm bg-background focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="admin"
required
/>
</div>
<div>
<label for="password" class="block text-sm font-medium mb-1">Пароль</label>
<input
id="password"
type="password"
bind:value={password}
class="w-full px-3 py-2 border rounded-md text-sm bg-background focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="••••••"
required
/>
</div>
<button
type="submit"
disabled={loading}
class="w-full py-2 px-4 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
{loading ? 'Вход...' : 'Войти'}
</button>
</form>
<p class="mt-4 text-xs text-muted-foreground text-center">
По умолчанию: admin / admin
</p>
</div>
</div>
+166
View File
@@ -0,0 +1,166 @@
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { onMount, onDestroy } from 'svelte';
import { isAuthenticated, getNodes, toggleNode, connectWebSocket, type NodeInfo } from '$lib/api';
import { toast } from 'svelte-sonner';
import { Phone, Power, PowerOff } from 'lucide-svelte';
let nodes: NodeInfo[] = [];
let ws: WebSocket | null = null;
let loading = true;
// Toggle dialog
let toggleTarget: string | null = null;
let toggleDisabling = false;
let toggleReason = '';
if (browser && !isAuthenticated()) { goto('/login'); }
onMount(() => {
loadNodes();
ws = connectWebSocket((msg) => {
if (msg.type === 'nodes_update') {
nodes = msg.payload;
}
});
});
onDestroy(() => {
ws?.close();
});
async function loadNodes() {
try {
nodes = await getNodes();
} catch (e: any) {
toast.error(e.message);
} finally {
loading = false;
}
}
function openToggle(nodeId: string, currentlyDisabled: boolean) {
toggleTarget = nodeId;
toggleDisabling = !currentlyDisabled;
toggleReason = '';
}
async function confirmToggle() {
if (!toggleTarget) return;
try {
const updated = await toggleNode(toggleTarget, toggleDisabling, toggleReason);
const idx = nodes.findIndex(n => n.node_id === toggleTarget);
if (idx >= 0) nodes[idx] = updated;
toast.success(toggleDisabling ? `Нода ${toggleTarget} исключена из распределения` : `Нода ${toggleTarget} возвращена в распределение`);
toggleTarget = null;
} catch (e: any) {
toast.error(e.message);
}
}
function scoreColor(score: number): string {
if (score >= 70) return 'text-green-600';
if (score >= 40) return 'text-yellow-600';
if (score >= 0) return 'text-orange-600';
return 'text-red-600';
}
</script>
<div class="space-y-6">
<h1 class="text-xl font-bold flex items-center gap-2">
<Phone class="h-5 w-5" /> Ноды
</h1>
{#if loading}
<p class="text-muted-foreground text-sm">Загрузка...</p>
{:else if nodes.length === 0}
<p class="text-muted-foreground text-sm py-8 text-center">Нет зарегистрированных нод.</p>
{:else}
<div class="border rounded-lg overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-secondary">
<tr>
<th class="px-4 py-3 text-left font-medium">Node ID</th>
<th class="px-4 py-3 text-left font-medium">Score</th>
<th class="px-4 py-3 text-left font-medium">Звонки</th>
<th class="px-4 py-3 text-left font-medium">CPU idle</th>
<th class="px-4 py-3 text-left font-medium">Load</th>
<th class="px-4 py-3 text-left font-medium">Fail %</th>
<th class="px-4 py-3 text-left font-medium">Статус</th>
<th class="px-4 py-3 text-right font-medium">Управление</th>
</tr>
</thead>
<tbody>
{#each nodes as n (n.node_id)}
<tr class="border-t hover:bg-secondary/50">
<td class="px-4 py-3 font-mono font-medium">{n.node_id}</td>
<td class="px-4 py-3 font-mono font-bold {scoreColor(n.score)}">{n.score.toFixed(0)}</td>
<td class="px-4 py-3 font-mono">{n.active_calls}/{n.max_calls}</td>
<td class="px-4 py-3 font-mono">{n.idle_cpu.toFixed(0)}%</td>
<td class="px-4 py-3 font-mono">{n.load_avg.toFixed(2)}</td>
<td class="px-4 py-3 font-mono">{n.call_failure_rate.toFixed(1)}%</td>
<td class="px-4 py-3">
{#if n.disabled}
<span class="text-red-600 font-medium">disabled</span>
{:else if n.is_stale}
<span class="text-red-600 font-medium">stale</span>
{:else if n.score < 0}
<span class="text-red-600 font-medium">{n.lethal_reason || 'unhealthy'}</span>
{:else}
<span class="text-green-600 font-medium">healthy</span>
{/if}
<div class="text-xs text-muted-foreground">{n.seconds_ago.toFixed(0)}с назад</div>
</td>
<td class="px-4 py-3 text-right">
{#if n.disabled}
<button on:click={() => openToggle(n.node_id, true)} class="flex items-center gap-1 px-3 py-1.5 bg-green-600 text-white rounded text-xs hover:opacity-90">
<Power class="h-3 w-3" /> Вернуть
</button>
{:else}
<button on:click={() => openToggle(n.node_id, false)} class="flex items-center gap-1 px-3 py-1.5 bg-red-600 text-white rounded text-xs hover:opacity-90">
<PowerOff class="h-3 w-3" /> Исключить
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<!-- Toggle confirmation dialog -->
{#if toggleTarget}
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" on:click={() => (toggleTarget = null)} on:keydown={(e) => { if (e.key === 'Escape') toggleTarget = null; }} role="dialog">
<div class="bg-card border rounded-lg p-6 w-full max-w-md shadow-lg" on:click|stopPropagation>
<h3 class="text-lg font-semibold mb-2">
{toggleDisabling ? 'Исключить ноду из распределения?' : 'Вернуть ноду в распределение?'}
</h3>
<p class="text-sm text-muted-foreground mb-4">
Нода: <span class="font-mono font-medium">{toggleTarget}</span>
</p>
{#if toggleDisabling}
<div class="mb-4">
<label for="reason" class="block text-sm font-medium mb-1">Причина исключения</label>
<input
id="reason"
type="text"
bind:value={toggleReason}
class="w-full px-3 py-2 border rounded-md text-sm bg-background"
placeholder="Например: технические работы"
/>
</div>
{/if}
<div class="flex gap-2 justify-end">
<button on:click={() => (toggleTarget = null)} class="px-4 py-2 border rounded-md text-sm">Отмена</button>
<button on:click={confirmToggle} class="px-4 py-2 {toggleDisabling ? 'bg-destructive text-destructive-foreground' : 'bg-primary text-primary-foreground'} rounded-md text-sm font-medium">
{toggleDisabling ? 'Исключить' : 'Вернуть'}
</button>
</div>
</div>
</div>
{/if}
</div>
+225
View File
@@ -0,0 +1,225 @@
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { isAuthenticated, getTrunks, createTrunk, updateTrunk, deleteTrunk, type Trunk } from '$lib/api';
import { toast } from 'svelte-sonner';
import { Plus, Trash2, Save, X, Cable } from 'lucide-svelte';
let trunks: Trunk[] = [];
let loading = true;
// Edit mode
let editing: string | null = null; // trunk ID being edited
let editData: Partial<Trunk> = {};
let creating = false;
let newData: Partial<Trunk> = {
type: 'balance',
enabled: true,
codecs: ['PCMU', 'PCMA'],
context: 'default',
};
let typeFilter = '';
if (browser && !isAuthenticated()) { goto('/login'); }
onMount(loadTrunks);
async function loadTrunks() {
loading = true;
try {
trunks = await getTrunks(typeFilter || undefined);
} catch (e: any) {
toast.error(e.message);
} finally {
loading = false;
}
}
function startEdit(t: Trunk) {
editing = t.id;
editData = { ...t };
}
function cancelEdit() {
editing = null;
creating = false;
editData = {};
}
async function saveEdit() {
if (!editing) return;
try {
const updated = await updateTrunk(editing, editData);
const idx = trunks.findIndex(t => t.id === editing);
if (idx >= 0) trunks[idx] = updated;
editing = null;
toast.success('Транк обновлён');
} catch (e: any) {
toast.error(e.message);
}
}
async function saveCreate() {
try {
const created = await createTrunk(newData);
trunks = [...trunks, created];
creating = false;
newData = { type: 'balance', enabled: true, codecs: ['PCMU', 'PCMA'], context: 'default' };
toast.success('Транк создан');
} catch (e: any) {
toast.error(e.message);
}
}
async function removeTrunk(id: string) {
if (!confirm('Удалить транк?')) return;
try {
await deleteTrunk(id);
trunks = trunks.filter(t => t.id !== id);
toast.success('Транк удалён');
} catch (e: any) {
toast.error(e.message);
}
}
function typeBadge(type: string): string {
switch (type) {
case 'ingress': return 'bg-blue-100 text-blue-700';
case 'balance': return 'bg-green-100 text-green-700';
case 'fallback': return 'bg-orange-100 text-orange-700';
default: return 'bg-gray-100 text-gray-700';
}
}
function typeLabel(type: string): string {
switch (type) {
case 'ingress': return 'Вход';
case 'balance': return 'Баланс';
case 'fallback': return 'Резерв';
default: return type;
}
}
function onFilterChange(e: Event) {
typeFilter = (e.target as HTMLSelectElement).value;
loadTrunks();
}
</script>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold flex items-center gap-2">
<Cable class="h-5 w-5" /> Транки
</h1>
<button on:click={() => (creating = true)} class="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:opacity-90">
<Plus class="h-4 w-4" /> Создать
</button>
</div>
<!-- Filter -->
<div class="flex items-center gap-4">
<select value={typeFilter} on:change={onFilterChange} class="px-3 py-2 border rounded-md text-sm bg-background">
<option value="">Все типы</option>
<option value="ingress">Ingress (входные)</option>
<option value="balance">Balance (балансировка)</option>
<option value="fallback">Fallback (резерв)</option>
</select>
</div>
<!-- Create form -->
{#if creating}
<div class="border rounded-lg p-4 bg-yellow-50 border-yellow-200">
<h3 class="text-sm font-semibold mb-3">Новый транк</h3>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-3">
<input class="px-3 py-2 border rounded-md text-sm" placeholder="Название" bind:value={newData.name} />
<select class="px-3 py-2 border rounded-md text-sm bg-background" bind:value={newData.type}>
<option value="ingress">Ingress</option>
<option value="balance">Balance</option>
<option value="fallback">Fallback</option>
</select>
<input class="px-3 py-2 border rounded-md text-sm" placeholder="Gateway (sip:...)" bind:value={newData.gateway} />
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3">
<input class="px-3 py-2 border rounded-md text-sm" placeholder="Node ID (для balance)" bind:value={newData.node_id} />
<input class="px-3 py-2 border rounded-md text-sm" placeholder="Context" bind:value={newData.context} />
</div>
<div class="flex items-center gap-3 mb-3">
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" bind:checked={newData.enabled} class="rounded" /> Включён
</label>
<input class="flex-1 px-3 py-2 border rounded-md text-sm" placeholder="Описание" bind:value={newData.description} />
</div>
<div class="flex gap-2">
<button on:click={saveCreate} class="flex items-center gap-1 px-3 py-2 bg-primary text-primary-foreground rounded-md text-sm"><Save class="h-3 w-3" /> Создать</button>
<button on:click={cancelEdit} class="flex items-center gap-1 px-3 py-2 border rounded-md text-sm"><X class="h-3 w-3" /> Отмена</button>
</div>
</div>
{/if}
{#if loading}
<p class="text-muted-foreground text-sm">Загрузка...</p>
{:else if trunks.length === 0}
<p class="text-muted-foreground text-sm py-8 text-center">Нет транков. Создайте первый.</p>
{:else}
<div class="border rounded-lg overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-secondary">
<tr>
<th class="px-4 py-3 text-left font-medium">Название</th>
<th class="px-4 py-3 text-left font-medium">Тип</th>
<th class="px-4 py-3 text-left font-medium">Gateway</th>
<th class="px-4 py-3 text-left font-medium">Node ID</th>
<th class="px-4 py-3 text-left font-medium">Статус</th>
<th class="px-4 py-3 text-right font-medium">Действия</th>
</tr>
</thead>
<tbody>
{#each trunks as t (t.id)}
{#if editing === t.id}
<!-- Edit row -->
<tr class="border-t bg-yellow-50">
<td class="px-4 py-2"><input class="w-full px-2 py-1 border rounded text-sm" bind:value={editData.name} /></td>
<td class="px-4 py-2">
<select class="w-full px-2 py-1 border rounded text-sm" bind:value={editData.type}>
<option value="ingress">Ingress</option>
<option value="balance">Balance</option>
<option value="fallback">Fallback</option>
</select>
</td>
<td class="px-4 py-2"><input class="w-full px-2 py-1 border rounded text-sm" bind:value={editData.gateway} /></td>
<td class="px-4 py-2"><input class="w-full px-2 py-1 border rounded text-sm" bind:value={editData.node_id} /></td>
<td class="px-4 py-2">
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" bind:checked={editData.enabled} />
{editData.enabled ? 'Вкл' : 'Выкл'}
</label>
</td>
<td class="px-4 py-2 text-right">
<div class="flex gap-1 justify-end">
<button on:click={saveEdit} class="px-2 py-1 bg-primary text-primary-foreground rounded text-xs"><Save class="h-3 w-3 inline" /></button>
<button on:click={cancelEdit} class="px-2 py-1 border rounded text-xs"><X class="h-3 w-3 inline" /></button>
</div>
</td>
</tr>
{:else}
<!-- Normal row -->
<tr class="border-t hover:bg-secondary/50">
<td class="px-4 py-3 font-medium">{t.name}</td>
<td class="px-4 py-3"><span class="px-2 py-1 rounded text-xs font-medium {typeBadge(t.type)}">{typeLabel(t.type)}</span></td>
<td class="px-4 py-3 font-mono text-xs">{t.gateway}</td>
<td class="px-4 py-3 font-mono text-xs">{t.node_id || '—'}</td>
<td class="px-4 py-3">{t.enabled ? '🟢 Вкл' : '🔴 Выкл'}</td>
<td class="px-4 py-3 text-right">
<button on:click={() => startEdit(t)} class="px-2 py-1 text-xs text-primary hover:underline mr-1">Изменить</button>
<button on:click={() => removeTrunk(t.id)} class="px-2 py-1 text-xs text-destructive hover:underline"><Trash2 class="h-3 w-3 inline" /></button>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
</div>
+375
View File
@@ -0,0 +1,375 @@
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { isAuthenticated, getUsers, createUser, updateUser, deleteUser, type UserInfo } from '$lib/api';
import { toast } from 'svelte-sonner';
import { Users, Plus, Save, X, Trash2 } from 'lucide-svelte';
let users: UserInfo[] = [];
let loading = true;
// Создание
let creating = false;
let newUsername = '';
let newPassword = '';
let newRole = 'viewer';
// Редактирование
let editing: string | null = null;
let editUsername = '';
let editPassword = '';
let editRole = 'viewer';
// Подтверждение удаления
let deleteTarget: string | null = null;
let deleteUsername = '';
let deleteIsLastAdmin = false;
if (browser && !isAuthenticated()) {
goto('/login');
}
onMount(() => {
loadUsers();
});
async function loadUsers() {
loading = true;
try {
users = await getUsers();
} catch (e: any) {
toast.error(e.message || 'Ошибка загрузки');
} finally {
loading = false;
}
}
// ── Создание ────────────────────────────────────────────────────
async function handleCreate() {
if (!newUsername || !newPassword) {
toast.error('Заполните имя и пароль');
return;
}
try {
const u = await createUser({ username: newUsername, password: newPassword, role: newRole });
users = [...users, u];
creating = false;
newUsername = ''; newPassword = ''; newRole = 'viewer';
toast.success('Пользователь создан');
} catch (e: any) {
toast.error(e.message || 'Ошибка создания');
}
}
function cancelCreate() {
creating = false;
newUsername = ''; newPassword = ''; newRole = 'viewer';
}
// ── Редактирование ──────────────────────────────────────────────
function startEdit(u: UserInfo) {
editing = u.id;
editUsername = u.username;
editPassword = '';
editRole = u.role;
}
function cancelEdit() {
editing = null;
}
async function saveEdit() {
if (!editing || !editUsername) return;
const data: { username?: string; password?: string; role?: string } = {
username: editUsername,
role: editRole,
};
if (editPassword) {
data.password = editPassword;
}
try {
const updated = await updateUser(editing, data);
const idx = users.findIndex(u => u.id === editing);
if (idx >= 0) {
users = [...users.slice(0, idx), updated, ...users.slice(idx + 1)];
}
editing = null;
toast.success('Пользователь обновлён');
} catch (e: any) {
toast.error(e.message || 'Ошибка обновления');
}
}
// ── Удаление ────────────────────────────────────────────────────
function confirmDelete(u: UserInfo) {
const adminCount = users.filter(x => x.role === 'admin').length;
deleteIsLastAdmin = u.role === 'admin' && adminCount <= 1;
deleteTarget = u.id;
deleteUsername = u.username;
}
function cancelDelete() {
deleteTarget = null;
}
async function executeDelete() {
if (!deleteTarget || deleteIsLastAdmin) return;
try {
await deleteUser(deleteTarget);
users = users.filter(u => u.id !== deleteTarget);
deleteTarget = null;
toast.success('Пользователь удалён');
} catch (e: any) {
toast.error(e.message || 'Ошибка удаления');
}
}
// ── Форматирование ──────────────────────────────────────────────
function roleBadge(role: string): string {
return role === 'admin'
? 'bg-purple-100 text-purple-700'
: 'bg-gray-100 text-gray-700';
}
function formatDate(d: string): string {
if (!d) return '—';
return new Date(d).toLocaleDateString('ru-RU', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
});
}
// Проверка: является ли текущий пользователь владельцем строки
function currentUsername(): string {
const token = browser ? localStorage.getItem('token') : null;
if (!token) return '';
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload?.username || '';
} catch { return ''; }
}
</script>
<div class="space-y-6">
<!-- Заголовок -->
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold flex items-center gap-2">
<Users class="h-5 w-5" /> Пользователи
</h1>
<button
on:click={() => (creating = true)}
class="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:opacity-90"
>
<Plus class="h-4 w-4" /> Создать
</button>
</div>
<!-- ── Форма создания ────────────────────────────────────────── -->
{#if creating}
<div class="border rounded-lg p-4 bg-yellow-50 border-yellow-200">
<h3 class="text-sm font-semibold mb-3">Новый пользователь</h3>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-3">
<input
class="px-3 py-2 border rounded-md text-sm bg-background"
placeholder="Имя пользователя"
bind:value={newUsername}
/>
<input
class="px-3 py-2 border rounded-md text-sm bg-background"
type="password"
placeholder="Пароль"
bind:value={newPassword}
/>
<select
class="px-3 py-2 border rounded-md text-sm bg-background"
bind:value={newRole}
>
<option value="admin">admin</option>
<option value="viewer">viewer</option>
</select>
</div>
<div class="flex gap-2">
<button
on:click={handleCreate}
class="flex items-center gap-1 px-3 py-2 bg-primary text-primary-foreground rounded-md text-sm"
>
<Save class="h-3 w-3" /> Создать
</button>
<button
on:click={cancelCreate}
class="flex items-center gap-1 px-3 py-2 border rounded-md text-sm"
>
<X class="h-3 w-3" /> Отмена
</button>
</div>
</div>
{/if}
<!-- ── Таблица ────────────────────────────────────────────────── -->
{#if loading}
<p class="text-muted-foreground text-sm">Загрузка...</p>
{:else if users.length === 0}
<p class="text-muted-foreground text-sm py-8 text-center">
Нет пользователей. Создайте первого.
</p>
{:else}
<div class="border rounded-lg overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-secondary">
<tr>
<th class="px-4 py-3 text-left font-medium">Имя пользователя</th>
<th class="px-4 py-3 text-left font-medium">Роль</th>
<th class="px-4 py-3 text-left font-medium">Создан</th>
<th class="px-4 py-3 text-right font-medium">Действия</th>
</tr>
</thead>
<tbody>
{#each users as u (u.id)}
{#if editing === u.id}
<!-- ── Строка редактирования ── -->
<tr class="border-t bg-yellow-50">
<td class="px-4 py-2">
<input
class="w-full px-2 py-1 border rounded text-sm bg-background"
bind:value={editUsername}
/>
</td>
<td class="px-4 py-2">
{#if u.id === 'user-01'}
<span class="px-2 py-1 rounded text-xs font-medium bg-purple-100 text-purple-700">
admin 🔒
</span>
<span class="text-xs text-muted-foreground ml-1">защищён</span>
{:else}
<select
class="w-full px-2 py-1 border rounded text-sm bg-background"
bind:value={editRole}
>
<option value="admin">admin</option>
<option value="viewer">viewer</option>
</select>
{/if}
</td>
<td class="px-4 py-2 text-muted-foreground text-xs">
{formatDate(u.created_at)}
</td>
<td class="px-4 py-2 text-right">
<div class="flex gap-1 justify-end">
<input
class="w-24 px-2 py-1 border rounded text-xs bg-background"
type="password"
placeholder="Новый пароль"
bind:value={editPassword}
/>
<button
on:click={saveEdit}
class="px-2 py-1 bg-primary text-primary-foreground rounded text-xs"
>
<Save class="h-3 w-3 inline" />
</button>
<button
on:click={cancelEdit}
class="px-2 py-1 border rounded text-xs"
>
<X class="h-3 w-3 inline" />
</button>
</div>
</td>
</tr>
{:else}
<!-- ── Обычная строка ── -->
<tr class="border-t hover:bg-secondary/50">
<td class="px-4 py-3">
<span class="font-medium">{u.username}</span>
{#if u.id === 'user-01'}
<span class="text-xs text-purple-600 ml-1" title="Первичный администратор. Роль защищена от изменения.">основной</span>
{/if}
{#if u.username === currentUsername()}
<span class="text-xs text-muted-foreground ml-2">(вы)</span>
{/if}
</td>
<td class="px-4 py-3">
<span class="px-2 py-0.5 rounded text-xs font-medium {roleBadge(u.role)}">
{u.role}
</span>
</td>
<td class="px-4 py-3 text-xs text-muted-foreground">
{formatDate(u.created_at)}
</td>
<td class="px-4 py-3 text-right">
<button
on:click={() => startEdit(u)}
class="px-2 py-1 text-xs text-primary hover:underline mr-1"
>
Изменить
</button>
{#if u.id !== 'user-01'}
<button
on:click={() => confirmDelete(u)}
class="px-2 py-1 text-xs text-destructive hover:underline"
>
<Trash2 class="h-3 w-3 inline" />
</button>
{/if}
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
<!-- ── Модальное окно удаления ────────────────────────────────── -->
{#if deleteTarget}
<div
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
on:click={cancelDelete}
on:keydown={(e) => { if (e.key === 'Escape') cancelDelete(); }}
role="dialog"
>
<div class="bg-card border rounded-lg p-6 w-full max-w-md shadow-lg" on:click|stopPropagation>
<h3 class="text-lg font-semibold mb-2">
{deleteIsLastAdmin ? 'Невозможно удалить' : 'Удалить пользователя?'}
</h3>
{#if deleteIsLastAdmin}
<p class="text-sm text-muted-foreground mb-4">
<strong>{deleteUsername}</strong> — последний администратор.
Сначала назначьте другого пользователя администратором.
</p>
<div class="flex justify-end">
<button on:click={cancelDelete} class="px-4 py-2 border rounded-md text-sm">
Понятно
</button>
</div>
{:else}
<p class="text-sm text-muted-foreground mb-4">
Вы уверены, что хотите удалить пользователя
<strong>{deleteUsername}</strong>?
Это действие нельзя отменить.
</p>
<div class="flex gap-2 justify-end">
<button on:click={cancelDelete} class="px-4 py-2 border rounded-md text-sm">
Отмена
</button>
<button
on:click={executeDelete}
class="px-4 py-2 bg-destructive text-destructive-foreground rounded-md text-sm font-medium hover:opacity-90"
>
Удалить
</button>
</div>
{/if}
</div>
</div>
{/if}
</div>
+22
View File
@@ -0,0 +1,22 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: false,
strict: false
}),
alias: {
'$lib': './src/lib'
}
}
};
export default config;
+17
View File
@@ -0,0 +1,17 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
proxy: {
'/api': 'http://localhost:8080',
'/ws': {
target: 'ws://localhost:8080',
ws: true
}
}
}
});