first commit
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user