Merge branch 'main' of https://github.com/hamster1963/nezha-dash-v1 into hamster1963-main

This commit is contained in:
wood chen 2025-03-03 06:00:41 +08:00
commit b3df24ea08
26 changed files with 583 additions and 174 deletions

19
.cert/cert.pem Normal file
View File

@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDCTCCAfGgAwIBAgIUQxY5HJAktPoEWU9osMraUrm/DEAwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI1MDIxMzAzMTA0MVoXDTI2MDIx
MzAzMTA0MVowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEAraDt2UXkzKLRskNtVDo1iXe1tBTYTAFtl+m7JOvdYdmS
oenV3Cn/8Cd8JuusQVl9jovcMFb3pwrQzodSQ9oN70B/MSqA/Pjgpji+uu4Hjcas
VhaAHregBsV8ULl+OikPPFWcGKRZMtRyta3Sy/2E5Y44wr8vdERKDl/6ydDVioe5
dQQS+klyzamy9ayQj8fpSTR96H+WpDd6gGuDf+XlrqlnrgatiUIJiDkeJPCIUNJi
VSw8lq3KO8O4K376smCAdngdyYg+q/Sk2r5MnHi9VqNknwmos06yPk6vTWIpZ+mK
bz9W2HW4sukU0nwRXP0p29SKoW5ZKPvrLvfNDp0P3QIDAQABo1MwUTAdBgNVHQ4E
FgQUYSHtj6LjfaQ0BmuCdlHf/EXKm5AwHwYDVR0jBBgwFoAUYSHtj6LjfaQ0BmuC
dlHf/EXKm5AwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAQnM/
MIYunEp8ITMtllILW9TJhZVertfuux4S1rgRZ3VADmHgHftCgUKpm4kh8w2gEZ0M
DXTmnIwqaBa+lpiCcALECUQ1L2jPcwCYowmEfnKLF6Ob3Tnznz0eqr8TnvuKCX4c
ehSlfqOcUn8rveLDX91j+FJ+LSggf/kYjhE0ACtZHJyEM9csWu5chu8cCjpq5pn/
ahiPw5eUnxsyBWdqlkMvY+lofH7SaunXrbLcIDg67wMl0FpZ39z/UAhIVNiyUIDe
k7pNzRu99r5hIqdyfx5zULG2mzJCSsJj63t4BeDwr6u+zXSlyVMqh5cXj9mk4LJ6
DhJlnudcCV5t/RGyOw==
-----END CERTIFICATE-----

28
.cert/key.pem Normal file
View File

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCtoO3ZReTMotGy
Q21UOjWJd7W0FNhMAW2X6bsk691h2ZKh6dXcKf/wJ3wm66xBWX2Oi9wwVvenCtDO
h1JD2g3vQH8xKoD8+OCmOL667geNxqxWFoAet6AGxXxQuX46KQ88VZwYpFky1HK1
rdLL/YTljjjCvy90REoOX/rJ0NWKh7l1BBL6SXLNqbL1rJCPx+lJNH3of5akN3qA
a4N/5eWuqWeuBq2JQgmIOR4k8IhQ0mJVLDyWrco7w7grfvqyYIB2eB3JiD6r9KTa
vkyceL1Wo2SfCaizTrI+Tq9NYiln6YpvP1bYdbiy6RTSfBFc/Snb1Iqhblko++su
980OnQ/dAgMBAAECggEAI/6N+GI9N7AUVUaVqmWj1iL/Q/0jRwRvxhOyFIoiG6gp
dg/+IhWB5bUlz4LBc8270fqME+hfkF1VYs9aXk8c3unJxHVJhsgIeGUgoyt33Owg
K3ugJV4PWoAD0M9Xi/KZojokMVaW2EsDGcdWgSwGKjmk6jiMu6dxi8/Zc4+ryTsY
3+KMUocFyqMfYK1/sYSTPzlPWcCGMuaO36Df++cAzKLlqHRh7BLgSiCXBrV8ITFf
LTkQFDf/c+yVC6mJG/GXzqdKXS3OT97sW34tdmQPNhReCSkSEDVQt+tnFa5be1R2
18mODkaSv4DxnMXnlfexon/pGuXukgrMTZQXq2+pIQKBgQDaNvBmJrSCU9NSDNSj
I0yTX8DUzEv1bxErbfptSlSoUcEIPcLsxt+xZFVfU8IcMvQ43gHSsRquCfZsUZ0r
/ZIfJ7pWTqbxd/EybsMiC2ZSS8NdVX3MJhKinrZXMTRAA8l5a4AFr2YKMtNqQpGY
xWu8TS7PR8N9B6vZqGC9hhID4QKBgQDLsZLrezomWTthFAOfACj/ebIEyZ30YVNw
7IaaVTkeWtYGJXasMrts1+n15dPwR6a18c65hSywJKsCEYD6z/uXxaoX1bK32oLw
49thMw+qSilA1jMQ/XQxx9TFsmrCvwSm5xIjSV+0pD1sApiivGQAU+2oHZeEwLue
v51JxnaLfQKBgQDVYUWgThbTHk8U+7DuObVGoyp3q7JXNJ1wf2GTf0zbLt54RZSX
Xj0dRMRqrAey9Wx1MzpLIZ26M8nAz+nGO3Woe3utq8l5c9TqgP7VCpqqvKU0XkXd
3Bj65gHdryKtukZIMgOFC6fXLy4mySOAZQRdpIeybzVMzLSR6SF4EmMJYQKBgGtz
xVlLrCVGtThE4pQh9X6vp+U2poigPvA3FdqcUoFc0cJ0SOIV8SE91UHOd7stURhx
8ueTBTv2W++/ZBbrWIF72HqyVJEASErjKHtiAEWI0bJOTKoNyhnonKmdsQwC0GVr
R/otXrtgWLZ9uB9A2lAB9kDVO3TgZxkbY9HjS+3RAoGBAKVKcJFErNZhQxCx5ll3
u9wtE7duiVcS3jZhFa7tvcSc4O5+ahEQG/gy2M6kgqB/f3nMH6Rd9wsTzwPp1uZz
qiumr3ZOvpTWuLiIMQi3sE9pBGz7p+ZTeP8Z0Wez98v9MVmgsCsPqDOpa1JhnJIq
2AgG3D/RUJylOPYnMq8vdAyx
-----END PRIVATE KEY-----

