feat(admin): surface configuration problems in the admin panel

Add GET /api/admin/health powered by a config health service that
detects common deployment mistakes: missing or read-only data volume,
broken tariffs/locale-override/guides JSON files, payment providers
enabled without credentials, webhook providers without
WEBHOOK_BASE_URL, no enabled payment methods, missing or non-https
mini app URL, missing Redis, partially configured SMTP, untrusted
reverse proxy, invalid bot token, missing/mismatched/failing Telegram
webhook and unreachable Remnawave panel. Network checks (Telegram,
panel) are cached for two minutes; ?refresh=1 forces a re-check.

The admin UI shows the alerts as a banner on the dashboard with
per-section navigation chips and a manual re-check button, and as a
filtered banner inside each affected section. Alerts are localized
via admin_health_* keys with built-in Russian fallbacks.
This commit is contained in:
3252a8
2026-06-10 12:31:41 +03:00
parent ce6273a652
commit 1b2290ea66
12 changed files with 1208 additions and 0 deletions
@@ -0,0 +1,122 @@
<script>
import { getContext } from "svelte";
import { RefreshCw, TriangleAlert } from "$components/ui/icons.js";
import { AdminButton } from "$components/patterns/admin/index.js";
export let at = (key, _params = {}, fallback = "") => fallback || key;
export let section = "stats";
export let onNavigate = () => {};
const healthStore = getContext("healthStore");
const MESSAGE_FALLBACKS = {
data_dir_missing:
"Каталог data ({path}) не найден. Проверьте, что том data смонтирован в контейнер.",
data_dir_not_writable:
"Нет прав на запись в {path} — бэкапы, тарифы, логотипы и переводы не сохранятся.",
backups_dir_not_writable: "Каталог бэкапов {path} недоступен для записи.",
tariffs_config_invalid: "Файл тарифов {path} не читается: {error}",
locale_overrides_invalid: "Файл переводов {path} повреждён: {error}",
subscription_page_config_invalid: "Конфиг гайдов подписки не читается: {error}",
provider_not_configured:
"Провайдер {provider} включён, но не настроен — оплата через него не работает.",
provider_webhook_needs_base_url:
"Провайдеру {provider} нужен WEBHOOK_BASE_URL для приёма вебхуков, а он не задан.",
no_payment_methods: "Не включён ни один способ оплаты.",
mini_app_url_missing: "SUBSCRIPTION_MINI_APP_URL не задан — кнопка Mini App в боте не появится.",
mini_app_url_not_https: "SUBSCRIPTION_MINI_APP_URL должен начинаться с https:// (сейчас {url}).",
redis_not_configured:
"REDIS_URL не задан — состояния диалогов бота и кэш не переживут перезапуск.",
smtp_incomplete: "SMTP настроен не полностью — вход по email работать не будет.",
proxy_not_trusted:
"Запросы приходят через прокси {remote}, которого нет в TRUSTED_PROXIES — вебхуки платёжных провайдеров могут отклоняться по IP.",
bot_token_invalid: "Telegram отверг BOT_TOKEN — бот не работает.",
telegram_api_error: "Не удалось обратиться к Telegram API: {error}",
telegram_webhook_missing: "Вебхук Telegram не установлен — бот не получает обновления.",
telegram_webhook_mismatch: "Вебхук Telegram указывает на {actual}, ожидается {expected}.",
telegram_webhook_error: "Telegram сообщает об ошибке доставки вебхука: {error}",
telegram_webhook_pending: "В очереди Telegram скопилось {count} необработанных обновлений.",
panel_api_not_configured:
"PANEL_API_URL и PANEL_API_KEY не заданы — синхронизация и выдача подписок не работают.",
panel_api_unreachable: "Панель Remnawave недоступна по адресу {url}.",
};
const SECTION_FALLBACK_LABELS = {
settings: "Настройки",
payments: "Платежи",
backups: "Бэкапы",
tariffs: "Тарифы",
appearance: "Внешний вид",
translations: "Переводы",
users: "Пользователи",
};
function interpolate(template, params = {}) {
return String(template || "").replace(/\{(\w+)\}/g, (match, key) =>
params[key] !== undefined && params[key] !== null ? String(params[key]) : match
);
}
function alertText(alert) {
const fallback = interpolate(MESSAGE_FALLBACKS[alert.message_key] || alert.message_key, alert.params);
return at(`health_${alert.message_key}`, alert.params || {}, fallback);
}
function sectionLabel(id) {
return at(`nav_${id}`, {}, SECTION_FALLBACK_LABELS[id] || id);
}
$: alerts = $healthStore?.alerts || [];
$: healthLoading = $healthStore?.healthLoading;
$: isDashboard = section === "stats";
$: visibleAlerts = isDashboard
? alerts
: alerts.filter((alert) => (alert.sections || []).includes(section));
$: errorCount = visibleAlerts.filter((alert) => alert.severity === "error").length;
</script>
{#if visibleAlerts.length}
<div
class="admin-config-alerts"
class:admin-config-alerts-error={errorCount > 0}
role="alert"
aria-live="polite"
>
<div class="admin-config-alerts-head">
<span class="admin-config-alerts-title">
<TriangleAlert size={15} />
{at("health_title", {}, "Проблемы конфигурации")}
</span>
{#if isDashboard}
<AdminButton
onclick={() => healthStore.loadHealth({ refresh: true })}
disabled={healthLoading}
>
<RefreshCw size={13} />
{at("health_refresh", {}, "Проверить снова")}
</AdminButton>
{/if}
</div>
<ul class="admin-config-alerts-list">
{#each visibleAlerts as alert (alert.id)}
<li class="admin-config-alert admin-config-alert-{alert.severity}">
<span class="admin-config-alert-dot" aria-hidden="true"></span>
<span class="admin-config-alert-text">{alertText(alert)}</span>
{#if isDashboard && (alert.sections || []).length}
<span class="admin-config-alert-links">
{#each alert.sections as sectionId (sectionId)}
<button
type="button"
class="admin-config-alert-link"
on:click={() => onNavigate(sectionId)}
>
{sectionLabel(sectionId)}
</button>
{/each}
</span>
{/if}
</li>
{/each}
</ul>
</div>
{/if}