Implement alias filtering across multiple pages
Publish telemt-api gateway Docker image / test (push) Successful in 25s
Publish telemt-api gateway Docker image / build-and-push (push) Successful in 2m25s

- Introduced an `AliasFilterSelect` component to streamline alias selection in various routes, enhancing user experience.
- Refactored alias handling logic to use a single filter mechanism, improving consistency and reducing code duplication.
- Updated API calls to utilize the new alias filtering logic, ensuring accurate data retrieval based on user-selected aliases.
- Enhanced the loading of available aliases dynamically from aggregated summary data, keeping the interface up-to-date.
This commit is contained in:
Denozordec
2026-03-30 20:04:33 +07:00
parent d0d7b8c1e6
commit 725ab0bf6e
6 changed files with 144 additions and 158 deletions
@@ -0,0 +1,32 @@
<script lang="ts">
import * as Select from "$lib/components/ui/select/index.js";
let {
availableAliases = [],
value = $bindable("all"),
label = "Alias",
allLabel = "All nodes"
}: {
availableAliases: string[];
value?: string;
label?: string;
allLabel?: string;
} = $props();
let selectedLabel = $derived(value === "all" ? allLabel : value);
</script>
<label class="text-xs text-muted-foreground">
{label}
<Select.Root type="single" bind:value>
<Select.Trigger class="mt-1 w-[220px]">
{selectedLabel}
</Select.Trigger>
<Select.Content>
<Select.Item value="all">{allLabel}</Select.Item>
{#each availableAliases as alias (alias)}
<Select.Item value={alias}>{alias}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</label>
+29 -19
View File
@@ -17,6 +17,7 @@
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import AliasFilterSelect from '$lib/components/alias-filter-select.svelte';
import { cn } from '$lib/utils.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import AlertTriangleIcon from '@lucide/svelte/icons/triangle-alert';
@@ -75,8 +76,8 @@
let uniqueIps = $state<components['schemas']['UniqueIPsRow'][] | null>(null);
let generatedAt = $state<string | null>(null);
let partial = $state(false);
let aliases = $state('');
let aliasesInput = $state('');
let aliasFilter = $state('all');
let availableAliases = $state<string[]>([]);
let refreshSeconds = $state(30);
let refreshInput = $state('30');
let lastSuccessAtMs = $state<number | null>(null);
@@ -90,20 +91,39 @@
>
>({});
function parseAliasFilter(raw: string | null): string {
const value = raw?.trim() ?? '';
return !value || value.includes(',') ? 'all' : value;
}
function collectAliases(...lists: Array<Array<string | null | undefined> | null | undefined>): string[] {
const out = new Set<string>();
for (const list of lists) {
for (const item of list ?? []) {
if (typeof item === 'string' && item.trim()) out.add(item);
}
}
return Array.from(out).sort((a, b) => a.localeCompare(b));
}
async function load() {
loading = true;
err = null;
try {
const [s, f, u] = await Promise.all([
fetchAggSummary({ top_n: 15, aliases: aliases || undefined }),
fetchAggFleetStatus({ aliases: aliases || undefined }),
fetchAggUniqueIps({ aliases: aliases || undefined, geo: true })
fetchAggSummary({ top_n: 15, aliases: aliasFilter === 'all' ? undefined : aliasFilter }),
fetchAggFleetStatus({ aliases: aliasFilter === 'all' ? undefined : aliasFilter }),
fetchAggUniqueIps({ aliases: aliasFilter === 'all' ? undefined : aliasFilter, geo: true })
]);
generatedAt = s.generated_at;
partial = !!(s.partial || f.partial || u.partial);
summary = s.data;
fleet = f.data ?? null;
uniqueIps = u.data ?? null;
availableAliases = collectAliases(
(fleet?.servers ?? []).map((x) => x.alias),
(summary?.servers ?? []).map((x) => x.alias)
);
lastSuccessAtMs = Date.now();
// Как бейдж «OK» в таблице: только health + system/info (без x.ok — в JSON поле ok опционально).
@@ -163,10 +183,9 @@
async function applyControls() {
const q = new URLSearchParams(page.url.searchParams);
const normalizedAliases = aliasesInput.trim();
const parsedRefresh = parseRefresh(refreshInput);
if (normalizedAliases) q.set('aliases', normalizedAliases);
else q.delete('aliases');
if (aliasFilter === 'all') q.delete('aliases');
else q.set('aliases', aliasFilter);
if (parsedRefresh === 30) q.delete('refresh');
else q.set('refresh', String(parsedRefresh));
const qs = q.toString();
@@ -181,9 +200,8 @@
$effect(() => {
queryKey;
if (typeof window === 'undefined') return;
aliases = page.url.searchParams.get('aliases')?.trim() ?? '';
aliasFilter = parseAliasFilter(page.url.searchParams.get('aliases'));
refreshSeconds = parseRefresh(page.url.searchParams.get('refresh'));
aliasesInput = aliases;
refreshInput = String(refreshSeconds);
restartPolling();
void load();
@@ -370,15 +388,7 @@
</div>
<div class="mb-4 flex flex-wrap items-end gap-2">
<label class="text-xs text-muted-foreground">
Aliases
<input
type="text"
class="mt-1 h-9 w-56 rounded-md border bg-background px-2 text-sm"
bind:value={aliasesInput}
placeholder="node-a,node-b"
/>
</label>
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} />
<label class="text-xs text-muted-foreground">
Refresh (sec)
<input
+14 -52
View File
@@ -14,6 +14,7 @@
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import AliasFilterSelect from '$lib/components/alias-filter-select.svelte';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
type TriageState = {
@@ -31,7 +32,7 @@
let generatedAt = $state<string | null>(null);
let data = $state<IncidentsData | null>(null);
let availableAliases = $state<string[]>([]);
let selectedAliases = $state<string[]>([]);
let aliasFilter = $state('all');
let refreshSeconds = $state(30);
let refreshInput = $state('30');
let lastSuccessAtMs = $state<number | null>(null);
@@ -48,25 +49,9 @@
return Math.max(10, Math.min(300, Math.floor(n)));
}
function parseAliases(raw: string | null): string[] {
if (!raw) return [];
const out = raw
.split(',')
.map((s) => s.trim())
.filter(Boolean);
return Array.from(new Set(out)).sort();
}
function aliasesLabel(): string {
if (selectedAliases.length === 0) return 'Все ноды';
if (selectedAliases.length <= 3) return selectedAliases.join(', ');
return `${selectedAliases.slice(0, 3).join(', ')} +${selectedAliases.length - 3}`;
}
function toggleAlias(a: string) {
if (!a) return;
const has = selectedAliases.includes(a);
selectedAliases = has ? selectedAliases.filter((x) => x !== a) : [...selectedAliases, a].sort();
function parseAliasFilter(raw: string | null): string {
const value = raw?.trim() ?? '';
return !value || value.includes(',') ? 'all' : value;
}
function triageFor(id: string): TriageState {
@@ -133,8 +118,9 @@
loading = true;
err = null;
try {
const aliasesParam = selectedAliases.length ? selectedAliases.join(',') : undefined;
const env = await fetchAggIncidents({ aliases: aliasesParam });
const env = await fetchAggIncidents({
aliases: aliasFilter === 'all' ? undefined : aliasFilter
});
data = env.data ?? null;
partial = !!env.partial;
generatedAt = env.generated_at;
@@ -149,8 +135,8 @@
async function applyControls() {
const q = new URLSearchParams(page.url.searchParams);
const parsedRefresh = parseRefresh(refreshInput);
if (selectedAliases.length > 0) q.set('aliases', selectedAliases.join(','));
else q.delete('aliases');
if (aliasFilter === 'all') q.delete('aliases');
else q.set('aliases', aliasFilter);
if (parsedRefresh === 30) q.delete('refresh');
else q.set('refresh', String(parsedRefresh));
const qs = q.toString();
@@ -165,7 +151,7 @@
$effect(() => {
queryKey;
if (typeof window === 'undefined') return;
selectedAliases = parseAliases(page.url.searchParams.get('aliases'));
aliasFilter = parseAliasFilter(page.url.searchParams.get('aliases'));
refreshSeconds = parseRefresh(page.url.searchParams.get('refresh'));
refreshInput = String(refreshSeconds);
restartPolling();
@@ -187,9 +173,9 @@
const aliases = (env.data.servers ?? [])
.map((s) => s.alias)
.filter((a): a is string => typeof a === 'string' && a.length > 0)
.sort();
.sort((a, b) => a.localeCompare(b));
availableAliases = aliases;
selectedAliases = selectedAliases.filter((a) => aliases.includes(a));
if (aliasFilter !== 'all' && !aliases.includes(aliasFilter)) aliasFilter = 'all';
} catch {
availableAliases = [];
}
@@ -224,31 +210,7 @@
</div>
<div class="mb-4 flex flex-wrap items-end gap-2">
<details class="rounded-md border bg-background p-2">
<summary class="cursor-pointer select-none text-sm">
<span class="text-xs text-muted-foreground">Aliases</span>
<span class="ml-2 font-medium">{aliasesLabel()}</span>
</summary>
<div class="mt-2 max-h-48 overflow-auto pr-1">
{#if availableAliases.length === 0}
<div class="px-2 py-1 text-xs text-muted-foreground">Список нод не загружен</div>
{:else}
<div class="flex flex-col gap-1">
{#each availableAliases as a (a)}
<label class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-accent/50">
<input
type="checkbox"
class="rounded"
checked={selectedAliases.includes(a)}
onchange={() => toggleAlias(a)}
/>
<span class="text-sm">{a}</span>
</label>
{/each}
</div>
{/if}
</div>
</details>
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} />
<label class="text-xs text-muted-foreground">
Refresh (sec)
<input
+30 -18
View File
@@ -2,7 +2,7 @@
import { onMount, tick } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { fetchAggUniqueIps, ApiError } from '$lib/api/client.js';
import { fetchAggUniqueIps, fetchAggSummary, ApiError } from '$lib/api/client.js';
import type { components } from '$lib/api/aggregate.gen.js';
import type * as Leaflet from 'leaflet';
import 'leaflet/dist/leaflet.css';
@@ -13,14 +13,15 @@
import { Button } from '$lib/components/ui/button/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import AliasFilterSelect from '$lib/components/alias-filter-select.svelte';
let loading = $state(true);
let err = $state<string | null>(null);
let rows = $state<components['schemas']['UniqueIPsRow'][]>([]);
let partial = $state(false);
let geo = $state(true);
let aliases = $state('');
let aliasesInput = $state('');
let aliasFilter = $state('all');
let availableAliases = $state<string[]>([]);
let refreshSeconds = $state(30);
let refreshInput = $state('30');
let lastSuccessAtMs = $state<number | null>(null);
@@ -36,6 +37,11 @@
let mapStatus = $state<string | null>(null);
let renderAttempts = $state(0);
function parseAliasFilter(raw: string | null): string {
const value = raw?.trim() ?? '';
return !value || value.includes(',') ? 'all' : value;
}
type IpCategory = 'vpn' | 'hoster' | 'isp' | 'unknown';
function classifyByAsOrg(asOrg: string | null | undefined): IpCategory {
@@ -283,7 +289,10 @@
// Ждём, пока bind:this завершится и контейнеру карты будет доступен размер.
await tick();
initMap();
const env = await fetchAggUniqueIps({ aliases: aliases || undefined, geo });
const env = await fetchAggUniqueIps({
aliases: aliasFilter === 'all' ? undefined : aliasFilter,
geo
});
partial = !!env.partial;
rows = env.data ?? [];
lastSuccessAtMs = Date.now();
@@ -326,10 +335,9 @@
async function applyControls() {
const q = new URLSearchParams(page.url.searchParams);
const normalizedAliases = aliasesInput.trim();
const parsedRefresh = parseRefresh(refreshInput);
if (normalizedAliases) q.set('aliases', normalizedAliases);
else q.delete('aliases');
if (aliasFilter === 'all') q.delete('aliases');
else q.set('aliases', aliasFilter);
if (parsedRefresh === 30) q.delete('refresh');
else q.set('refresh', String(parsedRefresh));
q.set('geo', geo ? '1' : '0');
@@ -345,10 +353,9 @@
$effect(() => {
queryKey;
if (typeof window === 'undefined') return;
aliases = page.url.searchParams.get('aliases')?.trim() ?? '';
aliasFilter = parseAliasFilter(page.url.searchParams.get('aliases'));
refreshSeconds = parseRefresh(page.url.searchParams.get('refresh'));
geo = parse01(page.url.searchParams.get('geo'), true);
aliasesInput = aliases;
refreshInput = String(refreshSeconds);
restartPolling();
void load();
@@ -362,6 +369,19 @@
);
onMount(() => {
void (async () => {
try {
const env = await fetchAggSummary({ top_n: 10 });
const aliases = (env.data.servers ?? [])
.map((s) => s.alias)
.filter((a): a is string => typeof a === 'string' && a.length > 0)
.sort((a, b) => a.localeCompare(b));
availableAliases = aliases;
if (aliasFilter !== 'all' && !aliases.includes(aliasFilter)) aliasFilter = 'all';
} catch {
availableAliases = [];
}
})();
staleTimer = setInterval(() => {
nowMs = Date.now();
}, 1000);
@@ -398,15 +418,7 @@
</div>
<div class="mb-4 flex flex-wrap items-end gap-2">
<label class="text-xs text-muted-foreground">
Aliases
<input
type="text"
class="mt-1 h-9 w-56 rounded-md border bg-background px-2 text-sm"
bind:value={aliasesInput}
placeholder="node-a,node-b"
/>
</label>
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} />
<label class="text-xs text-muted-foreground">
Refresh (sec)
<input
+12 -51
View File
@@ -14,6 +14,7 @@
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import AliasFilterSelect from '$lib/components/alias-filter-select.svelte';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
type LiveStatus = 'healthy' | 'degraded' | 'critical';
@@ -34,7 +35,7 @@
};
let availableAliases = $state<string[]>([]);
let selectedAliases = $state<string[]>([]);
let aliasFilter = $state('all');
let err = $state<string | null>(null);
let connected = $state(false);
let reconnectInSec = $state<number | null>(null);
@@ -58,25 +59,9 @@
return 'default';
}
function parseAliases(raw: string | null): string[] {
if (!raw) return [];
const out = raw
.split(',')
.map((s) => s.trim())
.filter(Boolean);
return Array.from(new Set(out)).sort();
}
function aliasesLabel(): string {
if (selectedAliases.length === 0) return 'Все ноды';
if (selectedAliases.length <= 3) return selectedAliases.join(', ');
return `${selectedAliases.slice(0, 3).join(', ')} +${selectedAliases.length - 3}`;
}
function toggleAlias(a: string) {
if (!a) return;
const has = selectedAliases.includes(a);
selectedAliases = has ? selectedAliases.filter((x) => x !== a) : [...selectedAliases, a].sort();
function parseAliasFilter(raw: string | null): string {
const value = raw?.trim() ?? '';
return !value || value.includes(',') ? 'all' : value;
}
function normIncident(v: unknown): IncidentItem | null {
@@ -170,7 +155,7 @@
closeStream();
clearReconnectTimer();
err = null;
const aliasesParam = selectedAliases.length ? selectedAliases.join(',') : undefined;
const aliasesParam = aliasFilter === 'all' ? undefined : aliasFilter;
const url = liveEventsUrl({ aliases: aliasesParam });
const next = new EventSource(url);
next.onopen = () => {
@@ -215,8 +200,8 @@
async function applyControls() {
const q = new URLSearchParams(page.url.searchParams);
if (selectedAliases.length > 0) q.set('aliases', selectedAliases.join(','));
else q.delete('aliases');
if (aliasFilter === 'all') q.delete('aliases');
else q.set('aliases', aliasFilter);
const qs = q.toString();
await goto(`${page.url.pathname}${qs ? `?${qs}` : ''}`, {
replaceState: true,
@@ -229,7 +214,7 @@
$effect(() => {
queryKey;
if (typeof window === 'undefined') return;
selectedAliases = parseAliases(page.url.searchParams.get('aliases'));
aliasFilter = parseAliasFilter(page.url.searchParams.get('aliases'));
connectStream();
});
@@ -245,9 +230,9 @@
const aliases = (env.data.servers ?? [])
.map((s) => s.alias)
.filter((a): a is string => typeof a === 'string' && a.length > 0)
.sort();
.sort((a, b) => a.localeCompare(b));
availableAliases = aliases;
selectedAliases = selectedAliases.filter((a) => aliases.includes(a));
if (aliasFilter !== 'all' && !aliases.includes(aliasFilter)) aliasFilter = 'all';
} catch {
availableAliases = [];
}
@@ -283,31 +268,7 @@
</div>
<div class="mb-4 flex flex-wrap items-end gap-2">
<details class="rounded-md border bg-background p-2">
<summary class="cursor-pointer select-none text-sm">
<span class="text-xs text-muted-foreground">Aliases</span>
<span class="ml-2 font-medium">{aliasesLabel()}</span>
</summary>
<div class="mt-2 max-h-48 overflow-auto pr-1">
{#if availableAliases.length === 0}
<div class="px-2 py-1 text-xs text-muted-foreground">Список нод не загружен</div>
{:else}
<div class="flex flex-col gap-1">
{#each availableAliases as a (a)}
<label class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-accent/50">
<input
type="checkbox"
class="rounded"
checked={selectedAliases.includes(a)}
onchange={() => toggleAlias(a)}
/>
<span class="text-sm">{a}</span>
</label>
{/each}
</div>
{/if}
</div>
</details>
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} />
<Button variant="outline" size="sm" onclick={() => void applyControls()}>
Apply
</Button>
+27 -18
View File
@@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { fetchAggUsers, ApiError } from '$lib/api/client.js';
import { fetchAggUsers, fetchAggSummary, ApiError } from '$lib/api/client.js';
import type { components } from '$lib/api/aggregate.gen.js';
import { formatMiB } from '$lib/format.js';
import { Badge } from '$lib/components/ui/badge/index.js';
@@ -10,6 +10,7 @@
import * as Table from '$lib/components/ui/table/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import AliasFilterSelect from '$lib/components/alias-filter-select.svelte';
import CopyIcon from '@lucide/svelte/icons/copy';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
@@ -18,8 +19,8 @@
let rows = $state<components['schemas']['UsersRow'][]>([]);
let partial = $state(false);
let includeLinks = $state(false);
let aliases = $state('');
let aliasesInput = $state('');
let aliasFilter = $state('all');
let availableAliases = $state<string[]>([]);
let refreshSeconds = $state(30);
let refreshInput = $state('30');
let lastSuccessAtMs = $state<number | null>(null);
@@ -27,6 +28,11 @@
let pollTimer: ReturnType<typeof setInterval> | null = null;
let staleTimer: ReturnType<typeof setInterval> | null = null;
function parseAliasFilter(raw: string | null): string {
const value = raw?.trim() ?? '';
return !value || value.includes(',') ? 'all' : value;
}
function parseRefresh(raw: string | null): number {
if (raw == null || raw.trim() === '') return 30;
const n = Number(raw);
@@ -45,7 +51,7 @@
err = null;
try {
const usersEnv = await fetchAggUsers({
aliases: aliases || undefined,
aliases: aliasFilter === 'all' ? undefined : aliasFilter,
include_links: includeLinks
});
partial = !!usersEnv.partial;
@@ -72,10 +78,9 @@
async function applyControls() {
const q = new URLSearchParams(page.url.searchParams);
const normalizedAliases = aliasesInput.trim();
const parsedRefresh = parseRefresh(refreshInput);
if (normalizedAliases) q.set('aliases', normalizedAliases);
else q.delete('aliases');
if (aliasFilter === 'all') q.delete('aliases');
else q.set('aliases', aliasFilter);
if (parsedRefresh === 30) q.delete('refresh');
else q.set('refresh', String(parsedRefresh));
q.set('include_links', includeLinks ? '1' : '0');
@@ -91,10 +96,9 @@
$effect(() => {
queryKey;
if (typeof window === 'undefined') return;
aliases = page.url.searchParams.get('aliases')?.trim() ?? '';
aliasFilter = parseAliasFilter(page.url.searchParams.get('aliases'));
refreshSeconds = parseRefresh(page.url.searchParams.get('refresh'));
includeLinks = parse01(page.url.searchParams.get('include_links'), false);
aliasesInput = aliases;
refreshInput = String(refreshSeconds);
restartPolling();
void load();
@@ -108,6 +112,19 @@
);
onMount(() => {
void (async () => {
try {
const env = await fetchAggSummary({ top_n: 10 });
const aliases = (env.data.servers ?? [])
.map((s) => s.alias)
.filter((a): a is string => typeof a === 'string' && a.length > 0)
.sort((a, b) => a.localeCompare(b));
availableAliases = aliases;
if (aliasFilter !== 'all' && !aliases.includes(aliasFilter)) aliasFilter = 'all';
} catch {
availableAliases = [];
}
})();
staleTimer = setInterval(() => {
nowMs = Date.now();
}, 1000);
@@ -180,15 +197,7 @@
</div>
<div class="mb-4 flex flex-wrap items-end gap-2">
<label class="text-xs text-muted-foreground">
Aliases
<input
type="text"
class="mt-1 h-9 w-56 rounded-md border bg-background px-2 text-sm"
bind:value={aliasesInput}
placeholder="node-a,node-b"
/>
</label>
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} />
<label class="text-xs text-muted-foreground">
Refresh (sec)
<input