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:
|
resources:
|
||||||
- userbots.yaml
|
- userbots.yaml
|
||||||
- common-secret.yaml
|
- panel.yaml
|
||||||
- common-config.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
|
name: forust-userbot-deployment
|
||||||
labels:
|
labels:
|
||||||
app: forust-userbot
|
app: forust-userbot
|
||||||
|
app.kubernetes.io/name: userbot
|
||||||
|
app.kubernetes.io/instance: forust
|
||||||
|
annotations:
|
||||||
|
userbot.forust.xyz/display-name: forust
|
||||||
|
userbot.forust.xyz/legacy: "true"
|
||||||
|
userbot.forust.xyz/credentials-secret: userbot-forust-secrets
|
||||||
|
userbot.forust.xyz/pvc: forust-pvc
|
||||||
spec:
|
spec:
|
||||||
replicas: 1
|
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: forust-userbot
|
app: forust-userbot
|
||||||
@@ -13,6 +19,8 @@ spec:
|
|||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
app: forust-userbot
|
app: forust-userbot
|
||||||
|
annotations:
|
||||||
|
kubectl.kubernetes.io/restartedAt: "2026-07-25T20:24:51+02:00"
|
||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: forust-userbot
|
- name: forust-userbot
|
||||||
@@ -33,6 +41,8 @@ spec:
|
|||||||
- secretRef:
|
- secretRef:
|
||||||
name: userbot-forust-secrets
|
name: userbot-forust-secrets
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
|
- name: downloads
|
||||||
|
mountPath: /app/downloads
|
||||||
- name: forust-storage
|
- name: forust-storage
|
||||||
mountPath: /app/data
|
mountPath: /app/data
|
||||||
subPath: data
|
subPath: data
|
||||||
@@ -40,6 +50,10 @@ spec:
|
|||||||
mountPath: /app/logs
|
mountPath: /app/logs
|
||||||
subPath: logs
|
subPath: logs
|
||||||
volumes:
|
volumes:
|
||||||
|
- name: downloads
|
||||||
|
hostPath:
|
||||||
|
path: /srv/homelab/userbot/Downloads
|
||||||
|
type: DirectoryOrCreate
|
||||||
- name: forust-storage
|
- name: forust-storage
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: forust-pvc
|
claimName: forust-pvc
|
||||||
@@ -62,8 +76,14 @@ metadata:
|
|||||||
name: anna-userbot-deployment
|
name: anna-userbot-deployment
|
||||||
labels:
|
labels:
|
||||||
app: anna-userbot
|
app: anna-userbot
|
||||||
|
app.kubernetes.io/name: userbot
|
||||||
|
app.kubernetes.io/instance: anna
|
||||||
|
annotations:
|
||||||
|
userbot.forust.xyz/display-name: anna
|
||||||
|
userbot.forust.xyz/legacy: "true"
|
||||||
|
userbot.forust.xyz/credentials-secret: userbot-anna-secrets
|
||||||
|
userbot.forust.xyz/pvc: anna-pvc
|
||||||
spec:
|
spec:
|
||||||
replicas: 1
|
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: anna-userbot
|
app: anna-userbot
|
||||||
@@ -71,6 +91,8 @@ spec:
|
|||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
app: anna-userbot
|
app: anna-userbot
|
||||||
|
annotations:
|
||||||
|
kubectl.kubernetes.io/restartedAt: "2026-07-25T20:24:51+02:00"
|
||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: anna-userbot
|
- name: anna-userbot
|
||||||
@@ -91,6 +113,8 @@ spec:
|
|||||||
- secretRef:
|
- secretRef:
|
||||||
name: userbot-anna-secrets
|
name: userbot-anna-secrets
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
|
- name: downloads
|
||||||
|
mountPath: /app/downloads
|
||||||
- name: anna-storage
|
- name: anna-storage
|
||||||
mountPath: /app/data
|
mountPath: /app/data
|
||||||
subPath: data
|
subPath: data
|
||||||
@@ -98,6 +122,10 @@ spec:
|
|||||||
mountPath: /app/logs
|
mountPath: /app/logs
|
||||||
subPath: logs
|
subPath: logs
|
||||||
volumes:
|
volumes:
|
||||||
|
- name: downloads
|
||||||
|
hostPath:
|
||||||
|
path: /srv/homelab/userbot/Downloads
|
||||||
|
type: DirectoryOrCreate
|
||||||
- name: anna-storage
|
- name: anna-storage
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: anna-pvc
|
claimName: anna-pvc
|
||||||
|
|||||||
@@ -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