From c9fa6f7a8846c6df5fcef2324e937a420f538fd3 Mon Sep 17 00:00:00 2001 From: admin_gitea Date: Sun, 16 Aug 2026 19:33:03 +0800 Subject: [PATCH] feat: complete 2.0.7.12 platform overhaul --- installer/ymhut_box_winui.iss | 13 +- scripts/build-winui.ps1 | 47 +- server/unified-management/README.md | 17 +- .../cmd/unified-management/app/app.go | 2 + .../nginx/unified-management.conf.example | 86 + .../internal/adminassets/adminassets.go | 205 ++ .../adminassets/adminassets_embed_test.go | 15 + .../internal/adminassets/adminassets_test.go | 63 + .../unified-management/internal/auth/auth.go | 16 +- .../internal/auth/auth_test.go | 21 +- .../internal/config/config.go | 14 +- .../internal/config/config_test.go | 24 + .../internal/config/preflight.go | 58 +- .../internal/db/admin_performance_test.go | 80 + .../internal/db/audit_store.go | 131 +- .../internal/db/feedback_store.go | 21 +- .../internal/db/release_store.go | 81 + .../internal/db/scanner_util.go | 23 + .../unified-management/internal/db/schema.go | 13 +- .../internal/db/system_logs.go | 249 +-- .../internal/health/health.go | 45 +- .../internal/health/health_test.go | 38 + .../internal/reference/data/cpu-ranking.json | 34 + .../internal/reference/data/gpu-ranking.json | 34 + .../internal/reference/reference.go | 76 + .../internal/reference/reference_test.go | 46 + .../internal/releases/releases.go | 485 +++-- .../internal/releases/releases_test.go | 266 +++ .../internal/sources/sources.go | 17 + .../internal/web/admin_feedback_routes.go | 2 +- .../internal/web/admin_release_routes.go | 7 + .../internal/web/admin_system_routes.go | 57 +- .../internal/web/client_routes.go | 43 +- .../internal/web/dashboard_cache.go | 85 + .../internal/web/dashboard_cache_test.go | 42 + .../internal/web/public_snapshot.go | 18 + .../internal/web/response.go | 56 +- .../unified-management/internal/web/router.go | 85 +- .../internal/web/router_test.go | 269 ++- .../internal/web/static_routes.go | 68 +- .../scripts/build-release.ps1 | 13 +- .../scripts/build-release.sh | 12 +- .../scripts/validate-admin-build.mjs | 48 + .../web/admin/package-lock.json | 1722 ++++++++++++++++- .../unified-management/web/admin/package.json | 13 +- .../unified-management/web/admin/src/App.vue | 744 +++---- .../web/admin/src/api/admin.test.ts | 125 ++ .../web/admin/src/api/admin.ts | 43 +- .../web/admin/src/branding.ts | 6 +- .../src/components/LatencyTrendChart.vue | 77 + .../unified-management/web/admin/src/env.d.ts | 10 + .../unified-management/web/admin/src/main.ts | 104 +- .../web/admin/src/router.test.ts | 41 + .../web/admin/src/router.ts | 98 + .../web/admin/src/stores/auth.ts | 5 +- .../web/admin/src/stores/dashboard.ts | 26 +- .../web/admin/src/stores/feedback.ts | 5 +- .../web/admin/src/stores/legacy.ts | 11 +- .../web/admin/src/stores/releases.ts | 9 +- .../web/admin/src/stores/sources.ts | 5 +- .../web/admin/src/stores/stores.test.ts | 54 + .../web/admin/src/stores/system.ts | 34 +- .../web/admin/src/styles.css | 134 +- .../web/admin/src/types/admin.ts | 411 ++++ .../web/admin/src/utils/adminAsync.test.ts | 40 + .../web/admin/src/utils/eventBatcher.ts | 27 + .../web/admin/src/utils/latestRequest.ts | 32 + .../admin/src/utils/resourceRecovery.test.ts | 24 + .../web/admin/src/utils/resourceRecovery.ts | 21 + .../web/admin/src/views/AuditLogView.vue | 3 +- .../web/admin/src/views/DashboardView.vue | 57 +- .../web/admin/src/views/EndpointsView.vue | 4 +- .../web/admin/src/views/FeedbacksView.vue | 3 +- .../web/admin/src/views/LegacyJsonView.vue | 5 +- .../web/admin/src/views/LoginView.test.ts | 54 + .../web/admin/src/views/LoginView.vue | 33 + .../web/admin/src/views/ReleasesView.vue | 11 +- .../web/admin/src/views/SourcesView.vue | 5 +- .../web/admin/src/views/SystemView.vue | 17 +- .../web/admin/tsconfig.json | 4 +- .../web/admin/vite.config.ts | 21 +- server/unified-management/web/embed.go | 5 +- .../Data/HardwareRankingService.cs | 204 ++ .../Music/KugouMusicProvider.cs | 522 +++++ src/YMhut.Box.Core/Music/MusicHistoryStore.cs | 85 + src/YMhut.Box.Core/Music/MusicModels.cs | 91 +- .../Music/MusicProviderRegistry.cs | 58 + .../Music/MusicSeekInteraction.cs | 75 + .../Music/NeteaseMusicProvider.cs | 308 ++- .../Plugins/Runtime/ShellRuntimeService.cs | 4 + .../Processes/ShellOutputDecoder.cs | 108 ++ .../Settings/AgreementDocument.cs | 88 +- .../System/PrivilegedOperations.cs | 30 +- .../Tools/BuiltinReferenceToolService.cs | 28 +- src/YMhut.Box.Core/Tools/NexNativeTools.cs | 82 +- src/YMhut.Box.Core/Tools/ToolExecutor.cs | 4 +- .../Tools/ToolResultExperienceCatalog.cs | 22 +- .../Tools/ToolboxLayoutCalculator.cs | 7 +- .../Tools/WindowsToolsService.cs | 457 +++++ .../InstallerEngine.cs | 4 +- .../InstallerWindow.cs | 476 ++++- src/YMhut.Box.PrivilegedHost/Program.cs | 42 +- .../AgreementAcceptanceStoreTests.cs | 30 + src/YMhut.Box.Tests/HardwareRankingTests.cs | 107 + .../KugouMusicProviderTests.cs | 325 ++++ src/YMhut.Box.Tests/MusicAndHotkeyTests.cs | 134 ++ src/YMhut.Box.Tests/MusicSessionStoreTests.cs | 41 + .../NeteaseMusicProviderTests.cs | 359 ++++ .../PrivilegedOperationTests.cs | 38 +- .../ShellOutputDecoderTests.cs | 40 + src/YMhut.Box.Tests/ToolExecutorTests.cs | 12 + src/YMhut.Box.Tests/ToolboxLayoutTests.cs | 3 + src/box-winUI/Assets/tool-pages/tool-page.css | 105 +- src/box-winUI/Assets/tool-pages/tool-page.js | 2 +- src/box-winUI/Assets/tool-results/result.css | 77 +- src/box-winUI/Assets/tool-results/result.js | 4 +- src/box-winUI/Controls/AppShell.cs | 37 +- src/box-winUI/Controls/AppTitleBar.cs | 27 +- src/box-winUI/MainWindow.xaml.cs | 47 +- src/box-winUI/ModernUi.cs | 232 ++- src/box-winUI/Package.appxmanifest | 2 +- src/box-winUI/Services/AppServices.cs | 18 +- .../Services/MusicLyricsCoordinator.cs | 217 +++ .../Services/WebView2EnvironmentFactory.cs | 10 + .../Services/WindowsMusicPlaybackService.cs | 240 ++- src/box-winUI/Styles/ShellResources.xaml | 8 +- src/box-winUI/Views/AboutPage.cs | 25 +- .../BuiltinReferenceToolPage.ModernChrome.cs | 39 +- src/box-winUI/Views/DownloadManagerPage.cs | 26 +- .../ExternalToolDetailPage.ModernChrome.cs | 39 +- src/box-winUI/Views/FeedbackPage.cs | 17 +- src/box-winUI/Views/HardwarePage.cs | 25 +- src/box-winUI/Views/HardwareRankingPage.cs | 259 +++ src/box-winUI/Views/HomePage.cs | 83 +- src/box-winUI/Views/IndependentWindowShell.cs | 37 +- src/box-winUI/Views/LogPage.cs | 46 +- .../Views/MediaPlayerLauncherPage.cs | 35 +- src/box-winUI/Views/NetworkMusicPage.cs | 1075 ++++++++-- src/box-winUI/Views/NexNativeToolPage.cs | 380 +++- src/box-winUI/Views/OptimizationPage.cs | 132 +- src/box-winUI/Views/PluginDocsPage.cs | 28 +- src/box-winUI/Views/PluginHostPage.cs | 32 +- src/box-winUI/Views/PluginPage.cs | 42 +- src/box-winUI/Views/PluginRuntimePage.cs | 37 +- .../Views/SafeBrowserLauncherPage.cs | 35 +- src/box-winUI/Views/SafeBrowserPage.cs | 790 ++++++-- src/box-winUI/Views/ServiceStatusPage.cs | 53 +- src/box-winUI/Views/SettingsPage.cs | 58 +- src/box-winUI/Views/StartupCheckResultPage.cs | 56 +- .../Views/ToolDetailPage.ModernChrome.cs | 52 +- src/box-winUI/Views/ToolboxPage.cs | 130 +- .../Tools/AdaptiveToolPage.ModernChrome.cs | 45 +- src/box-winUI/Views/Tools/AdaptiveToolPage.cs | 10 +- .../Tools/DevEnvironmentConfigToolPage.cs | 36 +- .../Views/Tools/HtmlJsPlaygroundToolPage.cs | 54 +- src/box-winUI/Views/Tools/RandomCinemaPage.cs | 36 +- src/box-winUI/YMhut.Box.WinUI.csproj | 4 + update-notice/2.0.7.12.json | 100 + update-notice/total.json | 88 +- 159 files changed, 13243 insertions(+), 2539 deletions(-) create mode 100644 server/unified-management/deploy/nginx/unified-management.conf.example create mode 100644 server/unified-management/internal/adminassets/adminassets.go create mode 100644 server/unified-management/internal/adminassets/adminassets_embed_test.go create mode 100644 server/unified-management/internal/adminassets/adminassets_test.go create mode 100644 server/unified-management/internal/db/admin_performance_test.go create mode 100644 server/unified-management/internal/health/health_test.go create mode 100644 server/unified-management/internal/reference/data/cpu-ranking.json create mode 100644 server/unified-management/internal/reference/data/gpu-ranking.json create mode 100644 server/unified-management/internal/reference/reference.go create mode 100644 server/unified-management/internal/reference/reference_test.go create mode 100644 server/unified-management/internal/web/dashboard_cache.go create mode 100644 server/unified-management/internal/web/dashboard_cache_test.go create mode 100644 server/unified-management/scripts/validate-admin-build.mjs create mode 100644 server/unified-management/web/admin/src/api/admin.test.ts create mode 100644 server/unified-management/web/admin/src/components/LatencyTrendChart.vue create mode 100644 server/unified-management/web/admin/src/env.d.ts create mode 100644 server/unified-management/web/admin/src/router.test.ts create mode 100644 server/unified-management/web/admin/src/router.ts create mode 100644 server/unified-management/web/admin/src/stores/stores.test.ts create mode 100644 server/unified-management/web/admin/src/types/admin.ts create mode 100644 server/unified-management/web/admin/src/utils/adminAsync.test.ts create mode 100644 server/unified-management/web/admin/src/utils/eventBatcher.ts create mode 100644 server/unified-management/web/admin/src/utils/latestRequest.ts create mode 100644 server/unified-management/web/admin/src/utils/resourceRecovery.test.ts create mode 100644 server/unified-management/web/admin/src/utils/resourceRecovery.ts create mode 100644 server/unified-management/web/admin/src/views/LoginView.test.ts create mode 100644 server/unified-management/web/admin/src/views/LoginView.vue create mode 100644 src/YMhut.Box.Core/Data/HardwareRankingService.cs create mode 100644 src/YMhut.Box.Core/Music/KugouMusicProvider.cs create mode 100644 src/YMhut.Box.Core/Music/MusicHistoryStore.cs create mode 100644 src/YMhut.Box.Core/Music/MusicProviderRegistry.cs create mode 100644 src/YMhut.Box.Core/Music/MusicSeekInteraction.cs create mode 100644 src/YMhut.Box.Core/Processes/ShellOutputDecoder.cs create mode 100644 src/YMhut.Box.Core/Tools/WindowsToolsService.cs create mode 100644 src/YMhut.Box.Tests/HardwareRankingTests.cs create mode 100644 src/YMhut.Box.Tests/KugouMusicProviderTests.cs create mode 100644 src/YMhut.Box.Tests/MusicSessionStoreTests.cs create mode 100644 src/YMhut.Box.Tests/NeteaseMusicProviderTests.cs create mode 100644 src/YMhut.Box.Tests/ShellOutputDecoderTests.cs create mode 100644 src/box-winUI/Services/MusicLyricsCoordinator.cs create mode 100644 src/box-winUI/Views/HardwareRankingPage.cs create mode 100644 update-notice/2.0.7.12.json diff --git a/installer/ymhut_box_winui.iss b/installer/ymhut_box_winui.iss index 2356ce2..f1b3590 100644 --- a/installer/ymhut_box_winui.iss +++ b/installer/ymhut_box_winui.iss @@ -56,8 +56,12 @@ DefaultGroupName={#MyAppName} OutputDir=..\installer_output OutputBaseFilename=YMhut_Box_WinUI_Setup_{#MyAppVersion} Compression=lzma2 -CompressionThreads=auto -LZMADictionarySize=1048576 +; Preserve a high compression ratio without returning to the 1 GiB dictionary, +; which requires more than 11 GiB of working memory for this solid payload. +CompressionThreads=1 +LZMADictionarySize=262144 +LZMANumFastBytes=273 +LZMAUseSeparateProcess=yes SolidCompression=yes WizardStyle=modern ShowLanguageDialog=yes @@ -1059,7 +1063,10 @@ begin if (CurProgress > 0) then begin - ProgressPercent := CurProgress * 100 div MaxProgress; + { CurProgress can represent tens or hundreds of millions of bytes. Cast + before multiplying so large payloads do not raise an integer-overflow + runtime error from the progress callback. } + ProgressPercent := (Int64(CurProgress) * 100) div MaxProgress; if ProgressPercent <> LastInstallProgressPercent then begin LastInstallProgressPercent := ProgressPercent; diff --git a/scripts/build-winui.ps1 b/scripts/build-winui.ps1 index a7f5fee..5544594 100644 --- a/scripts/build-winui.ps1 +++ b/scripts/build-winui.ps1 @@ -43,7 +43,6 @@ $ToolStateRoot = Join-Path $Root '.cache\tool_state' $NuGetRoot = Join-Path $Root '.cache\nuget' $LocalNuGetFeedRoot = Join-Path $NuGetRoot 'feed' $AppDataRoot = Join-Path $ToolStateRoot 'appdata' -$HomeRoot = Join-Path $ToolStateRoot 'home' $PackageIdentityName = 'YMhut.Box' $PackagePublisher = 'CN=YMhut' @@ -1259,10 +1258,46 @@ function Build-InstallerBootstrap([object] $VersionInfo, [string] $SignTool) { Invoke-ToolQuiet $setupPath @('/SELFTEST') 'WinUI installer bootstrap self-test failed' " Installer self-test: $setupPath" Invoke-ToolQuiet $setupPath @('/WINDOWSELFTEST') 'WinUI installer window navigation self-test failed' " Installer window self-test: $setupPath" } + +function Wait-FileWritable([string] $Path, [int] $Attempts = 20, [int] $DelayMilliseconds = 250) { + for ($attempt = 1; $attempt -le $Attempts; $attempt++) { + $stream = $null + try { + $stream = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) + return + } catch { + if ($attempt -eq $Attempts) { + throw "File remained locked after $Attempts attempts: $Path" + } + Start-Sleep -Milliseconds $DelayMilliseconds + } finally { + if ($stream) { + $stream.Dispose() + } + } + } +} + function Sign-Artifact([string] $SignTool, [string] $Path) { Ensure-LocalDeveloperCertificate - Invoke-ToolQuiet $SignTool @('sign', '/fd', 'SHA256', '/f', $PfxPath, '/p', $PfxPassword, $Path) "Signing failed for $Path" " Signed: $Path" - Assert-ArtifactSignatureMatchesCertificate $Path + $lastError = $null + for ($attempt = 1; $attempt -le 5; $attempt++) { + try { + Wait-FileWritable $Path + Invoke-ToolQuiet $SignTool @('sign', '/fd', 'SHA256', '/f', $PfxPath, '/p', $PfxPassword, $Path) "Signing failed for $Path" " Signed: $Path" + Assert-ArtifactSignatureMatchesCertificate $Path + return + } catch { + $lastError = $_ + if ($attempt -eq 5) { + throw + } + Write-Warning "Signing attempt $attempt failed for '$Path'; retrying after Windows releases the file handle." + Start-Sleep -Milliseconds (500 * $attempt) + } + } + + throw $lastError } function Clear-PeCertificateDirectory([string] $Path) { @@ -1432,13 +1467,11 @@ pause } New-Directory $AppDataRoot -New-Directory $HomeRoot New-Directory $NuGetRoot New-Directory $LocalNuGetFeedRoot New-Directory $OutputRoot $env:APPDATA = $AppDataRoot $env:LOCALAPPDATA = $AppDataRoot -$env:HOME = $HomeRoot $env:DOTNET_CLI_HOME = Join-Path $ToolStateRoot 'dotnet' $env:NUGET_PACKAGES = $NuGetRoot $env:DOTNET_CLI_TELEMETRY_OPTOUT = '1' @@ -1456,7 +1489,7 @@ Ensure-MsixAssets Invoke-DotNet @( 'restore', $Solution, - '--source', $LocalNuGetFeedRoot, + '--configfile', $NuGetConfig, '--ignore-failed-sources', '--disable-parallel' ) @@ -1465,7 +1498,7 @@ foreach ($ridProject in @($Project, $InstallerBootstrapProject)) { 'restore', $ridProject, '-r', 'win-x64', - '--source', $LocalNuGetFeedRoot, + '--configfile', $NuGetConfig, '--ignore-failed-sources', '--disable-parallel' ) diff --git a/server/unified-management/README.md b/server/unified-management/README.md index eb702bd..b2f4204 100644 --- a/server/unified-management/README.md +++ b/server/unified-management/README.md @@ -2,6 +2,12 @@ This service unifies the old `server/update` and `server/feedback-mailer` projects. +## Nginx Reverse Proxy + +Production deployments should start from `deploy/nginx/unified-management.conf.example`. Set its domain, TLS settings, upstream port, and `/downloads/` alias for the current machine. Keep `client_max_body_size` at least 8 MiB above `YMHUT_RELEASE_UPLOAD_MAX_BYTES` for multipart overhead; the default service limit is 1 GiB. + +The upload location disables request buffering so packages stream directly to Go and uses a 30-minute timeout. The download location lets Nginx send immutable package files without routing their bytes through the service. Run `nginx -t` before reloading a control-panel generated configuration. + ## What It Provides - Go backend with SQLite by default. @@ -54,26 +60,29 @@ $env:YMHUT_GOPROXY="https://your-proxy.example,direct" ## Frontend Build Check -Startup preflight checks both built web apps: +Startup preflight checks the selected admin asset source and both other built web apps. The admin build must include: - `web/admin/dist/index.html` -- `web/admin/dist/assets` +- `web/admin/dist/asset-manifest.json` +- `web/admin/dist/admin-build.json` +- every entry, CSS file, asset, import, and dynamic import named by the manifest - `web/portal/dist/index.html` - `web/portal/dist/assets` -If either frontend is missing, the service still starts, but the preflight log tells you to run the frontend build. Release binaries built with `embed_web` report that embedded frontend assets are being used instead. Build the local dist files with: +Release binaries built with `embed_web` default to the embedded admin build and never mix it with files from disk. Non-embedded development builds default to disk. Override this explicitly with `YMHUT_ADMIN_ASSET_MODE=embedded|disk`; an invalid or incomplete selected source is reported through `/api/admin/system/health` and is not replaced by files from the other source. Build the local dist files with: ```powershell cd server\unified-management\web\admin npm install npm run build +npm run validate:build cd ..\portal npm install npm run build ``` -The Go server serves portal assets from `/assets/*` and admin assets from `/admin/assets/*`. Disk `dist` files are preferred during development; embedded assets are used as the release fallback. +The Go server serves portal assets from `/assets/*` and admin assets from `/admin/assets/*`. Admin HTML is always `no-store`, hashed assets are immutable, and missing assets return 404 rather than the SPA shell. Production deployment replaces the single embedded service binary; do not copy `web/admin/dist` over a production binary. ## Release Binaries diff --git a/server/unified-management/cmd/unified-management/app/app.go b/server/unified-management/cmd/unified-management/app/app.go index 2284a89..d9b1d96 100644 --- a/server/unified-management/cmd/unified-management/app/app.go +++ b/server/unified-management/cmd/unified-management/app/app.go @@ -54,6 +54,8 @@ func Run() { log.Printf("update notice import skipped: %v", err) } releaseService := releases.NewService(cfg, store, noticeService) + releaseService.Start(context.Background()) + defer releaseService.Stop() sourceService := sources.NewService(cfg, store) feedbackService := feedback.NewService(cfg, store) legacyService := legacy.NewService(cfg, store) diff --git a/server/unified-management/deploy/nginx/unified-management.conf.example b/server/unified-management/deploy/nginx/unified-management.conf.example new file mode 100644 index 0000000..9dbe2b8 --- /dev/null +++ b/server/unified-management/deploy/nginx/unified-management.conf.example @@ -0,0 +1,86 @@ +upstream ymhut_unified_management { + server 127.0.0.1:33550; + keepalive 16; +} + +server { + listen 443 ssl http2; + server_name update.example.com; + + # Default package limit (1 GiB) plus multipart form overhead. + # Increase both this value and YMHUT_RELEASE_UPLOAD_MAX_BYTES together. + client_max_body_size 1032m; + + location /downloads/ { + # Replace this path with the configured DownloadsDir. + alias /srv/ymhut/data/update/public/downloads/; + sendfile on; + tcp_nopush on; + etag on; + add_header Cache-Control "public, max-age=300" always; + } + + location = /api/admin/releases/packages { + proxy_pass http://ymhut_unified_management; + proxy_http_version 1.1; + proxy_request_buffering off; + proxy_buffering off; + proxy_read_timeout 30m; + proxy_send_timeout 30m; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /api/admin/events { + proxy_pass http://ymhut_unified_management; + proxy_http_version 1.1; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 1h; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Hashed Vite assets are immutable. A missing asset remains a real 404; + # never route it back to the SPA HTML shell. + location ~* ^/admin/assets/.+-[A-Za-z0-9_-]{8,}\.(?:js|css|woff2?|ttf|eot|svg|png|webp)$ { + proxy_pass http://ymhut_unified_management; + proxy_http_version 1.1; + proxy_intercept_errors off; + add_header Cache-Control "public, max-age=31536000, immutable"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /admin/ { + proxy_pass http://ymhut_unified_management; + proxy_http_version 1.1; + proxy_intercept_errors off; + add_header Cache-Control "no-store" always; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + proxy_pass http://ymhut_unified_management; + proxy_http_version 1.1; + proxy_read_timeout 60s; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/server/unified-management/internal/adminassets/adminassets.go b/server/unified-management/internal/adminassets/adminassets.go new file mode 100644 index 0000000..564344a --- /dev/null +++ b/server/unified-management/internal/adminassets/adminassets.go @@ -0,0 +1,205 @@ +package adminassets + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + webassets "ymhut-box/server/unified-management/web" +) + +const ( + ModeEmbedded = "embedded" + ModeDisk = "disk" +) + +type Diagnostics struct { + Mode string `json:"mode"` + Source string `json:"source"` + BuildID string `json:"buildId,omitempty"` + CompiledBuildID string `json:"compiledBuildId,omitempty"` + ManifestStatus string `json:"manifestStatus"` + ManifestEntries int `json:"manifestEntries"` + Ready bool `json:"ready"` + ValidationError string `json:"validationError,omitempty"` +} + +type Service struct { + mode string + diskRoot string + embedRoot string + diagnostics Diagnostics +} + +type manifestEntry struct { + File string `json:"file"` + CSS []string `json:"css"` + Assets []string `json:"assets"` + Imports []string `json:"imports"` + DynamicImports []string `json:"dynamicImports"` +} + +type buildMetadata struct { + BuildID string `json:"buildId"` +} + +type fileReader func(string) ([]byte, error) + +func New(mode, diskRoot, compiledBuildID string) *Service { + mode = strings.ToLower(strings.TrimSpace(mode)) + if mode != ModeDisk && mode != ModeEmbedded { + mode = DefaultMode() + } + service := &Service{ + mode: mode, + diskRoot: diskRoot, + embedRoot: "admin/dist", + } + service.diagnostics = service.validate(compiledBuildID) + return service +} + +func DefaultMode() string { + if webassets.Embedded { + return ModeEmbedded + } + return ModeDisk +} + +func (s *Service) Mode() string { + return s.mode +} + +func (s *Service) Diagnostics() Diagnostics { + return s.diagnostics +} + +func (s *Service) ReadFile(name string) ([]byte, error) { + name, err := cleanAssetPath(name) + if err != nil { + return nil, err + } + if !s.diagnostics.Ready { + return nil, errors.New("admin asset source failed validation") + } + if s.mode == ModeEmbedded { + return webassets.ReadFile(s.embedRoot + "/" + name) + } + return os.ReadFile(filepath.Join(s.diskRoot, filepath.FromSlash(name))) +} + +func ValidateDisk(root, compiledBuildID string) Diagnostics { + return validateSource(ModeDisk, root, compiledBuildID, func(name string) ([]byte, error) { + return os.ReadFile(filepath.Join(root, filepath.FromSlash(name))) + }) +} + +func ValidateEmbedded(compiledBuildID string) Diagnostics { + const root = "admin/dist" + return validateSource(ModeEmbedded, root, compiledBuildID, func(name string) ([]byte, error) { + return webassets.ReadFile(root + "/" + name) + }) +} + +func (s *Service) validate(compiledBuildID string) Diagnostics { + if s.mode == ModeEmbedded { + if !webassets.Embedded { + return Diagnostics{ + Mode: s.mode, Source: s.embedRoot, CompiledBuildID: compiledBuildID, + ManifestStatus: "unavailable", ValidationError: "binary was built without embed_web", + } + } + return ValidateEmbedded(compiledBuildID) + } + return ValidateDisk(s.diskRoot, compiledBuildID) +} + +func validateSource(mode, source, compiledBuildID string, read fileReader) Diagnostics { + result := Diagnostics{ + Mode: mode, Source: source, CompiledBuildID: strings.TrimSpace(compiledBuildID), + ManifestStatus: "invalid", + } + fail := func(err error) Diagnostics { + result.ValidationError = err.Error() + return result + } + if _, err := read("index.html"); err != nil { + return fail(fmt.Errorf("read index.html: %w", err)) + } + metadataBytes, err := read("admin-build.json") + if err != nil { + return fail(fmt.Errorf("read admin-build.json: %w", err)) + } + var metadata buildMetadata + if err := json.Unmarshal(metadataBytes, &metadata); err != nil { + return fail(fmt.Errorf("parse admin-build.json: %w", err)) + } + result.BuildID = strings.TrimSpace(metadata.BuildID) + if result.BuildID == "" { + return fail(errors.New("admin-build.json has an empty buildId")) + } + if result.CompiledBuildID != "" && result.CompiledBuildID != "dev" && result.BuildID != result.CompiledBuildID { + return fail(fmt.Errorf("admin build ID %q does not match binary build ID %q", result.BuildID, result.CompiledBuildID)) + } + manifestBytes, err := read("asset-manifest.json") + if err != nil { + return fail(fmt.Errorf("read asset-manifest.json: %w", err)) + } + manifest := map[string]manifestEntry{} + if err := json.Unmarshal(manifestBytes, &manifest); err != nil { + return fail(fmt.Errorf("parse asset-manifest.json: %w", err)) + } + if len(manifest) == 0 { + return fail(errors.New("asset-manifest.json is empty")) + } + for key, entry := range manifest { + if strings.TrimSpace(entry.File) == "" { + return fail(fmt.Errorf("manifest entry %q has no output file", key)) + } + for _, dependencyKey := range append(append([]string{}, entry.Imports...), entry.DynamicImports...) { + if _, ok := manifest[dependencyKey]; !ok { + return fail(fmt.Errorf("manifest entry %q references missing entry %q", key, dependencyKey)) + } + } + files := append([]string{entry.File}, entry.CSS...) + files = append(files, entry.Assets...) + for _, name := range files { + name, cleanErr := cleanAssetPath(name) + if cleanErr != nil { + return fail(fmt.Errorf("manifest entry %q: %w", key, cleanErr)) + } + data, readErr := read(name) + if readErr != nil { + return fail(fmt.Errorf("manifest entry %q is missing %s: %w", key, name, readErr)) + } + if len(data) == 0 { + return fail(fmt.Errorf("manifest entry %q references empty file %s", key, name)) + } + } + } + result.ManifestEntries = len(manifest) + result.ManifestStatus = "valid" + result.Ready = true + return result +} + +func cleanAssetPath(name string) (string, error) { + name = filepath.ToSlash(strings.TrimSpace(name)) + name = strings.TrimPrefix(name, "./") + if name == "" || strings.HasPrefix(name, "/") || strings.Contains(name, "\\") { + return "", fmt.Errorf("invalid admin asset path %q", name) + } + cleaned := filepath.ToSlash(filepath.Clean(name)) + if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") { + return "", fmt.Errorf("invalid admin asset path %q", name) + } + return cleaned, nil +} + +func IsMissing(err error) bool { + return errors.Is(err, fs.ErrNotExist) +} diff --git a/server/unified-management/internal/adminassets/adminassets_embed_test.go b/server/unified-management/internal/adminassets/adminassets_embed_test.go new file mode 100644 index 0000000..9327bb0 --- /dev/null +++ b/server/unified-management/internal/adminassets/adminassets_embed_test.go @@ -0,0 +1,15 @@ +//go:build embed_web + +package adminassets + +import "testing" + +func TestEmbeddedProductionBuildIsComplete(t *testing.T) { + status := New(ModeEmbedded, "", "dev").Diagnostics() + if !status.Ready { + t.Fatalf("embedded admin build is incomplete: %#v", status) + } + if status.ManifestEntries == 0 { + t.Fatalf("embedded admin manifest is empty: %#v", status) + } +} diff --git a/server/unified-management/internal/adminassets/adminassets_test.go b/server/unified-management/internal/adminassets/adminassets_test.go new file mode 100644 index 0000000..de844f7 --- /dev/null +++ b/server/unified-management/internal/adminassets/adminassets_test.go @@ -0,0 +1,63 @@ +package adminassets + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestValidateDiskChecksDynamicImportsAndEveryOutput(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "assets"), 0o755); err != nil { + t.Fatal(err) + } + writeAssetTestFile(t, root, "index.html", ``) + writeAssetTestFile(t, root, "admin-build.json", `{"buildId":"build-1"}`) + writeAssetTestFile(t, root, "asset-manifest.json", `{ + "src/main.ts":{"file":"assets/index-12345678.js","dynamicImports":["src/views/FeedbacksView.vue"]}, + "src/views/FeedbacksView.vue":{"file":"assets/FeedbacksView-12345678.js","css":["assets/FeedbacksView-12345678.css"]} + }`) + writeAssetTestFile(t, root, "assets/index-12345678.js", "import('./FeedbacksView-12345678.js')") + writeAssetTestFile(t, root, "assets/FeedbacksView-12345678.js", "export default {}") + writeAssetTestFile(t, root, "assets/FeedbacksView-12345678.css", ".page{}") + + status := ValidateDisk(root, "build-1") + if !status.Ready || status.ManifestEntries != 2 { + t.Fatalf("valid build rejected: %#v", status) + } + if err := os.Remove(filepath.Join(root, "assets", "FeedbacksView-12345678.js")); err != nil { + t.Fatal(err) + } + status = ValidateDisk(root, "build-1") + if status.Ready || !strings.Contains(status.ValidationError, "FeedbacksView-12345678.js") { + t.Fatalf("missing lazy chunk not diagnosed: %#v", status) + } +} + +func TestValidateDiskRejectsBuildIDMismatch(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "assets"), 0o755); err != nil { + t.Fatal(err) + } + writeAssetTestFile(t, root, "index.html", "
") + writeAssetTestFile(t, root, "admin-build.json", `{"buildId":"old"}`) + writeAssetTestFile(t, root, "asset-manifest.json", `{"src/main.ts":{"file":"assets/index-12345678.js"}}`) + writeAssetTestFile(t, root, "assets/index-12345678.js", "export{}") + + status := ValidateDisk(root, "new") + if status.Ready || !strings.Contains(status.ValidationError, "does not match") { + t.Fatalf("build mismatch not rejected: %#v", status) + } +} + +func writeAssetTestFile(t *testing.T, root, name, value string) { + t.Helper() + path := filepath.Join(root, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(value), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/server/unified-management/internal/auth/auth.go b/server/unified-management/internal/auth/auth.go index ed353b8..ada82c7 100644 --- a/server/unified-management/internal/auth/auth.go +++ b/server/unified-management/internal/auth/auth.go @@ -77,20 +77,16 @@ func NewService(store *db.Store) *Service { } } -func (s *Service) Bootstrap(ctx context.Context) (map[string]any, error) { +func (s *Service) Bootstrap(ctx context.Context, includeSecurityState bool) (map[string]any, error) { + payload := map[string]any{"ok": true} + if !includeSecurityState { + return payload, nil + } isDefault, err := s.store.IsDefaultAdminPassword(ctx) if err != nil { return nil, err } - payload := map[string]any{ - "ok": true, - "defaultUsername": "admin", - "defaultPassword": "", - "isDefaultPassword": isDefault, - } - if isDefault { - payload["defaultPassword"] = "admin" - } + payload["isDefaultPassword"] = isDefault return payload, nil } diff --git a/server/unified-management/internal/auth/auth_test.go b/server/unified-management/internal/auth/auth_test.go index 6e0b9e0..c67a28a 100644 --- a/server/unified-management/internal/auth/auth_test.go +++ b/server/unified-management/internal/auth/auth_test.go @@ -12,7 +12,7 @@ import ( "ymhut-box/server/unified-management/internal/db" ) -func TestBootstrapShowsDefaultPasswordOnlyBeforeChange(t *testing.T) { +func TestBootstrapOnlyReturnsDefaultPasswordStateToAuthenticatedCaller(t *testing.T) { root := t.TempDir() cfg := &config.Config{ StorageDir: root, @@ -32,22 +32,29 @@ func TestBootstrapShowsDefaultPasswordOnlyBeforeChange(t *testing.T) { t.Fatal(err) } service := NewService(store) - payload, err := service.Bootstrap(context.Background()) + publicPayload, err := service.Bootstrap(context.Background(), false) if err != nil { t.Fatal(err) } - if payload["isDefaultPassword"] != true || payload["defaultPassword"] != "admin" { - t.Fatalf("unexpected bootstrap payload: %#v", payload) + if _, exists := publicPayload["isDefaultPassword"]; exists || publicPayload["defaultPassword"] != nil || publicPayload["defaultUsername"] != nil { + t.Fatalf("public bootstrap leaked administrator state: %#v", publicPayload) + } + payload, err := service.Bootstrap(context.Background(), true) + if err != nil { + t.Fatal(err) + } + if payload["isDefaultPassword"] != true || payload["defaultPassword"] != nil || payload["defaultUsername"] != nil { + t.Fatalf("unexpected authenticated bootstrap payload: %#v", payload) } if err := store.ChangeAdminPassword(context.Background(), "admin", "admin", "changed-password"); err != nil { t.Fatal(err) } - payload, err = service.Bootstrap(context.Background()) + payload, err = service.Bootstrap(context.Background(), true) if err != nil { t.Fatal(err) } - if payload["isDefaultPassword"] != false || payload["defaultPassword"] != "" { - t.Fatalf("default password leaked after change: %#v", payload) + if payload["isDefaultPassword"] != false || payload["defaultPassword"] != nil || payload["defaultUsername"] != nil { + t.Fatalf("default password fields leaked after change: %#v", payload) } } diff --git a/server/unified-management/internal/config/config.go b/server/unified-management/internal/config/config.go index 5e06b93..a97c336 100644 --- a/server/unified-management/internal/config/config.go +++ b/server/unified-management/internal/config/config.go @@ -11,7 +11,10 @@ import ( const DefaultListen = ":33550" -var Version = "0.1.0" +var ( + Version = "0.1.0" + AdminBuildID = "dev" +) type Config struct { BaseDir string `json:"base_dir"` @@ -26,6 +29,7 @@ type Config struct { UpdateNoticeDir string `json:"update_notice_dir"` DownloadsDir string `json:"downloads_dir"` AdminWebDir string `json:"admin_web_dir"` + AdminAssetMode string `json:"admin_asset_mode"` PortalWebDir string `json:"portal_web_dir"` SetupWebDir string `json:"setup_web_dir"` LegacyUpdateDir string `json:"legacy_update_dir"` @@ -140,6 +144,7 @@ func defaults(root string) *Config { UpdateNoticeDir: filepath.Join(root, "data", "update-notice"), DownloadsDir: filepath.Join(root, "data", "update", "public", "downloads"), AdminWebDir: filepath.Join(root, "web", "admin", "dist"), + AdminAssetMode: defaultAdminAssetMode(), PortalWebDir: filepath.Join(root, "web", "portal", "dist"), SetupWebDir: filepath.Join(root, "web", "setup", "dist"), LegacyUpdateDir: filepath.Clean(filepath.Join(root, "..", "update")), @@ -225,6 +230,9 @@ func applyEnv(cfg *Config) { if value := os.Getenv("YMHUT_DOWNLOADS_DIR"); value != "" { cfg.DownloadsDir = value } + if value := os.Getenv("YMHUT_ADMIN_ASSET_MODE"); value != "" { + cfg.AdminAssetMode = value + } if value := os.Getenv("YMHUT_LEGACY_UPDATE_DIR"); value != "" { cfg.LegacyUpdateDir = value } @@ -386,6 +394,10 @@ func normalize(root string, cfg *Config) { cfg.AdminWebDir = filepath.Join(cfg.BaseDir, "web", "admin", "dist") } cfg.AdminWebDir = absPath(cfg.BaseDir, cfg.AdminWebDir) + cfg.AdminAssetMode = strings.ToLower(strings.TrimSpace(cfg.AdminAssetMode)) + if cfg.AdminAssetMode != "embedded" && cfg.AdminAssetMode != "disk" { + cfg.AdminAssetMode = defaultAdminAssetMode() + } if cfg.PortalWebDir == "" { cfg.PortalWebDir = filepath.Join(cfg.BaseDir, "web", "portal", "dist") } diff --git a/server/unified-management/internal/config/config_test.go b/server/unified-management/internal/config/config_test.go index 00258ad..76a30ed 100644 --- a/server/unified-management/internal/config/config_test.go +++ b/server/unified-management/internal/config/config_test.go @@ -111,9 +111,27 @@ func TestReleaseUploadLimitCanBeConfiguredFromEnvironment(t *testing.T) { } } +func TestAdminAssetModeCanBeConfiguredFromEnvironment(t *testing.T) { + t.Setenv("YMHUT_ADMIN_ASSET_MODE", "embedded") + cfg := defaults(t.TempDir()) + applyEnv(cfg) + normalize(cfg.BaseDir, cfg) + if cfg.AdminAssetMode != "embedded" { + t.Fatalf("AdminAssetMode = %q, want embedded", cfg.AdminAssetMode) + } + + t.Setenv("YMHUT_ADMIN_ASSET_MODE", "invalid") + applyEnv(cfg) + normalize(cfg.BaseDir, cfg) + if cfg.AdminAssetMode != defaultAdminAssetMode() { + t.Fatalf("invalid AdminAssetMode normalized to %q, want %q", cfg.AdminAssetMode, defaultAdminAssetMode()) + } +} + func TestPreflightReportsMissingAdminAssetName(t *testing.T) { root := t.TempDir() cfg := defaults(root) + cfg.AdminAssetMode = "disk" if err := os.MkdirAll(filepath.Join(cfg.AdminWebDir, "assets"), 0o755); err != nil { t.Fatal(err) } @@ -123,6 +141,12 @@ func TestPreflightReportsMissingAdminAssetName(t *testing.T) { 0o644); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(cfg.AdminWebDir, "admin-build.json"), []byte(`{"buildId":"dev"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cfg.AdminWebDir, "asset-manifest.json"), []byte(`{"src/main.ts":{"file":"assets/missing.js","isEntry":true}}`), 0o644); err != nil { + t.Fatal(err) + } checks := Preflight(cfg) admin := checks[0] diff --git a/server/unified-management/internal/config/preflight.go b/server/unified-management/internal/config/preflight.go index 048c4c8..5841a32 100644 --- a/server/unified-management/internal/config/preflight.go +++ b/server/unified-management/internal/config/preflight.go @@ -4,9 +4,9 @@ import ( "fmt" "os" "path/filepath" - "regexp" "strings" + "ymhut-box/server/unified-management/internal/adminassets" webassets "ymhut-box/server/unified-management/web" ) @@ -46,45 +46,37 @@ func Preflight(cfg *Config) []Check { checkSeedFile("legacy update-info", filepath.Join(cfg.UpdatePublicDir, "update-info.json"), []byte(defaultUpdateInfoJSON)), checkSeedFile("legacy media-types", filepath.Join(cfg.UpdatePublicDir, "media-types.json"), []byte(defaultMediaTypesJSON)), checkNoticeIndex("version notice index", filepath.Join(cfg.UpdateNoticeDir, "total.json")), - checkAdminWebBuild("admin web dist", cfg.AdminWebDir, "admin/dist"), + checkAdminWebBuild("admin web dist", cfg), checkWebBuild("portal web dist", cfg.PortalWebDir, "portal/dist"), checkWebBuild("setup web dist", cfg.SetupWebDir, "setup/dist"), } return checks } -var adminPreflightAssetPattern = regexp.MustCompile(`(?:src|href)=["'](/admin/assets/[^"'?#]+)`) +func checkAdminWebBuild(name string, cfg *Config) Check { + var status adminassets.Diagnostics + path := cfg.AdminWebDir + if cfg.AdminAssetMode == adminassets.ModeEmbedded { + status = adminassets.ValidateEmbedded(AdminBuildID) + path = "embedded:admin/dist" + } else { + status = adminassets.ValidateDisk(cfg.AdminWebDir, AdminBuildID) + } + if !status.Ready { + message := status.ValidationError + if cfg.AdminAssetMode == adminassets.ModeDisk { + message = strings.ReplaceAll(message, cfg.AdminWebDir, ".") + } + return Check{Name: name, Status: "error", Path: path, Message: message} + } + return Check{ + Name: name, Status: "ok", Path: path, + Message: fmt.Sprintf("%s assets; build %s; %d manifest entries", status.Mode, status.BuildID, status.ManifestEntries), + } +} -func checkAdminWebBuild(name, path, embedRoot string) Check { - check := checkWebBuild(name, path, embedRoot) - if check.Status != "ok" || strings.Contains(check.Message, "embedded frontend assets") { - return check - } - index := filepath.Join(path, "index.html") - data, err := os.ReadFile(index) - if err != nil { - return check - } - matches := adminPreflightAssetPattern.FindAllSubmatch(data, -1) - if len(matches) == 0 { - return Check{Name: name, Status: "error", Path: index, Message: "index.html does not reference any /admin/assets files"} - } - for _, match := range matches { - assetPath := strings.TrimPrefix(string(match[1]), "/admin/") - if strings.Contains(assetPath, "..") || strings.ContainsAny(assetPath, `\`) { - return Check{Name: name, Status: "error", Path: index, Message: fmt.Sprintf("invalid admin asset reference %s", assetPath)} - } - info, statErr := os.Stat(filepath.Join(path, filepath.FromSlash(assetPath))) - if statErr == nil && !info.IsDir() { - continue - } - message := fmt.Sprintf("disk asset %s is missing", assetPath) - if embeddedWebBuildOK(embedRoot) { - return Check{Name: name, Status: "ok", Path: path, Message: message + "; using embedded frontend assets"} - } - return Check{Name: name, Status: "error", Path: filepath.Join(path, filepath.FromSlash(assetPath)), Message: message} - } - return check +func defaultAdminAssetMode() string { + return adminassets.DefaultMode() } func checkDir(name, path string, create bool) Check { diff --git a/server/unified-management/internal/db/admin_performance_test.go b/server/unified-management/internal/db/admin_performance_test.go new file mode 100644 index 0000000..40bddfe --- /dev/null +++ b/server/unified-management/internal/db/admin_performance_test.go @@ -0,0 +1,80 @@ +package db + +import ( + "path/filepath" + "testing" + "time" + + "ymhut-box/server/unified-management/internal/config" +) + +func TestFeedbackSummaryPageOmitsHeavyDetailFields(t *testing.T) { + store := openAdminPerformanceStore(t) + defer store.Close() + item := Feedback{Code: "FB-SUMMARY-1", Title: "Summary", Body: "large private body", SummaryText: "short", Status: "new"} + if err := store.InsertFeedback(item); err != nil { + t.Fatal(err) + } + items, total, err := store.ListFeedbackSummariesFiltered(1, 20, FeedbackFilters{}) + if err != nil || total != 1 || len(items) != 1 { + t.Fatalf("summary list total=%d items=%d err=%v", total, len(items), err) + } + if items[0].Body != "" || items[0].SummaryText != "short" { + t.Fatalf("summary list loaded heavy fields: %#v", items[0]) + } + detail, err := store.GetFeedback("FB-SUMMARY-1") + if err != nil || detail.Body != "large private body" { + t.Fatalf("detail body=%q err=%v", detail.Body, err) + } +} + +func TestSystemLogsUseFilteredDatabasePagination(t *testing.T) { + store := openAdminPerformanceStore(t) + defer store.Close() + if err := store.InsertAudit(AuditLog{Actor: "admin", Type: "release.saved", Target: "2.0", Message: "published"}); err != nil { + t.Fatal(err) + } + if err := store.RecordSourceCall(SourceCall{SourceID: "music", Status: "ok", LatencyMS: 30, Client: "test"}); err != nil { + t.Fatal(err) + } + page, err := store.ListSystemLogsPage(SystemLogFilters{Page: 1, PerPage: 1}) + if err != nil || page.Total < 2 || len(page.Items) != 1 { + t.Fatalf("system log page=%#v err=%v", page, err) + } + filtered, err := store.ListSystemLogsPage(SystemLogFilters{Page: 1, PerPage: 10, Category: "client", Query: "music"}) + if err != nil || filtered.Total != 1 || filtered.Items[0].Category != "client" { + t.Fatalf("filtered system logs=%#v err=%v", filtered, err) + } +} + +func TestDashboardWindowIncludesSummaryAndGeneratedMetadata(t *testing.T) { + store := openAdminPerformanceStore(t) + defer store.Close() + source, err := store.UpsertSource(Source{CategoryID: "network", CategoryName: "网络", SourceID: "status", Name: "状态", APIURL: "https://example.com", Enabled: true, EnabledSet: true, ClientVisible: true, ClientVisibleSet: true}) + if err != nil { + t.Fatal(err) + } + if err := store.RecordSourceCheck(source.ID, "ok", 42, ""); err != nil { + t.Fatal(err) + } + overview, err := store.DashboardOverviewWindow(60, time.Now().UTC().Add(-time.Hour).Format(time.RFC3339)) + if err != nil { + t.Fatal(err) + } + if overview["generatedAt"] == "" || len(overview["sourceRows"].([]map[string]any)) != 1 { + t.Fatalf("dashboard metadata missing: %#v", overview) + } +} + +func openAdminPerformanceStore(t *testing.T) *Store { + t.Helper() + root := t.TempDir() + store, err := Open(&config.Config{ + StorageDir: root, + Database: config.DatabaseConfig{Provider: "sqlite", SQLitePath: filepath.Join(root, "admin-performance.sqlite"), HealthIntervalSec: 3600, MaxOpenConns: 2, MaxIdleConns: 1}, + }) + if err != nil { + t.Fatal(err) + } + return store +} diff --git a/server/unified-management/internal/db/audit_store.go b/server/unified-management/internal/db/audit_store.go index 2de4bca..ae15b68 100644 --- a/server/unified-management/internal/db/audit_store.go +++ b/server/unified-management/internal/db/audit_store.go @@ -8,21 +8,62 @@ import ( ) func (s *Store) DashboardOverview(limit int) (map[string]any, error) { + return s.DashboardOverviewWindow(limit, "") +} + +func (s *Store) DashboardOverviewWindow(limit int, since string) (map[string]any, error) { if limit <= 0 || limit > 200 { limit = 80 } - feedbackTotal, _ := s.countTable("feedback_tickets") - feedbackToday, _ := s.countWhere("feedback_tickets", "created_at LIKE ?", time.Now().UTC().Format("2006-01-02")+"%") - sourceTotal, _ := s.countTable("source_endpoints") - sourceVisible, _ := s.countWhere("source_endpoints", "enabled = 1 AND client_visible = 1") - releaseTotal, _ := s.countTable("release_notices") - mailFailed, _ := s.countWhere("mail_records", "status = ?", "failed") - statusCounts, _ := s.groupCounts("feedback_tickets", "status") - healthCounts, _ := s.groupCounts("source_endpoints", "last_status") - recentChecks, _ := s.RecentSourceChecks(limit) - recentCalls, _ := s.RecentSourceCalls(limit) - averageLatency, _ := s.AverageSourceLatencyBuckets(limit) - audit, _ := s.ListAuditLogs(10) + var feedbackTotal, feedbackToday, sourceTotal, sourceVisible, releaseTotal, mailFailed int + today := time.Now().UTC().Format("2006-01-02") + "T00:00:00Z" + if err := s.queryRow(`SELECT + (SELECT COUNT(*) FROM feedback_tickets), + (SELECT COUNT(*) FROM feedback_tickets WHERE created_at >= ?), + (SELECT COUNT(*) FROM source_endpoints), + (SELECT COUNT(*) FROM source_endpoints WHERE enabled = 1 AND client_visible = 1), + (SELECT COUNT(*) FROM release_notices), + (SELECT COUNT(*) FROM mail_records WHERE status = 'failed')`, today).Scan( + &feedbackTotal, &feedbackToday, &sourceTotal, &sourceVisible, &releaseTotal, &mailFailed, + ); err != nil { + return nil, err + } + warnings := []string{} + statusCounts, err := s.groupCounts("feedback_tickets", "status") + if err != nil { + warnings = append(warnings, "feedback status unavailable: "+err.Error()) + statusCounts = map[string]int{} + } + healthCounts, err := s.groupCounts("source_endpoints", "last_status") + if err != nil { + warnings = append(warnings, "source health unavailable: "+err.Error()) + healthCounts = map[string]int{} + } + recentChecks, err := s.RecentSourceChecksWindow(limit, since) + if err != nil { + warnings = append(warnings, "source checks unavailable: "+err.Error()) + recentChecks = []map[string]any{} + } + recentCalls, err := s.RecentSourceCallsWindow(limit, since) + if err != nil { + warnings = append(warnings, "client calls unavailable: "+err.Error()) + recentCalls = []map[string]any{} + } + averageLatency, err := s.AverageSourceLatencyBucketsWindow(limit, since) + if err != nil { + warnings = append(warnings, "latency trend unavailable: "+err.Error()) + averageLatency = []map[string]any{} + } + audit, err := s.ListAuditLogs(10) + if err != nil { + warnings = append(warnings, "audit summary unavailable: "+err.Error()) + audit = []AuditLog{} + } + sourceRows, err := s.DashboardSourceRows() + if err != nil { + warnings = append(warnings, "source summary unavailable: "+err.Error()) + sourceRows = []map[string]any{} + } return map[string]any{ "ok": true, "kpis": map[string]any{ @@ -40,14 +81,28 @@ func (s *Store) DashboardOverview(limit int) (map[string]any, error) { "clientCalls": recentCalls, "database": s.Status(), "audit": audit, + "sourceRows": sourceRows, + "generatedAt": time.Now().UTC().Format(time.RFC3339), + "warnings": warnings, }, nil } func (s *Store) AverageSourceLatencyBuckets(limit int) ([]map[string]any, error) { + return s.AverageSourceLatencyBucketsWindow(limit, "") +} + +func (s *Store) AverageSourceLatencyBucketsWindow(limit int, since string) ([]map[string]any, error) { if limit <= 0 || limit > 200 { limit = 80 } - rows, err := s.query(`SELECT checked_at, latency_ms, status FROM endpoint_health_checks ORDER BY checked_at DESC, id DESC LIMIT ?`, limit*4) + where := "" + args := []any{} + if strings.TrimSpace(since) != "" { + where = " WHERE checked_at >= ?" + args = append(args, since) + } + args = append(args, limit*4) + rows, err := s.query(`SELECT checked_at, latency_ms, status FROM endpoint_health_checks`+where+` ORDER BY checked_at DESC, id DESC LIMIT ?`, args...) if err != nil { return nil, err } @@ -125,9 +180,20 @@ func latencyBucketLabel(value string) string { } func (s *Store) RecentSourceChecks(limit int) ([]map[string]any, error) { + return s.RecentSourceChecksWindow(limit, "") +} + +func (s *Store) RecentSourceChecksWindow(limit int, since string) ([]map[string]any, error) { + where := "" + args := []any{} + if strings.TrimSpace(since) != "" { + where = " WHERE h.checked_at >= ?" + args = append(args, since) + } + args = append(args, limit) rows, err := s.query(`SELECT h.id, h.source_db_id, COALESCE(e.source_id, ''), COALESCE(e.name, ''), h.status, h.latency_ms, h.error, h.checked_at FROM endpoint_health_checks h LEFT JOIN source_endpoints e ON e.id = h.source_db_id - ORDER BY h.checked_at DESC, h.id DESC LIMIT ?`, limit) + `+where+` ORDER BY h.checked_at DESC, h.id DESC LIMIT ?`, args...) if err != nil { return nil, err } @@ -152,7 +218,18 @@ func (s *Store) RecentSourceChecks(limit int) ([]map[string]any, error) { } func (s *Store) RecentSourceCalls(limit int) ([]map[string]any, error) { - rows, err := s.query(`SELECT id, source_id, status, latency_ms, error, client, created_at FROM endpoint_call_logs ORDER BY created_at DESC, id DESC LIMIT ?`, limit) + return s.RecentSourceCallsWindow(limit, "") +} + +func (s *Store) RecentSourceCallsWindow(limit int, since string) ([]map[string]any, error) { + where := "" + args := []any{} + if strings.TrimSpace(since) != "" { + where = " WHERE created_at >= ?" + args = append(args, since) + } + args = append(args, limit) + rows, err := s.query(`SELECT id, source_id, status, latency_ms, error, client, created_at FROM endpoint_call_logs`+where+` ORDER BY created_at DESC, id DESC LIMIT ?`, args...) if err != nil { return nil, err } @@ -170,6 +247,30 @@ func (s *Store) RecentSourceCalls(limit int) ([]map[string]any, error) { return items, rows.Err() } +func (s *Store) DashboardSourceRows() ([]map[string]any, error) { + rows, err := s.query(`SELECT source_id, category_id, category_name, name, enabled, client_visible, + last_status, last_latency_ms, last_checked_at, last_error, consecutive_failure + FROM source_endpoints ORDER BY category_id ASC, name ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var sourceID, categoryID, categoryName, name, status, checkedAt, lastError string + var enabled, visible, latency, failures int + if err := rows.Scan(&sourceID, &categoryID, &categoryName, &name, &enabled, &visible, &status, &latency, &checkedAt, &lastError, &failures); err != nil { + return nil, err + } + items = append(items, map[string]any{ + "sourceId": sourceID, "categoryId": categoryID, "categoryName": categoryName, "name": name, + "enabled": enabled == 1, "clientVisible": visible == 1, "status": status, + "latencyMs": latency, "checkedAt": checkedAt, "healthError": lastError, "consecutiveFailure": failures, + }) + } + return items, rows.Err() +} + func (s *Store) InsertAudit(log AuditLog) error { return s.InsertAuditContext(context.Background(), log) } diff --git a/server/unified-management/internal/db/feedback_store.go b/server/unified-management/internal/db/feedback_store.go index eeee1c4..082a38d 100644 --- a/server/unified-management/internal/db/feedback_store.go +++ b/server/unified-management/internal/db/feedback_store.go @@ -106,6 +106,14 @@ func (s *Store) ListFeedbacks(limit int) ([]Feedback, error) { } func (s *Store) ListFeedbacksFiltered(page, perPage int, filters FeedbackFilters) ([]Feedback, int, error) { + return s.listFeedbacksFiltered(page, perPage, filters, false) +} + +func (s *Store) ListFeedbackSummariesFiltered(page, perPage int, filters FeedbackFilters) ([]Feedback, int, error) { + return s.listFeedbacksFiltered(page, perPage, filters, true) +} + +func (s *Store) listFeedbacksFiltered(page, perPage int, filters FeedbackFilters, summaryOnly bool) ([]Feedback, int, error) { page, perPage = normalizePage(page, perPage) where, args := feedbackWhere(filters) var total int @@ -117,12 +125,21 @@ func (s *Store) ListFeedbacksFiltered(page, perPage int, filters FeedbackFilters order = ` ORDER BY created_at ASC` } args = append(args, perPage, (page-1)*perPage) - rows, err := s.query(feedbackSelectSQL()+where+order+` LIMIT ? OFFSET ?`, args...) + selectSQL := feedbackSelectSQL() + if summaryOnly { + selectSQL = feedbackSummarySelectSQL() + } + rows, err := s.query(selectSQL+where+order+` LIMIT ? OFFSET ?`, args...) if err != nil { return nil, 0, err } defer rows.Close() - items, err := scanFeedbackRows(rows) + var items []Feedback + if summaryOnly { + items, err = scanFeedbackSummaryRows(rows) + } else { + items, err = scanFeedbackRows(rows) + } return items, total, err } diff --git a/server/unified-management/internal/db/release_store.go b/server/unified-management/internal/db/release_store.go index 64e6542..8fe69f5 100644 --- a/server/unified-management/internal/db/release_store.go +++ b/server/unified-management/internal/db/release_store.go @@ -6,6 +6,87 @@ import ( "strings" ) +func (s *Store) UpsertReleasePackage(item ReleasePackage) (ReleasePackage, error) { + item.FileName = strings.TrimSpace(item.FileName) + if item.FileName == "" { + return ReleasePackage{}, errors.New("file name is required") + } + if item.CreatedAt == "" { + if existing, ok, err := s.GetReleasePackage(item.FileName); err == nil && ok { + item.CreatedAt = existing.CreatedAt + } else if err != nil { + return ReleasePackage{}, err + } else { + item.CreatedAt = Now() + } + } + if item.UpdatedAt == "" { + item.UpdatedAt = Now() + } + columns := []string{"product", "version", "platform", "arch", "file_name", "url", "sha256", "size_bytes", "enabled", "created_at", "updated_at"} + conn, d := s.active() + _, err := conn.Exec(d.rebind(d.upsert("release_packages", columns, []string{"file_name"})), + sanitize(item.Product), sanitize(item.Version), sanitize(item.Platform), sanitize(item.Arch), sanitize(item.FileName), + sanitizeLong(item.URL, 1200), sanitize(item.SHA256), item.SizeBytes, d.boolExpr(item.Enabled), item.CreatedAt, item.UpdatedAt) + if err != nil { + s.markFailover(err) + return ReleasePackage{}, err + } + stored, ok, err := s.GetReleasePackage(item.FileName) + if err != nil { + return ReleasePackage{}, err + } + if !ok { + return ReleasePackage{}, errors.New("release package was not persisted") + } + return stored, nil +} + +func (s *Store) GetReleasePackage(fileName string) (ReleasePackage, bool, error) { + var item ReleasePackage + var enabled int + err := s.queryRow(releasePackageSelectSQL()+` WHERE file_name = ?`, strings.TrimSpace(fileName)).Scan( + &item.ID, &item.Product, &item.Version, &item.Platform, &item.Arch, &item.FileName, &item.URL, + &item.SHA256, &item.SizeBytes, &enabled, &item.CreatedAt, &item.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return ReleasePackage{}, false, nil + } + if err != nil { + return ReleasePackage{}, false, err + } + item.Enabled = enabled != 0 + return item, true, nil +} + +func (s *Store) ListReleasePackages() ([]ReleasePackage, error) { + rows, err := s.query(releasePackageSelectSQL() + ` ORDER BY file_name ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ReleasePackage{} + for rows.Next() { + var item ReleasePackage + var enabled int + if err := rows.Scan(&item.ID, &item.Product, &item.Version, &item.Platform, &item.Arch, &item.FileName, &item.URL, + &item.SHA256, &item.SizeBytes, &enabled, &item.CreatedAt, &item.UpdatedAt); err != nil { + return nil, err + } + item.Enabled = enabled != 0 + items = append(items, item) + } + return items, rows.Err() +} + +func (s *Store) DeleteReleasePackage(fileName string) error { + _, err := s.exec(`DELETE FROM release_packages WHERE file_name = ?`, strings.TrimSpace(fileName)) + return err +} + +func releasePackageSelectSQL() string { + return `SELECT id, product, version, platform, arch, file_name, url, sha256, size_bytes, enabled, created_at, updated_at FROM release_packages` +} + func (s *Store) UpsertReleaseNotice(item ReleaseNotice) (ReleaseNotice, error) { now := Now() item.Version = strings.TrimSpace(item.Version) diff --git a/server/unified-management/internal/db/scanner_util.go b/server/unified-management/internal/db/scanner_util.go index 0089f63..0962741 100644 --- a/server/unified-management/internal/db/scanner_util.go +++ b/server/unified-management/internal/db/scanner_util.go @@ -21,6 +21,11 @@ func feedbackSelectSQL() string { mail_sent, remote_addr, tags, created_at, updated_at, last_activity_at FROM feedback_tickets` } +func feedbackSummarySelectSQL() string { + return `SELECT code, title, type, severity, category, priority, status, assignee, source_channel, + risk_score, summary_text, mail_sent, tags, created_at, updated_at, last_activity_at FROM feedback_tickets` +} + func releaseNoticeSelectSQL() string { return `SELECT id, version, build, channel, title, message, release_notes, message_md, release_notes_md, download_url, notice_file, raw_json, published_at, created_at, updated_at FROM release_notices` @@ -65,6 +70,24 @@ func scanFeedbackRows(rows *sql.Rows) ([]Feedback, error) { return items, rows.Err() } +func scanFeedbackSummaryRows(rows *sql.Rows) ([]Feedback, error) { + items := []Feedback{} + for rows.Next() { + var item Feedback + var mailSent int + var tags string + if err := rows.Scan(&item.Code, &item.Title, &item.Type, &item.Severity, &item.Category, &item.Priority, + &item.Status, &item.Assignee, &item.SourceChannel, &item.RiskScore, &item.SummaryText, &mailSent, + &tags, &item.CreatedAt, &item.UpdatedAt, &item.LastActivityAt); err != nil { + return nil, err + } + item.MailSent = mailSent == 1 + _ = json.Unmarshal([]byte(tags), &item.Tags) + items = append(items, item) + } + return items, rows.Err() +} + func sourceSelectSQL() string { return `SELECT id, category_id, category_name, source_id, name, description, method, api_url, url_template, thumbnail_url, proxy_mode, timeout_ms, retry_count, cache_seconds, check_interval_sec, enabled, client_visible, supported_formats, diff --git a/server/unified-management/internal/db/schema.go b/server/unified-management/internal/db/schema.go index b1f8345..614aed0 100644 --- a/server/unified-management/internal/db/schema.go +++ b/server/unified-management/internal/db/schema.go @@ -5,7 +5,7 @@ import ( "fmt" ) -const CurrentSchemaVersion = "2026-06-compat-baseline" +const CurrentSchemaVersion = "2026-08-admin-performance-indexes" func (s *Store) migrate(conn *sql.DB, d dialect) error { statements := []string{} @@ -296,11 +296,20 @@ type schemaIndex struct { func schemaIndexes() []schemaIndex { return []schemaIndex{ {name: "idx_feedback_tickets_activity", table: "feedback_tickets", columns: "last_activity_at"}, + {name: "idx_feedback_tickets_status_activity", table: "feedback_tickets", columns: "status, last_activity_at"}, + {name: "idx_feedback_tickets_priority_activity", table: "feedback_tickets", columns: "priority, last_activity_at"}, + {name: "idx_feedback_tickets_category_activity", table: "feedback_tickets", columns: "category, last_activity_at"}, {name: "idx_feedback_comments_code", table: "feedback_comments", columns: "feedback_code"}, {name: "idx_feedback_attachments_code", table: "feedback_attachments", columns: "feedback_code"}, {name: "idx_feedback_events_code", table: "feedback_events", columns: "feedback_code"}, {name: "idx_mail_records_code", table: "mail_records", columns: "feedback_code"}, + {name: "idx_mail_records_status_created", table: "mail_records", columns: "status, created_at"}, + {name: "idx_source_endpoints_checked", table: "source_endpoints", columns: "last_checked_at"}, + {name: "idx_endpoint_health_checked", table: "endpoint_health_checks", columns: "checked_at, id"}, + {name: "idx_endpoint_health_source_checked", table: "endpoint_health_checks", columns: "source_db_id, checked_at"}, {name: "idx_endpoint_call_logs_source", table: "endpoint_call_logs", columns: "source_id"}, + {name: "idx_endpoint_call_logs_source_created", table: "endpoint_call_logs", columns: "source_id, created_at"}, + {name: "idx_endpoint_call_logs_created", table: "endpoint_call_logs", columns: "created_at, id"}, {name: "idx_audit_logs_created", table: "audit_logs", columns: "created_at"}, {name: "idx_audit_logs_type", table: "audit_logs", columns: "type"}, {name: "idx_audit_logs_target", table: "audit_logs", columns: "target"}, @@ -350,7 +359,7 @@ func (s *Store) recordSchemaVersion(conn *sql.DB, d dialect) error { _, err := conn.Exec(d.rebind(d.upsert("schema_migrations", columns, []string{"version"})), CurrentSchemaVersion, Now(), - "unified-management layered monolith baseline", + "admin performance indexes and production asset diagnostics", ) return err } diff --git a/server/unified-management/internal/db/system_logs.go b/server/unified-management/internal/db/system_logs.go index 2591997..c6b9006 100644 --- a/server/unified-management/internal/db/system_logs.go +++ b/server/unified-management/internal/db/system_logs.go @@ -1,11 +1,6 @@ package db -import ( - "encoding/json" - "fmt" - "sort" - "strings" -) +import "strings" func (s *Store) ListSystemLogsPage(filters SystemLogFilters) (SystemLogPage, error) { page := filters.Page @@ -19,209 +14,71 @@ func (s *Store) ListSystemLogsPage(filters SystemLogFilters) (SystemLogPage, err if perPage > 100 { perPage = 100 } - items, err := s.collectSystemLogs(filters) + base := systemLogUnionSQL() + where, args := systemLogWhere(filters) + var total int + if err := s.queryRow(`SELECT COUNT(*) FROM (`+base+`) system_logs`+where, args...).Scan(&total); err != nil { + return SystemLogPage{}, err + } + queryArgs := append(append([]any{}, args...), perPage, (page-1)*perPage) + rows, err := s.query(`SELECT id, category, type, target, status, message, detail, created_at + FROM (`+base+`) system_logs`+where+` ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`, queryArgs...) if err != nil { return SystemLogPage{}, err } - sort.SliceStable(items, func(i, j int) bool { - if items[i].CreatedAt == items[j].CreatedAt { - return items[i].ID > items[j].ID - } - return items[i].CreatedAt > items[j].CreatedAt - }) - total := len(items) - start := (page - 1) * perPage - if start > total { - start = total - } - end := start + perPage - if end > total { - end = total - } - return SystemLogPage{Items: items[start:end], Total: total, Page: page, PerPage: perPage}, nil -} - -func (s *Store) collectSystemLogs(filters SystemLogFilters) ([]SystemLogItem, error) { - items := []SystemLogItem{} - appendItems := func(category string, next []SystemLogItem, err error) error { - if err != nil { - return err - } - for _, item := range next { - if matchesSystemLog(filters, item, category) { - items = append(items, item) - } - } - return nil - } - logs, err := s.operationLogs() - if err := appendItems("operation", logs, err); err != nil { - return nil, err - } - logs, err = s.healthLogs() - if err := appendItems("health", logs, err); err != nil { - return nil, err - } - logs, err = s.clientCallLogs() - if err := appendItems("client", logs, err); err != nil { - return nil, err - } - logs, err = s.databaseSyncLogs() - if err := appendItems("database_sync", logs, err); err != nil { - return nil, err - } - logs, err = s.legacySyncLogs() - if err := appendItems("legacy_sync", logs, err); err != nil { - return nil, err - } - return items, nil -} - -func (s *Store) operationLogs() ([]SystemLogItem, error) { - rows, err := s.query(`SELECT id, actor, type, target, message, ip, user_agent, created_at FROM audit_logs ORDER BY id DESC LIMIT 500`) - if err != nil { - return nil, err - } defer rows.Close() items := []SystemLogItem{} for rows.Next() { - var item AuditLog - if err := rows.Scan(&item.ID, &item.Actor, &item.Type, &item.Target, &item.Message, &item.IP, &item.UserAgent, &item.CreatedAt); err != nil { - return nil, err + var item SystemLogItem + if err := rows.Scan(&item.ID, &item.Category, &item.Type, &item.Target, &item.Status, &item.Message, &item.Detail, &item.CreatedAt); err != nil { + return SystemLogPage{}, err } - items = append(items, SystemLogItem{ - ID: item.ID, - Category: "operation", - Type: item.Type, - Target: item.Target, - Status: firstNonEmpty(item.Actor, "system"), - Message: item.Message, - Detail: strings.TrimSpace(item.IP + " " + item.UserAgent), - CreatedAt: item.CreatedAt, - }) + items = append(items, item) } - return items, rows.Err() + if err := rows.Err(); err != nil { + return SystemLogPage{}, err + } + return SystemLogPage{Items: items, Total: total, Page: page, PerPage: perPage}, nil } -func (s *Store) healthLogs() ([]SystemLogItem, error) { - rows, err := s.query(`SELECT h.id, h.source_db_id, COALESCE(e.source_id, ''), COALESCE(e.name, ''), h.status, h.latency_ms, h.error, h.checked_at +func systemLogUnionSQL() string { + return `SELECT id, 'operation' AS category, type, target, + COALESCE(NULLIF(actor, ''), 'system') AS status, message, user_agent AS detail, created_at + FROM audit_logs + UNION ALL + SELECT h.id, 'health' AS category, 'endpoint.health' AS type, + COALESCE(NULLIF(e.source_id, ''), 'unknown') AS target, h.status, + COALESCE(NULLIF(e.name, ''), 'unknown endpoint') AS message, h.error AS detail, h.checked_at AS created_at FROM endpoint_health_checks h LEFT JOIN source_endpoints e ON e.id = h.source_db_id - ORDER BY h.id DESC LIMIT 500`) - if err != nil { - return nil, err - } - defer rows.Close() - items := []SystemLogItem{} - for rows.Next() { - var id, sourceDBID int64 - var sourceID, name, status, message, checkedAt string - var latency int - if err := rows.Scan(&id, &sourceDBID, &sourceID, &name, &status, &latency, &message, &checkedAt); err != nil { - return nil, err - } - target := firstNonEmpty(sourceID, fmt.Sprintf("source#%d", sourceDBID)) - items = append(items, SystemLogItem{ - ID: id, - Category: "health", - Type: "endpoint.health", - Target: target, - Status: status, - Message: firstNonEmpty(name, target), - Detail: fmt.Sprintf("%dms %s", latency, message), - CreatedAt: checkedAt, - }) - } - return items, rows.Err() + UNION ALL + SELECT id, 'client' AS category, 'endpoint.call' AS type, source_id AS target, + status, error AS message, client AS detail, created_at + FROM endpoint_call_logs + UNION ALL + SELECT id, 'database_sync' AS category, direction AS type, direction AS target, + status, message, tables_json AS detail, COALESCE(NULLIF(finished_at, ''), started_at) AS created_at + FROM database_sync_jobs + UNION ALL + SELECT id, 'legacy_sync' AS category, 'legacy.sync' AS type, 'legacy' AS target, + status, summary AS message, stats_json AS detail, COALESCE(NULLIF(finished_at, ''), started_at) AS created_at + FROM legacy_sync_jobs` } -func (s *Store) clientCallLogs() ([]SystemLogItem, error) { - rows, err := s.query(`SELECT id, source_id, status, latency_ms, error, client, created_at FROM endpoint_call_logs ORDER BY id DESC LIMIT 500`) - if err != nil { - return nil, err +func systemLogWhere(filters SystemLogFilters) (string, []any) { + clauses := []string{} + args := []any{} + if category := strings.TrimSpace(filters.Category); category != "" { + clauses = append(clauses, "category = ?") + args = append(args, sanitize(category)) } - defer rows.Close() - items := []SystemLogItem{} - for rows.Next() { - var id int64 - var sourceID, status, message, client, createdAt string - var latency int - if err := rows.Scan(&id, &sourceID, &status, &latency, &message, &client, &createdAt); err != nil { - return nil, err - } - items = append(items, SystemLogItem{ - ID: id, - Category: "client", - Type: "endpoint.call", - Target: sourceID, - Status: status, - Message: message, - Detail: fmt.Sprintf("%dms %s", latency, client), - CreatedAt: createdAt, - }) + if query := strings.TrimSpace(filters.Query); query != "" { + like := "%" + strings.ToLower(sanitize(query)) + "%" + clauses = append(clauses, `(LOWER(category) LIKE ? OR LOWER(type) LIKE ? OR LOWER(target) LIKE ? OR + LOWER(status) LIKE ? OR LOWER(message) LIKE ? OR LOWER(detail) LIKE ? OR LOWER(created_at) LIKE ?)`) + args = append(args, like, like, like, like, like, like, like) } - return items, rows.Err() -} - -func (s *Store) databaseSyncLogs() ([]SystemLogItem, error) { - rows, err := s.query(`SELECT id, direction, status, message, tables_json, started_at, finished_at FROM database_sync_jobs ORDER BY id DESC LIMIT 500`) - if err != nil { - return nil, err - } - defer rows.Close() - items := []SystemLogItem{} - for rows.Next() { - job, err := scanDatabaseSyncJob(rows) - if err != nil { - return nil, err - } - detail, _ := json.Marshal(map[string]any{"tables": job.Tables, "warnings": job.Warnings, "errors": job.Errors}) - items = append(items, SystemLogItem{ - ID: job.ID, - Category: "database_sync", - Type: job.Direction, - Target: directionLabel(job.Direction), - Status: job.Status, - Message: strings.Join(job.Output, "\n"), - Detail: string(detail), - CreatedAt: firstNonEmpty(job.FinishedAt, job.StartedAt), - }) - } - return items, rows.Err() -} - -func (s *Store) legacySyncLogs() ([]SystemLogItem, error) { - rows, err := s.query(`SELECT id, status, summary, stats_json, started_at, finished_at FROM legacy_sync_jobs ORDER BY id DESC LIMIT 500`) - if err != nil { - return nil, err - } - defer rows.Close() - items := []SystemLogItem{} - for rows.Next() { - var item LegacySyncJob - if err := rows.Scan(&item.ID, &item.Status, &item.Summary, &item.StatsJSON, &item.StartedAt, &item.FinishedAt); err != nil { - return nil, err - } - items = append(items, SystemLogItem{ - ID: item.ID, - Category: "legacy_sync", - Type: "legacy.sync", - Target: "legacy", - Status: item.Status, - Message: item.Summary, - Detail: item.StatsJSON, - CreatedAt: firstNonEmpty(item.FinishedAt, item.StartedAt), - }) - } - return items, rows.Err() -} - -func matchesSystemLog(filters SystemLogFilters, item SystemLogItem, category string) bool { - if value := strings.TrimSpace(filters.Category); value != "" && value != category && value != item.Category { - return false - } - if value := strings.ToLower(strings.TrimSpace(filters.Query)); value != "" { - haystack := strings.ToLower(strings.Join([]string{item.Category, item.Type, item.Target, item.Status, item.Message, item.Detail, item.CreatedAt}, " ")) - return strings.Contains(haystack, value) - } - return true + if len(clauses) == 0 { + return "", args + } + return " WHERE " + strings.Join(clauses, " AND "), args } diff --git a/server/unified-management/internal/health/health.go b/server/unified-management/internal/health/health.go index 7750031..36f80b3 100644 --- a/server/unified-management/internal/health/health.go +++ b/server/unified-management/internal/health/health.go @@ -1,20 +1,55 @@ package health import ( + "sync" + "time" + "ymhut-box/server/unified-management/internal/config" "ymhut-box/server/unified-management/internal/db" ) -func Snapshot(cfg *config.Config, store *db.Store) map[string]any { +type Service struct { + cfg *config.Config + store *db.Store + mu sync.RWMutex + checks []config.Check + checkedAt time.Time +} + +func NewService(cfg *config.Config, store *db.Store) *Service { + service := &Service{cfg: cfg, store: store} + service.RefreshPreflight() + return service +} + +func (s *Service) RefreshPreflight() []config.Check { + checks := config.Preflight(s.cfg) + s.mu.Lock() + s.checks = append([]config.Check(nil), checks...) + s.checkedAt = time.Now().UTC() + s.mu.Unlock() + return checks +} + +func (s *Service) Snapshot() map[string]any { + s.mu.RLock() + checks := append([]config.Check(nil), s.checks...) + checkedAt := s.checkedAt + s.mu.RUnlock() return map[string]any{ "ok": true, "version": config.Version, "service": map[string]any{ "name": "YMhut Unified Management", - "baseUrl": cfg.BaseURL, - "cdnBaseUrl": cfg.CDNBaseURL, + "baseUrl": s.cfg.BaseURL, + "cdnBaseUrl": s.cfg.CDNBaseURL, }, - "database": store.Status(), - "preflight": config.Preflight(cfg), + "database": s.store.Status(), + "preflight": checks, + "preflightCheckedAt": checkedAt.Format(time.RFC3339), } } + +func Snapshot(cfg *config.Config, store *db.Store) map[string]any { + return NewService(cfg, store).Snapshot() +} diff --git a/server/unified-management/internal/health/health_test.go b/server/unified-management/internal/health/health_test.go new file mode 100644 index 0000000..3ff3008 --- /dev/null +++ b/server/unified-management/internal/health/health_test.go @@ -0,0 +1,38 @@ +package health + +import ( + "os" + "path/filepath" + "testing" + + "ymhut-box/server/unified-management/internal/config" + "ymhut-box/server/unified-management/internal/db" +) + +func TestSnapshotReadsCachedPreflightUntilManualRefresh(t *testing.T) { + root := t.TempDir() + cfg := &config.Config{ + BaseDir: root, StorageDir: filepath.Join(root, "storage"), DataDir: filepath.Join(root, "data"), + UpdatePublicDir: filepath.Join(root, "data", "update", "public"), UpdateNoticeDir: filepath.Join(root, "data", "notices"), + DownloadsDir: filepath.Join(root, "data", "update", "public", "downloads"), AdminWebDir: filepath.Join(root, "admin"), + PortalWebDir: filepath.Join(root, "portal"), SetupWebDir: filepath.Join(root, "setup"), AdminAssetMode: "disk", + Database: config.DatabaseConfig{Provider: "sqlite", SQLitePath: filepath.Join(root, "storage", "health.sqlite"), HealthIntervalSec: 3600}, + } + store, err := db.Open(cfg) + if err != nil { + t.Fatal(err) + } + defer store.Close() + service := NewService(cfg, store) + if err := os.RemoveAll(cfg.DownloadsDir); err != nil { + t.Fatal(err) + } + _ = service.Snapshot() + if _, err := os.Stat(cfg.DownloadsDir); !os.IsNotExist(err) { + t.Fatalf("cached snapshot unexpectedly reran filesystem preflight: %v", err) + } + service.RefreshPreflight() + if _, err := os.Stat(cfg.DownloadsDir); err != nil { + t.Fatalf("manual refresh did not recreate downloads directory: %v", err) + } +} diff --git a/server/unified-management/internal/reference/data/cpu-ranking.json b/server/unified-management/internal/reference/data/cpu-ranking.json new file mode 100644 index 0000000..70fe67b --- /dev/null +++ b/server/unified-management/internal/reference/data/cpu-ranking.json @@ -0,0 +1,34 @@ +{ + "schemaVersion": 1, + "kind": "cpu", + "version": "2026.08", + "generatedAt": "2026-08-16T00:00:00Z", + "source": { + "name": "YMhut Box hardware reference", + "url": "https://update.ymhut.cn/api/client/reference-data/cpu", + "disclaimer": "Relative scores summarize public multi-core and gaming results. Actual performance depends on power limits, memory, cooling and workload." + }, + "checksum": "sha256:eca642fe4223c6fc53cf9fabce94d905cfcecc878d5ebd403fd66e1b06de429c", + "items": [ + { "rank": 1, "vendor": "AMD", "model": "Ryzen 9 9950X3D", "score": 100, "tier": "旗舰", "segment": "desktop" }, + { "rank": 2, "vendor": "AMD", "model": "Ryzen 9 9950X", "score": 96, "tier": "旗舰", "segment": "desktop" }, + { "rank": 3, "vendor": "Intel", "model": "Core Ultra 9 285K", "score": 90, "tier": "旗舰", "segment": "desktop" }, + { "rank": 4, "vendor": "AMD", "model": "Ryzen 9 9900X3D", "score": 86, "tier": "高端", "segment": "desktop" }, + { "rank": 5, "vendor": "AMD", "model": "Ryzen 7 9800X3D", "score": 81, "tier": "高端", "segment": "desktop" }, + { "rank": 6, "vendor": "Intel", "model": "Core i9-14900KS", "score": 79, "tier": "高端", "segment": "desktop" }, + { "rank": 7, "vendor": "AMD", "model": "Ryzen 9 7950X3D", "score": 77, "tier": "高端", "segment": "desktop" }, + { "rank": 8, "vendor": "Intel", "model": "Core i9-14900K", "score": 75, "tier": "高端", "segment": "desktop" }, + { "rank": 9, "vendor": "AMD", "model": "Ryzen 9 9900X", "score": 73, "tier": "高端", "segment": "desktop" }, + { "rank": 10, "vendor": "Intel", "model": "Core Ultra 7 265K", "score": 69, "tier": "中高端", "segment": "desktop" }, + { "rank": 11, "vendor": "AMD", "model": "Ryzen 7 9700X", "score": 64, "tier": "中高端", "segment": "desktop" }, + { "rank": 12, "vendor": "Intel", "model": "Core i7-14700K", "score": 62, "tier": "中高端", "segment": "desktop" }, + { "rank": 13, "vendor": "AMD", "model": "Ryzen 7 7800X3D", "score": 60, "tier": "中高端", "segment": "desktop" }, + { "rank": 14, "vendor": "AMD", "model": "Ryzen AI 9 HX 370", "score": 54, "tier": "移动高端", "segment": "mobile" }, + { "rank": 15, "vendor": "Intel", "model": "Core Ultra 9 185H", "score": 50, "tier": "移动高端", "segment": "mobile" }, + { "rank": 16, "vendor": "AMD", "model": "Ryzen 5 9600X", "score": 47, "tier": "主流", "segment": "desktop" }, + { "rank": 17, "vendor": "Intel", "model": "Core Ultra 5 245K", "score": 46, "tier": "主流", "segment": "desktop" }, + { "rank": 18, "vendor": "Intel", "model": "Core i5-14600K", "score": 44, "tier": "主流", "segment": "desktop" }, + { "rank": 19, "vendor": "AMD", "model": "Ryzen 5 7600", "score": 36, "tier": "主流", "segment": "desktop" }, + { "rank": 20, "vendor": "Intel", "model": "Core Ultra 7 155H", "score": 35, "tier": "移动主流", "segment": "mobile" } + ] +} diff --git a/server/unified-management/internal/reference/data/gpu-ranking.json b/server/unified-management/internal/reference/data/gpu-ranking.json new file mode 100644 index 0000000..dfb494d --- /dev/null +++ b/server/unified-management/internal/reference/data/gpu-ranking.json @@ -0,0 +1,34 @@ +{ + "schemaVersion": 1, + "kind": "gpu", + "version": "2026.08", + "generatedAt": "2026-08-16T00:00:00Z", + "source": { + "name": "YMhut Box hardware reference", + "url": "https://update.ymhut.cn/api/client/reference-data/gpu", + "disclaimer": "Relative scores summarize public gaming and compute results. Actual performance depends on power, cooling, drivers and workload." + }, + "checksum": "sha256:a8258fa438b830446322750cd71435fd8e0ff65102eede3ca006bea6b61f3361", + "items": [ + { "rank": 1, "vendor": "NVIDIA", "model": "GeForce RTX 5090", "score": 100, "tier": "旗舰", "segment": "desktop" }, + { "rank": 2, "vendor": "NVIDIA", "model": "GeForce RTX 4090", "score": 82, "tier": "旗舰", "segment": "desktop" }, + { "rank": 3, "vendor": "NVIDIA", "model": "GeForce RTX 5080", "score": 78, "tier": "旗舰", "segment": "desktop" }, + { "rank": 4, "vendor": "AMD", "model": "Radeon RX 9070 XT", "score": 67, "tier": "高端", "segment": "desktop" }, + { "rank": 5, "vendor": "NVIDIA", "model": "GeForce RTX 5070 Ti", "score": 65, "tier": "高端", "segment": "desktop" }, + { "rank": 6, "vendor": "AMD", "model": "Radeon RX 7900 XTX", "score": 63, "tier": "高端", "segment": "desktop" }, + { "rank": 7, "vendor": "NVIDIA", "model": "GeForce RTX 4080 SUPER", "score": 62, "tier": "高端", "segment": "desktop" }, + { "rank": 8, "vendor": "AMD", "model": "Radeon RX 9070", "score": 57, "tier": "高端", "segment": "desktop" }, + { "rank": 9, "vendor": "NVIDIA", "model": "GeForce RTX 5070", "score": 52, "tier": "中高端", "segment": "desktop" }, + { "rank": 10, "vendor": "AMD", "model": "Radeon RX 7900 XT", "score": 51, "tier": "中高端", "segment": "desktop" }, + { "rank": 11, "vendor": "NVIDIA", "model": "GeForce RTX 4070 Ti SUPER", "score": 49, "tier": "中高端", "segment": "desktop" }, + { "rank": 12, "vendor": "Intel", "model": "Arc B580", "score": 31, "tier": "主流", "segment": "desktop" }, + { "rank": 13, "vendor": "NVIDIA", "model": "GeForce RTX 5060 Ti", "score": 30, "tier": "主流", "segment": "desktop" }, + { "rank": 14, "vendor": "AMD", "model": "Radeon RX 7800 XT", "score": 29, "tier": "主流", "segment": "desktop" }, + { "rank": 15, "vendor": "NVIDIA", "model": "GeForce RTX 4060 Ti", "score": 24, "tier": "主流", "segment": "desktop" }, + { "rank": 16, "vendor": "Intel", "model": "Arc B570", "score": 23, "tier": "主流", "segment": "desktop" }, + { "rank": 17, "vendor": "AMD", "model": "Radeon RX 7700 XT", "score": 22, "tier": "主流", "segment": "desktop" }, + { "rank": 18, "vendor": "NVIDIA", "model": "GeForce RTX 5060 Laptop", "score": 20, "tier": "移动主流", "segment": "mobile" }, + { "rank": 19, "vendor": "AMD", "model": "Radeon RX 7600", "score": 17, "tier": "入门", "segment": "desktop" }, + { "rank": 20, "vendor": "NVIDIA", "model": "GeForce RTX 4060 Laptop", "score": 16, "tier": "移动主流", "segment": "mobile" } + ] +} diff --git a/server/unified-management/internal/reference/reference.go b/server/unified-management/internal/reference/reference.go new file mode 100644 index 0000000..ebd621d --- /dev/null +++ b/server/unified-management/internal/reference/reference.go @@ -0,0 +1,76 @@ +package reference + +import ( + "embed" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" +) + +//go:embed data/*.json +var bundled embed.FS + +var ErrNotFound = errors.New("reference data not found") + +type Service struct { + overrideRoot string +} + +func NewService(overrideRoot string) *Service { + return &Service{overrideRoot: strings.TrimSpace(overrideRoot)} +} + +func (s *Service) Read(kind string) (map[string]any, error) { + name, ok := fileName(kind) + if !ok { + return nil, ErrNotFound + } + var data []byte + var err error + if s.overrideRoot != "" { + data, err = os.ReadFile(filepath.Join(s.overrideRoot, "reference-data", name)) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, err + } + } + if len(data) == 0 { + data, err = bundled.ReadFile("data/" + name) + if err != nil { + return nil, err + } + } + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + return nil, err + } + if payload["schemaVersion"] == nil || payload["items"] == nil { + return nil, errors.New("reference data schema is invalid") + } + return payload, nil +} + +func (s *Service) Descriptors() map[string]any { + return map[string]any{ + "cpu": map[string]any{ + "url": "/api/client/reference-data/cpu", + "bundledFallback": true, + }, + "gpu": map[string]any{ + "url": "/api/client/reference-data/gpu", + "bundledFallback": true, + }, + } +} + +func fileName(kind string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(kind)) { + case "cpu", "cpu-ranking": + return "cpu-ranking.json", true + case "gpu", "gpu-ranking": + return "gpu-ranking.json", true + default: + return "", false + } +} diff --git a/server/unified-management/internal/reference/reference_test.go b/server/unified-management/internal/reference/reference_test.go new file mode 100644 index 0000000..024b4b4 --- /dev/null +++ b/server/unified-management/internal/reference/reference_test.go @@ -0,0 +1,46 @@ +package reference + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReadUsesBundledSnapshot(t *testing.T) { + service := NewService(t.TempDir()) + payload, err := service.Read("gpu") + if err != nil { + t.Fatal(err) + } + if payload["kind"] != "gpu" { + t.Fatalf("kind = %#v", payload["kind"]) + } + items, ok := payload["items"].([]any) + if !ok || len(items) < 10 { + t.Fatalf("unexpected items: %#v", payload["items"]) + } +} + +func TestReadPrefersValidOverride(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "reference-data") + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "cpu-ranking.json"), []byte(`{"schemaVersion":1,"kind":"cpu","version":"override","items":[]}`), 0o640); err != nil { + t.Fatal(err) + } + payload, err := NewService(root).Read("cpu") + if err != nil { + t.Fatal(err) + } + if payload["version"] != "override" { + t.Fatalf("version = %#v", payload["version"]) + } +} + +func TestReadRejectsUnknownKind(t *testing.T) { + if _, err := NewService("").Read("unknown"); err != ErrNotFound { + t.Fatalf("err = %v", err) + } +} diff --git a/server/unified-management/internal/releases/releases.go b/server/unified-management/internal/releases/releases.go index 2bbe1fa..2fd5f31 100644 --- a/server/unified-management/internal/releases/releases.go +++ b/server/unified-management/internal/releases/releases.go @@ -1,6 +1,7 @@ package releases import ( + "context" "crypto/sha256" "encoding/hex" "encoding/json" @@ -8,8 +9,10 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "path/filepath" + "reflect" "regexp" "sort" "strconv" @@ -23,11 +26,19 @@ import ( ) type Service struct { - cfg *config.Config - store *db.Store - notices *notices.Service - hashMu sync.Mutex - hashes map[string]cachedFileHash + cfg *config.Config + store *db.Store + notices *notices.Service + hashMu sync.Mutex + hashes map[string]cachedFileHash + hashFile func(string) string + packageMu sync.RWMutex + packages []Package + reconcileMu sync.Mutex + callbackMu sync.RWMutex + onChange func() + stop chan struct{} + stopOnce sync.Once } type cachedFileHash struct { @@ -72,16 +83,60 @@ var ( ErrUploadedPackageEmpty = errors.New("uploaded file is empty") ErrUploadedPackageStorageFailed = errors.New("upload storage failed") ErrUploadedPackageManifestFailed = errors.New("manifest update failed") + ErrUploadedPackageIndexFailed = errors.New("package index update failed") + ErrUnsupportedPackage = errors.New("unsupported package extension") + ErrUnsafePackageName = errors.New("unsafe package file name") ) func NewService(cfg *config.Config, store *db.Store, noticeService ...*notices.Service) *Service { - service := &Service{cfg: cfg, store: store, hashes: map[string]cachedFileHash{}} + return newService(cfg, store, sha256File, noticeService...) +} + +func newService(cfg *config.Config, store *db.Store, hashFile func(string) string, noticeService ...*notices.Service) *Service { + service := &Service{cfg: cfg, store: store, hashes: map[string]cachedFileHash{}, hashFile: hashFile, stop: make(chan struct{})} if len(noticeService) > 0 { service.notices = noticeService[0] } + _ = service.Reconcile(context.Background()) return service } +func (s *Service) Start(ctx context.Context) { + go func() { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + select { + case <-ticker.C: + _ = s.Reconcile(context.Background()) + case <-ctx.Done(): + return + case <-s.stop: + return + } + } + }() +} + +func (s *Service) Stop() { + s.stopOnce.Do(func() { close(s.stop) }) +} + +func (s *Service) SetChangeCallback(callback func()) { + s.callbackMu.Lock() + s.onChange = callback + s.callbackMu.Unlock() +} + +func (s *Service) notifyChanged() { + s.callbackMu.RLock() + callback := s.onChange + s.callbackMu.RUnlock() + if callback != nil { + callback() + } +} + func (s *Service) LegacyUpdateInfo(r *http.Request) map[string]any { payload := s.legacyUpdateBase() manifest := s.Manifest(r) @@ -102,7 +157,6 @@ func (s *Service) Manifest(r *http.Request) map[string]any { } payload["manifest_version"] = 2 payload["service_version"] = config.Version - payload["packages"] = packages payload["modules"] = modules payload["assets"] = []any{} payload["generated_at"] = time.Now().UTC().Format(time.RFC3339) @@ -121,23 +175,60 @@ func (s *Service) Manifest(r *http.Request) map[string]any { setIfMissing(payload, "download_url", latestNotice.DownloadURL) } } + preferredVersion := manifestString(payload, "app_version") + packages = prioritizePackages(packages, preferredVersion) + payload["packages"] = packages if len(packages) > 0 { latest := packages[0] - payload["app_version"] = latest.Version - payload["download_url"] = latest.URL - payload["download_mirrors"] = []map[string]any{{ - "id": "primary", - "name": "官方直连", - "url": latest.URL, - "type": "direct", - "sha256": latest.SHA256, - "enabled": true, - }} - payload["detected_product"] = latest.Name + if preferredVersion == "" { + preferredVersion = latest.Version + payload["app_version"] = preferredVersion + } + if sameVersion(latest.Version, preferredVersion) { + payload["download_url"] = latest.URL + payload["download_mirrors"] = []map[string]any{{ + "id": "primary", + "name": "官方直连", + "url": latest.URL, + "type": "direct", + "sha256": latest.SHA256, + "enabled": true, + }} + payload["detected_product"] = latest.Name + } } return payload } +func manifestString(payload map[string]any, key string) string { + value, _ := payload[key].(string) + return strings.TrimSpace(value) +} + +func prioritizePackages(items []Package, preferredVersion string) []Package { + if strings.TrimSpace(preferredVersion) == "" { + return items + } + sort.SliceStable(items, func(i, j int) bool { + left := sameVersion(items[i].Version, preferredVersion) + right := sameVersion(items[j].Version, preferredVersion) + return left && !right + }) + return items +} + +func sameVersion(a, b string) bool { + a = strings.TrimSpace(a) + b = strings.TrimSpace(b) + if a == "" || b == "" { + return false + } + if a == b { + return true + } + return versionPattern.FindString(a) == a && versionPattern.FindString(b) == b && compareVersion(a, b) == 0 +} + func (s *Service) PublishLegacyUpdateInfo(r *http.Request, actor string) error { payload := s.LegacyUpdateInfo(r) data, err := json.MarshalIndent(payload, "", " ") @@ -162,48 +253,149 @@ func setIfMissing(payload map[string]any, key, value string) { } func (s *Service) ScanPackages(r *http.Request) []Package { + base := firstNonEmpty(strings.TrimRight(s.cfg.CDNBaseURL, "/"), requestBaseURL(r, s.cfg.BaseURL)) + s.packageMu.RLock() + items := append([]Package(nil), s.packages...) + s.packageMu.RUnlock() + for index := range items { + items[index].URL = base + "/downloads/" + items[index].FileName + } + return items +} + +// Reconcile keeps request-time manifest generation independent from package I/O. +// Hashes are reused from the persistent index whenever size and mtime still match. +func (s *Service) Reconcile(ctx context.Context) error { + s.reconcileMu.Lock() + defer s.reconcileMu.Unlock() + if err := os.MkdirAll(s.cfg.DownloadsDir, 0o750); err != nil { + return err + } + indexed := map[string]db.ReleasePackage{} + var firstErr error + if s.store != nil { + rows, err := s.store.ListReleasePackages() + if err != nil { + firstErr = err + } else { + for _, item := range rows { + indexed[item.FileName] = item + } + } + } entries, err := os.ReadDir(s.cfg.DownloadsDir) if err != nil { - return []Package{} + return err } - base := firstNonEmpty(strings.TrimRight(s.cfg.CDNBaseURL, "/"), requestBaseURL(r, s.cfg.BaseURL)) - items := []Package{} + items := make([]Package, 0, len(entries)) + seen := map[string]bool{} for _, entry := range entries { - if entry.IsDir() { - continue + if err := ctx.Err(); err != nil { + return err } - name := entry.Name() - lower := strings.ToLower(name) - if !(strings.HasSuffix(lower, ".exe") || strings.HasSuffix(lower, ".msix") || strings.HasSuffix(lower, ".appinstaller") || strings.HasSuffix(lower, ".msi")) { + if entry.IsDir() || !isSupportedPackageName(entry.Name()) { continue } info, err := entry.Info() if err != nil { + if firstErr == nil { + firstErr = err + } continue } - version := detectVersion(name) - platform, arch := detectPlatform(name) - product := detectProduct(name) - url := base + "/downloads/" + name - items = append(items, Package{ - ID: strings.ToLower(strings.ReplaceAll(product+"-"+platform+"-"+arch+"-"+version, " ", "-")), - Name: product, - Version: version, - Platform: platform, - Arch: arch, - URL: url, - SHA256: s.cachedSHA256(filepath.Join(s.cfg.DownloadsDir, name), info), - Size: info.Size(), - Required: strings.Contains(strings.ToLower(product), "ymhut"), - Enabled: true, - FileName: name, - UpdatedAt: info.ModTime().UTC().Format(time.RFC3339), - }) + name := entry.Name() + seen[name] = true + modifiedAt := info.ModTime().UTC().Format(time.RFC3339Nano) + record, cached := indexed[name] + var item Package + if cached && record.SizeBytes == info.Size() && record.UpdatedAt == modifiedAt && strings.TrimSpace(record.SHA256) != "" { + item = packageFromRecord(record) + s.hashMu.Lock() + s.hashes[filepath.Join(s.cfg.DownloadsDir, name)] = cachedFileHash{size: info.Size(), modifiedAt: info.ModTime().UnixNano(), sha256: record.SHA256} + s.hashMu.Unlock() + } else { + version := detectVersion(name) + platform, arch := detectPlatform(name) + product := detectProduct(name) + hashValue := s.cachedSHA256(filepath.Join(s.cfg.DownloadsDir, name), info) + if strings.TrimSpace(hashValue) == "" { + if firstErr == nil { + firstErr = fmt.Errorf("hash package %q: empty SHA-256", name) + } + continue + } + item = Package{ + ID: packageID(product, platform, arch, version), + Name: product, + Version: version, + Platform: platform, + Arch: arch, + SHA256: hashValue, + Size: info.Size(), + Required: strings.Contains(strings.ToLower(product), "ymhut"), + Enabled: true, + FileName: name, + UpdatedAt: modifiedAt, + } + if s.store != nil { + if _, err := s.store.UpsertReleasePackage(releasePackageRecord(item)); err != nil && firstErr == nil { + firstErr = err + } + } + } + items = append(items, item) } - sort.Slice(items, func(i, j int) bool { - return compareVersion(items[i].Version, items[j].Version) > 0 - }) - return items + if s.store != nil { + for name := range indexed { + if !seen[name] { + if err := s.store.DeleteReleasePackage(name); err != nil && firstErr == nil { + firstErr = err + } + } + } + } + if firstErr != nil { + return firstErr + } + sort.Slice(items, func(i, j int) bool { return compareVersion(items[i].Version, items[j].Version) > 0 }) + s.packageMu.Lock() + changed := !reflect.DeepEqual(s.packages, items) + s.packages = items + s.packageMu.Unlock() + if changed { + s.notifyChanged() + } + return firstErr +} + +func packageFromRecord(item db.ReleasePackage) Package { + return Package{ + ID: packageID(item.Product, item.Platform, item.Arch, item.Version), Name: item.Product, Version: item.Version, + Platform: item.Platform, Arch: item.Arch, URL: item.URL, SHA256: item.SHA256, Size: item.SizeBytes, + Required: strings.Contains(strings.ToLower(item.Product), "ymhut"), Enabled: item.Enabled, + FileName: item.FileName, UpdatedAt: item.UpdatedAt, + } +} + +func releasePackageRecord(item Package) db.ReleasePackage { + return db.ReleasePackage{ + Product: item.Name, Version: item.Version, Platform: item.Platform, Arch: item.Arch, FileName: item.FileName, + URL: item.URL, SHA256: item.SHA256, SizeBytes: item.Size, Enabled: item.Enabled, UpdatedAt: item.UpdatedAt, + } +} + +func packageID(product, platform, arch, version string) string { + return strings.ToLower(strings.ReplaceAll(product+"-"+platform+"-"+arch+"-"+version, " ", "-")) +} + +func isSupportedPackageName(name string) bool { + lower := strings.ToLower(name) + for _, suffix := range []string{".exe", ".msix", ".appinstaller", ".msi", ".zip", ".7z"} { + if strings.HasSuffix(lower, suffix) { + return true + } + } + return false } func (s *Service) cachedSHA256(path string, info os.FileInfo) string { @@ -215,7 +407,7 @@ func (s *Service) cachedSHA256(path string, info os.FileInfo) string { } s.hashMu.Unlock() - value := sha256File(path) + value := s.hashFile(path) s.hashMu.Lock() s.hashes[path] = cachedFileHash{size: info.Size(), modifiedAt: modifiedAt, sha256: value} for cachedPath := range s.hashes { @@ -241,64 +433,28 @@ func (s *Service) SaveUploadedPackage(r *http.Request, reader io.Reader, opts Up if err := os.MkdirAll(s.cfg.DownloadsDir, 0o750); err != nil { return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, err) } - target := filepath.Join(s.cfg.DownloadsDir, name) - resolved, err := filepath.Abs(target) - if err != nil { - return Package{}, err - } - base, _ := filepath.Abs(s.cfg.DownloadsDir) - if resolved != base && !strings.HasPrefix(resolved, base+string(os.PathSeparator)) { - return Package{}, errors.New("path escape rejected") - } tmp, err := os.CreateTemp(s.cfg.DownloadsDir, "."+name+".*.upload") if err != nil { - return Package{}, err + return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, err) } tmpName := tmp.Name() defer os.Remove(tmpName) hash := sha256.New() - written, err := io.Copy(tmp, io.TeeReader(reader, hash)) + written, err := io.Copy(io.MultiWriter(tmp, hash), reader) + if err == nil { + err = tmp.Sync() + } if closeErr := tmp.Close(); err == nil { err = closeErr } if err != nil { - return Package{}, err + return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, err) } if written <= 0 { - return Package{}, errors.New("uploaded file is empty") + return Package{}, ErrUploadedPackageEmpty } - if err := os.Chmod(tmpName, 0o640); err != nil { - return Package{}, err - } - if err := os.Rename(tmpName, target); err != nil { - return Package{}, err - } - version := firstNonEmpty(opts.Version, detectVersion(name)) - platform, arch := detectPlatform(name) - platform = firstNonEmpty(opts.Platform, platform) - arch = firstNonEmpty(opts.Arch, arch) - product := detectProduct(name) - pkg := Package{ - ID: strings.ToLower(strings.ReplaceAll(product+"-"+platform+"-"+arch+"-"+version, " ", "-")), - Name: product, - Version: version, - Platform: platform, - Arch: arch, - URL: firstNonEmpty(strings.TrimRight(s.cfg.CDNBaseURL, "/"), requestBaseURL(r, s.cfg.BaseURL)) + "/downloads/" + name, - SHA256: hex.EncodeToString(hash.Sum(nil)), - Size: written, - Required: strings.Contains(strings.ToLower(product), "ymhut"), - Enabled: true, - FileName: name, - UpdatedAt: time.Now().UTC().Format(time.RFC3339), - } - if opts.UpdateManifest { - if err := s.updateLegacyManifest(pkg, opts); err != nil { - return Package{}, err - } - } - _ = s.store.InsertAudit(db.AuditLog{Actor: firstNonEmpty(actor, "admin"), Type: "release.package_uploaded", Target: name, Message: fmt.Sprintf("已上传发布包 %s(%s,%s)", name, version, formatBytes(written))}) - return pkg, nil + opts.FileName = name + return s.SavePreparedPackage(r, UploadedPackageFile{TempPath: tmpName, Size: written, SHA256: hex.EncodeToString(hash.Sum(nil))}, opts, actor) } func (s *Service) SavePreparedPackage(r *http.Request, uploaded UploadedPackageFile, opts UploadOptions, actor string) (Package, error) { @@ -335,13 +491,26 @@ func (s *Service) SavePreparedPackage(r *http.Request, uploaded UploadedPackageF _ = restorePackageBackup(target, backup) return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, err) } + rollbackFile := func(cause error) error { + if rollbackErr := restorePackageBackup(target, backup); rollbackErr != nil { + return fmt.Errorf("%w; rollback failed: %v", cause, rollbackErr) + } + return cause + } + info, err := os.Stat(target) + if err != nil { + return Package{}, rollbackFile(fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, err)) + } + if info.Size() != uploaded.Size { + return Package{}, rollbackFile(fmt.Errorf("%w: uploaded size changed during commit", ErrUploadedPackageStorageFailed)) + } version := firstNonEmpty(opts.Version, detectVersion(name)) platform, arch := detectPlatform(name) platform = firstNonEmpty(opts.Platform, platform) arch = firstNonEmpty(opts.Arch, arch) product := detectProduct(name) pkg := Package{ - ID: strings.ToLower(strings.ReplaceAll(product+"-"+platform+"-"+arch+"-"+version, " ", "-")), + ID: packageID(product, platform, arch, version), Name: product, Version: version, Platform: platform, @@ -352,23 +521,95 @@ func (s *Service) SavePreparedPackage(r *http.Request, uploaded UploadedPackageF Required: strings.Contains(strings.ToLower(product), "ymhut"), Enabled: true, FileName: name, - UpdatedAt: time.Now().UTC().Format(time.RFC3339), + UpdatedAt: info.ModTime().UTC().Format(time.RFC3339Nano), } + var previousManifest fileSnapshot if opts.UpdateManifest { + previousManifest, err = captureFile(filepath.Join(s.cfg.UpdatePublicDir, "update-info.json")) + if err != nil { + return Package{}, rollbackFile(fmt.Errorf("%w: %v", ErrUploadedPackageManifestFailed, err)) + } if err := s.updateLegacyManifest(pkg, opts); err != nil { - if rollbackErr := restorePackageBackup(target, backup); rollbackErr != nil { - return Package{}, fmt.Errorf("%w: %v; rollback failed: %v", ErrUploadedPackageManifestFailed, err, rollbackErr) + return Package{}, rollbackFile(fmt.Errorf("%w: %v", ErrUploadedPackageManifestFailed, err)) + } + } + if s.store != nil { + if _, err := s.store.UpsertReleasePackage(releasePackageRecord(pkg)); err != nil { + cause := fmt.Errorf("%w: %v", ErrUploadedPackageIndexFailed, err) + if opts.UpdateManifest { + if restoreErr := previousManifest.restore(filepath.Join(s.cfg.UpdatePublicDir, "update-info.json")); restoreErr != nil { + cause = fmt.Errorf("%w; manifest rollback failed: %v", cause, restoreErr) + } } - return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageManifestFailed, err) + return Package{}, rollbackFile(cause) } } if backup != "" { _ = os.Remove(backup) } - _ = s.store.InsertAudit(db.AuditLog{Actor: firstNonEmpty(actor, "admin"), Type: "release.package_uploaded", Target: name, Message: fmt.Sprintf("已上传发布包 %s(%s,%s)", name, version, formatBytes(uploaded.Size))}) + s.upsertPackageSnapshot(pkg) + if s.store != nil { + _ = s.store.InsertAudit(db.AuditLog{Actor: firstNonEmpty(actor, "admin"), Type: "release.package_uploaded", Target: name, Message: fmt.Sprintf("已上传发布包 %s(%s,%s)", name, version, formatBytes(uploaded.Size))}) + } return pkg, nil } +type fileSnapshot struct { + data []byte + mode os.FileMode + existed bool +} + +func captureFile(path string) (fileSnapshot, error) { + info, err := os.Stat(path) + if errors.Is(err, os.ErrNotExist) { + return fileSnapshot{}, nil + } + if err != nil { + return fileSnapshot{}, err + } + data, err := os.ReadFile(path) + if err != nil { + return fileSnapshot{}, err + } + return fileSnapshot{data: data, mode: info.Mode(), existed: true}, nil +} + +func (s fileSnapshot) restore(path string) error { + if !s.existed { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return err + } + mode := s.mode + if mode == 0 { + mode = 0o640 + } + return os.WriteFile(path, s.data, mode) +} + +func (s *Service) upsertPackageSnapshot(item Package) { + s.packageMu.Lock() + replaced := false + for index := range s.packages { + if s.packages[index].FileName == item.FileName { + s.packages[index] = item + replaced = true + break + } + } + if !replaced { + s.packages = append(s.packages, item) + } + sort.Slice(s.packages, func(i, j int) bool { return compareVersion(s.packages[i].Version, s.packages[j].Version) > 0 }) + s.packageMu.Unlock() + s.notifyChanged() +} + func backupExistingPackage(target string) (string, error) { if _, err := os.Stat(target); errors.Is(err, os.ErrNotExist) { return "", nil @@ -492,21 +733,46 @@ func atomicWrite(path string, data []byte) error { func requestBaseURL(r *http.Request, fallback string) string { if r != nil { - scheme := r.Header.Get("X-Forwarded-Proto") - if scheme == "" { + scheme := firstForwardedHeader(r.Header.Get("X-Forwarded-Proto")) + if scheme != "http" && scheme != "https" { if r.TLS != nil { scheme = "https" } else { scheme = "http" } } - if r.Host != "" { - return scheme + "://" + r.Host + host := firstForwardedHeader(r.Header.Get("X-Forwarded-Host")) + if !validForwardedHost(host) { + host = strings.TrimSpace(r.Host) + } + if validForwardedHost(host) { + return scheme + "://" + host } } return strings.TrimRight(fallback, "/") } +func firstForwardedHeader(value string) string { + return strings.ToLower(strings.TrimSpace(strings.Split(value, ",")[0])) +} + +func validForwardedHost(value string) bool { + if value == "" || strings.TrimSpace(value) != value || strings.ContainsAny(value, "\\\\\r\n\t ") { + return false + } + parsed, err := url.Parse("http://" + value) + if err != nil || parsed.Host != value || parsed.User != nil || parsed.Hostname() == "" || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" { + return false + } + if port := parsed.Port(); port != "" { + value, err := strconv.Atoi(port) + if err != nil || value < 1 || value > 65535 { + return false + } + } + return true +} + var versionPattern = regexp.MustCompile(`\d+\.\d+\.\d+(?:\.\d+)?`) func detectVersion(name string) string { @@ -577,16 +843,13 @@ func sha256File(path string) string { func safePackageName(name string) (string, error) { original := strings.TrimSpace(name) if original == "" || original == "." || original == ".." || strings.ContainsAny(original, `/\`) { - return "", errors.New("invalid filename") + return "", ErrUnsafePackageName } name = filepath.Base(original) - lower := strings.ToLower(name) - for _, suffix := range []string{".exe", ".msix", ".appinstaller", ".msi", ".zip", ".7z"} { - if strings.HasSuffix(lower, suffix) { - return name, nil - } + if isSupportedPackageName(name) { + return name, nil } - return "", errors.New("unsupported package extension") + return "", ErrUnsupportedPackage } func formatBytes(value int64) string { diff --git a/server/unified-management/internal/releases/releases_test.go b/server/unified-management/internal/releases/releases_test.go index 2754eaa..ab09b62 100644 --- a/server/unified-management/internal/releases/releases_test.go +++ b/server/unified-management/internal/releases/releases_test.go @@ -2,6 +2,7 @@ package releases import ( "errors" + "net/http" "net/http/httptest" "os" "path/filepath" @@ -12,6 +13,46 @@ import ( "ymhut-box/server/unified-management/internal/db" ) +func TestRequestBaseURLUsesTrustedForwardedValues(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "http://internal:33550/downloads/file.zip", nil) + req.Header.Set("X-Forwarded-Proto", "HTTPS, http") + req.Header.Set("X-Forwarded-Host", "updates.example.com:8443, internal:33550") + + if got := requestBaseURL(req, "https://fallback.example.com/"); got != "https://updates.example.com:8443" { + t.Fatalf("requestBaseURL() = %q", got) + } +} + +func TestRequestBaseURLRejectsInvalidForwardedValues(t *testing.T) { + tests := []struct { + name string + proto string + host string + request string + fallback string + want string + }{ + {name: "invalid scheme", proto: "javascript", host: "updates.example.com", request: "https://internal:33550/path", want: "https://updates.example.com"}, + {name: "invalid forwarded host", proto: "https", host: "user@evil.example", request: "http://internal:33550/path", want: "https://internal:33550"}, + {name: "invalid forwarded port", proto: "https", host: "updates.example.com:99999", request: "http://internal:33550/path", want: "https://internal:33550"}, + {name: "invalid request host", proto: "https", host: "evil.example/path", request: "http://internal/path", fallback: "https://fallback.example.com/", want: "https://fallback.example.com"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, test.request, nil) + req.Header.Set("X-Forwarded-Proto", test.proto) + req.Header.Set("X-Forwarded-Host", test.host) + if test.name == "invalid request host" { + req.Host = "bad host" + } + if got := requestBaseURL(req, test.fallback); got != test.want { + t.Fatalf("requestBaseURL() = %q, want %q", got, test.want) + } + }) + } +} + func TestCompareVersion(t *testing.T) { cases := []struct { a string @@ -67,6 +108,231 @@ func TestScanPackagesUsesCDNAndCachesHashes(t *testing.T) { } } +func TestReconcileListsEverySupportedPackageType(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"app.exe", "app.msix", "app.appinstaller", "app.msi", "app.zip", "app.7z"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o640); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("ignored"), 0o640); err != nil { + t.Fatal(err) + } + service := NewService(&config.Config{DownloadsDir: dir, BaseURL: "https://update.example"}, nil) + if got := len(service.ScanPackages(httptest.NewRequest("GET", "https://update.example/api/client/releases", nil))); got != 6 { + t.Fatalf("listed %d packages, want 6", got) + } +} + +func TestReconcileReusesPersistentHashAfterRestart(t *testing.T) { + dir := t.TempDir() + cfg := &config.Config{ + StorageDir: filepath.Join(dir, "storage"), + DownloadsDir: filepath.Join(dir, "downloads"), + BaseURL: "https://update.example", + Database: config.DatabaseConfig{ + Provider: "sqlite", + SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"), + HealthIntervalSec: 30, + }, + } + if err := os.MkdirAll(cfg.DownloadsDir, 0o750); err != nil { + t.Fatal(err) + } + name := "YMhut_Box_2.1.0_x64.zip" + if err := os.WriteFile(filepath.Join(cfg.DownloadsDir, name), []byte("persistent package hash"), 0o640); err != nil { + t.Fatal(err) + } + store, err := db.Open(cfg) + if err != nil { + t.Fatal(err) + } + defer store.Close() + first := NewService(cfg, store) + if packages := first.ScanPackages(httptest.NewRequest("GET", "https://update.example", nil)); len(packages) != 1 || packages[0].SHA256 == "" { + t.Fatalf("initial reconcile did not index the package: %#v", packages) + } + hashCalls := 0 + second := newService(cfg, store, func(path string) string { + hashCalls++ + return sha256File(path) + }) + if packages := second.ScanPackages(httptest.NewRequest("GET", "https://update.example", nil)); len(packages) != 1 { + t.Fatalf("restart snapshot contains %d packages, want 1", len(packages)) + } + if hashCalls != 0 { + t.Fatalf("restart rehashed %d files, want 0", hashCalls) + } +} + +func TestReconcileRehashesChangedFileAndRemovesDeletedIndex(t *testing.T) { + service, cfg, cleanup := newPreparedPackageTestService(t) + defer cleanup() + name := "YMhut_Box_2.2.0_x64.zip" + path := filepath.Join(cfg.DownloadsDir, name) + if err := os.MkdirAll(cfg.DownloadsDir, 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("first"), 0o640); err != nil { + t.Fatal(err) + } + hashCalls := 0 + service.hashFile = func(path string) string { + hashCalls++ + return sha256File(path) + } + if err := service.Reconcile(t.Context()); err != nil { + t.Fatal(err) + } + first := service.ScanPackages(nil) + if len(first) != 1 || hashCalls != 1 { + t.Fatalf("first reconcile packages=%d hashCalls=%d", len(first), hashCalls) + } + if err := os.WriteFile(path, []byte("changed package bytes"), 0o640); err != nil { + t.Fatal(err) + } + if err := service.Reconcile(t.Context()); err != nil { + t.Fatal(err) + } + second := service.ScanPackages(nil) + if hashCalls != 2 || len(second) != 1 || second[0].SHA256 == first[0].SHA256 { + t.Fatalf("changed package was not rehashed: first=%#v second=%#v calls=%d", first, second, hashCalls) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := service.Reconcile(t.Context()); err != nil { + t.Fatal(err) + } + if packages := service.ScanPackages(nil); len(packages) != 0 { + t.Fatalf("deleted package remains in snapshot: %#v", packages) + } + if _, ok, err := service.store.GetReleasePackage(name); err != nil || ok { + t.Fatalf("deleted package remains in index: ok=%v err=%v", ok, err) + } +} + +func TestScanPackagesNeverHashesDuringRequest(t *testing.T) { + dir := t.TempDir() + name := "YMhut_Box_2.2.1_x64.7z" + if err := os.WriteFile(filepath.Join(dir, name), []byte("large package placeholder"), 0o640); err != nil { + t.Fatal(err) + } + service := NewService(&config.Config{DownloadsDir: dir, BaseURL: "https://update.example"}, nil) + service.hashFile = func(string) string { + t.Fatal("request-time package scan attempted to hash file content") + return "" + } + for range 10 { + if packages := service.ScanPackages(httptest.NewRequest("GET", "https://update.example/api/client/bootstrap", nil)); len(packages) != 1 { + t.Fatalf("snapshot contains %d packages, want 1", len(packages)) + } + } +} + +func TestManifestPrioritizesPublishedVersionOverNumericallyHigherHistoricalPackage(t *testing.T) { + dir := t.TempDir() + cfg := &config.Config{ + DownloadsDir: filepath.Join(dir, "downloads"), + UpdatePublicDir: filepath.Join(dir, "update", "public"), + BaseURL: "https://update.example", + } + if err := os.MkdirAll(cfg.DownloadsDir, 0o750); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(cfg.UpdatePublicDir, 0o750); err != nil { + t.Fatal(err) + } + for _, name := range []string{ + "YMhut_Box_WinUI_Setup_2.0.7.111.exe", + "YMhut_Box_WinUI_Setup_2.0.7.12.exe", + } { + if err := os.WriteFile(filepath.Join(cfg.DownloadsDir, name), []byte(name), 0o640); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile( + filepath.Join(cfg.UpdatePublicDir, "update-info.json"), + []byte(`{"app_version":"2.0.7.12"}`), + 0o640); err != nil { + t.Fatal(err) + } + + service := NewService(cfg, nil) + manifest := service.Manifest(httptest.NewRequest(http.MethodGet, "https://update.example/api/client/releases", nil)) + if got := manifestString(manifest, "app_version"); got != "2.0.7.12" { + t.Fatalf("manifest app_version = %q, want 2.0.7.12", got) + } + packages, ok := manifest["packages"].([]Package) + if !ok || len(packages) != 2 { + t.Fatalf("manifest packages = %#v", manifest["packages"]) + } + if packages[0].Version != "2.0.7.12" { + t.Fatalf("first package version = %q, want published version 2.0.7.12", packages[0].Version) + } + if got := manifestString(manifest, "download_url"); !strings.Contains(got, "2.0.7.12.exe") { + t.Fatalf("manifest download_url = %q, want current package", got) + } +} + +func TestManifestFallsBackToHighestPackageWithoutPublishedVersion(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"YMhut_Box_2.0.7.12.exe", "YMhut_Box_2.0.7.111.exe"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o640); err != nil { + t.Fatal(err) + } + } + service := NewService(&config.Config{ + DownloadsDir: dir, + UpdatePublicDir: filepath.Join(dir, "missing-public"), + BaseURL: "https://update.example", + }, nil) + manifest := service.Manifest(httptest.NewRequest(http.MethodGet, "https://update.example/api/client/releases", nil)) + if got := manifestString(manifest, "app_version"); got != "2.0.7.111" { + t.Fatalf("manifest fallback app_version = %q, want highest indexed package 2.0.7.111", got) + } +} + +func TestSavePreparedPackageRestoresFileAndManifestWhenIndexFails(t *testing.T) { + service, cfg, closeStore := newPreparedPackageTestService(t) + name := "YMhut_Box_WinUI_Setup_2.3.0_x64.exe" + if err := os.MkdirAll(cfg.DownloadsDir, 0o750); err != nil { + t.Fatal(err) + } + target := filepath.Join(cfg.DownloadsDir, name) + if err := os.WriteFile(target, []byte("old package"), 0o640); err != nil { + t.Fatal(err) + } + manifestPath := filepath.Join(cfg.UpdatePublicDir, "update-info.json") + if err := os.MkdirAll(cfg.UpdatePublicDir, 0o750); err != nil { + t.Fatal(err) + } + oldManifest := []byte("{\"app_version\":\"1.0.0\"}\n") + if err := os.WriteFile(manifestPath, oldManifest, 0o640); err != nil { + t.Fatal(err) + } + temp := filepath.Join(cfg.DownloadsDir, ".upload-new") + if err := os.WriteFile(temp, []byte("new package"), 0o640); err != nil { + t.Fatal(err) + } + closeStore() + + _, err := service.SavePreparedPackage( + httptest.NewRequest("POST", "https://update.ymhut.cn/api/admin/releases/packages", nil), + UploadedPackageFile{TempPath: temp, Size: int64(len("new package")), SHA256: "abc123"}, + UploadOptions{FileName: name, UpdateManifest: true}, + "admin") + if !errors.Is(err, ErrUploadedPackageIndexFailed) { + t.Fatalf("got %v, want index failure", err) + } + if data, readErr := os.ReadFile(target); readErr != nil || string(data) != "old package" { + t.Fatalf("package rollback data=%q err=%v", data, readErr) + } + if data, readErr := os.ReadFile(manifestPath); readErr != nil || string(data) != string(oldManifest) { + t.Fatalf("manifest rollback data=%q err=%v", data, readErr) + } +} + func TestSaveUploadedPackageWritesFileAndUpdatesManifest(t *testing.T) { dir := t.TempDir() cfg := &config.Config{ diff --git a/server/unified-management/internal/sources/sources.go b/server/unified-management/internal/sources/sources.go index 342874b..a223b85 100644 --- a/server/unified-management/internal/sources/sources.go +++ b/server/unified-management/internal/sources/sources.go @@ -28,6 +28,7 @@ type Service struct { mu sync.RWMutex jobs map[string]CheckJob subscribers map[chan Event]struct{} + onChange func() } type Event struct { @@ -548,6 +549,7 @@ func (s *Service) CheckOne(ctx context.Context, item db.Source) error { } func (s *Service) CheckOneStatus(ctx context.Context, item db.Source) (string, error) { + defer s.notifyChange() if strings.TrimSpace(item.APIURL) == "" { return "error", errors.New("source api_url is empty") } @@ -657,6 +659,21 @@ func (s *Service) PublishEvent(kind string, data map[string]any) { s.emit(kind, data) } +func (s *Service) SetChangeCallback(callback func()) { + s.mu.Lock() + s.onChange = callback + s.mu.Unlock() +} + +func (s *Service) notifyChange() { + s.mu.RLock() + callback := s.onChange + s.mu.RUnlock() + if callback != nil { + callback() + } +} + func newJobID() string { return fmt.Sprintf("check-%d", time.Now().UnixNano()) } diff --git a/server/unified-management/internal/web/admin_feedback_routes.go b/server/unified-management/internal/web/admin_feedback_routes.go index 5c72550..3ee613a 100644 --- a/server/unified-management/internal/web/admin_feedback_routes.go +++ b/server/unified-management/internal/web/admin_feedback_routes.go @@ -17,7 +17,7 @@ func (r *router) handleAdminFeedbacks(w http.ResponseWriter, req *http.Request) if req.URL.Query().Get("page") != "" { page, _ := strconv.Atoi(req.URL.Query().Get("page")) perPage, _ := strconv.Atoi(req.URL.Query().Get("perPage")) - items, total, err := r.store.ListFeedbacksFiltered(page, perPage, db.FeedbackFilters{ + items, total, err := r.store.ListFeedbackSummariesFiltered(page, perPage, db.FeedbackFilters{ Status: req.URL.Query().Get("status"), Category: req.URL.Query().Get("category"), Priority: req.URL.Query().Get("priority"), diff --git a/server/unified-management/internal/web/admin_release_routes.go b/server/unified-management/internal/web/admin_release_routes.go index 6f73377..9164f5a 100644 --- a/server/unified-management/internal/web/admin_release_routes.go +++ b/server/unified-management/internal/web/admin_release_routes.go @@ -222,9 +222,16 @@ func writeReleaseUploadError(w http.ResponseWriter, err error) { if errors.Is(err, releases.ErrUploadedPackageManifestFailed) { status = http.StatusInternalServerError code = "MANIFEST_UPDATE_FAILED" + } else if errors.Is(err, releases.ErrUploadedPackageIndexFailed) { + status = http.StatusInternalServerError + code = "PACKAGE_INDEX_FAILED" } else if errors.Is(err, releases.ErrUploadedPackageStorageFailed) { status = http.StatusInternalServerError code = "UPLOAD_STORAGE_FAILED" + } else if errors.Is(err, releases.ErrUnsupportedPackage) { + code = "PACKAGE_TYPE_UNSUPPORTED" + } else if errors.Is(err, releases.ErrUnsafePackageName) { + code = "PACKAGE_NAME_INVALID" } else if errors.Is(err, releases.ErrUploadedPackageEmpty) { code = "PACKAGE_EMPTY" } else if errors.Is(err, releases.ErrUploadedPackageMissing) { diff --git a/server/unified-management/internal/web/admin_system_routes.go b/server/unified-management/internal/web/admin_system_routes.go index e8313c3..004e375 100644 --- a/server/unified-management/internal/web/admin_system_routes.go +++ b/server/unified-management/internal/web/admin_system_routes.go @@ -11,7 +11,6 @@ import ( "ymhut-box/server/unified-management/internal/config" "ymhut-box/server/unified-management/internal/db" - "ymhut-box/server/unified-management/internal/health" feedbackmail "ymhut-box/server/unified-management/internal/mail" ) @@ -54,6 +53,7 @@ func (r *router) handleAdminDatabase(w http.ResponseWriter, req *http.Request) { writeError(w, http.StatusInternalServerError, "DATABASE_SAVE_FAILED", err) return } + r.health.RefreshPreflight() _ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "system.database.saved", Target: body.Provider, Message: "数据库配置已保存并热切换", IP: req.RemoteAddr, UserAgent: req.UserAgent()}) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "database": r.store.Status(), "config": config.SafeDatabase(r.cfg.BaseDir, r.cfg.Database)}) case req.Method == http.MethodPost && path == "/api/admin/database/sync/jobs": @@ -161,15 +161,47 @@ func (r *router) handleAdminDashboard(w http.ResponseWriter, req *http.Request) http.NotFound(w, req) return } - overview, err := r.store.DashboardOverview(80) + window, duration, points := dashboardWindow(req.URL.Query().Get("window")) + overview, cacheHit, err := r.dashboardCache.Get(window, func() (map[string]any, error) { + since := time.Now().UTC().Add(-duration).Format(time.RFC3339) + result, buildErr := r.store.DashboardOverviewWindow(points, since) + if buildErr != nil { + return nil, buildErr + } + result["window"] = window + jobs := r.sources.CheckJobs() + if len(jobs) > 5 { + jobs = jobs[:5] + } + result["sourceCheckJobs"] = jobs + result["health"] = r.healthSnapshot() + return result, nil + }) if err != nil { writeError(w, http.StatusInternalServerError, "DASHBOARD_FAILED", err) return } - overview["health"] = health.Snapshot(r.cfg, r.store) + if cacheHit { + w.Header().Set("X-Admin-Cache", "hit") + } else { + w.Header().Set("X-Admin-Cache", "miss") + } writeJSON(w, http.StatusOK, overview) } +func dashboardWindow(value string) (string, time.Duration, int) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "1h": + return "1h", time.Hour, 60 + case "6h": + return "6h", 6 * time.Hour, 72 + case "7d": + return "7d", 7 * 24 * time.Hour, 112 + default: + return "24h", 24 * time.Hour, 96 + } +} + func (r *router) handleAdminSync(w http.ResponseWriter, req *http.Request) { if r.syncer == nil { writeError(w, http.StatusNotFound, "SYNC_DISABLED", errors.New("legacy sync service is not configured")) @@ -239,7 +271,18 @@ func (r *router) handleAdminSystem(w http.ResponseWriter, req *http.Request) { path := cleanPath(req.URL.Path) switch path { case "/api/admin/system/health": - writeJSON(w, http.StatusOK, health.Snapshot(r.cfg, r.store)) + if req.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET required")) + return + } + writeJSON(w, http.StatusOK, r.healthSnapshot()) + case "/api/admin/system/preflight": + if req.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required")) + return + } + r.health.RefreshPreflight() + writeJSON(w, http.StatusOK, r.healthSnapshot()) case "/api/admin/system/audit": page, err := r.store.ListAuditLogsPage(db.AuditFilters{ Page: queryInt(req, "page", 1), @@ -293,6 +336,12 @@ func (r *router) handleAdminSystem(w http.ResponseWriter, req *http.Request) { } } +func (r *router) healthSnapshot() map[string]any { + snapshot := r.health.Snapshot() + snapshot["adminAssets"] = r.adminAssets.Diagnostics() + return snapshot +} + func queryInt(req *http.Request, key string, fallback int) int { value, err := strconv.Atoi(req.URL.Query().Get(key)) if err != nil || value <= 0 { diff --git a/server/unified-management/internal/web/client_routes.go b/server/unified-management/internal/web/client_routes.go index 341d4e7..aedacf0 100644 --- a/server/unified-management/internal/web/client_routes.go +++ b/server/unified-management/internal/web/client_routes.go @@ -1,6 +1,7 @@ package web import ( + "errors" "net/http" "strings" "time" @@ -8,6 +9,7 @@ import ( "ymhut-box/server/unified-management/internal/config" "ymhut-box/server/unified-management/internal/health" "ymhut-box/server/unified-management/internal/notices" + "ymhut-box/server/unified-management/internal/reference" ) func (r *router) handleClientBootstrap(w http.ResponseWriter, req *http.Request) { @@ -34,6 +36,7 @@ func (r *router) handleClientBootstrap(w http.ResponseWriter, req *http.Request) "releaseManifest": true, "endpointCalls": true, "legacyJson": true, + "referenceData": true, }, "endpoints": map[string]string{ "releases": "/api/client/releases", @@ -41,6 +44,7 @@ func (r *router) handleClientBootstrap(w http.ResponseWriter, req *http.Request) "clientEndpoints": "/api/client/endpoints", "endpointCalls": "/api/client/endpoint-calls", "notices": "/api/client/notices", + "referenceData": "/api/client/reference-data/:kind", "feedback": "/", }, "cache": map[string]int{ @@ -48,19 +52,44 @@ func (r *router) handleClientBootstrap(w http.ResponseWriter, req *http.Request) "releasesSeconds": 300, "sourcesSeconds": 600, "healthSeconds": 300, + "referenceSeconds": 86400, }, - "legacyRoutes": []string{"/update-info.json", "/update-info", "/api/update-info", "/api/releases", "/tool-status.json", "/media-types.json", "/modules.json", "/downloads/:filename"}, - "release": release, - "sources": sourceCatalog, - "feedback": map[string]any{"submit": "/", "status": "/?api=status&code=:code"}, - "branding": config.SafeBranding(r.effectiveBranding()), - "health": health.Snapshot(r.cfg, r.store), - "notices": publicNotices, + "legacyRoutes": []string{"/update-info.json", "/update-info", "/api/update-info", "/api/releases", "/tool-status.json", "/media-types.json", "/modules.json", "/downloads/:filename"}, + "release": release, + "sources": sourceCatalog, + "feedback": map[string]any{"submit": "/", "status": "/?api=status&code=:code"}, + "branding": config.SafeBranding(r.effectiveBranding()), + "health": health.Snapshot(r.cfg, r.store), + "notices": publicNotices, + "referenceData": r.referenceData.Descriptors(), } }) writePublicSnapshot(w, req, snapshot) } +func (r *router) handleClientReferenceData(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet && req.Method != http.MethodHead { + writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET required")) + return + } + kind := strings.TrimPrefix(cleanPath(req.URL.Path), "/api/client/reference-data/") + if kind == "" || strings.Contains(kind, "/") { + http.NotFound(w, req) + return + } + payload, err := r.referenceData.Read(kind) + if errors.Is(err, reference.ErrNotFound) { + writeError(w, http.StatusNotFound, "REFERENCE_DATA_NOT_FOUND", err) + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "REFERENCE_DATA_FAILED", err) + return + } + snapshot := r.publicSnapshots.Get("reference-data|"+kind, func(_ time.Time) any { return payload }) + writePublicSnapshot(w, req, snapshot) +} + func (r *router) handleClientSources(w http.ResponseWriter, req *http.Request) { baseURL := requestBaseURL(req, r.cfg.BaseURL) snapshot := r.publicSnapshots.Get("sources|"+baseURL, func(_ time.Time) any { diff --git a/server/unified-management/internal/web/dashboard_cache.go b/server/unified-management/internal/web/dashboard_cache.go new file mode 100644 index 0000000..690e204 --- /dev/null +++ b/server/unified-management/internal/web/dashboard_cache.go @@ -0,0 +1,85 @@ +package web + +import ( + "sync" + "time" +) + +type dashboardCacheEntry struct { + value map[string]any + expiresAt time.Time + building chan struct{} +} + +type dashboardSnapshotCache struct { + mu sync.Mutex + ttl time.Duration + generation uint64 + entries map[string]*dashboardCacheEntry +} + +func newDashboardSnapshotCache(ttl time.Duration) *dashboardSnapshotCache { + if ttl <= 0 { + ttl = 5 * time.Second + } + return &dashboardSnapshotCache{ttl: ttl, entries: map[string]*dashboardCacheEntry{}} +} + +func (c *dashboardSnapshotCache) Get(key string, build func() (map[string]any, error)) (map[string]any, bool, error) { + for { + now := time.Now() + c.mu.Lock() + entry := c.entries[key] + if entry != nil && entry.value != nil && now.Before(entry.expiresAt) { + value := entry.value + c.mu.Unlock() + return value, true, nil + } + if entry != nil && entry.building != nil { + ready := entry.building + c.mu.Unlock() + <-ready + continue + } + generation := c.generation + ready := make(chan struct{}) + c.entries[key] = &dashboardCacheEntry{building: ready} + c.mu.Unlock() + + value, err := build() + + c.mu.Lock() + if generation != c.generation { + if current := c.entries[key]; current != nil && current.building == ready { + delete(c.entries, key) + } + close(ready) + c.mu.Unlock() + if err != nil { + return nil, false, err + } + continue + } + if err != nil { + delete(c.entries, key) + close(ready) + c.mu.Unlock() + return nil, false, err + } + c.entries[key] = &dashboardCacheEntry{value: value, expiresAt: time.Now().Add(c.ttl)} + close(ready) + c.mu.Unlock() + return value, false, nil + } +} + +func (c *dashboardSnapshotCache) Invalidate() { + c.mu.Lock() + c.generation++ + for key, entry := range c.entries { + if entry.building == nil { + delete(c.entries, key) + } + } + c.mu.Unlock() +} diff --git a/server/unified-management/internal/web/dashboard_cache_test.go b/server/unified-management/internal/web/dashboard_cache_test.go new file mode 100644 index 0000000..3e70243 --- /dev/null +++ b/server/unified-management/internal/web/dashboard_cache_test.go @@ -0,0 +1,42 @@ +package web + +import ( + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestDashboardSnapshotCacheSharesConcurrentBuildAndInvalidates(t *testing.T) { + cache := newDashboardSnapshotCache(time.Minute) + var builds atomic.Int32 + build := func() (map[string]any, error) { + builds.Add(1) + time.Sleep(15 * time.Millisecond) + return map[string]any{"ok": true}, nil + } + + var wait sync.WaitGroup + for range 12 { + wait.Add(1) + go func() { + defer wait.Done() + value, _, err := cache.Get("24h", build) + if err != nil || value["ok"] != true { + t.Errorf("Get returned value=%#v err=%v", value, err) + } + }() + } + wait.Wait() + if got := builds.Load(); got != 1 { + t.Fatalf("concurrent cache builds = %d, want 1", got) + } + + cache.Invalidate() + if _, hit, err := cache.Get("24h", build); err != nil || hit { + t.Fatalf("invalidated cache returned hit=%v err=%v", hit, err) + } + if got := builds.Load(); got != 2 { + t.Fatalf("builds after invalidation = %d, want 2", got) + } +} diff --git a/server/unified-management/internal/web/public_snapshot.go b/server/unified-management/internal/web/public_snapshot.go index 33d6d66..30b38ca 100644 --- a/server/unified-management/internal/web/public_snapshot.go +++ b/server/unified-management/internal/web/public_snapshot.go @@ -28,6 +28,8 @@ type publicSnapshotService struct { entries map[string]publicSnapshotEntry } +const maxPublicSnapshotEntries = 64 + func newPublicSnapshotService(ttl time.Duration) *publicSnapshotService { return &publicSnapshotService{ttl: ttl, entries: map[string]publicSnapshotEntry{}} } @@ -39,6 +41,22 @@ func (s *publicSnapshotService) Get(key string, build func(generatedAt time.Time if entry, ok := s.entries[key]; ok && now.Before(entry.expiresAt) { return entry.snapshot } + for entryKey, entry := range s.entries { + if !now.Before(entry.expiresAt) { + delete(s.entries, entryKey) + } + } + if len(s.entries) >= maxPublicSnapshotEntries { + oldestKey := "" + var oldestExpiry time.Time + for entryKey, entry := range s.entries { + if oldestKey == "" || entry.expiresAt.Before(oldestExpiry) { + oldestKey = entryKey + oldestExpiry = entry.expiresAt + } + } + delete(s.entries, oldestKey) + } payload := build(now) data, err := json.Marshal(payload) diff --git a/server/unified-management/internal/web/response.go b/server/unified-management/internal/web/response.go index 0b97872..243da1f 100644 --- a/server/unified-management/internal/web/response.go +++ b/server/unified-management/internal/web/response.go @@ -2,12 +2,31 @@ package web import ( "encoding/json" + "fmt" + "log" "net/http" + "net/url" + "runtime/debug" + "strconv" "strings" ) func withSecurity(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if recovered := recover(); recovered != nil { + if recovered == http.ErrAbortHandler { + panic(recovered) + } + log.Printf("recovered request panic method=%s path=%s error=%v\n%s", r.Method, r.URL.Path, recovered, debug.Stack()) + w.Header().Set("Cache-Control", "no-store") + if strings.HasPrefix(cleanPath(r.URL.Path), "/api/") { + writeError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", fmt.Errorf("request failed unexpectedly")) + return + } + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } + }() w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "same-origin") next.ServeHTTP(w, r) @@ -77,6 +96,7 @@ func localizedErrorMessage(code, message string) string { return translated } byCode := map[string]string{ + "INTERNAL_SERVER_ERROR": "服务端处理请求时发生异常,请稍后重试并检查服务日志", "UNAUTHORIZED": "需要登录后继续操作", "LOGIN_FAILED": "登录失败,请检查密码和验证码", "LOGIN_LOCKED": "登录失败次数过多,请 5 分钟后重试", @@ -98,6 +118,9 @@ func localizedErrorMessage(code, message string) string { "PACKAGE_UPLOAD_FAILED": "发布包上传失败", "PACKAGE_EMPTY": "发布包不能为空", "PACKAGE_TOO_LARGE": "发布包超过服务端上传上限", + "PACKAGE_INDEX_FAILED": "发布包已回滚,数据库索引更新失败", + "PACKAGE_TYPE_UNSUPPORTED": "仅支持 EXE、MSIX、APPINSTALLER、MSI、ZIP 或 7Z 发布包", + "PACKAGE_NAME_INVALID": "发布包文件名不合法", "UPLOAD_STORAGE_FAILED": "服务端无法保存上传文件", "MANIFEST_UPDATE_FAILED": "发布包已回滚,更新清单写入失败", "UPLOAD_INTERRUPTED": "上传连接已中断,请重新上传", @@ -163,20 +186,45 @@ func cleanPath(path string) string { } func requestBaseURL(r *http.Request, fallback string) string { - scheme := r.Header.Get("X-Forwarded-Proto") - if scheme == "" { + scheme := firstForwardedHeader(r.Header.Get("X-Forwarded-Proto")) + if scheme != "http" && scheme != "https" { if r.TLS != nil { scheme = "https" } else { scheme = "http" } } - if r.Host != "" { - return scheme + "://" + r.Host + host := firstForwardedHeader(r.Header.Get("X-Forwarded-Host")) + if !validForwardedHost(host) { + host = strings.TrimSpace(r.Host) + } + if validForwardedHost(host) { + return scheme + "://" + host } return strings.TrimRight(fallback, "/") } +func firstForwardedHeader(value string) string { + return strings.ToLower(strings.TrimSpace(strings.Split(value, ",")[0])) +} + +func validForwardedHost(value string) bool { + if value == "" || strings.TrimSpace(value) != value || strings.ContainsAny(value, "\\\\\r\n\t ") { + return false + } + parsed, err := url.Parse("http://" + value) + if err != nil || parsed.Host != value || parsed.User != nil || parsed.Hostname() == "" || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" { + return false + } + if port := parsed.Port(); port != "" { + value, err := strconv.Atoi(port) + if err != nil || value < 1 || value > 65535 { + return false + } + } + return true +} + func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { diff --git a/server/unified-management/internal/web/router.go b/server/unified-management/internal/web/router.go index 1dec8ab..134b021 100644 --- a/server/unified-management/internal/web/router.go +++ b/server/unified-management/internal/web/router.go @@ -4,17 +4,21 @@ import ( "context" "encoding/json" "errors" + "log" "net" "net/http" "strings" "time" + "ymhut-box/server/unified-management/internal/adminassets" "ymhut-box/server/unified-management/internal/auth" "ymhut-box/server/unified-management/internal/config" "ymhut-box/server/unified-management/internal/db" "ymhut-box/server/unified-management/internal/feedback" + "ymhut-box/server/unified-management/internal/health" "ymhut-box/server/unified-management/internal/legacy" "ymhut-box/server/unified-management/internal/notices" + "ymhut-box/server/unified-management/internal/reference" "ymhut-box/server/unified-management/internal/releases" "ymhut-box/server/unified-management/internal/sources" "ymhut-box/server/unified-management/internal/synclegacy" @@ -29,8 +33,12 @@ type router struct { sources *sources.Service legacy *legacy.Service notices *notices.Service + referenceData *reference.Service syncer *synclegacy.Service publicSnapshots *publicSnapshotService + adminAssets *adminassets.Service + health *health.Service + dashboardCache *dashboardSnapshotCache } const loginRequestTimeout = 8 * time.Second @@ -44,7 +52,17 @@ func NewRouter(cfg *config.Config, store *db.Store, authService *auth.Service, f releases: releaseService, sources: sourceService, legacy: legacyService, + referenceData: reference.NewService(cfg.UpdatePublicDir), publicSnapshots: newPublicSnapshotService(60 * time.Second), + adminAssets: adminassets.New(cfg.AdminAssetMode, cfg.AdminWebDir, config.AdminBuildID), + health: health.NewService(cfg, store), + dashboardCache: newDashboardSnapshotCache(5 * time.Second), + } + assetStatus := r.adminAssets.Diagnostics() + if assetStatus.Ready { + log.Printf("admin assets: mode=%s build=%s manifest=%s entries=%d", assetStatus.Mode, assetStatus.BuildID, assetStatus.ManifestStatus, assetStatus.ManifestEntries) + } else { + log.Printf("admin assets unavailable: mode=%s source=%s error=%s", assetStatus.Mode, assetStatus.Source, assetStatus.ValidationError) } for _, item := range optional { switch typed := item.(type) { @@ -54,11 +72,32 @@ func NewRouter(cfg *config.Config, store *db.Store, authService *auth.Service, f r.syncer = typed } } + if releaseService != nil { + releaseService.SetChangeCallback(func() { + r.publicSnapshots.Invalidate() + r.dashboardCache.Invalidate() + }) + } + if sourceService != nil { + sourceService.SetChangeCallback(r.dashboardCache.Invalidate) + } return withSecurity(r) } func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) { + req.RemoteAddr = clientAddress(req) path := cleanPath(req.URL.Path) + if strings.HasPrefix(path, "/api/admin/") { + w.Header().Set("X-Admin-Build-ID", r.adminAssets.Diagnostics().BuildID) + if path != "/api/admin/events" { + diagnostics := &diagnosticResponseWriter{ResponseWriter: w, status: http.StatusOK} + w = diagnostics + started := time.Now() + defer func() { + log.Printf("admin_api method=%s path=%s status=%d duration_ms=%d cache=%s build=%s", req.Method, path, diagnostics.status, time.Since(started).Milliseconds(), firstNonEmpty(w.Header().Get("X-Admin-Cache"), "n/a"), r.adminAssets.Diagnostics().BuildID) + }() + } + } if strings.HasPrefix(path, "/api/admin/") && path != "/api/admin/auth/login" && path != "/api/admin/auth/logout" && @@ -70,6 +109,7 @@ func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } r.publicSnapshots.Invalidate() + r.dashboardCache.Invalidate() if r.sources != nil { r.sources.PublishEvent(adminMutationEvent(path), map[string]any{"path": path, "method": req.Method, "time": time.Now().UTC().Format(time.RFC3339)}) } @@ -110,6 +150,8 @@ func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) { r.handleClientEndpoints(w, req) case path == "/api/client/notices" || strings.HasPrefix(path, "/api/client/notices/"): r.handleClientNotices(w, req) + case strings.HasPrefix(path, "/api/client/reference-data/"): + r.handleClientReferenceData(w, req) case path == "/api/client/endpoint-calls" || path == "/api/client/source-calls": r.handleSourceCall(w, req) case path == "/update-info.json" || path == "/update-info": @@ -160,6 +202,16 @@ type mutationResponseWriter struct { status int } +type diagnosticResponseWriter struct { + http.ResponseWriter + status int +} + +func (w *diagnosticResponseWriter) WriteHeader(status int) { + w.status = status + w.ResponseWriter.WriteHeader(status) +} + func (w *mutationResponseWriter) WriteHeader(status int) { w.status = status w.ResponseWriter.WriteHeader(status) @@ -181,11 +233,13 @@ func adminMutationEvent(path string) string { } func (r *router) handleAuthBootstrap(w http.ResponseWriter, req *http.Request) { - payload, err := r.auth.Bootstrap(req.Context()) + _, _, authenticated := r.auth.UserForRequest(req) + payload, err := r.auth.Bootstrap(req.Context(), authenticated) if err != nil { writeError(w, http.StatusInternalServerError, "BOOTSTRAP_FAILED", err) return } + w.Header().Set("Cache-Control", "no-store") writeJSON(w, http.StatusOK, payload) } @@ -264,6 +318,35 @@ func remoteHost(remoteAddress string) string { return strings.TrimSpace(remoteAddress) } +func clientAddress(req *http.Request) string { + if req == nil { + return "" + } + peer := remoteHost(req.RemoteAddr) + peerIP := net.ParseIP(strings.Trim(peer, "[]")) + if peerIP == nil || !peerIP.IsLoopback() { + return peer + } + if forwarded := validClientIP(req.Header.Get("X-Real-IP")); forwarded != "" { + return forwarded + } + values := strings.Split(req.Header.Get("X-Forwarded-For"), ",") + for index := len(values) - 1; index >= 0; index-- { + if forwarded := validClientIP(values[index]); forwarded != "" { + return forwarded + } + } + return peer +} + +func validClientIP(value string) string { + value = strings.Trim(strings.TrimSpace(value), "[]") + if parsed := net.ParseIP(value); parsed != nil { + return parsed.String() + } + return "" +} + func (r *router) recordLoginAudit(username, remoteAddr, userAgent string) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() diff --git a/server/unified-management/internal/web/router_test.go b/server/unified-management/internal/web/router_test.go index 1caddcf..b4b26ae 100644 --- a/server/unified-management/internal/web/router_test.go +++ b/server/unified-management/internal/web/router_test.go @@ -11,6 +11,7 @@ import ( "encoding/hex" "encoding/json" "errors" + "fmt" "image/color" "image/png" "io" @@ -26,6 +27,7 @@ import ( "testing" "time" + "ymhut-box/server/unified-management/internal/adminassets" "ymhut-box/server/unified-management/internal/auth" "ymhut-box/server/unified-management/internal/config" "ymhut-box/server/unified-management/internal/db" @@ -36,6 +38,74 @@ import ( "ymhut-box/server/unified-management/internal/sources" ) +func TestRequestBaseURLUsesTrustedForwardedValues(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "http://internal:33550/api/client/bootstrap", nil) + req.Header.Set("X-Forwarded-Proto", "HTTPS, http") + req.Header.Set("X-Forwarded-Host", "updates.example.com:8443, internal:33550") + + if got := requestBaseURL(req, "https://fallback.example.com/"); got != "https://updates.example.com:8443" { + t.Fatalf("requestBaseURL() = %q", got) + } +} + +func TestRequestBaseURLRejectsInvalidForwardedValues(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "https://internal:33550/api/client/bootstrap", nil) + req.Header.Set("X-Forwarded-Proto", "file") + req.Header.Set("X-Forwarded-Host", "user@evil.example") + if got := requestBaseURL(req, "https://fallback.example.com/"); got != "https://internal:33550" { + t.Fatalf("requestBaseURL() = %q", got) + } + + req.Host = "bad host" + req.Header.Set("X-Forwarded-Host", "updates.example.com:99999") + if got := requestBaseURL(req, "https://fallback.example.com/"); got != "https://fallback.example.com" { + t.Fatalf("requestBaseURL() with invalid hosts = %q", got) + } +} + +func TestPublicSnapshotCacheBoundsHostVariants(t *testing.T) { + service := newPublicSnapshotService(time.Hour) + for index := 0; index < maxPublicSnapshotEntries+20; index++ { + key := fmt.Sprintf("bootstrap|https://host-%d.example.com", index) + service.Get(key, func(time.Time) any { return map[string]any{"ok": true} }) + } + if got := len(service.entries); got != maxPublicSnapshotEntries { + t.Fatalf("snapshot cache has %d entries, want %d", got, maxPublicSnapshotEntries) + } +} + +func TestPublicSnapshotCacheRemovesExpiredEntries(t *testing.T) { + service := newPublicSnapshotService(time.Millisecond) + service.Get("expired", func(time.Time) any { return map[string]any{"value": 1} }) + time.Sleep(2 * time.Millisecond) + service.Get("current", func(time.Time) any { return map[string]any{"value": 2} }) + + if _, exists := service.entries["expired"]; exists { + t.Fatal("expired snapshot was not removed") + } +} + +func TestClientAddressTrustsForwardedIPOnlyFromLoopbackProxy(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "http://internal/", nil) + req.RemoteAddr = "127.0.0.1:44000" + req.Header.Set("X-Real-IP", "203.0.113.8") + req.Header.Set("X-Forwarded-For", "198.51.100.2, 203.0.113.9") + if got := clientAddress(req); got != "203.0.113.8" { + t.Fatalf("clientAddress() = %q", got) + } + + req.Header.Set("X-Real-IP", "not-an-ip") + if got := clientAddress(req); got != "203.0.113.9" { + t.Fatalf("clientAddress() XFF fallback = %q", got) + } + + req.RemoteAddr = "192.0.2.25:44000" + req.Header.Set("X-Real-IP", "203.0.113.10") + if got := clientAddress(req); got != "192.0.2.25" { + t.Fatalf("clientAddress() trusted a non-loopback peer: %q", got) + } +} + func TestCompatibilityRoutes(t *testing.T) { handler, cleanup := testRouter(t) defer cleanup() @@ -54,6 +124,29 @@ func TestCompatibilityRoutes(t *testing.T) { } } +func TestPublicAuthBootstrapDoesNotExposeAdministratorCredentials(t *testing.T) { + handler, cleanup := testRouter(t) + defer cleanup() + req := httptest.NewRequest(http.MethodGet, "/api/admin/auth/bootstrap", nil) + res := httptest.NewRecorder() + handler.ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("bootstrap returned %d: %s", res.Code, res.Body.String()) + } + var payload map[string]any + if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + for _, field := range []string{"defaultUsername", "defaultPassword", "isDefaultPassword"} { + if _, exists := payload[field]; exists { + t.Fatalf("public bootstrap exposed %s: %#v", field, payload) + } + } + if cacheControl := res.Header().Get("Cache-Control"); cacheControl != "no-store" { + t.Fatalf("bootstrap cache control = %q, want no-store", cacheControl) + } +} + func TestClientBootstrapSupportsConditionalCaching(t *testing.T) { handler, cleanup := testRouter(t) defer cleanup() @@ -246,6 +339,48 @@ func TestClientBootstrapAndEndpointsShape(t *testing.T) { } } +func TestClientReferenceDataSupportsCachingAndBootstrapDiscovery(t *testing.T) { + handler, cleanup := testRouter(t) + defer cleanup() + + request := httptest.NewRequest(http.MethodGet, "/api/client/reference-data/gpu", nil) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("reference data returned %d: %s", response.Code, response.Body.String()) + } + etag := response.Header().Get("ETag") + if etag == "" { + t.Fatal("reference data did not include an ETag") + } + var ranking map[string]any + if err := json.Unmarshal(response.Body.Bytes(), &ranking); err != nil { + t.Fatal(err) + } + if ranking["kind"] != "gpu" || ranking["items"] == nil { + t.Fatalf("unexpected reference data: %#v", ranking) + } + + conditional := httptest.NewRequest(http.MethodGet, "/api/client/reference-data/gpu", nil) + conditional.Header.Set("If-None-Match", etag) + conditionalResponse := httptest.NewRecorder() + handler.ServeHTTP(conditionalResponse, conditional) + if conditionalResponse.Code != http.StatusNotModified { + t.Fatalf("conditional reference data returned %d", conditionalResponse.Code) + } + + bootstrapRequest := httptest.NewRequest(http.MethodGet, "/api/client/bootstrap", nil) + bootstrapResponse := httptest.NewRecorder() + handler.ServeHTTP(bootstrapResponse, bootstrapRequest) + var bootstrap map[string]any + if err := json.Unmarshal(bootstrapResponse.Body.Bytes(), &bootstrap); err != nil { + t.Fatal(err) + } + if bootstrap["referenceData"] == nil { + t.Fatalf("bootstrap does not advertise reference data: %#v", bootstrap) + } +} + func TestAdminDeleteSourcePublishesCompatibilityJSON(t *testing.T) { handler, cleanup := testRouter(t) defer cleanup() @@ -653,7 +788,7 @@ func TestAdminReleasePackageUploadRejectsMissingAndOversizedFile(t *testing.T) { {name: "missing", include: false, wantStatus: http.StatusBadRequest, wantCode: "FILE_REQUIRED"}, {name: "empty", include: true, data: []byte{}, wantStatus: http.StatusBadRequest, wantCode: "PACKAGE_EMPTY"}, {name: "oversized", include: true, data: bytes.Repeat([]byte{'x'}, (1<<20)+1), wantStatus: http.StatusRequestEntityTooLarge, wantCode: "PACKAGE_TOO_LARGE"}, - {name: "unsafe-name", include: true, data: []byte("package"), wantStatus: http.StatusBadRequest, wantCode: "PACKAGE_UPLOAD_FAILED"}, + {name: "unsafe-name", include: true, data: []byte("package"), wantStatus: http.StatusBadRequest, wantCode: "PACKAGE_NAME_INVALID"}, } { t.Run(tc.name, func(t *testing.T) { handler, cleanup := testRouter(t) @@ -737,8 +872,10 @@ func TestValidateAdminDiskBuildRejectsMissingReferencedAsset(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(``), 0o644); err != nil { t.Fatal(err) } - if err := validateAdminDiskBuild(dir); err == nil || !strings.Contains(err.Error(), "assets/missing.js") { - t.Fatalf("validateAdminDiskBuild returned %v, want missing asset error", err) + writeAdminBuildMetadata(t, dir, "assets/missing.js") + status := adminassets.ValidateDisk(dir, "dev") + if status.Ready || !strings.Contains(status.ValidationError, "assets/missing.js") { + t.Fatalf("ValidateDisk returned %#v, want missing asset error", status) } } @@ -753,7 +890,8 @@ func TestIncompleteAdminDiskBuildDoesNotServeDiskAssets(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "assets", "stale.js"), []byte(`stale disk asset`), 0o644); err != nil { t.Fatal(err) } - r := &router{cfg: &config.Config{AdminWebDir: dir}} + writeAdminBuildMetadata(t, dir, "assets/missing.js") + r := &router{cfg: &config.Config{AdminWebDir: dir, AdminAssetMode: adminassets.ModeDisk}, adminAssets: adminassets.New(adminassets.ModeDisk, dir, "dev")} req := httptest.NewRequest(http.MethodGet, "/admin/assets/stale.js", nil) res := httptest.NewRecorder() r.serveAdminAsset(res, req, "assets/stale.js") @@ -768,6 +906,17 @@ func TestIncompleteAdminDiskBuildDoesNotServeDiskAssets(t *testing.T) { } } +func writeAdminBuildMetadata(t *testing.T, dir, output string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, "admin-build.json"), []byte(`{"buildId":"dev"}`), 0o644); err != nil { + t.Fatal(err) + } + manifest := fmt.Sprintf(`{"src/main.ts":{"file":%q,"isEntry":true}}`, output) + if err := os.WriteFile(filepath.Join(dir, "asset-manifest.json"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } +} + func containsAny(value string, needles []string) bool { for _, needle := range needles { if strings.Contains(value, needle) { @@ -913,6 +1062,111 @@ func TestAdminWriteRequiresCSRF(t *testing.T) { } } +func TestAdminPageReadRoutesNeverReturnGatewayOrServerErrors(t *testing.T) { + handler, cleanup := testRouter(t) + defer cleanup() + session, _, err := loginForTest(handler) + if err != nil { + t.Fatal(err) + } + + paths := []string{ + "/api/admin/dashboard/overview?window=24h", + "/api/admin/feedbacks?page=1&pageSize=20", + "/api/admin/releases", + "/api/admin/releases/notices", + "/api/admin/legacy/update-info", + "/api/admin/legacy/media-types", + "/api/admin/sources", + "/api/admin/sources/check/status", + "/api/admin/endpoints", + "/api/admin/database/status", + "/api/admin/database/sync/jobs/latest", + "/api/admin/system/migration", + "/api/admin/system/branding", + "/api/admin/system/mail/config", + "/api/admin/sync/legacy/preview", + "/api/admin/system/health", + "/api/admin/system/audit?page=1&pageSize=20", + "/api/admin/system/logs?page=1&pageSize=20", + } + + for _, path := range paths { + t.Run(path, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session}) + res := httptest.NewRecorder() + handler.ServeHTTP(res, req) + if res.Code >= http.StatusInternalServerError { + t.Fatalf("admin page request returned %d: %s", res.Code, res.Body.String()) + } + if res.Code == http.StatusUnauthorized || res.Code == http.StatusForbidden { + t.Fatalf("admin page request returned %d: %s", res.Code, res.Body.String()) + } + }) + } +} + +func TestAdminDashboardSnapshotAndHealthExposeDiagnostics(t *testing.T) { + handler, cleanup := testRouter(t) + defer cleanup() + session, _, err := loginForTest(handler) + if err != nil { + t.Fatal(err) + } + + request := func(path string) (*httptest.ResponseRecorder, map[string]any) { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session}) + res := httptest.NewRecorder() + handler.ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("%s returned %d: %s", path, res.Code, res.Body.String()) + } + var payload map[string]any + if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + return res, payload + } + + first, dashboard := request("/api/admin/dashboard/overview?window=6h") + second, _ := request("/api/admin/dashboard/overview?window=6h") + for _, key := range []string{"sourceRows", "sourceCheckJobs", "generatedAt", "warnings", "heartbeats", "averageLatency", "clientCalls"} { + if _, ok := dashboard[key]; !ok { + t.Fatalf("dashboard missing %s: %#v", key, dashboard) + } + } + if first.Header().Get("X-Admin-Cache") != "miss" || second.Header().Get("X-Admin-Cache") != "hit" { + t.Fatalf("cache headers first=%q second=%q", first.Header().Get("X-Admin-Cache"), second.Header().Get("X-Admin-Cache")) + } + _, healthPayload := request("/api/admin/system/health") + assets, ok := healthPayload["adminAssets"].(map[string]any) + if !ok || assets["manifestStatus"] != "valid" || assets["buildId"] != "dev" { + t.Fatalf("admin asset diagnostics missing: %#v", healthPayload["adminAssets"]) + } +} + +func TestSecurityMiddlewareRecoversAPIPanicAsJSON(t *testing.T) { + handler := withSecurity(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + panic("simulated handler failure") + })) + req := httptest.NewRequest(http.MethodGet, "/api/admin/system/health", nil) + res := httptest.NewRecorder() + + handler.ServeHTTP(res, req) + + if res.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d: %s", res.Code, http.StatusInternalServerError, res.Body.String()) + } + if contentType := res.Header().Get("Content-Type"); !strings.Contains(contentType, "application/json") { + t.Fatalf("content type = %q, want JSON", contentType) + } + if !strings.Contains(res.Body.String(), "INTERNAL_SERVER_ERROR") { + t.Fatalf("response does not contain stable error code: %s", res.Body.String()) + } +} + func loginForTest(handler http.Handler) (string, string, error) { captchaReq := httptest.NewRequest(http.MethodGet, "/api/admin/auth/captcha", nil) captchaRes := httptest.NewRecorder() @@ -1091,6 +1345,12 @@ func testRouter(t *testing.T) (http.Handler, func()) { if err := os.WriteFile(filepath.Join(adminDist, "assets", "admin.js"), []byte(`console.log("admin")`), 0o644); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(adminDist, "admin-build.json"), []byte(`{"buildId":"dev"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(adminDist, "asset-manifest.json"), []byte(`{"src/main.ts":{"file":"assets/admin.js","css":["assets/admin.css"],"isEntry":true}}`), 0o644); err != nil { + t.Fatal(err) + } if err := os.MkdirAll(noticeDir, 0o755); err != nil { t.Fatal(err) } @@ -1125,6 +1385,7 @@ func testRouter(t *testing.T) (http.Handler, func()) { UpdateNoticeDir: noticeDir, DownloadsDir: filepath.Join(public, "downloads"), AdminWebDir: adminDist, + AdminAssetMode: adminassets.ModeDisk, PortalWebDir: portalDist, SourceCheckSeconds: 3600, ClientSignatureKey: "ymhut-box-feedback-client-v1", diff --git a/server/unified-management/internal/web/static_routes.go b/server/unified-management/internal/web/static_routes.go index 31910bb..749fc3d 100644 --- a/server/unified-management/internal/web/static_routes.go +++ b/server/unified-management/internal/web/static_routes.go @@ -3,8 +3,7 @@ package web import ( "bytes" "errors" - "fmt" - "log" + "html" "mime" "net/http" "os" @@ -63,18 +62,10 @@ func (r *router) serveAdminAsset(w http.ResponseWriter, req *http.Request, asset return } setStaticCacheHeaders(w, assetPath) - if err := validateAdminDiskBuild(r.cfg.AdminWebDir); err == nil { - if tryServeDiskFile(w, req, r.cfg.AdminWebDir, assetPath) { - return - } - log.Printf("admin web disk build is missing requested asset: %s", assetPath) - } else { - if !errors.Is(err, os.ErrNotExist) { - log.Printf("admin web disk build is incomplete; serving embedded assets: %v", err) - } - if serveEmbeddedFile(w, req, "admin/dist/"+filepath.ToSlash(assetPath)) { - return - } + data, err := r.adminAssets.ReadFile(filepath.ToSlash(assetPath)) + if err == nil { + serveAssetContent(w, req, assetPath, data) + return } w.Header().Set("Cache-Control", "no-store") http.NotFound(w, req) @@ -136,52 +127,29 @@ func (r *router) servePortal(w http.ResponseWriter, req *http.Request) { func (r *router) serveAdmin(w http.ResponseWriter, req *http.Request) { w.Header().Set("Cache-Control", "no-store, must-revalidate") - index := filepath.Join(r.cfg.AdminWebDir, "index.html") - if err := validateAdminDiskBuild(r.cfg.AdminWebDir); err == nil { - http.ServeFile(w, req, index) - return - } else if !errors.Is(err, os.ErrNotExist) { - log.Printf("admin web disk build is incomplete: %v", err) - } - if serveEmbeddedFile(w, req, "admin/dist/index.html") { + data, err := r.adminAssets.ReadFile("index.html") + if err == nil { + serveAssetContent(w, req, "index.html", data) return } + status := r.adminAssets.Diagnostics() w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = w.Write([]byte(`YMhut Admin

YMhut Admin

Build web/admin to enable the Vue console.

`)) + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`管理后台资源不可用

管理后台资源不可用

服务拒绝混用不完整或版本不一致的后台资源。请重新发布同一构建生成的服务二进制。

` + html.EscapeString(status.Mode+" / "+status.BuildID+" / "+status.ValidationError) + `
`)) } -var adminAssetReferencePattern = regexp.MustCompile(`(?:src|href)=["'](/admin/assets/[^"'?#]+)`) - -func validateAdminDiskBuild(root string) error { - index := filepath.Join(root, "index.html") - data, err := os.ReadFile(index) - if err != nil { - return err +func serveAssetContent(w http.ResponseWriter, req *http.Request, name string, data []byte) { + if contentType := mime.TypeByExtension(filepath.Ext(name)); contentType != "" { + w.Header().Set("Content-Type", contentType) } - matches := adminAssetReferencePattern.FindAllSubmatch(data, -1) - if len(matches) == 0 { - return errors.New("index.html does not reference any admin assets") - } - for _, match := range matches { - assetPath := strings.TrimPrefix(string(match[1]), "/admin/") - if strings.Contains(assetPath, "..") || strings.ContainsAny(assetPath, `\`) { - return fmt.Errorf("invalid admin asset reference %s", assetPath) - } - path := filepath.Join(root, filepath.FromSlash(assetPath)) - info, statErr := os.Stat(path) - if statErr != nil { - return fmt.Errorf("missing %s: %w", assetPath, statErr) - } - if info.IsDir() { - return fmt.Errorf("%s is not a file", assetPath) - } - } - return nil + http.ServeContent(w, req, filepath.Base(name), time.Time{}, bytes.NewReader(data)) } +var hashedAssetPattern = regexp.MustCompile(`-[A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+$`) + func setStaticCacheHeaders(w http.ResponseWriter, assetPath string) { extension := strings.ToLower(filepath.Ext(assetPath)) - if strings.HasPrefix(filepath.ToSlash(assetPath), "assets/") && extension != ".ico" { + if strings.HasPrefix(filepath.ToSlash(assetPath), "assets/") && extension != ".ico" && hashedAssetPattern.MatchString(filepath.Base(assetPath)) { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") return } diff --git a/server/unified-management/scripts/build-release.ps1 b/server/unified-management/scripts/build-release.ps1 index e97dae7..a202675 100644 --- a/server/unified-management/scripts/build-release.ps1 +++ b/server/unified-management/scripts/build-release.ps1 @@ -7,6 +7,15 @@ param( $ErrorActionPreference = "Stop" $Root = Resolve-Path (Join-Path $PSScriptRoot "..") $Out = Join-Path $Root $OutDir +$ExistingAdminMetadata = Join-Path $Root "web\admin\dist\admin-build.json" +$AdminBuildId = if ($env:YMHUT_ADMIN_BUILD_ID) { + $env:YMHUT_ADMIN_BUILD_ID +} elseif ($SkipFrontend -and (Test-Path $ExistingAdminMetadata)) { + (Get-Content $ExistingAdminMetadata -Raw | ConvertFrom-Json).buildId +} else { + "$Version-$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())" +} +$env:YMHUT_ADMIN_BUILD_ID = $AdminBuildId $env:GOPROXY = if ($env:YMHUT_GOPROXY) { $env:YMHUT_GOPROXY } elseif ($env:GOPROXY) { $env:GOPROXY } else { "https://goproxy.io,https://mirrors.aliyun.com/goproxy,direct" } function Run-Step { @@ -39,6 +48,8 @@ if (-not $SkipFrontend) { } } +Run-Step (Join-Path $Root "web\admin") "npm" @("run", "validate:build") + New-Item -ItemType Directory -Force -Path $Out | Out-Null function Copy-IfExists { @@ -79,7 +90,7 @@ foreach ($Target in $Targets) { $env:GOARCH = $Target.GOARCH $env:CGO_ENABLED = "0" $Output = Join-Path $Out $Target.Name - Run-Step $Root "go" @("build", "-tags", "embed_web", "-buildvcs=false", "-trimpath", "-ldflags", "-s -w -X ymhut-box/server/unified-management/internal/config.Version=$Version", "-o", $Output, ".\cmd\unified-management") + Run-Step $Root "go" @("build", "-tags", "embed_web", "-buildvcs=false", "-trimpath", "-ldflags", "-s -w -X ymhut-box/server/unified-management/internal/config.Version=$Version -X ymhut-box/server/unified-management/internal/config.AdminBuildID=$AdminBuildId", "-o", $Output, ".\cmd\unified-management") } Remove-Item Env:\GOOS -ErrorAction SilentlyContinue diff --git a/server/unified-management/scripts/build-release.sh b/server/unified-management/scripts/build-release.sh index 97929f3..9221237 100644 --- a/server/unified-management/scripts/build-release.sh +++ b/server/unified-management/scripts/build-release.sh @@ -6,6 +6,14 @@ OUT_DIR="${OUT_DIR:-dist-release}" SKIP_FRONTEND="${SKIP_FRONTEND:-0}" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" OUT="$ROOT/$OUT_DIR" +if [[ -n "${YMHUT_ADMIN_BUILD_ID:-}" ]]; then + ADMIN_BUILD_ID="$YMHUT_ADMIN_BUILD_ID" +elif [[ "$SKIP_FRONTEND" == "1" && -f "$ROOT/web/admin/dist/admin-build.json" ]]; then + ADMIN_BUILD_ID="$(node -e "const fs=require('fs');process.stdout.write(JSON.parse(fs.readFileSync(process.argv[1],'utf8')).buildId||'')" "$ROOT/web/admin/dist/admin-build.json")" +else + ADMIN_BUILD_ID="$VERSION-$(date -u +%s)" +fi +export YMHUT_ADMIN_BUILD_ID="$ADMIN_BUILD_ID" export GOPROXY="${YMHUT_GOPROXY:-${GOPROXY:-https://goproxy.io,https://mirrors.aliyun.com/goproxy,direct}}" (cd "$ROOT" && go mod download all && go mod verify) @@ -20,6 +28,8 @@ if [[ "$SKIP_FRONTEND" != "1" ]]; then done fi +(cd "$ROOT/web/admin" && npm run validate:build) + mkdir -p "$OUT" copy_if_exists() { @@ -50,7 +60,7 @@ build_target() { local name="$3" (cd "$ROOT" && \ GOOS="$goos" GOARCH="$goarch" CGO_ENABLED=0 \ - go build -tags embed_web -buildvcs=false -trimpath -ldflags "-s -w -X ymhut-box/server/unified-management/internal/config.Version=$VERSION" \ + go build -tags embed_web -buildvcs=false -trimpath -ldflags "-s -w -X ymhut-box/server/unified-management/internal/config.Version=$VERSION -X ymhut-box/server/unified-management/internal/config.AdminBuildID=$ADMIN_BUILD_ID" \ -o "$OUT/$name" ./cmd/unified-management) } diff --git a/server/unified-management/scripts/validate-admin-build.mjs b/server/unified-management/scripts/validate-admin-build.mjs new file mode 100644 index 0000000..ab63fbb --- /dev/null +++ b/server/unified-management/scripts/validate-admin-build.mjs @@ -0,0 +1,48 @@ +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; + +const root = path.resolve(process.argv[2] || "web/admin/dist"); + +function resolveOutput(name) { + const normalized = String(name || "").replaceAll("\\", "/"); + if (!normalized || normalized.startsWith("/") || normalized.split("/").includes("..")) { + throw new Error(`Invalid asset path in manifest: ${JSON.stringify(name)}`); + } + const resolved = path.resolve(root, ...normalized.split("/")); + if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) { + throw new Error(`Asset path escapes dist: ${JSON.stringify(name)}`); + } + return resolved; +} + +async function requireFile(name) { + const file = resolveOutput(name); + const info = await stat(file); + if (!info.isFile() || info.size === 0) { + throw new Error(`Missing or empty admin asset: ${name}`); + } +} + +const manifest = JSON.parse(await readFile(path.join(root, "asset-manifest.json"), "utf8")); +const build = JSON.parse(await readFile(path.join(root, "admin-build.json"), "utf8")); + +if (!build.buildId || typeof build.buildId !== "string") { + throw new Error("admin-build.json does not contain a buildId"); +} +if (!manifest || typeof manifest !== "object" || Object.keys(manifest).length === 0) { + throw new Error("asset-manifest.json is empty"); +} + +await requireFile("index.html"); +for (const [key, entry] of Object.entries(manifest)) { + if (!entry?.file) throw new Error(`Manifest entry ${key} has no output file`); + for (const dependency of [...(entry.imports || []), ...(entry.dynamicImports || [])]) { + if (!manifest[dependency]) throw new Error(`Manifest entry ${key} references missing entry ${dependency}`); + } + for (const file of [entry.file, ...(entry.css || []), ...(entry.assets || [])]) { + await requireFile(file); + } +} + +process.stdout.write(`Admin build ${build.buildId}: ${Object.keys(manifest).length} manifest entries verified.\n`); diff --git a/server/unified-management/web/admin/package-lock.json b/server/unified-management/web/admin/package-lock.json index 1d3d83c..5b41385 100644 --- a/server/unified-management/web/admin/package-lock.json +++ b/server/unified-management/web/admin/package-lock.json @@ -10,17 +10,52 @@ "dependencies": { "@primeuix/themes": "^1.2.3", "@vitejs/plugin-vue": "^5.2.4", - "echarts": "^6.1.0", "lucide-vue-next": "^0.468.0", "primeicons": "^7.0.0", "primevue": "^4.3.5", "vite": "^6.3.5", "vue": "^3.5.16", - "vue-echarts": "^8.0.1", "vue-router": "^4.6.4" }, "devDependencies": { - "typescript": "^5.8.3" + "@types/node": "^26.2.0", + "@vue/test-utils": "^2.4.11", + "jsdom": "^30.0.1", + "typescript": "^5.8.3", + "vitest": "^3.2.4" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" } }, "node_modules/@babel/helper-string-parser": { @@ -69,6 +104,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.0.tgz", + "integrity": "sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.8.tgz", + "integrity": "sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -485,12 +673,66 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@primeuix/styled": { "version": "0.7.4", "resolved": "https://registry.npmmirror.com/@primeuix/styled/-/styled-0.7.4.tgz", @@ -923,12 +1165,40 @@ "win32" ] }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, "node_modules/@vitejs/plugin-vue": { "version": "5.2.4", "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", @@ -942,6 +1212,131 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vue/compiler-core": { "version": "3.5.39", "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.39.tgz", @@ -1048,22 +1443,310 @@ "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", "license": "MIT" }, + "node_modules/@vue/test-utils": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.11.tgz", + "integrity": "sha512-GDqaqZsA6m2E5vNzej0aYiIb6BX8xV9pNSbbbXKOfEYwg7ZNblVX8suyqmUBThq8VIrgAJNxn+z72hVtUeiWHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^3.0.0" + }, + "peerDependencies": { + "@vue/compiler-dom": "3.x", + "@vue/server-renderer": "3.x", + "vue": "3.x" + }, + "peerDependenciesMeta": { + "@vue/server-renderer": { + "optional": true + } + } + }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, - "node_modules/echarts": { - "version": "6.1.0", - "resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.1.0.tgz", - "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", - "license": "Apache-2.0", + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", "dependencies": { - "tslib": "2.3.0", - "zrender": "6.1.0" + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", @@ -1076,6 +1759,13 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", @@ -1123,6 +1813,16 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", @@ -1140,6 +1840,23 @@ } } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", @@ -1154,6 +1871,182 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/lucide-vue-next": { "version": "0.468.0", "resolved": "https://registry.npmmirror.com/lucide-vue-next/-/lucide-vue-next-0.468.0.tgz", @@ -1172,10 +2065,50 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -1190,6 +2123,106 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", @@ -1209,9 +2242,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "funding": [ { "type": "opencollective", @@ -1228,7 +2261,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1258,6 +2291,33 @@ "node": ">=12.11.0" } }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz", @@ -1302,6 +2362,75 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1311,6 +2440,158 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1327,11 +2608,81 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tslib": { - "version": "2.3.0", - "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", - "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", - "license": "0BSD" + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } }, "node_modules/typescript": { "version": "5.9.3", @@ -1347,6 +2698,23 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/vite": { "version": "6.4.3", "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", @@ -1421,6 +2789,102 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/vue": { "version": "3.5.39", "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.39.tgz", @@ -1442,15 +2906,12 @@ } } }, - "node_modules/vue-echarts": { - "version": "8.0.1", - "resolved": "https://registry.npmmirror.com/vue-echarts/-/vue-echarts-8.0.1.tgz", - "integrity": "sha512-23rJTFLu1OUEGRWjJGmdGt8fP+8+ja1gVgzMYPIPaHWpXegcO1viIAaeu2H4QHESlVeHzUAHIxKXGrwjsyXAaA==", - "license": "MIT", - "peerDependencies": { - "echarts": "^6.0.0", - "vue": "^3.3.0" - } + "node_modules/vue-component-type-helpers": { + "version": "3.3.10", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.10.tgz", + "integrity": "sha512-t7IQivQ3oD4D01b7s7a9AWzHAcr3DGBIa/1jZREsFQJcFgSL92gqUkqNiHTYgzTt7QDuJa0I9wCeDwEtZICoBQ==", + "dev": true, + "license": "MIT" }, "node_modules/vue-router": { "version": "4.6.4", @@ -1467,14 +2928,201 @@ "vue": "^3.5.0" } }, - "node_modules/zrender": { - "version": "6.1.0", - "resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.1.0.tgz", - "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", - "license": "BSD-3-Clause", + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", "dependencies": { - "tslib": "2.3.0" + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" } } } diff --git a/server/unified-management/web/admin/package.json b/server/unified-management/web/admin/package.json index 98b7444..1385888 100644 --- a/server/unified-management/web/admin/package.json +++ b/server/unified-management/web/admin/package.json @@ -6,21 +6,26 @@ "scripts": { "dev": "vite --host 127.0.0.1", "build": "vite build", - "preview": "vite preview --host 127.0.0.1" + "validate:build": "node ../../scripts/validate-admin-build.mjs dist", + "preview": "vite preview --host 127.0.0.1", + "test": "vitest run", + "typecheck": "tsc --noEmit" }, "dependencies": { "@primeuix/themes": "^1.2.3", "@vitejs/plugin-vue": "^5.2.4", - "echarts": "^6.1.0", "lucide-vue-next": "^0.468.0", "primeicons": "^7.0.0", "primevue": "^4.3.5", "vite": "^6.3.5", "vue": "^3.5.16", - "vue-echarts": "^8.0.1", "vue-router": "^4.6.4" }, "devDependencies": { - "typescript": "^5.8.3" + "@types/node": "^26.2.0", + "@vue/test-utils": "^2.4.11", + "jsdom": "^30.0.1", + "typescript": "^5.8.3", + "vitest": "^3.2.4" } } diff --git a/server/unified-management/web/admin/src/App.vue b/server/unified-management/web/admin/src/App.vue index 4df8936..0b5e744 100644 --- a/server/unified-management/web/admin/src/App.vue +++ b/server/unified-management/web/admin/src/App.vue @@ -1,18 +1,12 @@