feat(admin): polish console interactions and visual states

This commit is contained in:
wweir
2026-08-06 14:30:07 +08:00
parent 1981c61f0c
commit ef8b3fc72e
14 changed files with 1650 additions and 675 deletions
File diff suppressed because one or more lines are too long
+1374
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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
View File
@@ -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>
+10
View File
@@ -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>
+7 -1
View File
@@ -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}`
}
+20
View File
@@ -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)
}
}
+17 -2
View File
@@ -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>
+69 -32
View File
@@ -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
View File
@@ -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}
+75 -21
View File
@@ -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}