feat: tune admin panel visual, add animations, expand dashboard
This commit is contained in:
@@ -20,6 +20,7 @@
|
|||||||
UsersRound,
|
UsersRound,
|
||||||
} from "$components/ui/icons.js";
|
} from "$components/ui/icons.js";
|
||||||
import { onMount, setContext } from "svelte";
|
import { onMount, setContext } from "svelte";
|
||||||
|
import { fade } from "svelte/transition";
|
||||||
import { Select } from "$components/ui/primitives.js";
|
import { Select } from "$components/ui/primitives.js";
|
||||||
import { AdminBadge, AdminButton } from "$components/patterns/admin/index.js";
|
import { AdminBadge, AdminButton } from "$components/patterns/admin/index.js";
|
||||||
|
|
||||||
@@ -177,6 +178,15 @@
|
|||||||
let adminLanguageClickGuardTimer = null;
|
let adminLanguageClickGuardTimer = null;
|
||||||
let adminLanguageClickGuardArmTimer = null;
|
let adminLanguageClickGuardArmTimer = null;
|
||||||
|
|
||||||
|
function readReduceMotion() {
|
||||||
|
return (
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let reduceMotion = readReduceMotion();
|
||||||
|
|
||||||
function flash(text) {
|
function flash(text) {
|
||||||
onToast(text);
|
onToast(text);
|
||||||
}
|
}
|
||||||
@@ -345,6 +355,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
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") {
|
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
|
||||||
compactMql = window.matchMedia("(max-width: 720px)");
|
compactMql = window.matchMedia("(max-width: 720px)");
|
||||||
isCompact = compactMql.matches;
|
isCompact = compactMql.matches;
|
||||||
@@ -355,6 +375,7 @@
|
|||||||
window.addEventListener("popstate", onPopState);
|
window.addEventListener("popstate", onPopState);
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
|
if (motionMql) motionMql.removeEventListener("change", onMotionChange);
|
||||||
if (compactMql) {
|
if (compactMql) {
|
||||||
if (compactMql.removeEventListener)
|
if (compactMql.removeEventListener)
|
||||||
compactMql.removeEventListener("change", onCompactChange);
|
compactMql.removeEventListener("change", onCompactChange);
|
||||||
@@ -365,6 +386,9 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$: sectionFade = reduceMotion ? { duration: 0 } : { duration: 200 };
|
||||||
|
$: sidebarBackdropFade = reduceMotion ? { duration: 0 } : { duration: 180 };
|
||||||
|
|
||||||
$: if (
|
$: if (
|
||||||
active === "users" &&
|
active === "users" &&
|
||||||
initialUserId &&
|
initialUserId &&
|
||||||
@@ -380,6 +404,8 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="admin-sidebar-backdrop"
|
class="admin-sidebar-backdrop"
|
||||||
aria-label={at("close_menu", {}, "Закрыть меню")}
|
aria-label={at("close_menu", {}, "Закрыть меню")}
|
||||||
|
in:fade={sidebarBackdropFade}
|
||||||
|
out:fade={sidebarBackdropFade}
|
||||||
on:click={() => (sidebarOpen = false)}
|
on:click={() => (sidebarOpen = false)}
|
||||||
></button>
|
></button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -563,55 +589,59 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="admin-main">
|
<main class="admin-main">
|
||||||
{#if active === "stats"}
|
{#key active}
|
||||||
<StatsSection {at} {fmtDate} {fmtDateShort} {fmtMoney} {paymentStatusVariant} />
|
<div class="admin-section-stage" in:fade={sectionFade} out:fade={sectionFade}>
|
||||||
{/if}
|
{#if active === "stats"}
|
||||||
|
<StatsSection {at} {fmtDate} {fmtDateShort} {fmtMoney} {paymentStatusVariant} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if active === "users"}
|
{#if active === "users"}
|
||||||
<UsersSection
|
<UsersSection
|
||||||
{at}
|
{at}
|
||||||
{fmtDateShort}
|
{fmtDateShort}
|
||||||
{panelStatusBadge}
|
{panelStatusBadge}
|
||||||
{resolvedAvatarUrl}
|
{resolvedAvatarUrl}
|
||||||
{userDisplayName}
|
{userDisplayName}
|
||||||
{userInitials}
|
{userInitials}
|
||||||
{userSecondaryName}
|
{userSecondaryName}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if active === "payments"}
|
{#if active === "payments"}
|
||||||
<PaymentsSection
|
<PaymentsSection
|
||||||
{at}
|
{at}
|
||||||
{fmtDate}
|
{fmtDate}
|
||||||
{fmtMoney}
|
{fmtMoney}
|
||||||
{paymentStatusVariant}
|
{paymentStatusVariant}
|
||||||
onOpenUserCard={openPaymentUserCard}
|
onOpenUserCard={openPaymentUserCard}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if active === "promos"}
|
{#if active === "promos"}
|
||||||
<PromosSection {at} {fmtDateShort} />
|
<PromosSection {at} {fmtDateShort} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if active === "ads"}
|
{#if active === "ads"}
|
||||||
<AdsSection {at} {fmtMoney} />
|
<AdsSection {at} {fmtMoney} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if active === "broadcast"}
|
{#if active === "broadcast"}
|
||||||
<BroadcastSection {at} />
|
<BroadcastSection {at} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if active === "logs"}
|
{#if active === "logs"}
|
||||||
<LogsSection {at} {fmtDate} />
|
<LogsSection {at} {fmtDate} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if active === "tariffs"}
|
{#if active === "tariffs"}
|
||||||
<TariffsSection {at} {fmtMoney} />
|
<TariffsSection {at} {fmtMoney} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if active === "settings"}
|
{#if active === "settings"}
|
||||||
<SettingsSection {at} {isCompact} {onSettingsSaved} {currentLang} />
|
<SettingsSection {at} {isCompact} {onSettingsSaved} {currentLang} />
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
</main>
|
</main>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,10 +10,18 @@
|
|||||||
AdminDashboardStack,
|
AdminDashboardStack,
|
||||||
AdminBadge,
|
AdminBadge,
|
||||||
AdminEmptyState,
|
AdminEmptyState,
|
||||||
|
AdminRevenueChart,
|
||||||
|
AdminRevenueCustomRangePopover,
|
||||||
AdminSectionHeader,
|
AdminSectionHeader,
|
||||||
AdminTable,
|
AdminTable,
|
||||||
AdminTableSkeleton,
|
AdminTableSkeleton,
|
||||||
} from "$components/patterns/admin/index.js";
|
} from "$components/patterns/admin/index.js";
|
||||||
|
import {
|
||||||
|
aggregateRevenueSeries,
|
||||||
|
filterDailyByIsoRange,
|
||||||
|
inclusiveDaySpan,
|
||||||
|
sliceLastDays,
|
||||||
|
} from "../../lib/admin/revenueSeriesAgg.js";
|
||||||
|
|
||||||
export let at;
|
export let at;
|
||||||
export let fmtDate = (value) => value;
|
export let fmtDate = (value) => value;
|
||||||
@@ -35,12 +43,78 @@
|
|||||||
$: panelBw = panelPayload && !panelPayload.error ? parsePanelBandwidth(panelPayload) : null;
|
$: panelBw = panelPayload && !panelPayload.error ? parsePanelBandwidth(panelPayload) : null;
|
||||||
$: panelNodeTraffic =
|
$: panelNodeTraffic =
|
||||||
panelPayload && !panelPayload.error ? parsePanelNodeTraffic(panelPayload) : null;
|
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 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 : [];
|
$: 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);
|
$: 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 = [
|
$: recentPaymentHeaders = [
|
||||||
at("id", {}, ""),
|
at("id", {}, ""),
|
||||||
at("user", {}, ""),
|
at("user", {}, ""),
|
||||||
@@ -60,7 +134,6 @@
|
|||||||
const memTotal = Number(mem.total) || 0;
|
const memTotal = Number(mem.total) || 0;
|
||||||
const memUsed = Number(mem.used) || 0;
|
const memUsed = Number(mem.used) || 0;
|
||||||
const memPct = memTotal > 0 ? (memUsed / memTotal) * 100 : null;
|
const memPct = memTotal > 0 ? (memUsed / memTotal) * 100 : null;
|
||||||
const nodes = system.nodes || {};
|
|
||||||
const cpuRaw =
|
const cpuRaw =
|
||||||
system.cpu?.usage ??
|
system.cpu?.usage ??
|
||||||
system.cpu?.usedPercent ??
|
system.cpu?.usedPercent ??
|
||||||
@@ -75,7 +148,6 @@
|
|||||||
expired: statusCounts.EXPIRED ?? 0,
|
expired: statusCounts.EXPIRED ?? 0,
|
||||||
limited: statusCounts.LIMITED ?? 0,
|
limited: statusCounts.LIMITED ?? 0,
|
||||||
totalPanelUsers: u.totalUsers ?? 0,
|
totalPanelUsers: u.totalUsers ?? 0,
|
||||||
nodesOnline: nodes.totalOnline != null ? nodes.totalOnline : null,
|
|
||||||
memPct,
|
memPct,
|
||||||
cpuPct: Number.isFinite(cpuPct) ? cpuPct : null,
|
cpuPct: Number.isFinite(cpuPct) ? cpuPct : null,
|
||||||
};
|
};
|
||||||
@@ -396,44 +468,12 @@
|
|||||||
const tc = Number(financial.today_payments_count) || 0;
|
const tc = Number(financial.today_payments_count) || 0;
|
||||||
const tr = Number(financial.today_revenue) || 0;
|
const tr = Number(financial.today_revenue) || 0;
|
||||||
const avgToday = tc > 0 ? tr / tc : null;
|
const avgToday = tc > 0 ? tr / tc : null;
|
||||||
const total14 = amounts.reduce((a, b) => a + b, 0);
|
const tail14 = n >= 14 ? amounts.slice(-14) : amounts;
|
||||||
const maxY = Math.max(...amounts, 1e-9);
|
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 };
|
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) {
|
function growthBadgeVariant(pct) {
|
||||||
if (pct == null) return "outline";
|
if (pct == null) return "outline";
|
||||||
if (pct >= 0) return "default";
|
if (pct >= 0) return "default";
|
||||||
@@ -470,26 +510,139 @@
|
|||||||
</Card.Root>
|
</Card.Root>
|
||||||
{/each}
|
{/each}
|
||||||
</AdminDashboardGrid>
|
</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>
|
<Card.Header>
|
||||||
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span>
|
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span>
|
||||||
<span
|
<span
|
||||||
class="admin-skeleton admin-skeleton-line admin-skeleton-line-strong"
|
class="admin-skeleton admin-skeleton-line admin-skeleton-line-strong"
|
||||||
style="width:40%"
|
style="width:48%"
|
||||||
></span>
|
></span>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="admin-cn-card-content--flush">
|
<Card.Content>
|
||||||
<div class="admin-revenue-svg-frame">
|
<div class="admin-revenue-kpis" aria-hidden="true">
|
||||||
<div class="admin-skeleton" style="height:168px;border-radius:10px;"></div>
|
{#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>
|
</div>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
<AdminSectionHeader title={at("stats_recent_payments", {}, "")} />
|
|
||||||
<AdminTableSkeleton
|
<AdminSectionHeader
|
||||||
headers={recentPaymentHeaders}
|
title={at("stats_section_panel", {}, "")}
|
||||||
rows={6}
|
description={at("stats_section_panel_hint", {}, "")}
|
||||||
widths={["48px", "120px", "78px", "82px", "72px", "96px"]}
|
|
||||||
/>
|
/>
|
||||||
|
<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>
|
</AdminDashboardStack>
|
||||||
{:else if stats}
|
{:else if stats}
|
||||||
<AdminDashboardStack>
|
<AdminDashboardStack>
|
||||||
@@ -633,50 +786,104 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="admin-revenue-chart">
|
<div class="admin-revenue-chart">
|
||||||
<div class="admin-revenue-chart-title">{at("stats_revenue_chart_title", {}, "")}</div>
|
<div class="admin-revenue-chart-head">
|
||||||
{#if chartModel}
|
<div class="admin-revenue-chart-title">{at("stats_revenue_chart_title", {}, "")}</div>
|
||||||
<div
|
<div class="admin-revenue-chart-toolbar">
|
||||||
class="admin-revenue-svg-frame"
|
<div
|
||||||
role="img"
|
class="admin-revenue-period"
|
||||||
aria-label={at("stats_revenue_chart_aria", {}, "")}
|
role="tablist"
|
||||||
>
|
aria-label={at("stats_revenue_chart_aria", {}, "")}
|
||||||
<svg
|
|
||||||
class="admin-revenue-svg"
|
|
||||||
viewBox="0 0 {chartModel.W} {chartModel.H}"
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
>
|
>
|
||||||
<defs>
|
{#each REVENUE_PRESET_DAYS as d (d)}
|
||||||
<linearGradient id="adminRevenueFillDashboard" x1="0" y1="0" x2="0" y2="1">
|
<button
|
||||||
<stop offset="0%" stop-color="var(--admin-muted)" stop-opacity="0.28" />
|
type="button"
|
||||||
<stop offset="100%" stop-color="var(--admin-muted)" stop-opacity="0" />
|
class="admin-revenue-period-btn"
|
||||||
</linearGradient>
|
class:is-active={revenueRangeMode === "preset" && revenuePresetDays === d}
|
||||||
</defs>
|
role="tab"
|
||||||
{#each chartModel.gridYs as gy}
|
aria-selected={revenueRangeMode === "preset" && revenuePresetDays === d}
|
||||||
<line
|
on:click={() => setRevenuePresetDays(d)}
|
||||||
x1={chartModel.pad.l}
|
>
|
||||||
y1={gy}
|
{revenuePeriodLabel(d)}
|
||||||
x2={chartModel.W - chartModel.pad.r}
|
</button>
|
||||||
y2={gy}
|
|
||||||
stroke="var(--admin-border)"
|
|
||||||
stroke-opacity="0.45"
|
|
||||||
stroke-width="1"
|
|
||||||
/>
|
|
||||||
{/each}
|
{/each}
|
||||||
<path d={chartModel.areaD} fill="url(#adminRevenueFillDashboard)" stroke="none" />
|
</div>
|
||||||
<path
|
<AdminRevenueCustomRangePopover
|
||||||
d={chartModel.lineD}
|
bind:open={revenueCustomPopoverOpen}
|
||||||
fill="none"
|
minIso={revenueBoundsIso?.min ?? ""}
|
||||||
stroke="color-mix(in srgb, var(--admin-muted) 55%, var(--admin-text))"
|
maxIso={revenueBoundsIso?.max ?? ""}
|
||||||
stroke-width="2"
|
committedFrom={revenueCustomIso?.from ?? ""}
|
||||||
stroke-linejoin="round"
|
committedTo={revenueCustomIso?.to ?? ""}
|
||||||
stroke-linecap="round"
|
title={at("stats_revenue_custom_range_title", {}, "")}
|
||||||
/>
|
triggerLabel={at("stats_revenue_period_custom", {}, "Custom")}
|
||||||
</svg>
|
applyLabel={at("stats_revenue_custom_range_apply", {}, "Apply")}
|
||||||
|
isActive={revenueRangeMode === "custom"}
|
||||||
|
onApply={onCustomRangeApply}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-revenue-xlabels">
|
</div>
|
||||||
{#each chartModel.ticks as tk}
|
<div
|
||||||
<span>{tk.label}</span>
|
class="admin-revenue-granularity"
|
||||||
{/each}
|
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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="admin-muted">{at("stats_revenue_no_chart", {}, "")}</p>
|
<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-label">{at("stats_panel_limited", {}, "")}</div>
|
||||||
<div class="admin-panel-dash-tile-value">{panelMetrics.limited}</div>
|
<div class="admin-panel-dash-tile-value">{panelMetrics.limited}</div>
|
||||||
</div>
|
</div>
|
||||||
{#if panelMetrics.nodesOnline != null}
|
{#if panelNodesListedCount > 0}
|
||||||
<div class="admin-panel-dash-tile">
|
<div
|
||||||
|
class="admin-panel-dash-tile"
|
||||||
|
title={at("stats_panel_nodes_online_hint", {}, "")}
|
||||||
|
>
|
||||||
<div class="admin-panel-dash-tile-label">
|
<div class="admin-panel-dash-tile-label">
|
||||||
<span class="admin-panel-dash-ico" aria-hidden="true"><Server size={12} /></span
|
<span class="admin-panel-dash-ico" aria-hidden="true"><Server size={12} /></span
|
||||||
>
|
>
|
||||||
{at("stats_panel_nodes_online", {}, "")}
|
{at("stats_panel_nodes_online", {}, "")}
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-panel-dash-tile-value">{panelMetrics.nodesOnline}</div>
|
<div class="admin-panel-dash-tile-value">{panelNodesListedCount}</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if panelMetrics.memPct != null}
|
{#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 AdminEmptyState } from "./AdminEmptyState.svelte";
|
||||||
export { default as AdminField } from "./AdminField.svelte";
|
export { default as AdminField } from "./AdminField.svelte";
|
||||||
export { default as AdminPagination } from "./AdminPagination.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 AdminSelect } from "./AdminSelect.svelte";
|
||||||
export { default as AdminSectionHeader } from "./AdminSectionHeader.svelte";
|
export { default as AdminSectionHeader } from "./AdminSectionHeader.svelte";
|
||||||
export { default as AdminTable } from "./AdminTable.svelte";
|
export { default as AdminTable } from "./AdminTable.svelte";
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<script>
|
<script>
|
||||||
import { X } from "$components/ui/icons.js";
|
import { X } from "$components/ui/icons.js";
|
||||||
import { cn } from "$lib/utils.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";
|
import Button from "./button.svelte";
|
||||||
|
|
||||||
export let open = false;
|
export let open = false;
|
||||||
@@ -10,13 +13,49 @@
|
|||||||
export let onclose = () => {};
|
export let onclose = () => {};
|
||||||
let className = "";
|
let className = "";
|
||||||
export { className as class };
|
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>
|
</script>
|
||||||
|
|
||||||
{#if open}
|
{#if open}
|
||||||
<div class="dialog" role="dialog" aria-modal="true" aria-label={title}>
|
<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>
|
></button>
|
||||||
<section class={cn("dialog-card", className)}>
|
<section
|
||||||
|
class={cn("dialog-card", className)}
|
||||||
|
in:fly={cardIn}
|
||||||
|
out:fly={cardOut}
|
||||||
|
>
|
||||||
<div class="dialog-head">
|
<div class="dialog-head">
|
||||||
<div>
|
<div>
|
||||||
{#if title}<h2>{title}</h2>{/if}
|
{#if title}<h2>{title}</h2>{/if}
|
||||||
|
|||||||
@@ -66,11 +66,32 @@ export async function mockApi(path, options = {}, context = {}) {
|
|||||||
premium_traffic: { state: "none" },
|
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") {
|
if (path === "/admin/stats") {
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
currency_symbol: "RUB",
|
||||||
users: { total_users: 248, active_subscriptions: 172, banned_users: 3 },
|
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: {
|
panel_sync: {
|
||||||
status: "success",
|
status: "success",
|
||||||
last_sync_time: new Date().toISOString(),
|
last_sync_time: new Date().toISOString(),
|
||||||
|
|||||||
@@ -17,9 +17,7 @@
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
background:
|
background: #02070b;
|
||||||
radial-gradient(circle at 18% 0%, color-mix(in srgb, var(--accent) 10%, transparent), transparent 32%),
|
|
||||||
#02070b;
|
|
||||||
color: var(--admin-text);
|
color: var(--admin-text);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
@@ -313,6 +311,15 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-section-stage {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
min-width: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-card {
|
.admin-card {
|
||||||
border: 1px solid var(--admin-border);
|
border: 1px solid var(--admin-border);
|
||||||
background: var(--admin-card-bg);
|
background: var(--admin-card-bg);
|
||||||
@@ -551,6 +558,185 @@
|
|||||||
min-width: 0;
|
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 {
|
.admin-revenue-chart-title {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -559,19 +745,131 @@
|
|||||||
color: var(--admin-muted);
|
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 {
|
.admin-revenue-svg-frame {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid var(--admin-border);
|
border: 1px solid var(--admin-border);
|
||||||
background: color-mix(in srgb, var(--admin-bg) 88%, var(--admin-surface-2));
|
background: color-mix(in srgb, var(--admin-bg) 88%, var(--admin-surface-2));
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-revenue-svg {
|
.admin-revenue-svg-frame--chart {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 168px;
|
}
|
||||||
|
|
||||||
|
.admin-revenue-uplot-host {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
display: block;
|
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 {
|
.admin-revenue-xlabels {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -96,6 +96,10 @@
|
|||||||
min-height: 132px;
|
min-height: 132px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-cn-card-skeleton--tall {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 900px) {
|
@media (min-width: 900px) {
|
||||||
.admin-cn-card-title {
|
.admin-cn-card-title {
|
||||||
font-size: 1.65rem;
|
font-size: 1.65rem;
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
background: rgba(0, 0, 0, 0.56);
|
background: rgba(0, 0, 0, 0.56);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
animation: dialog-fade-in 0.18s ease-out both;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-card {
|
.dialog-card {
|
||||||
@@ -47,7 +46,6 @@
|
|||||||
background: color-mix(in srgb, var(--panel) 94%, #07111a);
|
background: color-mix(in srgb, var(--panel) 94%, #07111a);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
box-shadow: 0 26px 70px rgba(0, 0, 0, 0.46);
|
box-shadow: 0 26px 70px rgba(0, 0, 0, 0.46);
|
||||||
animation: dialog-slide-up 0.2s ease-out both;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-head {
|
.dialog-head {
|
||||||
@@ -118,22 +116,6 @@
|
|||||||
min-height: 0;
|
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) {
|
@media (min-width: 720px) {
|
||||||
.dialog-card {
|
.dialog-card {
|
||||||
border-radius: var(--radius-lg);
|
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_count = await session.execute(stmt_count_today)
|
||||||
today_payments_count = today_count.scalar() or 0
|
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 {
|
return {
|
||||||
"today_revenue": float(today_amount),
|
"today_revenue": float(today_amount),
|
||||||
|
|||||||
+27
-8
@@ -47,14 +47,21 @@ docker compose up -d --build
|
|||||||
|
|
||||||
## Резервная копия и восстановление PostgreSQL
|
## Резервная копия и восстановление 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
|
```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.
|
Такой файл удобно хранить вне сервера. При необходимости добавьте к `pg_dump` параметры сжатия или расписание через cron.
|
||||||
@@ -74,18 +81,30 @@ docker compose -f docker-compose-remote-server.yml stop remnawave-minishop
|
|||||||
2. Удалите базу и создайте пустую с тем же именем — пользователь из `.env` в официальном образе PostgreSQL обычно суперпользователь и может это сделать:
|
2. Удалите базу и создайте пустую с тем же именем — пользователь из `.env` в официальном образе PostgreSQL обычно суперпользователь и может это сделать:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker exec remnawave-minishop-db dropdb -U "$POSTGRES_USER" "$POSTGRES_DB" --if-exists
|
docker exec remnawave-minishop-db sh -c 'dropdb -U "$POSTGRES_USER" --if-exists "$POSTGRES_DB"'
|
||||||
docker exec remnawave-minishop-db createdb -U "$POSTGRES_USER" "$POSTGRES_DB"
|
docker exec remnawave-minishop-db sh -c 'createdb -U "$POSTGRES_USER" "$POSTGRES_DB"'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Имя базы у `dropdb` — последний аргумент; флаг **`--if-exists`** ставьте перед ним (иначе клиент может неверно разобрать командную строку).
|
||||||
|
|
||||||
Если `dropdb` сообщает, что база занята, убедитесь, что остановлен сервис `remnawave-minishop` и к базе нет других подключений.
|
Если `dropdb` сообщает, что база занята, убедитесь, что остановлен сервис `remnawave-minishop` и к базе нет других подключений.
|
||||||
|
|
||||||
3. Восстановите данные из файла на хосте:
|
3. Восстановите данные из файла на хосте. Переменные снова должны раскрываться **внутри** контейнера; на стороне `psql` имеет смысл включить **`ON_ERROR_STOP`**, чтобы при первой ошибке в дампе команда завершилась с ненулевым кодом.
|
||||||
|
|
||||||
|
**Bash:**
|
||||||
|
|
||||||
```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. Запустите приложение снова:
|
4. Запустите приложение снова:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+29
-3
@@ -979,8 +979,33 @@
|
|||||||
"admin_stats_trend_new_today": "Registrations today: {count}",
|
"admin_stats_trend_new_today": "Registrations today: {count}",
|
||||||
"admin_stats_section_revenue": "Revenue",
|
"admin_stats_section_revenue": "Revenue",
|
||||||
"admin_stats_section_revenue_hint": "Succeeded payments, shop currency",
|
"admin_stats_section_revenue_hint": "Succeeded payments, shop currency",
|
||||||
"admin_stats_revenue_chart_title": "Daily revenue (UTC, 14 days)",
|
"admin_stats_revenue_chart_title": "Daily revenue (UTC)",
|
||||||
"admin_stats_revenue_chart_aria": "Daily revenue chart for the last 14 days",
|
"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_check": "Average ticket today: {value}",
|
||||||
"admin_stats_revenue_avg_none": "No successful payments today",
|
"admin_stats_revenue_avg_none": "No successful payments today",
|
||||||
"admin_stats_revenue_avg_ticket_label": "Avg. ticket (today)",
|
"admin_stats_revenue_avg_ticket_label": "Avg. ticket (today)",
|
||||||
@@ -1005,7 +1030,8 @@
|
|||||||
"admin_stats_panel_disabled": "Disabled",
|
"admin_stats_panel_disabled": "Disabled",
|
||||||
"admin_stats_panel_limited": "Limited",
|
"admin_stats_panel_limited": "Limited",
|
||||||
"admin_stats_panel_total_users": "Total in panel",
|
"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_memory": "Memory",
|
||||||
"admin_stats_panel_bw_week": "Traffic 7 days",
|
"admin_stats_panel_bw_week": "Traffic 7 days",
|
||||||
"admin_stats_panel_bw_month": "Traffic 30 days",
|
"admin_stats_panel_bw_month": "Traffic 30 days",
|
||||||
|
|||||||
+29
-3
@@ -979,8 +979,33 @@
|
|||||||
"admin_stats_trend_new_today": "Регистраций сегодня: {count}",
|
"admin_stats_trend_new_today": "Регистраций сегодня: {count}",
|
||||||
"admin_stats_section_revenue": "Доходы",
|
"admin_stats_section_revenue": "Доходы",
|
||||||
"admin_stats_section_revenue_hint": "Успешные платежи, валюта магазина",
|
"admin_stats_section_revenue_hint": "Успешные платежи, валюта магазина",
|
||||||
"admin_stats_revenue_chart_title": "Выручка по дням (UTC, 14 дней)",
|
"admin_stats_revenue_chart_title": "Выручка по дням (UTC)",
|
||||||
"admin_stats_revenue_chart_aria": "График выручки по дням за последние 14 дней",
|
"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_check": "Средний чек сегодня: {value}",
|
||||||
"admin_stats_revenue_avg_none": "Сегодня без успешных платежей",
|
"admin_stats_revenue_avg_none": "Сегодня без успешных платежей",
|
||||||
"admin_stats_revenue_avg_ticket_label": "Средний чек (сегодня)",
|
"admin_stats_revenue_avg_ticket_label": "Средний чек (сегодня)",
|
||||||
@@ -1005,7 +1030,8 @@
|
|||||||
"admin_stats_panel_disabled": "Отключено",
|
"admin_stats_panel_disabled": "Отключено",
|
||||||
"admin_stats_panel_limited": "Лимит",
|
"admin_stats_panel_limited": "Лимит",
|
||||||
"admin_stats_panel_total_users": "Всего в панели",
|
"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_memory": "Память",
|
||||||
"admin_stats_panel_bw_week": "Трафик 7 дней",
|
"admin_stats_panel_bw_week": "Трафик 7 дней",
|
||||||
"admin_stats_panel_bw_month": "Трафик 30 дней",
|
"admin_stats_panel_bw_month": "Трафик 30 дней",
|
||||||
|
|||||||
Generated
+9
-2
@@ -6,6 +6,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
|
"@internationalized/date": "^3.12.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
"@tailwindcss/cli": "4.2.4",
|
"@tailwindcss/cli": "4.2.4",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
"svelte-eslint-parser": "^1.4.1",
|
"svelte-eslint-parser": "^1.4.1",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "4.2.4",
|
"tailwindcss": "4.2.4",
|
||||||
|
"uplot": "^1.6.32",
|
||||||
"vite": "^8.0.10"
|
"vite": "^8.0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -760,7 +762,6 @@
|
|||||||
"integrity": "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==",
|
"integrity": "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@swc/helpers": "^0.5.0"
|
"@swc/helpers": "^0.5.0"
|
||||||
}
|
}
|
||||||
@@ -1489,7 +1490,6 @@
|
|||||||
"integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==",
|
"integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.8.0"
|
"tslib": "^2.8.0"
|
||||||
}
|
}
|
||||||
@@ -4014,6 +4014,13 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/uri-js": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
"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"
|
"fix": "npm run format && npm run lint:fix"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.2",
|
||||||
|
"@internationalized/date": "^3.12.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
"@tailwindcss/cli": "4.2.4",
|
"@tailwindcss/cli": "4.2.4",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
@@ -28,18 +30,18 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"esbuild": "^0.28.0",
|
"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": "^9.39.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-svelte": "^3.14.0",
|
"eslint-plugin-svelte": "^3.14.0",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"lucide-svelte": "^1.0.1",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"prettier-plugin-svelte": "^3.4.0",
|
"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