feat: tune admin panel visual, add animations, expand dashboard
This commit is contained in:
@@ -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>
|
||||
+141
@@ -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}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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 day’s 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
@@ -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 дней",
|
||||
|
||||
Generated
+9
-2
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user