23 Commits

Author SHA1 Message Date
wmp
82f69e2964 发布v0.4.0版本 2025-10-22 09:35:37 +08:00
6a0ae418ce 完善 redis 配置环境变量 2025-10-21 19:12:17 +08:00
9df17211a5 细化导航栏样式 2025-10-21 13:44:29 +08:00
80310f710c 优化开发时环境配置 2025-10-21 13:42:39 +08:00
5617502713 重构网关配置总览 & 完善数据表格组件 2025-10-21 11:25:59 +08:00
wmp
15d5dc93c0 侧边栏添加图标样式 2025-10-21 11:21:15 +08:00
wmp
9fff300a22 网关配置的表格添加内网IP的inner_ip字段的筛选条件 2025-10-20 17:15:44 +08:00
8c05d0b332 基本样式优化 & 数据表组件使用 tanstack-table 库实现 2025-10-20 16:22:01 +08:00
wmp
3c38945750 清除冗余代码 2025-10-16 18:11:29 +08:00
wmp
8e0c9284a0 页面更新替换data-table组件 2025-10-16 14:18:52 +08:00
wmp
5d3f1daadf 封装数据驱动的table组件修改网关table样式 2025-10-15 18:31:25 +08:00
wmp
b590d5b8c1 调整页面样式布局 2025-10-15 11:43:47 +08:00
d798eab0a9 调整整体页面布局 2025-10-14 17:55:32 +08:00
6bbaf9f904 完善配置查询接口 2025-10-14 17:11:02 +08:00
wmp
010990ea3c 更新侧边栏改造使用路由结构,更新中间件重定向 2025-10-14 16:54:55 +08:00
wmp
10395a49c1 更新二维矩阵里的首列字段展示 2025-10-14 14:32:25 +08:00
6e9e7af780 完善查询函数 2025-10-14 12:34:12 +08:00
wmp
4940e3a9d2 修改节点信息分页接口传参 2025-10-13 15:48:01 +08:00
wmp
e95f9d33f3 更新网关配置的二维表格展示&修改节点信息的分页,取消网关信息的mac地址跳转功能 2025-10-13 14:58:23 +08:00
wmp
e5b6099d03 更新网关信息配置布局表格变更为二维矩阵 2025-09-30 17:36:15 +08:00
wmp
969d49ab50 新增表单查询和复制功能 2025-09-29 12:34:47 +08:00
e36cfbca83 完善解决编译时初始化连接问题 2025-09-29 12:06:16 +08:00
8239c9fb37 修复 drizzle 编译问题 & 简化页面结构与排序功能 2025-09-29 10:35:16 +08:00
35 changed files with 1876 additions and 1402 deletions

View File

@@ -1,8 +1,15 @@
# 数据库连接字符串
DATABASE_URL=
DATABASE_HOST=localhost
DATABASE_PORT=23306
DATABASE_USERNAME=root
DATABASE_PASSWORD=root
DATABASE_NAME=app
# Redis 连接字符串
REDIS_URL=
REDIS_HOST=localhost
REDIS_PORT=26379
REDIS_USERNAME=
REDIS_PASSWORD=
# 京东网关配置
JD_BASE=https://smart.jdbox.xyz:58001

44
.gitignore vendored
View File

@@ -1,7 +1,39 @@
node_modules
src/generated/
.next
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env
deploy.sh
.volumes
.vscode
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# dev
.volumes/

26
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,26 @@
{
"sqltools.connections": [
{
"mysqlOptions": {
"authProtocol": "default",
"enableSsl": "Disabled"
},
"ssh": "Disabled",
"previewLimit": 50,
"server": "localhost",
"port": 23306,
"driver": "MariaDB",
"name": "localhost",
"database": "app",
"username": "root"
}
],
"[typescript]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
},
"[typescriptreact]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
}
}

View File

@@ -10,6 +10,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@tanstack/react-table": "^8.21.3",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -363,6 +364,10 @@
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.12", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.12", "@tailwindcss/oxide": "4.1.12", "postcss": "^8.4.41", "tailwindcss": "4.1.12" } }, "sha512-5PpLYhCAwf9SJEeIsSmCDLgyVfdBhdBpzX1OJ87anT9IVR0Z9pjM0FNixCAUAHGnMBGB8K99SwAheXrT0Kh6QQ=="],
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "https://registry.npmmirror.com/@tanstack/react-table/-/react-table-8.21.3.tgz", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "https://registry.npmmirror.com/@tanstack/table-core/-/table-core-8.21.3.tgz", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],

View File

@@ -1,3 +1,5 @@
name: jihu-monitor
services:
mariadb:
@@ -6,13 +8,13 @@ services:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: app
ports:
- "23306:3306"
- "${DATABASE_PORT}:3306"
volumes:
- .volumes/mysql:/var/lib/mysql
redis:
image: redis:7
ports:
- "26379:6379"
- "${REDIS_PORT}:6379"
volumes:
- .volumes/redis:/data

View File

View File

