Compare commits

...
11 Commits
Author SHA1 Message Date
3252a8andGitHub 3e1f1cb787 Merge pull request #15 from 3252a8/dev
Split subscription stats by access type, CI/CD pipelines and notification/docs-demo fixesDev
2026-05-31 00:30:11 +03:00
3252a8 74da8ab98e chore: compact admin subscription stats cards 2026-05-31 00:26:42 +03:00
3252a8 19f7daff6c fix: stabilize docs demo runtime routing 2026-05-31 00:25:23 +03:00
3252a8 cf3af17243 fix: avoid Telegram notification probe messages 2026-05-31 00:17:20 +03:00
3252a8 93353db511 feat: split subscription stats by access type 2026-05-31 00:08:31 +03:00
3252a8andGitHub e7f93a5f47 Merge pull request #14 from 3252a8/dependabot/npm_and_yarn/frontend/npm_and_yarn-de2076f775
chore(deps-dev): bump svelte from 5.55.5 to 5.56.0 in /frontend in the npm_and_yarn group across 1 directory
2026-05-30 23:26:04 +03:00
3252a8andGitHub c2ab881be2 Merge pull request #13 from 3252a8/feature/ci-cd-workflows
ci: add GitHub Actions for image builds, PR checks and security scans
2026-05-30 23:22:51 +03:00
3252a8 4a84bba697 ci: use v-prefixed trivy-action tag (v0.36.0) 2026-05-30 23:20:22 +03:00
3252a8 cda3b741a1 ci: fix workflow failures on PR
- drop permissions block from reusable build workflow so callers set token
  scope (fixes PR-checks startup failure: ci.yml grants only contents:read
  while the reusable demanded packages:write)
- remove codeql.yml: repo already uses CodeQL default setup, which conflicts
  with an advanced configuration
- pin trivy-action to 0.36.0 (0.28.0 tag does not exist; <0.35.0 is the
  compromised supply-chain release flagged by dependency-review)
- make pip-audit and npm audit informational (continue-on-error); they flag
  upstream/transitive advisories, dependency-review stays the PR gate
2026-05-30 23:14:46 +03:00
dependabot[bot]andGitHub 2e82febdcc chore(deps-dev): bump svelte
Bumps the npm_and_yarn group with 1 update in the /frontend directory: [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte).


Updates `svelte` from 5.55.5 to 5.56.0
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.56.0/packages/svelte)

---
updated-dependencies:
- dependency-name: svelte
  dependency-version: 5.56.0
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-30 20:09:10 +00:00
3252a8 bd7710d03c ci: add GitHub Actions for image builds, PR checks and security scans
- dev-images: build/push backend, worker, frontend to ghcr.io and Docker
  Hub on every push to dev (tags: dev, dev-<sha>)
- release-images: same images on v* tag push (tags: latest, <version>)
- PR checks (into main/dev): ruff lint+format, eslint+prettier, no-push
  Docker build of all targets
