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:
@@ -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>
|
||||
Generated
+3970
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 сек назад");
|
||||
});
|
||||
});
|
||||
@@ -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} ч назад`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
@@ -0,0 +1,5 @@
|
||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./src/test/setup.ts"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user