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" /> <meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/apple-touch-icon.png" /> <link rel="icon" type="image/png" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CZL SVR</title> <title>CZL Server</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" /> <!-- 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> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

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

View File

@ -1,11 +1,8 @@
import { formatBytes } from "@/lib/format" import { formatBytes } from "@/lib/format"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { CircleStackIcon } from "@heroicons/react/24/outline"
import React from "react" import React from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import AnimatedCircularProgressBar from "./ui/animated-circular-progress-bar"
interface CycleTransferStatsClientProps { interface CycleTransferStatsClientProps {
name: string name: string
from: string from: string
@ -26,7 +23,7 @@ export const CycleTransferStatsClient: React.FC<CycleTransferStatsClientProps> =
return ( return (
<div <div
className={cn( 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, className,
{ {
"bg-card/70": customBackgroundImage, "bg-card/70": customBackgroundImage,
@ -37,43 +34,41 @@ export const CycleTransferStatsClient: React.FC<CycleTransferStatsClientProps> =
const progress = (transfer / max) * 100 const progress = (transfer / max) * 100
return ( return (
<div key={serverId}> <div key={serverId} className="space-y-3">
<section className="flex justify-between items-center"> {/* Header */}
<div className="bg-green-600 w-fit text-white px-1.5 py-0.5 rounded-full text-[10px]">{name}</div> <div className="flex items-center justify-between">
<span className="text-stone-600 dark:text-stone-400 text-xs"> <span className="text-sm font-medium text-neutral-800 dark:text-neutral-200">{serverName}</span>
{new Date(from).toLocaleDateString()} - {new Date(to).toLocaleDateString()} <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>
</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> </div>
<section className="flex justify-between items-center mt-2"> {/* Progress Section */}
<span className="text-[13px] text-stone-800 dark:text-stone-400 font-medium"> <div className="space-y-1.5">
{formatBytes(transfer)} {t("cycleTransfer.used")} <div className="flex items-center justify-between">
</span> <div className="flex items-baseline gap-1">
<span className="text-xs text-stone-500 dark:text-stone-400 font-normal"> <span className="text-sm font-medium text-neutral-800 dark:text-neutral-200">{formatBytes(transfer)}</span>
{formatBytes(max)} {t("cycleTransfer.total")} <span className="text-xs text-neutral-500 dark:text-neutral-400">/ {formatBytes(max)}</span>
</span> </div>
</section> <span className="text-xs font-medium text-neutral-600 dark:text-neutral-300">{progress.toFixed(1)}%</span>
<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()}
</div> </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> </div>
) )
})} })}

View File

@ -1,6 +1,16 @@
import React from "react" import React from "react"
const Footer: React.FC = () => { 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 ( return (
<footer className="mx-auto w-full max-w-5xl px-4 lg:px-0 pb-4 server-footer"> <footer className="mx-auto w-full max-w-5xl px-4 lg:px-0 pb-4 server-footer">
<section className="flex flex-col"> <section className="flex flex-col">
@ -11,9 +21,16 @@ const Footer: React.FC = () => {
CZL LTD CZL LTD
</a> </a>
</div> </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 All Rights Reserved
</p> </p>
</div>
</section> </section>
</section> </section>
</footer> </footer>

View File

@ -293,7 +293,7 @@ function Overview() {
<div style={{ fontVariantNumeric: "tabular-nums" }} className="flex text-sm font-medium mt-0.5"> <div style={{ fontVariantNumeric: "tabular-nums" }} className="flex text-sm font-medium mt-0.5">
<NumberFlow trend={1} value={time.hh} format={{ minimumIntegerDigits: 2 }} /> <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.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> </div>
</NumberFlowGroup> </NumberFlowGroup>
</div> </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 { ChartConfig, ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
import { fetchMonitor } from "@/lib/nezha-api" import { fetchMonitor } from "@/lib/nezha-api"
import { cn, formatTime } from "@/lib/utils" import { cn, formatTime } from "@/lib/utils"
import { formatRelativeTime } from "@/lib/utils"
import { NezhaMonitor, ServerMonitorChart } from "@/types/nezha-api" import { NezhaMonitor, ServerMonitorChart } from "@/types/nezha-api"
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import * as React from "react" 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 customBackgroundImage = (window.CustomBackgroundImage as string) !== "" ? window.CustomBackgroundImage : undefined
const forcePeakCutEnabled = (window.ForcePeakCutEnabled as boolean) ?? false
const [activeChart, setActiveChart] = React.useState(defaultChart) const [activeChart, setActiveChart] = React.useState(defaultChart)
const [isPeakEnabled, setIsPeakEnabled] = React.useState(false) const [isPeakEnabled, setIsPeakEnabled] = React.useState(forcePeakCutEnabled)
const handleButtonClick = useCallback( const handleButtonClick = useCallback(
(chart: string) => { (chart: string) => {
@ -264,12 +265,36 @@ export const NetworkChartClient = React.memo(function NetworkChart({
<CartesianGrid vertical={false} /> <CartesianGrid vertical={false} />
<XAxis <XAxis
dataKey="created_at" dataKey="created_at"
tickLine={false} tickLine={true}
tickSize={3}
axisLine={false} axisLine={false}
tickMargin={8} tickMargin={8}
minTickGap={32} minTickGap={80}
interval={"preserveStartEnd"} ticks={processedData
tickFormatter={(value) => formatRelativeTime(value)} .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`} /> <YAxis tickLine={false} axisLine={false} tickMargin={15} minTickGap={20} tickFormatter={(value) => `${value}ms`} />
<ChartTooltip <ChartTooltip

View File

@ -14,6 +14,7 @@ import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./ui/accordion" 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 }) { export default function ServerDetailOverview({ server_id }: { server_id: string }) {
const { t } = useTranslation() const { t } = useTranslation()
@ -165,17 +166,26 @@ export default function ServerDetailOverview({ server_id }: { server_id: string
) : null} ) : null}
{country_code && ( {country_code && (
<Card className="rounded-[10px] bg-transparent border-none shadow-none"> <TooltipProvider delayDuration={100}>
<CardContent className="px-1.5 py-1"> <Tooltip>
<section className="flex flex-col items-start gap-0.5"> <TooltipTrigger asChild>
<p className="text-xs text-muted-foreground">{t("serverDetail.region")}</p> <Card className="rounded-[10px] bg-transparent border-none shadow-none">
<section className="flex items-start gap-1"> <CardContent className="px-1.5 py-1">
<div className="text-xs text-start">{countries.getName(country_code?.toUpperCase(), "en")}</div> <section className="flex flex-col items-start gap-0.5">
{country_code && <ServerFlag className="text-[11px] -mt-[1px]" country_code={country_code} />} <p className="text-xs text-muted-foreground">{t("serverDetail.region")}</p>
</section> <section className="flex items-start gap-1">
</section> <div className="text-xs text-start">{country_code?.toUpperCase()}</div>
</CardContent> {country_code && <ServerFlag className="text-[11px] -mt-[1px]" country_code={country_code} />}
</Card> </section>
</section>
</CardContent>
</Card>
</TooltipTrigger>
<TooltipContent>
<p>{countries.getName(country_code?.toUpperCase(), "en")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)} )}
</section> </section>
<section className="flex flex-wrap gap-2 mt-1"> <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 processServiceData = (serviceData: ServiceData) => {
const days = serviceData.up.map((up, index) => ({ const days = serviceData.up.map((up, index) => {
completed: up > serviceData.down[index], const totalChecks = up + serviceData.down[index]
date: new Date(Date.now() - (29 - index) * 24 * 60 * 60 * 1000), 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 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) 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 { cn } from "@/lib/utils"
import React from "react" import React from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
@ -8,6 +9,8 @@ interface ServiceTrackerProps {
days: Array<{ days: Array<{
completed: boolean completed: boolean
date?: Date date?: Date
uptime: number
delay: number
}> }>
className?: string className?: string
title?: string title?: string
@ -18,6 +21,25 @@ interface ServiceTrackerProps {
export const ServiceTrackerClient: React.FC<ServiceTrackerProps> = ({ days, className, title, uptime = 100, avgDelay = 0 }) => { export const ServiceTrackerClient: React.FC<ServiceTrackerProps> = ({ days, className, title, uptime = 100, avgDelay = 0 }) => {
const { t } = useTranslation() const { t } = useTranslation()
const customBackgroundImage = (window.CustomBackgroundImage as string) !== "" ? window.CustomBackgroundImage : undefined 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 ( return (
<div <div
className={cn( className={cn(
@ -30,27 +52,58 @@ export const ServiceTrackerClient: React.FC<ServiceTrackerProps> = ({ days, clas
> >
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="flex items-center gap-2"> <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={cn("w-2.5 h-2.5 rounded-full transition-colors", getStatusColor(uptime))} />
<div className="w-3 h-3 rounded-full bg-white dark:bg-black" />
</div>
<span className="font-medium text-sm">{title}</span> <span className="font-medium text-sm">{title}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-3">
<span className="text-stone-600 dark:text-stone-400 font-medium text-sm">{avgDelay.toFixed(0)}ms</span> <span className={cn("font-medium text-sm transition-colors", getDelayColor(avgDelay))}>{avgDelay.toFixed(0)}ms</span>
<Separator className="h-4 mx-0" orientation="vertical" /> <Separator className="h-4" orientation="vertical" />
<span className="text-green-600 font-medium text-sm"> <span className={cn("font-medium text-sm transition-colors", getUptimeColor(uptime))}>
{uptime.toFixed(1)}% {t("serviceTracker.uptime")} {uptime.toFixed(1)}% {t("serviceTracker.uptime")}
</span> </span>
</div> </div>
</div> </div>
<div className="flex gap-[2px]"> <div className="flex gap-[3px] bg-muted/30 p-1 rounded-lg">
{days.map((day, index) => ( {days.map((day, index) => (
<div <TooltipProvider delayDuration={50} key={index}>
key={index} <Tooltip>
className={cn("flex-1 h-6 rounded-[5px] transition-colors", day.completed ? "bg-green-600" : "bg-red-500/60")} <TooltipTrigger asChild>
title={day.date ? day.date.toLocaleDateString() : `Day ${index + 1}`} <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> </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 { interface Window {
CustomBackgroundImage: string CustomBackgroundImage: string
CustomMobileBackgroundImage: 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 months = 12
break break
case "季": case "季":
case "q":
case "qr":
case "quarterly": case "quarterly":
cycleLabel = "季" cycleLabel = "季"
months = 3 months = 3

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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