Download models.py from ItsBounvy/bot_host: direct link, hf CLI and curl.
- Browser
- Download file 34.1 kB
-
https://huggingface.co/spaces/ItsBounvy/bot_host/resolve/main/models.py
- Command line
-
hf download hf://spaces/ItsBounvy/bot_host/models.py
-
curl -L -o models.py https://huggingface.co/spaces/ItsBounvy/bot_host/resolve/main/models.py
34.1 kB
| """Database models for the hosting panel.""" | |
| from __future__ import annotations | |
| import enum | |
| from datetime import datetime | |
| from typing import List, Optional | |
| from sqlalchemy import ( | |
| String, | |
| Integer, | |
| Boolean, | |
| DateTime, | |
| Text, | |
| ForeignKey, | |
| Enum, | |
| Index, | |
| func, | |
| Float, | |
| ) | |
| from sqlalchemy.ext.asyncio import AsyncAttrs | |
| from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship | |
| class Base(AsyncAttrs, DeclarativeBase): | |
| pass | |
| class BotStatus(str, enum.Enum): | |
| PENDING = "pending" # configured but not deployed | |
| BUILDING = "building" # HF Space building | |
| STARTING = "starting" # subprocess spawning | |
| RUNNING = "running" # active and healthy | |
| STOPPED = "stopped" # manually paused | |
| ERROR = "error" # deployment or runtime error | |
| SLEEPING = "sleeping" # HF Space free-tier sleep | |
| class DeploymentMode(str, enum.Enum): | |
| """How the bot is hosted.""" | |
| MULTITENANT = "multitenant" # subprocess in a HostSpace | |
| LEGACY_SPACE = "legacy_space" # one-bot-per-HF-Space (old model) | |
| class SpaceStatus(str, enum.Enum): | |
| PROVISIONING = "provisioning" # HF repo creation in flight | |
| PENDING_APPROVAL = "pending_approval" # row exists, awaiting admin OK to create HF repo | |
| READY = "ready" | |
| ERROR = "error" | |
| DRAINING = "draining" | |
| # User plan tier \u2014 used by the allocator to pick which pool of | |
| # HostSpaces a bot can run on. Free and paid bots never share a Space. | |
| PLAN_TIER_FREE = "free" | |
| PLAN_TIER_PAID = "paid" | |
| PLAN_TIERS: tuple[str, ...] = (PLAN_TIER_FREE, PLAN_TIER_PAID) | |
| # Bot lifecycle states relative to where its source code lives. | |
| SPACE_STATE_ON_DATASET = "on_dataset" # only HF dataset has a copy | |
| SPACE_STATE_PENDING_MATERIALIZE = "pending_materialize" | |
| SPACE_STATE_ON_SPACE = "on_space" # Space has a working copy + proc running | |
| SPACE_STATE_ON_SPACE_KEPT = "on_space_kept" # Space has a copy, proc is stopped | |
| class UserRole(str, enum.Enum): | |
| ADMIN = "admin" | |
| OPERATOR = "operator" | |
| VIEWER = "viewer" | |
| class User(Base): | |
| __tablename__ = "users" | |
| id: Mapped[int] = mapped_column(Integer, primary_key=True) | |
| username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) | |
| email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) | |
| password_hash: Mapped[str] = mapped_column(String(255), nullable=False) | |
| role: Mapped[UserRole] = mapped_column(Enum(UserRole), default=UserRole.VIEWER) | |
| is_active: Mapped[bool] = mapped_column(Boolean, default=True) | |
| created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now()) | |
| last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) | |
| login_attempts: Mapped[int] = mapped_column(Integer, default=0) | |
| locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) | |
| invite_code: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) | |
| totp_secret: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) # future 2FA | |
| # Per-user Fernet key (defense-in-depth: outer encryption layer for | |
| # each user's bot secrets). Stored encrypted by PANEL_ENCRYPTION_KEY | |
| # so even the DB admin can't read it without the panel key, AND | |
| # optionally mirrored to Supabase for cloud backup. | |
| user_key_enc: Mapped[Optional[str]] = mapped_column(Text, nullable=True) | |
| user_key_created_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) | |
| user_key_rotated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) | |
| user_key_supabase_synced_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) | |
| # --------------------------------------------------------------- | |
| # Plan limits — enforced by the allocator at every bot creation | |
| # or resource resize. The defaults below reflect the new "panel | |
| # on Render, bots on HF Spaces" architecture: each user gets their | |
| # own HF Space sized to fit this plan, panel itself runs on Render | |
| # using ~0.4 CPU / 512 MB. | |
| # | |
| # All values are admin-editable from the admin panel | |
| # (POST /admin/users/{id}/plan). Per-user storage_quota_mb caps | |
| # the working dataset each user can occupy on HF. | |
| # --------------------------------------------------------------- | |
| cpu_quota_cores: Mapped[float] = mapped_column(default=0.4) | |
| ram_quota_mb: Mapped[int] = mapped_column(Integer, default=512) | |
| storage_quota_mb: Mapped[int] = mapped_column(Integer, default=2048) | |
| # Max number of bot instances a user can create under their plan. | |
| # ``0`` means "blocked from creating new bots" (useful for trialing | |
| # an admin-restricted user). Default 4 keeps things sensible. | |
| plan_max_bots: Mapped[int] = mapped_column(Integer, default=4) | |
| # --------------------------------------------------------------- | |
| # Plan tier — picks which pool of HostSpaces the user's bots | |
| # run on. Free and paid bots never share a Space. Admins can flip | |
| # a user between ``PLAN_TIER_FREE`` / ``PLAN_TIER_PAID`` from the | |
| # admin panel; doing so does not retroactively move running bots | |
| # (the next start_bot cycle picks the new tier). | |
| # --------------------------------------------------------------- | |
| plan_tier: Mapped[str] = mapped_column(String(16), default=PLAN_TIER_FREE) | |
| # Soft hint of which HostSpace this user "prefers" (the one their | |
| # dashboard shows). The allocator does NOT bind bots to it — bots | |
| # are placed per-bot by tier-aware packing. Stored only so the UI | |
| # has something tangible to display. | |
| space_id: Mapped[Optional[int]] = mapped_column( | |
| ForeignKey("host_spaces.id"), nullable=True, | |
| ) | |
| # relationships | |
| bots: Mapped[List["BotInstance"]] = relationship( | |
| back_populates="owner", cascade="all, delete-orphan", lazy="selectin" | |
| ) | |
| audit_logs: Mapped[List["AuditLog"]] = relationship( | |
| back_populates="user", cascade="all, delete-orphan", lazy="selectin" | |
| ) | |
| space: Mapped[Optional["HostSpace"]] = relationship( | |
| back_populates="users", foreign_keys=[space_id], | |
| ) | |
| class InviteCode(Base): | |
| """Admin-issued invite code. | |
| Distinct from the legacy per-user ``User.invite_code`` (which every | |
| user gets automatically and can share 1:1). These are created | |
| explicitly from the admin panel, can be reused up to ``max_uses`` | |
| times (``0`` = unlimited), optionally expire, and can be revoked | |
| without touching any user row. | |
| """ | |
| __tablename__ = "invite_codes" | |
| id: Mapped[int] = mapped_column(Integer, primary_key=True) | |
| code: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) | |
| label: Mapped[Optional[str]] = mapped_column(String(128), nullable=True) | |
| created_by_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True) | |
| max_uses: Mapped[int] = mapped_column(Integer, default=1) # 0 = unlimited | |
| uses_count: Mapped[int] = mapped_column(Integer, default=0) | |
| is_active: Mapped[bool] = mapped_column(Boolean, default=True) | |
| expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) | |
| created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now()) | |
| created_by: Mapped[Optional[User]] = relationship(foreign_keys=[created_by_id]) | |
| redemptions: Mapped[List["InviteRedemption"]] = relationship( | |
| back_populates="invite", cascade="all, delete-orphan", lazy="selectin", | |
| order_by="InviteRedemption.redeemed_at.desc()", | |
| ) | |
| def is_exhausted(self) -> bool: | |
| return self.max_uses > 0 and self.uses_count >= self.max_uses | |
| def is_expired(self) -> bool: | |
| return bool(self.expires_at and self.expires_at < datetime.utcnow()) | |
| def is_usable(self) -> bool: | |
| return self.is_active and not self.is_exhausted and not self.is_expired | |
| class InviteRedemption(Base): | |
| """Audit row: which user_id consumed which invite code, and when. | |
| We keep this even after a code is fully consumed so admins can | |
| investigate "who got in with code X?" without cross-referencing | |
| the audit log. ``user_id`` is intentionally NOT a foreign key into | |
| ``users`` — a user who later gets deleted still leaves a redemption | |
| record. | |
| """ | |
| __tablename__ = "invite_redemptions" | |
| id: Mapped[int] = mapped_column(Integer, primary_key=True) | |
| invite_id: Mapped[int] = mapped_column(ForeignKey("invite_codes.id"), nullable=False, index=True) | |
| user_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True) | |
| username: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) # denormalised for fast display | |
| redeemed_at: Mapped[datetime] = mapped_column(DateTime, default=func.now()) | |
| invite: Mapped[InviteCode] = relationship(back_populates="redemptions") | |
| class HostSpace(Base): | |
| """A hosting container (HF Space) that hosts the panel and runs user | |
| bot processes. | |
| Capacity model (2026-07-05): | |
| * Capacity is fixed at provisioning time and matched against bot | |
| CPU/RAM usage (NOT user count). A single Space can host many | |
| users' bots as long as their total reserved CPU/RAM stays under | |
| the limit. | |
| * ``baseline_cpu_reserved`` (default 0.1 CPU) is permanently | |
| subtracted from the packable capacity so the orchestrator | |
| inside the Space can always talk back to the control plane | |
| even when every bot is at peak load. | |
| * Rows are created in two ways: | |
| 1. The allocator asks for a new Space when no existing one | |
| has room for a bot. The row is inserted in ``PROVISIONING`` | |
| and triggers HF API in the background. May also sit in | |
| ``PENDING_APPROVAL`` if the panel is configured to require | |
| admin sign-off for new Spaces. | |
| 2. Admin manually adds a Space via the admin panel — same | |
| lifecycle but skipping ``PENDING_APPROVAL``. | |
| * All new Spaces must be EITHER auto-approved by config OR | |
| approved manually by an admin in the admin panel; until then | |
| ``status != READY`` and the allocator will skip them. | |
| """ | |
| __tablename__ = "host_spaces" | |
| id: Mapped[int] = mapped_column(Integer, primary_key=True) | |
| name: Mapped[str] = mapped_column(String(128), unique=True, nullable=False) | |
| hf_space_url: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) | |
| hf_owner: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) | |
| # --- Tier & approval --- | |
| # Every Space serves exactly one tier. Bots placed by a free-tier | |
| # user only ever land on ``tier == 'free'`` Spaces. | |
| tier: Mapped[str] = mapped_column(String(16), default=PLAN_TIER_FREE) | |
| # Approval flow: | |
| # * ``requested_at`` is set when the row is first inserted by the | |
| # allocator (in PENDING_APPROVAL) or by admin (in PROVISIONING). | |
| # * ``approved_at`` is set once an admin (or the policy) signs off; | |
| # for admin-initiated manual creation the two timestamps will | |
| # be identical (or within a few seconds of each other). | |
| # * ``requested_by_user_id`` is nullable: bot-initiated requests | |
| # leave it NULL, admin-initiated ones store the admin's id. | |
| requested_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) | |
| approved_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) | |
| approved_by_user_id: Mapped[Optional[int]] = mapped_column( | |
| ForeignKey("users.id"), nullable=True, | |
| ) | |
| requested_by_user_id: Mapped[Optional[int]] = mapped_column( | |
| ForeignKey("users.id"), nullable=True, | |
| ) | |
| # Free-text operator note ("special tier for X", "migrating from old hardware") | |
| operator_note: Mapped[Optional[str]] = mapped_column(Text, nullable=True) | |
| # Capacity (aggregate hard limits enforced at the HF level). | |
| hardware_tier: Mapped[str] = mapped_column(String(32), default="cpu-basic") | |
| cpu_capacity_cores: Mapped[float] = mapped_column(default=2.0) | |
| ram_capacity_mb: Mapped[int] = mapped_column(Integer, default=16 * 1024) | |
| disk_capacity_mb: Mapped[int] = mapped_column(Integer, default=50 * 1024) | |
| # Permanent CPU/RAM budget reserved for the Space's own orchestrator | |
| # process and control-plane communication. Subtracted from | |
| # capacity at allocation time so no bot reservation can ever | |
| # starve it. Default 0.1 CPU matches orchestrator_overhead_cores | |
| # in the legacy 5-user model; tunable per-Space if a tier wants | |
| # more headroom. | |
| baseline_cpu_reserved: Mapped[float] = mapped_column(default=0.1) | |
| baseline_ram_reserved_mb: Mapped[int] = mapped_column(Integer, default=128) | |
| # Currently committed resources (sum of bot reservations). | |
| # ``cpu_used_cores`` does NOT include ``baseline_cpu_reserved`` | |
| # — that's tracked separately to keep the maths clean. | |
| cpu_used_cores: Mapped[float] = mapped_column(default=0.0) | |
| ram_used_mb: Mapped[int] = mapped_column(Integer, default=0) | |
| disk_used_mb: Mapped[int] = mapped_column(Integer, default=0) | |
| status: Mapped[SpaceStatus] = mapped_column( | |
| Enum(SpaceStatus), default=SpaceStatus.PROVISIONING, | |
| ) | |
| is_local: Mapped[bool] = mapped_column(Boolean, default=False) # is THIS Space the running panel? | |
| last_healthcheck: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) | |
| created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now()) | |
| users: Mapped[List[User]] = relationship( | |
| back_populates="space", foreign_keys=[User.space_id], | |
| ) | |
| bots: Mapped[List["BotInstance"]] = relationship( | |
| back_populates="space", foreign_keys="BotInstance.space_id", | |
| ) | |
| def packable_cpu(self) -> float: | |
| """CPU available for placing bots. Subtracts baseline.""" | |
| return max(0.0, float(self.cpu_capacity_cores) - float(self.baseline_cpu_reserved or 0.0)) | |
| def packable_ram_mb(self) -> int: | |
| """RAM available for placing bots. Subtracts baseline.""" | |
| return max(0, int(self.ram_capacity_mb) - int(self.baseline_ram_reserved_mb or 0)) | |
| def free_cpu(self) -> float: | |
| return max(0.0, self.packable_cpu - float(self.cpu_used_cores or 0.0)) | |
| def free_ram_mb(self) -> int: | |
| return max(0, self.packable_ram_mb - int(self.ram_used_mb or 0)) | |
| class BotInstance(Base): | |
| __tablename__ = "bot_instances" | |
| id: Mapped[int] = mapped_column(Integer, primary_key=True) | |
| owner_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) | |
| name: Mapped[str] = mapped_column(String(128), nullable=False) | |
| slug: Mapped[str] = mapped_column(String(128), unique=True, nullable=False) | |
| description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) | |
| status: Mapped[BotStatus] = mapped_column(Enum(BotStatus), default=BotStatus.PENDING) | |
| created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now()) | |
| updated_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), onupdate=func.now()) | |
| last_healthcheck: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) | |
| healthcheck_url: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) | |
| # --------------------------------------------------------------- | |
| # Source code — required. The panel deploys *whatever* lives in | |
| # this repo to the bot's HF Space; it doesn't ship any code itself. | |
| # --------------------------------------------------------------- | |
| source_repo: Mapped[str] = mapped_column(String(256), nullable=False, default="") | |
| source_type: Mapped[str] = mapped_column(String(16), default="hf") # "hf" | "github" | |
| framework: Mapped[str] = mapped_column(String(64), default="docker") # hint for UI | |
| entrypoint: Mapped[Optional[str]] = mapped_column(String(256), nullable=True) | |
| source_branch: Mapped[str] = mapped_column(String(64), default="main") | |
| # --------------------------------------------------------------- | |
| # HF Space config (where the bot actually runs) | |
| # --------------------------------------------------------------- | |
| hf_space_name: Mapped[Optional[str]] = mapped_column(String(128), nullable=True) | |
| hf_space_url: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) | |
| hf_token_enc: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # encrypted, optional | |
| hf_dataset_repo: Mapped[Optional[str]] = mapped_column(String(256), nullable=True) # optional | |
| # --------------------------------------------------------------- | |
| # Telegram bot — optional. Bots that aren't Telegram bots just | |
| # leave this blank; the panel doesn't care. | |
| # --------------------------------------------------------------- | |
| bot_token_enc: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # encrypted, optional | |
| # --------------------------------------------------------------- | |
| # LLM / proxy — optional. Bots that don't need an LLM (or that | |
| # call LLMs directly without a proxy) leave these blank. | |
| # --------------------------------------------------------------- | |
| nvidia_model: Mapped[Optional[str]] = mapped_column(String(128), nullable=True) | |
| render_service_name: Mapped[Optional[str]] = mapped_column(String(128), nullable=True) | |
| render_url: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) | |
| nvidia_api_key_enc: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # encrypted, optional | |
| llm_proxy_token_enc: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # encrypted, optional | |
| # --------------------------------------------------------------- | |
| # Plain env vars stored as JSON string (user-defined, non-secret) | |
| # --------------------------------------------------------------- | |
| env_vars_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True) | |
| # --------------------------------------------------------------- | |
| # Per-bot admin-panel secrets (the four magic fields: login, | |
| # password, secret_word, secret_number). Encrypted at rest with | |
| # the owner's per-user Fernet key + panel key (two-tier). | |
| # --------------------------------------------------------------- | |
| admin_login_enc: Mapped[Optional[str]] = mapped_column(Text, nullable=True) | |
| admin_password_enc: Mapped[Optional[str]] = mapped_column(Text, nullable=True) | |
| secret_word_enc: Mapped[Optional[str]] = mapped_column(Text, nullable=True) | |
| secret_number_enc: Mapped[Optional[str]] = mapped_column(Text, nullable=True) | |
| # --------------------------------------------------------------- | |
| # Hosting mode. The default is ``LEGACY_SPACE`` (one bot = one | |
| # HF Space) — that's the architecture that lets the panel itself | |
| # run on Render while every bot runs on its own HF Space. The | |
| # ``MULTITENANT`` mode (subprocess inside a HostSpace) is kept for | |
| # local development and historical installs, but new bot creation | |
| # always picks LEGACY_SPACE so a Render-hosted panel never tries | |
| # to spawn a subprocess on a host that can't honour it. | |
| # --------------------------------------------------------------- | |
| deployment_mode: Mapped[DeploymentMode] = mapped_column( | |
| Enum(DeploymentMode), default=DeploymentMode.LEGACY_SPACE, | |
| ) | |
| space_id: Mapped[Optional[int]] = mapped_column( | |
| ForeignKey("host_spaces.id"), nullable=True, | |
| ) | |
| port: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) | |
| pid: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) | |
| # --------------------------------------------------------------- | |
| # Where the bot's source code lives right now: | |
| # * ``on_dataset`` — only the central HF dataset has a copy, | |
| # no Space has materialized it. The bot's | |
| # canonical state lives in the dataset. | |
| # * ``pending_materialize`` — start_bot triggered a transfer | |
| # from dataset to Space; not yet done. | |
| # * ``on_space`` — a copy has been written to the Space's | |
| # working dir, the subprocess is using it. | |
| # * ``on_space_kept`` — bot is stopped but the on-Space copy was | |
| # kept so the next start is instant. The | |
| # dataset remains the source of truth. | |
| # Helps the runner decide whether to copy files before starting. | |
| # --------------------------------------------------------------- | |
| space_state: Mapped[str] = mapped_column(String(32), default="on_dataset") | |
| # User's chosen resource slice; bounded by owner's quota and the | |
| # assigned Space's capacity. ``None`` for legacy bots. | |
| cpu_cores: Mapped[Optional[float]] = mapped_column(default=0.1) | |
| ram_mb: Mapped[Optional[int]] = mapped_column(Integer, default=256) | |
| storage_used_mb: Mapped[int] = mapped_column(Integer, default=0) | |
| # --- relationships --- | |
| owner: Mapped[User] = relationship(back_populates="bots") | |
| space: Mapped[Optional[HostSpace]] = relationship( | |
| back_populates="bots", foreign_keys=[space_id], | |
| ) | |
| deployment_logs: Mapped[List["DeploymentLog"]] = relationship( | |
| back_populates="bot", cascade="all, delete-orphan", lazy="selectin", | |
| order_by="DeploymentLog.created_at.desc()", | |
| ) | |
| snapshots: Mapped[List["BotSnapshot"]] = relationship( | |
| back_populates="bot", cascade="all, delete-orphan", lazy="selectin", | |
| order_by="BotSnapshot.created_at.desc()", | |
| ) | |
| def display_name(self) -> str: | |
| return self.name or self.slug | |
| def has_telegram(self) -> bool: | |
| return bool(self.bot_token_enc) | |
| def has_proxy(self) -> bool: | |
| return bool(self.render_url) | |
| class DeploymentLog(Base): | |
| __tablename__ = "deployment_logs" | |
| id: Mapped[int] = mapped_column(Integer, primary_key=True) | |
| bot_id: Mapped[int] = mapped_column(ForeignKey("bot_instances.id"), nullable=False) | |
| action: Mapped[str] = mapped_column(String(64), nullable=False) # deploy, stop, restart, delete, config_update | |
| status: Mapped[str] = mapped_column(String(32), default="pending") # pending, success, failed | |
| message: Mapped[Optional[str]] = mapped_column(Text, nullable=True) | |
| details_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True) | |
| created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now()) | |
| bot: Mapped[BotInstance] = relationship(back_populates="deployment_logs") | |
| class BotSnapshot(Base): | |
| __tablename__ = "bot_snapshots" | |
| id: Mapped[int] = mapped_column(Integer, primary_key=True) | |
| bot_id: Mapped[int] = mapped_column(ForeignKey("bot_instances.id"), nullable=False) | |
| snapshot_path: Mapped[str] = mapped_column(String(512), nullable=False) | |
| size_bytes: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) | |
| created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now()) | |
| restored_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) | |
| bot: Mapped[BotInstance] = relationship(back_populates="snapshots") | |
| class AuditLog(Base): | |
| __tablename__ = "audit_logs" | |
| id: Mapped[int] = mapped_column(Integer, primary_key=True) | |
| user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True) | |
| action: Mapped[str] = mapped_column(String(64), nullable=False) | |
| target_type: Mapped[Optional[str]] = mapped_column(String(32), nullable=True) # bot, user, config | |
| target_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) | |
| details_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True) | |
| ip_address: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) | |
| user_agent: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) | |
| created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now()) | |
| user: Mapped[Optional[User]] = relationship(back_populates="audit_logs") | |
| class GlobalConfig(Base): | |
| __tablename__ = "global_config" | |
| id: Mapped[int] = mapped_column(Integer, primary_key=True) | |
| key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False) | |
| value: Mapped[str] = mapped_column(Text, nullable=False) | |
| updated_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), onupdate=func.now()) | |
| updated_by: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True) | |
| # --------------------------------------------------------------------------- | |
| # Ad banners (added 2026-07-05) | |
| # --------------------------------------------------------------------------- | |
| # | |
| # Admin-managed marketing banners that render as a responsive carousel | |
| # on the dashboard, login, and register pages. Each banner ships with | |
| # TWO image URLs \u2014 one for desktop, one for mobile \u2014 picked at | |
| # render time by CSS ``<source media=...>`` rules. Click on a banner | |
| # navigates the user to ``link_url`` (admin-set; opens in a new tab). | |
| # | |
| # Lifecycle: | |
| # * ``active`` \u2014 admin can disable without deleting the row. | |
| # * ``position`` \u2014 "dashboard", "login_register", or "all". | |
| # * ``order`` \u2014 float; lower first. Lets admin re-order with a | |
| # single ``POST /admin/banners/reorder`` call. | |
| # * ``created_by_user_id`` \u2014 audit trail; admin who first added the row. | |
| AD_BANNER_POSITION_DASHBOARD = "dashboard" | |
| AD_BANNER_POSITION_LOGIN_REGISTER = "login_register" | |
| AD_BANNER_POSITION_ALL = "all" | |
| AD_BANNER_POSITIONS: tuple[str, ...] = ( | |
| AD_BANNER_POSITION_DASHBOARD, | |
| AD_BANNER_POSITION_LOGIN_REGISTER, | |
| AD_BANNER_POSITION_ALL, | |
| ) | |
| class AdBanner(Base): | |
| __tablename__ = "ad_banners" | |
| id: Mapped[int] = mapped_column(Integer, primary_key=True) | |
| # Display label (used in admin table + ``alt`` attribute). | |
| name: Mapped[str] = mapped_column(String(128), nullable=False) | |
| # Where the user lands after clicking. Stored as raw URL; admin | |
| # must validate it on entry (route layer rejects non-http(s)). | |
| link_url: Mapped[str] = mapped_column(String(2048), nullable=False) | |
| # Two images \u2014 desktop (>=768px viewport) and mobile (<768px). | |
| # Both are remote URLs (admin pastes the link; no upload pipeline). | |
| desktop_image_url: Mapped[str] = mapped_column(String(2048), nullable=False) | |
| mobile_image_url: Mapped[str] = mapped_column(String(2048), nullable=False) | |
| # Optional overlay text shown above the image. | |
| title: Mapped[Optional[str]] = mapped_column(String(256), nullable=True) | |
| description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) | |
| # ``accent_color`` lets admins tint the title bar / dot indicator; | |
| # falls back to panel's --accent CSS variable when blank. | |
| accent_color: Mapped[Optional[str]] = mapped_column(String(16), nullable=True) | |
| # Placement | |
| position: Mapped[str] = mapped_column( | |
| String(32), default=AD_BANNER_POSITION_ALL, | |
| ) | |
| # Lower-first ordering; admin can re-order via /admin/banners/reorder. | |
| order: Mapped[float] = mapped_column(default=100.0) | |
| # Soft-disable without deleting the row. | |
| active: Mapped[bool] = mapped_column(Boolean, default=True) | |
| # Audit | |
| created_by_user_id: Mapped[Optional[int]] = mapped_column( | |
| ForeignKey("users.id"), nullable=True, | |
| ) | |
| created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now()) | |
| updated_at: Mapped[datetime] = mapped_column( | |
| DateTime, default=func.now(), onupdate=func.now(), | |
| ) | |
| def display_title(self) -> str: | |
| return self.title or self.name | |
| # --------------------------------------------------------------------------- | |
| # Analytics & Alerts (added 2026-07-03) | |
| # --------------------------------------------------------------------------- | |
| # | |
| # Two related concepts: | |
| # | |
| # 1. ``AlertRule`` — a stored predicate the evaluator loop wakes up | |
| # every N seconds and checks against current bot / cluster state. | |
| # When the rule fires we dispatch to the configured channel | |
| # (Telegram / webhook / both), record an ``AlertEvent`` and respect | |
| # the per-rule cooldown so the channel isn't spammed. | |
| # | |
| # 2. ``AlertEvent`` — an immutable record of one fired rule. Used for | |
| # the alerts log on /admin/alerts and for in-product counters. | |
| # | |
| # The actual *timeseries* metrics (proxy traffic per hour, bot | |
| # upload/download per hour) are computed on demand from ``AuditLog`` | |
| # — see ``analytics.py``. No rollup table needed because ``AuditLog`` | |
| # is already write-through to the HF Dataset and a single GROUP BY | |
| # over a 30-day window is cheap (well under 100k rows on the busiest | |
| # panel we've seen). | |
| # --------------------------------------------------------------------------- | |
| class AlertMetric(str, enum.Enum): | |
| """What the rule measures.""" | |
| BOT_RAM_MB = "bot_ram_mb" # RSS in MB; live from bot_runner | |
| BOT_CPU_PERCENT = "bot_cpu_percent" # live from bot_runner | |
| BOT_STATUS = "bot_status" # equality check on BotStatus | |
| ERROR_RATE_PERCENT = "error_rate" # 5xx / total proxy requests in window | |
| PROXY_LATENCY_P95_MS = "latency_p95" # p95 of proxy_request latency_ms | |
| PROXY_RPM = "proxy_rpm" # requests-per-minute over the window | |
| HOST_RAM_PERCENT = "host_ram_percent" | |
| HOST_CPU_PERCENT = "host_cpu_percent" | |
| class AlertOperator(str, enum.Enum): | |
| GT = ">" | |
| GTE = ">=" | |
| LT = "<" | |
| LTE = "<=" | |
| EQ = "==" | |
| NE = "!=" | |
| class AlertSeverity(str, enum.Enum): | |
| INFO = "info" | |
| WARNING = "warning" | |
| CRITICAL = "critical" | |
| class AlertChannel(str, enum.Enum): | |
| TELEGRAM = "telegram" | |
| WEBHOOK = "webhook" | |
| BOTH = "both" | |
| class AlertRule(Base): | |
| __tablename__ = "alert_rules" | |
| id: Mapped[int] = mapped_column(Integer, primary_key=True) | |
| # Display + ownership | |
| name: Mapped[str] = mapped_column(String(128), nullable=False) | |
| description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) | |
| created_by_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True) | |
| # Scope: ``None`` = applies to every bot (cluster-wide); a bot_id | |
| # means "only this bot". Rules that don't make sense per-bot | |
| # (host_*) implicitly require bot_id IS NULL. | |
| bot_id: Mapped[Optional[int]] = mapped_column( | |
| ForeignKey("bot_instances.id", ondelete="CASCADE"), nullable=True, index=True, | |
| ) | |
| # The predicate | |
| metric: Mapped[AlertMetric] = mapped_column(Enum(AlertMetric), nullable=False) | |
| operator: Mapped[AlertOperator] = mapped_column(Enum(AlertOperator), nullable=False) | |
| threshold: Mapped[float] = mapped_column(Float, nullable=False) | |
| # Rolling evaluation window in minutes. 5–60 typical. | |
| window_minutes: Mapped[int] = mapped_column(Integer, default=5) | |
| # Delivery | |
| severity: Mapped[AlertSeverity] = mapped_column( | |
| Enum(AlertSeverity), default=AlertSeverity.WARNING | |
| ) | |
| channel: Mapped[AlertChannel] = mapped_column( | |
| Enum(AlertChannel), default=AlertChannel.TELEGRAM | |
| ) | |
| telegram_chat_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) | |
| webhook_url: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) | |
| # Lifecycle | |
| is_active: Mapped[bool] = mapped_column(Boolean, default=True) | |
| cooldown_minutes: Mapped[int] = mapped_column(Integer, default=15) | |
| last_fired_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) | |
| last_value: Mapped[Optional[float]] = mapped_column(Float, nullable=True) | |
| fire_count: Mapped[int] = mapped_column(Integer, default=0) | |
| created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now()) | |
| updated_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), onupdate=func.now()) | |
| events: Mapped[List["AlertEvent"]] = relationship( | |
| back_populates="rule", cascade="all, delete-orphan", lazy="selectin", | |
| order_by="AlertEvent.fired_at.desc()", | |
| ) | |
| created_by: Mapped[Optional[User]] = relationship(foreign_keys=[created_by_id]) | |
| bot: Mapped[Optional[BotInstance]] = relationship(foreign_keys=[bot_id]) | |
| __table_args__ = ( | |
| Index("ix_alert_rules_metric_active", "metric", "is_active"), | |
| ) | |
| def in_cooldown(self, now: Optional[datetime] = None) -> bool: | |
| """True if the rule has fired recently enough to suppress the next dispatch.""" | |
| if not self.last_fired_at or self.cooldown_minutes <= 0: | |
| return False | |
| now = now or datetime.utcnow() | |
| elapsed_min = (now - self.last_fired_at).total_seconds() / 60.0 | |
| return elapsed_min < self.cooldown_minutes | |
| class AlertEvent(Base): | |
| __tablename__ = "alert_events" | |
| id: Mapped[int] = mapped_column(Integer, primary_key=True) | |
| rule_id: Mapped[int] = mapped_column( | |
| ForeignKey("alert_rules.id", ondelete="CASCADE"), nullable=False, index=True, | |
| ) | |
| bot_id: Mapped[Optional[int]] = mapped_column( | |
| ForeignKey("bot_instances.id", ondelete="SET NULL"), nullable=True, index=True, | |
| ) | |
| fired_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), index=True) | |
| metric_value: Mapped[float] = mapped_column(Float, nullable=False) | |
| metric_label: Mapped[str] = mapped_column(String(64), nullable=False) | |
| severity: Mapped[AlertSeverity] = mapped_column(Enum(AlertSeverity), nullable=False) | |
| # The rendered message that went out (or would have) — handy in the | |
| # alerts log so the admin can see "what was sent" without having | |
| # to query Telegram. | |
| message: Mapped[str] = mapped_column(Text, nullable=False) | |
| # JSON: {"telegram": "sent"|"skipped"|"failed: ...", "webhook": ...} | |
| delivery_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True) | |
| rule: Mapped[AlertRule] = relationship(back_populates="events") | |
| bot: Mapped[Optional[BotInstance]] = relationship(foreign_keys=[bot_id]) | |