feat: add breadcrumbs to management/agent pages and implement regional node grouping
This commit is contained in:
parent
b5109778b5
commit
860abc2870
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
35
src/app/panel/components/Breadcrumbs.tsx
Normal file
35
src/app/panel/components/Breadcrumbs.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
|
||||
export interface Crumb {
|
||||
label: string
|
||||
href: string
|
||||
}
|
||||
|
||||
export default function Breadcrumbs({ items }: { items: Crumb[] }) {
|
||||
return (
|
||||
<nav className="flex mb-4" aria-label="Breadcrumb">
|
||||
<ol className="inline-flex items-center space-x-1 md:space-x-3">
|
||||
{items.map((item, index) => (
|
||||
<li key={item.href} className="inline-flex items-center">
|
||||
{index > 0 && <ChevronRight className="w-4 h-4 text-[var(--color-text-subtle)] mx-1" />}
|
||||
{index === items.length - 1 ? (
|
||||
<span className="text-sm font-medium text-[var(--color-text-subtle)] cursor-default">
|
||||
{item.label}
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
href={item.href}
|
||||
className="inline-flex items-center text-sm font-medium text-[var(--color-primary)] hover:text-[var(--color-primary-hover)] transition-colors"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@ -1,12 +1,161 @@
|
||||
import Card from '../components/Card'
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import { Server, MapPin, Plus, ExternalLink, RefreshCw } from 'lucide-react'
|
||||
|
||||
import Breadcrumbs from '@/app/panel/components/Breadcrumbs'
|
||||
import { useLanguage } from '@i18n/LanguageProvider'
|
||||
import { translations } from '@i18n/translations'
|
||||
|
||||
interface VlessNode {
|
||||
name: string
|
||||
address: string
|
||||
port?: number
|
||||
transport?: string
|
||||
path?: string
|
||||
mode?: string
|
||||
security?: string
|
||||
uri_scheme_xhttp?: string
|
||||
uri_scheme_tcp?: string
|
||||
}
|
||||
|
||||
async function fetcher(url: string) {
|
||||
const res = await fetch(url, { credentials: 'include' })
|
||||
if (!res.ok) throw new Error('Failed to fetch')
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export default function UserCenterAgentRoute() {
|
||||
const { language } = useLanguage()
|
||||
const t = translations[language].userCenter
|
||||
const { data: nodes, error, isLoading, mutate } = useSWR<VlessNode[]>('/api/agent/nodes', fetcher)
|
||||
|
||||
const groupedNodes = useMemo(() => {
|
||||
const groups: Record<string, VlessNode[]> = {
|
||||
HK: [],
|
||||
JP: [],
|
||||
US: [],
|
||||
Other: [],
|
||||
}
|
||||
|
||||
if (!nodes) return groups
|
||||
|
||||
nodes.forEach((node) => {
|
||||
const name = node.name.toLowerCase()
|
||||
if (name.includes('hk')) groups.HK.push(node)
|
||||
else if (name.includes('jp')) groups.JP.push(node)
|
||||
else if (name.includes('us')) groups.US.push(node)
|
||||
else groups.Other.push(node)
|
||||
})
|
||||
|
||||
return groups
|
||||
}, [nodes])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Agent Management</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Manage node agents and rollout updates from a unified workspace.
|
||||
</p>
|
||||
</Card>
|
||||
<div className="space-y-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: t.items.dashboard, href: '/panel' },
|
||||
{ label: t.sections.productivity, href: '/panel/agent' },
|
||||
{ label: t.items.agents, href: '/panel/agent' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[var(--color-heading)]">{t.items.agents}</h1>
|
||||
<p className="text-sm text-[var(--color-text-subtle)]">
|
||||
{language === 'zh' ? '查看并管理您在全球分布的运行节点。' : 'View and manage your globally distributed running nodes.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => mutate()}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-[color:var(--color-surface-border)] bg-[var(--color-surface)] px-4 py-2 text-sm font-medium text-[var(--color-text)] transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
{language === 'zh' ? '刷新' : 'Refresh'}
|
||||
</button>
|
||||
<button className="inline-flex items-center gap-2 rounded-xl bg-[var(--color-primary)] px-4 py-2 text-sm font-medium text-[var(--color-primary-foreground)] shadow-sm transition-opacity hover:opacity-90">
|
||||
<Plus className="h-4 w-4" />
|
||||
{language === 'zh' ? '添加节点' : 'Add Node'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6">
|
||||
{Object.entries(groupedNodes).map(([region, regionNodes]) => (
|
||||
regionNodes.length > 0 && (
|
||||
<section key={region} className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="h-5 w-5 text-[var(--color-primary)]" />
|
||||
<h2 className="text-lg font-semibold text-[var(--color-heading)]">
|
||||
{region === 'Other' ? (language === 'zh' ? '其他地区' : 'Other Regions') : `${region} Region`}
|
||||
</h2>
|
||||
<span className="rounded-full bg-[var(--color-surface-muted)] px-2.5 py-0.5 text-xs font-medium text-[var(--color-text-subtle)]">
|
||||
{regionNodes.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{regionNodes.map((node) => (
|
||||
<div
|
||||
key={node.address}
|
||||
className="group relative rounded-2xl border border-[color:var(--color-surface-border)] bg-[var(--color-surface)] p-5 transition-all hover:border-[color:var(--color-primary-border)] hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--color-primary-muted)] text-[var(--color-primary)]">
|
||||
<Server className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="flex items-center gap-1.5 rounded-full bg-green-500/10 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-green-600 dark:bg-green-500/20 dark:text-green-400">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||
Online
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<h3 className="font-bold text-[var(--color-heading)] truncate">{node.name}</h3>
|
||||
<p className="mt-1 text-xs text-[var(--color-text-subtle)] truncate">{node.address}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-between border-t border-[color:var(--color-surface-border)] pt-4">
|
||||
<div className="text-[10px] text-[var(--color-text-subtle)]">
|
||||
Port: <span className="font-medium text-[var(--color-text)]">{node.port || 443}</span>
|
||||
</div>
|
||||
<button className="text-[var(--color-primary)] transition-transform hover:scale-110">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
))}
|
||||
|
||||
{!isLoading && nodes?.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center rounded-3xl border border-dashed border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)]/20 py-20 text-center">
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-[var(--color-surface-muted)] text-[var(--color-text-subtle)]">
|
||||
<Server className="h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-[var(--color-heading)]">
|
||||
{language === 'zh' ? '暂无运行节点' : 'No Running Nodes'}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-[var(--color-text-subtle)]">
|
||||
{language === 'zh' ? '您可以点击上方按钮添加您的第一个节点。' : 'You can click the button above to add your first node.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="animate-pulse rounded-2xl border border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)]/30 p-5 h-44" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -14,8 +14,11 @@ import UserGroupManagement, {
|
||||
type CreateManagedUserInput,
|
||||
} from '../management/components/UserGroupManagement'
|
||||
import { EmailBlacklist } from '../management/components/EmailBlacklist'
|
||||
import Breadcrumbs from '@/app/panel/components/Breadcrumbs'
|
||||
import { resolveAccess } from '@lib/accessControl'
|
||||
import { useUserStore } from '@lib/userStore'
|
||||
import { useLanguage } from '@i18n/LanguageProvider'
|
||||
import { translations } from '@i18n/translations'
|
||||
|
||||
type UserMetricsResponse = {
|
||||
overview: MetricsOverview
|
||||
@ -61,6 +64,8 @@ async function jsonFetcher<T>(input: RequestInfo, init?: RequestInit): Promise<T
|
||||
}
|
||||
|
||||
export default function UserCenterManagementRoute() {
|
||||
const { language } = useLanguage()
|
||||
const t = translations[language].userCenter
|
||||
const user = useUserStore((state) => state.user)
|
||||
const isUserLoading = useUserStore((state) => state.isLoading)
|
||||
const accessDecision = useMemo(() => resolveAccess(user, { requireLogin: true, roles: ['admin', 'operator'] }), [user])
|
||||
@ -325,6 +330,12 @@ export default function UserCenterManagementRoute() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: t.items.dashboard, href: '/panel' },
|
||||
{ label: translations[language].nav.account.management, href: '/panel/management' },
|
||||
]}
|
||||
/>
|
||||
<OverviewCards overview={metricsSWR.data?.overview} isLoading={metricsLoading} lastUpdatedLabel={lastUpdatedLabel} />
|
||||
<TrendChart series={metricsSWR.data?.series} isLoading={metricsLoading} />
|
||||
<PermissionMatrixEditor
|
||||
|
||||
Loading…
Reference in New Issue
Block a user