BIN
bun.lockb

Binary file not shown.

View File

@ -107,9 +107,17 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CZL SVR</title>
<link rel="stylesheet" href="https://i-aws.czl.net/jsdelivr/gh/lipis/flag-icons@7.0.0/css/flag-icons.min.css" />
<link rel="stylesheet" href="https://i-aws.czl.net/jsdelivr/npm/font-logos@1/assets/font-logos.css" />
<title>CZL Server</title>
<!-- PWA -->
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="hsl(0 0% 98%)" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="CZL Server" />
<link rel="stylesheet" href="https://i.czl.net/jsdelivr/gh/lipis/flag-icons@7.0.0/css/flag-icons.min.css" />
<link rel="stylesheet" href="https://i.czl.net/jsdelivr/npm/font-logos@1/assets/font-logos.css" />
</head>
<body>
<div id="root"></div>

View File

@ -1,7 +1,7 @@
{
"name": "nazha-dashboard-vite",
"name": "nazha-dash-v1",
"private": true,
"version": "1.0.1",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
@ -13,58 +13,60 @@
"@fontsource/inter": "^5.1.1",
"@heroicons/react": "^2.2.0",
"@number-flow/react": "^0.5.5",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@tanstack/react-query": "^5.65.1",
"@tanstack/react-query-devtools": "^5.65.1",
"@tanstack/react-table": "^8.20.6",
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.8",
"@tanstack/react-query": "^5.66.7",
"@tanstack/react-query-devtools": "^5.66.7",
"@tanstack/react-table": "^8.21.2",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/d3-geo": "^3.1.0",
"@types/luxon": "^3.4.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"country-flag-icons": "^1.5.14",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.0.0",
"country-flag-icons": "^1.5.18",
"d3-geo": "^3.1.1",
"dayjs": "^1.11.13",
"framer-motion": "^12.0.6",
"i18n-iso-countries": "^7.13.0",
"framer-motion": "^12.4.5",
"i18n-iso-countries": "^7.14.0",
"i18next": "^24.2.2",
"lucide-react": "^0.460.0",
"luxon": "^3.5.0",
"prettier-plugin-tailwindcss": "^0.6.11",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-i18next": "^15.4.0",
"react-router-dom": "^7.1.3",
"react-i18next": "^15.4.1",
"react-router-dom": "^7.2.0",
"recharts": "^2.15.1",
"sonner": "^1.7.3",
"sonner": "^1.7.4",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@types/node": "^22.12.0",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react-swc": "^3.7.2",
"@eslint/js": "^9.20.0",
"@types/node": "^22.13.4",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react-swc": "^3.8.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.19.0",
"eslint": "^9.20.1",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.18",
"globals": "^15.14.0",
"postcss": "^8.5.1",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^15.15.0",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"typescript": "~5.6.3",
"typescript-eslint": "^8.22.0",
"vite": "^6.0.11"
"typescript-eslint": "^8.24.1",
"vite": "^6.1.1"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

17
public/manifest.json Normal file
View File

@ -0,0 +1,17 @@
{
"name": "Nezha Monitoring",
"short_name": "Nezha Monitoring",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
}
],
"theme_color": "hsl(0 0% 98%)",
"background_color": "hsl(0 0% 98%)",
"start_url": "/",
"display": "standalone",
"orientation": "portrait"
}

View File

