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
+11
View File
@@ -45,9 +45,11 @@
import AppearanceSection from "./sections/AppearanceSection.svelte";
import UserDetailModal from "./sections/UserDetailModal.svelte";
import UsersSection from "./sections/UsersSection.svelte";
import ConfigAlertsBanner from "./ConfigAlertsBanner.svelte";
import { createAdsStore } from "../lib/admin/stores/adsStore.js";
import { createBackupsStore } from "../lib/admin/stores/backupsStore.js";
import { createBroadcastStore } from "../lib/admin/stores/broadcastStore.js";
import { createHealthStore } from "../lib/admin/stores/healthStore.js";
import { createLogsStore } from "../lib/admin/stores/logsStore.js";
import { createPaymentsStore } from "../lib/admin/stores/paymentsStore.js";
import { createPromosStore } from "../lib/admin/stores/promosStore.js";
@@ -247,6 +249,7 @@
const adsStore = createAdsStore({ api, onToast: flash, at });
const backupsStore = createBackupsStore({ api, onToast: flash, at });
const broadcastStore = createBroadcastStore({ api, onToast: flash, at });
const healthStore = createHealthStore({ api });
const logsStore = createLogsStore({ api, at });
const paymentsStore = createPaymentsStore({ api, onToast: flash, at, routePrefix });
const promosStore = createPromosStore({ api, onToast: flash, at });
@@ -260,6 +263,7 @@
setContext("promosStore", promosStore);
setContext("adsStore", adsStore);
setContext("healthStore", healthStore);
setContext("backupsStore", backupsStore);
setContext("broadcastStore", broadcastStore);
setContext("logsStore", logsStore);
@@ -531,6 +535,11 @@
window.addEventListener("popstate", onPopState);
}
void broadcastStore.loadCounts();
void healthStore.loadHealth();
const healthTimer =
typeof window !== "undefined"
? window.setInterval(() => void healthStore.loadHealth(), 5 * 60 * 1000)
: null;
return () => {
if (motionMql) motionMql.removeEventListener("change", onMotionChange);
if (compactMql) {
@@ -539,6 +548,7 @@
else if (compactMql.removeListener) compactMql.removeListener(onCompactChange);
}
if (typeof window !== "undefined") window.removeEventListener("popstate", onPopState);
if (healthTimer !== null) window.clearInterval(healthTimer);
clearAdminLanguageClickGuard();
};
});
@@ -802,6 +812,7 @@
</header>
<main class="admin-main">
<ConfigAlertsBanner {at} section={active} onNavigate={setActive} />
{#key active}
<div class="admin-section-stage" in:fade={sectionFade} out:fade={sectionFade}>
{#if active === "stats"}
@@ -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}
@@ -0,0 +1,35 @@
import { writable } from "svelte/store";
export function createHealthStore({ api }) {
const state = writable({
alerts: [],
checkedAt: null,
healthLoading: false,
healthError: "",
});
async function loadHealth({ refresh = false } = {}) {
state.update((s) => ({ ...s, healthLoading: true, healthError: "" }));
try {
const data = await api(`/admin/health${refresh ? "?refresh=1" : ""}`);
if (!data?.ok) {
state.update((s) => ({ ...s, healthError: data?.error || "load_failed" }));
} else {
state.update((s) => ({
...s,
alerts: Array.isArray(data.alerts) ? data.alerts : [],
checkedAt: data.checked_at || null,
}));
}
} catch (e) {
state.update((s) => ({ ...s, healthError: e?.message || String(e) }));
} finally {
state.update((s) => ({ ...s, healthLoading: false }));
}
}
return {
subscribe: state.subscribe,
loadHealth,
};
}
+23
View File
@@ -762,6 +762,29 @@ function demoApiResponse(path, cleanPath, options, context) {
}
if (cleanPath === "/admin/sync") return { ok: true, status: "queued" };
if (cleanPath === "/admin/health") {
return {
ok: true,
alerts: [
{
id: "provider_not_configured:wata",
severity: "error",
sections: ["settings"],
message_key: "provider_not_configured",
params: { provider: "Wata" },
},
{
id: "mini_app_url_missing",
severity: "warning",
sections: ["settings"],
message_key: "mini_app_url_missing",
params: {},
},
],
checked_at: new Date().toISOString(),
};
}
if (cleanPath === "/admin/payments") {
const page = paged(DEMO_DATASET.adminPayments || [], params, 25);
return {
+89
View File
@@ -372,6 +372,95 @@
width: 100%;
}
.admin-config-alerts {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 14px;
border: 1px solid var(--warning-border);
border-radius: 12px;
background: var(--warning-soft);
color: var(--warning-text);
}
.admin-config-alerts-error {
border-color: var(--danger-border);
background: var(--danger-soft);
color: var(--danger-text);
}
.admin-config-alerts-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.admin-config-alerts-title {
display: inline-flex;
align-items: center;
gap: 7px;
font-weight: 600;
font-size: 13px;
}
.admin-config-alerts-list {
display: flex;
flex-direction: column;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.admin-config-alert {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12.5px;
line-height: 1.45;
}
.admin-config-alert-dot {
flex-shrink: 0;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--warning-text);
transform: translateY(-1px);
}
.admin-config-alert-error .admin-config-alert-dot {
background: var(--danger-text);
}
.admin-config-alert-text {
min-width: 0;
overflow-wrap: anywhere;
}
.admin-config-alert-links {
display: inline-flex;
gap: 6px;
flex-wrap: wrap;
}
.admin-config-alert-link {
border: 1px solid currentColor;
border-radius: 999px;
padding: 1px 9px;
font-size: 11px;
background: transparent;
color: inherit;
cursor: pointer;
opacity: 0.85;
}
.admin-config-alert-link:hover {
opacity: 1;
}
.admin-section-stage:has(.support-admin-layout) {
flex: 1 1 auto;
min-height: 0;