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:
@@ -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}
|
||||
Reference in New Issue
Block a user