@@ -1,6 +1,6 @@
{
"name": "my-app",
"version": "0.3.0",
"version": "0.4.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -15,6 +15,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@tanstack/react-table": "^8.21.3",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View File

@@ -1,7 +1,8 @@
'use server'
import { Page, Res } from '@/lib/api'
import drizzle, { change, cityhash, count, desc, edge, eq, gateway, is, sql, token } from '@/lib/drizzle'
import drizzle, { and, change, cityhash, count, desc, edge, eq, gateway, is, sql, token } from '@/lib/drizzle'
import { cache } from 'react'
export type AllocationStatus = {
city: string
@@ -62,7 +63,7 @@ export async function getAllocationStatus(hours: number = 24) {
export type GatewayInfo = {
macaddr: string
inner_ip: string
setid: string
setid: number
enable: number
}
@@ -77,7 +78,7 @@ export async function getGatewayInfo() {
enable: token.enable,
})
.from(token)
.orderBy(token.macaddr)
.orderBy(sql`cast(regexp_replace(token.inner_ip, '192.168.50.', '') as unsigned)`)
return {
success: true,
@@ -105,15 +106,37 @@ export type GatewayConfig = {
}
// 网关配置
export async function getGatewayConfig(page?: number, mac?: string): Promise<Res<Page<GatewayConfig>>> {
export const getGatewayConfig = cache(async (page?: number, filters?: {
mac?: string
public?: string
city?: string
user?: string
inner_ip?: string
}): Promise<Res<Page<GatewayConfig>>> => {
try {
if (!page && !mac) {
if (!page && !filters?.mac) {
throw new Error('页码和MAC地址不能同时为空')
}
page = mac ? 1 : Math.max(1, page || 1)
page = filters?.mac ? 1 : Math.max(1, page || 1)
const condition = filters ? and(
filters.mac ? eq(gateway.macaddr, filters.mac) : undefined,
filters.public ? eq(edge.public, filters.public) : undefined,
filters.city ? eq(cityhash.city, filters.city) : undefined,
filters.user ? eq(gateway.user, filters.user) : undefined,
filters.inner_ip ? eq(gateway.network, filters.inner_ip) : undefined,
) : undefined
const [total, result] = await Promise.all([
drizzle.$count(gateway, mac ? eq(gateway.macaddr, mac) : undefined),
drizzle
.select({
value: count(),
})
.from(gateway)
.leftJoin(cityhash, eq(cityhash.hash, gateway.cityhash))
.leftJoin(edge, eq(edge.macaddr, gateway.edge))
.where(condition),
drizzle
.select({
city: cityhash.city,
@@ -127,16 +150,15 @@ export async function getGatewayConfig(page?: number, mac?: string): Promise<Res
.from(gateway)
.leftJoin(cityhash, eq(cityhash.hash, gateway.cityhash))
.leftJoin(edge, eq(edge.macaddr, gateway.edge))
.where(mac ? eq(gateway.macaddr, mac) : undefined)
.orderBy(gateway.macaddr, sql`cast(regexp_replace(gateway.network, '172.30.168.', '') as unsigned)`)
.where(condition)
.orderBy(sql`inet_aton(gateway.inner_ip)`)
.offset((page - 1) * 250)
.limit(250),
])
return {
success: true,
data: {
total,
total: total[0].value,
page,
size: 250,
items: result,
@@ -150,14 +172,14 @@ export async function getGatewayConfig(page?: number, mac?: string): Promise<Res
error: '查询网关配置失败',
}
}
}
})
export type CityNode = {
city: string
count: number
hash: string
label: string
offset: string
label: string | null
offset: number
}
// 城市节点数量分布
@@ -201,14 +223,62 @@ export async function getCityNodeCount() {
}
}
// 获取节点信息
export async function getEdgeNodes(page: number, size: number) {
try {
const offset = Math.max(0, (page - 1)) * size
const limit = Math.min(100, Math.max(10, size))
export type Edge = {
id: number
macaddr: string
city: string | null
public: string
isp: string
single: number | boolean
sole: number | boolean
arch: number
online: number
}
const [total, data] = await Promise.all([
drizzle.$count(edge, eq(edge.active, 1)),
// 获取节点信息
export async function getEdgeNodes(page: number, size: number, filters?: {
macaddr?: string
public?: string
city?: string
isp?: string
}): Promise<Res<Page<Edge>>> {
try {
page = Math.max(1, page)
size = Math.min(100, Math.max(10, size))
const condition = and(
eq(edge.active, 1),
filters?.macaddr ? eq(edge.macaddr, filters.macaddr) : undefined,
filters?.public ? eq(edge.public, filters.public) : undefined,
filters?.city ? eq(cityhash.city, filters.city) : undefined,
filters?.isp ? eq(edge.isp, filters.isp) : undefined,
)
console.log(drizzle
.select({
id: edge.id,
macaddr: edge.macaddr,
city: cityhash.city,
public: edge.public,
isp: edge.isp,
single: edge.single,
sole: edge.sole,
arch: edge.arch,
online: edge.online,
})
.from(edge)
.leftJoin(cityhash, eq(cityhash.id, edge.cityId))
.where(condition)
.orderBy(edge.id)
.offset(page * size - size)
.limit(size).toSQL().sql)
const [total, items] = await Promise.all([
drizzle
.select({ value: count() })
.from(edge)
.leftJoin(cityhash, eq(cityhash.id, edge.cityId))
.where(condition),
drizzle
.select({
id: edge.id,
@@ -223,25 +293,26 @@ export async function getEdgeNodes(page: number, size: number) {
})
.from(edge)
.leftJoin(cityhash, eq(cityhash.id, edge.cityId))
.where(eq(edge.active, 1))
.where(condition)
.orderBy(edge.id)
.offset(offset)
.limit(limit),
.offset(page * size - size)
.limit(size),
])
return {
data,
totalCount: total,
currentPage: Math.floor(offset / limit) + 1,
totalPages: Math.ceil(total / limit),
success: true,
data: {
total: total[0].value,
page,
size,
items,
},
}
}
catch (error) {
console.error('Edge nodes query error:', error)
return {
success: false,
data: [],
error: '查询边缘节点失败',
}
}

View File

@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Lock, User } from 'lucide-react'
import { LockIcon, UserIcon } from 'lucide-react'
import { useAuthStore } from '@/store/auth'
import { toast, Toaster } from 'sonner'
import { login } from '@/actions/auth'
@@ -41,7 +41,7 @@ export default function LoginPage() {
})
setAuth(true)
await new Promise(resolve => setTimeout(resolve, 1000))
router.push('/dashboard')
router.push('/gatewayinfo')
router.refresh()
}
else {
@@ -75,7 +75,7 @@ export default function LoginPage() {
<FormLabel></FormLabel>
<FormControl>
<div className="relative">
<User className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<UserIcon className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="请输入您的账号"
className="pl-8"
@@ -95,7 +95,7 @@ export default function LoginPage() {
<FormLabel></FormLabel>
<FormControl>
<div className="relative">
<Lock className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<LockIcon className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input type="password" placeholder="请输入密码" className="pl-8" {...field} />
</div>
</FormControl>

View File

@@ -0,0 +1,147 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import LoadingCard from '@/components/ui/loadingCard'
import ErrorCard from '@/components/ui/errorCard'
import { getAllocationStatus, type AllocationStatus } from '@/actions/stats'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Form, FormField } from '@/components/ui/form'
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { Button } from '@/components/ui/button'
import { Page } from '@/components/page'
import { DataTable } from '@/components/data-table'
const filterSchema = z.object({
timeFilter: z.string(),
})
type FilterSchema = z.infer<typeof filterSchema>
export default function AllocationStatus({ detailed = false }: { detailed?: boolean }) {
const [data, setData] = useState<AllocationStatus[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const form = useForm<FilterSchema>({
resolver: zodResolver(filterSchema),
defaultValues: {
timeFilter: '24',
},
})
const timeFilter = form.watch('timeFilter')
const getTimeHours = useCallback(() => {
return parseInt(timeFilter) || 24
}, [timeFilter])
const newData = data.map(item => ({
...item,
overage: Math.max(0, Number(item.assigned) - Number(item.count)),
}))
const fetchData = useCallback(async () => {
try {
setError(null)
setLoading(true)
const hours = getTimeHours()
const result = await getAllocationStatus(hours)
const validatedData = result.data.map(item => ({
city: item.city || '未知',
count: item.count,
assigned: item.assigned,
}))
setData(validatedData)
}
catch (error) {
console.error('Failed to fetch allocation status:', error)
setError(error instanceof Error ? error.message : 'Unknown error')
}
finally {
setLoading(false)
}
}, [getTimeHours])
useEffect(() => {
fetchData()
}, [fetchData])
const onSubmit = (data: FilterSchema) => {
fetchData()
}
if (loading) return <LoadingCard title="节点分配状态" />
if (error) return <ErrorCard title="节点分配状态" error={error} onRetry={fetchData} />
return (
<Page>
<h2 className="flex-none text-lg font-semibold mb-4"></h2>
<div className="mb-4 flex flex-wrap items-center gap-3">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex items-center gap-4">
<FormField
name="timeFilter"
render={({ field }) => (
<div className="flex items-center">
<span className="text-sm mr-2">:</span>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="h-9 w-36">
<SelectValue placeholder="选择时间范围" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="4">4</SelectItem>
<SelectItem value="12">12</SelectItem>
<SelectItem value="24">24</SelectItem>
<SelectItem value="168">7</SelectItem>
</SelectContent>
</Select>
</div>
)}
/>
<Button type="submit" className="py-2 bg-blue-600 hover:bg-blue-700">
</Button>
</form>
</Form>
</div>
<DataTable
data={newData}
columns={[
{
label: '城市',
props: 'city',
},
{
label: '可用IP量',
props: 'count',
sortable: true,
},
{
label: '分配IP量',
props: 'assigned',
sortable: true,
},
{
label: '超额量',
props: 'overage',
sortable: true,
render: (val) => {
const overage = val.overage as number
return (
<span className={overage > 0 ? 'text-red-600 font-medium' : ''}>
{overage}
</span>
)
},
},
]}
/>
</Page>
)
}

View File

@@ -0,0 +1,77 @@
'use client'
import { useEffect, useState } from 'react'
import { getCityNodeCount, type CityNode } from '@/actions/stats'
import { Page } from '@/components/page'
import { DataTable } from '@/components/data-table'
export default function CityNodeStats() {
const [data, setData] = useState<CityNode[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchData()
}, [])
const fetchData = async () => {
try {
const result = await getCityNodeCount()
if (!result.success) {
throw new Error(result.error || '查询城市节点失败')
}
setData(result.data)
}
catch (error) {
console.error('获取城市节点数据失败:', error)
}
finally {
setLoading(false)
}
}
if (loading) {
return (
<div className="bg-white p-6 w-full overflow-hidden">
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="text-gray-600">...</div>
</div>
)
}
return (
<Page>
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold"></h2>
<span className="text-sm text-gray-500">
{data.length}
</span>
</div>
<DataTable
data={data}
columns={[
{
label: '城市',
props: 'city',
},
{
label: '节点数量',
props: 'count',
},
{
label: 'Hash',
props: 'hash',
},
{
label: '标签',
props: 'label',
},
{
label: '轮换顺位',
props: 'offset',
},
]}
/>
</Page>
)
}

View File

@@ -0,0 +1,263 @@
'use client'
import { useEffect, useState } from 'react'
import { Pagination } from '@/components/ui/pagination'
import { getEdgeNodes, type Edge } from '@/actions/stats'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Form, FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { Page } from '@/components/page'
import { DataTable } from '@/components/data-table'
// 定义表单验证规则
const filterSchema = z.object({
macaddr: z.string(),
public: z.string(),
city: z.string(),
isp: z.string(),
})
type FilterFormValues = z.infer<typeof filterSchema>
export default function Edge() {
const [data, setData] = useState<Edge[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// 分页状态
const [page, setPage] = useState(1)
const [size, setSize] = useState(100)
const [total, setTotal] = useState(0)
// 初始化表单
const form = useForm<FilterFormValues>({
resolver: zodResolver(filterSchema),
defaultValues: {
macaddr: '',
public: '',
city: '',
isp: '',
},
})
const fetchData = async (page: number, size: number) => {
const filters = form.getValues()
setLoading(true)
try {
const result = await getEdgeNodes(page, size, filters)
if (!result.success) {
throw new Error(result.error)
}
const data = result.data
console.log(data)
setData(data.items)
setTotal(data.total)
setPage(data.page)
setSize(data.size)
setError(null)
}
catch (error) {
setError('获取边缘节点数据失败' + (error instanceof Error ? `: ${error.message}` : ''))
}
finally {
setLoading(false)
}
}
const onSubmit = () => {
fetchData(page, size)
}
// 处理页码变化
const handlePageChange = (page: number) => {
setPage(page)
fetchData(page, size)
}
// 处理每页显示数量变化
const handleSizeChange = (size: number) => {
setPage(1)
setSize(size)
fetchData(1, size)
}
useEffect(() => {
fetchData(page, size)
}, [])
if (loading) return (
<div className="bg-white w-full shadow p-6">
<h2 className="text-xl font-semibold text-gray-800 mb-4"></h2>
<div className="text-center py-8">...</div>
</div>
)
if (error) return (
<div className="bg-white w-full shadow p-6">
<h2 className="text-xl font-semibold text-gray-800 mb-4"></h2>
<div className="text-center py-8 text-red-600">{error}</div>
<button
onClick={() => fetchData(page, size)}
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors mx-auto block"
>
</button>
</div>
)
return (
<Page className="gap-3">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex gap-4">
<FormField
control={form.control}
name="macaddr"
render={({ field }) => (
<FormItem>
<FormLabel>MAC地址</FormLabel>
<FormControl>
<Input placeholder="输入MAC地址" {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
name="public"
render={({ field }) => (
<FormItem>
<FormLabel>IP</FormLabel>
<FormControl>
<Input placeholder="输入公网IP" {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
name="city"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="输入城市名称" {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
name="isp"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="输入运营商" {...field} />
</FormControl>
</FormItem>
)}
/>
<Button type="submit" className="mt-5 py-2 bg-blue-600 hover:bg-blue-700">
</Button>
<Button type="button" variant="outline" className="mt-5 py-2" onClick={() => form.reset()}>
</Button>
</form>
</Form>
<DataTable
data={data}
columns={[
{
label: 'MAC地址',
props: 'macaddr',
},
{
label: '城市',
props: 'city',
},
{
label: '公网IP',
props: 'public',
},
{
label: '运营商',
render: (val) => {
const isp = val.isp as string
return (
<span className={cn('px-2 py-1 rounded-full text-xs', 'bg-gray-100 text-gray-800',
{ : 'bg-blue-100 text-blue-800',
: 'bg-purple-100 text-purple-800',
: 'bg-red-100 text-red-800',
}[isp])}>
{isp}
</span>
)
},
},
{
label: '多IP节点',
render: (val) => {
const single = val.single as number
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
single === 1 ? 'bg-red-100 text-red-800' : single === 0 ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}>
{single === 1 ? '是' : single === 0 ? '否' : '未知'}
</span>
)
},
},
{
label: '独享IP',
render: (val) => {
const sole = val.sole as number
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
sole === 1 ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}>
{sole === 1 ? '是' : '否'}
</span>
)
},
},
{
label: '设备类型',
render: (val) => {
const arch = val.arch as number
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
arch === 0 ? 'bg-blue-100 text-blue-800'
: arch === 1 ? 'bg-green-100 text-green-800'
: arch === 2 ? 'bg-purple-100 text-purple-800'
: arch === 3 ? 'bg-orange-100 text-orange-800'
: 'bg-gray-100 text-gray-800'}`}>
{arch === 0 ? '一代' : arch === 1 ? '二代' : arch === 2 ? 'AMD64' : arch === 3 ? 'x86' : `未知 (${arch})`}
</span>
)
},
},
{
label: '在线时长',
render: (val) => {
const seconds = val.online as number
return seconds < 60 ? `${seconds}`
: seconds < 3600 ? `${Math.floor(seconds / 60)}分钟`
: seconds < 86400 ? `${Math.floor(seconds / 3600)}小时`
: `${Math.floor(seconds / 86400)}`
},
},
]}
/>
{/* 分页 */}
<Pagination
page={page}
size={size}
total={total}
onPageChange={handlePageChange}
onSizeChange={handleSizeChange}
/>
</Page>
)
}

View File

@@ -0,0 +1,361 @@
'use client'
import { useEffect, useState, Suspense, useCallback } from 'react'
import { getGatewayInfo, getGatewayConfig, type GatewayConfig, type GatewayInfo } from '@/actions/stats'
import { Pagination } from '@/components/ui/pagination'
import { toast } from 'sonner'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Form, FormField, FormItem, FormLabel } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { SearchIcon } from 'lucide-react'
import { Page } from '@/components/page'
import { DataTable } from '@/components/data-table'
function GatewayConfigContent() {
const [data, setData] = useState<GatewayConfig[]>([])
const [loading, setLoading] = useState(false)
const [infoData, setInfoData] = useState<GatewayInfo[]>([])
// 分页状态
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
// 定义表单验证规则
const filterSchema = z.object({
macaddr: z.string().optional(),
public: z.string().optional(),
city: z.string().optional(),
inner_ip: z.string().optional(),
user: z.string().optional(),
})
type FilterFormValues = z.infer<typeof filterSchema>
// 初始化表单
const form = useForm<FilterFormValues>({
resolver: zodResolver(filterSchema),
defaultValues: {
macaddr: '',
public: '',
city: '',
inner_ip: '',
user: '',
},
})
// 初始化调用
useEffect(() => {
const initData = async () => {
setLoading(true)
try {
// 获取网关基本信息
const infoResult = await getGatewayInfo()
if (!infoResult.success) {
throw new Error(infoResult.error || '查询网关信息失败')
}
setInfoData(infoResult.data)
}
catch (error) {
toast.error((error as Error).message || '获取数据失败')
}
finally {
setLoading(false)
}
}
initData()
fetchData({}, 1)
}, [])
const { handleSubmit: formHandleSubmit } = form
// 网关配置数据查询函数(用于表单查询)
const fetchData = async (filters: {
mac?: string
public?: string
city?: string
user?: string
inner_ip?: string
}, page: number = 1) => {
setLoading(true)
try {
const result = await getGatewayConfig(page, filters)
if (!result.success) {
throw new Error(result.error || '查询网关配置失败')
}
const shrink = ['黔东南', '延边']
result.data.items.forEach((item) => {
shrink.forEach((s) => {
if (item.city?.startsWith(s)) {
item.city = s
}
})
})
setData(result.data.items)
setTotal(result.data.total)
}
catch (error) {
toast.error((error as Error).message || '获取网关配置失败')
}
finally {
setLoading(false)
}
}
const onSubmit = (data: FilterFormValues) => {
setPage(1)
const filters = {
mac: data.macaddr || '',
public: data.public || '',
city: data.city || '',
user: data.user || '',
inner_ip: data.inner_ip || '',
}
fetchData(filters, 1)
}
// 处理页码变化
const handlePageChange = (page: number) => {
setPage(page)
const formValues = form.getValues()
const filters = {
mac: formValues.macaddr || undefined,
public: formValues.public || undefined,
city: formValues.city || undefined,
user: formValues.user || undefined,
inner_ip: formValues.inner_ip || undefined,
}
fetchData(filters, page)
}
// 处理每页显示数量变化
const handleSizeChange = (size: number) => {
setPage(1)
const formValues = form.getValues()
const filters = {
mac: formValues.macaddr || undefined,
public: formValues.public || undefined,
city: formValues.city || undefined,
user: formValues.user || undefined,
inner_ip: formValues.inner_ip || undefined,
}
fetchData(filters, 1)
}
// 当前选中的mac
const [selectedMac, setSelectedMac] = useState<string>('')
const handleMacClick = useCallback(async (macaddr: string) => {
setSelectedMac(macaddr)
await fetchData({ mac: macaddr }, 1)
}, [])
return (
<Page className="flex flex-row gap-6">
{/* 查询表单 */}
<div className="flex-1 flex flex-col gap-2">
<Form {...form}>
<form onSubmit={formHandleSubmit(onSubmit)}>
<FormField
name="macaddr"
render={({ field }) => (
<FormItem className="flex-1 relative">
<div className="relative">
<Input
placeholder="搜索MAC地址..."
{...field}
className="w-full pr-10"
/>
<SearchIcon
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 cursor-pointer"
onClick={(e) => {
e.preventDefault()
onSubmit(form.getValues())
}}
/>
</div>
</FormItem>
)}
/>
</form>
</Form>
<div className="flex gap-4">
<div className="flex items-center">
<div className="w-3 h-3 bg-green-500 rounded-full mr-2"></div>
<span className="text-sm font-medium">线 {infoData.filter(item => item.enable === 1).length}</span>
</div>
<div className="flex items-center">
<div className="w-3 h-3 bg-red-500 rounded-full mr-2"></div>
<span className="text-sm font-medium">线 {infoData.filter(item => item.enable === 0).length}</span>
</div>
</div>
<div className="overflow-auto border-t flex flex-col">
{infoData.map((item, index) => (
<div
key={index}
className={cn('p-4 flex-col ', index !== infoData.length - 1 ? 'border-b' : '')}>
<div className="flex flex-col">
<div className="flex items-center justify-between mb-2">
<div
className={cn('font-medium cursor-pointer',
selectedMac === item.macaddr ? 'text-blue-700' : 'text-gray-900')}
onClick={() => handleMacClick(item.macaddr)}
>
{item.macaddr}
</div>
<div className="flex items-center">
<div
className={cn(
'w-2 h-2 rounded-full mr-2',
item.enable === 1 ? 'bg-green-500' : 'bg-red-500',
)}
/>
<span className={cn(
'text-xs font-medium',
item.enable === 1 ? 'text-green-700' : 'text-red-700',
)}>
{item.enable === 1 ? '在线' : '离线'}
</span>
</div>
</div>
</div>
<div className="text-sm text-gray-600 mb-3">
{item.inner_ip || '未配置IP'}
</div>
<div className="flex gap-2 space-y-1 text-xs text-gray-500">
<div>: {item.setid || 'N/A'}</div>
</div>
</div>
))}
</div>
</div>
<div className="flex-3 overflow-hidden flex flex-col gap-3">
<Form {...form}>
<form className="flex gap-4" onSubmit={formHandleSubmit(onSubmit)}>
<FormField
name="public"
render={({ field }) => (
<FormItem>
<FormLabel>IP地址</FormLabel>
<Input placeholder="输入IP地址" {...field} />
</FormItem>
)}
/>
<FormField
name="user"
render={({ field }) => (
<FormItem>
<FormLabel>线</FormLabel>
<Input placeholder="输入线路" {...field} />
</FormItem>
)}
/>
<FormField
name="inner_ip"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<Input placeholder="输入端口" {...field} />
</FormItem>
)}
/>
<FormField
name="city"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<Input placeholder="输入城市" {...field} />
</FormItem>
)}
/>
<Button type="submit" className="ml-2 mt-6 px-4 py-2 bg-blue-600 hover:bg-blue-700">
</Button>
<Button type="button" variant="outline" className="ml-2 mt-6 px-4 py-2" onClick={() => form.reset()}>
</Button>
</form>
</Form>
<DataTable
data={data}
columns={[
{
label: '端口',
props: 'inner_ip',
},
{
label: '线路',
props: 'user',
},
{
label: '城市',
props: 'city',
},
{
label: '节点MAC',
props: 'edge',
},
{
label: '节点IP',
props: 'public',
},
{
label: '配置更新',
render: (val) => {
const ischange = val.ischange as number
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
ischange === 0 ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{ischange === 0 ? '正常' : '更新'}
</span>
)
},
},
{
label: '在用状态',
render: (val) => {
const isonline = val.isonline as number
return (
<div className="flex items-center gap-1.5">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
isonline === 0 ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{isonline === 0 ? '空闲' : '在用'}
</span>
</div>
)
},
},
]}
/>
{/* 分页组件 */}
<Pagination
total={total}
page={page}
size={250}
sizeOptions={[250]}
onPageChange={handlePageChange}
onSizeChange={handleSizeChange}
/>
</div>
</Page>
)
}
export default function GatewayConfig() {
return (
<Suspense fallback={(
<div className="bg-white shadow p-6">
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600">...</p>
</div>
</div>
)}>
<GatewayConfigContent />
</Suspense>
)
}

View File

@@ -0,0 +1,159 @@
'use client'
import { useEffect, useState, Suspense } from 'react'
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table'
import { getGatewayInfo, getGatewayConfig, type GatewayConfig, type GatewayInfo } from '@/actions/stats'
import { toast } from 'sonner'
import { Page } from '@/components/page'
export default function GatewayConfigs() {
const [gateways, setGateways] = useState<Map<string, GatewayInfo>>(new Map())
const [data, setData] = useState<Map<string, Map<string, GatewayConfig | undefined>>>(new Map())
// 初始化数据
const initData = async () => {
const now = Date.now()
try {
// 固定端口信息
const slots = new Set<string>()
for (let i = 2; i <= 251; i++) {
slots.add(`172.30.168.${i}`)
}
// 获取网关信息
const resp = await getGatewayInfo()
if (!resp.success) {
throw new Error(`查询网关信息失败:${resp.error}`)
}
const gateways = resp.data
const findGateways = gateways.reduce((map, gateway) => {
map.set(gateway.macaddr, gateway)
return map
}, new Map<string, GatewayInfo>())
setGateways(findGateways)
// 获取网关配置
const data = new Map<string, Map<string, GatewayConfig | undefined>>()
for (const slot of slots) {
data.set(slot, new Map<string, GatewayConfig>())
}
await Promise.all(gateways.map((gateway, index) => {
return new Promise<void>(async (resolve) => {
const resp = await getGatewayConfig(1, { mac: gateway.macaddr })
if (!resp.success) {
throw new Error(`查询网关 ${gateway.inner_ip} 配置失败:${resp.error}`)
}
const configs = resp.data.items
const findConfig = configs.reduce((map, config) => {
map.set(config.inner_ip, config)
return map
}, new Map<string, GatewayConfig>())
for (const slot of slots) {
data.get(slot)!.set(gateway.macaddr, findConfig.get(slot))
}
resolve()
})
}))
setData(data)
}
catch (error) {
toast.error(`初始化页面数据失败:${(error as Error).message}`)
}
console.log('初始化数据耗时', Date.now() - now, 'ms')
}
useEffect(() => {
initData()
}, [])
return (
<Page className="gap-3">
<div className="flex gap-2">
<div className="flex items-center">
<div className="w-3 h-3 bg-green-500 rounded-full mr-2"></div>
<span className="text-sm font-medium">线 {Array.from(gateways.values()).filter(item => item.enable).length}</span>
</div>
<div className="flex items-center">
<div className="w-3 h-3 bg-red-500 rounded-full mr-2"></div>
<span className="text-sm font-medium">线 {Array.from(gateways.values()).filter(item => !item.enable).length}</span>
</div>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="border-r sticky left-0 bg-gray-50"></TableHead>
{gateways.values().map((pair, index) => (
<TableHead key={index} className="border-r h-auto">
<div className="flex flex-col items-center">
<div className="font-medium">{pair.inner_ip}</div>
<div className="text-xs text-gray-500 mt-1">{pair.macaddr}</div>
</div>
</TableHead>
)).toArray()}
</TableRow>
</TableHeader>
<TableBody>
{data.entries().map(([slot, configs], rowIndex) => (
<TableRow key={rowIndex}>
<TableCell className="border-r sticky left-0 bg-background">{slot}</TableCell>
{configs.entries().map(([_, config], colIndex) => {
if (!config) {
return (
<TableCell key={colIndex} className="not-last:border-r">
-
</TableCell>
)
}
const statusConfig = {
ischange: config.ischange === 0
? { bg: 'bg-green-100', text: 'text-green-800', label: '正常' }
: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: '更新' },
isonline: config.isonline === 0
? { bg: 'bg-green-100', text: 'text-green-800', label: '空闲' }
: { bg: 'bg-blue-100', text: 'text-blue-800', label: '在用' },
}
return (
<TableCell key={`${colIndex}`} className="not-last:border-r">
<div key={colIndex} className="flex flex-col gap-1">
<div className="text-sm font-medium">{config.public}</div>
<div className="text-xs font-medium flex justify-between">
<span>{config.user}</span>
<span>{shrinkCity(config.city || '?')}</span>
</div>
<div className="flex justify-between items-center">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusConfig.ischange.bg} ${statusConfig.ischange.text}`}>
{statusConfig.ischange.label}
</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusConfig.isonline.bg} ${statusConfig.isonline.text}`}>
{statusConfig.isonline.label}
</span>
</div>
</div>
</TableCell>
)
}).toArray()}
</TableRow>
)).toArray()}
</TableBody>
</Table>
</Page>
)
}
function shrinkCity(city: string) {
switch (city) {
case '黔东南苗族侗族自治州':
return '黔东南'
case '延边朝鲜族自治州':
return '延边'
default:
return city
}
}

View File

@@ -1,14 +1,15 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table'
import { DataTable } from '@/components/data-table'
import { Form, FormField } from '@/components/ui/form'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { getGatewayInfo, type GatewayInfo } from '@/actions/stats'
import { CopyIcon, CheckIcon } from 'lucide-react'
import { Page } from '@/components/page'
const filterSchema = z.object({
status: z.string(),
@@ -16,14 +17,64 @@ const filterSchema = z.object({
type FilterSchema = z.infer<typeof filterSchema>
// IP地址排序函数
const sortByIpAddress = (a: string, b: string): number => {
const ipToNumber = (ip: string): number => {
const parts = ip.split('.').map(part => parseInt(part, 10))
return (parts[0] << 24) + (parts[1] << 16) + (parts[2] << 8) + parts[3]
const SmartCopyButton = ({
data,
mode = 'single',
}: {
data: string | GatewayInfo[]
mode?: 'single' | 'batch'
}) => {
const [isCopied, setIsCopied] = useState(false)
const handleCopy = async () => {
try {
let textToCopy: string
if (mode === 'single' && typeof data === 'string') {
textToCopy = data
}
else if (mode === 'batch' && Array.isArray(data)) {
if (data.length === 0) return
textToCopy = data.map(item => item.macaddr).join('\n')
}
else {
return
}
await navigator.clipboard.writeText(textToCopy)
setIsCopied(true)
setTimeout(() => setIsCopied(false), 2000)
}
catch (err) {
console.error('复制失败:', err)
}
}
return ipToNumber(a) - ipToNumber(b)
const isBatch = mode === 'batch'
const disabled = isBatch && Array.isArray(data) && data.length === 0
return (
<button
onClick={handleCopy}
className={`
flex items-center gap-1 transition-colors
${isBatch
? 'px-2 py-1 text-xs bg-blue-50 text-blue-600 rounded hover:bg-blue-100'
: 'ml-2 p-1 rounded hover:bg-gray-100'
}
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
`}
title={isBatch ? '复制所有MAC地址' : '复制MAC地址'}
disabled={disabled}
>
{isCopied ? (
<CheckIcon className="w-3 h-3 text-green-600" />
) : (
<CopyIcon className="w-3 h-3 text-gray-500" />
)}
{isBatch && <span>{isCopied ? '已复制' : '复制全部'}</span>}
</button>
)
}
export default function Gatewayinfo() {
@@ -31,7 +82,6 @@ export default function Gatewayinfo() {
const [filteredData, setFilteredData] = useState<GatewayInfo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const router = useRouter()
const form = useForm<FilterSchema>({
resolver: zodResolver(filterSchema),
@@ -71,11 +121,8 @@ export default function Gatewayinfo() {
setError('')
const result = await getGatewayInfo()
const sortedData = result.data.sort((a: GatewayInfo, b: GatewayInfo) =>
sortByIpAddress(a.inner_ip, b.inner_ip),
)
setData(sortedData)
setFilteredData(sortedData) // 初始化时设置filteredData
setData(result.data)
setFilteredData(result.data) // 初始化时设置filteredData
}
catch (error) {
console.error('Failed to fetch gateway info:', error)
@@ -86,19 +133,9 @@ export default function Gatewayinfo() {
}
}
const getStatusText = (enable: number) => {
return enable === 1 ? '启用' : '禁用'
}
const getStatusClass = (enable: number) => {
return enable === 1
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}
if (loading) {
return (
<div className="bg-white shadow rounded-lg p-6 overflow-hidden">
<div className="bg-white w-full shadow p-6 overflow-hidden">
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="text-center py-8">...</div>
</div>
@@ -107,7 +144,7 @@ export default function Gatewayinfo() {
if (error) {
return (
<div className="bg-white shadow rounded-lg p-6">
<div className="bg-white w-full shadow p-6">
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="text-center py-8 text-red-600">{error}</div>
</div>
@@ -115,9 +152,9 @@ export default function Gatewayinfo() {
}
return (
<div className="flex flex-col bg-white shadow rounded-lg p-6 overflow-hidden">
<div className="flex gap-6">
<div className="flex flex-3 justify-between ">
<Page>
<div className="gap-6 flex">
<div className="flex flex-3 justify-between ">
<span className="text-lg pt-2 font-semibold mb-4"></span>
<Form {...form}>
<form className="flex items-center gap-4">
@@ -150,47 +187,45 @@ export default function Gatewayinfo() {
<div className="flex flex-1"></div>
</div>
<div className="flex gap-6 overflow-hidden">
<div className="flex-3 w-full flex">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="px-4 py-2 text-left">MAC地址</TableHead>
<TableHead className="px-4 py-2 text-left">IP</TableHead>
<TableHead className="px-4 py-2 text-left"></TableHead>
<TableHead className="px-4 py-2 text-left"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredData.map((item, index) => (
<TableRow
key={index}
className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}
>
<TableCell className="px-4 py-2">
<button
onClick={() => {
router.push(`/dashboard?tab=gateway&mac=${item.macaddr}`)
}}
className="font-mono text-blue-600 hover:text-blue-800 hover:underline cursor-pointer"
>
{item.macaddr}
</button>
</TableCell>
<TableCell className="px-4 py-2">{item.inner_ip}</TableCell>
<TableCell className="px-4 py-2">{item.setid}</TableCell>
<TableCell className="px-4 py-2">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(item.enable)}`}>
{getStatusText(item.enable)}
<div className="flex-auto overflow-hidden gap-6 flex">
<div className="flex-3 flex flex-col">
<DataTable
data={filteredData}
columns={[
{
label: 'MAC地址',
render: val => (
<div className="flex items-center gap-2">
{String(val.macaddr)}
<SmartCopyButton data={String(val.macaddr)} />
</div>
),
},
{
label: '内网IP',
props: 'inner_ip',
},
{
label: '配置版本',
props: 'setid',
},
{
label: '状态',
render: (val) => {
const enable = val.enable as number
return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${enable === 1
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'}`}>
{enable === 1 ? '启用' : '禁用'}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
},
},
]}
/>
</div>
<div className="flex flex-1 flex-col gap-4 mb-6">
<div className="flex-1 flex-col gap-4 mb-6 flex">
<div className="bg-blue-50 p-4 rounded-lg">
<div className="text-2xl font-bold text-blue-600">{data.length}</div>
<div className="text-sm text-blue-800"></div>
@@ -209,6 +244,6 @@ export default function Gatewayinfo() {
</div>
</div>
</div>
</div>
</Page>
)
}

125
src/app/(root)/layout.tsx Normal file
View File

@@ -0,0 +1,125 @@
'use client'
import { ReactNode, useState } from 'react'
import { useRouter, usePathname } from 'next/navigation'
import { logout } from '@/actions/auth'
import { LayoutDashboardIcon, LogOutIcon, User2Icon } from 'lucide-react'
import Link from 'next/link'
import { cn } from '@/lib/utils'
import { DoorClosedIcon } from 'lucide-react'
import { DoorClosedLockedIcon } from 'lucide-react'
import { DoorOpenIcon } from 'lucide-react'
import { MapPinnedIcon } from 'lucide-react'
import { ContainerIcon } from 'lucide-react'
import { GitForkIcon } from 'lucide-react'
import { SettingsIcon } from 'lucide-react'
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const [isLoading, setIsLoading] = useState(false)
const router = useRouter()
const pathname = usePathname()
const handleLogout = async () => {
setIsLoading(true)
try {
const response = await logout()
if (response) {
router.push('/login')
router.refresh()
}
}
catch (error) {
console.error('退出错误:', error)
}
finally {
setIsLoading(false)
}
}
const isActive = (path: string) => {
return pathname === path
}
return (
<div className="w-screen h-screen flex flex-col">
{/* 顶部导航栏 */}
<header className="flex-none basis-16 border-b">
<div className="px-4 sm:px-6">
<div className="flex justify-between h-16 items-center">
<div className="flex items-center">
<h1 className="text-xl font-bold text-gray-900"></h1>
</div>
<button
onClick={handleLogout}
disabled={isLoading}
className="flex items-center space-x-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 disabled:opacity-50 transition-colors"
>
<LogOutIcon className="h-4 w-4" />
<span>{isLoading ? '退出中...' : '退出登录'}</span>
</button>
</div>
</div>
</header>
{/* 主要内容区域 */}
<div className="flex-auto overflow-hidden flex">
{/* 侧边栏 */}
<nav className="flex-none basis-64 border-r flex flex-col p-3">
<NavbarTitle></NavbarTitle>
<NavbarItem href="/cityNodeStats" active={isActive('/cityNodeStats')}><MapPinnedIcon size={20} /></NavbarItem>
<NavbarTitle></NavbarTitle>
<NavbarItem href="/gatewayinfo" active={isActive('/gatewayinfo')}><DoorClosedIcon size={20} /></NavbarItem>
<NavbarItem href="/gatewayConfig" active={isActive('/gatewayConfig')}><DoorClosedLockedIcon size={20} /></NavbarItem>
<NavbarItem href="/gatewayMonitor" active={isActive('/gatewayMonitor')}><LayoutDashboardIcon size={20} /></NavbarItem>
<NavbarTitle></NavbarTitle>
<NavbarItem href="/edge" active={isActive('/edge')}><GitForkIcon size={20} /></NavbarItem>
<NavbarItem href="/allocationStatus" active={isActive('/allocationStatus')}><ContainerIcon size={20} /></NavbarItem>
<NavbarTitle></NavbarTitle>
<NavbarItem href="/settings" active={isActive('/settings')}><User2Icon size={20} /></NavbarItem>
</nav>
{/* 内容区域 */}
<main className="flex-auto overflow-hidden">
{children}
</main>
</div>
</div>
)
}
function NavbarTitle(props: {
children: ReactNode
}) {
return (
<h3 className="text-sm text-weak h-8 flex items-end px-2 pb-1">
{props.children}
</h3>
)
}
function NavbarItem(props: {
href: string
active: boolean
children: ReactNode
}) {
return (
<Link
href={props.href}
className={cn(
'transition-colors duration-150 ease-in-out',
'h-10 rounded-md text-sm flex items-center p-2 gap-2',
props.active
? 'text-primary bg-primary/10'
: 'hover:bg-muted',
)}
>
{props.children}
</Link>
)
}

View File

@@ -0,0 +1,296 @@
'use client'
import { useState, useEffect } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import * as z from 'zod'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { UserIcon, LockIcon, SearchIcon, Trash2Icon, PlusIcon, XIcon } from 'lucide-react'
import { toast, Toaster } from 'sonner'
import { findUsers, createUser, removeUser } from '@/actions/user'
import { Page } from '@/components/page'
import { DataTable } from '@/components/data-table'
// 用户类型定义
interface UserData {
id: number
account: string
createdAt: Date
name: string | null
updatedAt: Date
}
const formSchema = z.object({
account: z.string().min(3, '账号至少需要3个字符'),
password: z.string().min(6, '密码至少需要6个字符'),
confirmPassword: z.string().min(6, '密码至少需要6个字符'),
}).refine(data => data.password === data.confirmPassword, {
message: '密码不匹配',
path: ['confirmPassword'],
})
export default function Settings() {
const [loading, setLoading] = useState(false)
const [users, setUsers] = useState<UserData[]>([])
const [searchTerm, setSearchTerm] = useState('')
const [isCreateMode, setIsCreateMode] = useState(false)
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
account: '',
password: '',
confirmPassword: '',
},
})
// 获取用户列表
const fetchUsers = async () => {
try {
const data = await findUsers()
if (data.success) {
setUsers(data.data)
}
}
catch (error) {
toast.error('获取用户列表失败', {
description: error instanceof Error ? error.message : '服务器连接失败,请稍后重试',
})
}
}
useEffect(() => {
fetchUsers()
}, [])
// 创建用户
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true)
try {
const data = await createUser({
account: values.account,
password: values.password,
name: '',
})
if (data.success) {
toast.success('用户创建成功', {
description: '新账户已成功添加',
})
form.reset()
setIsCreateMode(false)
fetchUsers()
}
}
catch (error) {
toast.error('创建用户失败', {
description: error instanceof Error ? error.message : '服务器连接失败,请稍后重试',
})
}
finally {
setLoading(false)
}
}
// 删除用户
const handleDeleteUser = async (userId: number) => {
if (!confirm('确定要删除这个用户吗?此操作不可恢复。')) {
return
}
try {
const data = await removeUser(userId)
if (data.success) {
toast.success('用户删除成功', {
description: '用户账户已删除',
})
fetchUsers()
}
}
catch (error) {
toast.error('删除用户失败', {
description: error instanceof Error ? error.message : '服务器连接失败,请稍后重试',
})
}
}
const newData = users
.filter(user => user.account.toLowerCase().includes(searchTerm.toLowerCase()))
.map(user => ({
id: user.id,
account: user.account,
createdAt: new Date(user.createdAt).toLocaleDateString(),
}))
return (
<Page>
<div className="flex justify-between items-center mb-3">
<h1 className="text-3xl font-bold"></h1>
<Button onClick={() => setIsCreateMode(!isCreateMode)}>
{isCreateMode ? (
<>
<XIcon className="mr-2 h-4 w-4" />
</>
) : (
<>
<PlusIcon className="mr-2 h-4 w-4" />
</>
)}
</Button>
</div>
{isCreateMode && (
<Card className="mb-6">
<CardHeader>
<CardTitle className="text-xl"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="account"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<div className="relative">
<UserIcon className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="请输入需要添加的账号"
className="pl-8"
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<div className="relative">
<LockIcon className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="password"
placeholder="请输入密码"
className="pl-8"
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<div className="relative">
<LockIcon className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="password"
placeholder="请再次输入密码"
className="pl-8"
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2">
<Button
type="submit"
disabled={loading}
>
{loading ? (
<div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
...
</div>
) : (
'创建用户'
)}
</Button>
<Button
type="button"
variant="outline"
onClick={() => setIsCreateMode(false)}
>
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
)}
{/* 用户列表直接显示在页面上 */}
<div className="space-y-4">
<div>
<h2 className="text-2xl font-semibold"></h2>
<p className="text-muted-foreground"></p>
</div>
<div className="relative max-w-sm mt-4">
<SearchIcon className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索用户..."
className="pl-8"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
</div>
<DataTable
data={newData}
columns={[
{
label: '账号',
props: 'account',
},
{
label: '创建时间',
props: 'createdAt',
},
{
label: '操作',
render: (val) => {
return (
<Button
variant="outline"
size="sm"
className="h-5 border-0 hover:bg-transparent"
onClick={() => handleDeleteUser(Number(val.id))}
>
<Trash2Icon className="h-4 w-4" />
</Button>
)
},
},
]}
/>
</div>
<Toaster richColors />
</Page>
)
}

View File

@@ -1,132 +0,0 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import LoadingCard from '@/components/ui/loadingCard'
import ErrorCard from '@/components/ui/errorCard'
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table'
import { getAllocationStatus, type AllocationStatus } from '@/actions/stats'
export default function AllocationStatus({ detailed = false }: { detailed?: boolean }) {
const [data, setData] = useState<AllocationStatus[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [timeFilter, setTimeFilter] = useState('24') // 默认24小时
const [customHours, setCustomHours] = useState('')
// 获取时间参数(小时数)
const getTimeHours = useCallback(() => {
if (timeFilter === 'custom' && customHours) {
const hours = parseInt(customHours)
return isNaN(hours) ? 24 : Math.max(1, hours) // 默认24小时最少1小时
}
return parseInt(timeFilter) || 24 // 默认24小时
}, [timeFilter, customHours])
// 计算超额量
const calculateOverage = (assigned: number, count: number) => {
const overage = assigned - count
return Math.max(0, overage)
}
const fetchData = useCallback(async () => {
try {
setError(null)
setLoading(true)
const hours = getTimeHours()
const result = await getAllocationStatus(hours)
// 数据验证
const validatedData = (result.data).map(item => ({
city: item.city || '未知',
count: item.count,
assigned: item.assigned,
}))
const sortedData = validatedData.sort((a, b) => b.count - a.count)
setData(sortedData)
}
catch (error) {
console.error('Failed to fetch allocation status:', error)
setError(error instanceof Error ? error.message : 'Unknown error')
}
finally {
setLoading(false)
}
}, [getTimeHours])
useEffect(() => {
fetchData()
}, [fetchData])
if (loading) return <LoadingCard title="节点分配状态" />
if (error) return <ErrorCard title="节点分配状态" error={error} onRetry={fetchData} />
const problematicCities = data.filter(item => item.assigned > item.count)
return (
<div className="flex flex-col bg-white shadow rounded-lg p-6 overflow-hidden ">
<h2 className="text-lg font-semibold mb-4"></h2>
{/* 时间筛选器 */}
<div className="mb-4 flex flex-wrap items-center gap-3">
<label className="font-medium">:</label>
<select
value={timeFilter}
onChange={e => setTimeFilter(e.target.value)}
className="border rounded p-2"
>
<option value="1">1</option>
<option value="6">6</option>
<option value="12">12</option>
<option value="24">24</option>
<option value="168">7</option>
</select>
<button
onClick={fetchData}
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
>
</button>
</div>
<div className="flex gap-6 overflow-hidden">
<div className="flex w-full">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="px-4 py-2 text-left"></TableHead>
<TableHead className="px-4 py-2 text-left">IP量</TableHead>
<TableHead className="px-4 py-2 text-left">IP量</TableHead>
<TableHead className="px-4 py-2 text-left"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((item, index) => {
const overage = calculateOverage(Number(item.assigned), Number(item.count))
return (
<TableRow
key={index}
className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}
>
<TableCell className="px-4 py-2">{item.city}</TableCell>
<TableCell className="px-4 py-2">{item.count}</TableCell>
<TableCell className="px-4 py-2">{item.assigned}</TableCell>
<TableCell className="px-4 py-2">
<span className={overage > 0 ? 'text-red-600 font-medium' : ''}>
{overage}
</span>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
</div>
)
}

View File

@@ -1,81 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table'
import { getCityNodeCount, type CityNode } from '@/actions/stats'
export default function CityNodeStats() {
const [data, setData] = useState<CityNode[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchData()
}, [])
const fetchData = async () => {
try {
const result = await getCityNodeCount()
setData(result.data)
}
catch (error) {
console.error('获取城市节点数据失败:', error)
}
finally {
setLoading(false)
}
}
if (loading) {
return (
<div className="bg-white rounded-lg p-6 overflow-hidden">
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="text-gray-600">...</div>
</div>
)
}
return (
<div className="flex flex-col bg-white rounded-lg p-6 overflow-hidden">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold"></h2>
<span className="text-sm text-gray-500">
{data.length}
</span>
</div>
<div className="flex overflow-hidden ">
<div className="flex w-full">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="px-4 py-2 text-left font-medium text-gray-600"></TableHead>
<TableHead className="px-4 py-2 text-left font-medium text-gray-600"></TableHead>
<TableHead className="px-4 py-2 text-left font-medium text-gray-600">Hash</TableHead>
<TableHead className="px-4 py-2 text-left font-medium text-gray-600"></TableHead>
<TableHead className="px-4 py-2 text-left font-medium text-gray-600"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((item, index) => (
<TableRow
key={index}
className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}
>
<TableCell className="px-4 py-2">{item.city}</TableCell>
<TableCell className="px-4 py-2">{item.count}</TableCell>
<TableCell className="px-4 py-2">{item.hash}</TableCell>
<TableCell className="px-4 py-2">
<span className="bg-gray-100 px-2 py-1 rounded text-gray-700">
{item.label}
</span>
</TableCell>
<TableCell className="px-4 py-2">{item.offset}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
)
}

View File

@@ -1,261 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { Pagination } from '@/components/ui/pagination'
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table'
import { getEdgeNodes } from '@/actions/stats'
interface Edge {
id: number
macaddr: string
city: string
public: string
isp: string
single: number | boolean
sole: number | boolean
arch: number
online: number
}
export default function Edge() {
const [data, setData] = useState<Edge[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// 分页状态
const [currentPage, setCurrentPage] = useState(1)
const [itemsPerPage, setItemsPerPage] = useState(100) // 默认100条
const [totalItems, setTotalItems] = useState(0)
useEffect(() => {
fetchData()
}, [currentPage, itemsPerPage]) // 监听页码和每页数量的变化
const fetchData = async () => {
try {
setError(null)
setLoading(true)
// 计算偏移量
const offset = (currentPage - 1) * itemsPerPage
// if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
const result = await getEdgeNodes(offset, itemsPerPage)
type ResultEdge = {
id: number
macaddr: string
city: string
public: string
isp: string
single: number | boolean
sole: number | boolean
arch: number
online: number
}
const validatedData = (result.data as ResultEdge[]).map(item => ({
id: item.id,
macaddr: item.macaddr || '',
city: item.city || '',
public: item.public || '',
isp: item.isp || '',
single: item.single,
sole: item.sole,
arch: item.arch,
online: item.online,
}))
setData(validatedData)
setTotalItems(result.totalCount || 0)
}
catch (error) {
console.error('Failed to fetch edge nodes:', error)
setError(error instanceof Error ? error.message : '获取边缘节点数据失败')
}
finally {
setLoading(false)
}
}
// 多IP节点格式化
const formatMultiIP = (value: number | boolean): string => {
if (typeof value === 'number') {
switch (value) {
case 1: return '是'
case 0: return '否'
case -1: return '未知'
default: return `未知 (${value})`
}
}
return value ? '是' : '否'
}
// 独享IP节点格式化
const formatExclusiveIP = (value: number | boolean): string => {
if (typeof value === 'number') {
return value === 1 ? '是' : '否'
}
return value ? '是' : '否'
}
// 多IP节点颜色
const getMultiIPColor = (value: number | boolean): string => {
if (typeof value === 'number') {
switch (value) {
case 1: return 'bg-red-100 text-red-800'
case 0: return 'bg-green-100 text-green-800'
case -1: return 'bg-gray-100 text-gray-800'
default: return 'bg-gray-100 text-gray-800'
}
}
return value ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800'
}
// 独享IP节点颜色
const getExclusiveIPColor = (value: number | boolean): string => {
if (typeof value === 'number') {
return value === 1 ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}
return value ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}
const formatArchType = (arch: number): string => {
switch (arch) {
case 0: return '一代'
case 1: return '二代'
case 2: return 'AMD64'
case 3: return 'x86'
default: return `未知 (${arch})`
}
}
const getArchColor = (arch: number): string => {
switch (arch) {
case 0: return 'bg-blue-100 text-blue-800'
case 1: return 'bg-green-100 text-green-800'
case 2: return 'bg-purple-100 text-purple-800'
case 3: return 'bg-orange-100 text-orange-800'
default: return 'bg-gray-100 text-gray-800'
}
}
const formatOnlineTime = (seconds: number): string => {
if (seconds < 60) return `${seconds}`
if (seconds < 3600) return `${Math.floor(seconds / 60)}分钟`
if (seconds < 86400) return `${Math.floor(seconds / 3600)}小时`
return `${Math.floor(seconds / 86400)}`
}
// 处理页码变化
const handlePageChange = (page: number) => {
setCurrentPage(page)
}
// 处理每页显示数量变化
const handleSizeChange = (size: number) => {
setItemsPerPage(size)
setCurrentPage(1) // 重置到第一页
}
if (loading) return (
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-xl font-semibold text-gray-800 mb-4"></h2>
<div className="text-center py-8">...</div>
</div>
)
if (error) return (
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-xl font-semibold text-gray-800 mb-4"></h2>
<div className="text-center py-8 text-red-600">{error}</div>
<button
onClick={() => fetchData()}
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors mx-auto block"
>
</button>
</div>
)
return (
<div className="flex bg-white flex-col shadow overflow-hidden rounded-lg p-6">
{data.length === 0 ? (
<div className="text-center py-12">
<div className="text-gray-400 text-4xl mb-4">📋</div>
<p className="text-gray-600"></p>
</div>
) : (
<>
<div className="flex gap-6 overflow-hidden">
<div className="flex-3 w-full overflow-y-auto">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">MAC地址</TableHead>
<TableHead className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider"></TableHead>
<TableHead className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">IP</TableHead>
<TableHead className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider"></TableHead>
<TableHead className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">IP节点</TableHead>
<TableHead className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">IP</TableHead>
<TableHead className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider"></TableHead>
<TableHead className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">线</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((item, index) => (
<TableRow key={item.id} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<TableCell className="px-4 py-3 text-sm font-mono text-blue-600">{item.macaddr}</TableCell>
<TableCell className="px-4 py-3 text-sm text-gray-700">{item.city}</TableCell>
<TableCell className="px-4 py-3 text-sm font-mono text-green-600">{item.public}</TableCell>
<TableCell className="px-4 py-3 text-sm text-gray-700">
<span className={`px-2 py-1 rounded-full text-xs ${
item.isp === '移动'
? 'bg-blue-100 text-blue-800'
: item.isp === '电信'
? 'bg-purple-100 text-purple-800'
: item.isp === '联通'
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{item.isp}
</span>
</TableCell>
<TableCell className="px-4 py-3 text-sm text-center">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getMultiIPColor(item.single)}`}>
{formatMultiIP(item.single)}
</span>
</TableCell>
<TableCell className="px-4 py-3 text-sm text-center">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getExclusiveIPColor(item.sole)}`}>
{formatExclusiveIP(item.sole)}
</span>
</TableCell>
<TableCell className="px-4 py-3 text-sm text-center">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getArchColor(item.arch)}`}>
{formatArchType(item.arch)}
</span>
</TableCell>
<TableCell className="px-4 py-3 text-sm text-gray-700">{formatOnlineTime(item.online)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{/* 分页 */}
<Pagination
page={currentPage}
size={itemsPerPage}
total={totalItems}
onPageChange={handlePageChange}
onSizeChange={handleSizeChange}
className="mt-4"
/>
</>
)}
</div>
)
}

View File

@@ -1,224 +0,0 @@
'use client'
import { useEffect, useState, Suspense } from 'react'
import { useSearchParams } from 'next/navigation'
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table'
import { Pagination } from '@/components/ui/pagination'
import { getGatewayConfig, type GatewayConfig } from '@/actions/stats'
import { toast } from 'sonner'
function GatewayConfigContent() {
const [data, setData] = useState<GatewayConfig[]>([])
const [loading, setLoading] = useState(false)
const [macAddress, setMacAddress] = useState('')
const searchParams = useSearchParams()
// 分页状态
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
// 监听URL的mac参数变化
useEffect(() => {
const urlMac = searchParams.get('mac')
if (urlMac) {
setMacAddress(urlMac)
setPage(1) // 重置到第一页
fetchData(urlMac, 1)
}
else {
setMacAddress('')
setPage(1) // 重置到第一页
fetchData('', 1)
}
}, [searchParams])
const fetchData = async (mac: string, page: number = 1) => {
setLoading(true)
try {
// 计算偏移量
const result = await getGatewayConfig(page, mac)
if (!result.success) {
throw new Error(result.error || '查询网关配置失败')
}
const shrink = ['黔东南', '延边']
result.data.items.forEach((item) => {
shrink.forEach((s) => {
if (item.city?.startsWith(s)) {
item.city = s
}
})
})
setData(result.data.items)
setTotal(result.data.total)
}
catch (error) {
toast.error((error as Error).message || '获取网关配置失败')
}
finally {
setLoading(false)
}
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
setPage(1) // 重置到第一页
fetchData(macAddress, 1)
}
// 处理页码变化
const handlePageChange = (page: number) => {
setPage(page)
fetchData(macAddress, page)
}
// 处理每页显示数量变化
const handleSizeChange = (size: number) => {
setPage(1)
fetchData(macAddress, 1)
}
const getStatusBadge = (value: number, trueText: string = '是', falseText: string = '否') => {
// 0是正常1是更新正常绿+ 更新(红)
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${value === 0 ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
{value === 0 ? trueText : falseText}
</span>
)
}
const getOnlineStatus = (isonline: number) => {
// 0是空闲1是在用在用+ 空闲(绿)
return (
<div className="flex items-center">
<div className={`${isonline === 0 ? 'bg-green-500' : 'bg-red-500'}`} />
{getStatusBadge(isonline, '空闲', '在用')}
</div>
)
}
return (
<div className="flex flex-col bg-white shadow rounded-lg p-6 overflow-hidden">
<div className="flex justify-between items-start mb-6">
<div>
<h2 className="text-xl font-semibold text-gray-800"></h2>
<p className="text-sm text-gray-600 mt-1"></p>
</div>
</div>
{/* 查询表单 */}
<form onSubmit={handleSubmit} className="mb-6">
<div className="flex items-center gap-2">
<input
type="text"
value={macAddress}
onChange={e => setMacAddress(e.target.value)}
placeholder="输入MAC地址查询"
className="px-4 py-2 h-10 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<button
type="submit"
className="ml-2 px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
</button>
</div>
</form>
{loading ? (
<div className="text-center py-12">
<div className="text-gray-400 text-4xl mb-4"></div>
<p className="text-gray-600">...</p>
</div>
) : data.length > 0 ? (
<>
<div className="flex gap-6 overflow-hidden">
<div className="flex-3 w-full flex">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"></TableHead>
<TableHead className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">线</TableHead>
<TableHead className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"></TableHead>
<TableHead className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">MAC</TableHead>
<TableHead className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">IP</TableHead>
<TableHead className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"></TableHead>
<TableHead className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((item, index) => (
<TableRow key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<TableCell>{item.inner_ip}</TableCell>
<TableCell>{item.user}</TableCell>
<TableCell>{item.city}</TableCell>
<TableCell>{item.edge}</TableCell>
<TableCell>{item.public}</TableCell>
<TableCell>
{getStatusBadge(item.ischange, '正常', '更新')}
</TableCell>
<TableCell>
{getOnlineStatus(item.isonline)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex flex-1 flex-col gap-4 mb-6">
<div className="bg-blue-50 p-4 rounded-lg border border-blue-100">
<div className="text-2xl font-bold text-blue-600">{total}</div>
<div className="text-sm text-blue-800"></div>
</div>
<div className="bg-green-50 p-4 rounded-lg border border-green-100">
<div className="text-2xl font-bold text-green-600">
{data.filter(item => item.isonline === 1).length}
</div>
<div className="text-sm text-green-800"></div>
</div>
<div className="bg-orange-50 p-4 rounded-lg border border-orange-100">
<div className="text-2xl font-bold text-orange-600">
{data.filter(item => item.ischange === 1).length}
</div>
<div className="text-sm text-orange-800"></div>
</div>
</div>
</div>
{/* 分页组件 */}
{!macAddress && (
<Pagination
total={total}
page={page}
size={250}
sizeOptions={[250]}
onPageChange={handlePageChange}
onSizeChange={handleSizeChange}
className="mt-4"
/>
)}
</>
) : (
<div className="text-center py-12">
<div className="text-gray-400 text-4xl mb-4">📋</div>
<p className="text-gray-600"></p>
</div>
)}
</div>
)
}
export default function GatewayConfig() {
return (
<Suspense fallback={(
<div className="bg-white shadow rounded-lg p-6">
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600">...</p>
</div>
</div>
)}>
<GatewayConfigContent />
</Suspense>
)
}

View File

@@ -1,305 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import * as z from 'zod'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { User, Lock, Search, Trash2, Plus, X } from 'lucide-react'
import { toast, Toaster } from 'sonner'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { findUsers, createUser, removeUser } from '@/actions/user'
// 用户类型定义
interface UserData {
id: number
account: string
createdAt: Date
name: string | null
updatedAt: Date
}
const formSchema = z.object({
account: z.string().min(3, '账号至少需要3个字符'),
password: z.string().min(6, '密码至少需要6个字符'),
confirmPassword: z.string().min(6, '密码至少需要6个字符'),
}).refine(data => data.password === data.confirmPassword, {
message: '密码不匹配',
path: ['confirmPassword'],
})
export default function Settings() {
const [loading, setLoading] = useState(false)
const [users, setUsers] = useState<UserData[]>([])
const [searchTerm, setSearchTerm] = useState('')
const [isCreateMode, setIsCreateMode] = useState(false)
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
account: '',
password: '',
confirmPassword: '',
},
})
// 获取用户列表
const fetchUsers = async () => {
try {
const data = await findUsers()
if (data.success) {
setUsers(data.data)
}
}
catch (error) {
toast.error('获取用户列表失败', {
description: error instanceof Error ? error.message : '服务器连接失败,请稍后重试',
})
}
}
// 初始加载用户列表
useEffect(() => {
fetchUsers()
}, [])
// 创建用户
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true)
try {
const data = await createUser({
account: values.account,
password: values.password,
name: '',
})
if (data.success) {
toast.success('用户创建成功', {
description: '新账户已成功添加',
})
form.reset()
setIsCreateMode(false)
fetchUsers() // 刷新用户列表
}
}
catch (error) {
toast.error('创建用户失败', {
description: error instanceof Error ? error.message : '服务器连接失败,请稍后重试',
})
}
finally {
setLoading(false)
}
}
// 删除用户
const handleDeleteUser = async (userId: number) => {
if (!confirm('确定要删除这个用户吗?此操作不可恢复。')) {
return
}
try {
const data = await removeUser(userId)
if (data.success) {
toast.success('用户删除成功', {
description: '用户账户已删除',
})
fetchUsers() // 刷新用户列表
}
}
catch (error) {
toast.error('删除用户失败', {
description: error instanceof Error ? error.message : '服务器连接失败,请稍后重试',
})
}
}
// 过滤用户列表
const filteredUsers = users.filter(user =>
user.account.toLowerCase().includes(searchTerm.toLowerCase()),
)
return (
<div className="bg-white p-4 md:p-8">
<div className="max-w-6xl mx-auto">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold"></h1>
<Button onClick={() => setIsCreateMode(!isCreateMode)}>
{isCreateMode ? (
<>
<X className="mr-2 h-4 w-4" />
</>
) : (
<>
<Plus className="mr-2 h-4 w-4" />
</>
)}
</Button>
</div>
{isCreateMode && (
<Card className="mb-6">
<CardHeader>
<CardTitle className="text-xl"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="account"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<div className="relative">
<User className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="请输入需要添加的账号"
className="pl-8"
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<div className="relative">
<Lock className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="password"
placeholder="请输入密码"
className="pl-8"
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<div className="relative">
<Lock className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="password"
placeholder="请再次输入密码"
className="pl-8"
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2">
<Button
type="submit"
disabled={loading}
>
{loading ? (
<div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
...
</div>
) : (
'创建用户'
)}
</Button>
<Button
type="button"
variant="outline"
onClick={() => setIsCreateMode(false)}
>
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
)}
{/* 用户列表直接显示在页面上 */}
<div className="space-y-4">
<div>
<h2 className="text-2xl font-semibold"></h2>
<p className="text-muted-foreground"></p>
</div>
<div className="relative max-w-sm mt-4">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索用户..."
className="pl-8"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
</div>
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredUsers.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-center py-4">
</TableCell>
</TableRow>
) : (
filteredUsers.map(user => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.account}</TableCell>
<TableCell>{new Date(user.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
className="h-5 border-0 hover:bg-transparent"
onClick={() => handleDeleteUser(Number(user.id))}
><Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</div>
<Toaster richColors />
</div>
)
}

View File

@@ -1,136 +0,0 @@
'use client'
import { useState, useEffect, Suspense } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Gatewayinfo from './components/gatewayinfo'
import GatewayConfig from './components/gatewayConfig'
import CityNodeStats from './components/cityNodeStats'
import AllocationStatus from './components/allocationStatus'
import Settings from './components/settings'
import Edge from './components/edge'
import { LogOut } from 'lucide-react'
import { logout } from '@/actions/auth'
import { toast } from 'sonner'
const tabs = [
{ id: 'gatewayInfo', label: '网关信息' },
{ id: 'gateway', label: '网关配置' },
{ id: 'city', label: '城市信息' },
{ id: 'allocation', label: '分配状态' },
{ id: 'edge', label: '节点信息' },
{ id: 'setting', label: '设置' },
]
function DashboardContent() {
const [activeTab, setActiveTab] = useState('gatewayInfo')
const [isLoading, setIsLoading] = useState(false)
const router = useRouter()
const searchParams = useSearchParams()
// 监听URL参数变化
useEffect(() => {
const urlTab = searchParams.get('tab')
if (urlTab && tabs.some(tab => tab.id === urlTab)) {
setActiveTab(urlTab)
}
}, [searchParams])
// 退出登录
const handleLogout = async () => {
setIsLoading(true)
try {
const response = await logout()
if (response) {
// 退出成功后跳转到登录页
router.push('/login')
router.refresh()
}
else {
console.error('退出失败')
}
}
catch (error) {
console.error('退出错误:', error)
}
finally {
setIsLoading(false)
}
}
const handleTabClick = (tabId: string) => {
setActiveTab(tabId)
// 更新 URL 参数
const params = new URLSearchParams()
params.set('tab', tabId)
router.push(`/dashboard?${params.toString()}`)
}
return (
<div className=" bg-gray-100 w-screen h-screen flex flex-col">
<nav className="bg-white flex-none h-16 shadow-sm">
<div className="px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16 items-center">
<div className="flex items-center">
<h1 className="text-xl font-bold text-gray-900"></h1>
</div>
{/* 简化的退出按钮 */}
<button
onClick={handleLogout}
disabled={isLoading}
className="flex items-center space-x-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 disabled:opacity-50 transition-colors"
>
<LogOut className="h-4 w-4" />
<span>{isLoading ? '退出中...' : '退出登录'}</span>
</button>
</div>
</div>
</nav>
<div className="flex flex-3 overflow-hidden px-4 sm:px-6 lg:px-8 py-8">
<div className="border-b border-gray-200 mb-6">
<nav className="flex flex-col w-64 -mb-px space-x-8">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => handleTabClick(tab.id)}
className={`py-2 px-1 h-12 border-b-2 font-medium text-sm ${
activeTab === tab.id
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
<div className="grid grid-cols-1 gap-6 flex-auto">
{activeTab === 'gatewayInfo' && <Gatewayinfo />}
{activeTab === 'gateway' && <GatewayConfig />}
{activeTab === 'city' && <CityNodeStats />}
{activeTab === 'allocation' && <AllocationStatus detailed />}
{activeTab === 'edge' && <Edge />}
{activeTab === 'setting' && <Settings />}
</div>
</div>
</div>
)
}
export default function Dashboard() {
return (
<Suspense fallback={(
<div className=" bg-gray-100 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600">...</p>
</div>
</div>
)}>
<DashboardContent />
</Suspense>
)
}

View File

@@ -1,108 +0,0 @@
'use client'
import { findConfigs } from '@/actions/config'
import { gatewayConfigGet } from '@/actions/remote'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { useState } from 'react'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
type EdgeConfig = {
port?: string
edge?: string
city?: string
_index: number
}
export default function DebugConfigPage() {
const [macaddr, setMacaddr] = useState('')
const [remotes, setRemotes] = useState<EdgeConfig[]>([])
const [locals, setLocals] = useState<EdgeConfig[]>([])
const fetch = async (macaddr: string) => {
try {
console.log('fetch', macaddr)
if (!macaddr) return
const rawLocal = await findConfigs({ macaddr })
console.log('raw local', rawLocal)
const rawRemote = await gatewayConfigGet({ macaddr })
console.log('raw remote', rawRemote)
setLocals(rawLocal.map(rule => ({
port: rule.network,
edge: rule.edge,
city: rule.cityhash,
_index: parseInt(rule.network.split('.')[3] || '0'),
})).sort((a, b) => a._index - b._index))
setRemotes(rawRemote.rules.map((rule) => {
const port = rule.network.find(n => !!n)
return ({
port: port,
edge: rule.edge.find(n => !!n),
city: rule.cityhash,
_index: port ? parseInt(port.split('.')[3]) : 0,
})
}).sort((a, b) => a._index - b._index))
}
catch (e) {
console.error('数据获取失败', e)
}
}
return (
<div className="flex-auto overflow-hidden flex flex-col p-6 gap-4.5">
<div className="flex-none flex gap-3">
<Input type="text" name="macaddr" value={macaddr} onChange={e => setMacaddr(e.target.value)} className="flex-none basis-60" />
<Button onClick={() => fetch(macaddr)}></Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{!locals.length || !remotes.length ? (
<TableRow>
<TableCell colSpan={3} className="text-center"></TableCell>
</TableRow>
) : locals.length !== remotes.length ? (
<TableRow>
<TableCell colSpan={3} className="text-center"></TableCell>
</TableRow>
) : (
locals.map((item, index) => (
<TableRow key={index}>
<TableCell>
{item.port === remotes[index].port ? (
<span className="text-green-500">{item.port}</span>
) : (
<span className="text-red-500">{item.port} : {remotes[index].port}</span>
)}
</TableCell>
<TableCell>
{item.edge === remotes[index].edge ? (
<span className="text-green-500">{item.edge}</span>
) : (
<span className="text-red-500">{item.edge} : {remotes[index].edge}</span>
)}
</TableCell>
<TableCell>
{item.city === remotes[index].city ? (
<span className="text-green-500">{item.city}</span>
) : (
<span className="text-red-500">{item.city} : {remotes[index].city}</span>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)
}

View File

@@ -1,11 +0,0 @@
import { ReactNode } from 'react'
export default async function DebugLayout(props: {
children: ReactNode
}) {
return (
<div className="w-screen h-screen flex flex-col">
{props.children}
</div>
)
}

View File

@@ -6,6 +6,7 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-weak: var(--weak);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
@@ -45,14 +46,19 @@
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--weak: oklch(0.6 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary: oklch(0.65 0.175 255);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);

View File

@@ -0,0 +1,93 @@
'use client'
import * as React from 'react'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table'
import { ReactNode, useState } from 'react'
import { ArrowUpDownIcon, ArrowUpIcon, ArrowDownIcon } from 'lucide-react'
import { ColumnDef, flexRender, getCoreRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table'
import { cn } from '@/lib/utils'
type Data = Record<string, unknown>
type Column = {
label: string
props?: string
render?: (val: Data) => ReactNode
sortable?: boolean
}
export function DataTable<T extends Data>(props: {
data: T[]
columns: Column[]
pinFirst?: boolean
}) {
const table = useReactTable({
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
data: props.data,
columns: props.columns.map(col => ({
meta: col,
header: col.label,
accessorKey: col.props,
cell: info => col.render?.(info.row.original) || String(info.getValue()),
enableSorting: col.sortable,
})) as ColumnDef<T>[],
})
return (
<Table>
<TableHeader>
{/* 表头行 */}
{table.getHeaderGroups().map(group => (
<TableRow key={group.id}>
{/* 表头 */}
{group.headers.map((header, index) => (
<TableHead
key={header.id}
className={cn(
header.column.columnDef.enableSorting && 'hover:bg-gray-200 transition-colors duration-150 ease-in-out cursor-pointer',
header.column.getIsSorted() && 'text-primary',
props.pinFirst && index === 0 && 'sticky left-0 bg-gray-50 border-r',
)}
onClick={header.column.getToggleSortingHandler()}>
<div className="flex flex-row items-center justify-between">
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.columnDef.enableSorting && (
header.column.getIsSorted() == 'asc' ? (
<ArrowUpIcon className="size-4" />
) : header.column.getIsSorted() == 'desc' ? (
<ArrowDownIcon className="size-4" />
) : (
<ArrowUpDownIcon className="size-4" />
)
)}
</div>
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{/* 表格行 */}
{table.getRowModel().rows.map(row => (
<TableRow key={row.id}>
{/* 表格 */}
{row.getVisibleCells().map((cell, index) => (
<TableCell
key={cell.id}
className={cn(
props.pinFirst && index === 0 && 'sticky left-0 bg-white border-r',
)}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
)
}

12
src/components/page.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { cn } from '@/lib/utils'
import { ComponentProps, ReactNode } from 'react'
export function Page(props: {
children: ReactNode
} & ComponentProps<'div'>) {
return (
<div className={cn('w-full h-full p-6 flex flex-col', props.className)}>
{props.children}
</div>
)
}

View File

@@ -8,7 +8,7 @@ export default function ErrorCard({
onRetry: () => void
}) {
return (
<div className="bg-white shadow rounded-lg p-6 text-red-600">
<div className="bg-white shadow p-6 text-red-600">
<h2 className="text-lg font-semibold mb-2">{title}</h2>
<p>: {error}</p>
<button

View File

@@ -1,6 +1,6 @@
export default function LoadingCard({ title }: { title: string }) {
return (
<div className="bg-white shadow rounded-lg p-6">
<div className="bg-white w-full shadow p-6">
<div className="animate-pulse">
<div className="h-6 bg-gray-200 rounded w-1/4 mb-4"></div>
<div className="grid grid-cols-3 gap-4">

View File

@@ -107,7 +107,7 @@ function Pagination({
const paginationItems = generatePaginationItems()
return (
<div className={`flex flex-wrap items-center justify-end gap-4 ${className || ''}`}>
<div className={`flex flex-wrap items-center gap-4 ${className || ''}`}>
<div className="flex-none flex items-center gap-2 text-sm text-muted-foreground">
{' '}

View File

@@ -8,11 +8,11 @@ function Table({ className, ...props }: React.ComponentProps<'table'>) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
className="rounded-md border overflow-auto"
>
<table
data-slot="table"
className={cn('w-full caption-bottom text-sm ', className)}
className={cn('w-full caption-bottom text-sm border-separate border-spacing-0', className)}
{...props}
/>
</div>
@@ -23,7 +23,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
return (
<thead
data-slot="table-header"
className={cn('[&_tr]:border-b sticky top-0', className)}
className={cn('sticky top-0 bg-gray-50 z-10', className)}
{...props}
/>
)
@@ -33,7 +33,7 @@ function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
return (
<tbody
data-slot="table-body"
className={cn('[&_tr:last-child]:border-0', className)}
className={cn('[&>tr:last-child>td]:border-b-0', className)}
{...props}
/>
)
@@ -57,7 +57,7 @@ function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
<tr
data-slot="table-row"
className={cn(
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors h-10',
'hover:data-[state=selected]:bg-muted border-border/50 transition-colors',
className,
)}
{...props}
@@ -71,6 +71,7 @@ function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
data-slot="table-head"
className={cn(
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
'text-sm text-gray-500 border-b',
className,
)}
{...props}
@@ -83,7 +84,7 @@ function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
<td
data-slot="table-cell"
className={cn(
'p-2 h-10 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
'p-2 h-10 border-b align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}

View File

@@ -1,21 +1,23 @@
import 'dotenv/config'
import { drizzle as client } from 'drizzle-orm/mysql2'
import 'server-only'
import { drizzle as client, type MySql2Database } from 'drizzle-orm/mysql2'
import * as schema from './schema'
declare global {
var drizzle: ReturnType<typeof client<typeof schema>> | undefined
}
const globalForDrizzle = globalThis as { drizzle?: MySql2Database<typeof schema> }
const { DATABASE_URL } = process.env
if (!DATABASE_URL) {
throw new Error('DATABASE_URL is not set')
}
const { DATABASE_HOST, DATABASE_PORT, DATABASE_USERNAME, DATABASE_PASSWORD, DATABASE_NAME } = process.env
const proxy = new Proxy({} as MySql2Database<typeof schema>, {
get(_, prop) {
if (!globalForDrizzle.drizzle) {
globalForDrizzle.drizzle = client(
`mysql://${DATABASE_USERNAME}:${DATABASE_PASSWORD}@${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME}`,
{ mode: 'default', schema })
}
const drizzle = global.drizzle || client(DATABASE_URL, { mode: 'default', schema })
if (process.env.NODE_ENV !== 'production') {
global.drizzle = drizzle
}
const drizzle = globalForDrizzle.drizzle
return drizzle[prop as keyof typeof drizzle]
},
})
export default drizzle
export default proxy
export * from './schema'
export * from 'drizzle-orm'

View File

@@ -1,9 +1,20 @@
import 'server-only'
import { createClient } from 'redis'
import { createClient, type RedisClientType } from 'redis'
const client = createClient({
url: process.env.REDIS_URL,
})
const globalForRedis = globalThis as { redis?: RedisClientType }
const { REDIS_HOST, REDIS_PORT, REDIS_USERNAME, REDIS_PASSWORD } = process.env
if (!globalForRedis.redis) {
const url = REDIS_USERNAME && REDIS_PASSWORD
? `redis://${REDIS_USERNAME}:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}`
: `redis://${REDIS_HOST}:${REDIS_PORT}`
console.log('test url', url)
globalForRedis.redis = createClient({ url })
}
const redis = globalForRedis.redis
if (process.env.NODE_ENV === 'production') {
await redis.connect()
}
const redis = await client.connect()
export default redis

View File

@@ -26,7 +26,7 @@ export async function middleware(request: NextRequest) {
// 给没有页面的路径添加跳转页面
if (request.nextUrl.pathname === '/') {
return NextResponse.redirect(new URL('/dashboard', request.url))
return NextResponse.redirect(new URL('/gatewayinfo', request.url))
}
return NextResponse.next()