diff --git a/bot/app/web/frontend/src/admin/AdminPanel.svelte b/bot/app/web/frontend/src/admin/AdminPanel.svelte index 07592e4..a364c09 100644 --- a/bot/app/web/frontend/src/admin/AdminPanel.svelte +++ b/bot/app/web/frontend/src/admin/AdminPanel.svelte @@ -20,6 +20,7 @@ UsersRound, } from "$components/ui/icons.js"; import { onMount, setContext } from "svelte"; + import { fade } from "svelte/transition"; import { Select } from "$components/ui/primitives.js"; import { AdminBadge, AdminButton } from "$components/patterns/admin/index.js"; @@ -177,6 +178,15 @@ let adminLanguageClickGuardTimer = null; let adminLanguageClickGuardArmTimer = null; + function readReduceMotion() { + return ( + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); + } + + let reduceMotion = readReduceMotion(); + function flash(text) { onToast(text); } @@ -345,6 +355,16 @@ } onMount(() => { + reduceMotion = readReduceMotion(); + let motionMql = null; + const onMotionChange = () => { + reduceMotion = readReduceMotion(); + }; + if (typeof window !== "undefined" && typeof window.matchMedia === "function") { + motionMql = window.matchMedia("(prefers-reduced-motion: reduce)"); + reduceMotion = motionMql.matches; + motionMql.addEventListener("change", onMotionChange); + } if (typeof window !== "undefined" && typeof window.matchMedia === "function") { compactMql = window.matchMedia("(max-width: 720px)"); isCompact = compactMql.matches; @@ -355,6 +375,7 @@ window.addEventListener("popstate", onPopState); } return () => { + if (motionMql) motionMql.removeEventListener("change", onMotionChange); if (compactMql) { if (compactMql.removeEventListener) compactMql.removeEventListener("change", onCompactChange); @@ -365,6 +386,9 @@ }; }); + $: sectionFade = reduceMotion ? { duration: 0 } : { duration: 200 }; + $: sidebarBackdropFade = reduceMotion ? { duration: 0 } : { duration: 180 }; + $: if ( active === "users" && initialUserId && @@ -380,6 +404,8 @@ type="button" class="admin-sidebar-backdrop" aria-label={at("close_menu", {}, "Закрыть меню")} + in:fade={sidebarBackdropFade} + out:fade={sidebarBackdropFade} on:click={() => (sidebarOpen = false)} > {/if} @@ -563,55 +589,59 @@
- {#if active === "stats"} - - {/if} + {#key active} +
+ {#if active === "stats"} + + {/if} - {#if active === "users"} - - {/if} + {#if active === "users"} + + {/if} - {#if active === "payments"} - - {/if} + {#if active === "payments"} + + {/if} - {#if active === "promos"} - - {/if} + {#if active === "promos"} + + {/if} - {#if active === "ads"} - - {/if} + {#if active === "ads"} + + {/if} - {#if active === "broadcast"} - - {/if} + {#if active === "broadcast"} + + {/if} - {#if active === "logs"} - - {/if} + {#if active === "logs"} + + {/if} - {#if active === "tariffs"} - - {/if} + {#if active === "tariffs"} + + {/if} - {#if active === "settings"} - - {/if} + {#if active === "settings"} + + {/if} +
+ {/key}
diff --git a/bot/app/web/frontend/src/admin/sections/StatsSection.svelte b/bot/app/web/frontend/src/admin/sections/StatsSection.svelte index 3078e59..cefeac5 100644 --- a/bot/app/web/frontend/src/admin/sections/StatsSection.svelte +++ b/bot/app/web/frontend/src/admin/sections/StatsSection.svelte @@ -10,10 +10,18 @@ AdminDashboardStack, AdminBadge, AdminEmptyState, + AdminRevenueChart, + AdminRevenueCustomRangePopover, AdminSectionHeader, AdminTable, AdminTableSkeleton, } from "$components/patterns/admin/index.js"; + import { + aggregateRevenueSeries, + filterDailyByIsoRange, + inclusiveDaySpan, + sliceLastDays, + } from "../../lib/admin/revenueSeriesAgg.js"; export let at; export let fmtDate = (value) => value; @@ -35,12 +43,78 @@ $: panelBw = panelPayload && !panelPayload.error ? parsePanelBandwidth(panelPayload) : null; $: panelNodeTraffic = panelPayload && !panelPayload.error ? parsePanelNodeTraffic(panelPayload) : null; + /** Same rows as the «Per node (7 days)» block — not system.nodes.totalOnline from /system/stats */ + $: panelNodesListedCount = panelNodeTraffic?.seven?.length ?? 0; const PANEL_NODE_TILE_LIMIT = 10; + const REVENUE_CHART_MAX_CSS_HEIGHT = 204; + + const REVENUE_PRESET_DAYS = [7, 14, 30, 90, 180, 365]; + + /** @type {"preset" | "custom"} */ + let revenueRangeMode = "preset"; + let revenuePresetDays = 14; + /** @type {{ from: string; to: string } | null} */ + let revenueCustomIso = null; + /** @type {"day" | "week" | "month"} */ + let revenueGranularity = "day"; + let revenueCustomPopoverOpen = false; + $: dailySeries = Array.isArray(fin.daily_series) ? fin.daily_series : []; + $: revenueBoundsIso = + dailySeries.length > 0 + ? { min: dailySeries[0].date, max: dailySeries[dailySeries.length - 1].date } + : null; + + $: revenueDailyFiltered = (() => { + if (!dailySeries.length) return []; + if (revenueRangeMode === "custom" && revenueCustomIso) { + return filterDailyByIsoRange(dailySeries, revenueCustomIso.from, revenueCustomIso.to); + } + return sliceLastDays(dailySeries, revenuePresetDays); + })(); + + $: revenueChartSeries = aggregateRevenueSeries(revenueDailyFiltered, revenueGranularity); + $: revenueKpis = computeRevenueKpis(fin, dailySeries); - $: chartModel = buildRevenueChartModel(dailySeries, fmtDateShort); + $: chartRangeSum = revenueChartSeries.reduce((a, p) => a + (Number(p.amount) || 0), 0); + + function setRevenuePresetDays(days) { + const next = Number(days); + if (!REVENUE_PRESET_DAYS.includes(next)) return; + revenueRangeMode = "preset"; + revenuePresetDays = next; + revenueCustomPopoverOpen = false; + } + + function onCustomRangeApply({ fromIso, toIso }) { + revenueRangeMode = "custom"; + revenueCustomIso = { from: fromIso, to: toIso }; + } + + function setRevenueGranularity(next) { + const g = String(next); + if (g !== "day" && g !== "week" && g !== "month") return; + revenueGranularity = g; + } + + function revenuePeriodLabel(days) { + return at(`stats_revenue_period_${days}`, {}, `${days}d`); + } + + function revenueChartHintKey() { + if (revenueGranularity === "week") return "stats_revenue_chart_hint_week"; + if (revenueGranularity === "month") return "stats_revenue_chart_hint_month"; + return "stats_revenue_chart_hint"; + } + + $: revenueChartShortfall = + revenueRangeMode === "preset" && dailySeries.length < revenuePresetDays; + $: revenueCustomDaySpan = + revenueRangeMode === "custom" && revenueCustomIso + ? inclusiveDaySpan(revenueCustomIso.from, revenueCustomIso.to) + : 0; $: recentPaymentHeaders = [ at("id", {}, ""), at("user", {}, ""), @@ -60,7 +134,6 @@ const memTotal = Number(mem.total) || 0; const memUsed = Number(mem.used) || 0; const memPct = memTotal > 0 ? (memUsed / memTotal) * 100 : null; - const nodes = system.nodes || {}; const cpuRaw = system.cpu?.usage ?? system.cpu?.usedPercent ?? @@ -75,7 +148,6 @@ expired: statusCounts.EXPIRED ?? 0, limited: statusCounts.LIMITED ?? 0, totalPanelUsers: u.totalUsers ?? 0, - nodesOnline: nodes.totalOnline != null ? nodes.totalOnline : null, memPct, cpuPct: Number.isFinite(cpuPct) ? cpuPct : null, }; @@ -396,44 +468,12 @@ const tc = Number(financial.today_payments_count) || 0; const tr = Number(financial.today_revenue) || 0; const avgToday = tc > 0 ? tr / tc : null; - const total14 = amounts.reduce((a, b) => a + b, 0); - const maxY = Math.max(...amounts, 1e-9); + const tail14 = n >= 14 ? amounts.slice(-14) : amounts; + const total14 = tail14.reduce((a, b) => a + b, 0); + const maxY = amounts.length ? Math.max(...amounts, 1e-9) : 1e-9; return { last7, prev7, growthPct, avgToday, total14, maxY, amounts, n }; } - function buildRevenueChartModel(series, fmtShort) { - const W = 360; - const H = 168; - const pad = { t: 12, r: 10, b: 26, l: 8 }; - const innerW = W - pad.l - pad.r; - const innerH = H - pad.t - pad.b; - const amounts = series.map((p) => Number(p.amount) || 0); - const n = amounts.length; - if (!n) return null; - const maxY = Math.max(...amounts, 1e-9); - const pts = amounts.map((amt, i) => { - const x = pad.l + (n <= 1 ? innerW / 2 : (i / (n - 1)) * innerW); - const y = pad.t + innerH - (amt / maxY) * innerH; - return { x, y, amt, date: series[i]?.date }; - }); - const lineD = pts - .map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(1)},${p.y.toFixed(1)}`) - .join(""); - const baseY = (pad.t + innerH).toFixed(1); - const areaD = `${lineD} L${pts[n - 1].x.toFixed(1)},${baseY} L${pts[0].x.toFixed(1)},${baseY} Z`; - const pickIdx = (() => { - if (n === 1) return [0]; - const u = new Set([0, Math.floor((n - 1) / 3), Math.floor((2 * (n - 1)) / 3), n - 1]); - return [...u].sort((a, b) => a - b); - })(); - const ticks = pickIdx.map((i) => ({ - x: pts[i].x, - label: fmtShort(series[i]?.date), - })); - const gridYs = [0.33, 0.66].map((g) => pad.t + innerH * (1 - g)); - return { lineD, areaD, ticks, W, H, maxY, gridYs, pad, innerW, innerH }; - } - function growthBadgeVariant(pct) { if (pct == null) return "outline"; if (pct >= 0) return "default"; @@ -470,26 +510,139 @@ {/each} - + + + - -
-
+ + +
+
+ +
+
+
+
+
- - + + +
+ +
+
+ + +
+
+ {#each Array(4) as _, m (m)} +
+ + + +
+ {/each} +
+
+
+
+
+ + + +
+ + +
+
+
+ + + + + + + + + + {:else if stats} @@ -633,50 +786,104 @@
-
{at("stats_revenue_chart_title", {}, "")}
- {#if chartModel} - - {#if panelMetrics.nodesOnline != null} -
+ {#if panelNodesListedCount > 0} +
{at("stats_panel_nodes_online", {}, "")}
-
{panelMetrics.nodesOnline}
+
{panelNodesListedCount}
{/if} {#if panelMetrics.memPct != null} diff --git a/bot/app/web/frontend/src/lib/admin/revenueSeriesAgg.js b/bot/app/web/frontend/src/lib/admin/revenueSeriesAgg.js new file mode 100644 index 0000000..fefcfb5 --- /dev/null +++ b/bot/app/web/frontend/src/lib/admin/revenueSeriesAgg.js @@ -0,0 +1,121 @@ +/** @typedef {{ date: string, amount: number }} RevenuePoint */ + +/** + * @param {string} iso + * @returns {number} UTC ms at noon (stable day bucket) + */ +function noonUtcMs(iso) { + const s = String(iso || ""); + const t = Date.parse(s.includes("T") ? s : `${s}T12:00:00Z`); + return Number.isFinite(t) ? t : 0; +} + +/** + * @param {number} t + * @returns {string} YYYY-MM-DD UTC + */ +function isoUtcDateFromMs(t) { + const d = new Date(t); + const y = d.getUTCFullYear(); + const m = String(d.getUTCMonth() + 1).padStart(2, "0"); + const day = String(d.getUTCDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +/** + * Monday 00:00 UTC for the week containing `iso` (date-only). + * @param {string} iso + */ +export function utcWeekStartMs(iso) { + const d = new Date(iso.includes("T") ? iso : `${iso}T12:00:00Z`); + const dow = d.getUTCDay(); + const offset = (dow + 6) % 7; + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - offset); +} + +/** + * First day of month (UTC) containing `iso`. + * @param {string} iso + */ +export function utcMonthStartMs(iso) { + const d = new Date(iso.includes("T") ? iso : `${iso}T12:00:00Z`); + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1); +} + +/** + * @param {RevenuePoint[]} points sorted ascending by `date` + * @param {string} fromIso YYYY-MM-DD inclusive + * @param {string} toIso YYYY-MM-DD inclusive + * @returns {RevenuePoint[]} + */ +export function filterDailyByIsoRange(points, fromIso, toIso) { + if (!fromIso || !toIso) return []; + return points.filter((p) => p.date >= fromIso && p.date <= toIso); +} + +/** + * @param {RevenuePoint[]} points sorted ascending + * @param {number} n + */ +export function sliceLastDays(points, n) { + if (!points?.length || n <= 0) return []; + const take = Math.min(n, points.length); + return points.slice(-take); +} + +/** + * @param {RevenuePoint[]} daily sorted ascending, day granularity + * @returns {RevenuePoint[]} + */ +function bucketWeeks(daily) { + /** @type {Map} */ + const sums = new Map(); + for (const p of daily) { + const k = utcWeekStartMs(p.date); + const amt = Number(p.amount) || 0; + sums.set(k, (sums.get(k) || 0) + amt); + } + return [...sums.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([ms, amount]) => ({ date: isoUtcDateFromMs(ms), amount })); +} + +/** + * @param {RevenuePoint[]} daily sorted ascending + * @returns {RevenuePoint[]} + */ +function bucketMonths(daily) { + /** @type {Map} */ + const sums = new Map(); + for (const p of daily) { + const k = utcMonthStartMs(p.date); + const amt = Number(p.amount) || 0; + sums.set(k, (sums.get(k) || 0) + amt); + } + return [...sums.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([ms, amount]) => ({ date: isoUtcDateFromMs(ms), amount })); +} + +/** + * @param {RevenuePoint[]} dailySorted ascending by date, consecutive calendar days + * @param {"day" | "week" | "month"} granularity + */ +export function aggregateRevenueSeries(dailySorted, granularity) { + if (!dailySorted?.length) return []; + if (granularity === "week") return bucketWeeks(dailySorted); + if (granularity === "month") return bucketMonths(dailySorted); + return dailySorted.map((p) => ({ date: p.date, amount: Number(p.amount) || 0 })); +} + +/** + * For chart hint: calendar span of inclusive range. + * @param {string} fromIso + * @param {string} toIso + */ +export function inclusiveDaySpan(fromIso, toIso) { + const a = noonUtcMs(fromIso); + const b = noonUtcMs(toIso); + if (!a || !b) return 0; + return Math.max(1, Math.round((b - a) / 86400000) + 1); +} diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminRevenueChart.svelte b/bot/app/web/frontend/src/lib/components/patterns/admin/AdminRevenueChart.svelte new file mode 100644 index 0000000..8bd3798 --- /dev/null +++ b/bot/app/web/frontend/src/lib/components/patterns/admin/AdminRevenueChart.svelte @@ -0,0 +1,185 @@ + + +
diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminRevenueCustomRangePopover.svelte b/bot/app/web/frontend/src/lib/components/patterns/admin/AdminRevenueCustomRangePopover.svelte new file mode 100644 index 0000000..de8df3a --- /dev/null +++ b/bot/app/web/frontend/src/lib/components/patterns/admin/AdminRevenueCustomRangePopover.svelte @@ -0,0 +1,141 @@ + + + + + {triggerLabel} + + + + {#if title} +
{title}
+ {/if} + {#if minIso && maxIso} + + {#snippet children({ months, weekdays })} + + + + + + + + + +
+ {#each months as month (month.value.month)} + + + + {#each weekdays as wd (wd)} + + {wd.slice(0, 2)} + + {/each} + + + + {#each month.weeks as weekDates, wi (wi)} + + {#each weekDates as cellDate, di (`${wi}-${di}-${cellDate.toString()}`)} + + + {cellDate.day} + + + {/each} + + {/each} + + + {/each} +
+ {/snippet} +
+ {/if} +
+ +
+
+
+
diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/index.js b/bot/app/web/frontend/src/lib/components/patterns/admin/index.js index f44099e..60836e5 100644 --- a/bot/app/web/frontend/src/lib/components/patterns/admin/index.js +++ b/bot/app/web/frontend/src/lib/components/patterns/admin/index.js @@ -5,6 +5,8 @@ export { default as AdminDashboardStack } from "./AdminDashboardStack.svelte"; export { default as AdminEmptyState } from "./AdminEmptyState.svelte"; export { default as AdminField } from "./AdminField.svelte"; export { default as AdminPagination } from "./AdminPagination.svelte"; +export { default as AdminRevenueChart } from "./AdminRevenueChart.svelte"; +export { default as AdminRevenueCustomRangePopover } from "./AdminRevenueCustomRangePopover.svelte"; export { default as AdminSelect } from "./AdminSelect.svelte"; export { default as AdminSectionHeader } from "./AdminSectionHeader.svelte"; export { default as AdminTable } from "./AdminTable.svelte"; diff --git a/bot/app/web/frontend/src/lib/components/ui/dialog.svelte b/bot/app/web/frontend/src/lib/components/ui/dialog.svelte index bb752c5..4256200 100644 --- a/bot/app/web/frontend/src/lib/components/ui/dialog.svelte +++ b/bot/app/web/frontend/src/lib/components/ui/dialog.svelte @@ -1,6 +1,9 @@ {#if open}