- CodeQL (python, js/ts), dependency-review, pip-audit, npm audit, Trivy fs
- pin .github/workflows/*.yml to LF
2026-05-30 22:57:41 +03:00
25 changed files with 621 additions and 187 deletions
+1
View File
@@ -1,5 +1,6 @@
.gitattributes text eol=lf .gitattributes text eol=lf
*.sh text eol=lf *.sh text eol=lf
.github/workflows/*.yml text eol=lf
deploy/docker/frontend/*.sh text eol=lf deploy/docker/frontend/*.sh text eol=lf
frontend/src/*.js text eol=lf frontend/src/*.js text eol=lf
frontend/src/**/*.js text eol=lf frontend/src/**/*.js text eol=lf
+117
View File
@@ -0,0 +1,117 @@
name: Docker build & push (reusable)
# Reusable workflow that builds the three image targets defined in
# deploy/docker/Dockerfile (backend, worker, frontend) and optionally pushes
# them to both ghcr.io and Docker Hub under the 3252a8/ namespace.
#
# Called by:
# - docker-dev.yml (tag_mode: dev, push: true) on pushes to dev
# - docker-release.yml (tag_mode: release, push: true) on pushes to main
# - ci.yml (tag_mode: dev, push: false) on pull requests
on:
workflow_call:
inputs:
push:
description: "Push the built images to the registries"
type: boolean
default: true
tag_mode:
description: "Tagging strategy: 'dev' or 'release'"
type: string
required: true
# No permissions block here on purpose: a reusable workflow cannot request more
# than its caller grants, so the token scope is set by each caller
# (docker-dev.yml / docker-release.yml grant packages: write to push; ci.yml
# only needs contents: read for a no-push build).
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- target: backend
image: remnawave-minishop-backend
- target: worker
image: remnawave-minishop-worker
- target: frontend
image: remnawave-minishop-frontend
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history + tags: the Dockerfile's version-builder stage runs
# `git describe --tags` against the copied .git tree.
fetch-depth: 0
- name: Resolve release version
id: version
if: inputs.tag_mode == 'release'
run: |
# On a tag push github.ref_name is the tag (e.g. v3.4.5); for a
# manual workflow_dispatch on a branch, fall back to the latest tag.
if [ "${{ github.ref_type }}" = "tag" ]; then
raw="${{ github.ref_name }}"
else
raw="$(git describe --tags --abbrev=0 2>/dev/null)"
fi
version="${raw#v}"
if [ -z "$version" ]; then
echo "::error::No git tag found to derive the release version from"
exit 1
fi
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "Release version: ${version}"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
if: inputs.push
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
if: inputs.push
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
3252a8/${{ matrix.image }}
ghcr.io/3252a8/${{ matrix.image }}
tags: |
type=raw,value=dev,enable=${{ inputs.tag_mode == 'dev' }}
type=sha,prefix=dev-,format=short,enable=${{ inputs.tag_mode == 'dev' }}
type=raw,value=latest,enable=${{ inputs.tag_mode == 'release' }}
type=raw,value=${{ steps.version.outputs.version }},enable=${{ inputs.tag_mode == 'release' }}
- name: Build${{ inputs.push && ' & push' || '' }} ${{ matrix.image }}
uses: docker/build-push-action@v6
with:
context: .
file: deploy/docker/Dockerfile
target: ${{ matrix.target }}
platforms: linux/amd64
push: ${{ inputs.push }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# The Dockerfile's version-builder appends a "-<branch>" suffix to the
# internal version string for non-main builds. Force "main" on release
# (the ref is the tag, not a branch) so release images stay un-suffixed.
build-args: |
GITHUB_REF_NAME=${{ inputs.tag_mode == 'release' && 'main' || github.ref_name }}
cache-from: type=gha,scope=${{ matrix.target }}
cache-to: type=gha,mode=max,scope=${{ matrix.target }}
provenance: false
+64
View File
@@ -0,0 +1,64 @@
name: PR checks
# Runs on pull requests into main (typically from dev) and into dev (typically
# from feature/* branches): lint + format checks and a no-push image build to
# prove the Docker images still build.
on:
pull_request:
branches: [main, dev]
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
lint:
name: Lint & format
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install ruff
run: pip install "ruff>=0.8.0"
- name: Ruff lint (Python)
run: ruff check .
- name: Ruff format check (Python)
run: ruff format --check .
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install frontend deps
run: npm ci
working-directory: frontend
- name: ESLint (frontend)
run: npm run lint
working-directory: frontend
- name: Prettier check (frontend)
run: npm run format:check
working-directory: frontend
build:
name: Docker build
uses: ./.github/workflows/_docker-build-push.yml
with:
push: false
tag_mode: dev
+27
View File
@@ -0,0 +1,27 @@
name: Dependency review
# On PRs into main/dev, flag any newly added dependency that has a known
# vulnerability or an incompatible license before it gets merged.
on:
pull_request:
branches: [main, dev]
permissions:
contents: read
jobs:
dependency-review:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Dependency review
uses: actions/dependency-review-action@v4
with:
fail-on-severity: high
comment-summary-in-pr: on-failure
+25
View File
@@ -0,0 +1,25 @@
name: Dev images
# On every push to the dev branch, build all three images and push them to
# ghcr.io and Docker Hub tagged `dev` and `dev-<short-sha>`.
on:
push:
branches: [dev]
workflow_dispatch:
concurrency:
group: docker-dev-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
packages: write
jobs:
build-push:
uses: ./.github/workflows/_docker-build-push.yml
with:
push: true
tag_mode: dev
secrets: inherit
+28
View File
@@ -0,0 +1,28 @@
name: Release images
# Build all three images and push them to ghcr.io and Docker Hub tagged
# `latest` and the release version (the pushed tag with its leading `v`
# stripped, e.g. v3.4.5 -> 3.4.5). Triggered only when a new v* tag is pushed,
# so images are built once per release rather than on every commit to main.
on:
push:
tags:
- "v*"
workflow_dispatch:
concurrency:
group: docker-release-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
packages: write
jobs:
build-push:
uses: ./.github/workflows/_docker-build-push.yml
with:
push: true
tag_mode: release
secrets: inherit
+96
View File
@@ -0,0 +1,96 @@
name: Security
# Audits the full dependency set (pip-audit, npm audit) and runs a Trivy
# filesystem scan (dependencies + Dockerfile/IaC misconfig). Trivy results are
# uploaded to the Security -> Code scanning tab.
#
# pip-audit / npm audit are informational (continue-on-error): they surface
# upstream/transitive advisories that aren't necessarily fixable in a given PR,
# so they report in the logs without blocking merges. The PR gate for newly
# introduced vulnerable deps is dependency-review.yml.
on:
pull_request:
branches: [main, dev]
push:
branches: [main, dev]
schedule:
- cron: "27 4 * * 1" # weekly, Monday 04:27 UTC
workflow_dispatch:
concurrency:
group: security-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
python-audit:
name: pip-audit
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install pip-audit
run: pip install pip-audit
- name: Audit Python dependencies
continue-on-error: true
run: pip-audit -r backend/requirements.txt
npm-audit:
name: npm audit
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install frontend deps
run: npm ci
working-directory: frontend
- name: Audit npm dependencies
continue-on-error: true
run: npm audit --audit-level=high
working-directory: frontend
trivy:
name: Trivy filesystem scan
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run Trivy
uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: fs
scan-ref: .
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH
ignore-unfixed: true
- name: Upload Trivy results
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: trivy-results.sarif
category: trivy-fs
+7 -1
View File
@@ -71,11 +71,17 @@ async def show_statistics_handler(
f"📊 {_('admin_user_stats_total_label')}: <b>{user_stats['total_users']}</b>" f"📊 {_('admin_user_stats_total_label')}: <b>{user_stats['total_users']}</b>"
) )
# Removed: Active today moved to panel stats # Removed: Active today moved to panel stats
stats_text_parts.append(
f"📡 {_('admin_user_stats_active_subscription_label')}: <b>{user_stats['active_subscriptions']}</b>" # noqa: E501
)
stats_text_parts.append( stats_text_parts.append(
f"💳 {_('admin_user_stats_paid_subs_label')}: <b>{user_stats['paid_subscriptions']}</b>" f"💳 {_('admin_user_stats_paid_subs_label')}: <b>{user_stats['paid_subscriptions']}</b>"
) )
stats_text_parts.append( stats_text_parts.append(
f"🆓 {_('admin_user_stats_trial_label')}: <b>{user_stats['trial_users']}</b>" f"🧪 {_('admin_user_stats_trial_label')}: <b>{user_stats['trial_users']}</b>"
)
stats_text_parts.append(
f"🎁 {_('admin_user_stats_free_subscription_label')}: <b>{user_stats['free_subscription_users']}</b>" # noqa: E501
) )
stats_text_parts.append( stats_text_parts.append(
f"😴 {_('admin_user_stats_inactive_label')}: <b>{user_stats['inactive_users']}</b>" f"😴 {_('admin_user_stats_inactive_label')}: <b>{user_stats['inactive_users']}</b>"
+3 -1
View File
@@ -166,8 +166,10 @@ async def create_user_stats_result(
"inline_user_stats_message", "inline_user_stats_message",
total=user_stats["total_users"], total=user_stats["total_users"],
active_today=user_stats["active_today"], active_today=user_stats["active_today"],
active=user_stats["active_subscriptions"],
paid=user_stats["paid_subscriptions"], paid=user_stats["paid_subscriptions"],
trial=user_stats["trial_users"], trial=user_stats["trial_users"],
free=user_stats["free_subscription_users"],
inactive=user_stats["inactive_users"], inactive=user_stats["inactive_users"],
banned=user_stats["banned_users"], banned=user_stats["banned_users"],
referral=user_stats["referral_users"], referral=user_stats["referral_users"],
@@ -179,7 +181,7 @@ async def create_user_stats_result(
description=_( description=_(
"inline_user_stats_description", "inline_user_stats_description",
total=user_stats["total_users"], total=user_stats["total_users"],
active=user_stats["paid_subscriptions"], active=user_stats["active_subscriptions"],
), ),
input_message_content=InputTextMessageContent( input_message_content=InputTextMessageContent(
message_text=stats_text, parse_mode="HTML" message_text=stats_text, parse_mode="HTML"
+2 -46
View File
@@ -4,7 +4,6 @@ from typing import Any, Optional
from aiogram import Bot from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
@@ -128,37 +127,6 @@ async def mark_telegram_notifications_enabled_for_telegram_user(
) )
def _translate(
i18n: Optional[JsonI18n],
language: str,
key: str,
fallback: str,
**kwargs: Any,
) -> str:
if not i18n:
return fallback.format(**kwargs) if kwargs else fallback
return i18n.gettext(language, key, **kwargs) or fallback
def _probe_keyboard(
settings: Settings,
i18n: Optional[JsonI18n],
language: str,
) -> Optional[InlineKeyboardMarkup]:
app_url = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
if not app_url:
return None
text = _translate(
i18n,
language,
"telegram_notifications_open_app_button",
"Open app",
)
return InlineKeyboardMarkup(
inline_keyboard=[[InlineKeyboardButton(text=text, web_app=WebAppInfo(url=app_url))]]
)
async def probe_telegram_notifications( async def probe_telegram_notifications(
*, *,
session: AsyncSession, session: AsyncSession,
@@ -187,20 +155,8 @@ async def probe_telegram_notifications(
"start_link": telegram_notifications_start_link(bot_username), "start_link": telegram_notifications_start_link(bot_username),
} }
language = str(getattr(user, "language_code", "") or settings.DEFAULT_LANGUAGE)
text = _translate(
i18n,
language,
"telegram_notifications_enabled_message",
"Telegram notifications are enabled.",
)
try: try:
await bot.send_message( await bot.get_chat(int(telegram_id))
int(telegram_id),
text,
reply_markup=_probe_keyboard(settings, i18n, language),
disable_web_page_preview=True,
)
except Exception as exc: except Exception as exc:
status = telegram_notification_status_from_error(exc) status = telegram_notification_status_from_error(exc)
if status: if status:
@@ -211,7 +167,7 @@ async def probe_telegram_notifications(
"start_link": telegram_notifications_start_link(bot_username), "start_link": telegram_notifications_start_link(bot_username),
} }
logger.warning( logger.warning(
"Telegram notification probe failed for user %s / telegram %s: %s", "Telegram notification chat probe failed for user %s / telegram %s: %s",
user.user_id, user.user_id,
telegram_id, telegram_id,
exc, exc,
+67 -14
View File
@@ -666,17 +666,33 @@ async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]:
active_today = int(user_counts[2] or 0) active_today = int(user_counts[2] or 0)
referral_users = int(user_counts[3] or 0) referral_users = int(user_counts[3] or 0)
subscription_counts_stmt = ( provider_value = func.lower(func.coalesce(Subscription.provider, ""))
panel_status_value = func.upper(func.coalesce(Subscription.status_from_panel, ""))
trial_subscription_condition = or_(
provider_value == "trial",
panel_status_value == "TRIAL",
)
paid_subscription_condition = and_(
provider_value != "",
provider_value != "trial",
panel_status_value != "TRIAL",
)
free_subscription_condition = and_(
provider_value == "",
panel_status_value != "TRIAL",
)
active_subscription_flags_sq = (
select( select(
func.count( Subscription.user_id.label("user_id"),
func.distinct( func.max(case((paid_subscription_condition, 1), else_=0)).label(
case((Subscription.provider.is_not(None), Subscription.user_id), else_=None) "has_paid_subscription"
)
), ),
func.count( func.max(case((trial_subscription_condition, 1), else_=0)).label(
func.distinct( "has_trial_subscription"
case((Subscription.provider.is_(None), Subscription.user_id), else_=None) ),
) func.max(case((free_subscription_condition, 1), else_=0)).label(
"has_free_subscription"
), ),
) )
.join(User, Subscription.user_id == User.user_id) .join(User, Subscription.user_id == User.user_id)
@@ -686,27 +702,64 @@ async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]:
Subscription.end_date > now, Subscription.end_date > now,
) )
) )
.group_by(Subscription.user_id)
.subquery()
)
subscription_counts_stmt = select(
func.count(active_subscription_flags_sq.c.user_id),
func.coalesce(func.sum(active_subscription_flags_sq.c.has_paid_subscription), 0),
func.coalesce(
func.sum(
case(
(
active_subscription_flags_sq.c.has_paid_subscription == 0,
active_subscription_flags_sq.c.has_trial_subscription,
),
else_=0,
)
),
0,
),
func.coalesce(
func.sum(
case(
(
and_(
active_subscription_flags_sq.c.has_paid_subscription == 0,
active_subscription_flags_sq.c.has_trial_subscription == 0,
),
active_subscription_flags_sq.c.has_free_subscription,
),
else_=0,
)
),
0,
),
) )
subscription_counts = (await session.execute(subscription_counts_stmt)).one() subscription_counts = (await session.execute(subscription_counts_stmt)).one()
paid_subs_users = int(subscription_counts[0] or 0) active_subscription_users = int(subscription_counts[0] or 0)
trial_users = int(subscription_counts[1] or 0) paid_subs_users = int(subscription_counts[1] or 0)
trial_users = int(subscription_counts[2] or 0)
free_subscription_users = int(subscription_counts[3] or 0)
# Inactive users (no active subscription) inactive_users = total_users - active_subscription_users
inactive_users = total_users - paid_subs_users - trial_users - banned_users
return { return {
"total_users": total_users, "total_users": total_users,
"banned_users": banned_users, "banned_users": banned_users,
"active_today": active_today, "active_today": active_today,
"active_subscriptions": active_subscription_users,
"paid_subscriptions": paid_subs_users, "paid_subscriptions": paid_subs_users,
"trial_users": trial_users, "trial_users": trial_users,
"free_subscription_users": free_subscription_users,
"inactive_users": max(0, inactive_users), "inactive_users": max(0, inactive_users),
"referral_users": referral_users, "referral_users": referral_users,
} }
async def get_user_ids_with_active_subscription(session: AsyncSession) -> List[int]: async def get_user_ids_with_active_subscription(session: AsyncSession) -> List[int]:
"""Return non-banned user IDs who have an active subscription (paid or trial).""" """Return non-banned user IDs who have any active subscription."""
from datetime import datetime, timezone from datetime import datetime, timezone
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
+11 -10
View File
@@ -1,11 +1,12 @@
# Cloudflare Pages rewrites for non-materialized static docs demo runtime routes. # Cloudflare Pages rewrites for non-materialized static docs demo runtime routes.
# Use the directory entrypoint so Pages clean URLs do not loop between app.html and app. # Use the generated directory index instead of app.html so Pages clean URLs do not
/demo/runtime/home/* /demo/runtime/app/ 200 # loop between extensionless and .html variants.
/demo/runtime/install/* /demo/runtime/app/ 200 /demo/runtime/home/* /demo/runtime/app/index.html 200
/demo/runtime/trial/* /demo/runtime/app/ 200 /demo/runtime/install/* /demo/runtime/app/index.html 200
/demo/runtime/invite/* /demo/runtime/app/ 200 /demo/runtime/trial/* /demo/runtime/app/index.html 200
/demo/runtime/devices/* /demo/runtime/app/ 200 /demo/runtime/invite/* /demo/runtime/app/index.html 200
/demo/runtime/support/* /demo/runtime/app/ 200 /demo/runtime/devices/* /demo/runtime/app/index.html 200
/demo/runtime/settings/* /demo/runtime/app/ 200 /demo/runtime/support/* /demo/runtime/app/index.html 200
/demo/runtime/login/* /demo/runtime/app/ 200 /demo/runtime/settings/* /demo/runtime/app/index.html 200
/demo/runtime/admin/* /demo/runtime/app/ 200 /demo/runtime/login/* /demo/runtime/app/index.html 200
/demo/runtime/admin/* /demo/runtime/app/index.html 200
+2 -1
View File
@@ -191,6 +191,7 @@ await runNpm(["--prefix", frontendRoot, "run", "build:docs-demo"]);
await rm(runtimeDir, { recursive: true, force: true }); await rm(runtimeDir, { recursive: true, force: true });
await mkdir(runtimeDir, { recursive: true }); await mkdir(runtimeDir, { recursive: true });
await mkdir(path.join(runtimeDir, "app"), { recursive: true });
const html = await appHtml(); const html = await appHtml();
@@ -204,7 +205,7 @@ await Promise.all([
path.join(runtimeDir, "default-brand"), path.join(runtimeDir, "default-brand"),
), ),
copyDirectory(themesDir, path.join(runtimeDir, "themes"), copyThemeFile), copyDirectory(themesDir, path.join(runtimeDir, "themes"), copyThemeFile),
writeFile(path.join(runtimeDir, "app.html"), html, "utf8"), writeFile(path.join(runtimeDir, "app", "index.html"), html, "utf8"),
installGuidesConfigPayload().then((payload) => installGuidesConfigPayload().then((payload) =>
writeFile( writeFile(
path.join(runtimeDir, "subscription-guides-config.json"), path.join(runtimeDir, "subscription-guides-config.json"),
@@ -19,7 +19,13 @@ async function copyHtml(source, route) {
} }
const demoShell = path.join(distRoot, "demo", "index.html"); const demoShell = path.join(distRoot, "demo", "index.html");
const runtimeApp = path.join(distRoot, "demo", "runtime", "app.html"); const runtimeApp = path.join(
distRoot,
"demo",
"runtime",
"app",
"index.html",
);
for (const route of demoRoutes) { for (const route of demoRoutes) {
await copyHtml(demoShell, route); await copyHtml(demoShell, route);
@@ -1,14 +0,0 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
const runtimeAppHtml = path.join(process.cwd(), "public", "demo", "runtime", "app.html");
export const prerender = true;
export async function GET() {
return new Response(await readFile(runtimeAppHtml, "utf8"), {
headers: {
"Content-Type": "text/html; charset=utf-8",
},
});
}
+1 -1
View File
@@ -23,7 +23,7 @@
- использует entrypoint `frontend/src/docsDemoEntry.js`, где подключены моковые данные и mock API; - использует entrypoint `frontend/src/docsDemoEntry.js`, где подключены моковые данные и mock API;
- дополнительно собирает обычный admin-бандл, чтобы админка работала внутри демо; - дополнительно собирает обычный admin-бандл, чтобы админка работала внутри демо;
- копирует JS/CSS, темы, default-brand ассеты, локали и конфиг гайдов подключения в `docs-site/public/demo/runtime/`; - копирует JS/CSS, темы, default-brand ассеты, локали и конфиг гайдов подключения в `docs-site/public/demo/runtime/`;
- генерирует `app.html`, который грузит demo runtime и встроенные переводы; - генерирует `app/index.html`, который грузит demo runtime и встроенные переводы;
- после Astro build материализует публичные страницы `/demo/home`, `/demo/install`, `/demo/admin/stats` и другие основные demo routes как статические `index.html`; - после Astro build материализует публичные страницы `/demo/home`, `/demo/install`, `/demo/admin/stats` и другие основные demo routes как статические `index.html`;
- Cloudflare Pages rewrite-правила остаются только для внутреннего `/demo/runtime/*`, чтобы iframe мог использовать обычный History API без влияния на остальные страницы документации; - Cloudflare Pages rewrite-правила остаются только для внутреннего `/demo/runtime/*`, чтобы iframe мог использовать обычный History API без влияния на остальные страницы документации;
- страницы `/demo/home`, `/demo/install`, `/demo/admin/*` и другие demo routes служат полноэкранной обвязкой с верхней панелью возврата в документацию, а внешняя страница синхронизирует читаемый адрес демо. - страницы `/demo/home`, `/demo/install`, `/demo/admin/*` и другие demo routes служат полноэкранной обвязкой с верхней панелью возврата в документацию, а внешняя страница синхронизирует читаемый адрес демо.
+16 -76
View File
@@ -24,7 +24,7 @@
"lucide-svelte": "^1.0.1", "lucide-svelte": "^1.0.1",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.0", "prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.55.5", "svelte": "^5.56.0",
"svelte-eslint-parser": "^1.4.1", "svelte-eslint-parser": "^1.4.1",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "4.3.0", "tailwindcss": "4.3.0",
@@ -947,9 +947,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -971,9 +968,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -995,9 +989,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1019,9 +1010,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1043,9 +1031,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1067,9 +1052,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1239,9 +1221,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1259,9 +1238,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1279,9 +1255,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1299,9 +1272,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1319,9 +1289,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1339,9 +1306,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1429,9 +1393,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@sveltejs/acorn-typescript": { "node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.9", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz",
"integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
@@ -1620,9 +1584,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1640,9 +1601,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1660,9 +1618,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1680,9 +1635,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2096,9 +2048,9 @@
} }
}, },
"node_modules/devalue": { "node_modules/devalue": {
"version": "5.7.1", "version": "5.8.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
"integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==", "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -2421,9 +2373,9 @@
} }
}, },
"node_modules/esrap": { "node_modules/esrap": {
"version": "2.2.5", "version": "2.2.9",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.5.tgz", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.9.tgz",
"integrity": "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==", "integrity": "sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2899,9 +2851,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2923,9 +2872,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2947,9 +2893,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2971,9 +2914,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3640,24 +3580,24 @@
} }
}, },
"node_modules/svelte": { "node_modules/svelte": {
"version": "5.55.5", "version": "5.56.0",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.0.tgz",
"integrity": "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw==", "integrity": "sha512-kTXr26t1bchFp28ROrb957LtbujpBmBDibmqMGziVpUs7awBi96TGgX6SovrA8BNoEUDVRK2Fb9FkeYlGspoVg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/remapping": "^2.3.4", "@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
"@sveltejs/acorn-typescript": "^1.0.5", "@sveltejs/acorn-typescript": "^1.0.10",
"@types/estree": "^1.0.5", "@types/estree": "^1.0.5",
"@types/trusted-types": "^2.0.7", "@types/trusted-types": "^2.0.7",
"acorn": "^8.12.1", "acorn": "^8.12.1",
"aria-query": "5.3.1", "aria-query": "5.3.1",
"axobject-query": "^4.1.0", "axobject-query": "^4.1.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"devalue": "^5.6.4", "devalue": "^5.8.1",
"esm-env": "^1.2.1", "esm-env": "^1.2.1",
"esrap": "^2.2.4", "esrap": "^2.2.9",
"is-reference": "^3.0.3", "is-reference": "^3.0.3",
"locate-character": "^3.0.0", "locate-character": "^3.0.0",
"magic-string": "^0.30.11", "magic-string": "^0.30.11",
+1 -1
View File
@@ -34,7 +34,7 @@
"lucide-svelte": "^1.0.1", "lucide-svelte": "^1.0.1",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.0", "prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.55.5", "svelte": "^5.56.0",
"svelte-eslint-parser": "^1.4.1", "svelte-eslint-parser": "^1.4.1",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "4.3.0", "tailwindcss": "4.3.0",
@@ -692,17 +692,25 @@
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>
<Card.Description>{at("stats_label_paid_subs", {}, "")}</Card.Description> <Card.Description>{at("stats_label_active_subs", {}, "")}</Card.Description>
<Card.Title>{users.paid_subscriptions ?? 0}</Card.Title> <Card.Title>{users.active_subscriptions ?? 0}</Card.Title>
<Card.Action> <Card.Action>
<Badge variant="outline">{users.trial_users ?? 0}</Badge> <Badge variant="outline"
>{users.total_users
? Math.round(((users.active_subscriptions ?? 0) / (users.total_users || 1)) * 100)
: 0}%</Badge
>
</Card.Action> </Card.Action>
</Card.Header> </Card.Header>
<Card.Footer class="admin-cn-card-footer--stack"> <Card.Footer class="admin-cn-card-footer--stack">
<div class="admin-cn-card-footer-primary"> <div class="admin-cn-card-footer-primary">
{at("stats_trend_trials", { count: users.trial_users ?? 0 }, "")} {at("stats_trend_paid", { count: users.paid_subscriptions ?? 0 }, "")}
· {at("stats_trend_free", { count: users.free_subscription_users ?? 0 }, "")}
· {at("stats_trend_trials", { count: users.trial_users ?? 0 }, "")}
</div>
<div class="admin-cn-card-footer-muted">
{at("stats_card_active_subs_caption", {}, "")}
</div> </div>
<div class="admin-cn-card-footer-muted">{at("stats_card_paid_caption", {}, "")}</div>
</Card.Footer> </Card.Footer>
</Card.Root> </Card.Root>
+11 -1
View File
@@ -1231,7 +1231,17 @@ export async function mockApi(path, options = {}, context = {}) {
return { return {
ok: true, ok: true,
currency_symbol: "RUB", currency_symbol: "RUB",
users: { total_users: 248, active_subscriptions: 172, banned_users: 3 }, users: {
total_users: 248,
active_today: 9,
active_subscriptions: 172,
paid_subscriptions: 141,
trial_users: 8,
free_subscription_users: 23,
inactive_users: 76,
banned_users: 3,
referral_users: 34,
},
financial: { financial: {
today_revenue: 1240, today_revenue: 1240,
week_revenue: 15800, week_revenue: 15800,
+16 -6
View File
@@ -347,8 +347,8 @@
"admin_user_no_logs": "📜 User has no actions", "admin_user_no_logs": "📜 User has no actions",
"admin_user_logs_error": "❌ Error loading user actions", "admin_user_logs_error": "❌ Error loading user actions",
"admin_direct_message_signature": "\n\n---\n💬 Message from administrator", "admin_direct_message_signature": "\n\n---\n💬 Message from administrator",
"inline_user_stats_message": "👥 <b>User Statistics</b>\n\n📊 Total: <b>{total}</b>\n📈 Active today: <b>{active_today}</b>\n💳 With paid subscription: <b>{paid}</b>\n🆓 On trial period: <b>{trial}</b>\n😴 Inactive: <b>{inactive}</b>\n🚫 Banned: <b>{banned}</b>\n🎁 Via referral program: <b>{referral}</b>", "inline_user_stats_message": "👥 <b>User Statistics</b>\n\n📊 Total: <b>{total}</b>\n📈 Active today: <b>{active_today}</b>\n📡 With active subscription: <b>{active}</b>\n💳 With paid subscription: <b>{paid}</b>\n🧪 With trial subscription: <b>{trial}</b>\n🎁 With free subscription: <b>{free}</b>\n😴 Without active subscription: <b>{inactive}</b>\n🚫 Banned: <b>{banned}</b>\n🎁 Via referral program: <b>{referral}</b>",
"inline_user_stats_description": "Total: {total}, Paid: {active}", "inline_user_stats_description": "Total: {total}, subscribed: {active}",
"inline_admin_user_stats_title": "👥 User Statistics", "inline_admin_user_stats_title": "👥 User Statistics",
"inline_financial_stats_message": "💰 <b>Financial Statistics</b>\n\n📅 Today: <b>{today:.2f} RUB</b>\n ({today_count} payments)\n📅 Week: <b>{week:.2f} RUB</b>\n📅 Month: <b>{month:.2f} RUB</b>\n🏆 All time: <b>{all_time:.2f} RUB</b>", "inline_financial_stats_message": "💰 <b>Financial Statistics</b>\n\n📅 Today: <b>{today:.2f} RUB</b>\n ({today_count} payments)\n📅 Week: <b>{week:.2f} RUB</b>\n📅 Month: <b>{month:.2f} RUB</b>\n🏆 All time: <b>{all_time:.2f} RUB</b>",
"inline_admin_financial_stats_title": "💰 Financial Statistics", "inline_admin_financial_stats_title": "💰 Financial Statistics",
@@ -488,9 +488,11 @@
"inline_referral_description": "Share referral link to get bonuses", "inline_referral_description": "Share referral link to get bonuses",
"inline_financial_description": "Today: {today} RUB", "inline_financial_description": "Today: {today} RUB",
"inline_system_description": "🟢 Online: {online}, 📊 Active: {active}", "inline_system_description": "🟢 Online: {online}, 📊 Active: {active}",
"admin_user_stats_active_subscription_label": "With active subscription",
"admin_user_stats_paid_subs_label": "With paid subscription", "admin_user_stats_paid_subs_label": "With paid subscription",
"admin_user_stats_trial_label": "On trial period", "admin_user_stats_trial_label": "With trial subscription",
"admin_user_stats_inactive_label": "Inactive", "admin_user_stats_free_subscription_label": "With free subscription",
"admin_user_stats_inactive_label": "Without active subscription",
"admin_user_stats_banned_label": "Banned", "admin_user_stats_banned_label": "Banned",
"admin_user_stats_referral_label": "Attracted via referral program", "admin_user_stats_referral_label": "Attracted via referral program",
"admin_financial_today_label": "Today", "admin_financial_today_label": "Today",
@@ -1098,8 +1100,13 @@
"admin_stats_error": "Failed to load statistics: {error}", "admin_stats_error": "Failed to load statistics: {error}",
"admin_stats_label_users": "Users", "admin_stats_label_users": "Users",
"admin_stats_trend_banned": "Banned: {count}", "admin_stats_trend_banned": "Banned: {count}",
"admin_stats_label_paid_subs": "Paid Subscriptions", "admin_stats_label_active_subs": "Active users",
"admin_stats_label_paid_subs": "Paid users",
"admin_stats_label_trial_users": "With trial subscription",
"admin_stats_label_free_users": "With free subscription",
"admin_stats_trend_paid": "Paid: {count}",
"admin_stats_trend_trials": "Trials: {count}", "admin_stats_trend_trials": "Trials: {count}",
"admin_stats_trend_free": "Free: {count}",
"admin_stats_label_today_rev": "Today's Revenue", "admin_stats_label_today_rev": "Today's Revenue",
"admin_stats_trend_payments": "{count} payments", "admin_stats_trend_payments": "{count} payments",
"admin_stats_label_week": "This Week", "admin_stats_label_week": "This Week",
@@ -1145,7 +1152,10 @@
"admin_stats_revenue_avg_check": "Average ticket today: {value}", "admin_stats_revenue_avg_check": "Average ticket today: {value}",
"admin_stats_revenue_avg_none": "No successful payments today", "admin_stats_revenue_avg_none": "No successful payments today",
"admin_stats_revenue_avg_ticket_label": "Avg. ticket (today)", "admin_stats_revenue_avg_ticket_label": "Avg. ticket (today)",
"admin_stats_card_paid_caption": "Trials shown in the badge", "admin_stats_card_active_subs_caption": "Paid, trial, and free access combined",
"admin_stats_card_paid_caption": "Paid users separated from trial and free access",
"admin_stats_card_trial_caption": "Only real trial subscriptions",
"admin_stats_card_free_caption": "Manual extensions and bonus access",
"admin_stats_card_inactive_caption": "Share of all users is in the badge", "admin_stats_card_inactive_caption": "Share of all users is in the badge",
"admin_stats_revenue_last_7_calendar": "Last 7 calendar days total", "admin_stats_revenue_last_7_calendar": "Last 7 calendar days total",
"admin_stats_revenue_prev_7_calendar": "Previous 7 days", "admin_stats_revenue_prev_7_calendar": "Previous 7 days",
+17 -7
View File
@@ -347,8 +347,8 @@
"admin_user_no_logs": "📜 У пользователя нет действий", "admin_user_no_logs": "📜 У пользователя нет действий",
"admin_user_logs_error": "❌ Ошибка загрузки действий пользователя", "admin_user_logs_error": "❌ Ошибка загрузки действий пользователя",
"admin_direct_message_signature": "\n\n---\n💬 Сообщение от администратора", "admin_direct_message_signature": "\n\n---\n💬 Сообщение от администратора",
"inline_user_stats_message": "📊 <b>Статистика Бота</b>\n👥 Пользователи\n\n📊 Всего: <b>{total}</b>\n📈 Активных сегодня: <b>{active_today}</b>\n💳 С платной подпиской: <b>{paid}</b>\n🆓 На пробном периоде: <b>{trial}</b>\n😴 Неактивных: <b>{inactive}</b>\n🚫 Заблокированных: <b>{banned}</b>\n🎁 Привлечено по реферальной программе: <b>{referral}</b>", "inline_user_stats_message": "📊 <b>Статистика Бота</b>\n👥 Пользователи\n\n📊 Всего: <b>{total}</b>\n📈 Активных сегодня: <b>{active_today}</b>\n📡 С активной подпиской: <b>{active}</b>\n💳 С платной подпиской: <b>{paid}</b>\n🧪 С пробной подпиской: <b>{trial}</b>\n🎁 С бесплатной подпиской: <b>{free}</b>\n😴 Без активной подписки: <b>{inactive}</b>\n🚫 Заблокированных: <b>{banned}</b>\n🎁 Привлечено по реферальной программе: <b>{referral}</b>",
"inline_user_stats_description": "Всего: {total}, Платных: {active}", "inline_user_stats_description": "Всего: {total}, с подпиской: {active}",
"inline_admin_user_stats_title": "👥 Статистика пользователей", "inline_admin_user_stats_title": "👥 Статистика пользователей",
"inline_financial_stats_message": "💰 <b>Финансовая статистика</b>\n\n📅 За сегодня: <b>{today:.2f} RUB</b>\n ({today_count} платежей)\n📅 За неделю: <b>{week:.2f} RUB</b>\n📅 За месяц: <b>{month:.2f} RUB</b>\n🏆 За все время: <b>{all_time:.2f} RUB</b>", "inline_financial_stats_message": "💰 <b>Финансовая статистика</b>\n\n📅 За сегодня: <b>{today:.2f} RUB</b>\n ({today_count} платежей)\n📅 За неделю: <b>{week:.2f} RUB</b>\n📅 За месяц: <b>{month:.2f} RUB</b>\n🏆 За все время: <b>{all_time:.2f} RUB</b>",
"inline_admin_financial_stats_title": "💰 Финансовая статистика", "inline_admin_financial_stats_title": "💰 Финансовая статистика",
@@ -488,9 +488,11 @@
"inline_referral_description": "Поделиться реферальной ссылкой для получения бонусов", "inline_referral_description": "Поделиться реферальной ссылкой для получения бонусов",
"inline_financial_description": "Сегодня: {today} RUB", "inline_financial_description": "Сегодня: {today} RUB",
"inline_system_description": "🟢 Онлайн: {online}, 📊 Активных: {active}", "inline_system_description": "🟢 Онлайн: {online}, 📊 Активных: {active}",
"admin_user_stats_active_subscription_label": "С активной подпиской",
"admin_user_stats_paid_subs_label": "С платной подпиской", "admin_user_stats_paid_subs_label": "С платной подпиской",
"admin_user_stats_trial_label": "На пробном периоде", "admin_user_stats_trial_label": "С пробной подпиской",
"admin_user_stats_inactive_label": "Неактивных", "admin_user_stats_free_subscription_label": "С бесплатной подпиской",
"admin_user_stats_inactive_label": "Без активной подписки",
"admin_user_stats_banned_label": "Заблокированных", "admin_user_stats_banned_label": "Заблокированных",
"admin_user_stats_referral_label": "Привлечено по реферальной программе", "admin_user_stats_referral_label": "Привлечено по реферальной программе",
"admin_financial_today_label": "За сегодня", "admin_financial_today_label": "За сегодня",
@@ -1098,8 +1100,13 @@
"admin_stats_error": "Не удалось загрузить статистику: {error}", "admin_stats_error": "Не удалось загрузить статистику: {error}",
"admin_stats_label_users": "Пользователи", "admin_stats_label_users": "Пользователи",
"admin_stats_trend_banned": "В бане: {count}", "admin_stats_trend_banned": "В бане: {count}",
"admin_stats_label_paid_subs": "Платные подписки", "admin_stats_label_active_subs": "Активные пользователи",
"admin_stats_trend_trials": "Триалы: {count}", "admin_stats_label_paid_subs": "Платные пользователи",
"admin_stats_label_trial_users": "С пробной подпиской",
"admin_stats_label_free_users": "С бесплатной подпиской",
"admin_stats_trend_paid": "Платные: {count}",
"admin_stats_trend_trials": "Пробные: {count}",
"admin_stats_trend_free": "Бесплатные: {count}",
"admin_stats_label_today_rev": "Доход за день", "admin_stats_label_today_rev": "Доход за день",
"admin_stats_trend_payments": "{count} платежей", "admin_stats_trend_payments": "{count} платежей",
"admin_stats_label_week": "За неделю", "admin_stats_label_week": "За неделю",
@@ -1145,7 +1152,10 @@
"admin_stats_revenue_avg_check": "Средний чек сегодня: {value}", "admin_stats_revenue_avg_check": "Средний чек сегодня: {value}",
"admin_stats_revenue_avg_none": "Сегодня без успешных платежей", "admin_stats_revenue_avg_none": "Сегодня без успешных платежей",
"admin_stats_revenue_avg_ticket_label": "Средний чек (сегодня)", "admin_stats_revenue_avg_ticket_label": "Средний чек (сегодня)",
"admin_stats_card_paid_caption": "Триалы — отдельно в бейдже", "admin_stats_card_active_subs_caption": "Платные, пробные и бесплатные вместе",
"admin_stats_card_paid_caption": "Платные отдельно от пробных и бесплатных",
"admin_stats_card_trial_caption": "Только реальные пробные подписки",
"admin_stats_card_free_caption": "Ручные начисления и бонусный доступ",
"admin_stats_card_inactive_caption": "Доля от всех пользователей — в бейдже", "admin_stats_card_inactive_caption": "Доля от всех пользователей — в бейдже",
"admin_stats_revenue_last_7_calendar": "Сумма за последние 7 дн. (календарь)", "admin_stats_revenue_last_7_calendar": "Сумма за последние 7 дн. (календарь)",
"admin_stats_revenue_prev_7_calendar": "Предыдущие 7 дн.", "admin_stats_revenue_prev_7_calendar": "Предыдущие 7 дн.",
+3 -1
View File
@@ -96,9 +96,11 @@ class AdminDbStatsCacheTests(unittest.IsolatedAsyncioTestCase):
"total_users": 10, "total_users": 10,
"banned_users": 1, "banned_users": 1,
"active_today": 2, "active_today": 2,
"active_subscriptions": 8,
"paid_subscriptions": 7, "paid_subscriptions": 7,
"trial_users": 1, "trial_users": 1,
"inactive_users": 1, "free_subscription_users": 0,
"inactive_users": 2,
"referral_users": 3, "referral_users": 3,
} }
) )
+43 -1
View File
@@ -1,6 +1,12 @@
import asyncio
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock
from bot.services.telegram_notifications import telegram_notifications_need_prompt from bot.services import telegram_notifications as module
from bot.services.telegram_notifications import (
TELEGRAM_NOTIFICATIONS_ENABLED,
telegram_notifications_need_prompt,
)
def _user(status: str): def _user(status: str):
@@ -22,3 +28,39 @@ def test_telegram_notifications_prompt_requires_linked_telegram():
) )
assert telegram_notifications_need_prompt(user) is False assert telegram_notifications_need_prompt(user) is False
def test_probe_telegram_notifications_uses_silent_chat_check(monkeypatch):
user = SimpleNamespace(
user_id=42,
telegram_id=123,
telegram_notifications_status="unknown",
)
bot = SimpleNamespace(
get_chat=AsyncMock(return_value=SimpleNamespace(id=123)),
send_message=AsyncMock(),
)
recorded = []
async def fake_mark_status(session, user_id, status, *, telegram_id=None, checked_at=None):
recorded.append((session, user_id, status, telegram_id, checked_at))
return user
monkeypatch.setattr(module, "mark_telegram_notifications_status", fake_mark_status)
result = asyncio.run(
module.probe_telegram_notifications(
session="session",
bot=bot,
settings=SimpleNamespace(DEFAULT_LANGUAGE="ru"),
i18n=None,
user=user,
bot_username="preview_bot",
)
)
bot.get_chat.assert_awaited_once_with(123)
bot.send_message.assert_not_called()
assert result["ok"] is True
assert result["status"] == TELEGRAM_NOTIFICATIONS_ENABLED
assert recorded == [("session", 42, TELEGRAM_NOTIFICATIONS_ENABLED, 123, None)]
+43
View File
@@ -27,6 +27,49 @@ class FakeResult:
return self._scalar_value return self._scalar_value
return [self._scalar_value] return [self._scalar_value]
def one(self):
return self._scalar_value
class UserDalStatisticsTests(unittest.IsolatedAsyncioTestCase):
async def test_get_enhanced_user_statistics_splits_paid_trial_and_free_users(self):
session = SimpleNamespace(
execute=AsyncMock(
side_effect=[
FakeResult((10, 1, 2, 3)),
FakeResult((8, 4, 2, 2)),
]
)
)
stats = await user_dal.get_enhanced_user_statistics(session)
self.assertEqual(
stats,
{
"total_users": 10,
"banned_users": 1,
"active_today": 2,
"active_subscriptions": 8,
"paid_subscriptions": 4,
"trial_users": 2,
"free_subscription_users": 2,
"inactive_users": 2,
"referral_users": 3,
},
)
stmt = session.execute.await_args_list[1].args[0]
sql = str(
stmt.compile(
dialect=postgresql.dialect(),
compile_kwargs={"literal_binds": True},
)
).upper()
self.assertIn("GROUP BY SUBSCRIPTIONS.USER_ID", sql)
self.assertIn("SUBSCRIPTIONS.PROVIDER", sql)
self.assertIn("TRIAL", sql)
class UserDalMergeTests(unittest.IsolatedAsyncioTestCase): class UserDalMergeTests(unittest.IsolatedAsyncioTestCase):
async def test_get_panel_user_uuids_for_user_includes_subscription_fallbacks_once(self): async def test_get_panel_user_uuids_for_user_includes_subscription_fallbacks_once(self):