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