feat(userbot): add Kubernetes control panel

Manage Telegram instances through Kubernetes with legacy adoption for forust and anna. Build and deploy the panel image alongside the runtime.
This commit is contained in:
2026-07-26 19:39:09 +02:00
parent 30e7d1b65f
commit 96de2d2573
39 changed files with 8188 additions and 5 deletions
+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.0" />
<meta name="theme-color" content="#080808" />
<title>Userbot · Kubernetes Control</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+3970
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "userbot-panel",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite --host 0.0.0.0",
"test": "vitest run"
},
"dependencies": {
"lucide-svelte": "^0.468.0",
"svelte": "^5.16.0"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/svelte": "^5.2.6",
"@types/node": "^22.10.5",
"jsdom": "^25.0.1",
"svelte-check": "^4.7.3",
"typescript": "~5.7.2",
"vite": "^6.0.7",
"vitest": "^2.1.8"
}
}
+211
View File
@@ -0,0 +1,211 @@
<script lang="ts">
import {
Menu,
Plus,
RefreshCw,
Search,
Server,
} from 'lucide-svelte';
import { onMount } from 'svelte';
import AddAccountDrawer from './components/AddAccountDrawer.svelte';
import DetailsDrawer from './components/DetailsDrawer.svelte';
import InstanceTable from './components/InstanceTable.svelte';
import Sidebar from './components/Sidebar.svelte';
import { api } from './lib/api';
import { filterInstances } from './lib/instances';
import type { Instance } from './lib/types';
let instances: Instance[] = [];
let selected: Instance | null = null;
let query = '';
let status = '';
let loading = true;
let refreshing = false;
let error = '';
let addOpen = false;
let menuOpen = false;
let busyId = '';
$: visibleInstances = filterInstances(instances, query, status);
$: running = instances.filter((item) => item.status === 'running').length;
$: stopped = instances.filter((item) => item.status === 'stopped').length;
$: restarts = instances.reduce((total, item) => total + item.restarts, 0);
onMount(() => {
refresh();
const timer = window.setInterval(() => refresh(true), 5000);
return () => window.clearInterval(timer);
});
async function refresh(silent = false) {
if (!silent) refreshing = true;
try {
const next = await api.instances();
instances = next;
if (selected) {
selected =
next.find(
(item) =>
item.instance_id === selected?.instance_id && item.namespace === selected?.namespace,
) || null;
}
error = '';
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Не удалось загрузить инстансы';
} finally {
loading = false;
refreshing = false;
}
}
async function runAction(
instance: Instance,
action: 'start' | 'stop' | 'restart' | 'logs',
) {
if (action === 'logs') {
selected = instance;
return;
}
busyId = instance.instance_id;
try {
const updated = await api.action(instance.instance_id, action);
instances = instances.map((item) =>
item.instance_id === updated.instance_id && item.namespace === updated.namespace
? updated
: item,
);
if (selected?.instance_id === updated.instance_id) selected = updated;
window.setTimeout(() => refresh(true), 900);
} catch (cause) {
error = cause instanceof Error ? cause.message : `Не удалось выполнить ${action}`;
} finally {
busyId = '';
}
}
async function deleteInstance(instance: Instance, deleteData: boolean) {
busyId = instance.instance_id;
try {
await api.delete(instance.instance_id, deleteData);
selected = null;
await refresh();
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Не удалось удалить инстанс';
} finally {
busyId = '';
}
}
function created(instance: Instance) {
addOpen = false;
selected = instance;
refresh();
}
</script>
<main class="app-shell">
<Sidebar
open={menuOpen}
on:add={() => {
addOpen = true;
menuOpen = false;
}}
on:close={() => (menuOpen = false)}
/>
{#if menuOpen}
<button class="mobile-overlay" aria-label="Закрыть меню" on:click={() => (menuOpen = false)}></button>
{/if}
<section class="workspace">
<header class="topbar">
<button class="mobile-menu icon-button" aria-label="Открыть меню" on:click={() => (menuOpen = true)}>
<Menu size={18} />
</button>
<div class="live-state"><span></span>{running} инстанса работают</div>
<div class="topbar-actions">
<span class="namespace"><Server size={14} />userbot</span>
<button class="primary" on:click={() => (addOpen = true)}>
<Plus size={16} />
Добавить аккаунт
</button>
</div>
</header>
<div class="content">
<div class="page-heading">
<div>
<h1>Инстансы</h1>
<p>Telegram-аккаунты и их состояние в Kubernetes</p>
</div>
</div>
<section class="summary-strip" aria-label="Сводка">
<div><span>Всего</span><strong>{instances.length}</strong></div>
<div><span>Работают</span><strong>{running}</strong></div>
<div><span>Остановлены</span><strong>{stopped}</strong></div>
<div><span>Перезапуски</span><strong>{restarts}</strong></div>
</section>
<div class="toolbar">
<label class="search-field">
<Search size={16} />
<input bind:value={query} placeholder="Поиск" aria-label="Поиск инстансов" />
</label>
<select bind:value={status} aria-label="Фильтр статуса">
<option value="">Все статусы</option>
<option value="running">Работают</option>
<option value="stopped">Остановлены</option>
<option value="pending">Запускаются</option>
<option value="error">Ошибки</option>
</select>
<button
class="icon-button refresh"
aria-label="Обновить"
disabled={refreshing}
on:click={() => refresh()}
>
<RefreshCw size={17} class={refreshing ? 'spin' : ''} />
</button>
</div>
{#if error}
<div class="error-banner" role="alert">
<span>{error}</span>
<button on:click={() => refresh()}>Повторить</button>
</div>
{/if}
{#if loading}
<div class="center-state"><RefreshCw class="spin" size={22} />Загружаем инстансы…</div>
{:else if visibleInstances.length}
<InstanceTable
instances={visibleInstances}
{busyId}
on:select={(event) => (selected = event.detail)}
on:action={(event) => runAction(event.detail.instance, event.detail.action)}
/>
{:else}
<div class="empty-state">
<Server size={25} />
<h2>{instances.length ? 'Ничего не найдено' : 'Инстансов пока нет'}</h2>
<p>{instances.length ? 'Измените поиск или фильтр.' : 'Добавьте первый Telegram-аккаунт.'}</p>
{#if !instances.length}
<button class="primary" on:click={() => (addOpen = true)}>
<Plus size={16} />Добавить аккаунт
</button>
{/if}
</div>
{/if}
</div>
</section>
</main>
<AddAccountDrawer open={addOpen} on:close={() => (addOpen = false)} on:created={(event) => created(event.detail)} />
<DetailsDrawer
instance={selected}
busy={busyId === selected?.instance_id}
on:close={() => (selected = null)}
on:action={(event) => runAction(event.detail.instance, event.detail.action)}
on:delete={(event) => deleteInstance(event.detail.instance, event.detail.deleteData)}
/>
@@ -0,0 +1,354 @@
<script lang="ts">
import { ArrowLeft, Check, Eye, EyeOff, LoaderCircle, X } from 'lucide-svelte';
import { createEventDispatcher } from 'svelte';
import { api } from '../lib/api';
import type { AccountDraft, Instance } from '../lib/types';
export let open = false;
type Method = 'phone' | 'session';
type AuthPhase = 'code' | 'password';
const emptyDraft = (): AccountDraft => ({
instance_id: '',
display_name: '',
api_id: '',
api_hash: '',
phone: '',
session_string: '',
resources: {
storage: '1Gi',
cpu_limit: '300m',
memory_limit: '1536Mi',
},
});
let draft = emptyDraft();
let step = 1;
let method: Method = 'phone';
let authPhase: AuthPhase = 'code';
let flowId = '';
let code = '';
let password = '';
let error = '';
let busy = false;
let showHash = false;
let showPassword = false;
const dispatch = createEventDispatcher<{ close: void; created: Instance }>();
function reset() {
draft = emptyDraft();
step = 1;
method = 'phone';
authPhase = 'code';
flowId = '';
code = '';
password = '';
error = '';
busy = false;
showHash = false;
showPassword = false;
}
function close() {
reset();
dispatch('close');
}
function validSettings() {
return (
/^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/.test(draft.instance_id) &&
draft.display_name.trim().length > 0 &&
Number(draft.api_id) > 0 &&
draft.api_hash.trim().length >= 16 &&
(method === 'phone'
? /^\+\d[\d\s()-]{6,}$/.test(draft.phone)
: draft.session_string.trim().length >= 32)
);
}
async function waitUntilReady(initial: Instance) {
let instance = initial;
for (let attempt = 0; attempt < 60 && instance.status === 'pending'; attempt += 1) {
await new Promise((resolve) => window.setTimeout(resolve, 2000));
instance = await api.instance(initial.instance_id);
if (instance.status === 'error') {
throw new Error(`Pod не запустился: ${instance.reason || 'неизвестная ошибка'}`);
}
}
return instance;
}
async function finishProvisioning(instance: Instance) {
step = 3;
const readyInstance = await waitUntilReady(instance);
dispatch('created', readyInstance);
close();
}
async function begin() {
error = '';
if (!validSettings()) {
error = 'Заполните обязательные поля и проверьте формат значений.';
return;
}
busy = true;
try {
if (method === 'session') {
step = 3;
const result = await api.stringSession(draft);
if (result.instance) {
await finishProvisioning(result.instance);
}
return;
}
const result = await api.startPhone(draft);
flowId = result.flow_id || '';
authPhase = 'code';
step = 2;
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Не удалось начать авторизацию';
step = 1;
} finally {
busy = false;
}
}
async function submitCode() {
if (!flowId || !code.trim()) return;
error = '';
busy = true;
try {
const result = await api.submitCode(flowId, code);
if (result.status === 'password_required') {
authPhase = 'password';
} else if (result.instance) {
await finishProvisioning(result.instance);
}
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Не удалось проверить код';
} finally {
busy = false;
}
}
async function submitPassword() {
if (!flowId || !password) return;
error = '';
busy = true;
try {
const result = await api.submitPassword(flowId, password);
if (result.instance) {
await finishProvisioning(result.instance);
}
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Не удалось проверить пароль';
} finally {
busy = false;
}
}
</script>
{#if open}
<div class="drawer-backdrop" on:click={close} aria-hidden="true"></div>
<aside class="drawer add-drawer" aria-label="Добавить аккаунт">
<header class="drawer-header">
<div>
<h2>Добавить аккаунт</h2>
<p>Новый Kubernetes-инстанс userbot</p>
</div>
<button class="icon-button borderless" aria-label="Закрыть" on:click={close}>
<X size={19} />
</button>
</header>
<ol class="steps" aria-label="Шаги создания">
<li class:active={step === 1} class:complete={step > 1}><span>1</span>Настройки</li>
<li class:active={step === 2} class:complete={step > 2}><span>2</span>Авторизация</li>
<li class:active={step === 3}><span>3</span>Создание</li>
</ol>
{#if step === 1}
<form class="drawer-form" on:submit|preventDefault={begin}>
<fieldset>
<legend>Инстанс</legend>
<label>
Имя
<input
bind:value={draft.instance_id}
placeholder="personal"
maxlength="40"
autocomplete="off"
required
/>
<small>Только a-z, 0-9 и дефис</small>
</label>
<label>
Отображаемое имя
<input bind:value={draft.display_name} placeholder="Personal" maxlength="80" required />
</label>
</fieldset>
<fieldset>
<legend>Telegram API</legend>
<div class="two-columns">
<label>
API ID
<input bind:value={draft.api_id} inputmode="numeric" placeholder="12345678" required />
</label>
<label>
API Hash
<span class="input-with-action">
<input
bind:value={draft.api_hash}
type={showHash ? 'text' : 'password'}
placeholder="0123456789abcdef"
required
/>
<button
type="button"
aria-label={showHash ? 'Скрыть API Hash' : 'Показать API Hash'}
on:click={() => (showHash = !showHash)}
>
{#if showHash}<EyeOff size={15} />{:else}<Eye size={15} />{/if}
</button>
</span>
</label>
</div>
</fieldset>
<fieldset>
<legend>Авторизация</legend>
<div class="segmented">
<button type="button" class:active={method === 'phone'} on:click={() => (method = 'phone')}>
По номеру
</button>
<button type="button" class:active={method === 'session'} on:click={() => (method = 'session')}>
StringSession
</button>
</div>
{#if method === 'phone'}
<label>
Номер телефона
<input bind:value={draft.phone} type="tel" placeholder="+421 900 123 456" required />
<small>Код придёт в Telegram. При включённой 2FA потребуется пароль.</small>
</label>
{:else}
<label>
StringSession
<textarea
bind:value={draft.session_string}
rows="3"
placeholder="Вставьте Pyrogram/Pyrofork StringSession"
required
></textarea>
</label>
{/if}
</fieldset>
<fieldset>
<legend>Ресурсы</legend>
<div class="three-columns">
<label>
Хранилище
<select bind:value={draft.resources.storage}>
<option value="1Gi">1 GiB</option>
<option value="2Gi">2 GiB</option>
<option value="5Gi">5 GiB</option>
</select>
</label>
<label>
CPU
<select bind:value={draft.resources.cpu_limit}>
<option value="200m">200m</option>
<option value="300m">300m</option>
<option value="500m">500m</option>
</select>
</label>
<label>
RAM
<select bind:value={draft.resources.memory_limit}>
<option value="1024Mi">1 GiB</option>
<option value="1536Mi">1.5 GiB</option>
<option value="2048Mi">2 GiB</option>
</select>
</label>
</div>
</fieldset>
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
<footer class="form-actions">
<button type="button" on:click={close}>Отмена</button>
<button class="primary" type="submit" disabled={busy}>
{#if busy}<LoaderCircle class="spin" size={16} />{/if}
{method === 'phone' ? 'Запросить код' : 'Создать инстанс'}
</button>
</footer>
</form>
{:else if step === 2}
<div class="auth-step">
<button class="back-link" on:click={() => (step = 1)}>
<ArrowLeft size={15} />Назад к настройкам
</button>
{#if authPhase === 'code'}
<h3>Введите код</h3>
<p>Код отправлен в Telegram на {draft.phone}.</p>
<form on:submit|preventDefault={submitCode}>
<label>
Код Telegram
<input
class="code-input"
bind:value={code}
inputmode="numeric"
autocomplete="one-time-code"
placeholder="12345"
/>
</label>
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
<button class="primary full" type="submit" disabled={busy || !code.trim()}>
{#if busy}<LoaderCircle class="spin" size={16} />{/if}
Авторизовать
</button>
</form>
{:else}
<h3>Пароль 2FA</h3>
<p>Для этого аккаунта включена двухэтапная аутентификация.</p>
<form on:submit|preventDefault={submitPassword}>
<label>
Пароль
<span class="input-with-action">
<input
bind:value={password}
type={showPassword ? 'text' : 'password'}
autocomplete="current-password"
/>
<button
type="button"
aria-label={showPassword ? 'Скрыть пароль' : 'Показать пароль'}
on:click={() => (showPassword = !showPassword)}
>
{#if showPassword}<EyeOff size={15} />{:else}<Eye size={15} />{/if}
</button>
</span>
</label>
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
<button class="primary full" type="submit" disabled={busy || !password}>
{#if busy}<LoaderCircle class="spin" size={16} />{/if}
Продолжить
</button>
</form>
{/if}
</div>
{:else}
<div class="creating-state">
<LoaderCircle class="spin" size={28} />
<h3>Создаём инстанс</h3>
<p>Secret, PVC и Deployment созданы. Ждём Ready в namespace userbot.</p>
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
</div>
{/if}
</aside>
{/if}
@@ -0,0 +1,204 @@
<script lang="ts">
import {
Copy,
FileTerminal,
Play,
RotateCw,
Square,
Trash2,
X,
} from 'lucide-svelte';
import { createEventDispatcher } from 'svelte';
import { api } from '../lib/api';
import type { Instance } from '../lib/types';
import StatusDot from './StatusDot.svelte';
export let instance: Instance | null = null;
export let busy = false;
let logs = '';
let logsLoading = false;
let logsOpen = false;
let deleteOpen = false;
let deleteData = false;
let confirmation = '';
let activeInstance = '';
const dispatch = createEventDispatcher<{
close: void;
action: { instance: Instance; action: 'start' | 'stop' | 'restart' };
delete: { instance: Instance; deleteData: boolean };
}>();
$: instanceKey = instance ? `${instance.namespace}/${instance.instance_id}` : '';
$: if (instanceKey !== activeInstance) {
activeInstance = instanceKey;
logs = '';
logsOpen = false;
deleteOpen = false;
deleteData = false;
confirmation = '';
}
async function loadLogs() {
if (!instance) return;
logsOpen = true;
logsLoading = true;
try {
logs = (await api.logs(instance.instance_id)).logs;
} catch (error) {
logs = error instanceof Error ? error.message : 'Не удалось загрузить логи';
} finally {
logsLoading = false;
}
}
function copy(value: string | null) {
if (value) navigator.clipboard.writeText(value);
}
</script>
{#if instance}
<div class="drawer-backdrop" on:click={() => dispatch('close')} aria-hidden="true"></div>
<aside class="drawer details-drawer" aria-label={`Инстанс ${instance.display_name}`}>
<header class="drawer-header">
<div>
<h2>{instance.display_name}</h2>
<StatusDot status={instance.status} />
</div>
<button class="icon-button borderless" aria-label="Закрыть" on:click={() => dispatch('close')}>
<X size={19} />
</button>
</header>
<div class="drawer-scroll">
<section>
<h3>Ресурсы</h3>
<div class="resource-grid">
<div class="resource">
<span>CPU</span>
<strong>{instance.cpu_usage || '—'} <small>/ {instance.cpu_limit || '—'}</small></strong>
</div>
<div class="resource">
<span>RAM</span>
<strong>{instance.memory_usage || '—'} <small>/ {instance.memory_limit || '—'}</small></strong>
</div>
<div class="resource">
<span>Рестарты</span>
<strong>{instance.restarts}</strong>
</div>
<div class="resource">
<span>Хранилище</span>
<strong>{instance.storage || '—'}</strong>
</div>
</div>
</section>
<section>
<h3>Kubernetes</h3>
<dl class="metadata-list">
<div>
<dt>Namespace</dt>
<dd>{instance.namespace}</dd>
</div>
<div>
<dt>Deployment</dt>
<dd>
<span>{instance.deployment}</span>
<button class="copy-button" aria-label="Копировать Deployment" on:click={() => copy(instance?.deployment)}>
<Copy size={14} />
</button>
</dd>
</div>
<div>
<dt>Pod</dt>
<dd>
<span>{instance.pod || 'Нет pod'}</span>
{#if instance.pod}
<button class="copy-button" aria-label="Копировать Pod" on:click={() => copy(instance?.pod)}>
<Copy size={14} />
</button>
{/if}
</dd>
</div>
<div>
<dt>PVC</dt>
<dd>{instance.pvc || '—'}</dd>
</div>
<div>
<dt>Image</dt>
<dd class="wrap">{instance.image}</dd>
</div>
</dl>
</section>
{#if logsOpen}
<section class="logs-section">
<div class="section-title">
<h3>Логи</h3>
<button class="text-button" on:click={loadLogs}>Обновить</button>
</div>
<pre>{logsLoading ? 'Загрузка…' : logs}</pre>
</section>
{/if}
{#if deleteOpen}
<section class="danger-zone">
<h3>Удалить инстанс</h3>
<p>Deployment и credentials Secret будут удалены. PVC по умолчанию останется.</p>
<label>
Введите <strong>{instance.instance_id}</strong>
<input bind:value={confirmation} autocomplete="off" />
</label>
<label class="checkbox-row">
<input type="checkbox" bind:checked={deleteData} />
Удалить PVC и все данные
</label>
<button
class="danger-button"
disabled={confirmation !== instance.instance_id || busy}
on:click={() => dispatch('delete', { instance, deleteData })}
>
Подтвердить удаление
</button>
</section>
{/if}
</div>
<footer class="drawer-actions">
<button on:click={loadLogs}><FileTerminal size={16} />Логи</button>
<button
disabled={busy || instance.status === 'stopped'}
on:click={() => dispatch('action', { instance, action: 'restart' })}
>
<RotateCw size={16} />Перезапустить
</button>
{#if instance.status === 'stopped'}
<button
class="primary"
disabled={busy}
on:click={() => dispatch('action', { instance, action: 'start' })}
>
<Play size={16} />Запустить
</button>
{:else}
<button
class="primary"
disabled={busy}
on:click={() => dispatch('action', { instance, action: 'stop' })}
>
<Square size={15} />Остановить
</button>
{/if}
<button
class="delete-trigger"
disabled={!instance.deletable}
title={instance.deletable ? 'Удалить инстанс' : 'Legacy-инстансы удаляются из Git'}
on:click={() => (deleteOpen = !deleteOpen)}
>
<Trash2 size={16} />Удалить
</button>
</footer>
</aside>
{/if}
@@ -0,0 +1,114 @@
<script lang="ts">
import { FileTerminal, MoreVertical, Play, RotateCw, Square } from 'lucide-svelte';
import { createEventDispatcher } from 'svelte';
import { relativeTime } from '../lib/instances';
import type { Instance } from '../lib/types';
import StatusDot from './StatusDot.svelte';
export let instances: Instance[] = [];
export let busyId = '';
const dispatch = createEventDispatcher<{
select: Instance;
action: { instance: Instance; action: 'start' | 'stop' | 'restart' | 'logs' };
}>();
function action(
event: MouseEvent,
instance: Instance,
name: 'start' | 'stop' | 'restart' | 'logs',
) {
event.stopPropagation();
dispatch('action', { instance, action: name });
}
</script>
<div class="table-frame">
<div class="table-head table-grid" aria-hidden="true">
<span>ИНСТАНС</span>
<span>СТАТУС</span>
<span>POD</span>
<span>РЕСТАРТЫ</span>
<span>ХРАНИЛИЩЕ</span>
<span>ОБНОВЛЁН</span>
<span class="right">ДЕЙСТВИЯ</span>
</div>
{#each instances as instance (instance.namespace + instance.instance_id)}
<div
class="instance-row table-grid"
class:error-row={instance.status === 'error'}
on:click={() => dispatch('select', instance)}
on:keydown={(event) => {
if (event.key === 'Enter' || event.key === ' ') dispatch('select', instance);
}}
role="button"
tabindex="0"
aria-label={`Открыть ${instance.display_name}`}
>
<span class="instance-name">
<strong>{instance.display_name}</strong>
<small>{instance.instance_id} · {instance.namespace}</small>
</span>
<StatusDot status={instance.status} />
<span class="pod-name" title={instance.pod || instance.reason || ''}>
{instance.pod || instance.reason || 'Нет pod'}
</span>
<span>{instance.restarts}</span>
<span>{instance.storage || '—'}</span>
<span>{relativeTime(instance.updated_at)}</span>
<span class="row-actions">
<button
class="icon-button"
title="Логи"
aria-label={`Логи ${instance.display_name}`}
on:click={(event) => action(event, instance, 'logs')}
>
<FileTerminal size={16} />
</button>
<button
class="icon-button"
title="Перезапустить"
aria-label={`Перезапустить ${instance.display_name}`}
disabled={busyId === instance.instance_id || instance.status === 'stopped'}
on:click={(event) => action(event, instance, 'restart')}
>
<RotateCw size={16} />
</button>
{#if instance.status === 'stopped'}
<button
class="icon-button"
title="Запустить"
aria-label={`Запустить ${instance.display_name}`}
disabled={busyId === instance.instance_id}
on:click={(event) => action(event, instance, 'start')}
>
<Play size={16} />
</button>
{:else}
<button
class="icon-button"
title="Остановить"
aria-label={`Остановить ${instance.display_name}`}
disabled={busyId === instance.instance_id}
on:click={(event) => action(event, instance, 'stop')}
>
<Square size={15} />
</button>
{/if}
<button
class="icon-button"
title="Подробнее"
aria-label={`Подробнее ${instance.display_name}`}
on:click={(event) => {
event.stopPropagation();
dispatch('select', instance);
}}
>
<MoreVertical size={16} />
</button>
</span>
</div>
{/each}
</div>
@@ -0,0 +1,39 @@
<script lang="ts">
import { Bot, Box, Plus, X } from 'lucide-svelte';
import { createEventDispatcher } from 'svelte';
export let open = false;
const dispatch = createEventDispatcher<{ add: void; close: void }>();
</script>
<aside class:open aria-label="Навигация">
<div class="brand">
<Bot size={31} strokeWidth={1.8} />
<div>
<strong>USERBOT</strong>
<span>Kubernetes Control</span>
</div>
<button class="mobile-close" aria-label="Закрыть меню" on:click={() => dispatch('close')}>
<X size={18} />
</button>
</div>
<nav>
<button class="nav-item active" on:click={() => dispatch('close')}>
<Box size={18} />
Инстансы
</button>
<button class="nav-item" on:click={() => dispatch('add')}>
<Plus size={19} />
Добавить аккаунт
</button>
</nav>
<div class="cluster-state">
<span class="cluster-dot"></span>
<div>
<strong>Кластер доступен</strong>
<span>namespace: userbot</span>
</div>
</div>
</aside>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { InstanceStatus } from '../lib/types';
export let status: InstanceStatus;
const labels: Record<InstanceStatus, string> = {
running: 'Работает',
stopped: 'Остановлен',
pending: 'Запускается',
error: 'Ошибка',
};
</script>
<span class="status status-{status}">
<span class="status-dot" aria-hidden="true"></span>
{labels[status]}
</span>
+76
View File
@@ -0,0 +1,76 @@
import type { AccountDraft, AuthResult, Instance } from "./types";
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, init);
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload.detail || response.statusText || "Request failed");
}
if (response.status === 204) return undefined as T;
return response.json();
}
const json = (body: unknown): RequestInit => ({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
export const api = {
instances(query = "", status = "") {
const params = new URLSearchParams();
if (query) params.set("query", query);
if (status) params.set("status", status);
const suffix = params.size ? `?${params}` : "";
return request<Instance[]>(`/api/instances${suffix}`);
},
instance(id: string) {
return request<Instance>(`/api/instances/${encodeURIComponent(id)}`);
},
logs(id: string) {
return request<{ logs: string }>(`/api/instances/${encodeURIComponent(id)}/logs`);
},
action(id: string, action: "start" | "stop" | "restart") {
return request<Instance>(`/api/instances/${encodeURIComponent(id)}/${action}`, { method: "POST" });
},
delete(id: string, deleteData: boolean) {
return request<void>(
`/api/instances/${encodeURIComponent(id)}/delete`,
json({ confirmation: id, delete_data: deleteData }),
);
},
startPhone(draft: AccountDraft) {
return request<AuthResult>(
"/api/auth/phone/start",
json({
...basePayload(draft),
phone: draft.phone,
}),
);
},
submitCode(flowId: string, code: string) {
return request<AuthResult>(`/api/auth/phone/${encodeURIComponent(flowId)}/code`, json({ code }));
},
submitPassword(flowId: string, password: string) {
return request<AuthResult>(`/api/auth/phone/${encodeURIComponent(flowId)}/password`, json({ password }));
},
stringSession(draft: AccountDraft) {
return request<AuthResult>(
"/api/auth/string-session",
json({
...basePayload(draft),
session_string: draft.session_string,
}),
);
},
};
function basePayload(draft: AccountDraft) {
return {
instance_id: draft.instance_id,
display_name: draft.display_name,
api_id: Number(draft.api_id),
api_hash: draft.api_hash,
resources: draft.resources,
};
}
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { filterInstances, relativeTime } from "./instances";
import type { Instance } from "./types";
const instance = (overrides: Partial<Instance>): Instance => ({
instance_id: "forust",
display_name: "Forust",
namespace: "userbot",
deployment: "userbot-forust",
pod: "userbot-forust-abc",
status: "running",
reason: null,
ready: true,
restarts: 0,
pvc: "userbot-forust-data",
storage: "1Gi",
image: "userbot:latest",
cpu_limit: "300m",
memory_limit: "1536Mi",
cpu_usage: null,
memory_usage: null,
updated_at: null,
legacy: false,
deletable: true,
...overrides,
});
describe("filterInstances", () => {
it("filters by status and searchable fields", () => {
const items = [
instance({ instance_id: "forust" }),
instance({ instance_id: "anna", display_name: "Anna", status: "stopped" }),
];
expect(filterInstances(items, "ann", "stopped").map((item) => item.instance_id)).toEqual(["anna"]);
});
});
describe("relativeTime", () => {
it("formats recent timestamps", () => {
expect(relativeTime("2026-01-01T00:00:00Z", Date.parse("2026-01-01T00:00:45Z"))).toBe("45 сек назад");
});
});
+24
View File
@@ -0,0 +1,24 @@
import type { Instance } from "./types";
export function filterInstances(instances: Instance[], query: string, status: string): Instance[] {
const normalized = query.trim().toLowerCase();
return instances.filter((instance) => {
const matchesStatus = !status || instance.status === status;
const matchesQuery =
!normalized ||
instance.instance_id.toLowerCase().includes(normalized) ||
instance.display_name.toLowerCase().includes(normalized) ||
(instance.pod || "").toLowerCase().includes(normalized);
return matchesStatus && matchesQuery;
});
}
export function relativeTime(value: string | null, now = Date.now()): string {
if (!value) return "—";
const seconds = Math.max(0, Math.round((now - new Date(value).getTime()) / 1000));
if (seconds < 60) return `${seconds} сек назад`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} мин назад`;
const hours = Math.floor(minutes / 60);
return `${hours} ч назад`;
}
+45
View File
@@ -0,0 +1,45 @@
export type InstanceStatus = "running" | "stopped" | "pending" | "error";
export interface Instance {
instance_id: string;
display_name: string;
namespace: string;
deployment: string;
pod: string | null;
status: InstanceStatus;
reason: string | null;
ready: boolean;
restarts: number;
pvc: string | null;
storage: string | null;
image: string;
cpu_limit: string | null;
memory_limit: string | null;
cpu_usage: string | null;
memory_usage: string | null;
updated_at: string | null;
legacy: boolean;
deletable: boolean;
}
export interface Resources {
storage: string;
cpu_limit: string;
memory_limit: string;
}
export interface AccountDraft {
instance_id: string;
display_name: string;
api_id: string;
api_hash: string;
phone: string;
session_string: string;
resources: Resources;
}
export interface AuthResult {
status: "code_required" | "password_required" | "ready";
flow_id?: string;
instance?: Instance;
}
+8
View File
@@ -0,0 +1,8 @@
import { mount } from "svelte";
import App from "./App.svelte";
import "./styles.css";
mount(App, {
target: document.getElementById("app")!,
});
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";
+5
View File
@@ -0,0 +1,5 @@
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
export default {
preprocess: vitePreprocess(),
};
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"target": "ES2022",
"module": "ESNext",
"types": ["svelte", "vite/client", "vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
import { svelte } from "@sveltejs/vite-plugin-svelte";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [svelte()],
build: {
outDir: "dist",
emptyOutDir: true,
},
server: {
proxy: {
"/api": "http://127.0.0.1:8080",
},
},
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
},
});