mirror of
https://github.com/wweir/sower.git
synced 2026-10-20 00:15:34 +00:00
feat(admin): polish console interactions and visual states
This commit is contained in:
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1374
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-575
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Sower Admin</title>
|
||||
<script type="module" crossorigin src="/assets/index-Ds2OrZNy.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DBmMlH5L.css">
|
||||
<script type="module" crossorigin src="/assets/index-DcMUCrH9.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dm4O8E14.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
+17
-16
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition'
|
||||
import Login from './views/Login.svelte'
|
||||
import Overview from './views/Overview.svelte'
|
||||
import Rules from './views/Rules.svelte'
|
||||
@@ -6,13 +7,14 @@
|
||||
import BreadcrumbHeader from './lib/components/BreadcrumbHeader.svelte'
|
||||
import { navItems, type BreadcrumbContextSegment, type NavKey } from './lib/navigation'
|
||||
import { api, ApiError, type Category, type Status } from './lib/api'
|
||||
import { startPolling } from './lib/poll'
|
||||
|
||||
let authed = $state(false)
|
||||
let activeNav: NavKey = $state('overview')
|
||||
let ruleCategory: Category = $state('proxy')
|
||||
let status: Status | null = $state(null)
|
||||
let statusError = $state('')
|
||||
let statusTimer: ReturnType<typeof setInterval> | undefined
|
||||
let stopStatusPoll: (() => void) | undefined
|
||||
|
||||
async function refreshStatus() {
|
||||
if (!authed) return
|
||||
@@ -65,10 +67,7 @@
|
||||
activeNav = 'overview'
|
||||
status = null
|
||||
statusError = ''
|
||||
void refreshStatus()
|
||||
if (!statusTimer) {
|
||||
statusTimer = setInterval(refreshStatus, 5000)
|
||||
}
|
||||
stopStatusPoll = startPolling(refreshStatus, 5000)
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
@@ -85,10 +84,8 @@
|
||||
authed = false
|
||||
status = null
|
||||
statusError = ''
|
||||
if (statusTimer) {
|
||||
clearInterval(statusTimer)
|
||||
statusTimer = undefined
|
||||
}
|
||||
stopStatusPoll?.()
|
||||
stopStatusPoll = undefined
|
||||
}
|
||||
|
||||
function handleContextSelect(label: string, value: string) {
|
||||
@@ -111,13 +108,17 @@
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
<main>
|
||||
{#if activeNav === 'overview'}
|
||||
<Overview {onUnauthorized} />
|
||||
{:else if activeNav === 'rules'}
|
||||
<Rules category={ruleCategory} {onUnauthorized} />
|
||||
{:else}
|
||||
<Traffic {onUnauthorized} />
|
||||
{/if}
|
||||
{#key activeNav}
|
||||
<div in:fade={{ duration: 120 }}>
|
||||
{#if activeNav === 'overview'}
|
||||
<Overview {status} {onUnauthorized} />
|
||||
{:else if activeNav === 'rules'}
|
||||
<Rules category={ruleCategory} {onUnauthorized} />
|
||||
{:else}
|
||||
<Traffic {onUnauthorized} />
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -294,7 +294,7 @@
|
||||
{statusError ? 'Error' : status ? 'Ready' : 'Loading'}
|
||||
</Badge>
|
||||
|
||||
<Button variant="ghost" size="icon-sm" title="退出登录" onclick={onLogout}>
|
||||
<Button variant="ghost" size="icon-sm" title="退出登录" aria-label="退出登录" onclick={onLogout}>
|
||||
<LogOut class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { LoaderCircle } from 'lucide-svelte'
|
||||
|
||||
let { label = '加载中…' }: { label?: string } = $props()
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground" role="status">
|
||||
<LoaderCircle class="size-4 animate-spin" aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
@@ -28,8 +28,14 @@ export function formatCount(n: number): string {
|
||||
return `${n}`
|
||||
}
|
||||
|
||||
// formatTime renders a compact timestamp: HH:MM:SS for today, with the date
|
||||
// prepended for older entries. Admin tables refresh every few seconds, so the
|
||||
// full locale date-time string is mostly noise.
|
||||
export function formatTime(iso: string): string {
|
||||
const t = new Date(iso)
|
||||
if (Number.isNaN(t.getTime())) return '-'
|
||||
return t.toLocaleString()
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const hms = `${pad(t.getHours())}:${pad(t.getMinutes())}:${pad(t.getSeconds())}`
|
||||
if (t.toDateString() === new Date().toDateString()) return hms
|
||||
return `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())} ${hms}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// startPolling runs fn immediately, then every intervalMs while the page is
|
||||
// visible, and once more when the page becomes visible again. Returns a stop
|
||||
// function. Skipping hidden tabs avoids pointless admin API requests.
|
||||
export function startPolling(fn: () => void, intervalMs: number): () => void {
|
||||
void fn()
|
||||
const timer = setInterval(() => {
|
||||
if (document.hidden) return
|
||||
void fn()
|
||||
}, intervalMs)
|
||||
|
||||
const onVisible = () => {
|
||||
if (!document.hidden) void fn()
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
|
||||
return () => {
|
||||
clearInterval(timer)
|
||||
document.removeEventListener('visibilitychange', onVisible)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { api } from '$lib/api'
|
||||
import * as Card from '$lib/components/ui/card'
|
||||
import * as Alert from '$lib/components/ui/alert'
|
||||
import Input from '$lib/components/ui/input/input.svelte'
|
||||
import Label from '$lib/components/ui/label/label.svelte'
|
||||
import Button from '$lib/components/ui/button/button.svelte'
|
||||
import { CircleAlert } from 'lucide-svelte'
|
||||
import { CircleAlert, LoaderCircle, Sprout } from 'lucide-svelte'
|
||||
|
||||
let { onLogin }: { onLogin: () => void } = $props()
|
||||
|
||||
let password = $state('')
|
||||
let error = $state('')
|
||||
let busy = $state(false)
|
||||
let passwordInput: HTMLInputElement | null = $state(null)
|
||||
|
||||
onMount(() => {
|
||||
passwordInput?.focus()
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
if (!password || busy) return
|
||||
@@ -32,6 +38,9 @@
|
||||
<div class="flex min-h-svh items-center justify-center p-4">
|
||||
<Card.Card class="w-full max-w-sm">
|
||||
<Card.CardHeader>
|
||||
<div class="mb-1 flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Sprout class="size-5" aria-hidden="true" />
|
||||
</div>
|
||||
<Card.CardTitle>Sower Admin</Card.CardTitle>
|
||||
<Card.CardDescription>输入 sower 配置中的 admin 密码。</Card.CardDescription>
|
||||
</Card.CardHeader>
|
||||
@@ -42,6 +51,7 @@
|
||||
<Input
|
||||
id="admin-password"
|
||||
type="password"
|
||||
bind:ref={passwordInput}
|
||||
bind:value={password}
|
||||
placeholder="Password"
|
||||
autocomplete="current-password"
|
||||
@@ -54,7 +64,12 @@
|
||||
</Alert.Alert>
|
||||
{/if}
|
||||
<Button type="submit" disabled={busy || !password}>
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
{#if busy}
|
||||
<LoaderCircle class="size-4 animate-spin" aria-hidden="true" />
|
||||
Signing in…
|
||||
{:else}
|
||||
Sign in
|
||||
{/if}
|
||||
</Button>
|
||||
</form>
|
||||
</Card.CardContent>
|
||||
|
||||
@@ -2,22 +2,33 @@
|
||||
import { onMount } from 'svelte'
|
||||
import { api, ApiError, type Status, type TrafficSnapshot } from '$lib/api'
|
||||
import { formatBytes, formatUptime, formatCount } from '$lib/format'
|
||||
import { startPolling } from '$lib/poll'
|
||||
import * as Card from '$lib/components/ui/card'
|
||||
import * as Alert from '$lib/components/ui/alert'
|
||||
import Badge from '$lib/components/ui/badge/badge.svelte'
|
||||
import { CircleAlert } from 'lucide-svelte'
|
||||
import Loading from '$lib/components/Loading.svelte'
|
||||
import {
|
||||
ArrowDownUp,
|
||||
Ban,
|
||||
CircleAlert,
|
||||
Clock3,
|
||||
Globe,
|
||||
Link,
|
||||
Lock,
|
||||
Network,
|
||||
Package,
|
||||
Route,
|
||||
Zap,
|
||||
} from 'lucide-svelte'
|
||||
|
||||
let { onUnauthorized }: { onUnauthorized: () => void } = $props()
|
||||
let { status, onUnauthorized }: { status: Status | null; onUnauthorized: () => void } = $props()
|
||||
|
||||
let status = $state<Status | null>(null)
|
||||
let traffic = $state<TrafficSnapshot | null>(null)
|
||||
let error = $state('')
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const [s, t] = await Promise.all([api.status(), api.traffic()])
|
||||
status = s
|
||||
traffic = t
|
||||
traffic = await api.traffic()
|
||||
error = ''
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
@@ -28,11 +39,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
refresh()
|
||||
const timer = setInterval(refresh, 5000)
|
||||
return () => clearInterval(timer)
|
||||
})
|
||||
onMount(() => startPolling(refresh, 5000))
|
||||
</script>
|
||||
|
||||
{#if error}
|
||||
@@ -41,12 +48,15 @@
|
||||
<Alert.AlertDescription>{error}</Alert.AlertDescription>
|
||||
</Alert.Alert>
|
||||
{:else if !status || !traffic}
|
||||
<p class="py-8 text-center text-sm text-muted-foreground">Loading…</p>
|
||||
<Loading />
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card.Card>
|
||||
<Card.CardHeader>
|
||||
<Card.CardDescription>版本</Card.CardDescription>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Card.CardDescription>版本</Card.CardDescription>
|
||||
<Package class="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<Card.CardTitle class="text-xl">{status.version || '-'}</Card.CardTitle>
|
||||
</Card.CardHeader>
|
||||
<Card.CardContent>
|
||||
@@ -56,8 +66,11 @@
|
||||
|
||||
<Card.Card>
|
||||
<Card.CardHeader>
|
||||
<Card.CardDescription>运行时长</Card.CardDescription>
|
||||
<Card.CardTitle class="text-xl">{formatUptime(traffic.uptime)}</Card.CardTitle>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Card.CardDescription>运行时长</Card.CardDescription>
|
||||
<Clock3 class="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<Card.CardTitle class="text-xl tabular-nums">{formatUptime(traffic.uptime)}</Card.CardTitle>
|
||||
</Card.CardHeader>
|
||||
<Card.CardContent>
|
||||
<p class="text-xs text-muted-foreground">自启动以来</p>
|
||||
@@ -66,8 +79,11 @@
|
||||
|
||||
<Card.Card>
|
||||
<Card.CardHeader>
|
||||
<Card.CardDescription>DNS 查询</Card.CardDescription>
|
||||
<Card.CardTitle class="text-xl">{formatCount(traffic.dnsQueries)}</Card.CardTitle>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Card.CardDescription>DNS 查询</Card.CardDescription>
|
||||
<Globe class="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<Card.CardTitle class="text-xl tabular-nums">{formatCount(traffic.dnsQueries)}</Card.CardTitle>
|
||||
</Card.CardHeader>
|
||||
<Card.CardContent>
|
||||
<p class="text-xs text-muted-foreground">全部上游查询</p>
|
||||
@@ -76,13 +92,16 @@
|
||||
|
||||
<Card.Card>
|
||||
<Card.CardHeader>
|
||||
<Card.CardDescription>代理流量</Card.CardDescription>
|
||||
<Card.CardTitle class="text-xl">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Card.CardDescription>代理流量</Card.CardDescription>
|
||||
<ArrowDownUp class="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<Card.CardTitle class="text-xl tabular-nums">
|
||||
{formatBytes(traffic.bytesUp + traffic.bytesDown)}
|
||||
</Card.CardTitle>
|
||||
</Card.CardHeader>
|
||||
<Card.CardContent>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
<p class="text-xs text-muted-foreground tabular-nums">
|
||||
↑ {formatBytes(traffic.bytesUp)} / ↓ {formatBytes(traffic.bytesDown)}
|
||||
</p>
|
||||
</Card.CardContent>
|
||||
@@ -92,20 +111,29 @@
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-3">
|
||||
<Card.Card>
|
||||
<Card.CardHeader>
|
||||
<Card.CardDescription>HTTP 连接</Card.CardDescription>
|
||||
<Card.CardTitle class="text-xl">{formatCount(traffic.conns.http)}</Card.CardTitle>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Card.CardDescription>HTTP 连接</Card.CardDescription>
|
||||
<Link class="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<Card.CardTitle class="text-xl tabular-nums">{formatCount(traffic.conns.http)}</Card.CardTitle>
|
||||
</Card.CardHeader>
|
||||
</Card.Card>
|
||||
<Card.Card>
|
||||
<Card.CardHeader>
|
||||
<Card.CardDescription>HTTPS 连接</Card.CardDescription>
|
||||
<Card.CardTitle class="text-xl">{formatCount(traffic.conns.https)}</Card.CardTitle>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Card.CardDescription>HTTPS 连接</Card.CardDescription>
|
||||
<Lock class="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<Card.CardTitle class="text-xl tabular-nums">{formatCount(traffic.conns.https)}</Card.CardTitle>
|
||||
</Card.CardHeader>
|
||||
</Card.Card>
|
||||
<Card.Card>
|
||||
<Card.CardHeader>
|
||||
<Card.CardDescription>SOCKS5 连接</Card.CardDescription>
|
||||
<Card.CardTitle class="text-xl">{formatCount(traffic.conns.socks5)}</Card.CardTitle>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Card.CardDescription>SOCKS5 连接</Card.CardDescription>
|
||||
<Network class="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<Card.CardTitle class="text-xl tabular-nums">{formatCount(traffic.conns.socks5)}</Card.CardTitle>
|
||||
</Card.CardHeader>
|
||||
</Card.Card>
|
||||
</div>
|
||||
@@ -113,20 +141,29 @@
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-3">
|
||||
<Card.Card>
|
||||
<Card.CardHeader>
|
||||
<Card.CardDescription>Block 规则</Card.CardDescription>
|
||||
<Card.CardTitle class="text-xl">{formatCount(status.rules.block)}</Card.CardTitle>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Card.CardDescription>Block 规则</Card.CardDescription>
|
||||
<Ban class="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<Card.CardTitle class="text-xl tabular-nums">{formatCount(status.rules.block)}</Card.CardTitle>
|
||||
</Card.CardHeader>
|
||||
</Card.Card>
|
||||
<Card.Card>
|
||||
<Card.CardHeader>
|
||||
<Card.CardDescription>Direct 规则</Card.CardDescription>
|
||||
<Card.CardTitle class="text-xl">{formatCount(status.rules.direct)}</Card.CardTitle>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Card.CardDescription>Direct 规则</Card.CardDescription>
|
||||
<Zap class="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<Card.CardTitle class="text-xl tabular-nums">{formatCount(status.rules.direct)}</Card.CardTitle>
|
||||
</Card.CardHeader>
|
||||
</Card.Card>
|
||||
<Card.Card>
|
||||
<Card.CardHeader>
|
||||
<Card.CardDescription>Proxy 规则</Card.CardDescription>
|
||||
<Card.CardTitle class="text-xl">{formatCount(status.rules.proxy)}</Card.CardTitle>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Card.CardDescription>Proxy 规则</Card.CardDescription>
|
||||
<Route class="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<Card.CardTitle class="text-xl tabular-nums">{formatCount(status.rules.proxy)}</Card.CardTitle>
|
||||
</Card.CardHeader>
|
||||
</Card.Card>
|
||||
</div>
|
||||
|
||||
+57
-24
@@ -5,17 +5,27 @@
|
||||
import * as Alert from '$lib/components/ui/alert'
|
||||
import Badge from '$lib/components/ui/badge/badge.svelte'
|
||||
import Button from '$lib/components/ui/button/button.svelte'
|
||||
import Input from '$lib/components/ui/input/input.svelte'
|
||||
import Textarea from '$lib/components/ui/textarea/textarea.svelte'
|
||||
import { CircleAlert } from 'lucide-svelte'
|
||||
import Loading from '$lib/components/Loading.svelte'
|
||||
import { CircleAlert, ListX, Search } from 'lucide-svelte'
|
||||
|
||||
let { category, onUnauthorized }: { category: Category; onUnauthorized: () => void } = $props()
|
||||
|
||||
let rules = $state<string[] | null>(null)
|
||||
let draft = $state('')
|
||||
let filter = $state('')
|
||||
let error = $state('')
|
||||
let busy = $state(false)
|
||||
let requestID = 0
|
||||
|
||||
const visibleRules = $derived.by(() => {
|
||||
if (!rules) return []
|
||||
const q = filter.trim().toLowerCase()
|
||||
if (!q) return rules
|
||||
return rules.filter((rule) => rule.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
function requestIsCurrent(id: number, requestedCategory: Category) {
|
||||
return id === requestID && requestedCategory === category
|
||||
}
|
||||
@@ -97,6 +107,7 @@
|
||||
$effect(() => {
|
||||
category
|
||||
rules = null
|
||||
filter = ''
|
||||
error = ''
|
||||
busy = false
|
||||
void load()
|
||||
@@ -125,29 +136,51 @@
|
||||
</Card.Card>
|
||||
|
||||
{#if rules === null}
|
||||
<p class="py-8 text-center text-sm text-muted-foreground">Loading…</p>
|
||||
<Loading />
|
||||
{:else if rules.length === 0}
|
||||
<p class="py-8 text-center text-sm text-muted-foreground">No rules in this category.</p>
|
||||
{:else}
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<Badge variant="secondary">{formatCount(rules.length)} rules</Badge>
|
||||
<div class="flex flex-col items-center gap-2 py-10 text-sm text-muted-foreground">
|
||||
<ListX class="size-5" aria-hidden="true" />
|
||||
<p>该分类暂无规则。</p>
|
||||
</div>
|
||||
<Card.Card>
|
||||
<Card.CardContent class="divide-y p-0">
|
||||
{#each rules as rule (rule)}
|
||||
<div class="flex items-center justify-between gap-3 px-4 py-2.5">
|
||||
<code class="min-w-0 break-all font-mono text-sm">{rule}</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onclick={() => removeRule(rule)}
|
||||
disabled={busy}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</Card.CardContent>
|
||||
</Card.Card>
|
||||
{:else}
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="relative min-w-0 flex-1 sm:max-w-xs">
|
||||
<Search class="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
bind:value={filter}
|
||||
placeholder="过滤规则…"
|
||||
aria-label="过滤规则"
|
||||
class="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Badge variant="secondary" class="ml-auto shrink-0">
|
||||
{#if visibleRules.length === rules.length}
|
||||
{formatCount(rules.length)} rules
|
||||
{:else}
|
||||
{formatCount(visibleRules.length)} / {formatCount(rules.length)} rules
|
||||
{/if}
|
||||
</Badge>
|
||||
</div>
|
||||
{#if visibleRules.length === 0}
|
||||
<p class="py-8 text-center text-sm text-muted-foreground">没有匹配“{filter}”的规则。</p>
|
||||
{:else}
|
||||
<Card.Card>
|
||||
<Card.CardContent class="divide-y p-0">
|
||||
{#each visibleRules as rule (rule)}
|
||||
<div class="flex items-center justify-between gap-3 px-4 py-2.5 transition-colors hover:bg-muted/40">
|
||||
<code class="min-w-0 break-all font-mono text-sm">{rule}</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onclick={() => removeRule(rule)}
|
||||
disabled={busy}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</Card.CardContent>
|
||||
</Card.Card>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -2,18 +2,32 @@
|
||||
import { onMount } from 'svelte'
|
||||
import { api, ApiError, type TrafficSnapshot } from '$lib/api'
|
||||
import { formatBytes, formatCount, formatTime } from '$lib/format'
|
||||
import { startPolling } from '$lib/poll'
|
||||
import * as Alert from '$lib/components/ui/alert'
|
||||
import * as Card from '$lib/components/ui/card'
|
||||
import * as Table from '$lib/components/ui/table'
|
||||
import Badge from '$lib/components/ui/badge/badge.svelte'
|
||||
import { CircleAlert } from 'lucide-svelte'
|
||||
import Button from '$lib/components/ui/button/button.svelte'
|
||||
import Input from '$lib/components/ui/input/input.svelte'
|
||||
import Loading from '$lib/components/Loading.svelte'
|
||||
import { CircleAlert, Inbox, Pause, Play, Search } from 'lucide-svelte'
|
||||
|
||||
let { onUnauthorized }: { onUnauthorized: () => void } = $props()
|
||||
|
||||
let traffic = $state<TrafficSnapshot | null>(null)
|
||||
let error = $state('')
|
||||
let paused = $state(false)
|
||||
let filter = $state('')
|
||||
|
||||
const visibleDomains = $derived.by(() => {
|
||||
const domains = traffic?.domains ?? []
|
||||
const q = filter.trim().toLowerCase()
|
||||
if (!q) return domains
|
||||
return domains.filter((d) => d.domain.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
if (paused) return
|
||||
try {
|
||||
traffic = await api.traffic()
|
||||
error = ''
|
||||
@@ -26,11 +40,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
refresh()
|
||||
const timer = setInterval(refresh, 5000)
|
||||
return () => clearInterval(timer)
|
||||
})
|
||||
onMount(() => startPolling(refresh, 5000))
|
||||
|
||||
const headCell = 'sticky top-0 z-10 bg-card'
|
||||
</script>
|
||||
|
||||
{#if error}
|
||||
@@ -39,24 +51,67 @@
|
||||
<Alert.AlertDescription>{error}</Alert.AlertDescription>
|
||||
</Alert.Alert>
|
||||
{:else if !traffic}
|
||||
<p class="py-8 text-center text-sm text-muted-foreground">Loading…</p>
|
||||
<Loading />
|
||||
{:else if (traffic.domains ?? []).length === 0}
|
||||
<p class="py-8 text-center text-sm text-muted-foreground">No traffic recorded yet.</p>
|
||||
<div class="flex flex-col items-center gap-2 py-10 text-sm text-muted-foreground">
|
||||
<Inbox class="size-5" aria-hidden="true" />
|
||||
<p>暂无流量记录。</p>
|
||||
</div>
|
||||
{:else}
|
||||
<Card.Card>
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="relative min-w-0 flex-1 sm:max-w-xs">
|
||||
<Search class="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
bind:value={filter}
|
||||
placeholder="过滤域名…"
|
||||
aria-label="过滤域名"
|
||||
class="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="ml-auto shrink-0 gap-1.5"
|
||||
aria-pressed={paused}
|
||||
onclick={() => (paused = !paused)}
|
||||
>
|
||||
{#if paused}
|
||||
<Play class="size-3.5" />
|
||||
继续
|
||||
{:else}
|
||||
<Pause class="size-3.5" />
|
||||
暂停
|
||||
{/if}
|
||||
</Button>
|
||||
<Badge variant="secondary" class="shrink-0">
|
||||
{#if visibleDomains.length === (traffic.domains ?? []).length}
|
||||
{formatCount(visibleDomains.length)} domains
|
||||
{:else}
|
||||
{formatCount(visibleDomains.length)} / {formatCount((traffic.domains ?? []).length)} domains
|
||||
{/if}
|
||||
</Badge>
|
||||
</div>
|
||||
<Card.Card class="max-h-[70vh] overflow-auto">
|
||||
<Table.Table>
|
||||
<Table.TableHeader>
|
||||
<Table.TableRow>
|
||||
<Table.TableHead>Domain</Table.TableHead>
|
||||
<Table.TableHead class="text-right">Requests</Table.TableHead>
|
||||
<Table.TableHead class="text-right">Up</Table.TableHead>
|
||||
<Table.TableHead class="text-right">Down</Table.TableHead>
|
||||
<Table.TableHead class="text-right">Total</Table.TableHead>
|
||||
<Table.TableHead class="text-right">Last seen</Table.TableHead>
|
||||
<Table.TableHead class={headCell}>Domain</Table.TableHead>
|
||||
<Table.TableHead class={headCell + ' text-right'}>Requests</Table.TableHead>
|
||||
<Table.TableHead class={headCell + ' text-right'}>Up</Table.TableHead>
|
||||
<Table.TableHead class={headCell + ' text-right'}>Down</Table.TableHead>
|
||||
<Table.TableHead class={headCell + ' text-right'}>Total</Table.TableHead>
|
||||
<Table.TableHead class={headCell + ' text-right'}>Last seen</Table.TableHead>
|
||||
</Table.TableRow>
|
||||
</Table.TableHeader>
|
||||
<Table.TableBody>
|
||||
{#each traffic.domains ?? [] as d (d.domain)}
|
||||
{#if visibleDomains.length === 0}
|
||||
<Table.TableRow>
|
||||
<Table.TableCell colspan={6} class="py-8 text-center text-sm text-muted-foreground">
|
||||
没有匹配“{filter}”的域名。
|
||||
</Table.TableCell>
|
||||
</Table.TableRow>
|
||||
{/if}
|
||||
{#each visibleDomains as d (d.domain)}
|
||||
<Table.TableRow>
|
||||
<Table.TableCell class="max-w-[20rem] truncate font-mono text-xs">{d.domain}</Table.TableCell>
|
||||
<Table.TableCell class="text-right tabular-nums">{formatCount(d.conns)}</Table.TableCell>
|
||||
@@ -65,7 +120,7 @@
|
||||
<Table.TableCell class="text-right font-medium tabular-nums">
|
||||
{formatBytes(d.bytesUp + d.bytesDown)}
|
||||
</Table.TableCell>
|
||||
<Table.TableCell class="text-right text-muted-foreground">
|
||||
<Table.TableCell class="text-right text-muted-foreground tabular-nums">
|
||||
{formatTime(d.lastSeen)}
|
||||
</Table.TableCell>
|
||||
</Table.TableRow>
|
||||
@@ -73,8 +128,7 @@
|
||||
</Table.TableBody>
|
||||
</Table.Table>
|
||||
</Card.Card>
|
||||
<div class="mt-2 flex items-center justify-between">
|
||||
<p class="text-xs text-muted-foreground">按流量排序的前 100 个域名,每 5 秒刷新。</p>
|
||||
<Badge variant="outline">{formatCount(traffic.domains.length)} domains</Badge>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-muted-foreground">
|
||||
按流量排序的前 100 个域名{paused ? ',自动刷新已暂停。' : ',每 5 秒刷新。'}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user