feat: tune admin panel visual, add animations, expand dashboard

This commit is contained in:
3252a8
2026-05-12 23:40:41 +03:00
parent 94612e11f2
commit defbac43d7
17 changed files with 1297 additions and 183 deletions
@@ -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)}
></button>
{/if}
@@ -563,55 +589,59 @@
</header>
<main class="admin-main">
{#if active === "stats"}
<StatsSection {at} {fmtDate} {fmtDateShort} {fmtMoney} {paymentStatusVariant} />
{/if}
{#key active}
<div class="admin-section-stage" in:fade={sectionFade} out:fade={sectionFade}>
{#if active === "stats"}
<StatsSection {at} {fmtDate} {fmtDateShort} {fmtMoney} {paymentStatusVariant} />
{/if}
{#if active === "users"}
<UsersSection
{at}
{fmtDateShort}
{panelStatusBadge}
{resolvedAvatarUrl}
{userDisplayName}
{userInitials}
{userSecondaryName}
/>
{/if}
{#if active === "users"}
<UsersSection
{at}
{fmtDateShort}
{panelStatusBadge}
{resolvedAvatarUrl}
{userDisplayName}
{userInitials}
{userSecondaryName}
/>
{/if}
{#if active === "payments"}
<PaymentsSection
{at}
{fmtDate}
{fmtMoney}
{paymentStatusVariant}
onOpenUserCard={openPaymentUserCard}
/>
{/if}
{#if active === "payments"}
<PaymentsSection
{at}
{fmtDate}
{fmtMoney}
{paymentStatusVariant}
onOpenUserCard={openPaymentUserCard}
/>
{/if}
{#if active === "promos"}
<PromosSection {at} {fmtDateShort} />
{/if}
{#if active === "promos"}
<PromosSection {at} {fmtDateShort} />
{/if}
{#if active === "ads"}
<AdsSection {at} {fmtMoney} />
{/if}
{#if active === "ads"}
<AdsSection {at} {fmtMoney} />
{/if}
{#if active === "broadcast"}
<BroadcastSection {at} />
{/if}
{#if active === "broadcast"}
<BroadcastSection {at} />
{/if}
{#if active === "logs"}
<LogsSection {at} {fmtDate} />
{/if}
{#if active === "logs"}
<LogsSection {at} {fmtDate} />
{/if}
{#if active === "tariffs"}
<TariffsSection {at} {fmtMoney} />
{/if}
{#if active === "tariffs"}
<TariffsSection {at} {fmtMoney} />
{/if}
{#if active === "settings"}
<SettingsSection {at} {isCompact} {onSettingsSaved} {currentLang} />
{/if}
{#if active === "settings"}
<SettingsSection {at} {isCompact} {onSettingsSaved} {currentLang} />
{/if}
</div>
{/key}
</main>
</section>
</div>
@@ -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 @@
</Card.Root>
{/each}
</AdminDashboardGrid>
<Card.Root class="admin-cn-card-skeleton">
<AdminSectionHeader
title={at("stats_section_revenue", {}, "")}
description={at("stats_section_revenue_hint", {}, "")}
/>
<Card.Root class="admin-cn-card-skeleton admin-cn-card-skeleton--tall">
<Card.Header>
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span>
<span
class="admin-skeleton admin-skeleton-line admin-skeleton-line-strong"
style="width:40%"
style="width:48%"
></span>
</Card.Header>
<Card.Content class="admin-cn-card-content--flush">
<div class="admin-revenue-svg-frame">
<div class="admin-skeleton" style="height:168px;border-radius:10px;"></div>
<Card.Content>
<div class="admin-revenue-kpis" aria-hidden="true">
{#each Array(6) as _, i (i)}
<div class="admin-revenue-kpi">
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-tiny" style="width:72%"
></span>
<span
class="admin-skeleton admin-skeleton-line admin-skeleton-line-strong"
style="width:58%;height:20px;margin-top:4px"
></span>
</div>
{/each}
<div class="admin-revenue-kpi admin-revenue-kpi--wide">
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-tiny" style="width:46%"
></span>
<span
class="admin-skeleton admin-skeleton-line admin-skeleton-line-strong"
style="width:36%;height:20px;margin-top:4px"
></span>
<span class="admin-skeleton admin-skeleton-line" style="width:92%;height:9px;margin-top:6px"
></span>
</div>
</div>
<div class="admin-revenue-chart">
<div class="admin-revenue-chart-title">
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-tiny" style="width:42%"
></span>
</div>
<div class="admin-revenue-svg-frame">
<div
class="admin-skeleton admin-revenue-chart-skeleton"
style="display:block;width:100%;border-radius:0"
></div>
</div>
<div class="admin-revenue-xlabels" aria-hidden="true">
{#each Array(4) as _, j (j)}
<span
class="admin-skeleton admin-skeleton-line"
style="display:block;height:8px;flex:1;max-width:24%"
></span>
{/each}
</div>
</div>
</Card.Content>
</Card.Root>
<AdminSectionHeader title={at("stats_recent_payments", {}, "")} />
<AdminTableSkeleton
headers={recentPaymentHeaders}
rows={6}
widths={["48px", "120px", "78px", "82px", "72px", "96px"]}
<AdminSectionHeader
title={at("stats_section_panel", {}, "")}
description={at("stats_section_panel_hint", {}, "")}
/>
<Card.Root class="admin-cn-card-skeleton admin-cn-card-skeleton--tall">
<Card.Content class="admin-cn-card-content admin-panel-dash-card">
<div class="admin-panel-dash">
<div class="admin-panel-dash-tiles" aria-hidden="true">
{#each Array(9) as _, k (k)}
<div class="admin-panel-dash-tile">
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-tiny" style="width:58%"
></span>
<span
class="admin-skeleton admin-skeleton-line admin-skeleton-line-strong"
style="width:44%;height:22px;margin-top:6px"
></span>
</div>
{/each}
</div>
<div class="admin-panel-dash-nodes">
<div class="admin-panel-dash-nodes-head">
<span class="admin-skeleton admin-skeleton-line" style="width:40%;height:12px"></span>
<span class="admin-skeleton admin-skeleton-line" style="width:78%;height:9px;margin-top:6px"
></span>
</div>
<div class="admin-panel-dash-nodes-grid">
{#each Array(4) as _, m (m)}
<div class="admin-panel-dash-node">
<span class="admin-skeleton admin-skeleton-line" style="width:82%"></span>
<span
class="admin-skeleton admin-skeleton-line admin-skeleton-line-strong"
style="width:52%;height:16px;margin-top:6px"
></span>
<span
class="admin-skeleton admin-skeleton-line admin-skeleton-line-tiny"
style="width:44%;margin-top:6px"
></span>
</div>
{/each}
</div>
</div>
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Content class="admin-cn-card-content--flush" style="padding-top:12px;padding-bottom:12px;">
<div class="admin-sync-strip" style="border:0;background:transparent;padding:0;">
<span
class="admin-skeleton admin-skeleton-line"
style="display:block;width:min(100%, 340px);height:12px"
></span>
<span
class="admin-skeleton admin-skeleton-line"
style="display:block;width:min(100%, 220px);height:11px;margin-top:8px"
></span>
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header class="admin-cn-card-header--lead">
<span class="admin-skeleton admin-skeleton-line" style="width:44%;height:14px"></span>
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-tiny" style="width:30%;margin-top:8px"
></span>
</Card.Header>
<Card.Content class="admin-cn-card-content--flush">
<AdminTableSkeleton
headers={recentPaymentHeaders}
rows={5}
widths={["48px", "120px", "78px", "82px", "72px", "96px"]}
/>
</Card.Content>
</Card.Root>
</AdminDashboardStack>
{:else if stats}
<AdminDashboardStack>
@@ -633,50 +786,104 @@
</div>
<div class="admin-revenue-chart">
<div class="admin-revenue-chart-title">{at("stats_revenue_chart_title", {}, "")}</div>
{#if chartModel}
<div
class="admin-revenue-svg-frame"
role="img"
aria-label={at("stats_revenue_chart_aria", {}, "")}
>
<svg
class="admin-revenue-svg"
viewBox="0 0 {chartModel.W} {chartModel.H}"
preserveAspectRatio="none"
<div class="admin-revenue-chart-head">
<div class="admin-revenue-chart-title">{at("stats_revenue_chart_title", {}, "")}</div>
<div class="admin-revenue-chart-toolbar">
<div
class="admin-revenue-period"
role="tablist"
aria-label={at("stats_revenue_chart_aria", {}, "")}
>
<defs>
<linearGradient id="adminRevenueFillDashboard" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="var(--admin-muted)" stop-opacity="0.28" />
<stop offset="100%" stop-color="var(--admin-muted)" stop-opacity="0" />
</linearGradient>
</defs>
{#each chartModel.gridYs as gy}
<line
x1={chartModel.pad.l}
y1={gy}
x2={chartModel.W - chartModel.pad.r}
y2={gy}
stroke="var(--admin-border)"
stroke-opacity="0.45"
stroke-width="1"
/>
{#each REVENUE_PRESET_DAYS as d (d)}
<button
type="button"
class="admin-revenue-period-btn"
class:is-active={revenueRangeMode === "preset" && revenuePresetDays === d}
role="tab"
aria-selected={revenueRangeMode === "preset" && revenuePresetDays === d}
on:click={() => setRevenuePresetDays(d)}
>
{revenuePeriodLabel(d)}
</button>
{/each}
<path d={chartModel.areaD} fill="url(#adminRevenueFillDashboard)" stroke="none" />
<path
d={chartModel.lineD}
fill="none"
stroke="color-mix(in srgb, var(--admin-muted) 55%, var(--admin-text))"
stroke-width="2"
stroke-linejoin="round"
stroke-linecap="round"
/>
</svg>
</div>
<AdminRevenueCustomRangePopover
bind:open={revenueCustomPopoverOpen}
minIso={revenueBoundsIso?.min ?? ""}
maxIso={revenueBoundsIso?.max ?? ""}
committedFrom={revenueCustomIso?.from ?? ""}
committedTo={revenueCustomIso?.to ?? ""}
title={at("stats_revenue_custom_range_title", {}, "")}
triggerLabel={at("stats_revenue_period_custom", {}, "Custom")}
applyLabel={at("stats_revenue_custom_range_apply", {}, "Apply")}
isActive={revenueRangeMode === "custom"}
onApply={onCustomRangeApply}
/>
</div>
<div class="admin-revenue-xlabels">
{#each chartModel.ticks as tk}
<span>{tk.label}</span>
{/each}
</div>
<div
class="admin-revenue-granularity"
role="tablist"
aria-label={at("stats_revenue_granularity_aria", {}, "")}
>
{#each ["day", "week", "month"] as g (g)}
<button
type="button"
class="admin-revenue-period-btn admin-revenue-period-btn--compact"
class:is-active={revenueGranularity === g}
role="tab"
aria-selected={revenueGranularity === g}
on:click={() => setRevenueGranularity(g)}
>
{at(`stats_revenue_granularity_${g}`, {}, g)}
</button>
{/each}
</div>
<p class="admin-revenue-chart-hint admin-muted">{at(revenueChartHintKey(), {}, "")}</p>
{#if revenueChartSeries.length}
<div class="admin-revenue-chart-meta admin-muted">
<span
>{at(
"stats_revenue_chart_range_sum",
{ value: fmtMoney(chartRangeSum, currency) },
""
)}</span
>
{#if revenueGranularity !== "day"}
<span class="admin-revenue-chart-meta-sep" aria-hidden="true">·</span>
<span
>{at("stats_revenue_chart_bucket_count", { count: revenueChartSeries.length }, "")}</span
>
{/if}
{#if revenueChartShortfall}
<span class="admin-revenue-chart-meta-sep" aria-hidden="true">·</span>
<span
>{at(
"stats_revenue_chart_days_available",
{ count: dailySeries.length },
""
)}</span
>
{:else if revenueRangeMode === "custom" && revenueCustomDaySpan > 0}
<span class="admin-revenue-chart-meta-sep" aria-hidden="true">·</span>
<span
>{at(
"stats_revenue_chart_custom_span",
{ days: revenueCustomDaySpan },
""
)}</span
>
{/if}
</div>
<div class="admin-revenue-svg-frame admin-revenue-svg-frame--chart">
<AdminRevenueChart
series={revenueChartSeries}
plotHeight={REVENUE_CHART_MAX_CSS_HEIGHT}
{fmtMoney}
{currency}
legendTimeLabel={at("stats_revenue_chart_uplot_time", {}, "Time")}
legendValueLabel={at("stats_revenue_chart_uplot_value", {}, "Value")}
/>
</div>
{:else}
<p class="admin-muted">{at("stats_revenue_no_chart", {}, "")}</p>
@@ -736,14 +943,17 @@
<div class="admin-panel-dash-tile-label">{at("stats_panel_limited", {}, "")}</div>
<div class="admin-panel-dash-tile-value">{panelMetrics.limited}</div>
</div>
{#if panelMetrics.nodesOnline != null}
<div class="admin-panel-dash-tile">
{#if panelNodesListedCount > 0}
<div
class="admin-panel-dash-tile"
title={at("stats_panel_nodes_online_hint", {}, "")}
>
<div class="admin-panel-dash-tile-label">
<span class="admin-panel-dash-ico" aria-hidden="true"><Server size={12} /></span
>
{at("stats_panel_nodes_online", {}, "")}
</div>
<div class="admin-panel-dash-tile-value">{panelMetrics.nodesOnline}</div>
<div class="admin-panel-dash-tile-value">{panelNodesListedCount}</div>
</div>
{/if}
{#if panelMetrics.memPct != null}
@@ -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<number, number>} */
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<number, number>} */
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);
}
@@ -0,0 +1,185 @@
<script>
import { onMount, tick } from "svelte";
import uPlot from "uplot";
import "uplot/dist/uPlot.min.css";
/** `{ date: ISO date string, amount: number }[]` */
export let series = [];
/** Total plot height in CSS px (axes + canvas). */
export let plotHeight = 204;
export let fmtMoney = (v, _currency) => String(v);
/** @type {string} */
export let currency = "RUB";
/** uPlot live legend: column header for the time (x) series */
export let legendTimeLabel = "Time";
/** uPlot live legend: column header for the value (y) series */
export let legendValueLabel = "Value";
let hostEl;
let plot;
let resizeObserver;
let syncTimer = 0;
/** Rebuild plot when legend copy changes (language), since series labels are init-only */
let builtLegendSig = "";
function readCssColor(name, fallback) {
if (typeof document === "undefined") return fallback;
const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return raw || fallback;
}
function parseDayUnix(iso) {
const s = String(iso || "");
const t = Date.parse(s.includes("T") ? s : `${s}T12:00:00Z`);
if (!Number.isFinite(t)) return 0;
return Math.floor(t / 1000);
}
function toAlignedData(rows) {
if (!rows?.length) return null;
const xs = rows.map((p) => parseDayUnix(p.date));
const ys = rows.map((p) => Number(p.amount) || 0);
return [xs, ys];
}
function yAxisTickLabels(values) {
return values.map((v) => fmtMoney(Number(v), currency));
}
/** uPlot passes already-formatted tick strings; reserve enough gutter so amounts are not clipped */
function yAxisGutterWidth(_u, values) {
const pad = 14;
const charPx = 6.1;
const maxChars = (values || []).reduce((m, v) => Math.max(m, String(v ?? "").length), 0);
return Math.min(104, Math.max(58, Math.ceil(pad + maxChars * charPx)));
}
/** Axis `size`: height (x / bottom) or width (y / left) in CSS px — only customize the y gutter */
function axisBandSize(_u, values, axisIdx) {
if (axisIdx !== 1) return 32;
return yAxisGutterWidth(_u, values);
}
function buildOpts(width) {
const w = Math.max(80, Math.floor(width));
const muted = readCssColor("--admin-muted", "#9aa7a2");
const border = readCssColor("--admin-border", "rgba(255,255,255,0.12)");
const accent = readCssColor("--accent", "#00fe7a");
const lineStroke = readCssColor("--admin-text", "#e8f0ec");
return {
width: w,
height: plotHeight,
class: "admin-uplot",
pxAlign: true,
padding: [10, 12, 12, 10],
legend: {
show: true,
live: true,
markers: { show: true, width: 10, stroke: accent, fill: accent },
},
cursor: {
drag: { x: false, y: false },
points: { size: 7, width: 1, stroke: accent },
},
scales: {
x: { time: true },
y: { range: [0, null] },
},
series: [
{ label: legendTimeLabel },
{
label: legendValueLabel,
paths: uPlot.paths.spline(),
stroke: lineStroke,
width: 2,
cap: "round",
fill: "rgba(120, 140, 132, 0.14)",
},
],
axes: [
{
stroke: muted,
gap: 8,
grid: { show: true, stroke: border, width: 1 },
ticks: { stroke: border },
font: "11px system-ui,Segoe UI,sans-serif",
},
{
stroke: muted,
size: axisBandSize,
gap: 8,
grid: { show: true, stroke: border, width: 1 },
ticks: { stroke: border },
font: "10px system-ui,Segoe UI,sans-serif",
values: (u, ticks) => yAxisTickLabels(ticks),
},
],
};
}
function syncChart() {
if (!hostEl) return;
const d = toAlignedData(series);
const legendSig = `${legendTimeLabel}\0${legendValueLabel}`;
if (!d) {
plot?.destroy();
plot = undefined;
builtLegendSig = "";
return;
}
const w = Math.max(80, Math.floor(hostEl.clientWidth));
if (plot && builtLegendSig !== legendSig) {
plot.destroy();
plot = undefined;
}
if (!plot) {
plot = new uPlot(buildOpts(w), d, hostEl);
builtLegendSig = legendSig;
return;
}
plot.setData(d, true);
plot.setSize({ width: w, height: plotHeight });
}
function scheduleSync() {
if (typeof window === "undefined") return;
clearTimeout(syncTimer);
syncTimer = window.setTimeout(() => {
syncTimer = 0;
syncChart();
}, 0);
}
let rafId = 0;
onMount(() => {
rafId = requestAnimationFrame(() => {
void tick().then(() => {
scheduleSync();
if (!hostEl || typeof ResizeObserver === "undefined") return;
resizeObserver = new ResizeObserver(() => scheduleSync());
resizeObserver.observe(hostEl);
});
});
return () => {
cancelAnimationFrame(rafId);
clearTimeout(syncTimer);
resizeObserver?.disconnect();
resizeObserver = undefined;
plot?.destroy();
plot = undefined;
builtLegendSig = "";
};
});
$: if (hostEl) {
series;
plotHeight;
legendTimeLabel;
legendValueLabel;
scheduleSync();
}
</script>
<div class="admin-revenue-uplot-host" bind:this={hostEl}></div>
@@ -0,0 +1,141 @@
<script>
import { Popover, RangeCalendar } from "bits-ui";
import { parseDate } from "@internationalized/date";
import Button from "$components/ui/button.svelte";
import { ChevronLeft, ChevronRight } from "$components/ui/icons.js";
import { cn } from "$lib/utils.js";
let {
open = $bindable(false),
minIso = "",
maxIso = "",
committedFrom = "",
committedTo = "",
title = "",
applyLabel = "",
triggerLabel = "",
isActive = false,
onApply = () => {},
} = $props();
let value = $state({ start: undefined, end: undefined });
let prevOpen = $state(false);
function seedFromBounds() {
if (!minIso || !maxIso) return;
const minV = parseDate(minIso);
const maxV = parseDate(maxIso);
if (
committedFrom &&
committedTo &&
committedFrom >= minIso &&
committedTo <= maxIso &&
committedFrom <= committedTo
) {
value = { start: parseDate(committedFrom), end: parseDate(committedTo) };
return;
}
let start = maxV.subtract({ days: 29 });
if (start.compare(minV) < 0) start = minV;
value = { start, end: maxV };
}
$effect(() => {
if (open && !prevOpen) seedFromBounds();
prevOpen = open;
});
function calendarDateToIso(d) {
if (!d || typeof d !== "object") return "";
const y = d.year;
const m = String(d.month).padStart(2, "0");
const day = String(d.day).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function handleApply() {
const fromIso = calendarDateToIso(value?.start);
const toIso = calendarDateToIso(value?.end);
if (!fromIso || !toIso || fromIso > toIso) return;
onApply({ fromIso, toIso });
open = false;
}
</script>
<Popover.Root bind:open>
<Popover.Trigger
type="button"
class={cn("admin-revenue-period-btn", isActive && "is-active")}
disabled={!minIso || !maxIso}
aria-pressed={isActive}
>
{triggerLabel}
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
class="admin-revenue-range-popover"
side="bottom"
align="end"
sideOffset={8}
trapFocus={true}
>
{#if title}
<div class="admin-revenue-range-popover__title">{title}</div>
{/if}
{#if minIso && maxIso}
<RangeCalendar.Root
class="admin-revenue-rcal"
bind:value
minValue={parseDate(minIso)}
maxValue={parseDate(maxIso)}
weekdayFormat="short"
fixedWeeks={true}
weekStartsOn={1}
>
{#snippet children({ months, weekdays })}
<RangeCalendar.Header class="admin-revenue-rcal__header">
<RangeCalendar.PrevButton class="admin-revenue-rcal__nav">
<ChevronLeft />
</RangeCalendar.PrevButton>
<RangeCalendar.Heading class="admin-revenue-rcal__heading" />
<RangeCalendar.NextButton class="admin-revenue-rcal__nav">
<ChevronRight />
</RangeCalendar.NextButton>
</RangeCalendar.Header>
<div class="admin-revenue-rcal__grids">
{#each months as month (month.value.month)}
<RangeCalendar.Grid class="admin-revenue-rcal__grid">
<RangeCalendar.GridHead>
<RangeCalendar.GridRow class="admin-revenue-rcal__weekrow">
{#each weekdays as wd (wd)}
<RangeCalendar.HeadCell class="admin-revenue-rcal__headcell">
{wd.slice(0, 2)}
</RangeCalendar.HeadCell>
{/each}
</RangeCalendar.GridRow>
</RangeCalendar.GridHead>
<RangeCalendar.GridBody>
{#each month.weeks as weekDates, wi (wi)}
<RangeCalendar.GridRow class="admin-revenue-rcal__weekrow">
{#each weekDates as cellDate, di (`${wi}-${di}-${cellDate.toString()}`)}
<RangeCalendar.Cell date={cellDate} month={month.value} class="admin-revenue-rcal__cell">
<RangeCalendar.Day class="admin-revenue-rcal__day">
{cellDate.day}
</RangeCalendar.Day>
</RangeCalendar.Cell>
{/each}
</RangeCalendar.GridRow>
{/each}
</RangeCalendar.GridBody>
</RangeCalendar.Grid>
{/each}
</div>
{/snippet}
</RangeCalendar.Root>
{/if}
<div class="admin-revenue-range-popover__actions">
<Button variant="default" size="sm" onclick={handleApply}>{applyLabel}</Button>
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
@@ -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";
@@ -1,6 +1,9 @@
<script>
import { X } from "$components/ui/icons.js";
import { cn } from "$lib/utils.js";
import { cubicOut } from "svelte/easing";
import { onMount } from "svelte";
import { fade, fly } from "svelte/transition";
import Button from "./button.svelte";
export let open = false;
@@ -10,13 +13,49 @@
export let onclose = () => {};
let className = "";
export { className as class };
function readReduceMotion() {
return (
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
}
let reduceMotion = readReduceMotion();
onMount(() => {
reduceMotion = readReduceMotion();
if (typeof window === "undefined" || !window.matchMedia) return () => {};
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const handler = () => {
reduceMotion = mq.matches;
};
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
});
$: backdropTransition = reduceMotion ? { duration: 0 } : { duration: 200 };
$: cardIn = reduceMotion
? { duration: 0, y: 0 }
: { duration: 260, y: 16, easing: cubicOut };
$: cardOut = reduceMotion ? { duration: 0, y: 0 } : { duration: 200, y: 10, easing: cubicOut };
</script>
{#if open}
<div class="dialog" role="dialog" aria-modal="true" aria-label={title}>
<button class="dialog-backdrop" type="button" aria-label={closeLabel} onclick={onclose}
<button
class="dialog-backdrop"
type="button"
aria-label={closeLabel}
onclick={onclose}
in:fade={backdropTransition}
out:fade={backdropTransition}
></button>
<section class={cn("dialog-card", className)}>
<section
class={cn("dialog-card", className)}
in:fly={cardIn}
out:fly={cardOut}
>
<div class="dialog-head">
<div>
{#if title}<h2>{title}</h2>{/if}
+22 -1
View File
@@ -66,11 +66,32 @@ export async function mockApi(path, options = {}, context = {}) {
premium_traffic: { state: "none" },
},
];
const mockAdminDailySeries = (() => {
const days = 730;
const out = [];
const now = new Date();
for (let i = 0; i < days; i++) {
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
d.setUTCDate(d.getUTCDate() - (days - 1 - i));
const iso = d.toISOString().slice(0, 10);
const wave = Math.sin(i / 5) * 520 + 720 + ((i * 41) % 280);
out.push({ date: iso, amount: Math.max(0, Math.round(wave)) });
}
return out;
})();
if (path === "/admin/stats") {
return {
ok: true,
currency_symbol: "RUB",
users: { total_users: 248, active_subscriptions: 172, banned_users: 3 },
financial: { total_revenue: 186240, successful_payments_count: 934 },
financial: {
today_revenue: 1240,
week_revenue: 15800,
month_revenue: 44100,
all_time_revenue: 186240,
today_payments_count: 4,
daily_series: mockAdminDailySeries,
},
panel_sync: {
status: "success",
last_sync_time: new Date().toISOString(),
+303 -5
View File
@@ -17,9 +17,7 @@
inset: 0;
width: 100vw;
height: 100dvh;
background:
radial-gradient(circle at 18% 0%, color-mix(in srgb, var(--accent) 10%, transparent), transparent 32%),
#02070b;
background: #02070b;
color: var(--admin-text);
overflow: hidden;
overscroll-behavior: contain;
@@ -313,6 +311,15 @@
flex-shrink: 0;
}
.admin-section-stage {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
flex-shrink: 0;
width: 100%;
}
.admin-card {
border: 1px solid var(--admin-border);
background: var(--admin-card-bg);
@@ -551,6 +558,185 @@
min-width: 0;
}
.admin-revenue-chart-head {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 10px 14px;
min-width: 0;
}
.admin-revenue-chart-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
min-width: 0;
}
.admin-revenue-chart-toolbar .admin-revenue-period {
flex: 1 1 auto;
justify-content: flex-end;
}
.admin-revenue-granularity {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.admin-revenue-period-btn--compact {
padding: 4px 9px;
font-size: 10px;
}
.admin-revenue-period-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.admin-revenue-range-popover {
z-index: 80;
width: min(100vw - 24px, 320px);
padding: 12px 14px 14px;
border-radius: 12px;
border: 1px solid var(--admin-border);
background: var(--admin-surface);
color: var(--admin-text);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
}
.admin-revenue-range-popover__title {
font-size: 12px;
font-weight: 600;
margin-bottom: 10px;
color: var(--admin-text);
}
.admin-revenue-range-popover__actions {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.admin-revenue-rcal {
margin-top: 2px;
}
.admin-revenue-rcal__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.admin-revenue-rcal__heading {
flex: 1;
text-align: center;
font-size: 12px;
font-weight: 600;
color: var(--admin-text);
}
.admin-revenue-rcal__nav {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
border: 1px solid var(--admin-border);
background: color-mix(in srgb, var(--admin-surface-2) 70%, var(--admin-bg));
color: var(--admin-text);
cursor: pointer;
}
.admin-revenue-rcal__nav:hover {
background: color-mix(in srgb, var(--admin-surface) 80%, var(--admin-bg));
}
.admin-revenue-rcal__grids {
display: flex;
flex-direction: column;
gap: 10px;
}
.admin-revenue-rcal__grid {
width: 100%;
border-collapse: collapse;
}
.admin-revenue-rcal__weekrow {
display: flex;
width: 100%;
}
.admin-revenue-rcal__headcell {
flex: 1;
text-align: center;
font-size: 10px;
font-weight: 500;
color: var(--admin-muted);
padding: 2px 0 6px;
min-width: 0;
}
.admin-revenue-rcal__cell {
flex: 1;
padding: 0;
text-align: center;
min-width: 0;
}
.admin-revenue-rcal__day {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
max-width: 36px;
height: 32px;
margin: 0 auto;
border-radius: 8px;
font-size: 11px;
font-weight: 500;
color: var(--admin-text);
background: transparent;
border: 1px solid transparent;
cursor: pointer;
}
.admin-revenue-rcal__day:hover {
background: color-mix(in srgb, var(--admin-surface-2) 80%, var(--admin-bg));
}
.admin-revenue-rcal__day[data-highlighted] {
background: color-mix(in srgb, var(--accent) 10%, var(--admin-surface));
}
.admin-revenue-rcal__day[data-selection-start],
.admin-revenue-rcal__day[data-selection-end] {
background: color-mix(in srgb, var(--accent) 35%, var(--admin-surface));
color: var(--admin-text);
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
}
.admin-revenue-rcal__day[data-selected]:not([data-selection-start]):not([data-selection-end]) {
background: color-mix(in srgb, var(--accent) 14%, var(--admin-surface));
}
.admin-revenue-rcal__day[data-disabled] {
opacity: 0.35;
pointer-events: none;
}
.admin-revenue-rcal__day[data-outside-month] {
opacity: 0.25;
}
.admin-revenue-chart-title {
font-size: 11px;
font-weight: 600;
@@ -559,19 +745,131 @@
color: var(--admin-muted);
}
.admin-revenue-period {
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
padding: 3px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--admin-border) 80%, transparent);
background: color-mix(in srgb, var(--admin-surface-2) 55%, var(--admin-bg));
}
.admin-revenue-period-btn {
appearance: none;
border: 0;
margin: 0;
padding: 6px 10px;
border-radius: 7px;
font-size: 11px;
font-weight: 650;
letter-spacing: 0.02em;
color: var(--admin-muted);
background: transparent;
cursor: pointer;
font-family: inherit;
line-height: 1.1;
}
.admin-revenue-period-btn:hover {
color: var(--admin-text);
background: color-mix(in srgb, var(--admin-surface) 55%, transparent);
}
.admin-revenue-period-btn.is-active {
color: color-mix(in srgb, var(--accent) 22%, var(--admin-text));
background: color-mix(in srgb, var(--accent) 12%, var(--admin-surface));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent);
}
.admin-revenue-chart-hint {
margin: 0;
font-size: 11px;
line-height: 1.35;
}
.admin-revenue-chart-meta {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 8px;
font-size: 11px;
line-height: 1.35;
}
.admin-revenue-chart-meta-sep {
opacity: 0.55;
}
.admin-revenue-svg-frame {
position: relative;
width: 100%;
min-width: 0;
border-radius: 10px;
border: 1px solid var(--admin-border);
background: color-mix(in srgb, var(--admin-bg) 88%, var(--admin-surface-2));
overflow: hidden;
}
.admin-revenue-svg {
.admin-revenue-svg-frame--chart {
width: 100%;
height: 168px;
}
.admin-revenue-uplot-host {
width: 100%;
min-width: 0;
display: block;
}
/* uPlot ships `width: min-content` on `.uplot`, which can prevent full-width layout */
.admin-revenue-uplot-host .uplot {
width: 100%;
max-width: 100%;
min-width: 0;
}
.admin-revenue-uplot-host .u-wrap {
width: 100%;
border-radius: 8px;
overflow: hidden;
}
/* uPlot (canvas) — dark admin theme */
.admin-uplot.uplot {
color: var(--admin-muted);
font-family: var(--font-sans, system-ui, sans-serif);
}
.admin-uplot .u-title {
color: var(--admin-text);
}
.admin-revenue-uplot-host .u-legend {
padding: 10px 12px 8px;
box-sizing: border-box;
}
.admin-uplot .u-legend {
color: var(--admin-muted);
font-size: 11px;
}
.admin-uplot .u-legend th,
.admin-uplot .u-legend td {
color: var(--admin-text);
}
.admin-uplot .u-inline {
border-color: var(--admin-border);
}
.admin-revenue-chart-skeleton {
display: block;
width: 100%;
height: min(204px, 36vw);
min-height: 120px;
}
.admin-revenue-xlabels {
display: flex;
justify-content: space-between;
@@ -96,6 +96,10 @@
min-height: 132px;
}
.admin-cn-card-skeleton--tall {
min-height: 0;
}
@media (min-width: 900px) {
.admin-cn-card-title {
font-size: 1.65rem;
@@ -29,7 +29,6 @@
background: rgba(0, 0, 0, 0.56);
backdrop-filter: blur(10px);
cursor: pointer;
animation: dialog-fade-in 0.18s ease-out both;
}
.dialog-card {
@@ -47,7 +46,6 @@
background: color-mix(in srgb, var(--panel) 94%, #07111a);
color: var(--text);
box-shadow: 0 26px 70px rgba(0, 0, 0, 0.46);
animation: dialog-slide-up 0.2s ease-out both;
}
.dialog-head {
@@ -118,22 +116,6 @@
min-height: 0;
}
@keyframes dialog-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes dialog-slide-up {
from {
opacity: 0;
transform: translateY(18px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (min-width: 720px) {
.dialog-card {
border-radius: var(--radius-lg);
+2 -1
View File
@@ -251,7 +251,8 @@ async def get_financial_statistics(session: AsyncSession) -> Dict[str, Any]:
today_count = await session.execute(stmt_count_today)
today_payments_count = today_count.scalar() or 0
daily_series = await _daily_revenue_series_utc(session, days=14)
# Longer tail for admin dashboard charts (presets up to 1y + custom range on the client).
daily_series = await _daily_revenue_series_utc(session, days=730)
return {
"today_revenue": float(today_amount),
+27 -8
View File
@@ -47,14 +47,21 @@ docker compose up -d --build
## Резервная копия и восстановление PostgreSQL
Имя контейнера БД в типичном Compose — `remnawave-minishop-db`. Подставьте значения **`POSTGRES_USER`** и **`POSTGRES_DB`** из `.env` (на хосте можно выполнить `set -a && source .env && set +a` перед командами или подставить вручную).
Имя контейнера БД в типичном Compose — `remnawave-minishop-db`. Учётные данные уже передаются в контейнер через `env_file: .env`, поэтому надёжнее вызывать `pg_dump` / `psql` **внутри** контейнера через `sh -c '...'`, чтобы переменные раскрылись там, а не на хосте.
### Резервная копия (текстовый SQL)
Если написать на хосте `pg_dump -U "$POSTGRES_USER" ...` без экспорта переменных из `.env`, подставится пустая строка: PostgreSQL тогда берёт имя пользователя ОС (часто `root`) и выдаёт `FATAL: role "root" does not exist`. В **PowerShell** `$POSTGRES_DB` из файла `.env` сам не подхватывается — пустое имя базы даёт у `dropdb` ошибку `missing required argument database name`.
Логическое дампирование в один файл:
**Вариант A (рекомендуется):** переменные только внутри контейнера:
```bash
docker exec remnawave-minishop-db pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" > backup.sql
docker exec remnawave-minishop-db sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB"' > backup.sql
```
**Вариант B:** сначала загрузить `.env` в текущую сессию на сервере, затем обычная команда (переменные раскроются на хосте):
```bash
set -a && source .env && set +a
docker exec remnawave-minishop-db pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" > backup.sql
```
Такой файл удобно хранить вне сервера. При необходимости добавьте к `pg_dump` параметры сжатия или расписание через cron.
@@ -74,18 +81,30 @@ docker compose -f docker-compose-remote-server.yml stop remnawave-minishop
2. Удалите базу и создайте пустую с тем же именем — пользователь из `.env` в официальном образе PostgreSQL обычно суперпользователь и может это сделать:
```bash
docker exec remnawave-minishop-db dropdb -U "$POSTGRES_USER" "$POSTGRES_DB" --if-exists
docker exec remnawave-minishop-db createdb -U "$POSTGRES_USER" "$POSTGRES_DB"
docker exec remnawave-minishop-db sh -c 'dropdb -U "$POSTGRES_USER" --if-exists "$POSTGRES_DB"'
docker exec remnawave-minishop-db sh -c 'createdb -U "$POSTGRES_USER" "$POSTGRES_DB"'
```
Имя базы у `dropdb` — последний аргумент; флаг **`--if-exists`** ставьте перед ним (иначе клиент может неверно разобрать командную строку).
Если `dropdb` сообщает, что база занята, убедитесь, что остановлен сервис `remnawave-minishop` и к базе нет других подключений.
3. Восстановите данные из файла на хосте:
3. Восстановите данные из файла на хосте. Переменные снова должны раскрываться **внутри** контейнера; на стороне `psql` имеет смысл включить **`ON_ERROR_STOP`**, чтобы при первой ошибке в дампе команда завершилась с ненулевым кодом.
**Bash:**
```bash
docker exec -i remnawave-minishop-db psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" < backup.sql
docker exec -i remnawave-minishop-db sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < backup.sql
```
**PowerShell** (перенаправление `<` в `docker exec` часто не подходит; надёжнее передать дамп в stdin через pipe; явная **UTF-8**, чтобы кириллица в комментариях/SQL не исказилась):
```powershell
Get-Content backup.sql -Encoding utf8 | docker exec -i remnawave-minishop-db sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"'
```
Если путь к файлу содержит пробелы, используйте кавычки: `Get-Content "E:\backups\backup.sql" -Encoding utf8 | ...`
4. Запустите приложение снова:
```bash
+29 -3
View File
@@ -979,8 +979,33 @@
"admin_stats_trend_new_today": "Registrations today: {count}",
"admin_stats_section_revenue": "Revenue",
"admin_stats_section_revenue_hint": "Succeeded payments, shop currency",
"admin_stats_revenue_chart_title": "Daily revenue (UTC, 14 days)",
"admin_stats_revenue_chart_aria": "Daily revenue chart for the last 14 days",
"admin_stats_revenue_chart_title": "Daily revenue (UTC)",
"admin_stats_revenue_chart_aria": "Daily revenue chart; hover for date and amount",
"admin_stats_revenue_chart_aria_period": "Daily revenue, last {days} days (UTC)",
"admin_stats_revenue_chart_hint": "Hover the chart to see each days total",
"admin_stats_revenue_chart_range_sum": "Selected range total: {value}",
"admin_stats_revenue_chart_days_available": "Series length: {count} days",
"admin_stats_revenue_chart_uplot_time": "Time",
"admin_stats_revenue_chart_uplot_value": "Value",
"admin_stats_revenue_period_7": "7d",
"admin_stats_revenue_period_14": "14d",
"admin_stats_revenue_period_30": "30d",
"admin_stats_revenue_period_90": "90d",
"admin_stats_revenue_period_180": "6 mo",
"admin_stats_revenue_period_365": "1 yr",
"admin_stats_revenue_period_custom": "Custom",
"admin_stats_revenue_custom_range_title": "Date range (UTC)",
"admin_stats_revenue_custom_range_apply": "Apply",
"admin_stats_revenue_granularity_aria": "Revenue chart step",
"admin_stats_revenue_granularity_day": "By day",
"admin_stats_revenue_granularity_week": "By week",
"admin_stats_revenue_granularity_month": "By month",
"admin_stats_revenue_chart_hint_week": "Weeks start on Monday (UTC); hover the chart for totals",
"admin_stats_revenue_chart_hint_month": "Months are calendar months (UTC); hover the chart for totals",
"admin_stats_revenue_chart_bucket_count": "Points: {count}",
"admin_stats_revenue_chart_custom_span": "Range: {days} d.",
"admin_stats_revenue_tooltip_day": "Day",
"admin_stats_revenue_tooltip_amount": "Amount",
"admin_stats_revenue_avg_check": "Average ticket today: {value}",
"admin_stats_revenue_avg_none": "No successful payments today",
"admin_stats_revenue_avg_ticket_label": "Avg. ticket (today)",
@@ -1005,7 +1030,8 @@
"admin_stats_panel_disabled": "Disabled",
"admin_stats_panel_limited": "Limited",
"admin_stats_panel_total_users": "Total in panel",
"admin_stats_panel_nodes_online": "Nodes online",
"admin_stats_panel_nodes_online": "Nodes in breakdown",
"admin_stats_panel_nodes_online_hint": "Same as the number of rows in the per-node section below (7-day window from bandwidth or node metrics). Not the same as system.nodes.totalOnline from /system/stats, which often counts backends/inbounds and can exceed visible nodes.",
"admin_stats_panel_memory": "Memory",
"admin_stats_panel_bw_week": "Traffic 7 days",
"admin_stats_panel_bw_month": "Traffic 30 days",
+29 -3
View File
@@ -979,8 +979,33 @@
"admin_stats_trend_new_today": "Регистраций сегодня: {count}",
"admin_stats_section_revenue": "Доходы",
"admin_stats_section_revenue_hint": "Успешные платежи, валюта магазина",
"admin_stats_revenue_chart_title": "Выручка по дням (UTC, 14 дней)",
"admin_stats_revenue_chart_aria": "График выручки по дням за последние 14 дней",
"admin_stats_revenue_chart_title": "Выручка по дням (UTC)",
"admin_stats_revenue_chart_aria": "График дневной выручки; наведите курсор для даты и суммы",
"admin_stats_revenue_chart_aria_period": "Дневная выручка, последние {days} дн. (UTC)",
"admin_stats_revenue_chart_hint": "Наведите на график — дата и сумма за день",
"admin_stats_revenue_chart_range_sum": "Сумма за выбранный период: {value}",
"admin_stats_revenue_chart_days_available": "В выборке: {count} дн.",
"admin_stats_revenue_chart_uplot_time": "Время",
"admin_stats_revenue_chart_uplot_value": "Сумма",
"admin_stats_revenue_period_7": "7 дн.",
"admin_stats_revenue_period_14": "14 дн.",
"admin_stats_revenue_period_30": "30 дн.",
"admin_stats_revenue_period_90": "90 дн.",
"admin_stats_revenue_period_180": "6 мес.",
"admin_stats_revenue_period_365": "1 г.",
"admin_stats_revenue_period_custom": "Свой период",
"admin_stats_revenue_custom_range_title": "Диапазон дат (UTC)",
"admin_stats_revenue_custom_range_apply": "Применить",
"admin_stats_revenue_granularity_aria": "Шаг графика выручки",
"admin_stats_revenue_granularity_day": "По дням",
"admin_stats_revenue_granularity_week": "По неделям",
"admin_stats_revenue_granularity_month": "По месяцам",
"admin_stats_revenue_chart_hint_week": "Недели с понедельника (UTC); наведите на график для суммы",
"admin_stats_revenue_chart_hint_month": "Календарные месяцы (UTC); наведите на график для суммы",
"admin_stats_revenue_chart_bucket_count": "Точек: {count}",
"admin_stats_revenue_chart_custom_span": "Диапазон: {days} дн.",
"admin_stats_revenue_tooltip_day": "День",
"admin_stats_revenue_tooltip_amount": "Сумма",
"admin_stats_revenue_avg_check": "Средний чек сегодня: {value}",
"admin_stats_revenue_avg_none": "Сегодня без успешных платежей",
"admin_stats_revenue_avg_ticket_label": "Средний чек (сегодня)",
@@ -1005,7 +1030,8 @@
"admin_stats_panel_disabled": "Отключено",
"admin_stats_panel_limited": "Лимит",
"admin_stats_panel_total_users": "Всего в панели",
"admin_stats_panel_nodes_online": "Нод онлайн",
"admin_stats_panel_nodes_online": "Нод в списке",
"admin_stats_panel_nodes_online_hint": "Совпадает с числом строк в блоке «По нодам (7 дней)» ниже (данные bandwidth или метрик нод). Это не поле system.nodes.totalOnline из /system/stats — там часто считаются бэкенды/inbounds и число может быть больше, чем уникальных нод.",
"admin_stats_panel_memory": "Память",
"admin_stats_panel_bw_week": "Трафик 7 дней",
"admin_stats_panel_bw_month": "Трафик 30 дней",
+9 -2
View File
@@ -6,6 +6,7 @@
"": {
"devDependencies": {
"@eslint/js": "^9.39.2",
"@internationalized/date": "^3.12.1",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/cli": "4.2.4",
"@tailwindcss/vite": "^4.3.0",
@@ -24,6 +25,7 @@
"svelte-eslint-parser": "^1.4.1",
"tailwind-merge": "^3.5.0",
"tailwindcss": "4.2.4",
"uplot": "^1.6.32",
"vite": "^8.0.10"
}
},
@@ -760,7 +762,6 @@
"integrity": "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@swc/helpers": "^0.5.0"
}
@@ -1489,7 +1490,6 @@
"integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
@@ -4014,6 +4014,13 @@
"node": ">= 0.8.0"
}
},
"node_modules/uplot": {
"version": "1.6.32",
"resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz",
"integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==",
"dev": true,
"license": "MIT"
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+9 -7
View File
@@ -21,6 +21,8 @@
"fix": "npm run format && npm run lint:fix"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@internationalized/date": "^3.12.1",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/cli": "4.2.4",
"@tailwindcss/vite": "^4.3.0",
@@ -28,18 +30,18 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"esbuild": "^0.28.0",
"lucide-svelte": "^1.0.1",
"svelte": "^5.55.5",
"tailwind-merge": "^3.5.0",
"tailwindcss": "4.2.4",
"vite": "^8.0.10",
"@eslint/js": "^9.39.2",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.14.0",
"globals": "^16.5.0",
"lucide-svelte": "^1.0.1",
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.0",
"svelte-eslint-parser": "^1.4.1"
"svelte": "^5.55.5",
"svelte-eslint-parser": "^1.4.1",
"tailwind-merge": "^3.5.0",
"tailwindcss": "4.2.4",
"uplot": "^1.6.32",
"vite": "^8.0.10"
}
}