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:
@@ -3,7 +3,5 @@ kind: Kustomization
|
||||
|
||||
resources:
|
||||
- userbots.yaml
|
||||
- common-secret.yaml
|
||||
- panel.yaml
|
||||
- common-config.yaml
|
||||
- forust-secrets.yaml
|
||||
- anna-secrets.yaml
|
||||
|
||||
@@ -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
@@ -4,8 +4,14 @@ metadata:
|
||||
name: forust-userbot-deployment
|
||||
labels:
|
||||
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:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: forust-userbot
|
||||
@@ -13,6 +19,8 @@ spec:
|
||||
metadata:
|
||||
labels:
|
||||
app: forust-userbot
|
||||
annotations:
|
||||
kubectl.kubernetes.io/restartedAt: "2026-07-25T20:24:51+02:00"
|
||||
spec:
|
||||
containers:
|
||||
- name: forust-userbot
|
||||
@@ -33,6 +41,8 @@ spec:
|
||||
- secretRef:
|
||||
name: userbot-forust-secrets
|
||||
volumeMounts:
|
||||
- name: downloads
|
||||
mountPath: /app/downloads
|
||||
- name: forust-storage
|
||||
mountPath: /app/data
|
||||
subPath: data
|
||||
@@ -40,6 +50,10 @@ spec:
|
||||
mountPath: /app/logs
|
||||
subPath: logs
|
||||
volumes:
|
||||
- name: downloads
|
||||
hostPath:
|
||||
path: /srv/homelab/userbot/Downloads
|
||||
type: DirectoryOrCreate
|
||||
- name: forust-storage
|
||||
persistentVolumeClaim:
|
||||
claimName: forust-pvc
|
||||
@@ -62,8 +76,14 @@ metadata:
|
||||
name: anna-userbot-deployment
|
||||
labels:
|
||||
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:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: anna-userbot
|
||||
@@ -71,6 +91,8 @@ spec:
|
||||
metadata:
|
||||
labels:
|
||||
app: anna-userbot
|
||||
annotations:
|
||||
kubectl.kubernetes.io/restartedAt: "2026-07-25T20:24:51+02:00"
|
||||
spec:
|
||||
containers:
|
||||
- name: anna-userbot
|
||||
@@ -91,6 +113,8 @@ spec:
|
||||
- secretRef:
|
||||
name: userbot-anna-secrets
|
||||
volumeMounts:
|
||||
- name: downloads
|
||||
mountPath: /app/downloads
|
||||
- name: anna-storage
|
||||
mountPath: /app/data
|
||||
subPath: data
|
||||
@@ -98,6 +122,10 @@ spec:
|
||||
mountPath: /app/logs
|
||||
subPath: logs
|
||||
volumes:
|
||||
- name: downloads
|
||||
hostPath:
|
||||
path: /srv/homelab/userbot/Downloads
|
||||
type: DirectoryOrCreate
|
||||
- name: anna-storage
|
||||
persistentVolumeClaim:
|
||||
claimName: anna-pvc
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
backend/.venv
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
__pycache__
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
*.pyc
|
||||
@@ -0,0 +1,7 @@
|
||||
backend/.venv/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
"""Userbot Kubernetes control panel backend."""
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
-r requirements.txt
|
||||
httpx==0.28.1
|
||||
pytest==8.4.1
|
||||
pytest-asyncio==1.1.0
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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