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
+1 -3
View File
@@ -3,7 +3,5 @@ kind: Kustomization
resources: resources:
- userbots.yaml - userbots.yaml
- common-secret.yaml - panel.yaml
- common-config.yaml - common-config.yaml
- forust-secrets.yaml
- anna-secrets.yaml
+264
View File
@@ -0,0 +1,264 @@
apiVersion: v1
kind: Namespace
metadata:
name: userbot
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: userbot-panel
namespace: userbot
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: userbot-runtime
namespace: userbot
automountServiceAccountToken: false
---
apiVersion: v1
kind: ConfigMap
metadata:
name: userbot-common-config
namespace: userbot
data:
DATABASE_TYPE: ""
DATABASE_NAME: ""
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: userbot-panel
namespace: userbot
rules:
- apiGroups:
- apps
resources:
- deployments
verbs:
- get
- list
- watch
- create
- patch
- delete
- apiGroups:
- apps
resources:
- deployments/scale
verbs:
- get
- patch
- update
- apiGroups:
- ""
resources:
- pods
verbs:
- get
- list
- watch
- apiGroups:
- ""
resources:
- pods/log
verbs:
- get
- apiGroups:
- ""
resources:
- persistentvolumeclaims
verbs:
- get
- list
- watch
- create
- delete
- apiGroups:
- ""
resources:
- secrets
verbs:
- get
- create
- delete
- apiGroups:
- ""
resources:
- configmaps
verbs:
- get
- apiGroups:
- metrics.k8s.io
resources:
- pods
verbs:
- get
- list
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: userbot-panel
namespace: userbot
subjects:
- kind: ServiceAccount
name: userbot-panel
namespace: userbot
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: userbot-panel
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: userbot-panel-legacy
namespace: default
rules:
- apiGroups:
- apps
resources:
- deployments
verbs:
- get
- list
- watch
- patch
- apiGroups:
- apps
resources:
- deployments/scale
verbs:
- get
- patch
- update
- apiGroups:
- ""
resources:
- pods
verbs:
- get
- list
- watch
- apiGroups:
- ""
resources:
- pods/log
verbs:
- get
- apiGroups:
- ""
resources:
- persistentvolumeclaims
verbs:
- get
- list
- apiGroups:
- metrics.k8s.io
resources:
- pods
verbs:
- get
- list
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: userbot-panel-legacy
namespace: default
subjects:
- kind: ServiceAccount
name: userbot-panel
namespace: userbot
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: userbot-panel-legacy
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: userbot-panel
namespace: userbot
labels:
app: userbot-panel
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: userbot-panel
template:
metadata:
labels:
app: userbot-panel
spec:
serviceAccountName: userbot-panel
containers:
- name: userbot-panel
image: gcr.forust.xyz/forust/userbot-panel:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 8080
env:
- name: USERBOT_NAMESPACE
value: userbot
- name: USERBOT_LEGACY_NAMESPACES
value: default
- name: USERBOT_IMAGE
value: gcr.forust.xyz/forust/userbot:latest
- name: USERBOT_STORAGE_CLASS
value: local-path-retain
- name: USERBOT_DOWNLOADS_HOST_PATH
value: /srv/homelab/userbot/Downloads
resources:
requests:
cpu: 20m
memory: 96Mi
limits:
cpu: 300m
memory: 384Mi
readinessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 3
periodSeconds: 10
livenessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 10
periodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
name: userbot-panel
namespace: userbot
spec:
type: ClusterIP
selector:
app: userbot-panel
ports:
- name: http
port: 8080
targetPort: http
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: userbot-panel-route
namespace: userbot
spec:
entryPoints:
- websecure
routes:
- match: Host(`userbot.workstation.internal`)
kind: Rule
services:
- name: userbot-panel
port: 8080
+30 -2
View File
@@ -4,8 +4,14 @@ metadata:
name: forust-userbot-deployment name: forust-userbot-deployment
labels: labels:
app: forust-userbot app: forust-userbot
app.kubernetes.io/name: userbot
app.kubernetes.io/instance: forust
annotations:
userbot.forust.xyz/display-name: forust
userbot.forust.xyz/legacy: "true"
userbot.forust.xyz/credentials-secret: userbot-forust-secrets
userbot.forust.xyz/pvc: forust-pvc
spec: spec:
replicas: 1
selector: selector:
matchLabels: matchLabels:
app: forust-userbot app: forust-userbot
@@ -13,6 +19,8 @@ spec:
metadata: metadata:
labels: labels:
app: forust-userbot app: forust-userbot
annotations:
kubectl.kubernetes.io/restartedAt: "2026-07-25T20:24:51+02:00"
spec: spec:
containers: containers:
- name: forust-userbot - name: forust-userbot
@@ -33,6 +41,8 @@ spec:
- secretRef: - secretRef:
name: userbot-forust-secrets name: userbot-forust-secrets
volumeMounts: volumeMounts:
- name: downloads
mountPath: /app/downloads
- name: forust-storage - name: forust-storage
mountPath: /app/data mountPath: /app/data
subPath: data subPath: data
@@ -40,6 +50,10 @@ spec:
mountPath: /app/logs mountPath: /app/logs
subPath: logs subPath: logs
volumes: volumes:
- name: downloads
hostPath:
path: /srv/homelab/userbot/Downloads
type: DirectoryOrCreate
- name: forust-storage - name: forust-storage
persistentVolumeClaim: persistentVolumeClaim:
claimName: forust-pvc claimName: forust-pvc
@@ -62,8 +76,14 @@ metadata:
name: anna-userbot-deployment name: anna-userbot-deployment
labels: labels:
app: anna-userbot app: anna-userbot
app.kubernetes.io/name: userbot
app.kubernetes.io/instance: anna
annotations:
userbot.forust.xyz/display-name: anna
userbot.forust.xyz/legacy: "true"
userbot.forust.xyz/credentials-secret: userbot-anna-secrets
userbot.forust.xyz/pvc: anna-pvc
spec: spec:
replicas: 1
selector: selector:
matchLabels: matchLabels:
app: anna-userbot app: anna-userbot
@@ -71,6 +91,8 @@ spec:
metadata: metadata:
labels: labels:
app: anna-userbot app: anna-userbot
annotations:
kubectl.kubernetes.io/restartedAt: "2026-07-25T20:24:51+02:00"
spec: spec:
containers: containers:
- name: anna-userbot - name: anna-userbot
@@ -91,6 +113,8 @@ spec:
- secretRef: - secretRef:
name: userbot-anna-secrets name: userbot-anna-secrets
volumeMounts: volumeMounts:
- name: downloads
mountPath: /app/downloads
- name: anna-storage - name: anna-storage
mountPath: /app/data mountPath: /app/data
subPath: data subPath: data
@@ -98,6 +122,10 @@ spec:
mountPath: /app/logs mountPath: /app/logs
subPath: logs subPath: logs
volumes: volumes:
- name: downloads
hostPath:
path: /srv/homelab/userbot/Downloads
type: DirectoryOrCreate
- name: anna-storage - name: anna-storage
persistentVolumeClaim: persistentVolumeClaim:
claimName: anna-pvc claimName: anna-pvc
+7
View File
@@ -0,0 +1,7 @@
backend/.venv
frontend/node_modules
frontend/dist
__pycache__
.pytest_cache
.ruff_cache
*.pyc
+7
View File
@@ -0,0 +1,7 @@
backend/.venv/
frontend/node_modules/
frontend/dist/
.pytest_cache/
.ruff_cache/
__pycache__/
*.pyc
+32
View File
@@ -0,0 +1,32 @@
FROM node:22-alpine AS frontend
WORKDIR /src
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM python:3.13-slim AS python-builder
WORKDIR /build
COPY backend/requirements.txt ./
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& pip install --no-cache-dir --prefix=/install -r requirements.txt \
&& rm -rf /var/lib/apt/lists/*
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PANEL_STATIC_DIR=/app/static
WORKDIR /app
COPY --from=python-builder /install /usr/local
COPY backend/app ./app
COPY --from=frontend /src/dist ./static
USER 65532:65532
EXPOSE 8080
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
+43
View File
@@ -0,0 +1,43 @@
# Userbot Control Panel
Локальная панель управляет userbot-инстансами напрямую через Kubernetes API.
Новые аккаунты создаются в namespace `userbot`; существующие `forust` и `anna`
остаются в `default` и принимаются под управление только через metadata labels.
## Первый запуск
Скопировать общий Secret в новый namespace, не печатая его значения:
```bash
kubectl create namespace userbot --dry-run=client -o yaml | kubectl apply -f -
kubectl get secret userbot-common-secrets -n default -o json \
| jq 'del(.metadata.annotations,.metadata.creationTimestamp,.metadata.resourceVersion,.metadata.uid,.metadata.managedFields) | .metadata.namespace = "userbot"' \
| kubectl apply -f -
```
Затем применить манифесты:
```bash
kubectl apply -f userbot/k8s/base/panel.yaml
kubectl apply -f userbot/k8s/base/userbots.yaml
kubectl rollout status deployment/userbot-panel -n userbot
```
Панель доступна только через внутренний Traefik route:
`https://userbot.workstation.internal`.
## Локальная разработка
```bash
cd userbot/panel/frontend
npm install
npm run dev
```
Backend использует in-cluster config, а вне кластера — текущий kubeconfig:
```bash
cd userbot/panel/backend
uv run --with-requirements requirements-dev.txt \
uvicorn app.main:app --reload --port 8080
```
+1
View File
@@ -0,0 +1 @@
"""Userbot Kubernetes control panel backend."""
+202
View File
@@ -0,0 +1,202 @@
from __future__ import annotations
import asyncio
import secrets
from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pyrogram import Client
from pyrogram.errors import (
ApiIdInvalid,
FloodWait,
PasswordHashInvalid,
PhoneCodeExpired,
PhoneCodeInvalid,
PhoneNumberInvalid,
RPCError,
SessionPasswordNeeded,
Unauthorized,
)
from .errors import PanelError
from .models import AccountBase, PhoneStart, StringSessionStart
@dataclass
class AuthFlow:
flow_id: str
account: PhoneStart
client: Client
phone_code_hash: str
expires_at: datetime
@dataclass
class AuthorizedAccount:
account: AccountBase
session_string: str
class TelegramAuthService:
def __init__(self, ttl_seconds: int = 600) -> None:
self.ttl = timedelta(seconds=ttl_seconds)
self.flows: dict[str, AuthFlow] = {}
self._lock = asyncio.Lock()
async def start_phone(self, account: PhoneStart) -> str:
await self._cleanup_expired()
flow_id = secrets.token_urlsafe(24)
telegram = Client(
f"auth-{flow_id}",
api_id=account.api_id,
api_hash=account.api_hash,
in_memory=True,
no_updates=True,
)
try:
await telegram.connect()
sent = await telegram.send_code(account.phone)
except Exception as exc:
await self._disconnect(telegram)
raise self._translate(exc) from exc
flow = AuthFlow(
flow_id=flow_id,
account=account,
client=telegram,
phone_code_hash=sent.phone_code_hash,
expires_at=datetime.now(UTC) + self.ttl,
)
async with self._lock:
self.flows[flow_id] = flow
return flow_id
async def submit_code(
self,
flow_id: str,
code: str,
) -> AuthorizedAccount | None:
flow = await self._get(flow_id)
try:
await flow.client.sign_in(
flow.account.phone,
flow.phone_code_hash,
code,
)
except SessionPasswordNeeded:
return None
except PhoneCodeExpired as exc:
await self._discard(flow)
raise self._translate(exc) from exc
except Exception as exc:
raise self._translate(exc) from exc
return await self._finish(flow)
async def submit_password(
self,
flow_id: str,
password: str,
) -> AuthorizedAccount:
flow = await self._get(flow_id)
try:
await flow.client.check_password(password)
except Exception as exc:
raise self._translate(exc) from exc
return await self._finish(flow)
async def validate_string_session(
self,
account: StringSessionStart,
) -> AuthorizedAccount:
telegram = Client(
f"validate-{secrets.token_urlsafe(12)}",
api_id=account.api_id,
api_hash=account.api_hash,
session_string=account.session_string,
in_memory=True,
no_updates=True,
)
try:
await telegram.connect()
await telegram.get_me()
session_string = await telegram.export_session_string()
except Exception as exc:
raise self._translate(exc, session=True) from exc
finally:
await self._disconnect(telegram)
return AuthorizedAccount(account=account, session_string=session_string)
async def close(self) -> None:
async with self._lock:
flows = list(self.flows.values())
self.flows.clear()
await asyncio.gather(
*(self._disconnect(flow.client) for flow in flows),
return_exceptions=True,
)
async def _get(self, flow_id: str) -> AuthFlow:
await self._cleanup_expired()
async with self._lock:
flow = self.flows.get(flow_id)
if flow is None:
raise PanelError(410, "Authorization flow expired; start again")
return flow
async def _finish(self, flow: AuthFlow) -> AuthorizedAccount:
try:
await flow.client.get_me()
session_string = await flow.client.export_session_string()
except Exception as exc:
raise self._translate(exc) from exc
finally:
async with self._lock:
self.flows.pop(flow.flow_id, None)
await self._disconnect(flow.client)
return AuthorizedAccount(account=flow.account, session_string=session_string)
async def _discard(self, flow: AuthFlow) -> None:
async with self._lock:
self.flows.pop(flow.flow_id, None)
await self._disconnect(flow.client)
async def _cleanup_expired(self) -> None:
now = datetime.now(UTC)
async with self._lock:
expired = [
self.flows.pop(flow_id)
for flow_id, flow in list(self.flows.items())
if flow.expires_at <= now
]
if expired:
await asyncio.gather(
*(self._disconnect(flow.client) for flow in expired),
return_exceptions=True,
)
@staticmethod
async def _disconnect(telegram: Client) -> None:
with suppress(Exception):
if telegram.is_connected:
await telegram.disconnect()
@staticmethod
def _translate(exc: Exception, *, session: bool = False) -> PanelError:
if isinstance(exc, FloodWait):
return PanelError(429, f"Telegram rate limit; retry in {exc.value} seconds")
if isinstance(exc, ApiIdInvalid):
return PanelError(422, "Telegram API ID or API Hash is invalid")
if isinstance(exc, PhoneNumberInvalid):
return PanelError(422, "Phone number is invalid")
if isinstance(exc, PhoneCodeInvalid):
return PanelError(422, "Telegram code is invalid")
if isinstance(exc, PhoneCodeExpired):
return PanelError(410, "Telegram code expired; start again")
if isinstance(exc, PasswordHashInvalid):
return PanelError(422, "2FA password is invalid")
if session and isinstance(exc, (Unauthorized, RPCError)):
return PanelError(422, "StringSession is invalid or expired")
if isinstance(exc, RPCError):
return PanelError(422, "Telegram rejected the authorization request")
return PanelError(503, "Telegram authorization is unavailable")
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class Settings:
namespace: str = os.environ.get("USERBOT_NAMESPACE", "userbot")
legacy_namespaces: tuple[str, ...] = tuple(
value.strip()
for value in os.environ.get("USERBOT_LEGACY_NAMESPACES", "default").split(",")
if value.strip()
)
image: str = os.environ.get(
"USERBOT_IMAGE",
"gcr.forust.xyz/forust/userbot:latest",
)
common_secret: str = os.environ.get(
"USERBOT_COMMON_SECRET",
"userbot-common-secrets",
)
common_config: str = os.environ.get(
"USERBOT_COMMON_CONFIG",
"userbot-common-config",
)
storage_class: str = os.environ.get(
"USERBOT_STORAGE_CLASS",
"local-path-retain",
)
downloads_host_path: str = os.environ.get(
"USERBOT_DOWNLOADS_HOST_PATH",
"/srv/homelab/userbot/Downloads",
)
static_dir: Path = Path(os.environ.get("PANEL_STATIC_DIR", "/app/static"))
auth_ttl_seconds: int = int(os.environ.get("PANEL_AUTH_TTL_SECONDS", "600"))
default_storage: str = os.environ.get("USERBOT_DEFAULT_STORAGE", "1Gi")
default_cpu_limit: str = os.environ.get("USERBOT_DEFAULT_CPU_LIMIT", "300m")
default_memory_limit: str = os.environ.get(
"USERBOT_DEFAULT_MEMORY_LIMIT",
"1536Mi",
)
settings = Settings()
+5
View File
@@ -0,0 +1,5 @@
class PanelError(Exception):
def __init__(self, status_code: int, detail: str) -> None:
super().__init__(detail)
self.status_code = status_code
self.detail = detail
+522
View File
@@ -0,0 +1,522 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from kubernetes import client, config
from kubernetes.client.exceptions import ApiException
from .config import Settings
from .errors import PanelError
from .models import AccountBase, InstanceSummary
MANAGED_LABEL = "app.kubernetes.io/name=userbot"
INSTANCE_LABEL = "app.kubernetes.io/instance"
MANAGED_BY_LABEL = "app.kubernetes.io/managed-by"
DISPLAY_ANNOTATION = "userbot.forust.xyz/display-name"
LEGACY_ANNOTATION = "userbot.forust.xyz/legacy"
CREDENTIALS_ANNOTATION = "userbot.forust.xyz/credentials-secret"
PVC_ANNOTATION = "userbot.forust.xyz/pvc"
RESTART_ANNOTATION = "userbot.forust.xyz/restarted-at"
def _selector(labels: dict[str, str] | None) -> str:
return ",".join(f"{key}={value}" for key, value in (labels or {}).items())
def _as_datetime(value: Any) -> datetime | None:
if value is None:
return None
if isinstance(value, datetime):
return value
return getattr(value, "replace", lambda **_: None)(tzinfo=UTC)
class KubernetesService:
def __init__(
self,
settings: Settings,
*,
core: client.CoreV1Api | None = None,
apps: client.AppsV1Api | None = None,
custom: client.CustomObjectsApi | None = None,
) -> None:
self.settings = settings
if core is None or apps is None:
try:
config.load_incluster_config()
except config.ConfigException:
config.load_kube_config()
self.core = core or client.CoreV1Api()
self.apps = apps or client.AppsV1Api()
self.custom = custom or client.CustomObjectsApi()
def ensure_prerequisites(self) -> None:
try:
self.core.read_namespaced_secret(
self.settings.common_secret,
self.settings.namespace,
)
self.core.read_namespaced_config_map(
self.settings.common_config,
self.settings.namespace,
)
except ApiException as exc:
if exc.status == 404:
raise PanelError(
503,
"Userbot common Secret or ConfigMap is missing in the userbot namespace",
) from exc
raise self._api_error(exc, "Could not verify userbot prerequisites") from exc
def list_instances(
self,
query: str = "",
status: str = "",
) -> list[InstanceSummary]:
instances: list[InstanceSummary] = []
for namespace in (self.settings.namespace, *self.settings.legacy_namespaces):
try:
deployments = self.apps.list_namespaced_deployment(
namespace,
label_selector=MANAGED_LABEL,
).items
except ApiException as exc:
raise self._api_error(exc, f"Could not list Deployments in {namespace}") from exc
instances.extend(self._summarize(namespace, deployment) for deployment in deployments)
query = query.strip().lower()
if query:
instances = [
item
for item in instances
if query in item.instance_id.lower()
or query in item.display_name.lower()
or query in (item.pod or "").lower()
]
if status:
instances = [item for item in instances if item.status == status]
return sorted(instances, key=lambda item: (item.legacy, item.instance_id))
def get_instance(self, instance_id: str) -> InstanceSummary:
namespace, deployment = self._find_deployment(instance_id)
return self._summarize(namespace, deployment)
def assert_available(self, instance_id: str) -> None:
names = self._resource_names(instance_id)
checks = (
(self.apps.read_namespaced_deployment, names["deployment"], "Deployment"),
(self.core.read_namespaced_secret, names["secret"], "Secret"),
(self.core.read_namespaced_persistent_volume_claim, names["pvc"], "PVC"),
)
for read, name, kind in checks:
try:
read(name, self.settings.namespace)
except ApiException as exc:
if exc.status == 404:
continue
raise self._api_error(exc, f"Could not check {kind} {name}") from exc
raise PanelError(409, f"{kind} {name} already exists")
def provision(self, account: AccountBase, session_string: str) -> InstanceSummary:
self.ensure_prerequisites()
self.assert_available(account.instance_id)
names = self._resource_names(account.instance_id)
created: list[tuple[str, str]] = []
try:
self.core.create_namespaced_secret(
self.settings.namespace,
self._secret(account, session_string, names),
)
created.append(("secret", names["secret"]))
self.core.create_namespaced_persistent_volume_claim(
self.settings.namespace,
self._pvc(account, names),
)
created.append(("pvc", names["pvc"]))
self.apps.create_namespaced_deployment(
self.settings.namespace,
self._deployment(account, names),
)
created.append(("deployment", names["deployment"]))
except ApiException as exc:
self._rollback(created)
raise self._api_error(exc, "Could not create userbot instance") from exc
return self.get_instance(account.instance_id)
def scale(self, instance_id: str, replicas: int) -> InstanceSummary:
namespace, deployment = self._find_deployment(instance_id)
try:
self.apps.patch_namespaced_deployment_scale(
deployment.metadata.name,
namespace,
{"spec": {"replicas": replicas}},
)
except ApiException as exc:
raise self._api_error(exc, "Could not scale userbot instance") from exc
return self.get_instance(instance_id)
def restart(self, instance_id: str) -> InstanceSummary:
namespace, deployment = self._find_deployment(instance_id)
if (deployment.spec.replicas or 0) == 0:
raise PanelError(409, "Stopped instance cannot be restarted")
timestamp = datetime.now(UTC).isoformat()
try:
self.apps.patch_namespaced_deployment(
deployment.metadata.name,
namespace,
{
"spec": {
"template": {
"metadata": {
"annotations": {RESTART_ANNOTATION: timestamp},
}
}
}
},
)
except ApiException as exc:
raise self._api_error(exc, "Could not restart userbot instance") from exc
return self.get_instance(instance_id)
def logs(self, instance_id: str, tail: int = 250) -> str:
namespace, deployment = self._find_deployment(instance_id)
pods = self._pods_for_deployment(namespace, deployment)
if not pods:
raise PanelError(409, "Userbot Pod is not running")
pod = pods[0]
container = deployment.spec.template.spec.containers[0].name
try:
return self.core.read_namespaced_pod_log(
pod.metadata.name,
namespace,
container=container,
tail_lines=max(1, min(tail, 1000)),
timestamps=True,
)
except ApiException as exc:
raise self._api_error(exc, "Could not read userbot logs") from exc
def delete(self, instance_id: str, *, delete_data: bool) -> None:
namespace, deployment = self._find_deployment(instance_id)
annotations = deployment.metadata.annotations or {}
if annotations.get(LEGACY_ANNOTATION) == "true" or namespace != self.settings.namespace:
raise PanelError(409, "Legacy instances cannot be deleted from the panel")
names = self._resource_names(instance_id)
secret_name = annotations.get(CREDENTIALS_ANNOTATION, names["secret"])
pvc_name = annotations.get(PVC_ANNOTATION, names["pvc"])
operations = [
(
self.apps.delete_namespaced_deployment,
(deployment.metadata.name, namespace),
{"propagation_policy": "Foreground"},
),
(self.core.delete_namespaced_secret, (secret_name, namespace), {}),
]
if delete_data:
operations.append(
(
self.core.delete_namespaced_persistent_volume_claim,
(pvc_name, namespace),
{},
)
)
for delete_resource, args, kwargs in operations:
try:
delete_resource(*args, **kwargs)
except ApiException as exc:
if exc.status != 404:
raise self._api_error(exc, "Could not delete userbot instance") from exc
def _find_deployment(self, instance_id: str) -> tuple[str, Any]:
selector = f"{MANAGED_LABEL},{INSTANCE_LABEL}={instance_id}"
for namespace in (self.settings.namespace, *self.settings.legacy_namespaces):
try:
items = self.apps.list_namespaced_deployment(
namespace,
label_selector=selector,
).items
except ApiException as exc:
raise self._api_error(exc, "Could not find userbot instance") from exc
if items:
return namespace, items[0]
raise PanelError(404, f"Instance {instance_id} does not exist")
def _summarize(self, namespace: str, deployment: Any) -> InstanceSummary:
labels = deployment.metadata.labels or {}
annotations = deployment.metadata.annotations or {}
instance_id = labels.get(INSTANCE_LABEL, deployment.metadata.name)
legacy = annotations.get(LEGACY_ANNOTATION) == "true"
pods = self._pods_for_deployment(namespace, deployment)
pod = pods[0] if pods else None
desired = deployment.spec.replicas or 0
ready = False
reason: str | None = None
restarts = 0
updated_at = deployment.metadata.creation_timestamp
if pod is not None:
statuses = pod.status.container_statuses or []
ready = bool(statuses) and all(item.ready for item in statuses)
restarts = sum(item.restart_count for item in statuses)
for item in statuses:
state = item.state
if state and state.waiting and state.waiting.reason:
reason = state.waiting.reason
break
if state and state.terminated and state.terminated.reason:
reason = state.terminated.reason
break
updated_at = pod.status.start_time or pod.metadata.creation_timestamp
if desired == 0:
status = "stopped"
elif reason in {
"CrashLoopBackOff",
"Error",
"ImagePullBackOff",
"ErrImagePull",
"CreateContainerConfigError",
"RunContainerError",
} or (pod is not None and pod.status.phase == "Failed"):
status = "error"
elif ready and (deployment.status.available_replicas or 0) > 0:
status = "running"
else:
status = "pending"
reason = reason or (pod.status.phase if pod is not None else "Scheduling")
container_spec = deployment.spec.template.spec.containers[0]
limits = (container_spec.resources.limits or {}) if container_spec.resources else {}
pvc_name = annotations.get(PVC_ANNOTATION) or self._deployment_pvc(deployment)
storage = self._pvc_storage(namespace, pvc_name)
cpu_usage, memory_usage = self._pod_metrics(namespace, pod.metadata.name if pod else None)
return InstanceSummary(
instance_id=instance_id,
display_name=annotations.get(DISPLAY_ANNOTATION, instance_id),
namespace=namespace,
deployment=deployment.metadata.name,
pod=pod.metadata.name if pod else None,
status=status,
reason=reason,
ready=ready,
restarts=restarts,
pvc=pvc_name,
storage=storage,
image=container_spec.image,
cpu_limit=limits.get("cpu"),
memory_limit=limits.get("memory"),
cpu_usage=cpu_usage,
memory_usage=memory_usage,
updated_at=_as_datetime(updated_at),
legacy=legacy,
deletable=not legacy and namespace == self.settings.namespace,
)
def _pods_for_deployment(self, namespace: str, deployment: Any) -> list[Any]:
selector = _selector(deployment.spec.selector.match_labels)
try:
pods = self.core.list_namespaced_pod(
namespace,
label_selector=selector,
).items
except ApiException as exc:
raise self._api_error(exc, "Could not list userbot Pods") from exc
return sorted(
pods,
key=lambda pod: pod.metadata.creation_timestamp or datetime.min.replace(tzinfo=UTC),
reverse=True,
)
def _pod_metrics(self, namespace: str, pod_name: str | None) -> tuple[str | None, str | None]:
if not pod_name:
return None, None
try:
metrics = self.custom.get_namespaced_custom_object(
"metrics.k8s.io",
"v1beta1",
namespace,
"pods",
pod_name,
)
except (ApiException, AttributeError):
return None, None
containers = metrics.get("containers", [])
cpu = containers[0].get("usage", {}).get("cpu") if containers else None
memory = containers[0].get("usage", {}).get("memory") if containers else None
return cpu, memory
def _pvc_storage(self, namespace: str, pvc_name: str | None) -> str | None:
if not pvc_name:
return None
try:
pvc = self.core.read_namespaced_persistent_volume_claim(pvc_name, namespace)
except ApiException:
return None
requests = pvc.spec.resources.requests or {}
return requests.get("storage")
@staticmethod
def _deployment_pvc(deployment: Any) -> str | None:
for volume in deployment.spec.template.spec.volumes or []:
if volume.persistent_volume_claim:
return volume.persistent_volume_claim.claim_name
return None
@staticmethod
def _resource_names(instance_id: str) -> dict[str, str]:
return {
"deployment": f"userbot-{instance_id}",
"secret": f"userbot-{instance_id}-credentials",
"pvc": f"userbot-{instance_id}-data",
}
def _metadata(
self,
account: AccountBase,
names: dict[str, str],
resource_name: str,
) -> client.V1ObjectMeta:
return client.V1ObjectMeta(
name=resource_name,
namespace=self.settings.namespace,
labels={
"app.kubernetes.io/name": "userbot",
INSTANCE_LABEL: account.instance_id,
MANAGED_BY_LABEL: "userbot-panel",
},
annotations={
DISPLAY_ANNOTATION: account.display_name,
CREDENTIALS_ANNOTATION: names["secret"],
PVC_ANNOTATION: names["pvc"],
},
)
def _secret(
self,
account: AccountBase,
session_string: str,
names: dict[str, str],
) -> client.V1Secret:
return client.V1Secret(
metadata=self._metadata(account, names, names["secret"]),
type="Opaque",
string_data={
"API_ID": str(account.api_id),
"API_HASH": account.api_hash,
"STRINGSESSION": session_string,
},
)
def _pvc(
self,
account: AccountBase,
names: dict[str, str],
) -> client.V1PersistentVolumeClaim:
return client.V1PersistentVolumeClaim(
metadata=self._metadata(account, names, names["pvc"]),
spec=client.V1PersistentVolumeClaimSpec(
access_modes=["ReadWriteOnce"],
storage_class_name=self.settings.storage_class,
resources=client.V1VolumeResourceRequirements(
requests={"storage": account.resources.storage},
),
),
)
def _deployment(
self,
account: AccountBase,
names: dict[str, str],
) -> client.V1Deployment:
pod_labels = {
"app.kubernetes.io/name": "userbot",
INSTANCE_LABEL: account.instance_id,
MANAGED_BY_LABEL: "userbot-panel",
}
container = client.V1Container(
name="userbot",
image=self.settings.image,
image_pull_policy="Always",
env_from=[
client.V1EnvFromSource(
secret_ref=client.V1SecretEnvSource(name=self.settings.common_secret)
),
client.V1EnvFromSource(
config_map_ref=client.V1ConfigMapEnvSource(name=self.settings.common_config)
),
client.V1EnvFromSource(
secret_ref=client.V1SecretEnvSource(name=names["secret"])
),
],
resources=client.V1ResourceRequirements(
requests={"cpu": "80m", "memory": "512Mi"},
limits={
"cpu": account.resources.cpu_limit,
"memory": account.resources.memory_limit,
},
),
volume_mounts=[
client.V1VolumeMount(name="data", mount_path="/app/data"),
client.V1VolumeMount(name="downloads", mount_path="/app/downloads"),
],
)
pod_spec = client.V1PodSpec(
service_account_name="userbot-runtime",
automount_service_account_token=False,
containers=[container],
termination_grace_period_seconds=30,
volumes=[
client.V1Volume(
name="data",
persistent_volume_claim=client.V1PersistentVolumeClaimVolumeSource(
claim_name=names["pvc"]
),
),
client.V1Volume(
name="downloads",
host_path=client.V1HostPathVolumeSource(
path=self.settings.downloads_host_path,
type="DirectoryOrCreate",
),
),
],
)
return client.V1Deployment(
metadata=self._metadata(account, names, names["deployment"]),
spec=client.V1DeploymentSpec(
replicas=1,
strategy=client.V1DeploymentStrategy(type="Recreate"),
selector=client.V1LabelSelector(match_labels=pod_labels),
template=client.V1PodTemplateSpec(
metadata=client.V1ObjectMeta(labels=pod_labels),
spec=pod_spec,
),
),
)
def _rollback(self, created: list[tuple[str, str]]) -> None:
for kind, name in reversed(created):
try:
if kind == "deployment":
self.apps.delete_namespaced_deployment(name, self.settings.namespace)
elif kind == "pvc":
self.core.delete_namespaced_persistent_volume_claim(
name,
self.settings.namespace,
)
else:
self.core.delete_namespaced_secret(name, self.settings.namespace)
except ApiException:
pass
@staticmethod
def _api_error(exc: ApiException, detail: str) -> PanelError:
if exc.status == 403:
return PanelError(503, f"{detail}: Kubernetes RBAC denied the operation")
if exc.status == 409:
return PanelError(409, f"{detail}: resource conflict")
if exc.status == 404:
return PanelError(404, f"{detail}: resource not found")
return PanelError(503, detail)
+198
View File
@@ -0,0 +1,198 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import FastAPI, Query, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from .auth_service import TelegramAuthService
from .config import settings
from .errors import PanelError
from .kubernetes_service import KubernetesService
from .models import (
AuthResult,
CodeSubmit,
DeleteRequest,
InstanceSummary,
PasswordSubmit,
PhoneStart,
StringSessionStart,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.kubernetes = KubernetesService(settings)
app.state.telegram = TelegramAuthService(settings.auth_ttl_seconds)
yield
await app.state.telegram.close()
app = FastAPI(
title="Userbot Kubernetes Control",
version="1.0.0",
lifespan=lifespan,
)
@app.exception_handler(PanelError)
async def panel_error_handler(_request: Request, exc: PanelError) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
@app.exception_handler(RequestValidationError)
async def validation_error_handler(
_request: Request,
exc: RequestValidationError,
) -> JSONResponse:
errors = [
{
"loc": error.get("loc", ()),
"msg": error.get("msg", "Invalid value"),
"type": error.get("type", "value_error"),
}
for error in exc.errors()
]
return JSONResponse(status_code=422, content={"detail": errors})
def kube(request: Request) -> KubernetesService:
return request.app.state.kubernetes
def telegram(request: Request) -> TelegramAuthService:
return request.app.state.telegram
@app.get("/api/health")
def health(request: Request) -> dict[str, object]:
service = kube(request)
try:
service.apps.list_namespaced_deployment(
settings.namespace,
limit=1,
)
except Exception:
return {"ok": False, "kubernetes": False}
return {"ok": True, "kubernetes": True}
@app.get("/api/instances", response_model=list[InstanceSummary])
def list_instances(
request: Request,
query: str = "",
status: str = "",
) -> list[InstanceSummary]:
return kube(request).list_instances(query=query, status=status)
@app.get("/api/instances/{instance_id}", response_model=InstanceSummary)
def get_instance(instance_id: str, request: Request) -> InstanceSummary:
return kube(request).get_instance(instance_id)
@app.get("/api/instances/{instance_id}/logs")
def get_logs(
instance_id: str,
request: Request,
tail: Annotated[int, Query(ge=1, le=1000)] = 250,
) -> dict[str, str]:
return {"logs": kube(request).logs(instance_id, tail)}
@app.post("/api/instances/{instance_id}/start", response_model=InstanceSummary)
def start_instance(instance_id: str, request: Request) -> InstanceSummary:
return kube(request).scale(instance_id, 1)
@app.post("/api/instances/{instance_id}/stop", response_model=InstanceSummary)
def stop_instance(instance_id: str, request: Request) -> InstanceSummary:
return kube(request).scale(instance_id, 0)
@app.post("/api/instances/{instance_id}/restart", response_model=InstanceSummary)
def restart_instance(instance_id: str, request: Request) -> InstanceSummary:
return kube(request).restart(instance_id)
@app.post("/api/instances/{instance_id}/delete", status_code=204)
def delete_instance(
instance_id: str,
payload: DeleteRequest,
request: Request,
) -> Response:
if payload.confirmation != instance_id:
raise PanelError(422, "Type the instance id exactly to confirm deletion")
kube(request).delete(instance_id, delete_data=payload.delete_data)
return Response(status_code=204)
@app.post("/api/auth/phone/start", response_model=AuthResult)
async def auth_phone_start(
payload: PhoneStart,
request: Request,
) -> AuthResult:
service = kube(request)
service.ensure_prerequisites()
service.assert_available(payload.instance_id)
flow_id = await telegram(request).start_phone(payload)
return AuthResult(status="code_required", flow_id=flow_id)
@app.post("/api/auth/phone/{flow_id}/code", response_model=AuthResult)
async def auth_phone_code(
flow_id: str,
payload: CodeSubmit,
request: Request,
) -> AuthResult:
authorized = await telegram(request).submit_code(flow_id, payload.code)
if authorized is None:
return AuthResult(status="password_required", flow_id=flow_id)
instance = kube(request).provision(authorized.account, authorized.session_string)
return AuthResult(status="ready", instance=instance)
@app.post("/api/auth/phone/{flow_id}/password", response_model=AuthResult)
async def auth_phone_password(
flow_id: str,
payload: PasswordSubmit,
request: Request,
) -> AuthResult:
authorized = await telegram(request).submit_password(flow_id, payload.password)
instance = kube(request).provision(authorized.account, authorized.session_string)
return AuthResult(status="ready", instance=instance)
@app.post("/api/auth/string-session", response_model=AuthResult)
async def auth_string_session(
payload: StringSessionStart,
request: Request,
) -> AuthResult:
service = kube(request)
service.ensure_prerequisites()
service.assert_available(payload.instance_id)
authorized = await telegram(request).validate_string_session(payload)
instance = service.provision(authorized.account, authorized.session_string)
return AuthResult(status="ready", instance=instance)
static_dir = settings.static_dir
assets_dir = static_dir / "assets"
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
@app.get("/", include_in_schema=False)
def index() -> FileResponse:
return FileResponse(static_dir / "index.html")
@app.get("/{path:path}", include_in_schema=False)
def spa_fallback(path: str) -> FileResponse:
candidate = (static_dir / path).resolve()
if candidate.is_file() and static_dir.resolve() in candidate.parents:
return FileResponse(candidate)
return FileResponse(static_dir / "index.html")
+89
View File
@@ -0,0 +1,89 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field, field_validator
INSTANCE_PATTERN = r"^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$"
class Resources(BaseModel):
storage: str = "1Gi"
cpu_limit: str = "300m"
memory_limit: str = "1536Mi"
class AccountBase(BaseModel):
instance_id: str = Field(pattern=INSTANCE_PATTERN, max_length=40)
display_name: str = Field(min_length=1, max_length=80)
api_id: int = Field(gt=0)
api_hash: str = Field(min_length=16, max_length=128)
resources: Resources = Field(default_factory=Resources)
@field_validator("display_name", "api_hash")
@classmethod
def strip_text(cls, value: str) -> str:
return value.strip()
class PhoneStart(AccountBase):
phone: str = Field(min_length=7, max_length=32)
@field_validator("phone")
@classmethod
def normalize_phone(cls, value: str) -> str:
value = value.strip()
if not value.startswith("+"):
raise ValueError("phone must use international format")
return value
class StringSessionStart(AccountBase):
session_string: str = Field(min_length=32)
class CodeSubmit(BaseModel):
code: str = Field(min_length=3, max_length=12)
@field_validator("code")
@classmethod
def normalize_code(cls, value: str) -> str:
return "".join(value.split())
class PasswordSubmit(BaseModel):
password: str = Field(min_length=1, max_length=256)
class DeleteRequest(BaseModel):
confirmation: str
delete_data: bool = False
class InstanceSummary(BaseModel):
instance_id: str
display_name: str
namespace: str
deployment: str
pod: str | None = None
status: Literal["running", "stopped", "pending", "error"]
reason: str | None = None
ready: bool = False
restarts: int = 0
pvc: str | None = None
storage: str | None = None
image: str
cpu_limit: str | None = None
memory_limit: str | None = None
cpu_usage: str | None = None
memory_usage: str | None = None
updated_at: datetime | None = None
legacy: bool = False
deletable: bool = True
class AuthResult(BaseModel):
status: Literal["code_required", "password_required", "ready"]
flow_id: str | None = None
instance: InstanceSummary | None = None
+4
View File
@@ -0,0 +1,4 @@
-r requirements.txt
httpx==0.28.1
pytest==8.4.1
pytest-asyncio==1.1.0
+5
View File
@@ -0,0 +1,5 @@
fastapi==0.115.12
kubernetes==33.1.0
pyrofork==2.3.68
tgcrypto==1.2.5
uvicorn[standard]==0.34.2
+84
View File
@@ -0,0 +1,84 @@
from types import SimpleNamespace
import pytest
from app.auth_service import TelegramAuthService
from app.models import PhoneStart, StringSessionStart
class FakeClient:
instances = []
requires_password = False
def __init__(self, *_args, **kwargs):
self.kwargs = kwargs
self.is_connected = False
self.disconnected = False
self.__class__.instances.append(self)
async def connect(self):
self.is_connected = True
async def disconnect(self):
self.is_connected = False
self.disconnected = True
async def send_code(self, _phone):
return SimpleNamespace(phone_code_hash="hash")
async def sign_in(self, *_args):
if self.requires_password:
from pyrogram.errors import SessionPasswordNeeded
raise SessionPasswordNeeded()
async def check_password(self, _password):
return None
async def get_me(self):
return SimpleNamespace(id=1)
async def export_session_string(self):
return "exported-session"
def phone_payload() -> PhoneStart:
return PhoneStart(
instance_id="test",
display_name="Test",
api_id=123,
api_hash="0123456789abcdef",
phone="+421900000000",
)
@pytest.mark.asyncio
async def test_phone_code_success_closes_and_forgets_client(monkeypatch) -> None:
monkeypatch.setattr("app.auth_service.Client", FakeClient)
auth = TelegramAuthService()
flow_id = await auth.start_phone(phone_payload())
result = await auth.submit_code(flow_id, "12345")
assert result.session_string == "exported-session"
assert flow_id not in auth.flows
assert FakeClient.instances[-1].disconnected is True
assert FakeClient.instances[-1].kwargs["in_memory"] is True
assert FakeClient.instances[-1].kwargs["no_updates"] is True
@pytest.mark.asyncio
async def test_string_session_is_validated_and_closed(monkeypatch) -> None:
monkeypatch.setattr("app.auth_service.Client", FakeClient)
auth = TelegramAuthService()
payload = StringSessionStart(
instance_id="test",
display_name="Test",
api_id=123,
api_hash="0123456789abcdef",
session_string="x" * 64,
)
result = await auth.validate_string_session(payload)
assert result.session_string == "exported-session"
assert FakeClient.instances[-1].disconnected is True
@@ -0,0 +1,179 @@
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from app.config import Settings
from app.errors import PanelError
from app.kubernetes_service import KubernetesService
from app.models import AccountBase
from kubernetes.client.exceptions import ApiException
def service() -> KubernetesService:
core = Mock()
apps = Mock()
custom = Mock()
custom.get_namespaced_custom_object.side_effect = ApiException(status=404)
return KubernetesService(Settings(), core=core, apps=apps, custom=custom)
def account() -> AccountBase:
return AccountBase(
instance_id="test-account",
display_name="Test Account",
api_id=12345,
api_hash="0123456789abcdef0123456789abcdef",
)
def test_renders_managed_resources_without_leaking_credentials() -> None:
kube = service()
names = kube._resource_names("test-account")
secret = kube._secret(account(), "SESSION", names)
pvc = kube._pvc(account(), names)
deployment = kube._deployment(account(), names)
assert secret.string_data == {
"API_ID": "12345",
"API_HASH": "0123456789abcdef0123456789abcdef",
"STRINGSESSION": "SESSION",
}
assert pvc.spec.storage_class_name == "local-path-retain"
assert pvc.spec.resources.requests["storage"] == "1Gi"
assert deployment.spec.strategy.type == "Recreate"
assert deployment.spec.replicas == 1
assert deployment.spec.template.spec.automount_service_account_token is False
assert deployment.spec.template.spec.service_account_name == "userbot-runtime"
assert deployment.spec.template.spec.volumes[1].host_path.path.endswith("/Downloads")
assert deployment.spec.template.spec.containers[0].resources.requests == {
"cpu": "80m",
"memory": "512Mi",
}
def test_collision_is_reported_before_auth() -> None:
kube = service()
kube.apps.read_namespaced_deployment.return_value = object()
with pytest.raises(PanelError) as error:
kube.assert_available("test-account")
assert error.value.status_code == 409
assert "Deployment" in error.value.detail
def test_partial_provision_rolls_back_only_created_resources() -> None:
kube = service()
kube.ensure_prerequisites = Mock()
kube.assert_available = Mock()
kube.core.create_namespaced_persistent_volume_claim.side_effect = ApiException(status=500)
with pytest.raises(PanelError):
kube.provision(account(), "SESSION")
kube.core.delete_namespaced_secret.assert_called_once_with(
"userbot-test-account-credentials",
"userbot",
)
kube.core.delete_namespaced_persistent_volume_claim.assert_not_called()
kube.apps.delete_namespaced_deployment.assert_not_called()
def test_delete_retains_pvc_unless_explicitly_requested() -> None:
kube = service()
deployment = SimpleNamespace(
metadata=SimpleNamespace(
name="userbot-test-account",
annotations={
"userbot.forust.xyz/credentials-secret": "credentials",
"userbot.forust.xyz/pvc": "data",
},
)
)
kube._find_deployment = Mock(return_value=("userbot", deployment))
kube.delete("test-account", delete_data=False)
kube.apps.delete_namespaced_deployment.assert_called_once()
kube.core.delete_namespaced_secret.assert_called_once_with("credentials", "userbot")
kube.core.delete_namespaced_persistent_volume_claim.assert_not_called()
kube.delete("test-account", delete_data=True)
kube.core.delete_namespaced_persistent_volume_claim.assert_called_once_with(
"data",
"userbot",
)
def test_legacy_delete_is_blocked() -> None:
kube = service()
deployment = SimpleNamespace(
metadata=SimpleNamespace(
name="forust-userbot-deployment",
annotations={"userbot.forust.xyz/legacy": "true"},
)
)
kube._find_deployment = Mock(return_value=("default", deployment))
with pytest.raises(PanelError) as error:
kube.delete("forust", delete_data=False)
assert error.value.status_code == 409
def pod(*, ready: bool, phase: str = "Running", reason: str | None = None):
waiting = SimpleNamespace(reason=reason) if reason else None
state = SimpleNamespace(waiting=waiting, terminated=None)
status = SimpleNamespace(
container_statuses=[
SimpleNamespace(ready=ready, restart_count=2, state=state),
],
phase=phase,
start_time=datetime.now(UTC),
)
return SimpleNamespace(
metadata=SimpleNamespace(name="pod-1", creation_timestamp=datetime.now(UTC)),
status=status,
)
def deployment(replicas: int = 1):
resources = SimpleNamespace(limits={"cpu": "300m", "memory": "1536Mi"})
container = SimpleNamespace(image="userbot:latest", resources=resources)
template_spec = SimpleNamespace(containers=[container], volumes=[])
return SimpleNamespace(
metadata=SimpleNamespace(
name="userbot-test-account",
labels={"app.kubernetes.io/instance": "test-account"},
annotations={},
creation_timestamp=datetime.now(UTC),
),
spec=SimpleNamespace(
replicas=replicas,
selector=SimpleNamespace(match_labels={"app": "test"}),
template=SimpleNamespace(spec=template_spec),
),
status=SimpleNamespace(available_replicas=1 if replicas else 0),
)
@pytest.mark.parametrize(
("replicas", "pod_value", "expected"),
[
(0, None, "stopped"),
(1, pod(ready=True), "running"),
(1, pod(ready=False, phase="Pending"), "pending"),
(1, pod(ready=False, reason="CrashLoopBackOff"), "error"),
(1, pod(ready=False, phase="Failed"), "error"),
],
)
def test_status_classification(replicas, pod_value, expected) -> None:
kube = service()
kube._pods_for_deployment = Mock(return_value=[pod_value] if pod_value else [])
kube._pvc_storage = Mock(return_value=None)
kube._pod_metrics = Mock(return_value=(None, None))
result = kube._summarize("userbot", deployment(replicas))
assert result.status == expected
+44
View File
@@ -0,0 +1,44 @@
import pytest
from app.main import validation_error_handler
from app.models import AccountBase, DeleteRequest
from fastapi.exceptions import RequestValidationError
from pydantic import ValidationError
@pytest.mark.parametrize(
"instance_id",
["Upper", "has_space", "-leading", "trailing-", "x" * 41],
)
def test_invalid_instance_ids(instance_id: str) -> None:
with pytest.raises(ValidationError):
AccountBase(
instance_id=instance_id,
display_name="Test",
api_id=1,
api_hash="0123456789abcdef",
)
def test_delete_data_defaults_to_false() -> None:
request = DeleteRequest(confirmation="test")
assert request.delete_data is False
@pytest.mark.asyncio
async def test_validation_response_does_not_echo_secret_input() -> None:
sensitive_value = "-".join(("very", "private", "string", "session"))
error = RequestValidationError(
[
{
"type": "string_too_short",
"loc": ("body", "session_string"),
"msg": "String should have at least 32 characters",
"input": sensitive_value,
}
]
)
response = await validation_error_handler(None, error)
assert sensitive_value.encode() not in response.body
assert b'"input"' not in response.body
+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"],
},
});