Implement alias filtering across multiple pages
- 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:
@@ -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
@@ -17,6 +17,7 @@
|
|||||||
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||||
import * as Table from '$lib/components/ui/table/index.js';
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/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 { cn } from '$lib/utils.js';
|
||||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||||
import AlertTriangleIcon from '@lucide/svelte/icons/triangle-alert';
|
import AlertTriangleIcon from '@lucide/svelte/icons/triangle-alert';
|
||||||
@@ -75,8 +76,8 @@
|
|||||||
let uniqueIps = $state<components['schemas']['UniqueIPsRow'][] | null>(null);
|
let uniqueIps = $state<components['schemas']['UniqueIPsRow'][] | null>(null);
|
||||||
let generatedAt = $state<string | null>(null);
|
let generatedAt = $state<string | null>(null);
|
||||||
let partial = $state(false);
|
let partial = $state(false);
|
||||||
let aliases = $state('');
|
let aliasFilter = $state('all');
|
||||||
let aliasesInput = $state('');
|
let availableAliases = $state<string[]>([]);
|
||||||
let refreshSeconds = $state(30);
|
let refreshSeconds = $state(30);
|
||||||
let refreshInput = $state('30');
|
let refreshInput = $state('30');
|
||||||
let lastSuccessAtMs = $state<number | null>(null);
|
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() {
|
async function load() {
|
||||||
loading = true;
|
loading = true;
|
||||||
err = null;
|
err = null;
|
||||||
try {
|
try {
|
||||||
const [s, f, u] = await Promise.all([
|
const [s, f, u] = await Promise.all([
|
||||||
fetchAggSummary({ top_n: 15, aliases: aliases || undefined }),
|
fetchAggSummary({ top_n: 15, aliases: aliasFilter === 'all' ? undefined : aliasFilter }),
|
||||||
fetchAggFleetStatus({ aliases: aliases || undefined }),
|
fetchAggFleetStatus({ aliases: aliasFilter === 'all' ? undefined : aliasFilter }),
|
||||||
fetchAggUniqueIps({ aliases: aliases || undefined, geo: true })
|
fetchAggUniqueIps({ aliases: aliasFilter === 'all' ? undefined : aliasFilter, geo: true })
|
||||||
]);
|
]);
|
||||||
generatedAt = s.generated_at;
|
generatedAt = s.generated_at;
|
||||||
partial = !!(s.partial || f.partial || u.partial);
|
partial = !!(s.partial || f.partial || u.partial);
|
||||||
summary = s.data;
|
summary = s.data;
|
||||||
fleet = f.data ?? null;
|
fleet = f.data ?? null;
|
||||||
uniqueIps = u.data ?? null;
|
uniqueIps = u.data ?? null;
|
||||||
|
availableAliases = collectAliases(
|
||||||
|
(fleet?.servers ?? []).map((x) => x.alias),
|
||||||
|
(summary?.servers ?? []).map((x) => x.alias)
|
||||||
|
);
|
||||||
lastSuccessAtMs = Date.now();
|
lastSuccessAtMs = Date.now();
|
||||||
|
|
||||||
// Как бейдж «OK» в таблице: только health + system/info (без x.ok — в JSON поле ok опционально).
|
// Как бейдж «OK» в таблице: только health + system/info (без x.ok — в JSON поле ok опционально).
|
||||||
@@ -163,10 +183,9 @@
|
|||||||
|
|
||||||
async function applyControls() {
|
async function applyControls() {
|
||||||
const q = new URLSearchParams(page.url.searchParams);
|
const q = new URLSearchParams(page.url.searchParams);
|
||||||
const normalizedAliases = aliasesInput.trim();
|
|
||||||
const parsedRefresh = parseRefresh(refreshInput);
|
const parsedRefresh = parseRefresh(refreshInput);
|
||||||
if (normalizedAliases) q.set('aliases', normalizedAliases);
|
if (aliasFilter === 'all') q.delete('aliases');
|
||||||
else q.delete('aliases');
|
else q.set('aliases', aliasFilter);
|
||||||
if (parsedRefresh === 30) q.delete('refresh');
|
if (parsedRefresh === 30) q.delete('refresh');
|
||||||
else q.set('refresh', String(parsedRefresh));
|
else q.set('refresh', String(parsedRefresh));
|
||||||
const qs = q.toString();
|
const qs = q.toString();
|
||||||
@@ -181,9 +200,8 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
queryKey;
|
queryKey;
|
||||||
if (typeof window === 'undefined') return;
|
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'));
|
refreshSeconds = parseRefresh(page.url.searchParams.get('refresh'));
|
||||||
aliasesInput = aliases;
|
|
||||||
refreshInput = String(refreshSeconds);
|
refreshInput = String(refreshSeconds);
|
||||||
restartPolling();
|
restartPolling();
|
||||||
void load();
|
void load();
|
||||||
@@ -370,15 +388,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4 flex flex-wrap items-end gap-2">
|
<div class="mb-4 flex flex-wrap items-end gap-2">
|
||||||
<label class="text-xs text-muted-foreground">
|
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} />
|
||||||
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>
|
|
||||||
<label class="text-xs text-muted-foreground">
|
<label class="text-xs text-muted-foreground">
|
||||||
Refresh (sec)
|
Refresh (sec)
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/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';
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||||
|
|
||||||
type TriageState = {
|
type TriageState = {
|
||||||
@@ -31,7 +32,7 @@
|
|||||||
let generatedAt = $state<string | null>(null);
|
let generatedAt = $state<string | null>(null);
|
||||||
let data = $state<IncidentsData | null>(null);
|
let data = $state<IncidentsData | null>(null);
|
||||||
let availableAliases = $state<string[]>([]);
|
let availableAliases = $state<string[]>([]);
|
||||||
let selectedAliases = $state<string[]>([]);
|
let aliasFilter = $state('all');
|
||||||
let refreshSeconds = $state(30);
|
let refreshSeconds = $state(30);
|
||||||
let refreshInput = $state('30');
|
let refreshInput = $state('30');
|
||||||
let lastSuccessAtMs = $state<number | null>(null);
|
let lastSuccessAtMs = $state<number | null>(null);
|
||||||
@@ -48,25 +49,9 @@
|
|||||||
return Math.max(10, Math.min(300, Math.floor(n)));
|
return Math.max(10, Math.min(300, Math.floor(n)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseAliases(raw: string | null): string[] {
|
function parseAliasFilter(raw: string | null): string {
|
||||||
if (!raw) return [];
|
const value = raw?.trim() ?? '';
|
||||||
const out = raw
|
return !value || value.includes(',') ? 'all' : value;
|
||||||
.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 triageFor(id: string): TriageState {
|
function triageFor(id: string): TriageState {
|
||||||
@@ -133,8 +118,9 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
err = null;
|
err = null;
|
||||||
try {
|
try {
|
||||||
const aliasesParam = selectedAliases.length ? selectedAliases.join(',') : undefined;
|
const env = await fetchAggIncidents({
|
||||||
const env = await fetchAggIncidents({ aliases: aliasesParam });
|
aliases: aliasFilter === 'all' ? undefined : aliasFilter
|
||||||
|
});
|
||||||
data = env.data ?? null;
|
data = env.data ?? null;
|
||||||
partial = !!env.partial;
|
partial = !!env.partial;
|
||||||
generatedAt = env.generated_at;
|
generatedAt = env.generated_at;
|
||||||
@@ -149,8 +135,8 @@
|
|||||||
async function applyControls() {
|
async function applyControls() {
|
||||||
const q = new URLSearchParams(page.url.searchParams);
|
const q = new URLSearchParams(page.url.searchParams);
|
||||||
const parsedRefresh = parseRefresh(refreshInput);
|
const parsedRefresh = parseRefresh(refreshInput);
|
||||||
if (selectedAliases.length > 0) q.set('aliases', selectedAliases.join(','));
|
if (aliasFilter === 'all') q.delete('aliases');
|
||||||
else q.delete('aliases');
|
else q.set('aliases', aliasFilter);
|
||||||
if (parsedRefresh === 30) q.delete('refresh');
|
if (parsedRefresh === 30) q.delete('refresh');
|
||||||
else q.set('refresh', String(parsedRefresh));
|
else q.set('refresh', String(parsedRefresh));
|
||||||
const qs = q.toString();
|
const qs = q.toString();
|
||||||
@@ -165,7 +151,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
queryKey;
|
queryKey;
|
||||||
if (typeof window === 'undefined') return;
|
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'));
|
refreshSeconds = parseRefresh(page.url.searchParams.get('refresh'));
|
||||||
refreshInput = String(refreshSeconds);
|
refreshInput = String(refreshSeconds);
|
||||||
restartPolling();
|
restartPolling();
|
||||||
@@ -187,9 +173,9 @@
|
|||||||
const aliases = (env.data.servers ?? [])
|
const aliases = (env.data.servers ?? [])
|
||||||
.map((s) => s.alias)
|
.map((s) => s.alias)
|
||||||
.filter((a): a is string => typeof a === 'string' && a.length > 0)
|
.filter((a): a is string => typeof a === 'string' && a.length > 0)
|
||||||
.sort();
|
.sort((a, b) => a.localeCompare(b));
|
||||||
availableAliases = aliases;
|
availableAliases = aliases;
|
||||||
selectedAliases = selectedAliases.filter((a) => aliases.includes(a));
|
if (aliasFilter !== 'all' && !aliases.includes(aliasFilter)) aliasFilter = 'all';
|
||||||
} catch {
|
} catch {
|
||||||
availableAliases = [];
|
availableAliases = [];
|
||||||
}
|
}
|
||||||
@@ -224,31 +210,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4 flex flex-wrap items-end gap-2">
|
<div class="mb-4 flex flex-wrap items-end gap-2">
|
||||||
<details class="rounded-md border bg-background p-2">
|
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} />
|
||||||
<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>
|
|
||||||
<label class="text-xs text-muted-foreground">
|
<label class="text-xs text-muted-foreground">
|
||||||
Refresh (sec)
|
Refresh (sec)
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { onMount, tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { goto } from '$app/navigation';
|
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 { components } from '$lib/api/aggregate.gen.js';
|
||||||
import type * as Leaflet from 'leaflet';
|
import type * as Leaflet from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
@@ -13,14 +13,15 @@
|
|||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import * as Table from '$lib/components/ui/table/index.js';
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||||
|
import AliasFilterSelect from '$lib/components/alias-filter-select.svelte';
|
||||||
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let err = $state<string | null>(null);
|
let err = $state<string | null>(null);
|
||||||
let rows = $state<components['schemas']['UniqueIPsRow'][]>([]);
|
let rows = $state<components['schemas']['UniqueIPsRow'][]>([]);
|
||||||
let partial = $state(false);
|
let partial = $state(false);
|
||||||
let geo = $state(true);
|
let geo = $state(true);
|
||||||
let aliases = $state('');
|
let aliasFilter = $state('all');
|
||||||
let aliasesInput = $state('');
|
let availableAliases = $state<string[]>([]);
|
||||||
let refreshSeconds = $state(30);
|
let refreshSeconds = $state(30);
|
||||||
let refreshInput = $state('30');
|
let refreshInput = $state('30');
|
||||||
let lastSuccessAtMs = $state<number | null>(null);
|
let lastSuccessAtMs = $state<number | null>(null);
|
||||||
@@ -36,6 +37,11 @@
|
|||||||
let mapStatus = $state<string | null>(null);
|
let mapStatus = $state<string | null>(null);
|
||||||
let renderAttempts = $state(0);
|
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';
|
type IpCategory = 'vpn' | 'hoster' | 'isp' | 'unknown';
|
||||||
|
|
||||||
function classifyByAsOrg(asOrg: string | null | undefined): IpCategory {
|
function classifyByAsOrg(asOrg: string | null | undefined): IpCategory {
|
||||||
@@ -283,7 +289,10 @@
|
|||||||
// Ждём, пока bind:this завершится и контейнеру карты будет доступен размер.
|
// Ждём, пока bind:this завершится и контейнеру карты будет доступен размер.
|
||||||
await tick();
|
await tick();
|
||||||
initMap();
|
initMap();
|
||||||
const env = await fetchAggUniqueIps({ aliases: aliases || undefined, geo });
|
const env = await fetchAggUniqueIps({
|
||||||
|
aliases: aliasFilter === 'all' ? undefined : aliasFilter,
|
||||||
|
geo
|
||||||
|
});
|
||||||
partial = !!env.partial;
|
partial = !!env.partial;
|
||||||
rows = env.data ?? [];
|
rows = env.data ?? [];
|
||||||
lastSuccessAtMs = Date.now();
|
lastSuccessAtMs = Date.now();
|
||||||
@@ -326,10 +335,9 @@
|
|||||||
|
|
||||||
async function applyControls() {
|
async function applyControls() {
|
||||||
const q = new URLSearchParams(page.url.searchParams);
|
const q = new URLSearchParams(page.url.searchParams);
|
||||||
const normalizedAliases = aliasesInput.trim();
|
|
||||||
const parsedRefresh = parseRefresh(refreshInput);
|
const parsedRefresh = parseRefresh(refreshInput);
|
||||||
if (normalizedAliases) q.set('aliases', normalizedAliases);
|
if (aliasFilter === 'all') q.delete('aliases');
|
||||||
else q.delete('aliases');
|
else q.set('aliases', aliasFilter);
|
||||||
if (parsedRefresh === 30) q.delete('refresh');
|
if (parsedRefresh === 30) q.delete('refresh');
|
||||||
else q.set('refresh', String(parsedRefresh));
|
else q.set('refresh', String(parsedRefresh));
|
||||||
q.set('geo', geo ? '1' : '0');
|
q.set('geo', geo ? '1' : '0');
|
||||||
@@ -345,10 +353,9 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
queryKey;
|
queryKey;
|
||||||
if (typeof window === 'undefined') return;
|
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'));
|
refreshSeconds = parseRefresh(page.url.searchParams.get('refresh'));
|
||||||
geo = parse01(page.url.searchParams.get('geo'), true);
|
geo = parse01(page.url.searchParams.get('geo'), true);
|
||||||
aliasesInput = aliases;
|
|
||||||
refreshInput = String(refreshSeconds);
|
refreshInput = String(refreshSeconds);
|
||||||
restartPolling();
|
restartPolling();
|
||||||
void load();
|
void load();
|
||||||
@@ -362,6 +369,19 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
onMount(() => {
|
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(() => {
|
staleTimer = setInterval(() => {
|
||||||
nowMs = Date.now();
|
nowMs = Date.now();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
@@ -398,15 +418,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4 flex flex-wrap items-end gap-2">
|
<div class="mb-4 flex flex-wrap items-end gap-2">
|
||||||
<label class="text-xs text-muted-foreground">
|
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} />
|
||||||
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>
|
|
||||||
<label class="text-xs text-muted-foreground">
|
<label class="text-xs text-muted-foreground">
|
||||||
Refresh (sec)
|
Refresh (sec)
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import * as Table from '$lib/components/ui/table/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';
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||||
|
|
||||||
type LiveStatus = 'healthy' | 'degraded' | 'critical';
|
type LiveStatus = 'healthy' | 'degraded' | 'critical';
|
||||||
@@ -34,7 +35,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
let availableAliases = $state<string[]>([]);
|
let availableAliases = $state<string[]>([]);
|
||||||
let selectedAliases = $state<string[]>([]);
|
let aliasFilter = $state('all');
|
||||||
let err = $state<string | null>(null);
|
let err = $state<string | null>(null);
|
||||||
let connected = $state(false);
|
let connected = $state(false);
|
||||||
let reconnectInSec = $state<number | null>(null);
|
let reconnectInSec = $state<number | null>(null);
|
||||||
@@ -58,25 +59,9 @@
|
|||||||
return 'default';
|
return 'default';
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseAliases(raw: string | null): string[] {
|
function parseAliasFilter(raw: string | null): string {
|
||||||
if (!raw) return [];
|
const value = raw?.trim() ?? '';
|
||||||
const out = raw
|
return !value || value.includes(',') ? 'all' : value;
|
||||||
.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 normIncident(v: unknown): IncidentItem | null {
|
function normIncident(v: unknown): IncidentItem | null {
|
||||||
@@ -170,7 +155,7 @@
|
|||||||
closeStream();
|
closeStream();
|
||||||
clearReconnectTimer();
|
clearReconnectTimer();
|
||||||
err = null;
|
err = null;
|
||||||
const aliasesParam = selectedAliases.length ? selectedAliases.join(',') : undefined;
|
const aliasesParam = aliasFilter === 'all' ? undefined : aliasFilter;
|
||||||
const url = liveEventsUrl({ aliases: aliasesParam });
|
const url = liveEventsUrl({ aliases: aliasesParam });
|
||||||
const next = new EventSource(url);
|
const next = new EventSource(url);
|
||||||
next.onopen = () => {
|
next.onopen = () => {
|
||||||
@@ -215,8 +200,8 @@
|
|||||||
|
|
||||||
async function applyControls() {
|
async function applyControls() {
|
||||||
const q = new URLSearchParams(page.url.searchParams);
|
const q = new URLSearchParams(page.url.searchParams);
|
||||||
if (selectedAliases.length > 0) q.set('aliases', selectedAliases.join(','));
|
if (aliasFilter === 'all') q.delete('aliases');
|
||||||
else q.delete('aliases');
|
else q.set('aliases', aliasFilter);
|
||||||
const qs = q.toString();
|
const qs = q.toString();
|
||||||
await goto(`${page.url.pathname}${qs ? `?${qs}` : ''}`, {
|
await goto(`${page.url.pathname}${qs ? `?${qs}` : ''}`, {
|
||||||
replaceState: true,
|
replaceState: true,
|
||||||
@@ -229,7 +214,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
queryKey;
|
queryKey;
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
selectedAliases = parseAliases(page.url.searchParams.get('aliases'));
|
aliasFilter = parseAliasFilter(page.url.searchParams.get('aliases'));
|
||||||
connectStream();
|
connectStream();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -245,9 +230,9 @@
|
|||||||
const aliases = (env.data.servers ?? [])
|
const aliases = (env.data.servers ?? [])
|
||||||
.map((s) => s.alias)
|
.map((s) => s.alias)
|
||||||
.filter((a): a is string => typeof a === 'string' && a.length > 0)
|
.filter((a): a is string => typeof a === 'string' && a.length > 0)
|
||||||
.sort();
|
.sort((a, b) => a.localeCompare(b));
|
||||||
availableAliases = aliases;
|
availableAliases = aliases;
|
||||||
selectedAliases = selectedAliases.filter((a) => aliases.includes(a));
|
if (aliasFilter !== 'all' && !aliases.includes(aliasFilter)) aliasFilter = 'all';
|
||||||
} catch {
|
} catch {
|
||||||
availableAliases = [];
|
availableAliases = [];
|
||||||
}
|
}
|
||||||
@@ -283,31 +268,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4 flex flex-wrap items-end gap-2">
|
<div class="mb-4 flex flex-wrap items-end gap-2">
|
||||||
<details class="rounded-md border bg-background p-2">
|
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} />
|
||||||
<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>
|
|
||||||
<Button variant="outline" size="sm" onclick={() => void applyControls()}>
|
<Button variant="outline" size="sm" onclick={() => void applyControls()}>
|
||||||
Apply
|
Apply
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { goto } from '$app/navigation';
|
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 type { components } from '$lib/api/aggregate.gen.js';
|
||||||
import { formatMiB } from '$lib/format.js';
|
import { formatMiB } from '$lib/format.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/index.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 Table from '$lib/components/ui/table/index.js';
|
||||||
import * as Card from '$lib/components/ui/card/index.js';
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert/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 CopyIcon from '@lucide/svelte/icons/copy';
|
||||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||||
|
|
||||||
@@ -18,8 +19,8 @@
|
|||||||
let rows = $state<components['schemas']['UsersRow'][]>([]);
|
let rows = $state<components['schemas']['UsersRow'][]>([]);
|
||||||
let partial = $state(false);
|
let partial = $state(false);
|
||||||
let includeLinks = $state(false);
|
let includeLinks = $state(false);
|
||||||
let aliases = $state('');
|
let aliasFilter = $state('all');
|
||||||
let aliasesInput = $state('');
|
let availableAliases = $state<string[]>([]);
|
||||||
let refreshSeconds = $state(30);
|
let refreshSeconds = $state(30);
|
||||||
let refreshInput = $state('30');
|
let refreshInput = $state('30');
|
||||||
let lastSuccessAtMs = $state<number | null>(null);
|
let lastSuccessAtMs = $state<number | null>(null);
|
||||||
@@ -27,6 +28,11 @@
|
|||||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let staleTimer: 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 {
|
function parseRefresh(raw: string | null): number {
|
||||||
if (raw == null || raw.trim() === '') return 30;
|
if (raw == null || raw.trim() === '') return 30;
|
||||||
const n = Number(raw);
|
const n = Number(raw);
|
||||||
@@ -45,7 +51,7 @@
|
|||||||
err = null;
|
err = null;
|
||||||
try {
|
try {
|
||||||
const usersEnv = await fetchAggUsers({
|
const usersEnv = await fetchAggUsers({
|
||||||
aliases: aliases || undefined,
|
aliases: aliasFilter === 'all' ? undefined : aliasFilter,
|
||||||
include_links: includeLinks
|
include_links: includeLinks
|
||||||
});
|
});
|
||||||
partial = !!usersEnv.partial;
|
partial = !!usersEnv.partial;
|
||||||
@@ -72,10 +78,9 @@
|
|||||||
|
|
||||||
async function applyControls() {
|
async function applyControls() {
|
||||||
const q = new URLSearchParams(page.url.searchParams);
|
const q = new URLSearchParams(page.url.searchParams);
|
||||||
const normalizedAliases = aliasesInput.trim();
|
|
||||||
const parsedRefresh = parseRefresh(refreshInput);
|
const parsedRefresh = parseRefresh(refreshInput);
|
||||||
if (normalizedAliases) q.set('aliases', normalizedAliases);
|
if (aliasFilter === 'all') q.delete('aliases');
|
||||||
else q.delete('aliases');
|
else q.set('aliases', aliasFilter);
|
||||||
if (parsedRefresh === 30) q.delete('refresh');
|
if (parsedRefresh === 30) q.delete('refresh');
|
||||||
else q.set('refresh', String(parsedRefresh));
|
else q.set('refresh', String(parsedRefresh));
|
||||||
q.set('include_links', includeLinks ? '1' : '0');
|
q.set('include_links', includeLinks ? '1' : '0');
|
||||||
@@ -91,10 +96,9 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
queryKey;
|
queryKey;
|
||||||
if (typeof window === 'undefined') return;
|
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'));
|
refreshSeconds = parseRefresh(page.url.searchParams.get('refresh'));
|
||||||
includeLinks = parse01(page.url.searchParams.get('include_links'), false);
|
includeLinks = parse01(page.url.searchParams.get('include_links'), false);
|
||||||
aliasesInput = aliases;
|
|
||||||
refreshInput = String(refreshSeconds);
|
refreshInput = String(refreshSeconds);
|
||||||
restartPolling();
|
restartPolling();
|
||||||
void load();
|
void load();
|
||||||
@@ -108,6 +112,19 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
onMount(() => {
|
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(() => {
|
staleTimer = setInterval(() => {
|
||||||
nowMs = Date.now();
|
nowMs = Date.now();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
@@ -180,15 +197,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4 flex flex-wrap items-end gap-2">
|
<div class="mb-4 flex flex-wrap items-end gap-2">
|
||||||
<label class="text-xs text-muted-foreground">
|
<AliasFilterSelect availableAliases={availableAliases} bind:value={aliasFilter} />
|
||||||
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>
|
|
||||||
<label class="text-xs text-muted-foreground">
|
<label class="text-xs text-muted-foreground">
|
||||||
Refresh (sec)
|
Refresh (sec)
|
||||||
<input
|
<input
|
||||||
|
|||||||
Reference in New Issue
Block a user