diff --git a/web/src/lib/chart-theme.ts b/web/src/lib/chart-theme.ts index 2b960c3..25e9150 100644 --- a/web/src/lib/chart-theme.ts +++ b/web/src/lib/chart-theme.ts @@ -27,3 +27,68 @@ export function chartSeriesColors(): string[] { export function chartLegendColor(): string { return chartTickColor(); } + +/** + * Добавляет альфа-канал к цвету из темы (oklch/hex/rgb). + * Нельзя склеивать `oklch(...) + "33"` — в canvas это даёт невалидный цвет и чёрную заливку. + */ +export function chartColorWithAlpha(base: string, alpha: number): string { + const t = base.trim(); + const indigo = `rgba(99, 102, 241, ${alpha})`; + if (!t) return indigo; + + const oklch = /^oklch\(\s*(.+)\s*\)$/i.exec(t); + if (oklch) { + let inner = oklch[1].trim().replace(/\s*\/\s*[\d.]+%?\s*$/, '').trim(); + return `oklch(${inner} / ${alpha})`; + } + + if (t.startsWith('#')) { + let h = t.slice(1); + if (h.length === 3) { + h = [...h].map((c) => c + c).join(''); + } + if (h.length === 6 || h.length === 8) { + const r = parseInt(h.slice(0, 2), 16); + const g = parseInt(h.slice(2, 4), 16); + const b = parseInt(h.slice(4, 6), 16); + return `rgba(${r},${g},${b},${alpha})`; + } + } + + if (/^rgba?\(/i.test(t)) { + const m = t.match(/rgba?\(\s*([^)]+)\s*\)/i); + if (m) { + const parts = m[1].split(',').map((s) => s.trim()); + if (parts.length >= 3) { + return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${alpha})`; + } + } + } + + return `color-mix(in srgb, ${t} ${Math.round(alpha * 100)}%, transparent)`; +} + +/** Лёгкая заливка под линией: сверху чуть насыщеннее, к низу к прозрачному. */ +export function chartLineAreaGradient( + ctx: CanvasRenderingContext2D, + chartArea: { top: number; bottom: number }, + lineColor: string +): CanvasGradient { + const g = ctx.createLinearGradient(0, chartArea.top, 0, chartArea.bottom); + const c = lineColor.trim() || '#6366f1'; + g.addColorStop(0, chartColorWithAlpha(c, 0.16)); + g.addColorStop(0.45, chartColorWithAlpha(c, 0.045)); + g.addColorStop(1, chartColorWithAlpha(c, 0)); + return g; +} + +/** Callback для Chart.js `dataset.backgroundColor` у линейных графиков с заливкой. */ +export function chartLineAreaFillCallback(lineColor: string) { + return (context: { chart: { ctx: CanvasRenderingContext2D; chartArea?: { top: number; bottom: number } } }) => { + const { chart } = context; + const area = chart.chartArea; + if (!area) return 'transparent'; + return chartLineAreaGradient(chart.ctx, area, lineColor); + }; +} diff --git a/web/src/lib/components/fleet/fleet-overview-charts.svelte b/web/src/lib/components/fleet/fleet-overview-charts.svelte index 65c160a..2b31347 100644 --- a/web/src/lib/components/fleet/fleet-overview-charts.svelte +++ b/web/src/lib/components/fleet/fleet-overview-charts.svelte @@ -3,10 +3,12 @@ import Chart from 'chart.js/auto'; import * as Card from '$lib/components/ui/card/index.js'; import { + chartColorWithAlpha, chartGridColor, chartLegendColor, chartSeriesColors, - chartTickColor + chartTickColor, + readCssVar } from '$lib/chart-theme.js'; import ServerIcon from '@lucide/svelte/icons/server'; import ChartNoAxesCombinedIcon from '@lucide/svelte/icons/chart-no-axes-combined'; @@ -31,6 +33,7 @@ const colors = chartSeriesColors(); const legend = chartLegendColor(); chartDonut?.destroy(); + const hole = readCssVar('--card') || readCssVar('--background') || '#18181b'; chartDonut = new Chart(elDonut, { type: 'doughnut', data: { @@ -38,8 +41,14 @@ datasets: [ { data: [Math.max(nodesOk, 0), Math.max(nodesDegraded, 0)], - backgroundColor: [colors[1] ?? '#22c55e', colors[4] ?? '#ef4444'], - borderWidth: 0 + backgroundColor: [ + chartColorWithAlpha(colors[1] ?? '#22c55e', 0.88), + chartColorWithAlpha(colors[4] ?? '#ef4444', 0.88) + ], + borderWidth: 2, + borderColor: hole, + hoverBorderWidth: 2, + hoverBorderColor: hole } ] }, @@ -47,6 +56,7 @@ responsive: true, maintainAspectRatio: false, animation: false, + cutout: '58%', plugins: { legend: { labels: { color: legend } } } } }); @@ -67,7 +77,9 @@ { label: 'MiB', data: slice.map((x) => x.total_megabytes), - backgroundColor: colors[0] ?? '#6366f1' + backgroundColor: chartColorWithAlpha(colors[0] ?? '#6366f1', 0.52), + borderRadius: 4, + borderSkipped: false } ] }, diff --git a/web/src/lib/components/mihomo/mihomo-overview-charts.svelte b/web/src/lib/components/mihomo/mihomo-overview-charts.svelte index 3fcd7f6..d650eda 100644 --- a/web/src/lib/components/mihomo/mihomo-overview-charts.svelte +++ b/web/src/lib/components/mihomo/mihomo-overview-charts.svelte @@ -9,10 +9,13 @@ import NetworkIcon from '@lucide/svelte/icons/network'; import GaugeIcon from '@lucide/svelte/icons/gauge'; import { + chartColorWithAlpha, chartGridColor, chartLegendColor, + chartLineAreaFillCallback, chartSeriesColors, - chartTickColor + chartTickColor, + readCssVar } from '$lib/chart-theme.js'; let { @@ -76,6 +79,8 @@ if (elTraffic) { chartTraffic?.destroy(); + const c0 = colors[0] ?? '#38bdf8'; + const c4 = colors[4] ?? '#f472b6'; chartTraffic = new Chart(elTraffic, { type: 'line', data: { @@ -84,16 +89,22 @@ { label: 'Скачивание', data: [...histDown], - borderColor: colors[0] ?? '#38bdf8', - backgroundColor: `${colors[0] ?? '#38bdf8'}33`, + borderColor: c0, + backgroundColor: chartLineAreaFillCallback(c0), + borderWidth: 1.5, + pointRadius: 0, + pointHoverRadius: 3, tension: 0.2, fill: true }, { label: 'Загрузка', data: [...histUp], - borderColor: colors[4] ?? '#f472b6', - backgroundColor: `${colors[4] ?? '#f472b6'}33`, + borderColor: c4, + backgroundColor: chartLineAreaFillCallback(c4), + borderWidth: 1.5, + pointRadius: 0, + pointHoverRadius: 3, tension: 0.2, fill: true } @@ -105,6 +116,7 @@ if (elFlow) { chartFlow?.destroy(); + const hole = readCssVar('--card') || readCssVar('--background') || '#18181b'; chartFlow = new Chart(elFlow, { type: 'doughnut', data: { @@ -112,7 +124,14 @@ datasets: [ { data: [Math.max(totalDown, 0), Math.max(totalUp, 0)], - backgroundColor: [colors[0] ?? '#38bdf8', colors[4] ?? '#f472b6'] + backgroundColor: [ + chartColorWithAlpha(colors[0] ?? '#38bdf8', 0.88), + chartColorWithAlpha(colors[4] ?? '#f472b6', 0.88) + ], + borderWidth: 2, + borderColor: hole, + hoverBorderWidth: 2, + hoverBorderColor: hole } ] }, @@ -120,6 +139,7 @@ responsive: true, maintainAspectRatio: false, animation: false, + cutout: '58%', plugins: { legend: { labels: { color: legend } } } } }); @@ -127,6 +147,7 @@ if (elMem) { chartMem?.destroy(); + const c2 = colors[2] ?? '#a78bfa'; chartMem = new Chart(elMem, { type: 'line', data: { @@ -135,8 +156,11 @@ { label: 'KiB', data: [...histMem], - borderColor: colors[2] ?? '#a78bfa', - backgroundColor: `${colors[2] ?? '#a78bfa'}33`, + borderColor: c2, + backgroundColor: chartLineAreaFillCallback(c2), + borderWidth: 1.5, + pointRadius: 0, + pointHoverRadius: 3, tension: 0.2, fill: true } @@ -148,6 +172,7 @@ if (elConn) { chartConn?.destroy(); + const c1 = colors[1] ?? '#34d399'; chartConn = new Chart(elConn, { type: 'line', data: { @@ -156,8 +181,11 @@ { label: 'Соединения', data: [...histConn], - borderColor: colors[1] ?? '#34d399', - backgroundColor: `${colors[1] ?? '#34d399'}33`, + borderColor: c1, + backgroundColor: chartLineAreaFillCallback(c1), + borderWidth: 1.5, + pointRadius: 0, + pointHoverRadius: 3, tension: 0.2, fill: true } @@ -169,6 +197,7 @@ if (elNet) { chartNet?.destroy(); + const holeNet = readCssVar('--card') || readCssVar('--background') || '#18181b'; chartNet = new Chart(elNet, { type: 'doughnut', data: { @@ -176,7 +205,14 @@ datasets: [ { data: [tcpN, udpN], - backgroundColor: [colors[1] ?? '#22c55e', colors[3] ?? '#eab308'] + backgroundColor: [ + chartColorWithAlpha(colors[1] ?? '#22c55e', 0.88), + chartColorWithAlpha(colors[3] ?? '#eab308', 0.88) + ], + borderWidth: 2, + borderColor: holeNet, + hoverBorderWidth: 2, + hoverBorderColor: holeNet } ] }, @@ -184,6 +220,7 @@ responsive: true, maintainAspectRatio: false, animation: false, + cutout: '58%', plugins: { legend: { labels: { color: legend } } } } }); @@ -199,7 +236,9 @@ { label: 'Сессии', data: topList.map((x) => x.n), - backgroundColor: colors[0] ?? '#818cf8' + backgroundColor: chartColorWithAlpha(colors[0] ?? '#818cf8', 0.52), + borderRadius: 4, + borderSkipped: false } ] }, diff --git a/web/src/lib/components/ui/checkbox/checkbox.svelte b/web/src/lib/components/ui/checkbox/checkbox.svelte new file mode 100644 index 0000000..27b0da6 --- /dev/null +++ b/web/src/lib/components/ui/checkbox/checkbox.svelte @@ -0,0 +1,39 @@ + + + + {#snippet children({ checked, indeterminate })} +
+ {#if checked} + + {:else if indeterminate} + + {/if} +
+ {/snippet} +
diff --git a/web/src/lib/components/ui/checkbox/index.ts b/web/src/lib/components/ui/checkbox/index.ts new file mode 100644 index 0000000..6d92d94 --- /dev/null +++ b/web/src/lib/components/ui/checkbox/index.ts @@ -0,0 +1,6 @@ +import Root from "./checkbox.svelte"; +export { + Root, + // + Root as Checkbox, +}; diff --git a/web/src/routes/users/+page.svelte b/web/src/routes/users/+page.svelte index 284abfb..96d844c 100644 --- a/web/src/routes/users/+page.svelte +++ b/web/src/routes/users/+page.svelte @@ -10,8 +10,15 @@ import * as Table from '$lib/components/ui/table/index.js'; import * as Card from '$lib/components/ui/card/index.js'; import DataTableCard from '$lib/components/data-table/data-table-card.svelte'; - import DataTableToolbar from '$lib/components/data-table/data-table-toolbar.svelte'; import SortableTh from '$lib/components/data-table/sortable-th.svelte'; + import * as Tabs from '$lib/components/ui/tabs/index.js'; + import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js'; + import { Badge } from '$lib/components/ui/badge/index.js'; + import { Checkbox } from '$lib/components/ui/checkbox/index.js'; + import { cn } from '$lib/utils.js'; + import Columns3Icon from '@lucide/svelte/icons/columns-3'; + import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'; + import MoreVerticalIcon from '@lucide/svelte/icons/more-vertical'; import { ScrollArea } from '$lib/components/ui/scroll-area/index.js'; import SearchIcon from '@lucide/svelte/icons/search'; import { nextSortDir, compareStrings, compareNum, type SortDir } from '$lib/table-sort.js'; @@ -170,13 +177,27 @@ let userSearch = $state(''); let sortKey = $state<'name' | 'traffic' | 'ips' | 'exp' | null>(null); let sortDir = $state(null); + let usersViewTab = $state<'all' | 'traffic' | 'active'>('all'); + let showColLinks = $state(true); + let showColExpires = $state(true); + let selectedUsernames = $state([]); let hasStaleData = $derived(err != null && rows.length > 0); let showSkeleton = $derived(loading && rows.length === 0 && !err); let isDataEmpty = $derived(!loading && rows.length === 0 && !err); + let countAll = $derived(rows.length); + let countTraffic = $derived(rows.filter((r) => (r.total_megabytes ?? 0) > 0).length); + let countActive = $derived(rows.filter((r) => (r.active_unique_ips ?? 0) > 0).length); + + let tabFilteredRows = $derived.by(() => { + if (usersViewTab === 'traffic') return rows.filter((r) => (r.total_megabytes ?? 0) > 0); + if (usersViewTab === 'active') return rows.filter((r) => (r.active_unique_ips ?? 0) > 0); + return rows; + }); + let processedRows = $derived.by(() => { - let r = [...rows]; + let r = [...tabFilteredRows]; const q = userSearch.trim().toLowerCase(); if (q) r = r.filter((row) => (row.username ?? '').toLowerCase().includes(q)); const k = sortKey; @@ -205,11 +226,40 @@ return processedRows.slice(start, start + usersPerPage); }); + let pageUsernames = $derived( + pagedRows.map((r) => r.username ?? '').filter((u): u is string => u.length > 0) + ); + let selectedOnPage = $derived(pageUsernames.filter((u) => selectedUsernames.includes(u))); + let headerCheckboxChecked = $derived( + pageUsernames.length > 0 && selectedOnPage.length === pageUsernames.length + ); + let headerCheckboxIndeterminate = $derived( + selectedOnPage.length > 0 && selectedOnPage.length < pageUsernames.length + ); + $effect(() => { aliasFilter; userSearch; + usersViewTab; usersPage = 1; }); + + $effect(() => { + usersViewTab; + selectedUsernames = []; + }); + + $effect(() => { + rows; + const valid = new Set( + rows.map((r) => r.username ?? '').filter((u): u is string => u.length > 0) + ); + const next = selectedUsernames.filter((u) => valid.has(u)); + const same = + next.length === selectedUsernames.length && + next.every((u, i) => u === selectedUsernames[i]); + if (!same) selectedUsernames = next; + }); $effect(() => { if (usersPage > pageCount) usersPage = pageCount; }); @@ -240,6 +290,23 @@ void goto(`/users/${encodeURIComponent(u)}`); } + function toggleRowSelect(username: string, checked: boolean) { + if (!username) return; + if (checked) { + if (!selectedUsernames.includes(username)) selectedUsernames = [...selectedUsernames, username]; + } else { + selectedUsernames = selectedUsernames.filter((u) => u !== username); + } + } + + function toggleSelectAllOnPage(checked: boolean) { + if (checked) { + selectedUsernames = [...new Set([...selectedUsernames, ...pageUsernames])]; + } else { + selectedUsernames = selectedUsernames.filter((u) => !pageUsernames.includes(u)); + } + } + function fallbackCopy(value: string) { if (typeof document === 'undefined') return; const ta = document.createElement('textarea'); @@ -327,35 +394,104 @@ description="Слияние по имени между серверами; клик по строке — карточка пользователя" > {#snippet toolbar()} - -
- - +
+
+ + + + Все + {countAll} + + + С трафиком + {countTraffic} + + + Активные IP + {countActive} + + + + {#if selectedUsernames.length > 0} + + Выбрано: {selectedUsernames.length} + + {/if}
- - +
+
+ + +
+
+ + + {#snippet child({ props })} + + {/snippet} + + + Видимость столбцов + + + Ссылки + + + Истекает + + + + +
+
+
{/snippet} - - + + + + {#if pageUsernames.length > 0} + toggleSelectAllOnPage(v === true)} + /> + {/if} + toggleSort('name')} + class="min-w-[8rem]" >Имя - + Ссылки toggleSort('exp')} - class="hidden sm:table-cell" + class={cn( + !showColExpires && 'hidden', + showColExpires && 'hidden sm:table-cell' + )} >Истекает + {#each pagedRows as row (row.username ?? '')} + {@const uname = row.username ?? ''} openUser(row.username)} onkeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { @@ -393,9 +534,25 @@ } }} > - {row.username} -