From 203438242a84e387e67eb61c2520ed916da9b7d5 Mon Sep 17 00:00:00 2001 From: Ryrenz <163799701+ryrenz@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:05:41 -0400 Subject: [PATCH 01/13] feat: show GPU memory under the GPU chart (#92) Renders state.gpus[i] when the agent provides it, using the existing formatBytes helper so it reads like the memory card. Older agents omit the field and nothing changes. Refs nezhahq/nezha#1235 --- src/components/ServerDetailChart.tsx | 14 ++++++++++++++ src/types/nezha-api.ts | 6 ++++++ 2 files changed, 20 insertions(+) diff --git a/src/components/ServerDetailChart.tsx b/src/components/ServerDetailChart.tsx index 2a829e4..c22c671 100644 --- a/src/components/ServerDetailChart.tsx +++ b/src/components/ServerDetailChart.tsx @@ -274,6 +274,8 @@ export default function ServerDetailChart({ const gpuStats = server.state.gpu || []; const gpuList = server.host.gpu || []; + // Newer agents also report memory per card, index-aligned with the above. + const gpuDetails = server.state.gpus || []; return (
@@ -298,6 +300,7 @@ export default function ServerDetailChart({ now={nezhaWsData.now} gpuStat={gpuStats[index]} gpuName={gpu} + gpuMemory={gpuDetails[index]} messageHistory={messageHistory} period={selectedPeriod} key={index} @@ -311,6 +314,7 @@ export default function ServerDetailChart({ now={nezhaWsData.now} gpuStat={gpu} gpuName={`#${index + 1}`} + gpuMemory={gpuDetails[index]} messageHistory={messageHistory} period={selectedPeriod} key={index} @@ -426,6 +430,7 @@ function GpuChart({ index, gpuStat, gpuName, + gpuMemory, messageHistory, period, }: { @@ -434,6 +439,7 @@ function GpuChart({ index: number; gpuStat: number; gpuName?: string; + gpuMemory?: { memory_used?: number; memory_total?: number }; messageHistory: NezhaWebsocketResponse[]; period: ChartPeriod; }) { @@ -532,6 +538,14 @@ function GpuChart({
{!gpuName &&

GPU

} {gpuName &&

GPU: {gpuName}

} + {gpuMemory?.memory_total ? ( +

+ {gpuMemory.memory_used != null + ? formatBytes(gpuMemory.memory_used * 1024 * 1024) + : "—"}{" "} + / {formatBytes(gpuMemory.memory_total * 1024 * 1024)} +

+ ) : null}

diff --git a/src/types/nezha-api.ts b/src/types/nezha-api.ts index 7431130..4f8f6f8 100644 --- a/src/types/nezha-api.ts +++ b/src/types/nezha-api.ts @@ -45,6 +45,12 @@ export interface NezhaServerStatus { process_count: number; temperatures: temperature[]; gpu: number[]; + // Per-card figures; index-aligned with host.gpu. Absent on older agents. + gpus?: { + utilization: number; + memory_used?: number; + memory_total?: number; + }[]; } interface temperature { From 3e082beb79280017689e920a6aa2122501f90d47 Mon Sep 17 00:00:00 2001 From: hamster1963 <1410514192@qq.com> Date: Mon, 14 Sep 2026 10:06:17 +0800 Subject: [PATCH 02/13] fix(translations): update Indonesian and Brazilian Portuguese translation files --- src/locales/id/translation.json | 338 ++++++++++++++--------------- src/locales/pt_BR/translation.json | 330 ++++++++++++++-------------- 2 files changed, 334 insertions(+), 334 deletions(-) diff --git a/src/locales/id/translation.json b/src/locales/id/translation.json index b7a6079..c7cfda6 100644 --- a/src/locales/id/translation.json +++ b/src/locales/id/translation.json @@ -1,171 +1,171 @@ { - "nezha": "Pemantauan Nezha", - "overview": "Ikhtisar", - "dashboard": "Dasbor", - "login": "Masuk", - "online": "Daring", - "offline": "Luring", - "whereTheTimeIs": "Di mana waktunya", - "refreshing": "Menyegarkan", - "info": { - "websocketConnecting": "WebSocket menghubungkan", - "processing": "Memproses...", - "websocketDisconnected": "WebSocket terputus", - "websocketConnected": "WebSocket terhubung", - "noServers": "Tidak ada server yang tersedia", - "noMatchingServers": "Tidak ada server yang cocok dengan filter saat ini" - }, - "serverOverview": { - "totalServers": "Total Server", - "onlineServers": "Server Daring", - "offlineServers": "Server Luring", - "speed": "Kecepatan", - "totalBandwidth": "Total Bandwidth", - "network": "Jaringan" - }, - "theme": { - "light": "Terang", - "dark": "Gelap", - "system": "Sistem" - }, - "map": { - "Distributions": "Server didistribusikan di", - "Regions": "Wilayah", - "Servers": "server" - }, - "serverCard": { - "mem": "MEM", - "stg": "STG", - "days": "Hari", - "hours": "Jam", - "upload": "Unggah", - "download": "Unduh", - "system": "Sistem", - "uptime": "Waktu aktif", - "totalUpload": "Unggah", - "totalDownload": "Unduh" - }, - "cycleTransfer": { - "used": "digunakan", - "total": "total", - "nextUpdate": "pembaruan berikutnya" - }, - "serviceTracker": { - "noService": "Tidak ada data layanan", - "uptime": "Waktu aktif", - "delay": "Tunda", - "daysAgo": "hari yang lalu", - "today": "Hari ini", - "loading": "Memuat..." - }, - "serverDetail": { - "status": "Status", - "online": "Daring", - "days": "Hari", - "hours": "Jam", - "offline": "Luring", - "unknown": "Tidak diketahui", - "uptime": "Waktu aktif", - "version": "Versi", - "arch": "Arsitektur", - "mem": "Memori", - "disk": "Disk", - "region": "Wilayah", - "system": "Sistem", - "upload": "Unggah", - "download": "Unduh", - "lastActive": "Waktu aktif terakhir", - "temperature": "Suhu", - "bootTime": "Waktu boot" - }, - "serverDetailChart": { - "process": "Proses", - "disk": "Disk", - "mem": "Memori", - "swap": "Swap", - "upload": "Unggah", - "download": "Unduh", - "realtime": "Waktu nyata", - "period1d": "1 Hari", - "period7d": "7 Hari", - "period30d": "30 Hari", - "tsdbRequired": "Aktifkan TSDB untuk menggunakan data historis", - "loginRequired": "Silakan masuk untuk melihat" - }, - "footer": { - "themeBy": "Tema oleh " - }, - "language": { - "zh-CN": "简体中文", - "zh-TW": "繁體中文", - "en-US": "Inggris", - "de-DE": "Jerman", - "es-ES": "Spanyol", - "ru-RU": "Rusia", - "ta-IN": "Tamil" - }, - "error": { - "pageNotFound": "Halaman tidak ditemukan", - "backToHome": "Kembali ke beranda", - "backendUnavailableTitle": "API backend tidak tersedia", - "backendUnavailableDescription": "Halaman masih tersedia, tetapi data tidak dapat dimuat saat ini." - }, - "tabSwitch": { - "Detail": "Detail", - "Network": "Jaringan" - }, - "monitor": { - "noData": "Tidak ada data pemantauan server, harap tambahkan pemantauan layanan terlebih dahulu", - "avgDelay": "Latensi", - "monitorCount": "Layanan", - "packetLoss": "Kehilangan Paket", - "clearSelections": "Bersihkan", - "peakCut": "Pemotongan puncak", - "loginRequired": "Silakan masuk untuk melihat", - "period1d": "1 Hari", - "period7d": "7 Hari", - "period30d": "30 Hari" - }, - "pwa": { - "offlineReady": "Aplikasi siap bekerja luring", - "newContent": "Konten baru tersedia", - "reload": "Perbarui" - }, - "billingInfo": { - "remaining": "Tersisa", - "error": "galat", - "indefinite": "Tak terbatas", - "expired": "Kedaluwarsa", - "days": "hari", - "price": "Harga", - "free": "Gratis", - "usage-baseed": "Berdasarkan penggunaan" - }, - "TypeCommand": "Ketik perintah atau cari...", - "NoResults": "Tidak ada hasil ditemukan.", - "Servers": "Server", - "Shortcuts": "Jalan pintas", - "ToggleLightMode": "Alihkan Mode Terang", - "ToggleDarkMode": "Alihkan Mode Gelap", - "ToggleSystemMode": "Alihkan Mode Sistem", - "Home": "Beranda", - "sort": { - "label": "Urutkan", - "types": { - "default": "Default", - "name": "Nama", - "uptime": "Waktu aktif (uptime)", - "system": "Sistem", - "cpu": "CPU", - "mem": "Mem", - "disk": "Disk", - "up": "Unggah", - "down": "Unduh", - "up_total": "Total Unggah", - "down_total": "Total Unduh" - } - }, - "group": { - "all": "Semua" - } + "nezha": "Pemantauan Nezha", + "overview": "Ikhtisar", + "dashboard": "Dasbor", + "login": "Masuk", + "online": "Daring", + "offline": "Luring", + "whereTheTimeIs": "Di mana waktunya", + "refreshing": "Menyegarkan", + "info": { + "websocketConnecting": "WebSocket menghubungkan", + "processing": "Memproses...", + "websocketDisconnected": "WebSocket terputus", + "websocketConnected": "WebSocket terhubung", + "noServers": "Tidak ada server yang tersedia", + "noMatchingServers": "Tidak ada server yang cocok dengan filter saat ini" + }, + "serverOverview": { + "totalServers": "Total Server", + "onlineServers": "Server Daring", + "offlineServers": "Server Luring", + "speed": "Kecepatan", + "totalBandwidth": "Total Bandwidth", + "network": "Jaringan" + }, + "theme": { + "light": "Terang", + "dark": "Gelap", + "system": "Sistem" + }, + "map": { + "Distributions": "Server didistribusikan di", + "Regions": "Wilayah", + "Servers": "server" + }, + "serverCard": { + "mem": "MEM", + "stg": "STG", + "days": "Hari", + "hours": "Jam", + "upload": "Unggah", + "download": "Unduh", + "system": "Sistem", + "uptime": "Waktu aktif", + "totalUpload": "Unggah", + "totalDownload": "Unduh" + }, + "cycleTransfer": { + "used": "digunakan", + "total": "total", + "nextUpdate": "pembaruan berikutnya" + }, + "serviceTracker": { + "noService": "Tidak ada data layanan", + "uptime": "Waktu aktif", + "delay": "Tunda", + "daysAgo": "hari yang lalu", + "today": "Hari ini", + "loading": "Memuat..." + }, + "serverDetail": { + "status": "Status", + "online": "Daring", + "days": "Hari", + "hours": "Jam", + "offline": "Luring", + "unknown": "Tidak diketahui", + "uptime": "Waktu aktif", + "version": "Versi", + "arch": "Arsitektur", + "mem": "Memori", + "disk": "Disk", + "region": "Wilayah", + "system": "Sistem", + "upload": "Unggah", + "download": "Unduh", + "lastActive": "Waktu aktif terakhir", + "temperature": "Suhu", + "bootTime": "Waktu boot" + }, + "serverDetailChart": { + "process": "Proses", + "disk": "Disk", + "mem": "Memori", + "swap": "Swap", + "upload": "Unggah", + "download": "Unduh", + "realtime": "Waktu nyata", + "period1d": "1 Hari", + "period7d": "7 Hari", + "period30d": "30 Hari", + "tsdbRequired": "Aktifkan TSDB untuk menggunakan data historis", + "loginRequired": "Silakan masuk untuk melihat" + }, + "footer": { + "themeBy": "Tema oleh " + }, + "language": { + "zh-CN": "简体中文", + "zh-TW": "繁體中文", + "en-US": "Inggris", + "de-DE": "Jerman", + "es-ES": "Spanyol", + "ru-RU": "Rusia", + "ta-IN": "Tamil" + }, + "error": { + "pageNotFound": "Halaman tidak ditemukan", + "backToHome": "Kembali ke beranda", + "backendUnavailableTitle": "API backend tidak tersedia", + "backendUnavailableDescription": "Halaman masih tersedia, tetapi data tidak dapat dimuat saat ini." + }, + "tabSwitch": { + "Detail": "Detail", + "Network": "Jaringan" + }, + "monitor": { + "noData": "Tidak ada data pemantauan server, harap tambahkan pemantauan layanan terlebih dahulu", + "avgDelay": "Latensi", + "monitorCount": "Layanan", + "packetLoss": "Kehilangan Paket", + "clearSelections": "Bersihkan", + "peakCut": "Pemotongan puncak", + "loginRequired": "Silakan masuk untuk melihat", + "period1d": "1 Hari", + "period7d": "7 Hari", + "period30d": "30 Hari" + }, + "pwa": { + "offlineReady": "Aplikasi siap bekerja luring", + "newContent": "Konten baru tersedia", + "reload": "Perbarui" + }, + "billingInfo": { + "remaining": "Tersisa", + "error": "galat", + "indefinite": "Tak terbatas", + "expired": "Kedaluwarsa", + "days": "hari", + "price": "Harga", + "free": "Gratis", + "usage-baseed": "Berdasarkan penggunaan" + }, + "TypeCommand": "Ketik perintah atau cari...", + "NoResults": "Tidak ada hasil ditemukan.", + "Servers": "Server", + "Shortcuts": "Jalan pintas", + "ToggleLightMode": "Alihkan Mode Terang", + "ToggleDarkMode": "Alihkan Mode Gelap", + "ToggleSystemMode": "Alihkan Mode Sistem", + "Home": "Beranda", + "sort": { + "label": "Urutkan", + "types": { + "default": "Default", + "name": "Nama", + "uptime": "Waktu aktif (uptime)", + "system": "Sistem", + "cpu": "CPU", + "mem": "Mem", + "disk": "Disk", + "up": "Unggah", + "down": "Unduh", + "up_total": "Total Unggah", + "down_total": "Total Unduh" + } + }, + "group": { + "all": "Semua" + } } diff --git a/src/locales/pt_BR/translation.json b/src/locales/pt_BR/translation.json index e53a8f7..9368fd5 100644 --- a/src/locales/pt_BR/translation.json +++ b/src/locales/pt_BR/translation.json @@ -1,167 +1,167 @@ { - "overview": "Visão geral", - "dashboard": "Painel de controle", - "whereTheTimeIs": "Onde está a hora", - "refreshing": "Atualizando", - "info": { - "websocketConnecting": "Conectando WebSocket", - "websocketConnected": "WebSocket conectado", - "websocketDisconnected": "WebSocket desconectado", - "processing": "Processando...", - "noServers": "Nenhum servidor disponível", - "noMatchingServers": "Nenhum servidor corresponde aos filtros atuais" - }, - "serverOverview": { - "totalServers": "Total de Servidores", - "onlineServers": "Servidores Conectados", - "offlineServers": "Servidores Desconectados", - "totalBandwidth": "Largura de Banda Total", - "speed": "Velocidade", - "network": "Rede" - }, - "map": { - "Distributions": "Servidores estão distribuídos em", - "Regions": "Regiões", - "Servers": "servidores" - }, - "serverCard": { - "mem": "MEM", - "stg": "STG", - "days": "Dias", - "hours": "Horas", - "upload": "Upload", - "download": "Download", - "system": "Sistema", - "uptime": "Disponibilidade", - "totalUpload": "Envio total", - "totalDownload": "Recebimento total" - }, - "nezha": "Nezha Monitoramento", - "login": "Login", - "online": "Online", - "offline": "Offline", - "cycleTransfer": { - "used": "Usado", - "total": "Total", - "nextUpdate": "Próxima atualização" - }, - "serviceTracker": { - "noService": "Sem dados do serviço", - "uptime": "Disponibilidade", - "delay": "Atraso", - "daysAgo": "Dias anteriores", - "today": "Hoje", - "loading": "Carregando..." - }, - "serverDetail": { - "status": "Status", - "online": "Online", - "days": "Dias", - "hours": "Horas", - "offline": "Offline", - "unknown": "Desconhecido", - "uptime": "Disponibilidade", - "version": "Versão", - "arch": "Arquitetura", - "mem": "Memória", - "disk": "Armazenamento", - "region": "Região", - "system": "Sistema", - "upload": "Envio", - "download": "Recebimento", - "lastActive": "Ultima atividade", - "temperature": "Temperatura", - "bootTime": "Tempo de inicio" - }, - "serverDetailChart": { - "process": "Processo", - "disk": "Armazenamento", - "mem": "Memória", - "swap": "Swap", - "upload": "Envio", - "download": "Recebimento", - "realtime": "Em tempo real", - "period1d": "1 Dia", - "period7d": "7 Dias", - "period30d": "30 Dias", - "tsdbRequired": "Habilite o TSDB para usar dados históricos", - "loginRequired": "Por favor entre para visualizar" - }, - "footer": { - "themeBy": "Tema por " - }, - "language": { - "zh-CN": "Chinês simplificado", - "zh-TW": "Chinês Tradicional", - "en-US": "Inglês", - "de-DE": "Alemão", - "es-ES": "Espanhol", - "ru-RU": "Russo", - "ta-IN": "Tamil Indiano" - }, - "theme": { - "light": "Claro", - "dark": "Escuro", - "system": "Sistema" - }, - "error": { - "pageNotFound": "Página não encontrada", - "backToHome": "Voltar ao inicio", - "backendUnavailableTitle": "API Backend indisponível", - "backendUnavailableDescription": "A página ainda está disponível, mas os dados não podem ser carregados agora." - }, - "tabSwitch": { - "Detail": "Detalhes", - "Network": "Rede" - }, - "monitor": { - "noData": "Sem servidores para monitorar. por favor adicione um primeiro monitor de serviço", - "avgDelay": "Latência", - "monitorCount": "Serviços", - "packetLoss": "Perda de pacotes", - "clearSelections": "Limpar", - "peakCut": "Corte de pico", - "loginRequired": "Por favor entre para visualizar", - "period1d": "1 Dia", - "period7d": "7 Dias", - "period30d": "30 Dias" - }, - "pwa": { - "offlineReady": "Aplicativo pronto para funcionar offline", - "newContent": "Novo conteúdo disponivel", - "reload": "Atualizar" - }, - "billingInfo": { - "remaining": "Restante", - "error": "Erro", - "indefinite": "Indefinido", - "expired": "Expirado", - "days": "dias", - "price": "Preço", - "free": "Grátis", - "usage-baseed": "Baseado no uso" - }, - "TypeCommand": "Digite um comando ou pesquisa...", - "NoResults": "Nenhum resultado encontrado.", - "Servers": "Servidores", - "Shortcuts": "Atalhos", - "ToggleLightMode": "Habilitar tema claro", - "ToggleDarkMode": "habilitar tema escuro", - "ToggleSystemMode": "Habilitar modo do sistema", - "Home": "Inicio", - "sort": { - "label": "Organizar", - "types": { - "default": "Padrão", - "name": "Nome", - "uptime": "Disponibilidade", - "system": "Sistema", - "cpu": "CPU", - "mem": "Mem", - "disk": "Disco" - } - }, - "group": { - "all": "Todos" - } + "overview": "Visão geral", + "dashboard": "Painel de controle", + "whereTheTimeIs": "Onde está a hora", + "refreshing": "Atualizando", + "info": { + "websocketConnecting": "Conectando WebSocket", + "websocketConnected": "WebSocket conectado", + "websocketDisconnected": "WebSocket desconectado", + "processing": "Processando...", + "noServers": "Nenhum servidor disponível", + "noMatchingServers": "Nenhum servidor corresponde aos filtros atuais" + }, + "serverOverview": { + "totalServers": "Total de Servidores", + "onlineServers": "Servidores Conectados", + "offlineServers": "Servidores Desconectados", + "totalBandwidth": "Largura de Banda Total", + "speed": "Velocidade", + "network": "Rede" + }, + "map": { + "Distributions": "Servidores estão distribuídos em", + "Regions": "Regiões", + "Servers": "servidores" + }, + "serverCard": { + "mem": "MEM", + "stg": "STG", + "days": "Dias", + "hours": "Horas", + "upload": "Upload", + "download": "Download", + "system": "Sistema", + "uptime": "Disponibilidade", + "totalUpload": "Envio total", + "totalDownload": "Recebimento total" + }, + "nezha": "Nezha Monitoramento", + "login": "Login", + "online": "Online", + "offline": "Offline", + "cycleTransfer": { + "used": "Usado", + "total": "Total", + "nextUpdate": "Próxima atualização" + }, + "serviceTracker": { + "noService": "Sem dados do serviço", + "uptime": "Disponibilidade", + "delay": "Atraso", + "daysAgo": "Dias anteriores", + "today": "Hoje", + "loading": "Carregando..." + }, + "serverDetail": { + "status": "Status", + "online": "Online", + "days": "Dias", + "hours": "Horas", + "offline": "Offline", + "unknown": "Desconhecido", + "uptime": "Disponibilidade", + "version": "Versão", + "arch": "Arquitetura", + "mem": "Memória", + "disk": "Armazenamento", + "region": "Região", + "system": "Sistema", + "upload": "Envio", + "download": "Recebimento", + "lastActive": "Ultima atividade", + "temperature": "Temperatura", + "bootTime": "Tempo de inicio" + }, + "serverDetailChart": { + "process": "Processo", + "disk": "Armazenamento", + "mem": "Memória", + "swap": "Swap", + "upload": "Envio", + "download": "Recebimento", + "realtime": "Em tempo real", + "period1d": "1 Dia", + "period7d": "7 Dias", + "period30d": "30 Dias", + "tsdbRequired": "Habilite o TSDB para usar dados históricos", + "loginRequired": "Por favor entre para visualizar" + }, + "footer": { + "themeBy": "Tema por " + }, + "language": { + "zh-CN": "Chinês simplificado", + "zh-TW": "Chinês Tradicional", + "en-US": "Inglês", + "de-DE": "Alemão", + "es-ES": "Espanhol", + "ru-RU": "Russo", + "ta-IN": "Tamil Indiano" + }, + "theme": { + "light": "Claro", + "dark": "Escuro", + "system": "Sistema" + }, + "error": { + "pageNotFound": "Página não encontrada", + "backToHome": "Voltar ao inicio", + "backendUnavailableTitle": "API Backend indisponível", + "backendUnavailableDescription": "A página ainda está disponível, mas os dados não podem ser carregados agora." + }, + "tabSwitch": { + "Detail": "Detalhes", + "Network": "Rede" + }, + "monitor": { + "noData": "Sem servidores para monitorar. por favor adicione um primeiro monitor de serviço", + "avgDelay": "Latência", + "monitorCount": "Serviços", + "packetLoss": "Perda de pacotes", + "clearSelections": "Limpar", + "peakCut": "Corte de pico", + "loginRequired": "Por favor entre para visualizar", + "period1d": "1 Dia", + "period7d": "7 Dias", + "period30d": "30 Dias" + }, + "pwa": { + "offlineReady": "Aplicativo pronto para funcionar offline", + "newContent": "Novo conteúdo disponivel", + "reload": "Atualizar" + }, + "billingInfo": { + "remaining": "Restante", + "error": "Erro", + "indefinite": "Indefinido", + "expired": "Expirado", + "days": "dias", + "price": "Preço", + "free": "Grátis", + "usage-baseed": "Baseado no uso" + }, + "TypeCommand": "Digite um comando ou pesquisa...", + "NoResults": "Nenhum resultado encontrado.", + "Servers": "Servidores", + "Shortcuts": "Atalhos", + "ToggleLightMode": "Habilitar tema claro", + "ToggleDarkMode": "habilitar tema escuro", + "ToggleSystemMode": "Habilitar modo do sistema", + "Home": "Inicio", + "sort": { + "label": "Organizar", + "types": { + "default": "Padrão", + "name": "Nome", + "uptime": "Disponibilidade", + "system": "Sistema", + "cpu": "CPU", + "mem": "Mem", + "disk": "Disco" + } + }, + "group": { + "all": "Todos" + } } From 58fd74df143ea631addda06b003cb5946c56a722 Mon Sep 17 00:00:00 2001 From: hamster1963 <1410514192@qq.com> Date: Mon, 14 Sep 2026 11:38:48 +0800 Subject: [PATCH 03/13] feat(translations): add support for French, Galician, Indonesian, Japanese, Brazilian Portuguese, Romanian, and Ukrainian languages --- src/components/LanguageSwitcher.tsx | 7 +++++++ src/i18n.js | 28 ++++++++++++++++++++++++++++ src/locales/en/translation.json | 9 ++++++++- 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/components/LanguageSwitcher.tsx b/src/components/LanguageSwitcher.tsx index 1d7cd2f..1319ea0 100644 --- a/src/components/LanguageSwitcher.tsx +++ b/src/components/LanguageSwitcher.tsx @@ -34,6 +34,13 @@ export function LanguageSwitcher() { { name: t("language.es-ES"), code: "es-ES" }, { name: t("language.de-DE"), code: "de-DE" }, { name: t("language.ta-IN"), code: "ta-IN" }, + { name: t("language.fr"), code: "fr" }, + { name: t("language.gl"), code: "gl" }, + { name: t("language.id"), code: "id" }, + { name: t("language.ja"), code: "ja" }, + { name: t("language.pt_BR"), code: "pt_BR" }, + { name: t("language.ro"), code: "ro" }, + { name: t("language.uk"), code: "uk" }, ]; return ( diff --git a/src/i18n.js b/src/i18n.js index 052053f..c9c2d9f 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -4,8 +4,15 @@ import { initReactI18next } from "react-i18next"; import deTranslation from "./locales/de/translation.json"; import enTranslation from "./locales/en/translation.json"; import esTranslation from "./locales/es/translation.json"; +import frTranslation from "./locales/fr/translation.json"; +import glTranslation from "./locales/gl/translation.json"; +import idTranslation from "./locales/id/translation.json"; +import jaTranslation from "./locales/ja/translation.json"; +import ptBRTranslation from "./locales/pt_BR/translation.json"; +import roTranslation from "./locales/ro/translation.json"; import ruTranslation from "./locales/ru/translation.json"; import taTranslation from "./locales/ta/translation.json"; +import ukTranslation from "./locales/uk/translation.json"; import zhCNTranslation from "./locales/zh-CN/translation.json"; import zhTWTranslation from "./locales/zh-TW/translation.json"; @@ -25,12 +32,33 @@ const resources = { "es-ES": { translation: esTranslation, }, + fr: { + translation: frTranslation, + }, + gl: { + translation: glTranslation, + }, + id: { + translation: idTranslation, + }, + ja: { + translation: jaTranslation, + }, + pt_BR: { + translation: ptBRTranslation, + }, + ro: { + translation: roTranslation, + }, "ru-RU": { translation: ruTranslation, }, "ta-IN": { translation: taTranslation, }, + uk: { + translation: ukTranslation, + }, }; const getStoredLanguage = () => { diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index fa6c67f..6cdbd61 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -97,7 +97,14 @@ "de-DE": "Deutsch", "es-ES": "Español", "ru-RU": "Русский", - "ta-IN": "தமிழ்" + "ta-IN": "தமிழ்", + "fr": "Français", + "gl": "Galego", + "id": "Bahasa Indonesia", + "ja": "日本語", + "pt_BR": "Português (Brasil)", + "ro": "Română", + "uk": "Українська" }, "theme": { "light": "Light", From cd070d57dac800fb7a6a6e119a7db35066587149 Mon Sep 17 00:00:00 2001 From: hamster1963 <1410514192@qq.com> Date: Mon, 14 Sep 2026 11:54:31 +0800 Subject: [PATCH 04/13] chore: update version to 2.4.3 in package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4a373f7..8fa41cf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nazha-dash-v2", "private": true, - "version": "2.0.2", + "version": "2.4.3", "type": "module", "engines": { "node": ">=22", From 74a52e8cb063587328988f0672bd729a4e703023 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Sun, 4 Oct 2026 12:45:36 +0200 Subject: [PATCH 05/13] Translations update from Hosted Weblate (#94) Co-authored-by: Lee Vincent --- src/locales/zh-TW/translation.json | 337 +++++++++++++++-------------- 1 file changed, 172 insertions(+), 165 deletions(-) diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index 00dec72..f8e7dc0 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -1,167 +1,174 @@ { - "nezha": "哪吒監控", - "overview": "概覽", - "dashboard": "管理後台", - "login": "登錄", - "online": "在線", - "offline": "離線", - "whereTheTimeIs": "目前時間", - "refreshing": "刷新中", - "info": { - "websocketConnecting": "WebSocket 連線中", - "websocketConnected": "WebSocket 連線成功", - "websocketDisconnected": "WebSocket 連線中斷", - "processing": "處理中...", - "noServers": "暫無伺服器", - "noMatchingServers": "沒有符合目前篩選條件的伺服器" - }, - "serverOverview": { - "totalServers": "總伺服器", - "onlineServers": "線上伺服器", - "offlineServers": "離線伺服器", - "totalBandwidth": "總帶寬", - "speed": "速率", - "network": "網路" - }, - "map": { - "Distributions": "伺服器分布在", - "Regions": "個區域", - "Servers": "個伺服器" - }, - "serverCard": { - "mem": "內存", - "stg": "存儲", - "days": "天", - "hours": "小時", - "upload": "上傳", - "download": "下載", - "system": "系統", - "uptime": "運行時間", - "totalUpload": "總上傳", - "totalDownload": "總下載" - }, - "cycleTransfer": { - "used": "已使用", - "total": "總量", - "nextUpdate": "下次更新" - }, - "serviceTracker": { - "noService": "無服務數據", - "uptime": "在線率", - "delay": "延遲", - "daysAgo": "天前", - "today": "今天", - "loading": "載入中..." - }, - "serverDetail": { - "status": "狀態", - "online": "線上", - "days": "天", - "hours": "小時", - "offline": "離線", - "unknown": "未知", - "uptime": "運行時間", - "version": "版本", - "arch": "架構", - "mem": "內存", - "disk": "磁盤", - "region": "地區", - "system": "系統", - "upload": "上傳", - "download": "下載", - "lastActive": "最後上報時間", - "temperature": "溫度", - "bootTime": "啟動時間" - }, - "serverDetailChart": { - "process": "進程數", - "disk": "磁盤", - "mem": "內存", - "swap": "虛擬記憶體", - "upload": "上傳", - "download": "下載" - }, - "footer": { - "themeBy": "主題-" - }, - "language": { - "zh-CN": "简体中文", - "zh-TW": "繁體中文", - "en-US": "English", - "de-DE": "德文", - "es-ES": "西班牙文", - "ru-RU": "俄文", - "ta-IN": "தமிழ்" - }, - "theme": { - "light": "亮色", - "dark": "暗色", - "system": "跟隨系統" - }, - "error": { - "pageNotFound": "頁面不存在", - "backToHome": "回到主頁", - "backendUnavailableTitle": "後端 API 無法存取", - "backendUnavailableDescription": "頁面仍會正常顯示,但暫時無法載入監控資料。" - }, - "tabSwitch": { - "detail": "詳細資訊", - "network": "網路", - "Detail": "詳細資訊", - "Network": "網路" - }, - "monitor": { - "noData": "沒有服務監控數據,請在管理後台服務新增監控任務", - "status": "狀態", - "avgDelay": "延遲", - "monitorCount": "個監控", - "packetLoss": "丟包率", - "clearSelections": "清除", - "peakCut": "削峰", - "period1d": "1 天", - "period7d": "7 天", - "period30d": "30 天" - }, - "billingInfo": { - "remaining": "剩餘天數", - "error": "獲取失敗", - "indefinite": "無限期", - "expired": "已過期", - "days": "天", - "price": "價格", - "free": "免費", - "usage-baseed": "按量計費" - }, - "TypeCommand": "輸入命令或搜尋", - "NoResults": "沒有結果", - "Servers": "伺服器", - "Shortcuts": "快捷鍵", - "ToggleLightMode": "切換亮色模式", - "ToggleDarkMode": "切換暗色模式", - "ToggleSystemMode": "切換系統模式", - "Home": "首頁", - "pwa": { - "offlineReady": "可離線使用之應用程式", - "newContent": "有新内容可用", - "reload": "更新" - }, - "sort": { - "label": "排序", - "types": { - "default": "預設", - "name": "名稱", - "uptime": "運行時間", - "system": "系統", - "cpu": "CPU", - "mem": "記憶體", - "disk": "磁碟", - "up": "上傳", - "down": "下載", - "up_total": "上傳總量", - "down_total": "下載總量" - } - }, - "group": { - "all": "全部" - } + "nezha": "哪吒監控", + "overview": "概覽", + "dashboard": "管理後台", + "login": "登入", + "online": "線上", + "offline": "離線", + "whereTheTimeIs": "目前時間", + "refreshing": "重新整理中", + "info": { + "websocketConnecting": "WebSocket 連線中", + "websocketConnected": "WebSocket 連線成功", + "websocketDisconnected": "WebSocket 連線中斷", + "processing": "處理中...", + "noServers": "暫無伺服器", + "noMatchingServers": "沒有符合目前篩選條件的伺服器" + }, + "serverOverview": { + "totalServers": "總伺服器", + "onlineServers": "線上伺服器", + "offlineServers": "離線伺服器", + "totalBandwidth": "總頻寬", + "speed": "速率", + "network": "網路" + }, + "map": { + "Distributions": "伺服器分布在", + "Regions": "個區域", + "Servers": "個伺服器" + }, + "serverCard": { + "mem": "記憶體", + "stg": "儲存空間", + "days": "天", + "hours": "小時", + "upload": "上傳", + "download": "下載", + "system": "系統", + "uptime": "運作時間", + "totalUpload": "總上傳", + "totalDownload": "總下載" + }, + "cycleTransfer": { + "used": "已使用", + "total": "總量", + "nextUpdate": "下次更新" + }, + "serviceTracker": { + "noService": "無服務資料", + "uptime": "上線率", + "delay": "延遲", + "daysAgo": "天前", + "today": "今天", + "loading": "載入中..." + }, + "serverDetail": { + "status": "狀態", + "online": "線上", + "days": "天", + "hours": "小時", + "offline": "離線", + "unknown": "未知", + "uptime": "運作時間", + "version": "版本", + "arch": "架構", + "mem": "記憶體", + "disk": "磁碟", + "region": "地區", + "system": "系統", + "upload": "上傳", + "download": "下載", + "lastActive": "最後回報時間", + "temperature": "溫度", + "bootTime": "啟動時間" + }, + "serverDetailChart": { + "process": "程序數", + "disk": "磁碟", + "mem": "記憶體", + "swap": "虛擬記憶體", + "upload": "上傳", + "download": "下載", + "realtime": "即時", + "period1d": "1 天", + "period7d": "7 天", + "period30d": "30 天", + "tsdbRequired": "請啟用 TSDB 以使用歷史資料", + "loginRequired": "請先登入才能查看" + }, + "footer": { + "themeBy": "主題-" + }, + "language": { + "zh-CN": "简体中文", + "zh-TW": "繁體中文", + "en-US": "English", + "de-DE": "德文", + "es-ES": "西班牙文", + "ru-RU": "俄文", + "ta-IN": "தமிழ்" + }, + "theme": { + "light": "亮色", + "dark": "暗色", + "system": "跟隨系統" + }, + "error": { + "pageNotFound": "頁面不存在", + "backToHome": "回到首頁", + "backendUnavailableTitle": "後端 API 無法存取", + "backendUnavailableDescription": "頁面仍會正常顯示,但暫時無法載入監控資料。" + }, + "tabSwitch": { + "detail": "詳細資訊", + "network": "網路", + "Detail": "詳細資訊", + "Network": "網路" + }, + "monitor": { + "noData": "沒有伺服器監控資料,請先新增服務監控", + "status": "狀態", + "avgDelay": "延遲", + "monitorCount": "個監控", + "packetLoss": "封包遺失率", + "clearSelections": "清除", + "peakCut": "削峰", + "period1d": "1 天", + "period7d": "7 天", + "period30d": "30 天", + "loginRequired": "請先登入才能查看" + }, + "billingInfo": { + "remaining": "剩餘天數", + "error": "取得失敗", + "indefinite": "無限期", + "expired": "已過期", + "days": "天", + "price": "價格", + "free": "免費", + "usage-baseed": "按量計費" + }, + "TypeCommand": "輸入命令或搜尋", + "NoResults": "沒有結果", + "Servers": "伺服器", + "Shortcuts": "快捷鍵", + "ToggleLightMode": "切換亮色模式", + "ToggleDarkMode": "切換暗色模式", + "ToggleSystemMode": "切換系統模式", + "Home": "首頁", + "pwa": { + "offlineReady": "可離線使用之應用程式", + "newContent": "有新內容可用", + "reload": "更新" + }, + "sort": { + "label": "排序", + "types": { + "default": "預設", + "name": "名稱", + "uptime": "運作時間", + "system": "系統", + "cpu": "CPU", + "mem": "記憶體", + "disk": "磁碟", + "up": "上傳", + "down": "下載", + "up_total": "上傳總量", + "down_total": "下載總量" + } + }, + "group": { + "all": "全部" + } } From d4fe4e58069243394e2b398154bf5ca1c17a311a Mon Sep 17 00:00:00 2001 From: CyL <110070957+CyL-Cly@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:08:42 +0800 Subject: [PATCH 06/13] perf: bundle flag icons instead of blocking on a CDN (#99) index.html loaded two jsdelivr stylesheets synchronously in , which delayed first paint and failed outright on networks where the CDN is unreachable. Drop font-logos (unused in src/) and bundle flag-icons locally so Vite hashes the sprites and serves them from our own origin with immutable cache headers. --- index.html | 2 -- package.json | 1 + pnpm-lock.yaml | 29 ++++++++++++++++++++--------- src/main.tsx | 4 ++++ 4 files changed, 25 insertions(+), 11 deletions(-) diff --git a/index.html b/index.html index ec0ec58..5251854 100644 --- a/index.html +++ b/index.html @@ -72,8 +72,6 @@ - -

diff --git a/package.json b/package.json index 8fa41cf..f65e27d 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "country-flag-icons": "1.6.17", "d3-geo": "3.1.1", "dayjs": "1.11.21", + "flag-icons": "7.0.0", "i18n-iso-countries": "7.14.0", "i18next": "26.3.1", "lucide-react": "1.17.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 439e576..11c1e1c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: dayjs: specifier: 1.11.21 version: 1.11.21 + flag-icons: + specifier: 7.0.0 + version: 7.0.0 i18n-iso-countries: specifier: 7.14.0 version: 7.14.0 @@ -128,13 +131,13 @@ importers: version: 4.3.2 '@testing-library/jest-dom': specifier: ^6.9.1 - version: 6.9.1 + version: 6.10.0(@testing-library/dom@10.4.1) '@testing-library/react': specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@testing-library/user-event': specifier: ^14.6.1 - version: 14.6.1(@testing-library/dom@10.4.1) + version: 14.6.8(@testing-library/dom@10.4.1) '@types/node': specifier: 25.9.2 version: 25.9.2 @@ -1125,9 +1128,11 @@ packages: resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} - '@testing-library/jest-dom@6.9.1': - resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} - engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + '@testing-library/jest-dom@6.10.0': + resolution: {integrity: sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + peerDependencies: + '@testing-library/dom': '>=10 <11' '@testing-library/react@16.3.2': resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} @@ -1144,8 +1149,8 @@ packages: '@types/react-dom': optional: true - '@testing-library/user-event@14.6.1': - resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + '@testing-library/user-event@14.6.8': + resolution: {integrity: sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==} engines: {node: '>=12', npm: '>=6'} peerDependencies: '@testing-library/dom': '>=7.21.4' @@ -1566,6 +1571,9 @@ packages: picomatch: optional: true + flag-icons@7.0.0: + resolution: {integrity: sha512-U8nkYAJUHDro4SiKjJgnVJnAr6yMHWG7k6+FnV73dnWf/HkQXOZLxkie8qd4ZWR83+L6hQITEWJYgApN4sfUZA==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3032,9 +3040,10 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/jest-dom@6.9.1': + '@testing-library/jest-dom@6.10.0(@testing-library/dom@10.4.1)': dependencies: '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.1 aria-query: 5.3.2 css.escape: 1.5.1 dom-accessibility-api: 0.6.3 @@ -3051,7 +3060,7 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + '@testing-library/user-event@14.6.8(@testing-library/dom@10.4.1)': dependencies: '@testing-library/dom': 10.4.1 @@ -3388,6 +3397,8 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + flag-icons@7.0.0: {} + fsevents@2.3.3: optional: true diff --git a/src/main.tsx b/src/main.tsx index f7bf293..c0ababf 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -13,6 +13,10 @@ import { TooltipProvider } from "./context/tooltip-provider"; import { WebSocketProvider } from "./context/websocket-provider"; import "./i18n"; import "./index.css"; +// Bundled instead of loaded from jsdelivr: the CDN in index.html was +// render-blocking and unreliable on mainland networks. Vite hashes the flag +// sprites into assets/, so they inherit the immutable cache headers. +import "flag-icons/css/flag-icons.min.css"; const queryClient = new QueryClient(); const ReactQueryDevtools = import.meta.env.DEV From 2750d9858a7e1161fa94256b03111548987f59b5 Mon Sep 17 00:00:00 2001 From: Galaxy Roaming <45086632+apple-ouyang@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:09:17 +0800 Subject: [PATCH 07/13] fix: prompt login when guest API returns unauthorized (#98) When force_auth is on, the guest homepage still loads, but /api/v1/server returns {"error":"ApiErrorUnauthorized"}. BackendErrorState treated that the same as a down backend. Match that error exactly and show a login title, description, and a button to /dashboard. Other errors keep the old copy and the raw message. zh-TW is unchanged: that file uses spaces, and biome would reformat the whole translation. --- src/lib/nezha-api.ts | 8 +++++++ src/locales/en/translation.json | 4 +++- src/locales/id/translation.json | 4 +++- src/locales/pt_BR/translation.json | 4 +++- src/locales/zh-CN/translation.json | 4 +++- src/pages/Server.tsx | 34 ++++++++++++++++++++++------ src/test/lib/nezha-api.test.ts | 7 ++++++ src/test/pages/Server.test.tsx | 36 +++++++++++++++++++++++++++++- 8 files changed, 89 insertions(+), 12 deletions(-) diff --git a/src/lib/nezha-api.ts b/src/lib/nezha-api.ts index 175d1ce..32a1546 100644 --- a/src/lib/nezha-api.ts +++ b/src/lib/nezha-api.ts @@ -9,6 +9,14 @@ import type { SettingResponse, } from "@/types/nezha-api"; +export const API_ERROR_UNAUTHORIZED = "ApiErrorUnauthorized"; + +export function isUnauthorizedError(error: unknown): boolean { + const message = + error instanceof Error ? error.message : error == null ? "" : String(error); + return message === API_ERROR_UNAUTHORIZED; +} + let lastestRefreshTokenAt = 0; const getCookieValue = (name: string): string | undefined => { diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 6cdbd61..0b6a474 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -115,7 +115,9 @@ "pageNotFound": "Page not found", "backToHome": "Back to home", "backendUnavailableTitle": "Backend API unavailable", - "backendUnavailableDescription": "The page is still available, but data cannot be loaded right now." + "backendUnavailableDescription": "The page is still available, but data cannot be loaded right now.", + "loginRequiredTitle": "Login required", + "loginRequiredDescription": "This site requires a login to load monitoring data." }, "tabSwitch": { "Detail": "Detail", diff --git a/src/locales/id/translation.json b/src/locales/id/translation.json index c7cfda6..7cb3e74 100644 --- a/src/locales/id/translation.json +++ b/src/locales/id/translation.json @@ -108,7 +108,9 @@ "pageNotFound": "Halaman tidak ditemukan", "backToHome": "Kembali ke beranda", "backendUnavailableTitle": "API backend tidak tersedia", - "backendUnavailableDescription": "Halaman masih tersedia, tetapi data tidak dapat dimuat saat ini." + "backendUnavailableDescription": "Halaman masih tersedia, tetapi data tidak dapat dimuat saat ini.", + "loginRequiredTitle": "Perlu masuk", + "loginRequiredDescription": "Situs ini memerlukan login untuk memuat data pemantauan." }, "tabSwitch": { "Detail": "Detail", diff --git a/src/locales/pt_BR/translation.json b/src/locales/pt_BR/translation.json index 9368fd5..745af41 100644 --- a/src/locales/pt_BR/translation.json +++ b/src/locales/pt_BR/translation.json @@ -108,7 +108,9 @@ "pageNotFound": "Página não encontrada", "backToHome": "Voltar ao inicio", "backendUnavailableTitle": "API Backend indisponível", - "backendUnavailableDescription": "A página ainda está disponível, mas os dados não podem ser carregados agora." + "backendUnavailableDescription": "A página ainda está disponível, mas os dados não podem ser carregados agora.", + "loginRequiredTitle": "Login necessário", + "loginRequiredDescription": "Este site exige login para carregar os dados de monitoramento." }, "tabSwitch": { "Detail": "Detalhes", diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index 04b967a..bc421ce 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -109,7 +109,9 @@ "pageNotFound": "页面不存在", "backToHome": "回到主页", "backendUnavailableTitle": "后端 API 无法访问", - "backendUnavailableDescription": "页面仍会正常显示,但暂时无法加载监控数据。" + "backendUnavailableDescription": "页面仍会正常显示,但暂时无法加载监控数据。", + "loginRequiredTitle": "需要登录", + "loginRequiredDescription": "当前站点已开启强制登录,登录后即可加载监控数据。" }, "tabSwitch": { "Detail": "详情", diff --git a/src/pages/Server.tsx b/src/pages/Server.tsx index 20cc1a9..dcca4bf 100644 --- a/src/pages/Server.tsx +++ b/src/pages/Server.tsx @@ -17,11 +17,16 @@ import ServerCard from "@/components/ServerCard"; import ServerCardInline from "@/components/ServerCardInline"; import ServerOverview from "@/components/ServerOverview"; import { ServiceTracker } from "@/components/ServiceTracker"; +import { Button } from "@/components/ui/button"; import { SORT_TYPES } from "@/context/sort-context"; import { useSort } from "@/hooks/use-sort"; import { useStatus } from "@/hooks/use-status"; import { useWebSocketContext } from "@/hooks/use-websocket-context"; -import { fetchServerGroup, fetchService } from "@/lib/nezha-api"; +import { + fetchServerGroup, + fetchService, + isUnauthorizedError, +} from "@/lib/nezha-api"; import { cn } from "@/lib/utils"; import type { NezhaServer, ServerGroup } from "@/types/nezha-api"; @@ -53,21 +58,36 @@ const getErrorMessage = (error: unknown) => { function BackendErrorState({ error }: { error: unknown }) { const { t } = useTranslation(); + const unauthorized = isUnauthorizedError(error); const message = getErrorMessage(error); return (

- {t("error.backendUnavailableTitle")} + {t( + unauthorized + ? "error.loginRequiredTitle" + : "error.backendUnavailableTitle", + )}

- {t("error.backendUnavailableDescription")} + {t( + unauthorized + ? "error.loginRequiredDescription" + : "error.backendUnavailableDescription", + )}

- {message && ( -

- {message} -

+ {unauthorized ? ( + + ) : ( + message && ( +

+ {message} +

+ ) )}
diff --git a/src/test/lib/nezha-api.test.ts b/src/test/lib/nezha-api.test.ts index 6e17fe3..6e0792d 100644 --- a/src/test/lib/nezha-api.test.ts +++ b/src/test/lib/nezha-api.test.ts @@ -6,6 +6,7 @@ import { fetchServerMetrics, fetchService, fetchSetting, + isUnauthorizedError, } from "@/lib/nezha-api"; const jsonResponse = (body: unknown, init?: ResponseInit) => @@ -52,6 +53,12 @@ describe("nezha api fetchers", () => { await expect(fetchService()).rejects.toThrow("service unavailable"); }); + it("detects guest-auth errors from the dashboard API", () => { + expect(isUnauthorizedError(new Error("ApiErrorUnauthorized"))).toBe(true); + expect(isUnauthorizedError(new Error("settings failed"))).toBe(false); + expect(isUnauthorizedError(null)).toBe(false); + }); + it("adds monitor and metrics query parameters when periods are provided", async () => { vi.mocked(fetch) .mockResolvedValueOnce(jsonResponse({ success: true, data: [] })) diff --git a/src/test/pages/Server.test.tsx b/src/test/pages/Server.test.tsx index 1410718..a54c151 100644 --- a/src/test/pages/Server.test.tsx +++ b/src/test/pages/Server.test.tsx @@ -16,7 +16,15 @@ const apiMocks = vi.hoisted(() => ({ fetchService: vi.fn(), })); -vi.mock("@/lib/nezha-api", () => apiMocks); +vi.mock("@/lib/nezha-api", async () => { + const actual = + await vi.importActual("@/lib/nezha-api"); + return { + ...actual, + fetchServerGroup: apiMocks.fetchServerGroup, + fetchService: apiMocks.fetchService, + }; +}); vi.mock("@/components/GlobalMap", () => ({ default: ({ serverList }: { serverList: NezhaServer[] }) => ( @@ -217,6 +225,32 @@ describe("Servers page", () => { expect(screen.getByText("group failed")).toBeInTheDocument(); }); + it("prompts guests to log in when the API returns ApiErrorUnauthorized", async () => { + apiMocks.fetchServerGroup.mockRejectedValue( + new Error("ApiErrorUnauthorized"), + ); + + renderServerPage({ + connected: false, + lastData: null, + }); + + expect( + await screen.findByText("error.loginRequiredTitle"), + ).toBeInTheDocument(); + expect( + screen.getByText("error.loginRequiredDescription"), + ).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "login" })).toHaveAttribute( + "href", + "/dashboard", + ); + expect( + screen.queryByText("error.backendUnavailableTitle"), + ).not.toBeInTheDocument(); + expect(screen.queryByText("ApiErrorUnauthorized")).not.toBeInTheDocument(); + }); + it("summarizes online and offline servers from websocket data", async () => { const online = createServer({ id: 1, name: "alpha" }); const offline = createServer({ From 2a250378fe80cc7521d77b47ba20a361cff3f262 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BB=93=E9=BC=A0?= <71394853+hamster1963@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:56:43 +0800 Subject: [PATCH 08/13] perf(home): reduce continuous homepage animations (#100) * perf(home): remove continuous status and clock animations * chore: auto-fix linting and formatting issues --- src/components/Header.tsx | 13 +- src/components/ServerOverview.tsx | 2 - src/locales/zh-TW/translation.json | 344 ++++++++++++++--------------- 3 files changed, 181 insertions(+), 178 deletions(-) diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 5534689..c9e6f78 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -12,7 +12,6 @@ import { useWebSocketContext } from "@/hooks/use-websocket-context"; import { fetchLoginUser, fetchSetting } from "@/lib/nezha-api"; import { cn } from "@/lib/utils"; -import AnimateCountClient from "./AnimatedCount"; import { LanguageSwitcher } from "./LanguageSwitcher"; import { Loader, LoadingSpinner } from "./loading/Loader"; import NumericText from "./NumericText"; @@ -358,12 +357,18 @@ function Overview() {

{t("whereTheTimeIs")}

{mounted ? (
- + + {String(time.hh).padStart(2, "0")} + : - + + {String(time.mm).padStart(2, "0")} + : - + + {String(time.ss).padStart(2, "0")} +
) : ( diff --git a/src/components/ServerOverview.tsx b/src/components/ServerOverview.tsx index ed80fcc..c5fd0c2 100644 --- a/src/components/ServerOverview.tsx +++ b/src/components/ServerOverview.tsx @@ -91,7 +91,6 @@ export default function ServerOverview({

- @@ -121,7 +120,6 @@ export default function ServerOverview({

- diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index f8e7dc0..d88d4a6 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -1,174 +1,174 @@ { - "nezha": "哪吒監控", - "overview": "概覽", - "dashboard": "管理後台", - "login": "登入", - "online": "線上", - "offline": "離線", - "whereTheTimeIs": "目前時間", - "refreshing": "重新整理中", - "info": { - "websocketConnecting": "WebSocket 連線中", - "websocketConnected": "WebSocket 連線成功", - "websocketDisconnected": "WebSocket 連線中斷", - "processing": "處理中...", - "noServers": "暫無伺服器", - "noMatchingServers": "沒有符合目前篩選條件的伺服器" - }, - "serverOverview": { - "totalServers": "總伺服器", - "onlineServers": "線上伺服器", - "offlineServers": "離線伺服器", - "totalBandwidth": "總頻寬", - "speed": "速率", - "network": "網路" - }, - "map": { - "Distributions": "伺服器分布在", - "Regions": "個區域", - "Servers": "個伺服器" - }, - "serverCard": { - "mem": "記憶體", - "stg": "儲存空間", - "days": "天", - "hours": "小時", - "upload": "上傳", - "download": "下載", - "system": "系統", - "uptime": "運作時間", - "totalUpload": "總上傳", - "totalDownload": "總下載" - }, - "cycleTransfer": { - "used": "已使用", - "total": "總量", - "nextUpdate": "下次更新" - }, - "serviceTracker": { - "noService": "無服務資料", - "uptime": "上線率", - "delay": "延遲", - "daysAgo": "天前", - "today": "今天", - "loading": "載入中..." - }, - "serverDetail": { - "status": "狀態", - "online": "線上", - "days": "天", - "hours": "小時", - "offline": "離線", - "unknown": "未知", - "uptime": "運作時間", - "version": "版本", - "arch": "架構", - "mem": "記憶體", - "disk": "磁碟", - "region": "地區", - "system": "系統", - "upload": "上傳", - "download": "下載", - "lastActive": "最後回報時間", - "temperature": "溫度", - "bootTime": "啟動時間" - }, - "serverDetailChart": { - "process": "程序數", - "disk": "磁碟", - "mem": "記憶體", - "swap": "虛擬記憶體", - "upload": "上傳", - "download": "下載", - "realtime": "即時", - "period1d": "1 天", - "period7d": "7 天", - "period30d": "30 天", - "tsdbRequired": "請啟用 TSDB 以使用歷史資料", - "loginRequired": "請先登入才能查看" - }, - "footer": { - "themeBy": "主題-" - }, - "language": { - "zh-CN": "简体中文", - "zh-TW": "繁體中文", - "en-US": "English", - "de-DE": "德文", - "es-ES": "西班牙文", - "ru-RU": "俄文", - "ta-IN": "தமிழ்" - }, - "theme": { - "light": "亮色", - "dark": "暗色", - "system": "跟隨系統" - }, - "error": { - "pageNotFound": "頁面不存在", - "backToHome": "回到首頁", - "backendUnavailableTitle": "後端 API 無法存取", - "backendUnavailableDescription": "頁面仍會正常顯示,但暫時無法載入監控資料。" - }, - "tabSwitch": { - "detail": "詳細資訊", - "network": "網路", - "Detail": "詳細資訊", - "Network": "網路" - }, - "monitor": { - "noData": "沒有伺服器監控資料,請先新增服務監控", - "status": "狀態", - "avgDelay": "延遲", - "monitorCount": "個監控", - "packetLoss": "封包遺失率", - "clearSelections": "清除", - "peakCut": "削峰", - "period1d": "1 天", - "period7d": "7 天", - "period30d": "30 天", - "loginRequired": "請先登入才能查看" - }, - "billingInfo": { - "remaining": "剩餘天數", - "error": "取得失敗", - "indefinite": "無限期", - "expired": "已過期", - "days": "天", - "price": "價格", - "free": "免費", - "usage-baseed": "按量計費" - }, - "TypeCommand": "輸入命令或搜尋", - "NoResults": "沒有結果", - "Servers": "伺服器", - "Shortcuts": "快捷鍵", - "ToggleLightMode": "切換亮色模式", - "ToggleDarkMode": "切換暗色模式", - "ToggleSystemMode": "切換系統模式", - "Home": "首頁", - "pwa": { - "offlineReady": "可離線使用之應用程式", - "newContent": "有新內容可用", - "reload": "更新" - }, - "sort": { - "label": "排序", - "types": { - "default": "預設", - "name": "名稱", - "uptime": "運作時間", - "system": "系統", - "cpu": "CPU", - "mem": "記憶體", - "disk": "磁碟", - "up": "上傳", - "down": "下載", - "up_total": "上傳總量", - "down_total": "下載總量" - } - }, - "group": { - "all": "全部" - } + "nezha": "哪吒監控", + "overview": "概覽", + "dashboard": "管理後台", + "login": "登入", + "online": "線上", + "offline": "離線", + "whereTheTimeIs": "目前時間", + "refreshing": "重新整理中", + "info": { + "websocketConnecting": "WebSocket 連線中", + "websocketConnected": "WebSocket 連線成功", + "websocketDisconnected": "WebSocket 連線中斷", + "processing": "處理中...", + "noServers": "暫無伺服器", + "noMatchingServers": "沒有符合目前篩選條件的伺服器" + }, + "serverOverview": { + "totalServers": "總伺服器", + "onlineServers": "線上伺服器", + "offlineServers": "離線伺服器", + "totalBandwidth": "總頻寬", + "speed": "速率", + "network": "網路" + }, + "map": { + "Distributions": "伺服器分布在", + "Regions": "個區域", + "Servers": "個伺服器" + }, + "serverCard": { + "mem": "記憶體", + "stg": "儲存空間", + "days": "天", + "hours": "小時", + "upload": "上傳", + "download": "下載", + "system": "系統", + "uptime": "運作時間", + "totalUpload": "總上傳", + "totalDownload": "總下載" + }, + "cycleTransfer": { + "used": "已使用", + "total": "總量", + "nextUpdate": "下次更新" + }, + "serviceTracker": { + "noService": "無服務資料", + "uptime": "上線率", + "delay": "延遲", + "daysAgo": "天前", + "today": "今天", + "loading": "載入中..." + }, + "serverDetail": { + "status": "狀態", + "online": "線上", + "days": "天", + "hours": "小時", + "offline": "離線", + "unknown": "未知", + "uptime": "運作時間", + "version": "版本", + "arch": "架構", + "mem": "記憶體", + "disk": "磁碟", + "region": "地區", + "system": "系統", + "upload": "上傳", + "download": "下載", + "lastActive": "最後回報時間", + "temperature": "溫度", + "bootTime": "啟動時間" + }, + "serverDetailChart": { + "process": "程序數", + "disk": "磁碟", + "mem": "記憶體", + "swap": "虛擬記憶體", + "upload": "上傳", + "download": "下載", + "realtime": "即時", + "period1d": "1 天", + "period7d": "7 天", + "period30d": "30 天", + "tsdbRequired": "請啟用 TSDB 以使用歷史資料", + "loginRequired": "請先登入才能查看" + }, + "footer": { + "themeBy": "主題-" + }, + "language": { + "zh-CN": "简体中文", + "zh-TW": "繁體中文", + "en-US": "English", + "de-DE": "德文", + "es-ES": "西班牙文", + "ru-RU": "俄文", + "ta-IN": "தமிழ்" + }, + "theme": { + "light": "亮色", + "dark": "暗色", + "system": "跟隨系統" + }, + "error": { + "pageNotFound": "頁面不存在", + "backToHome": "回到首頁", + "backendUnavailableTitle": "後端 API 無法存取", + "backendUnavailableDescription": "頁面仍會正常顯示,但暫時無法載入監控資料。" + }, + "tabSwitch": { + "detail": "詳細資訊", + "network": "網路", + "Detail": "詳細資訊", + "Network": "網路" + }, + "monitor": { + "noData": "沒有伺服器監控資料,請先新增服務監控", + "status": "狀態", + "avgDelay": "延遲", + "monitorCount": "個監控", + "packetLoss": "封包遺失率", + "clearSelections": "清除", + "peakCut": "削峰", + "period1d": "1 天", + "period7d": "7 天", + "period30d": "30 天", + "loginRequired": "請先登入才能查看" + }, + "billingInfo": { + "remaining": "剩餘天數", + "error": "取得失敗", + "indefinite": "無限期", + "expired": "已過期", + "days": "天", + "price": "價格", + "free": "免費", + "usage-baseed": "按量計費" + }, + "TypeCommand": "輸入命令或搜尋", + "NoResults": "沒有結果", + "Servers": "伺服器", + "Shortcuts": "快捷鍵", + "ToggleLightMode": "切換亮色模式", + "ToggleDarkMode": "切換暗色模式", + "ToggleSystemMode": "切換系統模式", + "Home": "首頁", + "pwa": { + "offlineReady": "可離線使用之應用程式", + "newContent": "有新內容可用", + "reload": "更新" + }, + "sort": { + "label": "排序", + "types": { + "default": "預設", + "name": "名稱", + "uptime": "運作時間", + "system": "系統", + "cpu": "CPU", + "mem": "記憶體", + "disk": "磁碟", + "up": "上傳", + "down": "下載", + "up_total": "上傳總量", + "down_total": "下載總量" + } + }, + "group": { + "all": "全部" + } } From c59c18de17dc47c8c0335303667128f8d02753f4 Mon Sep 17 00:00:00 2001 From: Sam5440 <47051833+Sam5440@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:01:21 +0800 Subject: [PATCH 09/13] feat: add server online history tab with date-grouped chip wall (#97) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(server-detail): add online history tab Render per-server online periods from GET /api/v1/server/:id/online-history: summary card (current status, online rate, total in window, last active), daily online-time bars, and the raw period list. Window selector offers 1d/7d/30d and locks options beyond the server's max_days; guests hitting the 1d cap see a login hint. i18n keys added for all locales (zh-CN/zh-TW translated, others fall back to English for Weblate). * fix(online-history): keep selector pills above the sliding indicator The active-indicator overlay sits at z-10 and intercepted clicks on the day pills; wrap pills at z-20 like the existing PeriodSelector. * feat(online-history): render daily online as 24h timeline with hover tooltips 每日在线卡片从整日占比进度条改为 0:00–24:00 整轴时间条: 每个在线时段按当日时刻定位为高亮分段,hover 显示起止时间与时长; 进行中时段以亮色高亮。卡片顶部新增共享刻度(0:00 / 12:00 / 24:00)。 * feat(online-history): regroup periods by day as expandable chip wall Rework the Online tab per the chip-wall design: periods are grouped by panel-timezone day with per-day totals, defaulting to the latest day with "Expand more" revealing up to ten more groups per click, plus an Online/Offline switch that derives offline chips as the window complement. - respond to the backend panel timezone (timezone/tz_offset): dayjs 1.11 .tz(input, zone) ignores explicit offsets, so offset-bearing timestamps are parsed first and converted afterwards; naive date strings keep the zone-parse path (panelDay) - guests falling back automatically: a rejected days>1 request locks 7d/30d and retries at 1d instead of surfacing only a login prompt - fix day-selector indicator landing on the wrong pill: useActiveIndicator refs are stored by index, pass the map index not the day value - disable Node experimental webstorage in vitest workers: Node >=25 ships a broken global localStorage that shadows the jsdom one - 14 locales: tabSwitch.Online + serverOnlineHistory.* * feat(online-history): boxed summary stats with desktop/mobile layouts * fix(online-history): align offline gaps, DST coordinates and ongoing chips - extract pure timeline/grouping helpers into src/lib/online-history.ts - clamp offline complements to day and window boundaries, so a gap can no longer run into a later day's first online session - use wall-clock coordinates and true next-midnight boundaries on DST days - label only the segment ending at the live session end as Ongoing - keep group totals equal to the visible chips for cross-day sessions - keep period chips on one line and render next-midnight as 24:00 - add regression tests for multi-day offline gaps, window_start/window_end, spring-forward/fall-back geometry and ongoing clipping * style(online-history): lighten summary metrics and shorten rate bar * feat(online-history): refine UI and unify daily history rendering --------- Co-authored-by: Sam5440 Co-authored-by: hamster1963 <1410514192@qq.com> --- README.md | 25 + scripts/mock-nezha-server.js | 138 ++- src/components/ServerDetailOnlineHistory.tsx | 784 ++++++++++++++++++ .../loading/OnlineHistorySkeleton.tsx | 138 +++ src/hooks/use-media-query.ts | 31 + src/lib/nezha-api.ts | 16 + src/lib/online-history.ts | 398 +++++++++ src/locales/de/translation.json | 21 +- src/locales/en/translation.json | 28 +- src/locales/es/translation.json | 21 +- src/locales/fr/translation.json | 21 + src/locales/gl/translation.json | 23 +- src/locales/id/translation.json | 21 +- src/locales/ja/translation.json | 21 + src/locales/pt_BR/translation.json | 21 +- src/locales/ro/translation.json | 23 +- src/locales/ru/translation.json | 21 +- src/locales/ta/translation.json | 21 +- src/locales/uk/translation.json | 23 +- src/locales/zh-CN/translation.json | 28 +- src/locales/zh-TW/translation.json | 28 +- src/pages/ServerDetail.tsx | 14 +- .../server-detail-online-history.test.tsx | 446 ++++++++++ src/test/lib/online-history.test.ts | 270 ++++++ src/types/nezha-api.ts | 33 + vite.config.ts | 10 + 26 files changed, 2604 insertions(+), 20 deletions(-) create mode 100644 src/components/ServerDetailOnlineHistory.tsx create mode 100644 src/components/loading/OnlineHistorySkeleton.tsx create mode 100644 src/hooks/use-media-query.ts create mode 100644 src/lib/online-history.ts create mode 100644 src/test/components/server-detail-online-history.test.tsx create mode 100644 src/test/lib/online-history.test.ts diff --git a/README.md b/README.md index c793e10..fa0bcde 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,28 @@ > [!NOTE] > 此项目为 nezha-dash 的官方实现,作为哪吒监控的默认前端,功能上可能与 nezha-dash 有所不同。 > https://github.com/hamster1963/nezha-dash + +## 本地调整 UI + +使用 Node.js >= 22、pnpm >= 11,在项目目录运行: + +```bash +pnpm install +MOCK_NEZHA_COUNT=6 pnpm dev:mock +``` + +该命令同时启动本地 mock API(8008 端口)和 Vite,使用仓库 `.cert` 下的 HTTPS 证书。打开终端显示的地址(通常为 `https://localhost:5173`);若浏览器提示本地证书不受信任,允许访问本地开发地址。 + +进入 `/server/1`,点击「在线 / Online」标签。在线历史支持 1/7/30 天切换、时间轴提示、在线/离线时段和「展开更多」。数据按当前时间生成,使用 `Asia/Shanghai` 时区;查询窗口是最近 N × 24 小时,因此每日列表通常会包含首尾两个不完整日期。 + +| 详情页 | 预览场景 | +| --- | --- | +| `/server/1`、`/server/6` | 正常在线,包含短暂掉线和跨日会话 | +| `/server/2` | 持续在线,在线率 100% | +| `/server/3` | 当前离线,包含进行中的离线时段 | +| `/server/4` | 无在线历史,在线率 0%,最后活跃显示 N/A | +| `/server/5` | 频繁掉线,用于查看密集时段标签和换行 | + +主要样式位于 `src/components/ServerDetailOnlineHistory.tsx` 的 `className`,保存后自动热更新;桌面与手机布局的切换点是 768px。修改 mock 脚本后需重启 `pnpm dev:mock`,按 `Ctrl+C` 可停止前端和 mock 服务。 + +在线历史 mock 允许查询最多 30 天数据,无需真实后端或登录。不要同时运行占用 8008 端口的后端;省略 `MOCK_NEZHA_COUNT` 时仍使用原有的 3000 节点规模。 diff --git a/scripts/mock-nezha-server.js b/scripts/mock-nezha-server.js index 1edd80a..de5c5c3 100644 --- a/scripts/mock-nezha-server.js +++ b/scripts/mock-nezha-server.js @@ -7,6 +7,13 @@ const serverCount = Number(process.env.MOCK_NEZHA_COUNT || 3000); const updateIntervalMs = Number( process.env.MOCK_NEZHA_INTERVAL_MS || (serverCount > 10_000 ? 0 : 2000), ); +const dayMs = 86_400_000; +const minuteMs = 60_000; +const timezoneOffsetMs = 8 * 60 * minuteMs; +const startedAt = Math.floor(Date.now() / 1000) * 1000; + +// 前几个节点提供固定 UI 场景;其余节点保留每 17 个离线一个的规则。 +const isServerOffline = (id) => id === 3 || id === 4 || id % 17 === 0; const countries = [ "US", @@ -40,14 +47,16 @@ function createServer(index) { const memUsed = memTotal * ((index % 90) / 100); const diskUsed = diskTotal * ((index % 84) / 100); const swapUsed = swapTotal ? swapTotal * ((index % 60) / 100) : 0; - const isOffline = id % 17 === 0; + const isOffline = isServerOffline(id); return { id, name: `mock-server-${String(id).padStart(4, "0")}`, public_note: id % 19 === 0 ? "[[Plan]] bandwidth=1Gbps" : "", last_active: isOffline - ? new Date(Date.now() - 10 * 60 * 1000).toISOString() + ? new Date( + startedAt - (id === 4 ? 31 * dayMs : 10 * minuteMs), + ).toISOString() : nowIso(), country_code: countries[index % countries.length], host: { @@ -87,14 +96,16 @@ function createServer(index) { const servers = Array.from({ length: serverCount }, (_, index) => createServer(index), ); -let onlineCount = servers.filter((server) => server.id % 17 !== 0).length; +let onlineCount = servers.filter( + (server) => !isServerOffline(server.id), +).length; function updateServerMetrics() { const now = Date.now(); let nextOnlineCount = 0; for (const server of servers) { - const isOffline = server.id % 17 === 0; + const isOffline = isServerOffline(server.id); if (!isOffline) { server.last_active = new Date(now).toISOString(); nextOnlineCount += 1; @@ -126,8 +137,8 @@ function websocketPayload() { return JSON.stringify(payload); } -function jsonResponse(response, data) { - response.writeHead(200, { +function jsonResponse(response, data, statusCode = 200) { + response.writeHead(statusCode, { "Access-Control-Allow-Origin": "*", "Cache-Control": "no-store", "Content-Type": "application/json; charset=utf-8", @@ -135,6 +146,92 @@ function jsonResponse(response, data) { response.end(JSON.stringify(data)); } +function createOnlineHistory(server, days) { + const windowEnd = Math.floor(Date.now() / 1000) * 1000; + const windowStart = windowEnd - days * dayMs; + const firstDay = + Math.floor((windowStart + timezoneOffsetMs) / dayMs) * dayMs - + timezoneOffsetMs; + const currentlyOnline = !isServerOffline(server.id); + const onlineEnd = currentlyOnline + ? windowEnd + : Date.parse(server.last_active); + const periods = []; + const daily = []; + + const addPeriod = (start, end) => { + if (end <= start) return; + periods.push({ + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + ongoing: currentlyOnline && end === windowEnd, + }); + }; + + // 1/6: 日常短暂掉线;2: 持续在线;3: 当前离线;4: 无历史;5: 密集短时段。 + // 先生成离线间隙,再取补集,跨午夜的在线会话保持连续。 + if (server.id !== 4) { + let cursor = windowStart; + for (let day = firstDay; day < onlineEnd; day += dayMs) { + if (server.id === 2) continue; + const dayIndex = Math.floor((day + timezoneOffsetMs) / dayMs); + const hours = + server.id === 5 + ? [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22] + : [2, 8, 15]; + for (const hour of hours) { + const gapStart = Math.max(windowStart, day + hour * 60 * minuteMs); + const gapMinutes = + server.id === 5 ? 35 : 10 + ((dayIndex + server.id + hour) % 6) * 13; + // 最后活跃前半小时保持在线,让时段终点与 WebSocket 状态一致。 + const gapEnd = Math.min( + day + (hour * 60 + gapMinutes) * minuteMs, + onlineEnd - 30 * minuteMs, + ); + if (gapEnd <= gapStart) continue; + addPeriod(cursor, gapStart); + cursor = gapEnd; + } + } + addPeriod(cursor, onlineEnd); + } + + // 每日数字和概要都从同一组时段计算,避免时间轴与统计不一致。 + for (let day = firstDay; day < windowEnd; day += dayMs) { + const onlineSeconds = periods.reduce((total, period) => { + const start = Math.max(day, Date.parse(period.start)); + const end = Math.min(day + dayMs, Date.parse(period.end)); + return total + Math.max(0, (end - start) / 1000); + }, 0); + daily.push({ + date: new Date(day + timezoneOffsetMs).toISOString().slice(0, 10), + online_seconds: onlineSeconds, + }); + } + const totalOnlineSeconds = daily.reduce( + (total, day) => total + day.online_seconds, + 0, + ); + + return { + server_id: server.id, + server_name: server.name, + days, + max_days: 30, + timezone: "Asia/Shanghai", + window_start: new Date(windowStart).toISOString(), + window_end: new Date(windowEnd).toISOString(), + total_online_seconds: totalOnlineSeconds, + online_rate: + (totalOnlineSeconds / ((windowEnd - windowStart) / 1000)) * 100, + currently_online: currentlyOnline, + last_active: + server.id === 4 ? undefined : new Date(onlineEnd).toISOString(), + periods, + daily, + }; +} + const populatedServerGroups = [ { group: { @@ -211,6 +308,35 @@ const httpServer = http.createServer((request, response) => { return; } + const onlineHistoryMatch = requestUrl.pathname.match( + /^\/api\/v1\/server\/(\d+)\/online-history$/, + ); + if (onlineHistoryMatch) { + const server = servers[Number(onlineHistoryMatch[1]) - 1]; + const days = Number(requestUrl.searchParams.get("days") ?? 7); + if (!server) { + jsonResponse( + response, + { success: false, error: "server not found" }, + 404, + ); + return; + } + if (!Number.isInteger(days) || days < 1 || days > 30) { + jsonResponse( + response, + { success: false, error: "days must be an integer between 1 and 30" }, + 400, + ); + return; + } + jsonResponse(response, { + success: true, + data: createOnlineHistory(server, days), + }); + return; + } + switch (requestUrl.pathname) { case "/api/v1/setting": jsonResponse(response, { diff --git a/src/components/ServerDetailOnlineHistory.tsx b/src/components/ServerDetailOnlineHistory.tsx new file mode 100644 index 0000000..af93f8c --- /dev/null +++ b/src/components/ServerDetailOnlineHistory.tsx @@ -0,0 +1,784 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import OnlineHistorySkeleton from "@/components/loading/OnlineHistorySkeleton"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useActiveIndicator } from "@/hooks/use-active-indicator"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { useWebSocketContext } from "@/hooks/use-websocket-context"; +import { fetchServerOnlineHistory } from "@/lib/nezha-api"; +import { + buildDailyOnlineRows, + buildOfflineGroups, + formatDuration, + formatPeriodMoment, + groupOnlineRows, + panelDay, + panelNow, +} from "@/lib/online-history"; +import { cn } from "@/lib/utils"; + +const DAY_OPTIONS = [1, 7, 30]; +// Online periods 默认展开最近 3 天,每次「展开更多」追加 10 天 +const PERIODS_INITIAL_DAYS = 3; +const PERIODS_EXPAND_STEP = 10; + +type PeriodMode = "online" | "offline"; + +// 与详情页一致的轻量指标标签。 +function SummaryLabel({ children }: { children: ReactNode }) { + return {children}; +} + +// 四项指标共用行高,辅助信息在底部对齐。 +function SummaryMetric({ + label, + children, + footer, +}: { + label: ReactNode; + children: ReactNode; + footer: ReactNode; +}) { + return ( +
+ {label} +
+ {children} +
+
+ {footer} +
+
+ ); +} + +// 概要卡的信息盒:标签在上、数值在下 +function SummaryBox({ + label, + children, +}: { + label: ReactNode; + children: ReactNode; +}) { + return ( +
+ {label} + {children} +
+ ); +} + +function WindowShade({ + left, + width, + label, + striped = false, +}: { + left: number; + width: number; + label: string; + striped?: boolean; +}) { + if (width <= 0) return null; + return ( + + + + + + {t("serverOnlineHistory.dataCutoff")}{" "} + {day.window.end.format("HH:mm:ss")} + + + )} +
+
+ {[0, 6, 12, 18, 24].map((hour) => ( + {hour} + ))} +
+
+ ); + })} + +
+ + + {t("serverOnlineHistory.outsideWindow")} + + + + {t("serverOnlineHistory.notYetRecorded")} + + {t("serverOnlineHistory.dataCutoffHint")} +
+ + + + + + +
+

+ {t( + periodMode === "online" + ? "serverOnlineHistory.periods" + : "serverOnlineHistory.offlinePeriods", + )} +

+ { + setPeriodMode(nextMode); + setVisibleDays(PERIODS_INITIAL_DAYS); + }} + /> +
+ {(() => { + if (!online) { + return null; + } + const groups = periodGroups; + const visibleGroups = groups.slice(0, visibleDays); + if (groups.length === 0) { + return ( +

+ {t("serverOnlineHistory.noData")} +

+ ); + } + return ( + +
+ {visibleGroups.map((group) => ( +
+
+ + {group.day.format("MM-DD")} + {group.day.isSame(panelNow(tz), "day") && ( + + {" · "} + {t("serverOnlineHistory.today")} + + )} + + + {t(periodMode === "online" ? "online" : "offline")}{" "} + {formatDuration(group.total)} + +
+
+ {group.chips.map((chip, index) => { + const seconds = chip.end.diff(chip.start, "second"); + return ( + + + + {chip.ongoing && ( + + )} + {chip.start.format("HH:mm")} + {" → "} + {chip.ongoing + ? t("serverOnlineHistory.ongoing") + : formatPeriodMoment( + chip.end, + chip.start, + tz, + )} + + {formatDuration(seconds)} + + + + + {chip.start.format("HH:mm:ss")} + {" → "} + {chip.ongoing + ? t("serverOnlineHistory.ongoing") + : formatPeriodMoment( + chip.end, + chip.start, + tz, + true, + )} + {" · "} + {formatDuration(seconds)} + {chip.original && + (!chip.start.isSame( + panelDay(chip.original.start, tz), + ) || + !chip.end.isSame( + panelDay(chip.original.end, tz), + )) && ( +
+ {t("serverOnlineHistory.originalSession")}{" "} + {panelDay(chip.original.start, tz).format( + "MM-DD HH:mm:ss", + )} + {" → "} + {chip.original.ongoing + ? t("serverOnlineHistory.ongoing") + : panelDay( + chip.original.end, + tz, + ).format("MM-DD HH:mm:ss")} +
+ )} +
+
+ ); + })} +
+
+ ))} + {groups.length > visibleDays && ( + + )} +
+
+ ); + })()} +
+
+ + ); +} diff --git a/src/components/loading/OnlineHistorySkeleton.tsx b/src/components/loading/OnlineHistorySkeleton.tsx new file mode 100644 index 0000000..c927d68 --- /dev/null +++ b/src/components/loading/OnlineHistorySkeleton.tsx @@ -0,0 +1,138 @@ +import { useTranslation } from "react-i18next"; +import { Card, CardContent } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; + +function Bar({ className }: { className: string }) { + return ( + + ); +} + +function Metric({ footer = true }: { footer?: boolean }) { + return ( +
+
+ +
+ + {footer && ( +
+ +
+ )} +
+ ); +} + +export default function OnlineHistorySkeleton({ days = 7 }: { days?: number }) { + const { t } = useTranslation(); + const dayCount = Math.min(31, Math.max(1, days) + 1); + const cardClassName = cn({ + "bg-card/70": + typeof window !== "undefined" && Boolean(window.CustomBackgroundImage), + }); + + return ( +
+ +
+ ); +} diff --git a/src/hooks/use-media-query.ts b/src/hooks/use-media-query.ts new file mode 100644 index 0000000..5e6d725 --- /dev/null +++ b/src/hooks/use-media-query.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from "react"; + +// matchMedia 不可用(如 jsdom)时默认命中桌面布局,保证测试环境渲染完整概要 +export function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(() => { + if ( + typeof window === "undefined" || + typeof window.matchMedia !== "function" + ) { + return true; + } + return window.matchMedia(query).matches; + }); + + useEffect(() => { + if (typeof window.matchMedia !== "function") { + return; + } + const mediaQueryList = window.matchMedia(query); + const onChange = (event: MediaQueryListEvent) => { + setMatches(event.matches); + }; + setMatches(mediaQueryList.matches); + mediaQueryList.addEventListener("change", onChange); + return () => { + mediaQueryList.removeEventListener("change", onChange); + }; + }, [query]); + + return matches; +} diff --git a/src/lib/nezha-api.ts b/src/lib/nezha-api.ts index 32a1546..a85d3ac 100644 --- a/src/lib/nezha-api.ts +++ b/src/lib/nezha-api.ts @@ -3,6 +3,7 @@ import type { MetricPeriod, MetricType, MonitorResponse, + OnlineHistoryResponse, ServerGroupResponse, ServerMetricsResponse, ServiceResponse, @@ -118,3 +119,18 @@ export const fetchServerMetrics = async ( } return data; }; + +export const fetchServerOnlineHistory = async ( + server_id: number, + days?: number, +): Promise => { + const query = days ? `?days=${days}` : ""; + const response = await fetch( + `/api/v1/server/${server_id}/online-history${query}`, + ); + const data = await response.json(); + if (data.error) { + throw new Error(data.error); + } + return data; +}; diff --git a/src/lib/online-history.ts b/src/lib/online-history.ts new file mode 100644 index 0000000..b6b2ba7 --- /dev/null +++ b/src/lib/online-history.ts @@ -0,0 +1,398 @@ +import dayjs from "dayjs"; +import timezone from "dayjs/plugin/timezone"; +import utc from "dayjs/plugin/utc"; +import type { DailyOnlineStat, OnlinePeriod } from "@/types/nezha-api"; + +dayjs.extend(utc); +dayjs.extend(timezone); + +export type PeriodChip = { + start: dayjs.Dayjs; + end: dayjs.Dayjs; + ongoing: boolean; + original?: OnlinePeriod; +}; + +export type PeriodDayGroup = { + date: string; + day: dayjs.Dayjs; + total: number; + chips: PeriodChip[]; +}; + +export type DaySegment = { + period: OnlinePeriod; + segStart: dayjs.Dayjs; + segEnd: dayjs.Dayjs; + leftPct: number; + widthPct: number; + /** 该段是否仍然进行中(只有结束于真实会话末尾的段才为 true) */ + live: boolean; +}; + +// 后端按面板时区拆日;响应带 timezone 时前端用同一时区解释时间, +// 保证跨时区访客的时间轴、分组与每日数字一致 +export function panelDay(value: string, tz?: string): dayjs.Dayjs { + // dayjs 的 .tz(input, zone) 解析模式会忽略输入里的显式偏移(Z / ±HH:MM): + // 带偏移的时间戳须先按原义解析再换算时区,无偏移的日期串才用 zone 解析 + if (tz) { + return /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value) + ? dayjs(value).tz(tz) + : dayjs.tz(value, tz); + } + return dayjs(value); +} + +export function panelNow(tz?: string): dayjs.Dayjs { + return tz ? dayjs().tz(tz) : dayjs(); +} + +// 下一天的日历日期用 UTC 做纯日期运算,避免 dayjs .add(1, "day") +// 在 DST 切换日加的是 24 小时而不是下一个挂钟零点 +function nextDate(date: string): string { + const [year, month, day] = date.split("-").map(Number); + return new Date(Date.UTC(year, month - 1, day + 1)) + .toISOString() + .slice(0, 10); +} + +export function panelNextDayStart(date: string, tz?: string): dayjs.Dayjs { + return panelDay(nextDate(date), tz); +} + +// 结束于次日零点的段在 chip 上显示为 24:00,避免出现“00:00 → 00:00”这种读不通的全天段 +export function formatPeriodMoment( + value: dayjs.Dayjs, + anchor: dayjs.Dayjs, + tz?: string, + withSeconds = false, +): string { + const dayEnd = panelNextDayStart(anchor.format("YYYY-MM-DD"), tz); + if (value.valueOf() === dayEnd.valueOf()) { + return withSeconds ? "24:00:00" : "24:00"; + } + return value.format(withSeconds ? "HH:mm:ss" : "HH:mm"); +} + +export function formatDuration(seconds: number): string { + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (d > 0) { + return `${d}d ${h}h ${m}m`; + } + if (h > 0) { + return `${h}h ${m}m`; + } + return `${m}m`; +} + +// 时间轴刻度是固定的挂钟 0:00–24:00,定位必须用面板时区的挂钟时间; +// 用 elapsed seconds / 86400 会在 DST 切换日(23h/25h)偏移 +function wallClockPercent(value: dayjs.Dayjs): number { + const seconds = + value.hour() * 3600 + + value.minute() * 60 + + value.second() + + value.millisecond() / 1000; + return (seconds / 86400) * 100; +} + +// 每天实际纳入统计的区间;挂钟坐标用于绘图,秒数用于时长/在线率。 +export function buildDayWindow( + date: string, + windowStart: string, + windowEnd: string, + tz?: string, +) { + const dayStart = panelDay(date, tz); + const dayEnd = panelNextDayStart(date, tz); + const start = panelDay(windowStart, tz); + const end = panelDay(windowEnd, tz); + const effectiveStart = + start.valueOf() > dayStart.valueOf() ? start : dayStart; + const effectiveEnd = end.valueOf() < dayEnd.valueOf() ? end : dayEnd; + const percent = (value: dayjs.Dayjs) => + value.valueOf() < dayStart.valueOf() || + value.valueOf() === dayStart.valueOf() + ? 0 + : value.valueOf() > dayEnd.valueOf() || + value.valueOf() === dayEnd.valueOf() + ? 100 + : wallClockPercent(value); + return { + dayStart, + dayEnd, + start: effectiveStart, + end: effectiveEnd, + seconds: Math.max(0, effectiveEnd.diff(effectiveStart, "second")), + leftPct: percent(effectiveStart), + rightPct: percent(effectiveEnd), + }; +} + +export function dailyOnlineRate(onlineSeconds: number, windowSeconds: number) { + return windowSeconds > 0 + ? Math.max(0, Math.min(100, (onlineSeconds / windowSeconds) * 100)) + : 0; +} + +// 每份响应只解析一次时段和窗口边界;后续按天裁剪仅比较时间戳。 +function preparePeriods( + periods: OnlinePeriod[], + tz?: string, + windowStart?: string, + windowEnd?: string, +) { + const lower = windowStart ? panelDay(windowStart, tz) : null; + const upper = windowEnd ? panelDay(windowEnd, tz) : null; + return periods.flatMap((period) => { + const originalStart = panelDay(period.start, tz); + const originalEnd = panelDay(period.end, tz); + const start = + lower && lower.valueOf() > originalStart.valueOf() + ? lower + : originalStart; + const end = + upper && upper.valueOf() < originalEnd.valueOf() ? upper : originalEnd; + return start.isValid() && end.isValid() && end.valueOf() > start.valueOf() + ? [ + { + period, + start, + end, + live: period.ongoing && end.valueOf() === originalEnd.valueOf(), + }, + ] + : []; + }); +} + +function buildParsedDaySegments( + periods: ReturnType, + dayStart: dayjs.Dayjs, + dayEnd: dayjs.Dayjs, +): DaySegment[] { + const lower = dayStart.valueOf(), + upper = dayEnd.valueOf(); + return periods.flatMap(({ period, start, end, live }) => { + if (end.valueOf() <= lower || start.valueOf() >= upper) return []; + const segStart = start.valueOf() > lower ? start : dayStart; + const segEnd = end.valueOf() < upper ? end : dayEnd; + const leftPct = + segStart.valueOf() === lower ? 0 : wallClockPercent(segStart); + const rightPct = + segEnd.valueOf() === upper ? 100 : wallClockPercent(segEnd); + const widthPct = Math.max(0, Math.min(rightPct - leftPct, 100 - leftPct)); + return widthPct > 0 + ? [ + { + period, + segStart, + segEnd, + leftPct, + widthPct, + live: live && segEnd.valueOf() === end.valueOf(), + }, + ] + : []; + }); +} + +export function buildDaySegments( + periods: OnlinePeriod[], + date: string, + tz?: string, + windowStart?: string, + windowEnd?: string, +): DaySegment[] { + return buildParsedDaySegments( + preparePeriods(periods, tz, windowStart, windowEnd), + panelDay(date, tz), + panelNextDayStart(date, tz), + ); +} + +// 时间轴、每日总计及在线列表共享这份结果,不再各自遍历并转换整份历史。 +export function buildDailyOnlineRows( + periods: OnlinePeriod[], + daily: DailyOnlineStat[], + windowStart: string, + windowEnd: string, + tz?: string, +) { + const prepared = preparePeriods(periods, tz, windowStart, windowEnd); + return daily.map((day) => { + const window = buildDayWindow(day.date, windowStart, windowEnd, tz); + const segments = buildParsedDaySegments( + prepared, + window.dayStart, + window.dayEnd, + ); + return { + ...day, + segments, + window, + online_seconds: segments.reduce( + (sum, segment) => sum + segment.segEnd.diff(segment.segStart, "second"), + 0, + ), + }; + }); +} + +export function groupOnlineRows( + rows: { date: string; segments: DaySegment[] }[], + tz?: string, +): PeriodDayGroup[] { + return rows + .filter((row) => row.segments.length > 0) + .map((row) => ({ + date: row.date, + day: panelDay(row.date, tz), + chips: row.segments.map((segment) => ({ + start: segment.segStart, + end: segment.segEnd, + ongoing: segment.live, + original: segment.period, + })), + total: row.segments.reduce( + (sum, segment) => sum + segment.segEnd.diff(segment.segStart, "second"), + 0, + ), + })) + .sort((a, b) => b.date.localeCompare(a.date)); +} + +// 合并重叠/相邻的时间段,按开始时间排序 +export function mergePeriods( + periods: OnlinePeriod[], + tz?: string, +): PeriodChip[] { + const sorted = periods + .map((period) => ({ + start: panelDay(period.start, tz), + end: panelDay(period.end, tz), + ongoing: period.ongoing, + })) + .sort((a, b) => a.start.valueOf() - b.start.valueOf()); + const merged: PeriodChip[] = []; + for (const period of sorted) { + const last = merged[merged.length - 1]; + if (last && period.start.valueOf() < last.end.valueOf()) { + if (period.end.valueOf() > last.end.valueOf()) { + last.end = period.end; + } + last.ongoing = last.ongoing || period.ongoing; + } else { + merged.push({ ...period }); + } + } + return merged; +} + +// 独立调用也复用同一预解析/裁剪流程。 +export function groupOnlinePeriods( + periods: OnlinePeriod[], + tz?: string, + windowStart?: string, + windowEnd?: string, +): PeriodDayGroup[] { + const prepared = preparePeriods(periods, tz, windowStart, windowEnd); + const dates = new Set(); + for (const { start, end } of prepared) { + const endDate = end.format("YYYY-MM-DD"); + for ( + let date = start.format("YYYY-MM-DD"); + date <= endDate; + date = nextDate(date) + ) + dates.add(date); + } + return groupOnlineRows( + [...dates].map((date) => ({ + date, + segments: buildParsedDaySegments( + prepared, + panelDay(date, tz), + panelNextDayStart(date, tz), + ), + })), + tz, + ); +} + +// 离线模式:对窗口内每一天求在线时段的补集。计算区间先与查询窗口取交集, +// 避免在窗口开始前/结束后凭空生成离线段;服务器当前离线时,结束于窗口末端的 +// 最新一段标记为进行中。 +export function buildOfflineGroups( + periods: OnlinePeriod[], + daily: DailyOnlineStat[], + windowStart: string, + windowEnd: string, + currentlyOnline: boolean, + tz?: string, +): PeriodDayGroup[] { + const merged = mergePeriods(periods, tz); + const windowStartTime = panelDay(windowStart, tz); + const windowEndTime = panelDay(windowEnd, tz); + const groups = daily + .map((day) => day.date) + .sort((a, b) => (a < b ? 1 : -1)) + .flatMap((date): PeriodDayGroup[] => { + const dayStart = panelDay(date, tz); + const dayEnd = panelNextDayStart(date, tz); + // 当天与查询窗口的交集;首日/末日通常不是完整 24 小时 + const effectiveStart = + dayStart.valueOf() > windowStartTime.valueOf() + ? dayStart + : windowStartTime; + const effectiveEnd = + dayEnd.valueOf() < windowEndTime.valueOf() ? dayEnd : windowEndTime; + if (!(effectiveEnd.valueOf() > effectiveStart.valueOf())) { + return []; + } + const chips: PeriodChip[] = []; + let cursor = effectiveStart; + for (const segment of merged) { + if (!(segment.end.valueOf() > effectiveStart.valueOf())) { + continue; + } + // merged 按开始时间升序;到达当天区间之后的时段即可停止, + // 否则会把未来日期的开始时刻当成当天的 gap 终点 + if (!(segment.start.valueOf() < effectiveEnd.valueOf())) { + break; + } + const segStart = + segment.start.valueOf() > cursor.valueOf() ? segment.start : cursor; + if (segStart.valueOf() > cursor.valueOf()) { + chips.push({ start: cursor, end: segStart, ongoing: false }); + } + const segEnd = + segment.end.valueOf() < effectiveEnd.valueOf() + ? segment.end + : effectiveEnd; + if (segEnd.valueOf() > cursor.valueOf()) { + cursor = segEnd; + } + } + if (cursor.valueOf() < effectiveEnd.valueOf()) { + chips.push({ start: cursor, end: effectiveEnd, ongoing: false }); + } + const total = chips.reduce( + (sum, chip) => sum + chip.end.diff(chip.start, "second"), + 0, + ); + return [{ date, day: dayStart, total, chips }]; + }) + .filter((group) => group.chips.length > 0); + if (!currentlyOnline && groups.length > 0) { + const newest = groups[0].chips; + const last = newest[newest.length - 1]; + // 只有真正持续到窗口末端的离线段才是进行中;旧日期的段不能误标 + if (last?.end.isSame(windowEndTime)) { + last.ongoing = true; + } + } + return groups; +} diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 2612943..6d792d4 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -89,7 +89,8 @@ }, "tabSwitch": { "Network": "Netzwerk", - "Detail": "Detail" + "Detail": "Detail", + "Online": "Online" }, "nezha": "Nezha Monitoring", "dashboard": "Dashboard", @@ -140,5 +141,23 @@ "up_total": "Upload ges.", "down_total": "Download ges." } + }, + "serverOnlineHistory": { + "days1": "1d", + "days7": "7d", + "days30": "30d", + "onlineRate": "Online rate", + "totalOnline": "Online in window", + "lastActive": "Last active", + "dailyOnline": "Daily online time", + "periods": "Online periods", + "ongoing": "Ongoing", + "noData": "No online records", + "loginRequired": "This window requires login; guests can switch to 1d", + "offlinePeriods": "Offline periods", + "periodsOnline": "Online", + "periodsOffline": "Offline", + "expandMore": "Expand more", + "today": "Today" } } diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 0b6a474..bd29516 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -121,7 +121,8 @@ }, "tabSwitch": { "Detail": "Detail", - "Network": "Network" + "Network": "Network", + "Online": "Online" }, "monitor": { "noData": "No server monitor data, please add a service monitor first", @@ -176,5 +177,30 @@ }, "group": { "all": "All" + }, + "serverOnlineHistory": { + "loading": "Loading online history", + "originalSession": "Original session", + "dataCutoffHint": "Marker: data cutoff", + "dataCutoff": "Data through", + "notYetRecorded": "Not yet recorded", + "outsideWindow": "Outside selected window", + "days1": "1d", + "days7": "7d", + "days30": "30d", + "onlineRate": "Online rate", + "totalOnline": "Total online", + "totalOffline": "Total offline", + "lastActive": "Last active", + "dailyOnline": "Daily online time", + "periods": "Online periods", + "ongoing": "Ongoing", + "noData": "No online records", + "loginRequired": "This window requires login; guests can switch to 1d", + "offlinePeriods": "Offline periods", + "periodsOnline": "Online", + "periodsOffline": "Offline", + "expandMore": "Expand more", + "today": "Today" } } diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index cd9eebc..8fbc77c 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -86,7 +86,8 @@ }, "tabSwitch": { "Detail": "Detalle", - "Network": "Red" + "Network": "Red", + "Online": "Online" }, "monitor": { "avgDelay": "Latencia", @@ -155,5 +156,23 @@ "up_total": "Subida total", "down_total": "Bajada total" } + }, + "serverOnlineHistory": { + "days1": "1d", + "days7": "7d", + "days30": "30d", + "onlineRate": "Online rate", + "totalOnline": "Online in window", + "lastActive": "Last active", + "dailyOnline": "Daily online time", + "periods": "Online periods", + "ongoing": "Ongoing", + "noData": "No online records", + "loginRequired": "This window requires login; guests can switch to 1d", + "offlinePeriods": "Offline periods", + "periodsOnline": "Online", + "periodsOffline": "Offline", + "expandMore": "Expand more", + "today": "Today" } } diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index 855ae21..9531861 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -12,5 +12,26 @@ }, "serverDetail": { "download": "Téléchargement" + }, + "tabSwitch": { + "Online": "Online" + }, + "serverOnlineHistory": { + "days1": "1d", + "days7": "7d", + "days30": "30d", + "onlineRate": "Online rate", + "totalOnline": "Online in window", + "lastActive": "Last active", + "dailyOnline": "Daily online time", + "periods": "Online periods", + "ongoing": "Ongoing", + "noData": "No online records", + "loginRequired": "This window requires login; guests can switch to 1d", + "offlinePeriods": "Offline periods", + "periodsOnline": "Online", + "periodsOffline": "Offline", + "expandMore": "Expand more", + "today": "Today" } } diff --git a/src/locales/gl/translation.json b/src/locales/gl/translation.json index 3de11df..86d21ee 100644 --- a/src/locales/gl/translation.json +++ b/src/locales/gl/translation.json @@ -102,7 +102,8 @@ }, "tabSwitch": { "Detail": "Detalle", - "Network": "Rede" + "Network": "Rede", + "Online": "Online" }, "monitor": { "noData": "Non hai datos de monitor de servidor. Engada primeiro un monitor de servizo", @@ -133,5 +134,23 @@ "ToggleLightMode": "Cambiar ao modo claro", "ToggleDarkMode": "Cambiar ao modo escuro", "ToggleSystemMode": "Cambiar ao determinado polo sistema", - "Home": "Páxina principal" + "Home": "Páxina principal", + "serverOnlineHistory": { + "days1": "1d", + "days7": "7d", + "days30": "30d", + "onlineRate": "Online rate", + "totalOnline": "Online in window", + "lastActive": "Last active", + "dailyOnline": "Daily online time", + "periods": "Online periods", + "ongoing": "Ongoing", + "noData": "No online records", + "loginRequired": "This window requires login; guests can switch to 1d", + "offlinePeriods": "Offline periods", + "periodsOnline": "Online", + "periodsOffline": "Offline", + "expandMore": "Expand more", + "today": "Today" + } } diff --git a/src/locales/id/translation.json b/src/locales/id/translation.json index 7cb3e74..ec3165a 100644 --- a/src/locales/id/translation.json +++ b/src/locales/id/translation.json @@ -114,7 +114,8 @@ }, "tabSwitch": { "Detail": "Detail", - "Network": "Jaringan" + "Network": "Jaringan", + "Online": "Online" }, "monitor": { "noData": "Tidak ada data pemantauan server, harap tambahkan pemantauan layanan terlebih dahulu", @@ -169,5 +170,23 @@ }, "group": { "all": "Semua" + }, + "serverOnlineHistory": { + "days1": "1d", + "days7": "7d", + "days30": "30d", + "onlineRate": "Online rate", + "totalOnline": "Online in window", + "lastActive": "Last active", + "dailyOnline": "Daily online time", + "periods": "Online periods", + "ongoing": "Ongoing", + "noData": "No online records", + "loginRequired": "This window requires login; guests can switch to 1d", + "offlinePeriods": "Offline periods", + "periodsOnline": "Online", + "periodsOffline": "Offline", + "expandMore": "Expand more", + "today": "Today" } } diff --git a/src/locales/ja/translation.json b/src/locales/ja/translation.json index db7758c..9813326 100644 --- a/src/locales/ja/translation.json +++ b/src/locales/ja/translation.json @@ -23,5 +23,26 @@ "map": { "Distributions": "サーバーの場所", "Regions": "地域" + }, + "tabSwitch": { + "Online": "Online" + }, + "serverOnlineHistory": { + "days1": "1d", + "days7": "7d", + "days30": "30d", + "onlineRate": "Online rate", + "totalOnline": "Online in window", + "lastActive": "Last active", + "dailyOnline": "Daily online time", + "periods": "Online periods", + "ongoing": "Ongoing", + "noData": "No online records", + "loginRequired": "This window requires login; guests can switch to 1d", + "offlinePeriods": "Offline periods", + "periodsOnline": "Online", + "periodsOffline": "Offline", + "expandMore": "Expand more", + "today": "Today" } } diff --git a/src/locales/pt_BR/translation.json b/src/locales/pt_BR/translation.json index 745af41..8537c75 100644 --- a/src/locales/pt_BR/translation.json +++ b/src/locales/pt_BR/translation.json @@ -114,7 +114,8 @@ }, "tabSwitch": { "Detail": "Detalhes", - "Network": "Rede" + "Network": "Rede", + "Online": "Online" }, "monitor": { "noData": "Sem servidores para monitorar. por favor adicione um primeiro monitor de serviço", @@ -165,5 +166,23 @@ }, "group": { "all": "Todos" + }, + "serverOnlineHistory": { + "days1": "1d", + "days7": "7d", + "days30": "30d", + "onlineRate": "Online rate", + "totalOnline": "Online in window", + "lastActive": "Last active", + "dailyOnline": "Daily online time", + "periods": "Online periods", + "ongoing": "Ongoing", + "noData": "No online records", + "loginRequired": "This window requires login; guests can switch to 1d", + "offlinePeriods": "Offline periods", + "periodsOnline": "Online", + "periodsOffline": "Offline", + "expandMore": "Expand more", + "today": "Today" } } diff --git a/src/locales/ro/translation.json b/src/locales/ro/translation.json index 9638c12..1e78388 100644 --- a/src/locales/ro/translation.json +++ b/src/locales/ro/translation.json @@ -4,5 +4,26 @@ "light": "Mod luminos", "dark": "Mod întunecat" }, - "Servers": "Servere" + "Servers": "Servere", + "tabSwitch": { + "Online": "Online" + }, + "serverOnlineHistory": { + "days1": "1d", + "days7": "7d", + "days30": "30d", + "onlineRate": "Online rate", + "totalOnline": "Online in window", + "lastActive": "Last active", + "dailyOnline": "Daily online time", + "periods": "Online periods", + "ongoing": "Ongoing", + "noData": "No online records", + "loginRequired": "This window requires login; guests can switch to 1d", + "offlinePeriods": "Offline periods", + "periodsOnline": "Online", + "periodsOffline": "Offline", + "expandMore": "Expand more", + "today": "Today" + } } diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 4d8d247..0365a39 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -30,7 +30,8 @@ }, "tabSwitch": { "Detail": "Детали", - "Network": "Сеть" + "Network": "Сеть", + "Online": "Online" }, "whereTheTimeIs": "Где время", "theme": { @@ -159,5 +160,23 @@ "up_total": "Загружено", "down_total": "Скачано" } + }, + "serverOnlineHistory": { + "days1": "1d", + "days7": "7d", + "days30": "30d", + "onlineRate": "Online rate", + "totalOnline": "Online in window", + "lastActive": "Last active", + "dailyOnline": "Daily online time", + "periods": "Online periods", + "ongoing": "Ongoing", + "noData": "No online records", + "loginRequired": "This window requires login; guests can switch to 1d", + "offlinePeriods": "Offline periods", + "periodsOnline": "Online", + "periodsOffline": "Offline", + "expandMore": "Expand more", + "today": "Today" } } diff --git a/src/locales/ta/translation.json b/src/locales/ta/translation.json index 5378064..1085a1f 100644 --- a/src/locales/ta/translation.json +++ b/src/locales/ta/translation.json @@ -90,7 +90,8 @@ }, "tabSwitch": { "Detail": "விவரம்", - "Network": "பிணையம்" + "Network": "பிணையம்", + "Online": "Online" }, "monitor": { "noData": "சேவையக மானிட்டர் தரவு இல்லை, முதலில் ஒரு பணி மானிட்டரைச் சேர்க்கவும்", @@ -140,5 +141,23 @@ "up_total": "மொத்த பதிவேற்றம்", "down_total": "மொத்த பதிவிறக்கம்" } + }, + "serverOnlineHistory": { + "days1": "1d", + "days7": "7d", + "days30": "30d", + "onlineRate": "Online rate", + "totalOnline": "Online in window", + "lastActive": "Last active", + "dailyOnline": "Daily online time", + "periods": "Online periods", + "ongoing": "Ongoing", + "noData": "No online records", + "loginRequired": "This window requires login; guests can switch to 1d", + "offlinePeriods": "Offline periods", + "periodsOnline": "Online", + "periodsOffline": "Offline", + "expandMore": "Expand more", + "today": "Today" } } diff --git a/src/locales/uk/translation.json b/src/locales/uk/translation.json index be0878f..668a903 100644 --- a/src/locales/uk/translation.json +++ b/src/locales/uk/translation.json @@ -69,5 +69,26 @@ "today": "Сьогодні", "loading": "Завантаження..." }, - "login": "Логін" + "login": "Логін", + "tabSwitch": { + "Online": "Online" + }, + "serverOnlineHistory": { + "days1": "1d", + "days7": "7d", + "days30": "30d", + "onlineRate": "Online rate", + "totalOnline": "Online in window", + "lastActive": "Last active", + "dailyOnline": "Daily online time", + "periods": "Online periods", + "ongoing": "Ongoing", + "noData": "No online records", + "loginRequired": "This window requires login; guests can switch to 1d", + "offlinePeriods": "Offline periods", + "periodsOnline": "Online", + "periodsOffline": "Offline", + "expandMore": "Expand more", + "today": "Today" + } } diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index bc421ce..8bd769e 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -115,7 +115,8 @@ }, "tabSwitch": { "Detail": "详情", - "Network": "网络" + "Network": "网络", + "Online": "在线" }, "monitor": { "noData": "没有服务监控数据,请在管理后台服务页添加监控任务", @@ -170,5 +171,30 @@ }, "group": { "all": "全部" + }, + "serverOnlineHistory": { + "loading": "正在加载在线记录", + "originalSession": "原始会话", + "dataCutoffHint": "竖线:数据截止", + "dataCutoff": "数据截止", + "notYetRecorded": "尚未统计", + "outsideWindow": "未纳入统计", + "days1": "1 天", + "days7": "7 天", + "days30": "30 天", + "onlineRate": "在线率", + "totalOnline": "累计在线", + "totalOffline": "累计离线", + "lastActive": "最后活跃", + "dailyOnline": "每日在线时长", + "periods": "在线时间段", + "ongoing": "进行中", + "noData": "暂无在线记录", + "loginRequired": "当前窗口需要登录后查看,游客可切换到 1 天", + "offlinePeriods": "不在线时间段", + "periodsOnline": "在线", + "periodsOffline": "离线", + "expandMore": "展开更多", + "today": "今天" } } diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index d88d4a6..9213f19 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -114,7 +114,8 @@ "detail": "詳細資訊", "network": "網路", "Detail": "詳細資訊", - "Network": "網路" + "Network": "網路", + "Online": "線上" }, "monitor": { "noData": "沒有伺服器監控資料,請先新增服務監控", @@ -170,5 +171,30 @@ }, "group": { "all": "全部" + }, + "serverOnlineHistory": { + "loading": "正在載入在線記錄", + "originalSession": "原始連線", + "dataCutoffHint": "直線:資料截止", + "dataCutoff": "資料截止", + "notYetRecorded": "尚未統計", + "outsideWindow": "未納入統計", + "days1": "1 天", + "days7": "7 天", + "days30": "30 天", + "onlineRate": "線上率", + "totalOnline": "累計在線", + "totalOffline": "累計離線", + "lastActive": "最後活躍", + "dailyOnline": "每日線上時長", + "periods": "線上時間段", + "ongoing": "進行中", + "noData": "暫無線上紀錄", + "loginRequired": "此視窗需要登入後查看,訪客可切換到 1 天", + "offlinePeriods": "Offline periods", + "periodsOnline": "在線", + "periodsOffline": "離線", + "expandMore": "Expand more", + "today": "Today" } } diff --git a/src/pages/ServerDetail.tsx b/src/pages/ServerDetail.tsx index dac573d..1b81de2 100644 --- a/src/pages/ServerDetail.tsx +++ b/src/pages/ServerDetail.tsx @@ -1,5 +1,6 @@ import { lazy, Suspense, useEffect, useState } from "react"; import { Navigate, useParams } from "react-router-dom"; +import OnlineHistorySkeleton from "@/components/loading/OnlineHistorySkeleton"; import NetworkChartLoading from "@/components/NetworkChartLoading"; import ServerDetailChart from "@/components/ServerDetailChart"; import ServerDetailOverview from "@/components/ServerDetailOverview"; @@ -12,12 +13,18 @@ const NetworkChart = lazy(() => })), ); +const ServerDetailOnlineHistory = lazy(() => + import("@/components/ServerDetailOnlineHistory").then((module) => ({ + default: module.default, + })), +); + export default function ServerDetail() { useEffect(() => { window.scrollTo({ top: 0, left: 0, behavior: "instant" }); }, []); - const tabs = ["Detail", "Network"]; + const tabs = ["Detail", "Network", "Online"]; const [currentTab, setCurrentTab] = useState(tabs[0]); const { id: server_id } = useParams(); @@ -51,6 +58,11 @@ export default function ServerDetail() { )} + {currentTab === tabs[2] && ( + }> + + + )} ); } diff --git a/src/test/components/server-detail-online-history.test.tsx b/src/test/components/server-detail-online-history.test.tsx new file mode 100644 index 0000000..207d4a8 --- /dev/null +++ b/src/test/components/server-detail-online-history.test.tsx @@ -0,0 +1,446 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import dayjs from "dayjs"; +import type { ReactElement } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import ServerDetailOnlineHistory from "@/components/ServerDetailOnlineHistory"; +import { createTestQueryClient } from "@/test/utils"; +import type { OnlineHistoryResponse } from "@/types/nezha-api"; + +const websocketMocks = vi.hoisted(() => ({ + lastData: null as { servers: { id: number; last_active: string }[] } | null, +})); +vi.mock("@/hooks/use-websocket-context", () => ({ + useWebSocketContext: () => websocketMocks, +})); + +const apiMocks = vi.hoisted(() => ({ + fetchServerOnlineHistory: vi.fn(), +})); + +vi.mock("@/lib/nezha-api", () => ({ + fetchServerOnlineHistory: apiMocks.fetchServerOnlineHistory, +})); + +function onlineHistoryResponse(maxDays = 30): OnlineHistoryResponse { + return { + success: true, + data: { + server_id: 7, + server_name: "edge-online", + days: 7, + max_days: maxDays, + timezone: "UTC", + window_start: "2026-10-07T04:00:00.000Z", + window_end: "2026-10-14T12:00:00.000Z", + total_online_seconds: 5400, + online_rate: 0.89, + currently_online: true, + last_active: "2026-10-14T03:30:00.000Z", + periods: [ + { + start: "2026-10-13T08:00:00.000Z", + end: "2026-10-13T09:30:00.000Z", + ongoing: false, + }, + { + start: "2026-10-14T10:00:00.000Z", + end: "2026-10-14T11:00:00.000Z", + ongoing: true, + }, + ], + daily: [ + { date: "2026-10-13", online_seconds: 5400 }, + { date: "2026-10-14", online_seconds: 3600 }, + ], + }, + }; +} + +function renderWithQuery(ui: ReactElement) { + return render( + + {ui} + , + ); +} + +describe("ServerDetailOnlineHistory", () => { + beforeEach(() => { + websocketMocks.lastData = null; + apiMocks.fetchServerOnlineHistory.mockReset(); + }); + + it("renders the loading state while history data is unavailable", () => { + apiMocks.fetchServerOnlineHistory.mockReturnValue( + new Promise(() => undefined), + ); + + renderWithQuery(); + expect( + screen.getByRole("status", { name: "serverOnlineHistory.loading" }), + ).toHaveAttribute("aria-busy", "true"); + expect(screen.getByTestId("online-history-loading")).toBeInTheDocument(); + expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 7); + }); + + it("keeps existing content instead of flashing the skeleton while changing days", async () => { + apiMocks.fetchServerOnlineHistory + .mockResolvedValueOnce(onlineHistoryResponse()) + .mockReturnValue(new Promise(() => undefined)); + renderWithQuery(); + await screen.findByText("0.9%"); + expect( + screen.queryByTestId("online-history-loading"), + ).not.toBeInTheDocument(); + await userEvent + .setup() + .click(screen.getByText("serverOnlineHistory.days30")); + await waitFor(() => + expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 30), + ); + expect(screen.getByText("0.9%")).toBeInTheDocument(); + expect( + screen.queryByTestId("online-history-loading"), + ).not.toBeInTheDocument(); + }); + + it("renders daily totals and all groups when fewer than three days are available", async () => { + apiMocks.fetchServerOnlineHistory.mockResolvedValue( + onlineHistoryResponse(), + ); + renderWithQuery(); + await screen.findByText("serverOnlineHistory.dailyOnline"); + expect(screen.getAllByText("online")).toHaveLength(3); + expect(screen.getByText("0.9%")).toBeInTheDocument(); + expect(screen.getAllByText("1h 30m")).toHaveLength(4); + expect(screen.getAllByText("1h 0m")).toHaveLength(3); + expect(screen.getByText("10-13")).toBeInTheDocument(); + expect( + screen.queryByText("serverOnlineHistory.expandMore"), + ).not.toBeInTheDocument(); + expect(screen.getAllByTestId("period-chip")).toHaveLength(2); + }); + + it.each([ + [1800, "1h 30m"], + [7200, "0m"], + [7500, "0m"], + ])("calculates offline time from the actual window with %i online seconds", async (seconds, expected) => { + const response = onlineHistoryResponse(); + response.data.window_start = "2026-10-14T10:00:00.000Z"; + response.data.window_end = "2026-10-14T12:00:00.000Z"; + response.data.total_online_seconds = seconds; + apiMocks.fetchServerOnlineHistory.mockResolvedValue(response); + + renderWithQuery(); + + const label = await screen.findByText("serverOnlineHistory.totalOffline"); + expect(label.parentElement).toHaveTextContent(expected); + }); + + it("maps each day's periods onto the shared 0:00-24:00 timeline", async () => { + apiMocks.fetchServerOnlineHistory.mockResolvedValue( + onlineHistoryResponse(), + ); + + renderWithQuery(); + + expect(await screen.findByText(/0:00 – 24:00/)).toBeInTheDocument(); + + // 时间轴按面板时区(UTC)的挂钟定位:08:00 在 33.33%,1.5h 宽 6.25% + const segments = await screen.findAllByTestId("online-segment"); + expect(segments).toHaveLength(2); + expect(Number.parseFloat(segments[0].style.left)).toBeCloseTo( + (8 / 24) * 100, + 2, + ); + expect(Number.parseFloat(segments[0].style.width)).toBeCloseTo( + (1.5 / 24) * 100, + 2, + ); + expect(segments[0].style.minWidth).toBe("2px"); + }); + + it("keeps daily totals, carry-over chips and window markers consistent", async () => { + const response = onlineHistoryResponse(); + Object.assign(response.data, { + window_start: "2026-10-13T06:00:00.000Z", + window_end: "2026-10-14T12:00:00.000Z", + total_online_seconds: 9 * 3600, + daily: [ + { date: "2026-10-13", online_seconds: 7200 }, + { date: "2026-10-14", online_seconds: 7 * 3600 }, + ], + periods: [ + { + start: "2026-10-13T22:00:00.000Z", + end: "2026-10-14T02:00:00.000Z", + ongoing: false, + }, + { + start: "2026-10-14T03:00:00.000Z", + end: "2026-10-14T04:00:00.000Z", + ongoing: false, + }, + { + start: "2026-10-14T08:00:00.000Z", + end: "2026-10-14T12:00:00.000Z", + ongoing: true, + }, + ], + }); + apiMocks.fetchServerOnlineHistory.mockResolvedValue(response); + renderWithQuery(); + const rows = await screen.findAllByTestId("daily-online-day"); + expect(within(rows[1]).getByText("7h 0m")).toBeInTheDocument(); + expect(within(rows[1]).getAllByTestId("online-segment")).toHaveLength(3); + const latestGroup = screen.getAllByTestId("period-day")[0]; + expect(within(latestGroup).getByText("7h 0m")).toBeInTheDocument(); + const chips = within(latestGroup).getAllByTestId("period-chip"); + expect(chips).toHaveLength(3); + expect(chips[0]).toHaveTextContent("00:00 → 02:00"); + expect(screen.getByTestId("outside-window")).toHaveStyle({ width: "25%" }); + expect(screen.getByTestId("unrecorded-window")).toHaveStyle({ + left: "50%", + width: "50%", + }); + expect(screen.getByTestId("data-cutoff")).toHaveAccessibleName( + "serverOnlineHistory.dataCutoff 12:00:00", + ); + await userEvent.setup().hover(chips[0]); + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "serverOnlineHistory.originalSession 10-13 22:00:00 → 10-14 02:00:00", + ); + }); + + it("renders the compact mobile summary below the md breakpoint", async () => { + apiMocks.fetchServerOnlineHistory.mockResolvedValue( + onlineHistoryResponse(), + ); + vi.stubGlobal( + "matchMedia", + vi.fn().mockImplementation(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + ); + try { + renderWithQuery(); + + expect(await screen.findByTestId("summary-mobile")).toBeInTheDocument(); + expect(screen.queryByTestId("summary-desktop")).not.toBeInTheDocument(); + // 移动端:在线率 + 在线/离线双指标 + 最近活跃。 + expect(screen.getByText("0.9%")).toBeInTheDocument(); + expect( + screen.getByText("serverOnlineHistory.totalOffline"), + ).toBeInTheDocument(); + expect( + screen.getByText("serverOnlineHistory.totalOnline"), + ).toBeInTheDocument(); + expect( + screen.getByText("serverOnlineHistory.lastActive"), + ).toBeInTheDocument(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("keeps the live last-active time when switching history windows", async () => { + const user = userEvent.setup(); + websocketMocks.lastData = { + servers: [{ id: 7, last_active: "2026-10-14T12:34:56.000Z" }], + }; + apiMocks.fetchServerOnlineHistory.mockImplementation((_id, days) => { + const response = onlineHistoryResponse(); + response.data.online_rate = days === 30 ? 50 : 90; + response.data.last_active = + days === 30 ? "2026-10-14T10:00:00.000Z" : "2026-10-14T11:00:00.000Z"; + return Promise.resolve(response); + }); + renderWithQuery(); + expect(await screen.findByText("12:34:56")).toBeInTheDocument(); + await user.click(screen.getByText("serverOnlineHistory.days30")); + expect(await screen.findByText("50.0%")).toBeInTheDocument(); + await waitFor(() => + expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 30), + ); + expect(screen.getByText("12:34:56")).toBeInTheDocument(); + await user.click(screen.getByText("serverOnlineHistory.days7")); + expect(screen.getByText("12:34:56")).toBeInTheDocument(); + }); + + it("switches the query window via the day selector", async () => { + const user = userEvent.setup(); + apiMocks.fetchServerOnlineHistory.mockResolvedValue( + onlineHistoryResponse(), + ); + + renderWithQuery(); + await screen.findByText("serverOnlineHistory.dailyOnline"); + + await user.click(screen.getByText("serverOnlineHistory.days30")); + + await waitFor(() => { + expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 30); + }); + }); + + it("locks day options beyond the configured max window", async () => { + const user = userEvent.setup(); + apiMocks.fetchServerOnlineHistory.mockResolvedValue( + onlineHistoryResponse(7), + ); + + renderWithQuery(); + await screen.findByText("serverOnlineHistory.dailyOnline"); + + await user.click(screen.getByText("serverOnlineHistory.days30")); + expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledTimes(1); + expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 7); + }); + + it("shows the login hint when the API rejects anonymous guests", async () => { + apiMocks.fetchServerOnlineHistory.mockRejectedValue( + new Error("unauthorized: only 1d data available for guests"), + ); + + renderWithQuery(); + + expect( + await screen.findByText("serverOnlineHistory.loginRequired"), + ).toBeInTheDocument(); + }); + + it("falls back to one day and retries automatically for guests", async () => { + const user = userEvent.setup(); + apiMocks.fetchServerOnlineHistory + .mockRejectedValueOnce( + new Error("unauthorized: only 1d data available for guests"), + ) + .mockResolvedValue(onlineHistoryResponse()); + + renderWithQuery(); + + // 首次 7d 被拒后自动以 1d 重试并渲染数据 + await screen.findByText("serverOnlineHistory.dailyOnline"); + await waitFor(() => { + expect(apiMocks.fetchServerOnlineHistory).toHaveBeenNthCalledWith( + 2, + 7, + 1, + ); + }); + + // 更长窗口被锁定(游客上限 1 天),点击不再发起新请求 + const calls = apiMocks.fetchServerOnlineHistory.mock.calls.length; + await user.click(screen.getByText("serverOnlineHistory.days30")); + expect(apiMocks.fetchServerOnlineHistory).toHaveBeenNthCalledWith(2, 7, 1); + expect(apiMocks.fetchServerOnlineHistory.mock.calls.length).toBe(calls); + }); + + it("groups periods by the panel timezone from the response", async () => { + const data = onlineHistoryResponse().data; + // 面板时区非 UTC 才能暴露 dayjs .tz(input, zone) 忽略显式偏移的问题 + data.timezone = "Asia/Shanghai"; + data.window_start = "2026-01-01T16:00:00.000Z"; + data.window_end = "2026-01-02T16:00:00.000Z"; + data.daily = [{ date: "2026-01-02", online_seconds: 7200 }]; + data.periods = [ + { + start: "2026-01-01T20:00:00.000Z", + end: "2026-01-01T22:00:00.000Z", + ongoing: false, + }, + ]; + apiMocks.fetchServerOnlineHistory.mockResolvedValue({ + success: true, + data, + }); + + renderWithQuery(); + await screen.findByText("serverOnlineHistory.dailyOnline"); + + // 20:00Z 在面板时区(UTC+8)为 01-02 04:00:分组落到 01-02,段内时刻同步换算 + expect(screen.getByText("01-02")).toBeInTheDocument(); + expect(screen.queryByText("01-01")).not.toBeInTheDocument(); + const chip = screen.getByTestId("period-chip"); + expect(chip.textContent).toContain("04:00"); + expect(chip.textContent).toContain("06:00"); + }); + + it("shows three days initially and expands ten days per click", async () => { + const user = userEvent.setup(); + const data = onlineHistoryResponse().data; + const dates = Array.from({ length: 25 }, (_, i) => + dayjs("2026-10-14").subtract(i, "day").format("YYYY-MM-DD"), + ); + data.window_start = `${dates[dates.length - 1]}T00:00:00.000Z`; + data.daily = dates.map((date) => ({ date, online_seconds: 3600 })); + data.periods = dates.map((date) => ({ + start: `${date}T06:00:00.000Z`, + end: `${date}T07:00:00.000Z`, + ongoing: false, + })); + apiMocks.fetchServerOnlineHistory.mockResolvedValue({ + success: true, + data, + }); + + renderWithQuery(); + await screen.findByText("serverOnlineHistory.dailyOnline"); + + expect(screen.getAllByTestId("period-day")).toHaveLength(3); + + await user.click(screen.getByText("serverOnlineHistory.expandMore")); + expect(screen.getAllByTestId("period-day")).toHaveLength(13); + + await user.click(screen.getByText("serverOnlineHistory.expandMore")); + expect(screen.getAllByTestId("period-day")).toHaveLength(23); + + await user.click(screen.getByText("serverOnlineHistory.expandMore")); + expect(screen.getAllByTestId("period-day")).toHaveLength(25); + expect( + screen.queryByText("serverOnlineHistory.expandMore"), + ).not.toBeInTheDocument(); + }); + + it("derives offline periods as the complement of online sessions", async () => { + const user = userEvent.setup(); + const data = onlineHistoryResponse().data; + data.window_start = "2026-10-14T00:00:00.000Z"; + data.window_end = "2026-10-14T12:00:00.000Z"; + data.currently_online = false; + data.daily = [{ date: "2026-10-14", online_seconds: 7200 }]; + data.periods = [ + { + start: "2026-10-14T02:00:00.000Z", + end: "2026-10-14T04:00:00.000Z", + ongoing: false, + }, + ]; + apiMocks.fetchServerOnlineHistory.mockResolvedValue({ + success: true, + data, + }); + + renderWithQuery(); + await screen.findByText("serverOnlineHistory.dailyOnline"); + + await user.click(screen.getByText("serverOnlineHistory.periodsOffline")); + + // 在线 02:00-04:00 两侧的空档各成一段,且服务器当前离线 → 末段进行中 + const chips = screen.getAllByTestId("period-chip"); + expect(chips).toHaveLength(2); + expect(chips[0].textContent).toContain("00:00"); + expect(chips[0].textContent).toContain("2h 0m"); + expect(chips[1].textContent).toContain("04:00"); + expect(chips[1].textContent).toContain("8h 0m"); + expect(chips[1].textContent).toContain("serverOnlineHistory.ongoing"); + + // 组头合计 = 窗口时长 12h − 当日在线 2h = 10h + expect(screen.getByText("10h 0m")).toBeInTheDocument(); + }); +}); diff --git a/src/test/lib/online-history.test.ts b/src/test/lib/online-history.test.ts new file mode 100644 index 0000000..a0763a8 --- /dev/null +++ b/src/test/lib/online-history.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, it } from "vitest"; +import { + buildDaySegments, + buildDayWindow, + buildOfflineGroups, + dailyOnlineRate, + formatPeriodMoment, + groupOnlinePeriods, + panelDay, + panelNextDayStart, +} from "@/lib/online-history"; +import type { DailyOnlineStat, OnlinePeriod } from "@/types/nezha-api"; + +function daily(date: string, onlineSeconds: number): DailyOnlineStat { + return { date, online_seconds: onlineSeconds }; +} + +function period(start: string, end: string, ongoing = false): OnlinePeriod { + return { start, end, ongoing }; +} + +function expectChipsInsideTheirDay( + groups: ReturnType, +) { + for (const group of groups) { + const dayStart = panelDay(group.date, "UTC"); + const dayEnd = panelNextDayStart(group.date, "UTC"); + for (const chip of group.chips) { + expect(chip.start.valueOf()).toBeGreaterThanOrEqual(dayStart.valueOf()); + expect(chip.end.valueOf()).toBeLessThanOrEqual(dayEnd.valueOf()); + expect(chip.end.valueOf()).toBeGreaterThan(chip.start.valueOf()); + } + for (let index = 1; index < group.chips.length; index += 1) { + expect(group.chips[index].start.valueOf()).toBeGreaterThanOrEqual( + group.chips[index - 1].end.valueOf(), + ); + } + } +} + +describe("buildOfflineGroups", () => { + it("never lets a gap run into a later day's online segment", () => { + const groups = buildOfflineGroups( + [ + period("2026-10-02T10:00:00.000Z", "2026-10-02T12:00:00.000Z"), + period("2026-10-03T10:00:00.000Z", "2026-10-03T12:00:00.000Z"), + ], + [ + daily("2026-10-01", 0), + daily("2026-10-02", 7200), + daily("2026-10-03", 7200), + ], + "2026-10-01T00:00:00.000Z", + "2026-10-04T00:00:00.000Z", + true, + "UTC", + ); + + expect(groups).toHaveLength(3); + expectChipsInsideTheirDay(groups); + + // 10-01 没有在线段:应是一整段 00:00–24:00,而不是跨到 10-02 10:00 + const oldest = groups[2]; + expect(oldest.date).toBe("2026-10-01"); + expect(oldest.chips).toHaveLength(1); + expect(oldest.chips[0].start.format("HH:mm")).toBe("00:00"); + expect(oldest.chips[0].end.format("HH:mm")).toBe("00:00"); + expect(oldest.chips[0].end.diff(oldest.chips[0].start, "hour")).toBe(24); + expect(oldest.total).toBe(86400); + + // 10-02 / 10-03 各自只有 00:00–10:00 与 12:00–24:00 两段 + for (const group of [groups[1], groups[0]]) { + expect(group.chips).toHaveLength(2); + expect(group.total).toBe(86400 - 7200); + } + }); + + it("starts the first day at window_start and ends the last day at window_end", () => { + const groups = buildOfflineGroups( + [period("2026-10-02T07:00:00.000Z", "2026-10-02T08:00:00.000Z")], + [daily("2026-10-02", 3600), daily("2026-10-03", 0)], + "2026-10-02T06:00:00.000Z", + "2026-10-03T12:00:00.000Z", + false, + "UTC", + ); + + expectChipsInsideTheirDay(groups); + + const first = groups[1]; + expect(first.date).toBe("2026-10-02"); + // 窗口从 06:00 开始,不能凭空生成 00:00–06:00 的离线 + expect(first.chips[0].start.format("HH:mm")).toBe("06:00"); + expect(first.chips[0].end.format("HH:mm")).toBe("07:00"); + // 18h 窗口 − 1h 在线 = 17h + expect(first.total).toBe(17 * 3600); + + const last = groups[0]; + expect(last.date).toBe("2026-10-03"); + expect(last.chips).toHaveLength(1); + expect(last.chips[0].start.format("HH:mm")).toBe("00:00"); + expect(last.chips[0].end.format("HH:mm")).toBe("12:00"); + expect(last.total).toBe(12 * 3600); + // 服务器当前离线,且末段确实结束于窗口末端 → 标记进行中 + expect(last.chips[0].ongoing).toBe(true); + }); + + it("does not mark an old day's last gap as ongoing", () => { + const groups = buildOfflineGroups( + [], + [daily("2026-10-02", 0)], + "2026-10-02T00:00:00.000Z", + "2026-10-02T12:00:00.000Z", + false, + "UTC", + ); + + // 窗口在当天中午结束,末段确实持续到窗口末端 + expect(groups[0].chips[groups[0].chips.length - 1]?.ongoing).toBe(true); + }); +}); + +describe("groupOnlinePeriods", () => { + it("splits cross-midnight sessions into the same segments as the daily timeline", () => { + const periods = [ + period("2026-10-19T22:00:00.000Z", "2026-10-20T03:00:00.000Z", true), + ]; + const groups = groupOnlinePeriods(periods, "UTC"); + expect(groups.map((g) => [g.date, g.total])).toEqual([ + ["2026-10-20", 10800], + ["2026-10-19", 7200], + ]); + for (const group of groups) { + const segments = buildDaySegments(periods, group.date, "UTC"); + expect(group.total).toBe( + segments.reduce( + (sum, s) => sum + s.segEnd.diff(s.segStart, "second"), + 0, + ), + ); + expect(group.chips[0].original).toEqual(periods[0]); + } + expect(groups[0].chips[0].ongoing).toBe(true); + expect(groups[1].chips[0].ongoing).toBe(false); + }); + + it("clips both timeline and chips to the actual query window", () => { + const periods = [ + period("2026-10-19T22:00:00.000Z", "2026-10-21T03:00:00.000Z", true), + ]; + const start = "2026-10-20T06:00:00.000Z", + end = "2026-10-20T12:00:00.000Z"; + const [group] = groupOnlinePeriods(periods, "UTC", start, end); + expect(group.total).toBe(21600); + expect(group.chips[0].start.toISOString()).toBe(start); + expect(group.chips[0].end.toISOString()).toBe(end); + expect(group.chips[0].ongoing).toBe(false); + const [segment] = buildDaySegments(periods, group.date, "UTC", start, end); + expect(segment.leftPct).toBe(25); + expect(segment.widthPct).toBe(25); + }); +}); + +describe("buildDayWindow", () => { + it("distinguishes excluded hours from a partially observed day", () => { + const window = buildDayWindow( + "2026-10-20", + "2026-10-20T06:00:00Z", + "2026-10-20T12:00:00Z", + "UTC", + ); + expect(window.leftPct).toBe(25); + expect(window.rightPct).toBe(50); + expect(window.seconds).toBe(21600); + expect(dailyOnlineRate(21600, window.seconds)).toBe(100); + expect(dailyOnlineRate(0, 0)).toBe(0); + }); + it.each([ + ["2026-03-08", "2026-03-08T05:00:00Z", "2026-03-09T04:00:00Z", 23], + ["2026-11-01", "2026-11-01T04:00:00Z", "2026-11-02T05:00:00Z", 25], + ])("uses actual elapsed time across DST on %s", (date, start, end, hours) => { + const window = buildDayWindow(date, start, end, "America/New_York"); + expect(window.seconds).toBe(Number(hours) * 3600); + expect(window.leftPct).toBe(0); + expect(window.rightPct).toBe(100); + expect(dailyOnlineRate(window.seconds, window.seconds)).toBe(100); + }); +}); + +describe("formatPeriodMoment", () => { + it("renders a segment ending at the next midnight as 24:00", () => { + const day = panelDay("2026-10-01", "UTC"); + expect( + formatPeriodMoment(panelNextDayStart("2026-10-01", "UTC"), day, "UTC"), + ).toBe("24:00"); + expect( + formatPeriodMoment( + panelNextDayStart("2026-10-01", "UTC"), + day, + "UTC", + true, + ), + ).toBe("24:00:00"); + expect( + formatPeriodMoment( + panelDay("2026-10-01T23:00:00.000Z", "UTC"), + day, + "UTC", + ), + ).toBe("23:00"); + }); +}); + +describe("buildDaySegments", () => { + it("maps positions by panel wall clock on a spring-forward day", () => { + const tz = "America/New_York"; + const [fullDay] = buildDaySegments( + [period("2026-03-08T05:00:00.000Z", "2026-03-09T04:00:00.000Z")], + "2026-03-08", + tz, + ); + // 00:00 EST → 次日 00:00 EDT,全天应铺满 0–100% + expect(fullDay.leftPct).toBe(0); + expect(fullDay.widthPct).toBe(100); + + const [noon] = buildDaySegments( + [period("2026-03-08T16:00:00.000Z", "2026-03-08T18:00:00.000Z")], + "2026-03-08", + tz, + ); + // 12:00 EDT 必须落在 50%,而不是 elapsed 11h 的 45.8% + expect(noon.leftPct).toBeCloseTo(50, 5); + expect(noon.widthPct).toBeCloseTo((2 / 24) * 100, 5); + + const [evening] = buildDaySegments( + [period("2026-03-09T02:00:00.000Z", "2026-03-09T04:00:00.000Z")], + "2026-03-08", + tz, + ); + // 22:00 EST → 日界 00:00 EDT:日界必须用挂钟零点,不能加 24h + expect(evening.leftPct).toBeCloseTo((22 / 24) * 100, 5); + expect(evening.widthPct).toBeCloseTo((2 / 24) * 100, 5); + }); + + it("keeps a full day at 100% on a fall-back day", () => { + const [fullDay] = buildDaySegments( + [period("2026-11-01T04:00:00.000Z", "2026-11-02T05:00:00.000Z")], + "2026-11-01", + "America/New_York", + ); + expect(fullDay.leftPct).toBe(0); + expect(fullDay.widthPct).toBe(100); + }); + + it("marks only the segment ending at the live session end as ongoing", () => { + const periods = [ + period("2026-10-01T22:00:00.000Z", "2026-10-02T06:00:00.000Z", true), + ]; + const [previousDay] = buildDaySegments(periods, "2026-10-01", "UTC"); + const [currentDay] = buildDaySegments(periods, "2026-10-02", "UTC"); + + // 前一天被日界裁剪,不能显示为 Ongoing + expect(previousDay.live).toBe(false); + expect(previousDay.segEnd.format("HH:mm")).toBe("00:00"); + // 当天这一段才真正结束于会话末端 + expect(currentDay.live).toBe(true); + expect(currentDay.leftPct).toBe(0); + expect(currentDay.widthPct).toBeCloseTo((6 / 24) * 100, 5); + }); +}); diff --git a/src/types/nezha-api.ts b/src/types/nezha-api.ts index 4f8f6f8..b1bbf91 100644 --- a/src/types/nezha-api.ts +++ b/src/types/nezha-api.ts @@ -205,3 +205,36 @@ export interface ServerMetricsResponse { success: boolean; data: ServerMetricsData; } + +export interface OnlinePeriod { + start: string; + end: string; + ongoing: boolean; +} + +export interface DailyOnlineStat { + date: string; + online_seconds: number; +} + +export interface OnlineHistoryData { + server_id: number; + server_name: string; + days: number; + max_days: number; + /** 面板时区(Daily 拆日的日界),前端按同一时区渲染 */ + timezone?: string; + window_start: string; + window_end: string; + total_online_seconds: number; + online_rate: number; + currently_online: boolean; + last_active?: string; + periods: OnlinePeriod[]; + daily: DailyOnlineStat[]; +} + +export interface OnlineHistoryResponse { + success: boolean; + data: OnlineHistoryData; +} diff --git a/vite.config.ts b/vite.config.ts index 23f21e5..d195f1f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,16 @@ import path from "node:path"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vitest/config"; +// Node ≥25 默认启用实验性 webstorage:其全局 localStorage 在无有效存储文件时 +// 会遮蔽 jsdom 注入的同名全局(localStorage.clear 变为非函数)。测试 worker +// 进程统一禁用,保证 jsdom 的 storage 生效。 +process.env.NODE_OPTIONS = [ + process.env.NODE_OPTIONS, + "--no-experimental-webstorage", +] + .filter(Boolean) + .join(" "); + // Get git commit hash const getGitHash = () => { try { From 247cbcf5acb4bc0dff636b9a156a0cc1d92a02f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BB=93=E9=BC=A0?= <71394853+hamster1963@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:05:14 +0800 Subject: [PATCH 10/13] fix(deps): update vulnerable runtime and development dependencies (#101) --- package.json | 10 +- pnpm-lock.yaml | 619 +++++++++++++++++++++++++++++-------------------- 2 files changed, 375 insertions(+), 254 deletions(-) diff --git a/package.json b/package.json index f65e27d..2a6114c 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "react": "19.2.7", "react-dom": "19.2.7", "react-i18next": "17.0.8", - "react-router-dom": "^7.18.1", + "react-router-dom": "^7.18.4", "recharts": "3.8.1", "sonner": "2.0.7", "tailwind-merge": "3.6.0", @@ -74,14 +74,14 @@ "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "@vitejs/plugin-react": "^6.0.3", - "@vitest/coverage-v8": "^4.1.9", + "@vitest/coverage-v8": "^4.1.12", "globals": "17.6.0", "jsdom": "^29.1.1", - "postcss": "8.5.15", + "postcss": "8.5.31", "tailwindcss": "^4.3.2", "typescript": "^7.0.2", - "vite": "^8.1.3", - "vitest": "^4.1.9", + "vite": "^8.1.6", + "vitest": "^4.1.12", "ws": "^8.21.0" }, "packageManager": "pnpm@11.5.1+sha512.93f7b57422ea7068257235b4c16eb60762eb68e1dc23723199cc739043ea9be2c4143274a399d8c6defa2b1176226d9ca1c4b63482d6200c1a8fbaa78c1d1485" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11c1e1c..761c6a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,8 +108,8 @@ importers: specifier: 17.0.8 version: 17.0.8(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2) react-router-dom: - specifier: ^7.18.1 - version: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: ^7.18.4 + version: 7.18.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) recharts: specifier: 3.8.1 version: 3.8.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@18.3.1)(react@19.2.7)(redux@5.0.1) @@ -149,10 +149,10 @@ importers: version: 19.2.3(@types/react@19.2.17) '@vitejs/plugin-react': specifier: ^6.0.3 - version: 6.0.3(vite@8.1.3(@types/node@25.9.2)(jiti@2.7.0)) + version: 6.0.3(vite@8.1.6(@types/node@25.9.2)(jiti@2.7.0)) '@vitest/coverage-v8': - specifier: ^4.1.9 - version: 4.1.9(vitest@4.1.9) + specifier: ^4.1.12 + version: 4.1.12(vitest@4.1.12) globals: specifier: 17.6.0 version: 17.6.0 @@ -160,8 +160,8 @@ importers: specifier: ^29.1.1 version: 29.1.1 postcss: - specifier: 8.5.15 - version: 8.5.15 + specifier: 8.5.31 + version: 8.5.31 tailwindcss: specifier: ^4.3.2 version: 4.3.2 @@ -169,11 +169,11 @@ importers: specifier: ^7.0.2 version: 7.0.2 vite: - specifier: ^8.1.3 - version: 8.1.3(@types/node@25.9.2)(jiti@2.7.0) + specifier: ^8.1.6 + version: 8.1.6(@types/node@25.9.2)(jiti@2.7.0) vitest: - specifier: ^4.1.9 - version: 4.1.9(@types/node@25.9.2)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(vite@8.1.3(@types/node@25.9.2)(jiti@2.7.0)) + specifier: ^4.1.12 + version: 4.1.12(@types/node@25.9.2)(@vitest/coverage-v8@4.1.12)(jsdom@29.1.1)(vite@8.1.6(@types/node@25.9.2)(jiti@2.7.0)) ws: specifier: ^8.21.0 version: 8.21.0 @@ -214,17 +214,21 @@ packages: resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.7': - resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.10': + resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} + engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': - resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} '@bcoe/v8-coverage@1.0.2': @@ -379,20 +383,21 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@napi-rs/wasm-runtime@1.1.6': - resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + '@napi-rs/wasm-runtime@1.2.5': + resolution: {integrity: sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 - '@oxc-project/types@0.138.0': - resolution: {integrity: sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==} + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} '@radix-ui/number@1.1.2': resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} @@ -900,97 +905,97 @@ packages: react-redux: optional: true - '@rolldown/binding-android-arm64@1.1.4': - resolution: {integrity: sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==} + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.1.4': - resolution: {integrity: sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==} + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.1.4': - resolution: {integrity: sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==} + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.1.4': - resolution: {integrity: sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==} + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.1.4': - resolution: {integrity: sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==} + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.1.4': - resolution: {integrity: sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==} + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.1.4': - resolution: {integrity: sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==} + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.1.4': - resolution: {integrity: sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==} + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.1.4': - resolution: {integrity: sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==} + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.1.4': - resolution: {integrity: sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==} + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.1.4': - resolution: {integrity: sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==} + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.1.4': - resolution: {integrity: sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==} + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.1.4': - resolution: {integrity: sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==} + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.1.4': - resolution: {integrity: sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==} + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.1.4': - resolution: {integrity: sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==} + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1131,6 +1136,7 @@ packages: '@testing-library/jest-dom@6.10.0': resolution: {integrity: sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ==} engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + deprecated: Incorrect minor release with breaking changes (Node >=22 and required @testing-library/dom peer). Use 6.9.1 for the 6.x line, or upgrade to 7.0.0. peerDependencies: '@testing-library/dom': '>=10 <11' @@ -1155,8 +1161,8 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' - '@tybys/wasm-util@0.10.3': - resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@tybys/wasm-util@0.10.4': + resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -1353,20 +1359,20 @@ packages: babel-plugin-react-compiler: optional: true - '@vitest/coverage-v8@4.1.9': - resolution: {integrity: sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==} + '@vitest/coverage-v8@4.1.12': + resolution: {integrity: sha512-6S7/iVknKglLAxMFxQGNppBI0iqJS2NgM6FBxavitj7KaDq/vywOWGf6dGu7F2L1Qy0G6S3uTp7aGshHrzK8JA==} peerDependencies: - '@vitest/browser': 4.1.9 - vitest: 4.1.9 + '@vitest/browser': 4.1.12 + vitest: 4.1.12 peerDependenciesMeta: '@vitest/browser': optional: true - '@vitest/expect@4.1.9': - resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + '@vitest/expect@4.1.12': + resolution: {integrity: sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==} - '@vitest/mocker@4.1.9': - resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + '@vitest/mocker@4.1.12': + resolution: {integrity: sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -1376,20 +1382,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.9': - resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + '@vitest/pretty-format@4.1.12': + resolution: {integrity: sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==} - '@vitest/runner@4.1.9': - resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + '@vitest/runner@4.1.12': + resolution: {integrity: sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==} - '@vitest/snapshot@4.1.9': - resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + '@vitest/snapshot@4.1.12': + resolution: {integrity: sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==} - '@vitest/spy@4.1.9': - resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + '@vitest/spy@4.1.12': + resolution: {integrity: sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==} - '@vitest/utils@4.1.9': - resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + '@vitest/utils@4.1.12': + resolution: {integrity: sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==} ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} @@ -1414,14 +1420,14 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - ast-v8-to-istanbul@1.0.4: - resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} + ast-v8-to-istanbul@1.0.7: + resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==} bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} - chai@6.2.2: - resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} engines: {node: '>=18'} class-variance-authority@0.7.1: @@ -1546,8 +1552,8 @@ packages: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} - es-module-lexer@2.3.0: - resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} es-toolkit@1.49.0: resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} @@ -1670,30 +1676,60 @@ packages: cpu: [arm64] os: [android] + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + lightningcss-darwin-arm64@1.32.0: resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + lightningcss-darwin-x64@1.32.0: resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + lightningcss-freebsd-x64@1.32.0: resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + lightningcss-linux-arm-gnueabihf@1.32.0: resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + lightningcss-linux-arm64-gnu@1.32.0: resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} @@ -1701,6 +1737,13 @@ packages: os: [linux] libc: [glibc] + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} @@ -1708,6 +1751,13 @@ packages: os: [linux] libc: [musl] + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} @@ -1715,6 +1765,13 @@ packages: os: [linux] libc: [glibc] + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} @@ -1722,22 +1779,45 @@ packages: os: [linux] libc: [musl] + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + lightningcss-win32-x64-msvc@1.32.0: resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + lightningcss@1.32.0: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + lru-cache@11.5.1: resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} engines: {node: 20 || >=22} @@ -1758,8 +1838,8 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magicast@0.5.3: - resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + magicast@0.5.5: + resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} @@ -1772,13 +1852,13 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} - nanoid@3.3.15: - resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - obug@2.1.3: - resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} engines: {node: '>=12.20.0'} parse5@8.0.1: @@ -1790,16 +1870,12 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@4.0.5: - resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} - engines: {node: ^10 || ^12 || >=14} - - postcss@8.5.16: - resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + postcss@8.5.31: + resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==} engines: {node: ^10 || ^12 || >=14} pretty-format@27.5.1: @@ -1869,15 +1945,15 @@ packages: '@types/react': optional: true - react-router-dom@7.18.1: - resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==} + react-router-dom@7.18.4: + resolution: {integrity: sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' react-dom: '>=18' - react-router@7.18.1: - resolution: {integrity: sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==} + react-router@7.18.4: + resolution: {integrity: sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' @@ -1927,8 +2003,8 @@ packages: reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} - rolldown@1.1.4: - resolution: {integrity: sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==} + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -1956,15 +2032,15 @@ packages: react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - std-env@4.1.0: - resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} @@ -1998,16 +2074,16 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@1.2.4: - resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} engines: {node: '>=18'} tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} - tinyrainbow@3.1.0: - resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + tinyrainbow@3.2.0: + resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} engines: {node: '>=14.0.0'} tldts-core@7.4.6: @@ -2036,8 +2112,8 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} - undici@7.28.0: - resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + undici@7.30.1: + resolution: {integrity: sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==} engines: {node: '>=20.18.1'} use-callback-ref@1.3.3: @@ -2068,8 +2144,8 @@ packages: victory-vendor@37.3.6: resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} - vite@8.1.3: - resolution: {integrity: sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==} + vite@8.1.6: + resolution: {integrity: sha512-aRAHIxx5SW5zc1sMqFwJU/t5qaFfXb5Hj0J7W0VXihu/X99+346tz6080tO1T1xVIJORX/b3zVTXGFqUXh35uw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -2111,20 +2187,20 @@ packages: yaml: optional: true - vitest@4.1.9: - resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + vitest@4.1.12: + resolution: {integrity: sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.9 - '@vitest/browser-preview': 4.1.9 - '@vitest/browser-webdriverio': 4.1.9 - '@vitest/coverage-istanbul': 4.1.9 - '@vitest/coverage-v8': 4.1.9 - '@vitest/ui': 4.1.9 + '@vitest/browser-playwright': 4.1.12 + '@vitest/browser-preview': 4.1.12 + '@vitest/browser-webdriverio': ^4.1.11 + '@vitest/coverage-istanbul': 4.1.12 + '@vitest/coverage-v8': 4.1.12 + '@vitest/ui': 4.1.12 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -2232,13 +2308,15 @@ snapshots: '@babel/helper-validator-identifier@7.29.7': {} - '@babel/parser@7.29.7': + '@babel/parser@7.29.9': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/runtime@7.29.10': {} '@babel/runtime@7.29.7': {} - '@babel/types@7.29.7': + '@babel/types@7.29.8': dependencies: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 @@ -2351,7 +2429,7 @@ snapshots: '@jridgewell/gen-mapping@0.3.13': dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/remapping@2.3.5': @@ -2361,21 +2439,21 @@ snapshots: '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 - '@tybys/wasm-util': 0.10.3 + '@tybys/wasm-util': 0.10.4 optional: true - '@oxc-project/types@0.138.0': {} + '@oxc-project/types@0.139.0': {} '@radix-ui/number@1.1.2': {} @@ -2882,53 +2960,53 @@ snapshots: react: 19.2.7 react-redux: 9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1) - '@rolldown/binding-android-arm64@1.1.4': + '@rolldown/binding-android-arm64@1.1.5': optional: true - '@rolldown/binding-darwin-arm64@1.1.4': + '@rolldown/binding-darwin-arm64@1.1.5': optional: true - '@rolldown/binding-darwin-x64@1.1.4': + '@rolldown/binding-darwin-x64@1.1.5': optional: true - '@rolldown/binding-freebsd-x64@1.1.4': + '@rolldown/binding-freebsd-x64@1.1.5': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.1.4': + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': optional: true - '@rolldown/binding-linux-arm64-gnu@1.1.4': + '@rolldown/binding-linux-arm64-gnu@1.1.5': optional: true - '@rolldown/binding-linux-arm64-musl@1.1.4': + '@rolldown/binding-linux-arm64-musl@1.1.5': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.1.4': + '@rolldown/binding-linux-ppc64-gnu@1.1.5': optional: true - '@rolldown/binding-linux-s390x-gnu@1.1.4': + '@rolldown/binding-linux-s390x-gnu@1.1.5': optional: true - '@rolldown/binding-linux-x64-gnu@1.1.4': + '@rolldown/binding-linux-x64-gnu@1.1.5': optional: true - '@rolldown/binding-linux-x64-musl@1.1.4': + '@rolldown/binding-linux-x64-musl@1.1.5': optional: true - '@rolldown/binding-openharmony-arm64@1.1.4': + '@rolldown/binding-openharmony-arm64@1.1.5': optional: true - '@rolldown/binding-wasm32-wasi@1.1.4': + '@rolldown/binding-wasm32-wasi@1.1.5': dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true - '@rolldown/binding-win32-arm64-msvc@1.1.4': + '@rolldown/binding-win32-arm64-msvc@1.1.5': optional: true - '@rolldown/binding-win32-x64-msvc@1.1.4': + '@rolldown/binding-win32-x64-msvc@1.1.5': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -2944,7 +3022,7 @@ snapshots: jiti: 2.7.0 lightningcss: 1.32.0 magic-string: 0.30.21 - source-map-js: 1.2.1 + source-map-js: 1.2.2 tailwindcss: 4.3.2 '@tailwindcss/oxide-android-arm64@4.3.2': @@ -3003,7 +3081,7 @@ snapshots: '@alloc/quick-lru': 5.2.0 '@tailwindcss/node': 4.3.2 '@tailwindcss/oxide': 4.3.2 - postcss: 8.5.15 + postcss: 8.5.31 tailwindcss: 4.3.2 '@tanstack/query-core@5.101.0': {} @@ -3032,7 +3110,7 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 - '@babel/runtime': 7.29.7 + '@babel/runtime': 7.29.10 '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 @@ -3064,7 +3142,7 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 - '@tybys/wasm-util@0.10.3': + '@tybys/wasm-util@0.10.4': dependencies: tslib: 2.8.1 optional: true @@ -3186,65 +3264,65 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@vitejs/plugin-react@6.0.3(vite@8.1.3(@types/node@25.9.2)(jiti@2.7.0))': + '@vitejs/plugin-react@6.0.3(vite@8.1.6(@types/node@25.9.2)(jiti@2.7.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.1.3(@types/node@25.9.2)(jiti@2.7.0) + vite: 8.1.6(@types/node@25.9.2)(jiti@2.7.0) - '@vitest/coverage-v8@4.1.9(vitest@4.1.9)': + '@vitest/coverage-v8@4.1.12(vitest@4.1.12)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.9 - ast-v8-to-istanbul: 1.0.4 + '@vitest/utils': 4.1.12 + ast-v8-to-istanbul: 1.0.7 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-reports: 3.2.0 - magicast: 0.5.3 - obug: 2.1.3 - std-env: 4.1.0 - tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@25.9.2)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(vite@8.1.3(@types/node@25.9.2)(jiti@2.7.0)) + magicast: 0.5.5 + obug: 2.2.1 + std-env: 4.3.0 + tinyrainbow: 3.2.0 + vitest: 4.1.12(@types/node@25.9.2)(@vitest/coverage-v8@4.1.12)(jsdom@29.1.1)(vite@8.1.6(@types/node@25.9.2)(jiti@2.7.0)) - '@vitest/expect@4.1.9': + '@vitest/expect@4.1.12': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.9 - '@vitest/utils': 4.1.9 - chai: 6.2.2 - tinyrainbow: 3.1.0 + '@vitest/spy': 4.1.12 + '@vitest/utils': 4.1.12 + chai: 6.3.0 + tinyrainbow: 3.2.0 - '@vitest/mocker@4.1.9(vite@8.1.3(@types/node@25.9.2)(jiti@2.7.0))': + '@vitest/mocker@4.1.12(vite@8.1.6(@types/node@25.9.2)(jiti@2.7.0))': dependencies: - '@vitest/spy': 4.1.9 + '@vitest/spy': 4.1.12 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.3(@types/node@25.9.2)(jiti@2.7.0) + vite: 8.1.6(@types/node@25.9.2)(jiti@2.7.0) - '@vitest/pretty-format@4.1.9': + '@vitest/pretty-format@4.1.12': dependencies: - tinyrainbow: 3.1.0 + tinyrainbow: 3.2.0 - '@vitest/runner@4.1.9': + '@vitest/runner@4.1.12': dependencies: - '@vitest/utils': 4.1.9 + '@vitest/utils': 4.1.12 pathe: 2.0.3 - '@vitest/snapshot@4.1.9': + '@vitest/snapshot@4.1.12': dependencies: - '@vitest/pretty-format': 4.1.9 - '@vitest/utils': 4.1.9 + '@vitest/pretty-format': 4.1.12 + '@vitest/utils': 4.1.12 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.9': {} + '@vitest/spy@4.1.12': {} - '@vitest/utils@4.1.9': + '@vitest/utils@4.1.12': dependencies: - '@vitest/pretty-format': 4.1.9 + '@vitest/pretty-format': 4.1.12 convert-source-map: 2.0.0 - tinyrainbow: 3.1.0 + tinyrainbow: 3.2.0 ansi-regex@5.0.1: {} @@ -3262,7 +3340,7 @@ snapshots: assertion-error@2.0.1: {} - ast-v8-to-istanbul@1.0.4: + ast-v8-to-istanbul@1.0.7: dependencies: '@jridgewell/trace-mapping': 0.3.31 estree-walker: 3.0.3 @@ -3272,7 +3350,7 @@ snapshots: dependencies: require-from-string: 2.0.2 - chai@6.2.2: {} + chai@6.3.0: {} class-variance-authority@0.7.1: dependencies: @@ -3301,7 +3379,7 @@ snapshots: css-tree@3.2.1: dependencies: mdn-data: 2.27.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 css.escape@1.5.1: {} @@ -3381,7 +3459,7 @@ snapshots: entities@8.0.0: {} - es-module-lexer@2.3.0: {} + es-module-lexer@2.3.2: {} es-toolkit@1.49.0: {} @@ -3393,9 +3471,9 @@ snapshots: expect-type@1.4.0: {} - fdir@6.5.0(picomatch@4.0.5): + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: - picomatch: 4.0.5 + picomatch: 4.0.7 flag-icons@7.0.0: {} @@ -3476,7 +3554,7 @@ snapshots: saxes: 6.0.0 symbol-tree: 3.2.4 tough-cookie: 6.0.1 - undici: 7.28.0 + undici: 7.30.1 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 @@ -3488,36 +3566,69 @@ snapshots: lightningcss-android-arm64@1.32.0: optional: true + lightningcss-android-arm64@1.33.0: + optional: true + lightningcss-darwin-arm64@1.32.0: optional: true + lightningcss-darwin-arm64@1.33.0: + optional: true + lightningcss-darwin-x64@1.32.0: optional: true + lightningcss-darwin-x64@1.33.0: + optional: true + lightningcss-freebsd-x64@1.32.0: optional: true + lightningcss-freebsd-x64@1.33.0: + optional: true + lightningcss-linux-arm-gnueabihf@1.32.0: optional: true + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + lightningcss-linux-arm64-gnu@1.32.0: optional: true + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + lightningcss-linux-arm64-musl@1.32.0: optional: true + lightningcss-linux-arm64-musl@1.33.0: + optional: true + lightningcss-linux-x64-gnu@1.32.0: optional: true + lightningcss-linux-x64-gnu@1.33.0: + optional: true + lightningcss-linux-x64-musl@1.32.0: optional: true + lightningcss-linux-x64-musl@1.33.0: + optional: true + lightningcss-win32-arm64-msvc@1.32.0: optional: true + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + lightningcss-win32-x64-msvc@1.32.0: optional: true + lightningcss-win32-x64-msvc@1.33.0: + optional: true + lightningcss@1.32.0: dependencies: detect-libc: 2.1.2 @@ -3534,6 +3645,22 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + lru-cache@11.5.1: {} lucide-react@1.17.0(react@19.2.7): @@ -3546,13 +3673,13 @@ snapshots: magic-string@0.30.21: dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/sourcemap-codec': 1.6.0 - magicast@0.5.3: + magicast@0.5.5: dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - source-map-js: 1.2.1 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + source-map-js: 1.2.2 make-dir@4.0.0: dependencies: @@ -3562,9 +3689,9 @@ snapshots: min-indent@1.0.1: {} - nanoid@3.3.15: {} + nanoid@3.3.20: {} - obug@2.1.3: {} + obug@2.2.1: {} parse5@8.0.1: dependencies: @@ -3574,19 +3701,13 @@ snapshots: picocolors@1.1.1: {} - picomatch@4.0.5: {} + picomatch@4.0.7: {} - postcss@8.5.15: + postcss@8.5.31: dependencies: - nanoid: 3.3.15 + nanoid: 3.3.20 picocolors: 1.1.1 - source-map-js: 1.2.1 - - postcss@8.5.16: - dependencies: - nanoid: 3.3.15 - picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 pretty-format@27.5.1: dependencies: @@ -3644,13 +3765,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - react-router-dom@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + react-router-dom@7.18.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - react-router: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react-router: 7.18.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - react-router@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + react-router@7.18.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: cookie: 1.1.1 react: 19.2.7 @@ -3703,26 +3824,26 @@ snapshots: reselect@5.1.1: {} - rolldown@1.1.4: + rolldown@1.1.5: dependencies: - '@oxc-project/types': 0.138.0 + '@oxc-project/types': 0.139.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.1.4 - '@rolldown/binding-darwin-arm64': 1.1.4 - '@rolldown/binding-darwin-x64': 1.1.4 - '@rolldown/binding-freebsd-x64': 1.1.4 - '@rolldown/binding-linux-arm-gnueabihf': 1.1.4 - '@rolldown/binding-linux-arm64-gnu': 1.1.4 - '@rolldown/binding-linux-arm64-musl': 1.1.4 - '@rolldown/binding-linux-ppc64-gnu': 1.1.4 - '@rolldown/binding-linux-s390x-gnu': 1.1.4 - '@rolldown/binding-linux-x64-gnu': 1.1.4 - '@rolldown/binding-linux-x64-musl': 1.1.4 - '@rolldown/binding-openharmony-arm64': 1.1.4 - '@rolldown/binding-wasm32-wasi': 1.1.4 - '@rolldown/binding-win32-arm64-msvc': 1.1.4 - '@rolldown/binding-win32-x64-msvc': 1.1.4 + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 saxes@6.0.0: dependencies: @@ -3741,11 +3862,11 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - source-map-js@1.2.1: {} + source-map-js@1.2.2: {} stackback@0.0.2: {} - std-env@4.1.0: {} + std-env@4.3.0: {} strip-indent@3.0.0: dependencies: @@ -3771,14 +3892,14 @@ snapshots: tinybench@2.9.0: {} - tinyexec@1.2.4: {} + tinyexec@1.3.1: {} tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 - tinyrainbow@3.1.0: {} + tinyrainbow@3.2.0: {} tldts-core@7.4.6: {} @@ -3821,7 +3942,7 @@ snapshots: undici-types@7.24.6: {} - undici@7.28.0: {} + undici@7.30.1: {} use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.7): dependencies: @@ -3859,43 +3980,43 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite@8.1.3(@types/node@25.9.2)(jiti@2.7.0): + vite@8.1.6(@types/node@25.9.2)(jiti@2.7.0): dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.5 - postcss: 8.5.16 - rolldown: 1.1.4 + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.31 + rolldown: 1.1.5 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 25.9.2 fsevents: 2.3.3 jiti: 2.7.0 - vitest@4.1.9(@types/node@25.9.2)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1)(vite@8.1.3(@types/node@25.9.2)(jiti@2.7.0)): + vitest@4.1.12(@types/node@25.9.2)(@vitest/coverage-v8@4.1.12)(jsdom@29.1.1)(vite@8.1.6(@types/node@25.9.2)(jiti@2.7.0)): dependencies: - '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.1.3(@types/node@25.9.2)(jiti@2.7.0)) - '@vitest/pretty-format': 4.1.9 - '@vitest/runner': 4.1.9 - '@vitest/snapshot': 4.1.9 - '@vitest/spy': 4.1.9 - '@vitest/utils': 4.1.9 - es-module-lexer: 2.3.0 + '@vitest/expect': 4.1.12 + '@vitest/mocker': 4.1.12(vite@8.1.6(@types/node@25.9.2)(jiti@2.7.0)) + '@vitest/pretty-format': 4.1.12 + '@vitest/runner': 4.1.12 + '@vitest/snapshot': 4.1.12 + '@vitest/spy': 4.1.12 + '@vitest/utils': 4.1.12 + es-module-lexer: 2.3.2 expect-type: 1.4.0 magic-string: 0.30.21 - obug: 2.1.3 + obug: 2.2.1 pathe: 2.0.3 - picomatch: 4.0.5 - std-env: 4.1.0 + picomatch: 4.0.7 + std-env: 4.3.0 tinybench: 2.9.0 - tinyexec: 1.2.4 + tinyexec: 1.3.1 tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vite: 8.1.3(@types/node@25.9.2)(jiti@2.7.0) + tinyrainbow: 3.2.0 + vite: 8.1.6(@types/node@25.9.2)(jiti@2.7.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.2 - '@vitest/coverage-v8': 4.1.9(vitest@4.1.9) + '@vitest/coverage-v8': 4.1.12(vitest@4.1.12) jsdom: 29.1.1 transitivePeerDependencies: - msw From cc33841eded9ccf3c8c9772f51525faa1ac921a0 Mon Sep 17 00:00:00 2001 From: hamster1963 <1410514192@qq.com> Date: Thu, 22 Oct 2026 12:06:54 +0800 Subject: [PATCH 11/13] chore(release): v2.5.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2a6114c..e1a73ba 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nazha-dash-v2", "private": true, - "version": "2.4.3", + "version": "2.5.0", "type": "module", "engines": { "node": ">=22", From 512ef01b34a53d65a5567f560cb9aea5b4457543 Mon Sep 17 00:00:00 2001 From: hamster1963 <1410514192@qq.com> Date: Thu, 22 Oct 2026 17:09:24 +0800 Subject: [PATCH 12/13] fix(online-history): default to one-day history --- src/components/ServerDetailOnlineHistory.tsx | 2 +- .../loading/OnlineHistorySkeleton.tsx | 2 +- .../server-detail-online-history.test.tsx | 18 +++++++++++------- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/components/ServerDetailOnlineHistory.tsx b/src/components/ServerDetailOnlineHistory.tsx index af93f8c..48fbde4 100644 --- a/src/components/ServerDetailOnlineHistory.tsx +++ b/src/components/ServerDetailOnlineHistory.tsx @@ -245,7 +245,7 @@ export default function ServerDetailOnlineHistory({ }) { const { t } = useTranslation(); const { lastData } = useWebSocketContext(); - const [days, setDays] = useState(DAY_OPTIONS[1]); + const [days, setDays] = useState(DAY_OPTIONS[0]); const [guestMaxDays, setGuestMaxDays] = useState(null); const [periodMode, setPeriodMode] = useState("online"); const [visibleDays, setVisibleDays] = useState(PERIODS_INITIAL_DAYS); diff --git a/src/components/loading/OnlineHistorySkeleton.tsx b/src/components/loading/OnlineHistorySkeleton.tsx index c927d68..051d42c 100644 --- a/src/components/loading/OnlineHistorySkeleton.tsx +++ b/src/components/loading/OnlineHistorySkeleton.tsx @@ -30,7 +30,7 @@ function Metric({ footer = true }: { footer?: boolean }) { ); } -export default function OnlineHistorySkeleton({ days = 7 }: { days?: number }) { +export default function OnlineHistorySkeleton({ days = 1 }: { days?: number }) { const { t } = useTranslation(); const dayCount = Math.min(31, Math.max(1, days) + 1); const cardClassName = cn({ diff --git a/src/test/components/server-detail-online-history.test.tsx b/src/test/components/server-detail-online-history.test.tsx index 207d4a8..2eef023 100644 --- a/src/test/components/server-detail-online-history.test.tsx +++ b/src/test/components/server-detail-online-history.test.tsx @@ -82,7 +82,7 @@ describe("ServerDetailOnlineHistory", () => { screen.getByRole("status", { name: "serverOnlineHistory.loading" }), ).toHaveAttribute("aria-busy", "true"); expect(screen.getByTestId("online-history-loading")).toBeInTheDocument(); - expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 7); + expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 1); }); it("keeps existing content instead of flashing the skeleton while changing days", async () => { @@ -299,7 +299,7 @@ describe("ServerDetailOnlineHistory", () => { await user.click(screen.getByText("serverOnlineHistory.days30")); expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledTimes(1); - expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 7); + expect(apiMocks.fetchServerOnlineHistory).toHaveBeenCalledWith(7, 1); }); it("shows the login hint when the API rejects anonymous guests", async () => { @@ -314,30 +314,34 @@ describe("ServerDetailOnlineHistory", () => { ).toBeInTheDocument(); }); - it("falls back to one day and retries automatically for guests", async () => { + it("falls back to one day when guests request a longer window", async () => { const user = userEvent.setup(); apiMocks.fetchServerOnlineHistory + .mockResolvedValueOnce(onlineHistoryResponse()) .mockRejectedValueOnce( new Error("unauthorized: only 1d data available for guests"), ) - .mockResolvedValue(onlineHistoryResponse()); + .mockResolvedValue(onlineHistoryResponse(1)); renderWithQuery(); - // 首次 7d 被拒后自动以 1d 重试并渲染数据 + // 首屏直接请求 1d;手动选择 7d 被拒后自动回退到 1d。 await screen.findByText("serverOnlineHistory.dailyOnline"); + expect(apiMocks.fetchServerOnlineHistory).toHaveBeenNthCalledWith(1, 7, 1); + await user.click(screen.getByText("serverOnlineHistory.days7")); await waitFor(() => { expect(apiMocks.fetchServerOnlineHistory).toHaveBeenNthCalledWith( - 2, + 3, 7, 1, ); }); + expect(apiMocks.fetchServerOnlineHistory).toHaveBeenNthCalledWith(2, 7, 7); // 更长窗口被锁定(游客上限 1 天),点击不再发起新请求 const calls = apiMocks.fetchServerOnlineHistory.mock.calls.length; await user.click(screen.getByText("serverOnlineHistory.days30")); - expect(apiMocks.fetchServerOnlineHistory).toHaveBeenNthCalledWith(2, 7, 1); + expect(apiMocks.fetchServerOnlineHistory).toHaveBeenNthCalledWith(3, 7, 1); expect(apiMocks.fetchServerOnlineHistory.mock.calls.length).toBe(calls); }); From 576e2307948ea6a12e4cc072039906ce801b23a2 Mon Sep 17 00:00:00 2001 From: hamster1963 <1410514192@qq.com> Date: Thu, 22 Oct 2026 17:09:24 +0800 Subject: [PATCH 13/13] chore(release): v2.5.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e1a73ba..2f31e5c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nazha-dash-v2", "private": true, - "version": "2.5.0", + "version": "2.5.1", "type": "module", "engines": { "node": ">=22",