Enhance chart theming and functionality across fleet and Mihomo components
Publish telemt-api gateway Docker image / test (push) Successful in 26s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m33s

- Introduced `chartColorWithAlpha` function to manage color transparency, improving visual consistency in charts.
- Updated chart configurations in fleet and Mihomo components to utilize dynamic theming for background and border colors, enhancing user experience.
- Implemented `chartLineAreaFillCallback` for better area fill effects in line charts, providing a more polished visual representation.
- Refactored chart initialization logic to ensure proper handling of color and gradient settings, improving performance and responsiveness.
This commit is contained in:
Denozordec
2026-03-31 18:54:22 +07:00
parent 89b6cc5185
commit 8e616cd1c4
6 changed files with 432 additions and 50 deletions
+65
View File
@@ -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);
};
}
@@ -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
}
]
},
@@ -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
}
]
},
@@ -0,0 +1,39 @@
<script lang="ts">
import { Checkbox as CheckboxPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import CheckIcon from '@lucide/svelte/icons/check';
import MinusIcon from '@lucide/svelte/icons/minus';
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
...restProps
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
</script>
<CheckboxPrimitive.Root
bind:ref
data-slot="checkbox"
class={cn(
"border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-colors group-has-disabled/field:opacity-50 focus-visible:ring-3 aria-invalid:ring-3 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
bind:checked
bind:indeterminate
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<div
data-slot="checkbox-indicator"
class="[&>svg]:size-3.5 grid place-content-center text-current transition-none"
>
{#if checked}
<CheckIcon />
{:else if indeterminate}
<MinusIcon />
{/if}
</div>
{/snippet}
</CheckboxPrimitive.Root>
@@ -0,0 +1,6 @@
import Root from "./checkbox.svelte";
export {
Root,
//
Root as Checkbox,
};
+255 -34
View File
@@ -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<SortDir | null>(null);
let usersViewTab = $state<'all' | 'traffic' | 'active'>('all');
let showColLinks = $state(true);
let showColExpires = $state(true);
let selectedUsernames = $state<string[]>([]);
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()}
<DataTableToolbar>
<div class="relative max-w-sm flex-1">
<SearchIcon
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input class="h-8 ps-8" placeholder="Поиск по имени…" bind:value={userSearch} />
<div class="border-b border-border">
<div
class="flex flex-wrap items-center justify-between gap-2 bg-muted/30 px-3 py-2.5"
>
<Tabs.Root bind:value={usersViewTab} class="w-full min-w-0 sm:w-auto">
<Tabs.List class="h-auto min-h-8 w-full flex-wrap justify-start gap-1 p-1 sm:w-fit">
<Tabs.Trigger value="all" class="shrink-0 gap-1.5 px-2.5 py-1 text-xs sm:text-sm">
Все
<Badge variant="secondary" class="h-5 min-w-5 justify-center px-1.5 text-[10px] font-semibold tabular-nums sm:text-xs">{countAll}</Badge>
</Tabs.Trigger>
<Tabs.Trigger value="traffic" class="shrink-0 gap-1.5 px-2.5 py-1 text-xs sm:text-sm">
С трафиком
<Badge variant="secondary" class="h-5 min-w-5 justify-center px-1.5 text-[10px] font-semibold tabular-nums sm:text-xs">{countTraffic}</Badge>
</Tabs.Trigger>
<Tabs.Trigger value="active" class="shrink-0 gap-1.5 px-2.5 py-1 text-xs sm:text-sm">
Активные IP
<Badge variant="secondary" class="h-5 min-w-5 justify-center px-1.5 text-[10px] font-semibold tabular-nums sm:text-xs">{countActive}</Badge>
</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
{#if selectedUsernames.length > 0}
<span class="text-xs text-muted-foreground tabular-nums">
Выбрано: {selectedUsernames.length}
</span>
{/if}
</div>
<Button
variant="outline"
size="sm"
class="h-8"
onclick={() => {
userSearch = '';
sortKey = null;
sortDir = null;
}}
>Сброс</Button>
</DataTableToolbar>
<div
class="flex flex-wrap items-center justify-between gap-2 border-t border-border/60 bg-muted/30 px-3 py-2"
>
<div class="relative max-w-sm min-w-[200px] flex-1">
<SearchIcon
class="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input class="h-9 ps-9" placeholder="Поиск по имени…" bind:value={userSearch} />
</div>
<div class="flex shrink-0 flex-wrap items-center gap-2">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" size="sm" class="h-9 gap-1.5">
<Columns3Icon class="size-4 opacity-70" />
Столбцы
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-52">
<DropdownMenu.Label class="text-xs">Видимость столбцов</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.CheckboxItem bind:checked={showColLinks}>
Ссылки
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem bind:checked={showColExpires}>
Истекает
</DropdownMenu.CheckboxItem>
</DropdownMenu.Content>
</DropdownMenu.Root>
<Button
variant="outline"
size="sm"
class="h-9"
onclick={() => {
userSearch = '';
sortKey = null;
sortDir = null;
selectedUsernames = [];
}}
>Сброс</Button>
</div>
</div>
</div>
{/snippet}
<ScrollArea class="max-h-[min(70vh,560px)] w-full" orientation="both">
<Table.Root>
<Table.Header class="sticky top-0 z-10 bg-muted/95 backdrop-blur-sm">
<Table.Row>
<Table.Header class="sticky top-0 z-10 border-b border-border bg-muted/95 backdrop-blur-sm">
<Table.Row class="border-0 hover:bg-transparent">
<Table.Head class="w-10 px-2">
{#if pageUsernames.length > 0}
<Checkbox
checked={headerCheckboxChecked}
indeterminate={headerCheckboxIndeterminate}
aria-label="Выбрать всех на странице"
onCheckedChange={(v) => toggleSelectAllOnPage(v === true)}
/>
{/if}
</Table.Head>
<SortableTh
active={sortKey === 'name'}
dir={sortDir}
onToggle={() => toggleSort('name')}
class="min-w-[8rem]"
>Имя</SortableTh>
<Table.Head class="hidden sm:table-cell">Ссылки</Table.Head>
<Table.Head
class={cn(
'text-muted-foreground text-xs font-medium uppercase tracking-wide',
!showColLinks && 'hidden',
showColLinks && 'hidden sm:table-cell'
)}
>Ссылки</Table.Head>
<SortableTh
numeric
active={sortKey === 'traffic'}
@@ -374,17 +510,22 @@
active={sortKey === 'exp'}
dir={sortDir}
onToggle={() => toggleSort('exp')}
class="hidden sm:table-cell"
class={cn(
!showColExpires && 'hidden',
showColExpires && 'hidden sm:table-cell'
)}
>Истекает</SortableTh>
<Table.Head class="w-12 px-2 text-right" aria-hidden="true"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each pagedRows as row (row.username ?? '')}
{@const uname = row.username ?? ''}
<Table.Row
class="cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
class="group cursor-pointer border-b border-border transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
tabindex={0}
role="button"
aria-label={`Открыть пользователя ${row.username ?? ''}`}
aria-label={`Открыть пользователя ${uname}`}
onclick={() => openUser(row.username)}
onkeydown={(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -393,9 +534,25 @@
}
}}
>
<Table.Cell class="font-medium">{row.username}</Table.Cell>
<Table.Cell class="hidden sm:table-cell">
<div class="flex flex-wrap gap-1">
<Table.Cell class="w-10 px-2 align-middle" onclick={(e) => e.stopPropagation()}>
{#if uname}
<Checkbox
checked={selectedUsernames.includes(uname)}
aria-label={`Выбрать ${uname}`}
onCheckedChange={(v) => toggleRowSelect(uname, v === true)}
/>
{/if}
</Table.Cell>
<Table.Cell class="py-3 font-semibold text-foreground">{row.username}</Table.Cell>
<Table.Cell
class={cn(
'py-3 align-middle',
!showColLinks && 'hidden',
showColLinks && 'hidden sm:table-cell'
)}
onclick={(e) => e.stopPropagation()}
>
<div class="flex max-w-[220px] flex-wrap gap-1">
{#each row.links?.tls ?? [] as link (link)}
<Button
variant="outline"
@@ -424,18 +581,82 @@
<CopyIcon class="size-3 opacity-60" />
</Button>
{/each}
{#if !(row.links?.tls?.length) && !(row.links?.secure?.length)}
<span class="text-xs text-muted-foreground"></span>
{/if}
</div>
</Table.Cell>
<Table.Cell class="text-right tabular-nums">{formatMiB(row.total_megabytes ?? 0)}</Table.Cell>
<Table.Cell class="text-right text-sm font-mono tabular-nums">
{row.active_unique_ips ?? 0}
{#if row.max_unique_ips != null}
<span class="text-muted-foreground"> / {row.max_unique_ips}</span>
<Table.Cell class="py-3 text-right align-middle tabular-nums">
{#if (row.total_megabytes ?? 0) > 0}
<Badge variant="secondary" class="font-mono font-normal tabular-nums">
{formatMiB(row.total_megabytes ?? 0)}
</Badge>
{:else}
<Badge variant="outline" class="font-mono font-normal text-muted-foreground tabular-nums">
{formatMiB(0)}
</Badge>
{/if}
</Table.Cell>
<Table.Cell class="hidden sm:table-cell text-sm text-muted-foreground">
<Table.Cell class="py-3 text-right align-middle">
{#if (row.active_unique_ips ?? 0) > 0}
<Badge variant="secondary" class="font-mono font-normal tabular-nums">
{row.active_unique_ips ?? 0}
{#if row.max_unique_ips != null}
<span class="text-muted-foreground">/{row.max_unique_ips}</span>
{/if}
</Badge>
{:else}
<span class="font-mono text-sm tabular-nums text-muted-foreground">
{row.active_unique_ips ?? 0}
{#if row.max_unique_ips != null}
<span> / {row.max_unique_ips}</span>
{/if}
</span>
{/if}
</Table.Cell>
<Table.Cell
class={cn(
'py-3 text-sm text-muted-foreground',
!showColExpires && 'hidden',
showColExpires && 'hidden sm:table-cell'
)}
>
{row.expiration_rfc3339 ? new Date(row.expiration_rfc3339).toLocaleString() : '—'}
</Table.Cell>
<Table.Cell class="w-14 px-1 py-3 align-middle" onclick={(e) => e.stopPropagation()}>
<div class="flex items-center justify-end gap-0.5 pe-0.5">
<ChevronRightIcon
class="pointer-events-none size-4 text-muted-foreground/50 opacity-0 transition-opacity group-hover:opacity-100 max-sm:hidden"
aria-hidden="true"
/>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="ghost"
size="icon"
class="size-8 text-muted-foreground hover:text-foreground"
aria-label="Действия"
>
<MoreVerticalIcon class="size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-48">
<DropdownMenu.Item onclick={() => openUser(row.username)}>
Открыть карточку
</DropdownMenu.Item>
<DropdownMenu.Item
disabled={!uname}
onclick={() => uname && copy(uname)}
>
Копировать имя
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>