@ -17,7 +17,7 @@ export const CycleTransferStatsCard: React.FC<CycleTransferStatsProps> = ({ serv
const serverIdList = serverList.map((server) => server.id.toString())
return (
<section className="grid grid-cols-1 md:grid-cols-2 gap-2 md:gap-4">
<section className="grid grid-cols-1 md:grid-cols-3 gap-3">
{Object.entries(cycleStats).map(([cycleId, cycleData]) => {
if (!cycleData.server_name) {
return null

View File

@ -1,11 +1,8 @@
import { formatBytes } from "@/lib/format"
import { cn } from "@/lib/utils"
import { CircleStackIcon } from "@heroicons/react/24/outline"
import React from "react"
import { useTranslation } from "react-i18next"
import AnimatedCircularProgressBar from "./ui/animated-circular-progress-bar"
interface CycleTransferStatsClientProps {
name: string
from: string
@ -26,7 +23,7 @@ export const CycleTransferStatsClient: React.FC<CycleTransferStatsClientProps> =
return (
<div
className={cn(
"w-full bg-white px-4 py-3 rounded-lg border bg-card text-card-foreground shadow-lg shadow-neutral-200/40 dark:shadow-none space-y-2",
"w-full bg-white px-4 py-3.5 rounded-lg border bg-card text-card-foreground hover:shadow-sm transition-all duration-200 dark:shadow-none",
className,
{
"bg-card/70": customBackgroundImage,
@ -37,43 +34,41 @@ export const CycleTransferStatsClient: React.FC<CycleTransferStatsClientProps> =
const progress = (transfer / max) * 100
return (
<div key={serverId}>
<section className="flex justify-between items-center">
<div className="bg-green-600 w-fit text-white px-1.5 py-0.5 rounded-full text-[10px]">{name}</div>
<span className="text-stone-600 dark:text-stone-400 text-xs">
{new Date(from).toLocaleDateString()} - {new Date(to).toLocaleDateString()}
</span>
</section>
<section className="flex justify-between items-center mt-2">
<div className="flex gap-1 items-center">
<CircleStackIcon className="size-3 text-neutral-400 dark:text-neutral-600" />
<span className="text-sm font-semibold">{serverName}</span>
</div>
<div className="flex items-center gap-1">
<p className="text-xs text-end w-10 font-medium">{progress.toFixed(0)}%</p>
<AnimatedCircularProgressBar className="size-4 text-[0px]" max={100} min={0} value={progress} primaryColor="hsl(var(--chart-5))" />
</div>
</section>
<div className="w-full bg-neutral-100 dark:bg-neutral-800 rounded-full overflow-hidden h-2.5 mt-2">
<div className="bg-green-600 h-2.5 rounded-full" style={{ width: `${Math.min(progress, 100)}%` }} />
<div key={serverId} className="space-y-3">
{/* Header */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-neutral-800 dark:text-neutral-200">{serverName}</span>
<div className="bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 px-2 py-0.5 rounded text-xs font-medium">{name}</div>
</div>
<section className="flex justify-between items-center mt-2">
<span className="text-[13px] text-stone-800 dark:text-stone-400 font-medium">
{formatBytes(transfer)} {t("cycleTransfer.used")}
</span>
<span className="text-xs text-stone-500 dark:text-stone-400 font-normal">
{formatBytes(max)} {t("cycleTransfer.total")}
</span>
</section>
<section className="flex justify-between items-center mt-2">
<div className="text-xs text-stone-500 dark:text-stone-400">
{t("cycleTransfer.nextUpdate")}: {new Date(nextUpdate).toLocaleString()}
{/* Progress Section */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex items-baseline gap-1">
<span className="text-sm font-medium text-neutral-800 dark:text-neutral-200">{formatBytes(transfer)}</span>
<span className="text-xs text-neutral-500 dark:text-neutral-400">/ {formatBytes(max)}</span>
</div>
<span className="text-xs font-medium text-neutral-600 dark:text-neutral-300">{progress.toFixed(1)}%</span>
</div>
</section>
<div className="relative h-1.5">
<div className="absolute inset-0 bg-neutral-100 dark:bg-neutral-800 rounded-full" />
<div
className="absolute inset-0 bg-emerald-500 rounded-full transition-all duration-300"
style={{ width: `${Math.min(progress, 100)}%` }}
/>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between text-[11px] text-neutral-500 dark:text-neutral-400">
<span>
{new Date(from).toLocaleDateString()} - {new Date(to).toLocaleDateString()}
</span>
<span>
{t("cycleTransfer.nextUpdate")}: {new Date(nextUpdate).toLocaleString()}
</span>
</div>
</div>
)
})}

View File

@ -1,6 +1,16 @@
import React from "react"
const Footer: React.FC = () => {
const { t } = useTranslation()
const isMac = /macintosh|mac os x/i.test(navigator.userAgent)
const { data: settingData } = useQuery({
queryKey: ["setting"],
queryFn: () => fetchSetting(),
refetchOnMount: true,
refetchOnWindowFocus: true,
})
return (
<footer className="mx-auto w-full max-w-5xl px-4 lg:px-0 pb-4 server-footer">
<section className="flex flex-col">
@ -11,9 +21,16 @@ const Footer: React.FC = () => {
CZL LTD
</a>
</div>
<p className="server-footer-theme">
<div className="server-footer-theme flex flex-col items-center sm:items-end">
<p className="mt-1 text-[13px] font-light tracking-tight text-neutral-600/50 dark:text-neutral-300/50">
<kbd className="pointer-events-none mx-1 inline-flex h-4 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-100">
<span className="text-xs"></span>K
</kbd>
</p>
<p className="server-footer-theme">
All Rights Reserved
</p>
</p>
</div>
</section>
</section>
</footer>

View File

@ -293,7 +293,7 @@ function Overview() {
<div style={{ fontVariantNumeric: "tabular-nums" }} className="flex text-sm font-medium mt-0.5">
<NumberFlow trend={1} value={time.hh} format={{ minimumIntegerDigits: 2 }} />
<NumberFlow prefix=":" trend={1} value={time.mm} digits={{ 1: { max: 5 } }} format={{ minimumIntegerDigits: 2 }} />
<NumberFlow prefix=":" trend={1} value={time.ss} digits={{ 1: { max: 5 } }} format={{ minimumIntegerDigits: 2 }} />
<p className="mt-[0.5px]">:{time.ss.toString().padStart(2, "0")}</p>
</div>
</NumberFlowGroup>
</div>

View File

@ -4,7 +4,6 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { ChartConfig, ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
import { fetchMonitor } from "@/lib/nezha-api"
import { cn, formatTime } from "@/lib/utils"
import { formatRelativeTime } from "@/lib/utils"
import { NezhaMonitor, ServerMonitorChart } from "@/types/nezha-api"
import { useQuery } from "@tanstack/react-query"
import * as React from "react"
@ -95,8 +94,10 @@ export const NetworkChartClient = React.memo(function NetworkChart({
const customBackgroundImage = (window.CustomBackgroundImage as string) !== "" ? window.CustomBackgroundImage : undefined
const forcePeakCutEnabled = (window.ForcePeakCutEnabled as boolean) ?? false
const [activeChart, setActiveChart] = React.useState(defaultChart)
const [isPeakEnabled, setIsPeakEnabled] = React.useState(false)
const [isPeakEnabled, setIsPeakEnabled] = React.useState(forcePeakCutEnabled)
const handleButtonClick = useCallback(
(chart: string) => {
@ -264,12 +265,36 @@ export const NetworkChartClient = React.memo(function NetworkChart({
<CartesianGrid vertical={false} />
<XAxis
dataKey="created_at"
tickLine={false}
tickLine={true}
tickSize={3}
axisLine={false}
tickMargin={8}
minTickGap={32}
interval={"preserveStartEnd"}
tickFormatter={(value) => formatRelativeTime(value)}
minTickGap={80}
ticks={processedData
.filter((item, index, array) => {
if (array.length < 6) {
return index === 0 || index === array.length - 1
}
// 计算数据的总时间跨度(毫秒)
const timeSpan = array[array.length - 1].created_at - array[0].created_at
const hours = timeSpan / (1000 * 60 * 60)
// 根据时间跨度调整显示间隔
if (hours <= 12) {
// 12小时内每60分钟显示一个刻度
return index === 0 || index === array.length - 1 || new Date(item.created_at).getMinutes() % 60 === 0
}
// 超过12小时每2小时显示一个刻度
const date = new Date(item.created_at)
return date.getMinutes() === 0 && date.getHours() % 2 === 0
})
.map((item) => item.created_at)}
tickFormatter={(value) => {
const date = new Date(value)
const minutes = date.getMinutes()
return minutes === 0 ? `${date.getHours()}:00` : `${date.getHours()}:${minutes}`
}}
/>
<YAxis tickLine={false} axisLine={false} tickMargin={15} minTickGap={20} tickFormatter={(value) => `${value}ms`} />
<ChartTooltip

View File

@ -14,6 +14,7 @@ import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./ui/accordion"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"
export default function ServerDetailOverview({ server_id }: { server_id: string }) {
const { t } = useTranslation()
@ -165,17 +166,26 @@ export default function ServerDetailOverview({ server_id }: { server_id: string
) : null}
{country_code && (
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">{t("serverDetail.region")}</p>
<section className="flex items-start gap-1">
<div className="text-xs text-start">{countries.getName(country_code?.toUpperCase(), "en")}</div>
{country_code && <ServerFlag className="text-[11px] -mt-[1px]" country_code={country_code} />}
</section>
</section>
</CardContent>
</Card>
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<Card className="rounded-[10px] bg-transparent border-none shadow-none">
<CardContent className="px-1.5 py-1">
<section className="flex flex-col items-start gap-0.5">
<p className="text-xs text-muted-foreground">{t("serverDetail.region")}</p>
<section className="flex items-start gap-1">
<div className="text-xs text-start">{country_code?.toUpperCase()}</div>
{country_code && <ServerFlag className="text-[11px] -mt-[1px]" country_code={country_code} />}
</section>
</section>
</CardContent>
</Card>
</TooltipTrigger>
<TooltipContent>
<p>{countries.getName(country_code?.toUpperCase(), "en")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</section>
<section className="flex flex-wrap gap-2 mt-1">

View File

@ -19,10 +19,16 @@ export function ServiceTracker({ serverList }: { serverList: NezhaServer[] }) {
})
const processServiceData = (serviceData: ServiceData) => {
const days = serviceData.up.map((up, index) => ({
completed: up > serviceData.down[index],
date: new Date(Date.now() - (29 - index) * 24 * 60 * 60 * 1000),
}))
const days = serviceData.up.map((up, index) => {
const totalChecks = up + serviceData.down[index]
const dailyUptime = totalChecks > 0 ? (up / totalChecks) * 100 : 0
return {
completed: up > serviceData.down[index],
date: new Date(Date.now() - (29 - index) * 24 * 60 * 60 * 1000),
uptime: dailyUptime,
delay: serviceData.delay[index] || 0,
}
})
const totalUp = serviceData.up.reduce((a, b) => a + b, 0)
const totalChecks = serviceData.up.reduce((a, b) => a + b, 0) + serviceData.down.reduce((a, b) => a + b, 0)

View File

@ -1,3 +1,4 @@
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import React from "react"
import { useTranslation } from "react-i18next"
@ -8,6 +9,8 @@ interface ServiceTrackerProps {
days: Array<{
completed: boolean
date?: Date
uptime: number
delay: number
}>
className?: string
title?: string
@ -18,6 +21,25 @@ interface ServiceTrackerProps {
export const ServiceTrackerClient: React.FC<ServiceTrackerProps> = ({ days, className, title, uptime = 100, avgDelay = 0 }) => {
const { t } = useTranslation()
const customBackgroundImage = (window.CustomBackgroundImage as string) !== "" ? window.CustomBackgroundImage : undefined
const getUptimeColor = (uptime: number) => {
if (uptime >= 99) return "text-emerald-500"
if (uptime >= 95) return "text-amber-500"
return "text-rose-500"
}
const getDelayColor = (delay: number) => {
if (delay < 100) return "text-emerald-500"
if (delay < 300) return "text-amber-500"
return "text-rose-500"
}
const getStatusColor = (uptime: number) => {
if (uptime >= 99) return "bg-emerald-500"
if (uptime >= 95) return "bg-amber-500"
return "bg-rose-500"
}
return (
<div
className={cn(
@ -30,27 +52,58 @@ export const ServiceTrackerClient: React.FC<ServiceTrackerProps> = ({ days, clas
>
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<div className="w-5 h-5 rounded-full bg-green-600 flex items-center justify-center">
<div className="w-3 h-3 rounded-full bg-white dark:bg-black" />
</div>
<div className={cn("w-2.5 h-2.5 rounded-full transition-colors", getStatusColor(uptime))} />
<span className="font-medium text-sm">{title}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-stone-600 dark:text-stone-400 font-medium text-sm">{avgDelay.toFixed(0)}ms</span>
<Separator className="h-4 mx-0" orientation="vertical" />
<span className="text-green-600 font-medium text-sm">
<div className="flex items-center gap-3">
<span className={cn("font-medium text-sm transition-colors", getDelayColor(avgDelay))}>{avgDelay.toFixed(0)}ms</span>
<Separator className="h-4" orientation="vertical" />
<span className={cn("font-medium text-sm transition-colors", getUptimeColor(uptime))}>
{uptime.toFixed(1)}% {t("serviceTracker.uptime")}
</span>
</div>
</div>
<div className="flex gap-[2px]">
<div className="flex gap-[3px] bg-muted/30 p-1 rounded-lg">
{days.map((day, index) => (
<div
key={index}
className={cn("flex-1 h-6 rounded-[5px] transition-colors", day.completed ? "bg-green-600" : "bg-red-500/60")}
title={day.date ? day.date.toLocaleDateString() : `Day ${index + 1}`}
/>
<TooltipProvider delayDuration={50} key={index}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"relative flex-1 h-7 rounded-[4px] transition-all duration-200 cursor-help",
"before:absolute before:inset-0 before:rounded-[4px] before:opacity-0 hover:before:opacity-100 before:bg-white/10 before:transition-opacity",
"after:absolute after:inset-0 after:rounded-[4px] after:shadow-[inset_0_1px_theme(colors.white/10%)]",
day.completed
? "bg-gradient-to-b from-green-500/90 to-green-600 shadow-[0_1px_2px_theme(colors.green.600/30%)]"
: "bg-gradient-to-b from-red-500/80 to-red-600/90 shadow-[0_1px_2px_theme(colors.red.600/30%)]",
)}
/>
</TooltipTrigger>
<TooltipContent className="p-0 overflow-hidden">
<div className="px-3 py-2 bg-popover">
<p className="font-medium text-sm mb-2">{day.date?.toLocaleDateString()}</p>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<span className="text-xs text-muted-foreground">{t("serviceTracker.uptime")}:</span>
<span className={cn("text-xs font-medium", day.uptime > 95 ? "text-green-500" : "text-red-500")}>{day.uptime.toFixed(1)}%</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-xs text-muted-foreground">{t("serviceTracker.delay")}:</span>
<span
className={cn(
"text-xs font-medium",
day.delay < 100 ? "text-green-500" : day.delay < 300 ? "text-yellow-500" : "text-red-500",
)}
>
{day.delay.toFixed(0)}ms
</span>
</div>
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
</div>

View File

@ -0,0 +1,39 @@
"use client"
import { useTheme } from "@/hooks/use-theme"
import { useEffect } from "react"
export function ThemeColorManager() {
const { theme } = useTheme()
useEffect(() => {
const updateThemeColor = () => {
const currentTheme = theme
const meta = document.querySelector('meta[name="theme-color"]')
if (!meta) {
const newMeta = document.createElement("meta")
newMeta.name = "theme-color"
document.head.appendChild(newMeta)
}
const themeColor =
currentTheme === "dark"
? "hsl(30 15% 8%)" // 深色模式背景色
: "hsl(0 0% 98%)" // 浅色模式背景色
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", themeColor)
}
// Update on mount and theme change
updateThemeColor()
// Listen for system theme changes
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
mediaQuery.addEventListener("change", updateThemeColor)
return () => mediaQuery.removeEventListener("change", updateThemeColor)
}, [theme])
return null
}

View File

@ -0,0 +1,126 @@
import { cn } from "@/lib/utils"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import * as React from "react"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton ref={ref} className={cn("flex cursor-default items-center justify-center py-1", className)} {...props}>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton ref={ref} className={cn("flex cursor-default items-center justify-center py-1", className)} {...props}>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn("p-1", position === "popper" && "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]")}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<React.ElementRef<typeof SelectPrimitive.Label>, React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>>(
({ className, ...props }, ref) => (
<SelectPrimitive.Label ref={ref} className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} {...props} />
),
)
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<React.ElementRef<typeof SelectPrimitive.Item>, React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>>(
({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
),
)
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => <SelectPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />)
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@ -0,0 +1,27 @@
import { cn } from "@/lib/utils"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import * as React from "react"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-[8px] border font-medium bg-popover px-1.5 py-0.5 text-xs text-popover-foreground shadow-lg animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -4,6 +4,10 @@ declare global {
interface Window {
CustomBackgroundImage: string
CustomMobileBackgroundImage: string
ForceShowServices: boolean
ForceCardInline: boolean
ForceShowMap: boolean
ForcePeakCutEnabled: boolean
}
}

View File

@ -72,6 +72,8 @@ export function getDaysBetweenDatesWithAutoRenewal({ autoRenewal, cycle, startDa
months = 12
break
case "季":
case "q":
case "qr":
case "quarterly":
cycleLabel = "季"
months = 3

View File

@ -46,6 +46,7 @@
"serviceTracker": {
"noService": "No service data",
"uptime": "Uptime",
"delay": "Delay",
"daysAgo": "days ago",
"today": "Today",
"loading": "Loading..."

View File

@ -44,8 +44,9 @@
"nextUpdate": "下次更新"
},
"serviceTracker": {
"noService": "没有服务监测数据",
"uptime": "可用率",
"noService": "无服务数据",
"uptime": "在线率",
"delay": "延迟",
"daysAgo": "天前",
"today": "今天",
"loading": "加载中..."

View File

@ -44,8 +44,9 @@
"nextUpdate": "下次更新"
},
"serviceTracker": {
"noService": "沒有服務監控數據",
"uptime": "可用率",
"noService": "無服務數據",
"uptime": "在線率",
"delay": "延遲",
"daysAgo": "天前",
"today": "今天",
"loading": "載入中..."

View File

@ -4,6 +4,7 @@ import ReactDOM from "react-dom/client"
import { Toaster } from "sonner"
import App from "./App"
import { ThemeColorManager } from "./components/ThemeColorManager"
import { ThemeProvider } from "./components/ThemeProvider"
import { MotionProvider } from "./components/motion/motion-provider"
import { SortProvider } from "./context/sort-provider"
@ -18,6 +19,7 @@ const queryClient = new QueryClient()
ReactDOM.createRoot(document.getElementById("root")!).render(
<MotionProvider>
<ThemeProvider storageKey="vite-ui-theme">
<ThemeColorManager />
<QueryClientProvider client={queryClient}>
<WebSocketProvider url="/api/v1/ws/server">
<StatusProvider>

View File

@ -7,6 +7,7 @@ import { ServiceTracker } from "@/components/ServiceTracker"
import { Loader } from "@/components/loading/Loader"
import { Label } from "@/components/ui/label"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { SORT_ORDERS, SORT_TYPES } from "@/context/sort-context"
import { useSort } from "@/hooks/use-sort"
import { useStatus } from "@/hooks/use-status"
@ -53,18 +54,31 @@ export default function Servers() {
useEffect(() => {
const showServicesState = localStorage.getItem("showServices")
if (showServicesState !== null) {
if (window.ForceShowServices) {
setShowServices("1")
} else if (showServicesState !== null) {
setShowServices(showServicesState)
}
}, [])
useEffect(() => {
const inlineState = localStorage.getItem("inline")
if (inlineState !== null) {
if (window.ForceCardInline) {
setInline("1")
} else if (inlineState !== null) {
setInline(inlineState)
}
}, [])
useEffect(() => {
const showMapState = localStorage.getItem("showMap")
if (window.ForceShowMap) {
setShowMap("1")
} else if (showMapState !== null) {
setShowMap(showMapState)
}
}, [])
useEffect(() => {
const savedGroup = sessionStorage.getItem("selectedGroup") || "All"
setCurrentGroup(savedGroup)
@ -212,18 +226,24 @@ export default function Servers() {
<button
onClick={() => {
setShowMap(showMap === "0" ? "1" : "0")
localStorage.setItem("showMap", showMap === "0" ? "1" : "0")
}}
className={cn(
"rounded-[50px] text-white cursor-pointer [text-shadow:_0_1px_0_rgb(0_0_0_/_20%)] bg-blue-600 p-[10px] transition-all shadow-[inset_0_1px_0_rgba(255,255,255,0.2)] ",
"rounded-[50px] bg-white dark:bg-stone-800 cursor-pointer p-[10px] transition-all border dark:border-none border-stone-200 dark:border-stone-700 hover:bg-stone-100 dark:hover:bg-stone-700 shadow-[inset_0_1px_0_rgba(255,255,255,0.2)]",
{
"shadow-[inset_0_1px_0_rgba(0,0,0,0.2)] bg-blue-500": showMap === "1",
"shadow-[inset_0_1px_0_rgba(0,0,0,0.2)] !bg-blue-600 hover:!bg-blue-600 border-blue-600 dark:border-blue-600": showMap === "1",
"text-white": showMap === "1",
},
{
"bg-opacity-70": customBackgroundImage,
"bg-opacity-70 dark:bg-opacity-70": customBackgroundImage,
},
)}
>
<MapIcon className="size-[13px]" />
<MapIcon
className={cn("size-[13px]", {
"text-white": showMap === "1",
})}
/>
</button>
<button
onClick={() => {
@ -231,16 +251,21 @@ export default function Servers() {
localStorage.setItem("showServices", showServices === "0" ? "1" : "0")
}}
className={cn(
"rounded-[50px] text-white cursor-pointer [text-shadow:_0_1px_0_rgb(0_0_0_/_20%)] bg-blue-600 p-[10px] transition-all shadow-[inset_0_1px_0_rgba(255,255,255,0.2)] ",
"rounded-[50px] bg-white dark:bg-stone-800 cursor-pointer p-[10px] transition-all border dark:border-none border-stone-200 dark:border-stone-700 hover:bg-stone-100 dark:hover:bg-stone-700 shadow-[inset_0_1px_0_rgba(255,255,255,0.2)]",
{
"shadow-[inset_0_1px_0_rgba(0,0,0,0.2)] bg-blue-500": showServices === "1",
"shadow-[inset_0_1px_0_rgba(0,0,0,0.2)] !bg-blue-600 hover:!bg-blue-600 border-blue-600 dark:border-blue-600": showServices === "1",
"text-white": showServices === "1",
},
{
"bg-opacity-70": customBackgroundImage,
"bg-opacity-70 dark:bg-opacity-70": customBackgroundImage,
},
)}
>
<ChartBarSquareIcon className="size-[13px]" />
<ChartBarSquareIcon
className={cn("size-[13px]", {
"text-white": showServices === "1",
})}
/>
</button>
<button
onClick={() => {
@ -248,16 +273,21 @@ export default function Servers() {
localStorage.setItem("inline", inline === "0" ? "1" : "0")
}}
className={cn(
"rounded-[50px] text-white cursor-pointer [text-shadow:_0_1px_0_rgb(0_0_0_/_20%)] bg-blue-600 p-[10px] transition-all shadow-[inset_0_1px_0_rgba(255,255,255,0.2)] ",
"rounded-[50px] bg-white dark:bg-stone-800 cursor-pointer p-[10px] transition-all border dark:border-none border-stone-200 dark:border-stone-700 hover:bg-stone-100 dark:hover:bg-stone-700 shadow-[inset_0_1px_0_rgba(255,255,255,0.2)]",
{
"shadow-[inset_0_1px_0_rgba(0,0,0,0.2)] bg-blue-500": inline === "1",
"shadow-[inset_0_1px_0_rgba(0,0,0,0.2)] !bg-blue-600 hover:!bg-blue-600 border-blue-600 dark:border-blue-600": inline === "1",
"text-white": inline === "1",
},
{
"bg-opacity-70": customBackgroundImage,
"bg-opacity-70 dark:bg-opacity-70": customBackgroundImage,
},
)}
>
<ViewColumnsIcon className="size-[13px]" />
<ViewColumnsIcon
className={cn("size-[13px]", {
"text-white": inline === "1",
})}
/>
</button>
<GroupSwitch tabs={groupTabs} currentTab={currentGroup} setCurrentTab={handleTagChange} />
</section>
@ -265,7 +295,7 @@ export default function Servers() {
<PopoverTrigger asChild>
<button
className={cn(
"rounded-[50px] flex items-center gap-1 dark:text-white border dark:border-none text-black cursor-pointer dark:[text-shadow:_0_1px_0_rgb(0_0_0_/_20%)] dark:bg-stone-800 bg-stone-100 p-[10px] transition-all shadow-[inset_0_1px_0_rgba(255,255,255,0.2)] ",
"rounded-[50px] flex items-center gap-1 dark:text-white border dark:border-none text-black cursor-pointer dark:[text-shadow:_0_1px_0_rgb(0_0_0_/_20%)] dark:bg-stone-800 bg-white p-[10px] transition-all shadow-[inset_0_1px_0_rgba(255,255,255,0.2)] ",
{
"shadow-[inset_0_1px_0_rgba(0,0,0,0.2)] dark:bg-stone-700 bg-stone-200": settingsOpen,
},
@ -284,47 +314,38 @@ export default function Servers() {
)}
</button>
</PopoverTrigger>
<PopoverContent className="py-2 px-2 w-fit max-w-60 rounded-[8px]">
<div className="flex flex-col gap-2">
<section className="flex flex-col gap-1">
<Label className=" text-stone-500 text-xs">Sort by</Label>
<section className="flex items-center gap-1 flex-wrap">
{SORT_TYPES.map((type) => (
<button
key={type}
onClick={() => setSortType(type)}
className={cn(
"rounded-[5px] text-[11px] w-fit px-1 py-0.5 cursor-pointer bg-transparent border transition-all dark:shadow-none ",
{
"bg-black text-white dark:bg-white dark:text-black shadow-[inset_0_1px_0_rgba(255,255,255,0.2)]": sortType === type,
},
)}
>
{type}
</button>
))}
</section>
</section>
<section className="flex flex-col gap-1">
<Label className=" text-stone-500 text-xs">Sort order</Label>
<section className="flex items-center gap-1">
{SORT_ORDERS.map((order) => (
<button
disabled={sortType === "default"}
key={order}
onClick={() => setSortOrder(order)}
className={cn(
"rounded-[5px] text-[11px] w-fit px-1 py-0.5 cursor-pointer bg-transparent border transition-all shadow-[inset_0_1px_0_rgba(255,255,255,0.2)] dark:shadow-none",
{
"bg-black text-white dark:bg-white dark:text-black": sortOrder === order && sortType !== "default",
},
)}
>
{order}
</button>
))}
</section>
</section>
<PopoverContent className="p-4 w-[240px] rounded-lg">
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Sort by</Label>
<Select value={sortType} onValueChange={setSortType}>
<SelectTrigger className="w-full text-xs h-8">
<SelectValue placeholder="Choose type" />
</SelectTrigger>
<SelectContent>
{SORT_TYPES.map((type) => (
<SelectItem key={type} value={type} className="text-xs">
{type.charAt(0).toUpperCase() + type.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Sort order</Label>
<Select value={sortOrder} onValueChange={setSortOrder} disabled={sortType === "default"}>
<SelectTrigger className="w-full text-xs h-8">
<SelectValue placeholder="Choose order" />
</SelectTrigger>
<SelectContent>
{SORT_ORDERS.map((order) => (
<SelectItem key={order} value={order} className="text-xs">
{order.charAt(0).toUpperCase() + order.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</PopoverContent>
</Popover>

View File

@ -1,5 +1,6 @@
import react from "@vitejs/plugin-react-swc"
import { execSync } from "child_process"
import fs from "fs"
import path from "path"
import { defineConfig } from "vite"
@ -26,6 +27,10 @@ export default defineConfig({
},
},
server: {
https: {
key: fs.readFileSync("./.cert/key.pem"),
cert: fs.readFileSync("./.cert/cert.pem"),
},
proxy: {
"/api/v1/ws/server": {
target: "ws://localhost:18009",