From 9f05aa20ea0e09570d71e9fc8523c0ca46525070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=8B=E4=B8=80=E9=B8=A3?= <1000000008@qq.com> Date: Wed, 11 Nov 2026 18:46:16 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E9=80=9A=E7=94=A8=E4=BA=91=E7=AE=A1=E5=B7=A5=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=E3=80=81=E5=AE=A1=E8=AE=A1=E4=B8=8E=E8=AF=8A=E6=96=AD=E8=83=BD?= =?UTF-8?q?=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- console/e2e/admin-sessions.spec.ts | 89 + console/e2e/audits.spec.ts | 92 + console/e2e/diagnostics.spec.ts | 99 + console/e2e/editions.spec.ts | 131 + console/e2e/gpu-reconcile.spec.ts | 45 + console/e2e/mfa.spec.ts | 98 + console/e2e/task-history.spec.ts | 90 + console/e2e/terminal-alerts.spec.ts | 94 + console/src/api/audit-labels.ts | 1 + console/src/api/index.ts | 31 +- console/src/api/schema.d.ts | 1869 +++++++++++- console/src/common/common.css | 1 + console/src/common/diagnosis.test.ts | 21 + console/src/common/diagnosis.ts | 20 + .../src/components/AdministratorAccounts.vue | 7 +- .../src/components/AdministratorSessions.vue | 56 + console/src/components/AuditHistory.vue | 70 + console/src/components/MFADialog.vue | 87 + console/src/components/TaskHistory.vue | 94 + console/src/layouts/ConsoleLayout.vue | 31 +- console/src/router/index.ts | 16 +- console/src/stores/auth.ts | 4 +- console/src/stores/edition.ts | 19 + console/src/stores/platform.ts | 12 +- console/src/views/CommonDevices.vue | 24 + console/src/views/CommonHelp.vue | 25 + console/src/views/CommonPeople.vue | 44 + console/src/views/CommonRecords.vue | 27 + console/src/views/CommonWorkbench.vue | 16 + console/src/views/Desktops.vue | 2 +- console/src/views/Diagnostics.vue | 94 + console/src/views/Login.vue | 13 +- console/src/views/Monitor.vue | 17 +- console/src/views/Settings.vue | 2 +- console/src/views/Tasks.vue | 37 +- console/src/views/Terminals.vue | 2 +- deploy/compose.live.yml | 2 + docs/README.md | 12 + docs/cloud-admin-sessions-20261111.md | 38 + docs/cloud-audit-history-20261111.md | 42 + docs/cloud-diagnostics-20261111.md | 41 + docs/cloud-editions-20261111.md | 48 + ...-editions-and-assistant-design-20261111.md | 126 + docs/cloud-feature-progress-20261110.md | 25 +- docs/cloud-mfa-20261111.md | 50 + docs/cloud-platform-development.md | 11 +- docs/cloud-task-history-20261111.md | 28 + docs/cloud-terminal-alerts-20261111.md | 50 + docs/cloud-terminal-metrics-20261110.md | 2 +- docs/current-status.md | 13 + .../browser-verification.json | 16 + .../deployment.json | 19 + .../live-sessions-dark.png | Bin 0 -> 82974 bytes .../live-sessions-light.png | Bin 0 -> 84784 bytes .../live-sessions-mobile-light.png | Bin 0 -> 46033 bytes .../live-verification.json | 16 + .../browser-verification.json | 17 + .../deployment.json | 20 + .../live-audit-dark.png | Bin 0 -> 187602 bytes .../live-audit-filtered.png | Bin 0 -> 128564 bytes .../live-audit-light.png | Bin 0 -> 190605 bytes .../live-audit-mobile-light.png | Bin 0 -> 78258 bytes .../live-verification.json | 23 + .../release-files.json | 140 + .../validation.json | 28 + .../cloud-diagnostics-20261111/README.md | 19 + .../browser-verification.json | 26 + .../cloud-diagnostics-20261111/cleanup.json | 8 + .../collection-state-verification.json | 19 + .../collection-verification.json | 30 + .../deployment.json | 20 + .../live-diagnostics-dark.png | Bin 0 -> 121254 bytes .../live-diagnostics-light.png | Bin 0 -> 123797 bytes .../live-diagnostics-mobile.png | Bin 0 -> 158180 bytes .../live-log-collection-receipt.png | Bin 0 -> 121085 bytes .../live-verification.json | 74 + .../cloud-diagnostics-20261111/manifest.json | 70 + .../state-verification.json | 19 + .../browser-verification.json | 28 + .../final-browser-verification.json | 18 + .../live-advanced-overview.png | Bin 0 -> 140456 bytes .../live-common-workbench-dark.png | Bin 0 -> 82693 bytes .../live-common-workbench-light.png | Bin 0 -> 86128 bytes .../live-common-workbench-mobile.png | Bin 0 -> 58046 bytes .../state-verification.json | 7 + .../browser-verification.json | 22 + .../cloud-mfa-20261111/deployment.json | 19 + .../cloud-mfa-20261111/final-state.json | 10 + .../live-mfa-enabled-dark.png | Bin 0 -> 147013 bytes .../live-mfa-enabled-mobile-light.png | Bin 0 -> 95736 bytes .../live-mfa-login-challenge.png | Bin 0 -> 362026 bytes .../cloud-mfa-20261111/live-verification.json | 78 + .../cloud-mfa-20261111/validation.json | 18 + .../browser-verification.json | 18 + .../deployment.json | 19 + .../live-task-detail.png | Bin 0 -> 101350 bytes .../live-tasks-dark.png | Bin 0 -> 354444 bytes .../live-tasks-light.png | Bin 0 -> 358594 bytes .../live-tasks-mobile-light.png | Bin 0 -> 137822 bytes .../live-verification.json | 24 + .../browser-verification.json | 20 + .../live-terminal-alert-event-dark.png | Bin 0 -> 133568 bytes .../live-terminal-alert-event-light.png | Bin 0 -> 135699 bytes .../live-terminal-alert-events-mobile.png | Bin 0 -> 84611 bytes .../live-terminal-alert-rule-dark.png | Bin 0 -> 146973 bytes .../live-terminal-alert-rule-light.png | Bin 0 -> 152086 bytes .../live-terminal-alert-rule-mobile.png | Bin 0 -> 75276 bytes .../live-verification.json | 146 + .../terminal-alert-create-dark.png | Bin 0 -> 107953 bytes .../terminal-alert-stale-mobile-light.png | Bin 0 -> 73158 bytes .../validation.json | 26 + docs/evidence/linux-gpu-20261111/README.md | 250 ++ .../linux-gpu-20261111/box-sched-pair.py | 21 + .../linux-gpu-20261111/double-cursor-check.py | 44 + .../gpu-hook-switch-tasks.csv | 5 + .../gpu-hook-ubuntu-final-desktop.jpg | Bin 0 -> 39738 bytes .../gpu-hook-windows-desktop.jpg | Bin 0 -> 58128 bytes .../linux-gpu-20261111/gpu-switch-tasks.csv | 19 + .../guest-local-response.py | 66 + .../kylin-cycle-final-desktop.jpg | Bin 0 -> 176939 bytes .../kylin-cycle-final-drag.jpg | Bin 0 -> 168545 bytes .../kylin-cycle-return-from-windows.jpg | Bin 0 -> 44926 bytes .../kylin-cycle-switch-tasks.csv | 5 + .../kylin-cycle-ubuntu-desktop.jpg | Bin 0 -> 39880 bytes .../kylin-cycle-windows-desktop.jpg | Bin 0 -> 58169 bytes .../kylin-final-desktop.png | Bin 0 -> 5262290 bytes .../kylin-final25-drag.json | 1 + .../kylin-final25-response.json | 1 + .../kylin-fixed-ap-desktop.jpg | Bin 0 -> 176003 bytes .../kylin-fixed-ap-drag.json | 1 + .../linux-gpu-20261111/kylin-gpu-45-drag.json | 1 + .../kylin-gpu-45-response.json | 1 + .../linux-gpu-20261111/kylin-gpu-60-drag.json | 1 + .../kylin-opengl120-25-drag.json | 1 + .../kylin-opengl120-25-response.json | 1 + .../kylin-sched-fixed-ap.jsonl | 2 + .../linux-gpu-20261111/kylin-sched-pair.jsonl | 1 + .../measurement-summary.json | 122 + .../linux-gpu-20261111/response-probe-100.py | 67 + .../linux-gpu-20261111/response-probe-50.py | 67 + .../run-windows-response.py | 31 + .../terminal-optimization-summary.md | 62 + .../ubuntu-cursorfix-drag.json | 1 + .../ubuntu-double-cursor-before.jpg | Bin 0 -> 40142 bytes .../ubuntu-double-cursor-check.json | 1 + .../ubuntu-double-cursor-reconnect.json | 1 + .../ubuntu-final-desktop.jpg | Bin 0 -> 39775 bytes .../ubuntu-final25-drag.json | 1 + .../ubuntu-final25-response.json | 1 + .../ubuntu-fixed-ap-desktop.jpg | Bin 0 -> 39967 bytes .../ubuntu-fixed-ap-double-cursor.json | 1 + .../ubuntu-fixed-ap-drag.json | 1 + .../ubuntu-gpu-45-drag.json | 1 + .../ubuntu-gpu-45-response.json | 1 + .../ubuntu-gpu-60-drag.json | 1 + .../ubuntu-gpu-source120-drag.json | 1 + .../ubuntu-local-screen-response.raw | 4 + .../ubuntu-push25-drag.json | 1 + .../ubuntu-push25-response.json | 1 + .../ubuntu-sched-A1-drag.json | 1 + .../ubuntu-sched-A1-response.json | 1 + .../ubuntu-sched-A2-response.raw | 1 + .../ubuntu-sched-A3-response.json | 1 + .../ubuntu-sched-B1-drag.json | 1 + .../ubuntu-sched-B1-response.json | 1 + .../ubuntu-sched-B2-response.raw | 1 + .../ubuntu-sched-B3-response.json | 1 + .../ubuntu-sched-fixed-ap.jsonl | 2 + .../ubuntu-switch-recovered.jpg | Bin 0 -> 39780 bytes .../windows-baseline-drag.json | 1 + .../windows-final25-drag.json | 1 + .../windows-final25-response.json | 1 + .../windows-gpu-postreset-host.txt | 39 + .../windows-host-reboot-request.txt | 3 + .../windows-recovery-desktop-drag.jpg | Bin 0 -> 59660 bytes .../windows-recovery-host-final.txt | 13 + .../linux-gpu-20261111/windows-response.ps1 | 47 + docs/openapi/platform.v1.json | 2633 ++++++++++++++++- docs/operations.md | 5 + docs/roadmap.md | 15 +- platform/internal/config/config.go | 25 + platform/internal/server/admin.go | 50 +- platform/internal/server/admin_sessions.go | 125 + .../internal/server/admin_sessions_test.go | 194 ++ platform/internal/server/audits.go | 150 + platform/internal/server/audits_test.go | 133 + platform/internal/server/diagnostics.go | 253 ++ platform/internal/server/diagnostics_test.go | 219 ++ platform/internal/server/exclusive_gpu.go | 493 +++ .../internal/server/exclusive_gpu_test.go | 211 ++ platform/internal/server/hardware.go | 3 + platform/internal/server/login_limits.go | 15 +- platform/internal/server/mfa.go | 367 +++ platform/internal/server/mfa_test.go | 245 ++ platform/internal/server/observability.go | 53 +- platform/internal/server/power_batches.go | 6 + platform/internal/server/server.go | 24 +- platform/internal/server/snapshots.go | 3 + platform/internal/server/task_history.go | 153 + platform/internal/server/task_history_test.go | 129 + platform/internal/server/terminal.go | 22 +- platform/internal/server/terminal_alerts.go | 52 + .../internal/server/terminal_alerts_test.go | 266 ++ platform/internal/store/schema.sql | 19 + terminal/kiosk-shell/scripts/vdi-kiosk-start | 1 + .../kiosk-shell/src/desktop_list_page.cpp | 3 +- terminal/kiosk-shell/src/desktop_session.h | 2 + terminal/kiosk-shell/src/main_window.cpp | 6 +- .../kiosk-shell/src/moonlight_session.cpp | 6 +- terminal/kiosk-shell/src/moonlight_session.h | 1 + terminal/kiosk-shell/src/platform_client.cpp | 31 +- terminal/kiosk-shell/src/platform_client.h | 4 + .../tests/test_desktop_list_page.cpp | 22 + .../kiosk-shell/tests/test_main_window.cpp | 55 +- .../tests/test_moonlight_session.cpp | 14 + terminal/native-cursor/README.md | 14 + terminal/native-cursor/capture-no-cursor.c | 87 +- terminal/native-cursor/host.py | 53 + terminal/native-cursor/vendor/NvFBC.h | 1996 +++++++++++++ tools/generate-platform-openapi.mjs | 68 +- tools/pve-shared-gpu-hook.py | 124 + tools/tests/nvfbc_adapter_stub.c | 50 + tools/tests/run_nvfbc_adapter.py | 44 + tools/tests/test_native_cursor_host.py | 106 + tools/tests/test_pve_shared_gpu_hook.py | 70 + 225 files changed, 14266 insertions(+), 141 deletions(-) create mode 100644 console/e2e/admin-sessions.spec.ts create mode 100644 console/e2e/audits.spec.ts create mode 100644 console/e2e/diagnostics.spec.ts create mode 100644 console/e2e/editions.spec.ts create mode 100644 console/e2e/gpu-reconcile.spec.ts create mode 100644 console/e2e/mfa.spec.ts create mode 100644 console/e2e/task-history.spec.ts create mode 100644 console/e2e/terminal-alerts.spec.ts create mode 100644 console/src/api/audit-labels.ts create mode 100644 console/src/common/common.css create mode 100644 console/src/common/diagnosis.test.ts create mode 100644 console/src/common/diagnosis.ts create mode 100644 console/src/components/AdministratorSessions.vue create mode 100644 console/src/components/AuditHistory.vue create mode 100644 console/src/components/MFADialog.vue create mode 100644 console/src/components/TaskHistory.vue create mode 100644 console/src/stores/edition.ts create mode 100644 console/src/views/CommonDevices.vue create mode 100644 console/src/views/CommonHelp.vue create mode 100644 console/src/views/CommonPeople.vue create mode 100644 console/src/views/CommonRecords.vue create mode 100644 console/src/views/CommonWorkbench.vue create mode 100644 console/src/views/Diagnostics.vue create mode 100644 docs/cloud-admin-sessions-20261111.md create mode 100644 docs/cloud-audit-history-20261111.md create mode 100644 docs/cloud-diagnostics-20261111.md create mode 100644 docs/cloud-editions-20261111.md create mode 100644 docs/cloud-editions-and-assistant-design-20261111.md create mode 100644 docs/cloud-mfa-20261111.md create mode 100644 docs/cloud-task-history-20261111.md create mode 100644 docs/cloud-terminal-alerts-20261111.md create mode 100644 docs/evidence/cloud-admin-sessions-20261111/browser-verification.json create mode 100644 docs/evidence/cloud-admin-sessions-20261111/deployment.json create mode 100644 docs/evidence/cloud-admin-sessions-20261111/live-sessions-dark.png create mode 100644 docs/evidence/cloud-admin-sessions-20261111/live-sessions-light.png create mode 100644 docs/evidence/cloud-admin-sessions-20261111/live-sessions-mobile-light.png create mode 100644 docs/evidence/cloud-admin-sessions-20261111/live-verification.json create mode 100644 docs/evidence/cloud-audit-history-20261111/browser-verification.json create mode 100644 docs/evidence/cloud-audit-history-20261111/deployment.json create mode 100644 docs/evidence/cloud-audit-history-20261111/live-audit-dark.png create mode 100644 docs/evidence/cloud-audit-history-20261111/live-audit-filtered.png create mode 100644 docs/evidence/cloud-audit-history-20261111/live-audit-light.png create mode 100644 docs/evidence/cloud-audit-history-20261111/live-audit-mobile-light.png create mode 100644 docs/evidence/cloud-audit-history-20261111/live-verification.json create mode 100644 docs/evidence/cloud-audit-history-20261111/release-files.json create mode 100644 docs/evidence/cloud-audit-history-20261111/validation.json create mode 100644 docs/evidence/cloud-diagnostics-20261111/README.md create mode 100644 docs/evidence/cloud-diagnostics-20261111/browser-verification.json create mode 100644 docs/evidence/cloud-diagnostics-20261111/cleanup.json create mode 100644 docs/evidence/cloud-diagnostics-20261111/collection-state-verification.json create mode 100644 docs/evidence/cloud-diagnostics-20261111/collection-verification.json create mode 100644 docs/evidence/cloud-diagnostics-20261111/deployment.json create mode 100644 docs/evidence/cloud-diagnostics-20261111/live-diagnostics-dark.png create mode 100644 docs/evidence/cloud-diagnostics-20261111/live-diagnostics-light.png create mode 100644 docs/evidence/cloud-diagnostics-20261111/live-diagnostics-mobile.png create mode 100644 docs/evidence/cloud-diagnostics-20261111/live-log-collection-receipt.png create mode 100644 docs/evidence/cloud-diagnostics-20261111/live-verification.json create mode 100644 docs/evidence/cloud-diagnostics-20261111/manifest.json create mode 100644 docs/evidence/cloud-diagnostics-20261111/state-verification.json create mode 100644 docs/evidence/cloud-editions-20261111/browser-verification.json create mode 100644 docs/evidence/cloud-editions-20261111/final-browser-verification.json create mode 100644 docs/evidence/cloud-editions-20261111/live-advanced-overview.png create mode 100644 docs/evidence/cloud-editions-20261111/live-common-workbench-dark.png create mode 100644 docs/evidence/cloud-editions-20261111/live-common-workbench-light.png create mode 100644 docs/evidence/cloud-editions-20261111/live-common-workbench-mobile.png create mode 100644 docs/evidence/cloud-editions-20261111/state-verification.json create mode 100644 docs/evidence/cloud-mfa-20261111/browser-verification.json create mode 100644 docs/evidence/cloud-mfa-20261111/deployment.json create mode 100644 docs/evidence/cloud-mfa-20261111/final-state.json create mode 100644 docs/evidence/cloud-mfa-20261111/live-mfa-enabled-dark.png create mode 100644 docs/evidence/cloud-mfa-20261111/live-mfa-enabled-mobile-light.png create mode 100644 docs/evidence/cloud-mfa-20261111/live-mfa-login-challenge.png create mode 100644 docs/evidence/cloud-mfa-20261111/live-verification.json create mode 100644 docs/evidence/cloud-mfa-20261111/validation.json create mode 100644 docs/evidence/cloud-task-history-20261111/browser-verification.json create mode 100644 docs/evidence/cloud-task-history-20261111/deployment.json create mode 100644 docs/evidence/cloud-task-history-20261111/live-task-detail.png create mode 100644 docs/evidence/cloud-task-history-20261111/live-tasks-dark.png create mode 100644 docs/evidence/cloud-task-history-20261111/live-tasks-light.png create mode 100644 docs/evidence/cloud-task-history-20261111/live-tasks-mobile-light.png create mode 100644 docs/evidence/cloud-task-history-20261111/live-verification.json create mode 100644 docs/evidence/cloud-terminal-alerts-20261111/browser-verification.json create mode 100644 docs/evidence/cloud-terminal-alerts-20261111/live-terminal-alert-event-dark.png create mode 100644 docs/evidence/cloud-terminal-alerts-20261111/live-terminal-alert-event-light.png create mode 100644 docs/evidence/cloud-terminal-alerts-20261111/live-terminal-alert-events-mobile.png create mode 100644 docs/evidence/cloud-terminal-alerts-20261111/live-terminal-alert-rule-dark.png create mode 100644 docs/evidence/cloud-terminal-alerts-20261111/live-terminal-alert-rule-light.png create mode 100644 docs/evidence/cloud-terminal-alerts-20261111/live-terminal-alert-rule-mobile.png create mode 100644 docs/evidence/cloud-terminal-alerts-20261111/live-verification.json create mode 100644 docs/evidence/cloud-terminal-alerts-20261111/terminal-alert-create-dark.png create mode 100644 docs/evidence/cloud-terminal-alerts-20261111/terminal-alert-stale-mobile-light.png create mode 100644 docs/evidence/cloud-terminal-alerts-20261111/validation.json create mode 100644 docs/evidence/linux-gpu-20261111/README.md create mode 100644 docs/evidence/linux-gpu-20261111/box-sched-pair.py create mode 100644 docs/evidence/linux-gpu-20261111/double-cursor-check.py create mode 100644 docs/evidence/linux-gpu-20261111/gpu-hook-switch-tasks.csv create mode 100644 docs/evidence/linux-gpu-20261111/gpu-hook-ubuntu-final-desktop.jpg create mode 100644 docs/evidence/linux-gpu-20261111/gpu-hook-windows-desktop.jpg create mode 100644 docs/evidence/linux-gpu-20261111/gpu-switch-tasks.csv create mode 100644 docs/evidence/linux-gpu-20261111/guest-local-response.py create mode 100644 docs/evidence/linux-gpu-20261111/kylin-cycle-final-desktop.jpg create mode 100644 docs/evidence/linux-gpu-20261111/kylin-cycle-final-drag.jpg create mode 100644 docs/evidence/linux-gpu-20261111/kylin-cycle-return-from-windows.jpg create mode 100644 docs/evidence/linux-gpu-20261111/kylin-cycle-switch-tasks.csv create mode 100644 docs/evidence/linux-gpu-20261111/kylin-cycle-ubuntu-desktop.jpg create mode 100644 docs/evidence/linux-gpu-20261111/kylin-cycle-windows-desktop.jpg create mode 100644 docs/evidence/linux-gpu-20261111/kylin-final-desktop.png create mode 100644 docs/evidence/linux-gpu-20261111/kylin-final25-drag.json create mode 100644 docs/evidence/linux-gpu-20261111/kylin-final25-response.json create mode 100644 docs/evidence/linux-gpu-20261111/kylin-fixed-ap-desktop.jpg create mode 100644 docs/evidence/linux-gpu-20261111/kylin-fixed-ap-drag.json create mode 100644 docs/evidence/linux-gpu-20261111/kylin-gpu-45-drag.json create mode 100644 docs/evidence/linux-gpu-20261111/kylin-gpu-45-response.json create mode 100644 docs/evidence/linux-gpu-20261111/kylin-gpu-60-drag.json create mode 100644 docs/evidence/linux-gpu-20261111/kylin-opengl120-25-drag.json create mode 100644 docs/evidence/linux-gpu-20261111/kylin-opengl120-25-response.json create mode 100644 docs/evidence/linux-gpu-20261111/kylin-sched-fixed-ap.jsonl create mode 100644 docs/evidence/linux-gpu-20261111/kylin-sched-pair.jsonl create mode 100644 docs/evidence/linux-gpu-20261111/measurement-summary.json create mode 100644 docs/evidence/linux-gpu-20261111/response-probe-100.py create mode 100644 docs/evidence/linux-gpu-20261111/response-probe-50.py create mode 100644 docs/evidence/linux-gpu-20261111/run-windows-response.py create mode 100644 docs/evidence/linux-gpu-20261111/terminal-optimization-summary.md create mode 100644 docs/evidence/linux-gpu-20261111/ubuntu-cursorfix-drag.json create mode 100644 docs/evidence/linux-gpu-20261111/ubuntu-double-cursor-before.jpg create mode 100644 docs/evidence/linux-gpu-20261111/ubuntu-double-cursor-check.json create mode 100644 docs/evidence/linux-gpu-20261111/ubuntu-double-cursor-reconnect.json create mode 100644 docs/evidence/linux-gpu-20261111/ubuntu-final-desktop.jpg create mode 100644 docs/evidence/linux-gpu-20261111/ubuntu-final25-drag.json create mode 100644 docs/evidence/linux-gpu-20261111/ubuntu-final25-response.json create mode 100644 docs/evidence/linux-gpu-20261111/ubuntu-fixed-ap-desktop.jpg create mode 100644 docs/evidence/linux-gpu-20261111/ubuntu-fixed-ap-double-cursor.json create mode 100644 docs/evidence/linux-gpu-20261111/ubuntu-fixed-ap-drag.json create mode 100644 docs/evidence/linux-gpu-20261111/ubuntu-gpu-45-drag.json create mode 100644 docs/evidence/linux-gpu-20261111/ubuntu-gpu-45-response.json create mode 100644 docs/evidence/linux-gpu-20261111/ubuntu-gpu-60-drag.json create mode 100644 docs/evidence/linux-gpu-20261111/ubuntu-gpu-source120-drag.json create mode 100644 docs/evidence/linux-gpu-20261111/ubuntu-local-screen-response.raw create mode 100644 docs/evidence/linux-gpu-20261111/ubuntu-push25-drag.json create mode 100644 docs/evidence/linux-gpu-20261111/ubuntu-push25-response.json create mode 100644 docs/evidence/linux-gpu-20261111/ubuntu-sched-A1-drag.json create mode 100644 docs/evidence/linux-gpu-20261111/ubuntu-sched-A1-response.json create mode 100644 docs/evidence/linux-gpu-20261111/ubuntu-sched-A2-response.raw create mode 100644 docs/evidence/linux-gpu-20261111/ubuntu-sched-A3-response.json create mode 100644 docs/evidence/linux-gpu-20261111/ubuntu-sched-B1-drag.json create mode 100644 docs/evidence/linux-gpu-20261111/ubuntu-sched-B1-response.json create mode 100644 docs/evidence/linux-gpu-20261111/ubuntu-sched-B2-response.raw create mode 100644 docs/evidence/linux-gpu-20261111/ubuntu-sched-B3-response.json create mode 100644 docs/evidence/linux-gpu-20261111/ubuntu-sched-fixed-ap.jsonl create mode 100644 docs/evidence/linux-gpu-20261111/ubuntu-switch-recovered.jpg create mode 100644 docs/evidence/linux-gpu-20261111/windows-baseline-drag.json create mode 100644 docs/evidence/linux-gpu-20261111/windows-final25-drag.json create mode 100644 docs/evidence/linux-gpu-20261111/windows-final25-response.json create mode 100644 docs/evidence/linux-gpu-20261111/windows-gpu-postreset-host.txt create mode 100644 docs/evidence/linux-gpu-20261111/windows-host-reboot-request.txt create mode 100644 docs/evidence/linux-gpu-20261111/windows-recovery-desktop-drag.jpg create mode 100644 docs/evidence/linux-gpu-20261111/windows-recovery-host-final.txt create mode 100644 docs/evidence/linux-gpu-20261111/windows-response.ps1 create mode 100644 platform/internal/server/admin_sessions.go create mode 100644 platform/internal/server/admin_sessions_test.go create mode 100644 platform/internal/server/audits.go create mode 100644 platform/internal/server/audits_test.go create mode 100644 platform/internal/server/diagnostics.go create mode 100644 platform/internal/server/diagnostics_test.go create mode 100644 platform/internal/server/exclusive_gpu.go create mode 100644 platform/internal/server/exclusive_gpu_test.go create mode 100644 platform/internal/server/mfa.go create mode 100644 platform/internal/server/mfa_test.go create mode 100644 platform/internal/server/task_history.go create mode 100644 platform/internal/server/task_history_test.go create mode 100644 platform/internal/server/terminal_alerts.go create mode 100644 platform/internal/server/terminal_alerts_test.go create mode 100644 terminal/native-cursor/vendor/NvFBC.h create mode 100644 tools/pve-shared-gpu-hook.py create mode 100644 tools/tests/nvfbc_adapter_stub.c create mode 100644 tools/tests/run_nvfbc_adapter.py create mode 100644 tools/tests/test_native_cursor_host.py create mode 100644 tools/tests/test_pve_shared_gpu_hook.py diff --git a/console/e2e/admin-sessions.spec.ts b/console/e2e/admin-sessions.spec.ts new file mode 100644 index 0000000..cd547b4 --- /dev/null +++ b/console/e2e/admin-sessions.spec.ts @@ -0,0 +1,89 @@ +import { test, expect, type Page } from '@playwright/test' + +async function fixture(page: Page, options: { fail?: boolean; deny?: boolean; slow?: boolean } = {}) { + const revoked: string[] = [], queries: string[] = [] + const now = new Date().toISOString(), expires = new Date(Date.now() + 3600000).toISOString() + await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'session-fixture')) + await page.route('**/api/v1/**', async route => { + const request = route.request(), url = new URL(request.url()), path = url.pathname.replace('/api/v1', '') + const list = (items: unknown[], total = items.length) => route.fulfill({ json: { items, total, page: 1, pageSize: 20, requestId: 'sessions-ui' } }) + if (path === '/auth/me') { await route.fulfill({ json: { id: 'owner', username: 'root', displayName: '管理员', role: 'super_admin' } }); return } + if (path === '/settings') { await route.fulfill({ json: { mode: 'production', capabilities: ['administrator-sessions'], pveConfigured: true } }); return } + if (path === '/overview') { await route.fulfill({ json: { terminalCount: 0, onlineTerminals: 0, desktopCount: 0, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return } + if (path === '/administrators') { await list([{ id: 'owner', username: 'root', displayName: '管理员', role: 'super_admin', enabled: true }, { id: 'other', username: 'maintainer', displayName: '运维', role: 'operator', enabled: true }]); return } + const match = path.match(/^\/administrators\/(owner|other)\/sessions(?:\/(.+))?$/) + if (match) { + const id = match[1]!, key = match[2] + if (request.method() === 'DELETE') { + revoked.push(`${id}/${key}`) + if (options.deny) { await route.fulfill({ status: 403, json: { code: 'forbidden', message: '当前账号没有该操作权限' } }); return } + await route.fulfill({ status: 204 }); return + } + queries.push(id) + if (options.fail && id === 'other') { await route.fulfill({ status: 500, json: { code: 'database_error', message: '登录列表暂不可用' } }); return } + if (options.slow && id === 'owner') await new Promise(resolve => setTimeout(resolve, 700)) + const p = Number(url.searchParams.get('page') || 1), size = 20, total = 21 - revoked.filter(r => r.startsWith(`${id}/`)).length + await list(Array.from({ length: Math.max(0, Math.min(size, total - (p - 1) * size)) }, (_, i) => ({ key: `${id}-${(p - 1) * size + i}`, createdAt: i === 1 ? null : now, expiresAt: expires, clientIp: i === 1 ? '' : `192.0.2.${(p - 1) * size + i + 1}`, userAgent: 'Example browser ', current: id === 'owner' && p === 1 && i === 0 })), total); return + } + if (['/resources', '/desktops', '/users', '/terminals', '/tasks'].includes(path)) { await list([]); return } + await route.fulfill({ status: 404, json: { message: '测试接口未定义' } }) + }) + await page.goto('/settings'); await page.getByRole('button', { name: '管理员账号', exact: true }).click() + await expect(page.getByRole('heading', { name: '管理员账号', exact: true })).toBeVisible() + return { revoked, queries } +} +async function open(page: Page, name: string) { + await page.getByRole('row').filter({ hasText: name }).getByRole('button', { name: '在线登录', exact: true }).click() + return page.getByRole('dialog', { name: `在线登录 · ${name}`, exact: true }) +} + +test('登录来源、旧登录未知信息、分页、当前登录保护及深浅窄屏', async ({ page }, info) => { + const calls = await fixture(page), dialog = await open(page, 'root') + await expect(dialog.getByText('当前登录', { exact: true })).toBeVisible() + await expect(dialog.getByRole('row').filter({ hasText: '当前登录' }).getByRole('button', { name: '撤销登录' })).toBeDisabled() + await expect(dialog.getByText('升级前登录,时间未知', { exact: true })).toBeVisible() + await expect(dialog.getByText('升级前登录,来源未知', { exact: true })).toBeVisible() + await page.screenshot({ path: info.outputPath('admin-sessions-dark.png'), fullPage: true, animations: 'disabled' }) + await dialog.locator('.el-pagination .btn-next').click(); await expect(dialog.getByText('192.0.2.21', { exact: true })).toBeVisible() + await dialog.getByRole('button', { name: '关闭', exact: true }).click() + await page.getByRole('button', { name: '切换浅色主题' }).click(); await page.setViewportSize({ width: 390, height: 844 }) + await open(page, 'root'); await expect(dialog.getByText('当前登录', { exact: true })).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391) + await page.screenshot({ path: info.outputPath('admin-sessions-mobile.png'), fullPage: true, animations: 'disabled' }) + expect(calls.revoked).toEqual([]) +}) + +test('撤销指定登录需确认,取消不提交,成功刷新', async ({ page }) => { + const calls = await fixture(page), dialog = await open(page, 'maintainer') + await expect(dialog.getByText('共 21 次有效登录', { exact: true })).toBeVisible() + await dialog.getByRole('button', { name: '撤销登录', exact: true }).first().click() + await page.locator('.el-message-box').getByRole('button', { name: '取消', exact: true }).click() + expect(calls.revoked).toEqual([]) + await dialog.getByRole('button', { name: '撤销登录', exact: true }).first().click() + await page.locator('.el-message-box').getByRole('button', { name: '确认提交', exact: true }).click() + await expect(dialog.getByText('共 20 次有效登录', { exact: true })).toBeVisible() + expect(calls.revoked).toEqual(['other/other-0']) +}) + +test('查询和撤销失败清除旧结果并显示错误,不误报成功', async ({ page }) => { + const calls = await fixture(page, { deny: true }), dialog = await open(page, 'maintainer') + await dialog.getByRole('button', { name: '撤销登录', exact: true }).first().click() + await page.locator('.el-message-box').getByRole('button', { name: '确认提交', exact: true }).click() + await expect(dialog.getByText('当前账号没有该操作权限', { exact: true })).toBeVisible() + await expect(dialog.getByRole('button', { name: '撤销登录', exact: true })).toHaveCount(0) + expect(calls.revoked).toHaveLength(1) + await page.unrouteAll(); await fixture(page, { fail: true }); await open(page, 'maintainer') + await expect(page.getByRole('dialog').getByText('登录列表暂不可用', { exact: true })).toBeVisible() + await expect(page.getByRole('dialog').getByRole('button', { name: '撤销登录', exact: true })).toHaveCount(0) +}) + +test('切换账号后旧请求不能覆盖新的登录列表', async ({ page }) => { + const calls = await fixture(page, { slow: true }), old = await open(page, 'root') + await expect.poll(() => calls.queries.includes('owner')).toBe(true) + await old.getByRole('button', { name: '关闭', exact: true }).click() + const current = await open(page, 'maintainer') + await expect(current.getByText('共 21 次有效登录', { exact: true })).toBeVisible() + await page.waitForTimeout(850) + await expect(current.getByText('当前登录', { exact: true })).toHaveCount(0) + expect(calls.revoked).toEqual([]) +}) diff --git a/console/e2e/audits.spec.ts b/console/e2e/audits.spec.ts new file mode 100644 index 0000000..0c39f79 --- /dev/null +++ b/console/e2e/audits.spec.ts @@ -0,0 +1,92 @@ +import { test, expect, type Page } from '@playwright/test' + +async function fixture(page: Page, options: { fail?: boolean; oversized?: boolean; exportFail?: boolean } = {}) { + const queries: URLSearchParams[] = [], exports: URLSearchParams[] = [], writes: string[] = [] + const now = new Date().toISOString() + await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'audit-fixture')) + await page.route('**/api/v1/**', async route => { + const request = route.request(), url = new URL(request.url()), path = url.pathname.replace('/api/v1', '') + const list = (items: unknown[], extra = {}) => route.fulfill({ json: { items, total: items.length, page: 1, pageSize: 100, requestId: 'audit-ui', ...extra } }) + if (request.method() !== 'GET') { writes.push(path); await route.fulfill({ status: 403, json: { message: '不应发送写请求' } }); return } + if (path === '/auth/me') { await route.fulfill({ json: { id: 'reader', username: 'reader', displayName: '只读人员', role: 'viewer' } }); return } + if (path === '/settings') { await route.fulfill({ json: { mode: 'production', capabilities: ['audit'], pveConfigured: false } }); return } + if (path === '/overview') { await route.fulfill({ json: { terminalCount: 0, onlineTerminals: 0, desktopCount: 0, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return } + if (path === '/audit-logs/export') { + exports.push(url.searchParams) + if (options.exportFail) { await route.fulfill({ status: 413, json: { message: '匹配记录超过 10000 条,请缩小时间范围', code: 'export_too_large', requestId: 'audit-ui' } }); return } + await route.fulfill({ contentType: 'text/csv; charset=utf-8', body: '\ufeff时间,操作者\r\n2026-11-11T00:00:00Z,root\r\n' }); return + } + if (path === '/audit-logs') { + queries.push(url.searchParams) + if (options.fail && url.searchParams.get('keyword')) { await route.fulfill({ status: 500, json: { message: '审计查询暂不可用', code: 'database_error', requestId: 'audit-ui' } }); return } + const p = Number(url.searchParams.get('page') || 1), filtered = !!url.searchParams.get('actor'), total = options.oversized ? 10001 : filtered ? 1 : 45 + const items = Array.from({ length: Math.min(20, total - (p - 1) * 20) }, (_, i) => ({ id: `audit-${(p - 1) * 20 + i + 1}`, actor: filtered ? 'operator_%' : 'root', action: 'save_alert_rule', target: `node-${(p - 1) * 20 + i + 1}`, result: 'success', requestId: `req-${p}-${i}`, createdAt: now })) + await list(items, { total, page: p, pageSize: 20 }); return + } + if (['/resources', '/desktops', '/users', '/terminals', '/tasks'].includes(path)) { await list([]); return } + await route.fulfill({ status: 404, json: { message: '未定义的测试接口' } }) + }) + return { queries, exports, writes } +} + +test('按需加载审计,真实分页参数和固定截止时间,浅色移动布局', async ({ page }, info) => { + const calls = await fixture(page); await page.goto('/tasks') + await expect(page.getByRole('tab', { name: '执行任务' })).toBeVisible() + expect(calls.queries).toHaveLength(0) + await page.getByRole('tab', { name: '操作审计' }).click() + await expect(page.getByRole('cell', { name: 'node-1', exact: true })).toBeVisible() + await page.locator('.el-pagination .btn-next').click() + await expect(page.getByRole('cell', { name: 'node-21', exact: true })).toBeVisible() + expect(calls.queries[calls.queries.length - 1]?.get('page')).toBe('2') + expect(calls.queries[calls.queries.length - 1]?.get('to')).toBe(calls.queries[0]?.get('to')) + await page.screenshot({ path: info.outputPath('audit-dark.png'), fullPage: true, animations: 'disabled' }) + await page.getByRole('button', { name: '切换浅色主题' }).click(); await page.setViewportSize({ width: 390, height: 844 }) + await page.screenshot({ path: info.outputPath('audit-mobile-light.png'), fullPage: true, animations: 'disabled' }) + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391) + expect(calls.writes).toEqual([]) +}) + +test('操作人、动作、结果与时间筛选传到后端,导出使用已查询条件而非未提交的输入', async ({ page }) => { + const calls = await fixture(page); await page.goto('/tasks'); await page.getByRole('tab', { name: '操作审计' }).click() + await expect(page.getByRole('cell', { name: 'node-1', exact: true })).toBeVisible() + await page.getByRole('textbox', { name: '审计操作者', exact: true }).fill('operator_%') + await page.getByRole('textbox', { name: '审计关键词' }).fill('req_trace') + await page.getByRole('combobox', { name: '审计动作' }).click(); await page.getByRole('option', { name: '保存告警规则', exact: true }).click() + await page.getByRole('combobox', { name: '审计结果' }).click(); await page.getByRole('option', { name: '成功', exact: true }).click() + await page.getByRole('combobox', { name: '审计时间范围' }).first().click(); await page.getByRole('button', { name: '最近 7 天', exact: true }).click() + await page.getByRole('button', { name: '查询审计', exact: true }).click() + await expect(page.getByRole('cell', { name: 'operator_%', exact: true })).toBeVisible() + const q = calls.queries[calls.queries.length - 1]! + expect(Object.fromEntries(q)).toMatchObject({ actor: 'operator_%', keyword: 'req_trace', action: 'save_alert_rule', result: 'success', page: '1', pageSize: '20' }) + expect(q.get('from')).toBeTruthy(); expect(q.get('to')).toBeTruthy() + await page.getByRole('textbox', { name: '审计关键词' }).fill('还未查询的输入') + const downloaded = page.waitForEvent('download'); await page.getByRole('button', { name: '导出查询结果 CSV' }).click() + expect((await downloaded).suggestedFilename()).toMatch(/操作审计.*\.csv$/) + expect(Object.fromEntries(calls.exports[0]!)).toMatchObject({ actor: 'operator_%', keyword: 'req_trace', action: 'save_alert_rule', result: 'success', from: q.get('from'), to: q.get('to') }) + expect(calls.exports[0]?.get('page')).toBeNull() + await page.getByRole('button', { name: '重置', exact: true }).click() + await expect(page.getByRole('cell', { name: 'root', exact: true }).first()).toBeVisible() + expect(calls.queries[calls.queries.length - 1]?.get('actor')).toBe('') + expect(calls.writes).toEqual([]) +}) + +test('查询失败清除旧结果并禁用导出,允许重试', async ({ page }) => { + const calls = await fixture(page, { fail: true }); await page.goto('/tasks'); await page.getByRole('tab', { name: '操作审计' }).click() + await expect(page.getByRole('cell', { name: 'node-1', exact: true })).toBeVisible() + await page.getByRole('textbox', { name: '审计关键词' }).fill('failure'); await page.getByRole('button', { name: '查询审计', exact: true }).click() + await expect(page.getByText(/审计查询暂不可用/)).toBeVisible(); await expect(page.getByRole('cell', { name: 'node-1', exact: true })).toHaveCount(0) + await expect(page.getByRole('button', { name: '导出查询结果 CSV' })).toBeDisabled() + await page.getByRole('button', { name: '重置', exact: true }).click(); await expect(page.getByRole('cell', { name: 'node-1', exact: true })).toBeVisible() + expect(calls.exports).toHaveLength(0) +}) + +test('导出超量有明确提示,服务端导出错误保留查询结果', async ({ page }) => { + await fixture(page, { oversized: true }); await page.goto('/tasks'); await page.getByRole('tab', { name: '操作审计' }).click() + await expect(page.getByText(/匹配记录超过 10000 条/)).toBeVisible() + await expect(page.getByRole('button', { name: '导出查询结果 CSV' })).toBeDisabled() + await page.unrouteAll(); await fixture(page, { exportFail: true }); await page.reload(); await page.getByRole('tab', { name: '操作审计' }).click() + await expect(page.getByRole('cell', { name: 'node-1', exact: true })).toBeVisible() + await page.getByRole('button', { name: '导出查询结果 CSV' }).click() + await expect(page.getByText(/匹配记录超过 10000 条/)).toBeVisible() + await expect(page.getByRole('cell', { name: 'node-1', exact: true })).toBeVisible() +}) diff --git a/console/e2e/diagnostics.spec.ts b/console/e2e/diagnostics.spec.ts new file mode 100644 index 0000000..06652cb --- /dev/null +++ b/console/e2e/diagnostics.spec.ts @@ -0,0 +1,99 @@ +import { test, expect, type Page } from '@playwright/test' + +async function fixture(page: Page, options: { role?: string; queryError?: boolean; exportError?: boolean; slow?: boolean; offline?: boolean; pending?: boolean; collectError?: boolean } = {}) { + const queries: URLSearchParams[] = [], exports: URLSearchParams[] = [], writes: { path: string; key: string | undefined; body: unknown }[] = [] + let collected = false, attempts = 0 + const now = new Date().toISOString() + const task = () => ({ id: 'collect-1', kind: 'terminal', action: 'collect-logs', targetId: 't1', targetName: '同名盒子', actor: 'operator', status: collected ? 'success' : 'running', message: collected ? '日志已回传' : '等待设备回执', providerRef: '', createdAt: now, updatedAt: now }) + await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'diagnostics-fixture')) + await page.route('**/api/v1/**', async route => { + const request = route.request(), url = new URL(request.url()), path = url.pathname.replace('/api/v1', '') + const list = (items: unknown[]) => route.fulfill({ json: { items, total: items.length, page: 1, pageSize: 100 } }) + if (request.method() !== 'GET') { + writes.push({ path, key: request.headers()['idempotency-key'], body: request.postDataJSON() }) + if (path === '/terminals/t1/commands') { + attempts++ + if (options.collectError && attempts === 1) { await route.fulfill({ status: 503, json: { message: '提交结果尚未确认', code: 'unavailable', requestId: 'diag-ui' } }); return } + collected = true; await route.fulfill({ status: 202, json: { ...task(), status: 'queued', message: '采集命令已提交' } }); return + } + await route.abort(); return + } + if (path === '/auth/me') { await route.fulfill({ json: { id: 'operator', username: 'operator', displayName: '运维', role: options.role || 'operator' } }); return } + if (path === '/settings') { await route.fulfill({ json: { mode: 'production', pveConfigured: true, capabilities: [] } }); return } + if (path === '/overview') { await route.fulfill({ json: { activeSessions: 1 } }); return } + if (path === '/desktops') { await list([{ id: 'd1', name: '办公桌面', node: 'pve', vmid: 100, powerState: 'running', serviceState: 'unknown', os: 'Windows' }]); return } + if (path === '/terminals') { await list(['t1', 't2'].map((id, i) => ({ id, name: '同名盒子', sn: `SN-${i + 1}`, status: options.offline ? 'offline' : 'online', enabled: true, lastSeen: now, sessionDesktopId: null }))); return } + if (path === '/resources' || path === '/users') { await list([]); return } + if (path === '/tasks') { await list(collected || options.pending ? [options.pending ? { ...task(), status: 'timeout' } : task()] : []); return } + if (path === '/diagnostics/export') { + exports.push(url.searchParams) + if (options.exportError) { await route.fulfill({ status: 413, json: { code: 'export_too_large', message: '诊断事件超过 2000 条', requestId: 'diag-ui' } }); return } + await route.fulfill({ contentType: 'application/zip', body: 'fixture-download' }); return + } + if (path === '/diagnostics') { + queries.push(url.searchParams) + if (options.queryError && url.searchParams.get('source')) { await route.fulfill({ status: 500, json: { message: '诊断查询暂不可用', requestId: 'diag-ui' } }); return } + const target = url.searchParams.get('targetId') || 't1', kind = url.searchParams.get('targetType') || 'terminal', source = url.searchParams.get('source') || '', p = Number(url.searchParams.get('page') || 1) + if (options.slow && target === 't1') await new Promise(resolve => setTimeout(resolve, 600)) + const items = [{ id: `event-${p}`, source: 'logs', time: now, action: 'collect-logs', status: 'success', targetId: target, targetName: target === 't2' ? '第二个同名盒子' : '同名盒子', actor: 'operator', message: target === 't2' ? '第二个设备独立记录' : '终端已回传的日志片段', details: JSON.stringify({ taskId: `task-${p}`, logTail: '\nnetwork error\n[REDACTED]', bootId: 'test' }) }] + await route.fulfill({ json: { subject: { kind, id: target, name: kind === 'desktop' ? '办公桌面' : target === 't2' ? '第二个同名盒子' : '同名盒子', state: options.offline ? 'offline' : kind === 'desktop' ? 'running' : 'online', node: kind === 'desktop' ? 'pve' : '', vmid: kind === 'desktop' ? 100 : 0, lastSeen: now, agentVersion: 'test' }, from: url.searchParams.get('from') || now, to: url.searchParams.get('to') || now, observedAt: now, items, total: source ? 1 : 25, page: p, pageSize: 20, source, counts: { tasks: 10, sessions: 10, logs: 3, alerts: 1, audits: 1 }, notes: ['按唯一标识关联;无记录不代表设备无故障。', '日志为设备回传片段,当前快照独立于历史时间范围。'] } }); return + } + await route.fulfill({ status: 404, json: { message: '未定义的测试接口' } }) + }) + return { queries, exports, writes } +} + +test('按对象显式查询,关联来源与详情以纯文本展示,支持分页', async ({ page }) => { + const calls = await fixture(page); await page.goto('/diagnostics') + await expect(page.getByText('选择需要排查的设备或桌面,再查询诊断记录')).toBeVisible(); expect(calls.queries).toHaveLength(0) + await page.getByRole('combobox', { name: '诊断对象', exact: true }).click(); await page.getByRole('option', { name: '同名盒子 / SN-1', exact: true }).click(); await page.getByRole('button', { name: '查询诊断', exact: true }).click() + await expect(page.getByRole('heading', { name: '关联事件', exact: true })).toBeVisible(); expect(calls.queries.slice(-1)[0]?.get('targetId')).toBe('t1') + await page.getByText('查看关联标识与详情', { exact: true }).click(); await expect(page.locator('.diagnosis-log')).toContainText(' (window as Window & { diagnosticInjected?: boolean }).diagnosticInjected)).toBeUndefined() + await expect(page.locator('.diagnosis-event img')).toHaveCount(0) + const cutoff = calls.queries.slice(-1)[0]?.get('to'); await page.locator('.el-pagination .btn-next').click(); await expect.poll(() => calls.queries.slice(-1)[0]?.get('page')).toBe('2'); expect(calls.queries.slice(-1)[0]?.get('to')).toBe(cutoff) + expect(calls.writes).toHaveLength(0) +}) + +test('导出沿用已查询条件,修改筛选立即清除旧记录并禁用导出', async ({ page }) => { + const calls = await fixture(page); await page.goto('/diagnostics?targetType=terminal&targetId=t1'); await expect(page.getByRole('heading', { name: '关联事件' })).toBeVisible() + const applied = calls.queries.slice(-1)[0]!, download = page.waitForEvent('download'); await page.getByRole('button', { name: '导出诊断包', exact: true }).click(); expect((await download).suggestedFilename()).toContain('.zip') + expect(calls.exports[0]?.get('targetId')).toBe(applied.get('targetId')); expect(calls.exports[0]?.get('to')).toBe(applied.get('to')); expect(calls.exports[0]?.get('page')).toBeNull() + await page.locator('.diagnosis-query > .el-select').last().click(); await page.getByRole('option', { name: '监控告警', exact: true }).click(); await expect(page.getByRole('heading', { name: '关联事件' })).toHaveCount(0); await expect(page.getByRole('button', { name: '导出诊断包' })).toBeDisabled() + await page.getByRole('button', { name: '查询诊断', exact: true }).click(); await expect(page.getByRole('heading', { name: '关联事件' })).toBeVisible(); expect(calls.queries.slice(-1)[0]?.get('source')).toBe('alerts') +}) + +test('查询失败清除结果,导出超量显示后台错误', async ({ page }) => { + const calls = await fixture(page, { queryError: true, exportError: true }); await page.goto('/diagnostics?targetType=terminal&targetId=t1'); await expect(page.getByRole('heading', { name: '关联事件' })).toBeVisible() + await page.getByRole('button', { name: '导出诊断包' }).click(); await expect(page.getByText(/诊断事件超过 2000 条/)).toBeVisible() + await page.locator('.diagnosis-query > .el-select').last().click(); await page.getByRole('option', { name: '执行任务', exact: true }).click(); await page.getByRole('button', { name: '查询诊断', exact: true }).click(); await expect(page.getByText(/诊断查询暂不可用/)).toBeVisible(); await expect(page.getByRole('heading', { name: '关联事件' })).toHaveCount(0); await expect(page.getByRole('button', { name: '导出诊断包' })).toBeDisabled(); expect(calls.writes).toHaveLength(0) +}) + +test('切换对象后较晚返回的旧查询不能覆盖新对象', async ({ page }) => { + await fixture(page, { slow: true }); await page.goto('/diagnostics?targetType=terminal&targetId=t1') + await page.getByRole('combobox', { name: '诊断对象', exact: true }).click(); await page.getByRole('option', { name: '同名盒子 / SN-2', exact: true }).click(); await page.getByRole('button', { name: '查询诊断', exact: true }).click() + await expect(page.getByRole('heading', { name: '第二个同名盒子', exact: true })).toBeVisible(); await expect(page.getByText('第二个设备独立记录')).toBeVisible(); await expect(page.getByRole('heading', { name: '同名盒子', exact: true })).toHaveCount(0) +}) + +test('采集先确认,取消不提交,提交与回执分开,结果失败可复用原幂等标识', async ({ page }) => { + const calls = await fixture(page, { collectError: true }); await page.goto('/diagnostics?targetType=terminal&targetId=t1'); await page.getByRole('button', { name: '收集此设备日志' }).click() + const confirm = page.locator('.el-message-box'); await expect(confirm).toContainText('不会重启设备'); await confirm.getByRole('button', { name: '取消', exact: true }).click(); expect(calls.writes).toHaveLength(0) + await page.getByRole('button', { name: '收集此设备日志' }).click(); await confirm.getByRole('button', { name: '确认提交' }).click(); await expect(page.getByText(/提交结果尚未确认/)).toBeVisible() + await page.getByRole('button', { name: '收集此设备日志' }).click(); await confirm.getByRole('button', { name: '确认提交' }).click(); await expect(page.getByText('本次日志采集 · 同名盒子')).toBeVisible(); await expect(page.getByRole('button', { name: '查看本次采集' })).toBeVisible() + expect(calls.writes).toHaveLength(2); expect(calls.writes[0]?.key).toBeTruthy(); expect(calls.writes[0]?.key).toBe(calls.writes[1]?.key); expect(calls.writes[1]?.body).toEqual({ action: 'collect-logs' }) + await page.getByRole('button', { name: '查看本次采集' }).click(); await expect.poll(() => calls.queries.slice(-1)[0]?.get('source')).toBe('logs') +}) + +for (const options of [{ offline: true }, { pending: true }]) test(`离线或有未知任务时禁止发起新采集 ${JSON.stringify(options)}`, async ({ page }) => { + const calls = await fixture(page, options); await page.goto('/diagnostics?targetType=terminal&targetId=t1'); await expect(page.getByRole('button', { name: '收集此设备日志' })).toBeDisabled(); expect(calls.writes).toHaveLength(0) +}) + +test('只读角色隐藏高级入口且直接访问被拦截', async ({ page }) => { + const calls = await fixture(page, { role: 'viewer' }); await page.goto('/overview'); await expect(page.getByRole('navigation', { name: '主导航' }).getByRole('link', { name: '日志与诊断' })).toHaveCount(0) + await page.goto('/diagnostics?targetType=terminal&targetId=t1'); await expect(page).toHaveURL(/\/forbidden$/); expect(calls.queries).toHaveLength(0); expect(calls.writes).toHaveLength(0) +}) + +test('桌面视角与深浅主题、390宽度布局可读', async ({ page }, info) => { + const calls = await fixture(page); await page.goto('/diagnostics?targetType=desktop&targetId=d1'); await expect(page.getByRole('heading', { name: '办公桌面', exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: '收集此设备日志' })).toHaveCount(0) + await page.screenshot({ path: info.outputPath('diagnostics-dark.png'), fullPage: true, animations: 'disabled' }); await page.getByRole('button', { name: '切换浅色主题' }).click(); await page.screenshot({ path: info.outputPath('diagnostics-light.png'), fullPage: true, animations: 'disabled' }) + await page.setViewportSize({ width: 390, height: 844 }); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); await page.screenshot({ path: info.outputPath('diagnostics-mobile.png'), fullPage: true, animations: 'disabled' }); expect(calls.writes).toHaveLength(0) +}) diff --git a/console/e2e/editions.spec.ts b/console/e2e/editions.spec.ts new file mode 100644 index 0000000..0a5f6b8 --- /dev/null +++ b/console/e2e/editions.spec.ts @@ -0,0 +1,131 @@ +import { test, expect, type Page } from '@playwright/test' + +async function fixture(page: Page, options: { role?: string; empty?: boolean; fail?: string; id?: string; saved?: string; timeout?: boolean } = {}) { + const writes: { path: string; body: Record }[] = [], reads: string[] = [] + let users = options.empty ? [] : [{ id: 'u1', username: 'zhang', displayName: '张三', enabled: true, terminalId: 't1', desktopIds: ['d1'] }, { id: 'u2', username: 'li', displayName: '李四', enabled: false, terminalId: null, desktopIds: [] as string[] }] + const desktops = options.empty ? [] : ['d1', 'd2'].map((id, i) => ({ id, name: i ? '备用桌面' : '办公桌面', os: 'Windows', node: 'pve', vmid: 100 + i, powerState: i ? 'stopped' : 'running', serviceState: i ? 'unknown' : 'ready', setupRequired: false, updatedAt: new Date().toISOString() })) + const terminals = options.empty ? [] : [{ id: 't1', sn: 'SN-01', name: '前台盒子', enabled: true, status: 'offline', sessionDesktopId: null, lastSeen: new Date().toISOString() }, { id: 't2', sn: 'SN-02', name: '新盒子', enabled: true, status: 'unregistered', sessionDesktopId: null, lastSeen: null }] + const task = { id: 'k1', targetId: 'd2', targetName: '备用桌面', kind: 'desktop', action: 'start', status: options.timeout ? 'timeout' : 'success', message: '等待核对', actor: 'admin', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } + await page.addInitScript(({ id, saved }) => { sessionStorage.setItem('vdi.accessToken', 'editions-fixture'); if (saved) localStorage.setItem(`vdi.console-mode:${id}`, saved) }, { id: options.id || 'admin', saved: options.saved }) + await page.route('**/api/v1/**', async route => { + const request = route.request(), url = new URL(request.url()), path = url.pathname.replace('/api/v1', ''), method = request.method(), body = request.postData() ? request.postDataJSON() : {} + const list = (items: unknown[], total = items.length) => route.fulfill({ json: { items, total, page: Number(url.searchParams.get('page') || 1), pageSize: 10, requestId: 'edition-ui' } }) + if (method !== 'GET') writes.push({ path, body }); else reads.push(path + url.search) + if (options.fail === path && method !== 'GET' || options.fail === 'reads' && path === '/desktops' || options.fail === 'alerts' && path === '/monitor/alerts') { await route.fulfill({ status: 503, json: { code: 'unavailable', message: '当前服务不可用', requestId: 'edition-ui' } }); return } + if (path === '/auth/me') { await route.fulfill({ json: { id: options.id || 'admin', username: 'admin', displayName: '管理员', role: options.role || 'super_admin' } }); return } + if (path === '/settings') { await route.fulfill({ json: { mode: 'production', pveConfigured: true, capabilities: [] } }); return } + if (path === '/overview') { await route.fulfill({ json: { activeSessions: 1, desktopCount: desktops.length, onlineTerminals: 0, terminalCount: terminals.length, runningDesktops: 1, failedTasks: 0 } }); return } + if (path === '/desktops') { await list(desktops); return } + if (path === '/users' && method === 'POST') { const user = { ...body, id: 'new-user', terminalId: null, desktopIds: [], enabled: true }; users.push(user); await route.fulfill({ status: 201, json: user }); return } + if (path === '/users') { await list(users); return } + if (path === '/terminals' && method === 'POST') { await route.fulfill({ status: 201, json: { id: 'new-terminal', activationSecret: 'one-time-test-secret' } }); return } + if (path === '/terminals') { await list(terminals); return } + if (path === '/users/batch-bindings') { + users = users.map(u => body.userIds.includes(u.id) ? { ...u, desktopIds: body.action === 'grant' ? [...new Set([...u.desktopIds, ...body.desktopIds])] : u.desktopIds.filter(id => !body.desktopIds.includes(id)) } : u) + await route.fulfill({ json: { affectedUsers: 1 } }); return + } + if (path.startsWith('/desktops/') && path.endsWith('/actions')) { await route.fulfill({ status: 202, json: task }); return } + if (path === '/tasks') { await list(url.searchParams.get('active') ? options.timeout ? [task] : [] : [task]); return } + if (path === '/audit-logs') { await list([{ id: 'a1', target: '张三', action: 'batch_grant_bindings', result: 'success', actor: 'admin', createdAt: new Date().toISOString() }], 21); return } + if (path === '/monitor/alerts') { await route.fulfill({ json: { items: [], total: 0, firingCount: 0, unacknowledgedCount: 0 } }); return } + if (path === '/resources') { await list([]); return } + await route.fulfill({ status: 404, json: { message: '未定义的测试接口' } }) + }) + return { writes, reads } +} + +test('默认通用版五个入口,切换高级版保留专业深链和账号偏好', async ({ page }) => { + const calls = await fixture(page); await page.goto('/') + await expect(page).toHaveURL(/\/workbench$/); await expect(page.getByRole('heading', { name: '工作台', exact: true })).toBeVisible(); await expect(page.locator('.simple-stats .panel').first()).toContainText('运行中的桌面1 / 2 台桌面') + const nav = page.getByRole('navigation', { name: '主导航' }); await expect(nav.getByRole('link')).toHaveCount(5) + await expect(nav.getByRole('link', { name: 'GPU 资源' })).toHaveCount(0) + await page.getByRole('button', { name: '高级版', exact: true }).click(); await expect(page).toHaveURL(/\/overview$/) + await expect(nav.getByRole('link', { name: 'GPU 资源' })).toBeVisible(); await expect(nav.getByRole('link', { name: '镜像模板' })).toBeVisible() + await page.goto('/'); await expect(page).toHaveURL(/\/overview$/) + await page.goto('/people'); await expect(page.getByRole('button', { name: '通用版', exact: true })).toHaveAttribute('aria-pressed', 'true') + await page.goto('/users'); await expect(page.getByRole('heading', { name: '用户与授权' })).toBeVisible() + expect(calls.writes).toHaveLength(0) +}) + +test('另一个账号默认通用版,存储不可用时仍可切换', async ({ page }) => { + await fixture(page, { id: 'reader' }); await page.addInitScript(() => { localStorage.setItem('vdi.console-mode:another-account', 'advanced'); Storage.prototype.setItem = () => { throw new Error('storage disabled') } }) + await page.goto('/'); await expect(page).toHaveURL(/\/workbench$/) + await page.getByRole('button', { name: '高级版', exact: true }).click(); await expect(page).toHaveURL(/\/overview$/) +}) + +test('授权先展示固定对象方案,确认才提交,追加和撤销保留其余授权', async ({ page }) => { + const calls = await fixture(page); await page.goto('/people') + const row = page.getByRole('row').filter({ hasText: '张三' }).first() + await row.getByRole('button', { name: '分配桌面', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '分配已有桌面' }); await dialog.locator('.el-checkbox').filter({ hasText: '备用桌面' }).click() + await dialog.getByRole('button', { name: '查看方案' }).click(); await expect(dialog.getByText('确认变更方案')).toBeVisible(); expect(calls.writes).toHaveLength(0) + await dialog.getByRole('button', { name: '确认并保存授权' }).dblclick(); await expect(dialog).not.toBeVisible() + expect(calls.writes).toEqual([{ path: '/users/batch-bindings', body: { userIds: ['u1'], desktopIds: ['d2'], action: 'grant' } }]) + await expect(row).toContainText('备用桌面'); await expect(row).toContainText('办公桌面'); await expect(row).toContainText('前台盒子') + await row.getByRole('button', { name: '撤销授权' }).click(); const revoke = page.getByRole('dialog', { name: '撤销指定授权' }) + await revoke.locator('.el-checkbox').filter({ hasText: '备用桌面' }).click(); await revoke.getByRole('button', { name: '查看方案' }).click(); await revoke.getByRole('button', { name: '确认并保存授权' }).click() + await expect(revoke).not.toBeVisible(); await expect(row).not.toContainText('备用桌面'); await expect(row).toContainText('办公桌面') + expect(calls.writes[1]?.body).toEqual({ userIds: ['u1'], desktopIds: ['d2'], action: 'revoke' }) +}) + +test('授权失败保留方案,不显示成功或自动重发', async ({ page }) => { + const calls = await fixture(page, { fail: '/users/batch-bindings' }); await page.goto('/people') + await page.getByRole('row').filter({ hasText: '张三' }).first().getByRole('button', { name: '分配桌面' }).click() + const dialog = page.getByRole('dialog', { name: '分配已有桌面' }); await dialog.locator('.el-checkbox').filter({ hasText: '备用桌面' }).click(); await dialog.getByRole('button', { name: '查看方案' }).click(); await dialog.getByRole('button', { name: '确认并保存授权' }).click() + await expect(page.getByText(/当前服务不可用/)).toBeVisible(); await expect(dialog).toBeVisible(); expect(calls.writes).toHaveLength(1) +}) + +for (const role of ['viewer', 'operator']) test(`${role}切换版本不提升权限`, async ({ page }) => { + const calls = await fixture(page, { role }); await page.goto('/people') + await expect(page.getByRole('button', { name: '添加员工' })).toHaveCount(0); await expect(page.getByRole('button', { name: '分配桌面', exact: true })).toHaveCount(0) + if (role === 'viewer') await expect(page.getByRole('button', { name: '正常关机', exact: true })).toHaveCount(0) + await page.getByRole('button', { name: '高级版', exact: true }).click(); await page.goto('/settings'); await expect(page).toHaveURL(/\/forbidden$/) + expect(calls.writes).toHaveLength(0) +}) + +test('设备诊断只读,区分离线、等待激活与缺少关联', async ({ page }) => { + const calls = await fixture(page); await page.goto('/help?terminal=t1') + await expect(page.getByText('未收到在线心跳,请检查盒子电源、网络和平台地址。')).toBeVisible(); await expect(page.getByText('使用人:张三')).toBeVisible(); await expect(page.getByText('桌面运行且连接服务就绪', { exact: false })).toBeVisible() + await page.goto('/help?terminal=t2'); await expect(page.getByText('设备尚未激活', { exact: false })).toBeVisible(); await expect(page.getByText('请在员工与桌面中关联此设备。')).toBeVisible() + expect(calls.writes).toHaveLength(0) +}) + +test('设备登记返回真实接口激活信息,关闭后移除且不持久化', async ({ page }) => { + const calls = await fixture(page); await page.goto('/devices'); await page.getByRole('button', { name: '登记新设备' }).click() + await page.getByLabel('新设备名称').fill('新设备'); await page.getByLabel('新设备 SN').fill('SN-NEW'); await page.getByRole('button', { name: '登记并获取激活信息' }).click() + const dialog = page.getByRole('dialog', { name: '请保存设备激活信息' }); await expect(dialog.getByLabel('设备激活密钥')).toHaveValue('one-time-test-secret') + await dialog.getByRole('button', { name: '已保存,关闭' }).click(); await expect(dialog).not.toBeVisible() + expect(await page.evaluate(() => JSON.stringify({ ...localStorage, ...sessionStorage }))).not.toContain('one-time-test-secret') + expect(calls.writes[0]).toEqual({ path: '/terminals', body: { name: '新设备', sn: 'SN-NEW', group: '' } }) +}) + +test('电源操作取消不提交,结果未知时阻止重复操作', async ({ page }) => { + const calls = await fixture(page, { timeout: true }); await page.goto('/people') + const backup = page.getByRole('row').filter({ hasText: '备用桌面' }).last(); await expect(backup.getByRole('button', { name: '开机', exact: true })).toBeDisabled() + await page.getByRole('button', { name: '重启桌面', exact: true }).click(); const dialog = page.locator('.el-message-box') + await expect(dialog).toContainText('保存文件'); await dialog.getByRole('button', { name: '取消', exact: true }).click(); expect(calls.writes).toHaveLength(0) + await page.getByRole('button', { name: '重启桌面', exact: true }).click(); await dialog.getByRole('button', { name: '确认提交', exact: true }).click() + await expect.poll(() => calls.writes.length).toBe(1); expect(calls.writes[0]).toEqual({ path: '/desktops/d1/actions', body: { action: 'reboot' } }) +}) + +test('通用记录查历史审计和任务,服务端分页', async ({ page }) => { + const calls = await fixture(page); await page.goto('/records'); await expect(page.getByRole('cell', { name: '批量分配桌面' })).toBeVisible() + await page.locator('.el-pagination .btn-next').click(); await expect.poll(() => calls.reads.some(p => p.includes('/audit-logs?') && p.includes('page=2'))).toBe(true) + await page.getByRole('tab', { name: '设备与桌面任务' }).click(); await expect(page.getByRole('cell', { name: '等待核对' })).toBeVisible() + expect(calls.writes).toHaveLength(0) +}) + +test('空态、告警失败和平台数据失败有明确提示', async ({ page }) => { + await fixture(page, { empty: true, fail: 'alerts' }); await page.goto('/people'); await expect(page.getByText('暂无员工,请先添加员工')).toBeVisible() + await page.goto('/help'); await expect(page.getByText(/告警读取失败,不能判断是否正常/)).toBeVisible(); await expect(page.getByText('当前没有触发中的告警')).toHaveCount(0) + await page.unroute('**/api/v1/**'); await fixture(page, { fail: 'reads' }); await page.goto('/workbench'); await expect(page.getByText('数据刷新失败,当前信息可能已过期')).toBeVisible() +}) + +test('通用工作台深浅主题与窄屏导航无溢出', async ({ page }, info) => { + await fixture(page); await page.goto('/workbench'); await expect(page.getByRole('heading', { name: '你想完成什么?' })).toBeVisible() + await page.screenshot({ path: info.outputPath('workbench-dark.png'), fullPage: true }) + await page.getByRole('button', { name: /切换浅色主题/ }).click(); await page.screenshot({ path: info.outputPath('workbench-light.png'), fullPage: true }) + await page.setViewportSize({ width: 390, height: 844 }); await page.getByRole('button', { name: '打开导航', exact: true }).click(); await expect(page.getByRole('navigation', { name: '主导航' }).getByRole('link', { name: '员工与桌面' })).toBeVisible() + await page.getByRole('button', { name: '关闭导航', exact: true }).click(); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) + await page.screenshot({ path: info.outputPath('workbench-mobile.png'), fullPage: true }) +}) diff --git a/console/e2e/gpu-reconcile.spec.ts b/console/e2e/gpu-reconcile.spec.ts new file mode 100644 index 0000000..2ba1fd4 --- /dev/null +++ b/console/e2e/gpu-reconcile.spec.ts @@ -0,0 +1,45 @@ +import { test, expect, type Page } from '@playwright/test' +async function fixture(page: Page, options: { role?: string; fail?: boolean; status?: string } = {}) { + const writes: string[] = [] + const task = { id: 'gpu-task', kind: 'gpu-prepare', action: 'prepare-desktop', targetId: 'desk', targetName: '显卡办公桌面', actor: 'device:box', status: options.status || 'timeout', message: '等待核对', providerRef: '{}', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } + await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'gpu-fixture')) + await page.route('**/api/v1/**', async route => { + const request = route.request(), url = new URL(request.url()), path = url.pathname.replace('/api/v1', '') + const list = (items: unknown[]) => route.fulfill({ json: { items, total: items.length, page: 1, pageSize: 20 } }) + if (request.method() !== 'GET') { + writes.push(path) + if (path !== '/tasks/gpu-task/gpu-reconcile') { await route.abort(); return } + if (options.fail) { await route.fulfill({ status: 409, json: { code: 'unknown_submission', message: '未记录原 PVE 任务编号,需要在 PVE 手工核对,不能自动重发', requestId: 'gpu-ui' } }); return } + task.status = 'failed'; task.message = '管理员已核对原 PVE 任务及实际电源状态,可重新准备桌面' + await route.fulfill({ json: { reconciled: true, vmid: '100' } }); return + } + if (path === '/auth/me') { await route.fulfill({ json: { id: 'admin', username: 'admin', displayName: '管理员', role: options.role || 'operator' } }); return } + if (path === '/settings') { await route.fulfill({ json: { mode: 'production', pveConfigured: true, capabilities: [] } }); return } + if (path === '/overview') { await route.fulfill({ json: {} }); return } + if (path === '/tasks') { await list(url.searchParams.get('active') && task.status !== 'timeout' ? [] : [task]); return } + if (['/desktops', '/users', '/terminals', '/resources'].includes(path)) { await list([]); return } + await route.fulfill({ status: 404, json: { message: '未定义接口' } }) + }) + return writes +} + +test('高级版运维先确认再核对,取消无变更,成功后不再提供核对按钮', async ({ page }) => { + const writes = await fixture(page); await page.goto('/tasks'); await page.getByRole('button', { name: '显卡办公桌面', exact: true }).click() + const drawer = page.getByRole('dialog', { name: '任务详情' }); await drawer.getByRole('button', { name: '核对显卡准备结果', exact: true }).click() + const confirm = page.locator('.el-message-box'); await expect(confirm).toContainText('不重发电源操作'); await expect(confirm).toContainText('原任务标记为失败') + await confirm.getByRole('button', { name: '取消', exact: true }).click(); expect(writes).toHaveLength(0) + await drawer.getByRole('button', { name: '核对显卡准备结果', exact: true }).click(); await confirm.getByRole('button', { name: '确认提交' }).click() + await expect(drawer).not.toBeVisible(); expect(writes).toEqual(['/tasks/gpu-task/gpu-reconcile']) + await page.getByRole('button', { name: '显卡办公桌面', exact: true }).click(); await expect(drawer.getByRole('button', { name: '核对显卡准备结果' })).toHaveCount(0) +}) + +test('缺少原任务编号显示后台拒绝,保留未知状态且不自动重发', async ({ page }) => { + const writes = await fixture(page, { fail: true }); await page.goto('/tasks'); await page.getByRole('button', { name: '显卡办公桌面', exact: true }).click() + const drawer = page.getByRole('dialog', { name: '任务详情' }); await drawer.getByRole('button', { name: '核对显卡准备结果' }).click(); await page.locator('.el-message-box').getByRole('button', { name: '确认提交' }).click() + await expect(page.getByText(/未记录原 PVE 任务编号/)).toBeVisible(); await expect(drawer).toBeVisible(); expect(writes).toEqual(['/tasks/gpu-task/gpu-reconcile']) +}) + +for (const options of [{ role: 'viewer' }, { role: 'operator', status: 'success' }]) test(`角色${options.role}与状态${options.status || 'timeout'}限制核对`, async ({ page }) => { + const writes = await fixture(page, options); await page.goto('/tasks'); await page.getByRole('button', { name: '显卡办公桌面', exact: true }).click() + await expect(page.getByRole('dialog', { name: '任务详情' }).getByRole('button', { name: '核对显卡准备结果' })).toHaveCount(0); expect(writes).toHaveLength(0) +}) diff --git a/console/e2e/mfa.spec.ts b/console/e2e/mfa.spec.ts new file mode 100644 index 0000000..5b2a11d --- /dev/null +++ b/console/e2e/mfa.spec.ts @@ -0,0 +1,98 @@ +import { test, expect, type Page } from '@playwright/test' + +async function fixture(page: Page, options: { enabled?: boolean; fail?: boolean; loggedOut?: boolean } = {}) { + const writes: { path: string; body: Record }[] = [] + let enabled = options.enabled ?? false + const codes = Array.from({ length: 8 }, (_, i) => `AAAAAAAA-BBBBBBBB-CCCCCCCC-${String(i).padStart(8, '0')}`) + if (!options.loggedOut) await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'mfa-fixture')) + await page.route('**/api/v1/**', async route => { + const request = route.request(), path = new URL(request.url()).pathname.replace('/api/v1', ''), body = request.postData() ? request.postDataJSON() : {} + if (request.method() !== 'GET') writes.push({ path, body }) + const fail = (code: string, message: string, status = 400) => route.fulfill({ status, json: { code, message, requestId: 'mfa-ui' } }) + if (path === '/auth/login') { + if (body.password !== 'Correct-password-2026') { await fail('invalid_credentials', '用户名或密码错误', 401); return } + if (!body.code) { await fail('mfa_required', '请输入身份验证器动态码或一次性恢复码', 401); return } + if (body.code !== '123456' && body.code !== codes[0]) { await fail('mfa_invalid', '验证码错误、已使用或已过期', 401); return } + await route.fulfill({ json: { accessToken: 'logged-in-after-mfa', expiresIn: 3600, user: { id: 'reader', username: 'reader', displayName: '查看员', role: 'viewer' } } }); return + } + if (path === '/auth/me') { await route.fulfill({ json: { id: 'reader', username: 'reader', displayName: '查看员', role: 'viewer' } }); return } + if (path === '/auth/mfa' && request.method() === 'GET') { await route.fulfill({ json: { enabled, recoveryRemaining: enabled ? 8 : 0 } }); return } + if (path === '/auth/mfa/setup') { + if (body.password !== 'Correct-password-2026') { await fail('current_password_invalid', '当前密码不正确'); return } + await route.fulfill({ json: { secret: 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP', provisioningUri: 'otpauth://totp/VDI%20Cloud:reader?secret=example', expiresAt: new Date(Date.now() + 600000).toISOString() } }); return + } + if (path === '/auth/mfa/confirm') { + if (options.fail || body.code !== '123456') { await fail('mfa_invalid', '验证码错误或已过期'); return } + enabled = true; await route.fulfill({ json: { recoveryCodes: codes } }); return + } + if (path === '/auth/mfa/recovery') { await route.fulfill({ json: { recoveryCodes: codes } }); return } + if (path === '/auth/mfa' && request.method() === 'DELETE') { enabled = false; await route.fulfill({ status: 204 }); return } + if (path === '/settings') { await route.fulfill({ json: { mode: 'production', capabilities: ['admin-mfa'], pveConfigured: true } }); return } + if (path === '/overview') { await route.fulfill({ json: { terminalCount: 0, onlineTerminals: 0, desktopCount: 0, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return } + if (['/resources', '/desktops', '/users', '/terminals', '/tasks'].includes(path)) { await route.fulfill({ json: { items: [], total: 0, page: 1, pageSize: 100, requestId: 'mfa-ui' } }); return } + await route.fulfill({ status: 404, json: { message: '测试接口未定义' } }) + }) + return { writes, codes } +} + +test('只读人员也可绑定本人验证器,确认后才开启,恢复码下载和关闭后不再显示', async ({ page }, info) => { + const calls = await fixture(page); await page.goto('/overview'); await page.getByRole('button', { name: '双重验证', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '本人双重验证', exact: true }) + await expect(dialog.getByText('未开启', { exact: true })).toBeVisible() + await dialog.getByLabel('双重验证当前密码', { exact: true }).fill('Correct-password-2026') + await dialog.getByRole('button', { name: '开始绑定身份验证器' }).click() + await expect(dialog.getByLabel('身份验证器密钥')).toBeVisible() + expect(calls.writes.map(x => x.path)).toEqual(['/auth/mfa/setup']) + await dialog.getByRole('textbox', { name: '绑定动态码' }).fill('123456'); await dialog.getByRole('button', { name: '验证并开启' }).click() + await expect(dialog.getByText('恢复码仅显示这一次,请先保存')).toBeVisible() + await expect(dialog.getByText(calls.codes[0]!, { exact: true })).toBeVisible() + const download = page.waitForEvent('download'); await dialog.getByRole('button', { name: '下载恢复码' }).click(); expect((await download).suggestedFilename()).toBe('vdi-cloud-recovery-codes.txt') + await page.screenshot({ path: info.outputPath('mfa-recovery-dark.png'), fullPage: true, animations: 'disabled' }) + await dialog.getByRole('button', { name: '已保存,关闭' }).click(); await page.getByRole('button', { name: '双重验证', exact: true }).click() + await expect(dialog.getByText('已开启', { exact: true })).toBeVisible(); await expect(dialog.getByText(calls.codes[0]!, { exact: true })).toHaveCount(0) + expect(await page.evaluate(() => JSON.stringify({ ...localStorage, ...sessionStorage }))).not.toContain('JBSWY3') +}) + +test('验证码错误保持未完成绑定,窄屏和浅色布局可用,关闭清除密钥', async ({ page }, info) => { + await fixture(page, { fail: true }); await page.goto('/overview'); await page.getByRole('button', { name: '切换浅色主题' }).click(); await page.setViewportSize({ width: 390, height: 844 }) + await page.getByRole('button', { name: '双重验证', exact: true }).click(); const dialog = page.getByRole('dialog', { name: '本人双重验证' }) + await dialog.getByLabel('双重验证当前密码', { exact: true }).fill('Correct-password-2026'); await dialog.getByRole('button', { name: '开始绑定身份验证器' }).click() + await dialog.getByRole('textbox', { name: '绑定动态码' }).fill('111111'); await dialog.getByRole('button', { name: '验证并开启' }).click() + await expect(dialog.getByText(/验证码错误或已过期/)).toBeVisible(); await expect(dialog.getByText('恢复码仅显示这一次,请先保存')).toHaveCount(0) + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391) + await page.screenshot({ path: info.outputPath('mfa-setup-mobile-light.png'), fullPage: true, animations: 'disabled' }) + await dialog.getByRole('button', { name: '关闭', exact: true }).click(); await page.getByRole('button', { name: '双重验证', exact: true }).click(); await expect(dialog.getByText('未开启', { exact: true })).toBeVisible(); await expect(dialog.getByLabel('身份验证器密钥')).toHaveCount(0) +}) + +test('更新恢复码和关闭需要密码及验证码,取消不提交', async ({ page }) => { + const calls = await fixture(page, { enabled: true }); await page.goto('/overview'); await page.getByRole('button', { name: '双重验证', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '本人双重验证' }) + await dialog.getByRole('button', { name: '关闭双重验证', exact: true }).click() + await dialog.getByLabel('双重验证当前密码', { exact: true }).fill('Correct-password-2026'); await dialog.getByRole('textbox', { name: '双重验证确认码' }).fill('123456') + await dialog.getByRole('button', { name: '确认关闭双重验证' }).click(); await page.locator('.el-message-box').getByRole('button', { name: '取消', exact: true }).click(); expect(calls.writes).toHaveLength(0) + await dialog.getByRole('button', { name: '确认关闭双重验证' }).click(); await page.locator('.el-message-box').getByRole('button', { name: '确认提交' }).click() + await expect(dialog.getByText('未开启', { exact: true })).toBeVisible(); expect(calls.writes[0]?.body).toEqual({ password: 'Correct-password-2026', code: '123456' }) +}) + +test('重新生成恢复码经过确认后显示新码并支持下载', async ({ page }) => { + const calls = await fixture(page, { enabled: true }); await page.goto('/overview'); await page.getByRole('button', { name: '双重验证', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '本人双重验证' }) + await dialog.getByRole('button', { name: '重新生成恢复码', exact: true }).click() + await dialog.getByLabel('双重验证当前密码', { exact: true }).fill('Correct-password-2026'); await dialog.getByRole('textbox', { name: '双重验证确认码' }).fill(calls.codes[0]!) + await dialog.getByRole('button', { name: '生成新恢复码', exact: true }).click(); expect(calls.writes).toHaveLength(0) + await page.locator('.el-message-box').getByRole('button', { name: '确认提交', exact: true }).click() + await expect(dialog.getByText('恢复码仅显示这一次,请先保存')).toBeVisible() + expect(calls.writes[0]?.path).toBe('/auth/mfa/recovery'); expect(calls.writes[0]?.body.code).toBe(calls.codes[0]) + const download = page.waitForEvent('download'); await dialog.getByRole('button', { name: '下载恢复码' }).click(); expect((await download).suggestedFilename()).toBe('vdi-cloud-recovery-codes.txt') +}) + +for (const recovery of [false, true]) test(`登录先验证密码,动态码${recovery ? '使用恢复码' : '错误后重试'}成功前不保存令牌`, async ({ page }) => { + const calls = await fixture(page, { loggedOut: true }); await page.goto('/login') + await page.getByRole('textbox', { name: '用户名', exact: true }).fill('reader'); await page.getByLabel('密码', { exact: true }).fill('incorrect'); await page.getByRole('button', { name: '登录', exact: true }).click() + await expect(page.getByText(/用户名或密码错误/)).toBeVisible(); await expect(page.getByRole('textbox', { name: '登录动态码或恢复码' })).toHaveCount(0) + await page.getByLabel('密码', { exact: true }).fill('Correct-password-2026'); await page.getByRole('button', { name: '登录', exact: true }).click() + const input = page.getByRole('textbox', { name: '登录动态码或恢复码' }); await expect(input).toBeVisible(); expect(await page.evaluate(() => sessionStorage.getItem('vdi.accessToken'))).toBeNull() + await input.fill('111111'); await page.getByRole('button', { name: '登录', exact: true }).click(); await expect(page.getByText(/验证码错误、已使用或已过期/)).toBeVisible(); await expect(input).toHaveValue('') + await input.fill(recovery ? calls.codes[0]! : '123456'); await page.getByRole('button', { name: '登录', exact: true }).click(); await expect(page).toHaveURL(/\/workbench$/) + expect(await page.evaluate(() => sessionStorage.getItem('vdi.accessToken'))).toBe('logged-in-after-mfa') +}) diff --git a/console/e2e/task-history.spec.ts b/console/e2e/task-history.spec.ts new file mode 100644 index 0000000..9d3fdb5 --- /dev/null +++ b/console/e2e/task-history.spec.ts @@ -0,0 +1,90 @@ +import { test, expect, type Page } from '@playwright/test' + +async function fixture(page: Page, options: { fail?: boolean; pending?: boolean; slow?: boolean } = {}) { + const queries: URLSearchParams[] = [], exports: URLSearchParams[] = [], writes: string[] = [], now = new Date().toISOString() + await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'task-history-fixture')) + const task = (i: number, extra = {}) => ({ id: `task-${i}`, kind: 'desktop', action: 'start', targetId: `desk-${i}`, targetName: `历史桌面 ${i}`, actor: 'root', status: 'success', message: '真实回执已保存', providerRef: '', createdAt: now, updatedAt: now, ...extra }) + await page.route('**/api/v1/**', async route => { + const request = route.request(), url = new URL(request.url()), path = url.pathname.replace('/api/v1', '') + const list = (items: unknown[], extra = {}) => route.fulfill({ json: { items, total: items.length, page: 1, pageSize: 100, requestId: 'task-ui', ...extra } }) + if (request.method() !== 'GET') { writes.push(path); await route.fulfill({ status: 403, json: { message: '不应发送业务写请求' } }); return } + if (path === '/auth/me') { await route.fulfill({ json: { id: 'reader', username: 'reader', displayName: '任务查看员', role: options.pending ? 'operator' : 'viewer' } }); return } + if (path === '/settings') { await route.fulfill({ json: { mode: 'production', capabilities: ['task-history'], pveConfigured: false } }); return } + if (path === '/overview') { await route.fulfill({ json: { terminalCount: 0, onlineTerminals: 0, desktopCount: options.pending ? 1 : 0, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return } + if (path === '/tasks/export') { exports.push(url.searchParams); await route.fulfill({ contentType: 'text/csv; charset=utf-8', body: '\ufeff任务标识,状态\r\ntask-1,success\r\n' }); return } + if (path === '/tasks') { + queries.push(url.searchParams) + const p = Number(url.searchParams.get('page') || 1), size = Number(url.searchParams.get('pageSize') || 20), active = url.searchParams.get('active') === 'true' + if (active) { + const total = options.pending ? 101 : 0 + await list(Array.from({ length: Math.max(0, Math.min(size, total - (p - 1) * size)) }, (_, i) => task(2000 + (p - 1) * size + i, { status: 'queued', targetId: (p - 1) * size + i === 100 ? 'desk-pending' : `pending-${i}` })), { total, page: p, pageSize: size }); return + } + const keyword = url.searchParams.get('keyword') || '' + if (options.fail && keyword) { await route.fulfill({ status: 500, json: { code: 'database_error', message: '任务查询暂不可用', requestId: 'task-ui' } }); return } + if (options.slow && keyword === 'slow') await new Promise(resolve => setTimeout(resolve, 700)) + const filtered = !!url.searchParams.get('actor') || !!keyword, total = options.pending ? 1500 : filtered ? 1 : 45 + await list(Array.from({ length: Math.max(0, Math.min(size, total - (p - 1) * size)) }, (_, i) => task((p - 1) * size + i + 1, filtered ? { targetName: keyword || '筛选桌面', actor: url.searchParams.get('actor') || 'root' } : {})), { total, page: p, pageSize: size }); return + } + if (path === '/desktops' && options.pending) { await list([{ id: 'desk-pending', name: '有旧任务的桌面', vmid: 9000, node: 'test', os: 'Ubuntu', pool: '', provider: 'proxmox', powerState: 'stopped', serviceState: 'unknown', setupRequired: false, endpoint: '192.0.2.1', width: 1920, height: 1080, fps: 30, updatedAt: now }]); return } + if (['/resources', '/desktops', '/users', '/terminals', '/audit-logs'].includes(path)) { await list([]); return } + await route.fulfill({ status: 404, json: { message: '未定义的测试接口' } }) + }) + return { queries, exports, writes } +} + +test('任务历史分页、详情和深浅窄屏,历史读取不依赖全局近期任务', async ({ page }, info) => { + const calls = await fixture(page); await page.goto('/tasks') + await expect(page.getByRole('button', { name: '历史桌面 1', exact: true })).toBeVisible() + const initialPages = calls.queries.filter(q => q.get('pageSize') === '20') + const cutoffBeforePaging = initialPages[initialPages.length - 1]?.get('to') + await page.locator('.el-pagination .btn-next').click(); await expect(page.getByRole('button', { name: '历史桌面 21', exact: true })).toBeVisible() + await page.getByRole('button', { name: '历史桌面 21', exact: true }).click() + const drawer = page.getByRole('dialog', { name: '任务详情' }) + await expect(drawer.getByText('task-21', { exact: true })).toBeVisible() + await expect(drawer.getByRole('button', { name: /核对/ })).toHaveCount(0) + await drawer.locator('.el-drawer__close-btn').click() + await page.screenshot({ path: info.outputPath('task-history-dark.png'), fullPage: true, animations: 'disabled' }) + await page.getByRole('button', { name: '切换浅色主题' }).click(); await page.setViewportSize({ width: 390, height: 844 }) + await page.screenshot({ path: info.outputPath('task-history-mobile.png'), fullPage: true, animations: 'disabled' }) + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391) + const pages = calls.queries.filter(q => q.get('pageSize') === '20') + expect(pages[pages.length - 1]?.get('page')).toBe('2'); expect(pages[pages.length - 1]?.get('to')).toBe(cutoffBeforePaging) + expect(calls.writes).toEqual([]) +}) + +test('任务筛选和导出沿用已应用条件,修改草稿后导出不混用', async ({ page }) => { + const calls = await fixture(page); await page.goto('/tasks'); await expect(page.getByRole('button', { name: '历史桌面 1', exact: true })).toBeVisible() + await page.getByRole('textbox', { name: '任务操作者' }).fill('root_%') + await page.locator('.el-select').filter({ has: page.getByRole('combobox', { name: '任务状态筛选' }) }).click(); await page.getByRole('option', { name: '成功', exact: true }).click() + await page.getByRole('combobox', { name: '任务动作' }).click(); await page.getByRole('option', { name: '开机', exact: true }).click() + await page.getByRole('combobox', { name: '任务类型' }).click(); await page.getByRole('option', { name: '桌面电源', exact: true }).click() + await page.getByRole('combobox', { name: '任务时间范围' }).first().click(); await page.getByRole('button', { name: '最近 7 天', exact: true }).click() + await page.getByRole('button', { name: '查询任务', exact: true }).click(); await expect(page.getByRole('button', { name: '筛选桌面', exact: true })).toBeVisible() + await page.getByRole('textbox', { name: '搜索任务' }).fill('尚未应用') + const wait = page.waitForEvent('download'); await page.getByRole('button', { name: '导出任务 CSV' }).click() + expect((await wait).suggestedFilename()).toMatch(/执行任务.*\.csv$/) + expect(Object.fromEntries(calls.exports[0]!)).toMatchObject({ actor: 'root_%', status: 'success', action: 'start', kind: 'desktop', keyword: '' }) + expect(calls.exports[0]?.get('from')).toBeTruthy(); expect(calls.exports[0]?.get('page')).toBeNull() + await page.getByRole('button', { name: '重置', exact: true }).click(); await expect(page.getByRole('button', { name: '历史桌面 1', exact: true })).toBeVisible() + expect(calls.writes).toEqual([]) +}) + +test('查询失败清理旧结果并禁用导出,旧响应不能覆盖新条件', async ({ page }) => { + await fixture(page, { fail: true }); await page.goto('/tasks'); await expect(page.getByRole('button', { name: '历史桌面 1', exact: true })).toBeVisible() + await page.getByRole('textbox', { name: '搜索任务' }).fill('failure'); await page.getByRole('button', { name: '查询任务', exact: true }).click() + await expect(page.getByText(/任务查询暂不可用/)).toBeVisible(); await expect(page.getByRole('button', { name: '导出任务 CSV' })).toBeDisabled() + await expect(page.getByRole('button', { name: '历史桌面 1', exact: true })).toHaveCount(0) + await page.unrouteAll(); await fixture(page, { slow: true }); await page.reload(); await expect(page.getByRole('button', { name: '历史桌面 1', exact: true })).toBeVisible() + await page.getByRole('textbox', { name: '搜索任务' }).fill('slow'); await page.getByRole('textbox', { name: '搜索任务' }).press('Enter') + await page.getByRole('textbox', { name: '搜索任务' }).fill('new'); await page.getByRole('textbox', { name: '搜索任务' }).press('Enter') + await expect(page.getByRole('button', { name: 'new', exact: true })).toBeVisible() + await expect.poll(async () => { await page.waitForTimeout(800); return page.getByRole('button', { name: 'slow', exact: true }).count() }).toBe(0) +}) + +test('全局只取近期一页并分页拉全旧活动任务,旧任务仍阻止操作桌面', async ({ page }) => { + const calls = await fixture(page, { pending: true }); await page.goto('/desktops') + await expect(page.getByRole('button', { name: '任务执行中', exact: true })).toBeDisabled() + expect(calls.queries.some(q => q.get('active') === 'true' && q.get('page') === '2')).toBe(true) + expect(calls.queries.some(q => q.get('active') !== 'true' && q.get('page') === '2')).toBe(false) + expect(calls.writes).toEqual([]) +}) diff --git a/console/e2e/terminal-alerts.spec.ts b/console/e2e/terminal-alerts.spec.ts new file mode 100644 index 0000000..348c5fe --- /dev/null +++ b/console/e2e/terminal-alerts.spec.ts @@ -0,0 +1,94 @@ +import { test, expect, type Page } from '@playwright/test' + +async function fixture(page: Page, options: { role?: string; stale?: boolean; resolved?: boolean; fail?: boolean } = {}) { + const writes: { path: string; body: Record }[] = [], now = new Date().toISOString() + let acknowledged = false + await page.addInitScript(() => sessionStorage.setItem('vdi.accessToken', 'terminal-alert-fixture')) + const rules: Record[] = [{ id: 'temp-rule', name: '终端温度高水位', metric: 'terminal_temperature', threshold: 80, holdSeconds: 120, enabled: true }] + await page.route('**/api/v1/**', async route => { + const request = route.request(), path = new URL(request.url()).pathname.replace('/api/v1', '') + const list = (items: unknown[], extra = {}) => route.fulfill({ json: { items, total: items.length, page: 1, pageSize: 100, requestId: 'alert-ui', ...extra } }) + if (request.method() !== 'GET') { + const body = request.postDataJSON(); writes.push({ path, body }) + if (path.endsWith('/acknowledge')) { acknowledged = true; await route.fulfill({ json: { accepted: true } }); return } + if (path.startsWith('/monitor/rules')) { + if (options.fail) { await route.fulfill({ status: 400, json: { message: '温度阈值超出范围', code: 'invalid_request', requestId: 'alert-ui' } }); return } + const rule = { ...body, id: path.endsWith('/temp-rule') ? 'temp-rule' : 'created-rule' } + if (path.endsWith('/temp-rule')) rules[0] = rule; else rules.push(rule) + await route.fulfill({ json: rule }); return + } + await route.fulfill({ status: 403, json: { message: '不应出现的写操作' } }); return + } + if (path === '/auth/me') { await route.fulfill({ json: { id: 'alerts-reader', username: 'reader', displayName: '告警人员', role: options.role || 'super_admin' } }); return } + if (path === '/settings') { await route.fulfill({ json: { mode: 'production', capabilities: ['terminal-metric-alerts'], pveConfigured: false } }); return } + if (path === '/overview') { await route.fulfill({ json: { terminalCount: 1, onlineTerminals: 1, desktopCount: 0, runningDesktops: 0, activeSessions: 0, failedTasks: 0, mode: 'production' } }); return } + if (path === '/monitor/rules') { await list(rules); return } + if (path === '/monitor/alerts') { + await list([{ id: 'temp-alert', ruleId: 'temp-rule', ruleName: '终端温度高水位', metric: 'terminal_temperature', targetId: 'box1', targetName: '测试盒子', value: 86.5, threshold: 80, status: options.resolved ? 'resolved' : 'firing', startedAt: now, openedAt: now, resolvedAt: options.resolved ? now : null, resolveReason: options.resolved ? 'condition_cleared' : '', acknowledgedAt: acknowledged ? now : null, acknowledgedBy: acknowledged ? 'reader' : '', dataFresh: !options.stale }], { firingCount: options.resolved ? 0 : 1, unacknowledgedCount: acknowledged || options.resolved ? 0 : 1 }); return + } + if (['/resources', '/desktops', '/users', '/terminals', '/tasks', '/audit-logs'].includes(path)) { await list([]); return } + await route.fulfill({ status: 404, json: { message: '测试接口未定义', requestId: 'alert-ui' } }) + }) + return writes +} + +test('新增 SoC 温度规则使用摄氏度和独立上限,切换内存使用百分比', async ({ page }, info) => { + const writes = await fixture(page); await page.goto('/monitor') + await expect(page.getByRole('cell', { name: '86.5 / 80 °C', exact: true })).toBeVisible() + await page.getByRole('tab', { name: '告警规则' }).click() + await expect(page.getByRole('cell', { name: '80°C / 120 秒', exact: true })).toBeVisible() + await page.getByRole('button', { name: '新增规则', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '新增告警规则' }) + await dialog.locator('.el-select').click(); await page.getByRole('option', { name: '终端 SoC 温度', exact: true }).click() + await expect(dialog.getByText('SoC 温度阈值(°C)', { exact: true })).toBeVisible() + await expect(dialog.getByRole('spinbutton', { name: '告警阈值' })).toHaveAttribute('aria-valuemax', '150') + await expect(dialog.getByText(/重复读取不推进/)).toBeVisible() + await dialog.getByRole('textbox', { name: '规则名称' }).fill('盒子过热') + await page.screenshot({ path: info.outputPath('terminal-alert-create-dark.png'), fullPage: true, animations: 'disabled' }) + await dialog.getByRole('button', { name: '保存规则', exact: true }).click() + await expect(dialog).toBeHidden() + expect(writes[0]?.body).toMatchObject({ name: '盒子过热', metric: 'terminal_temperature', threshold: 80, holdSeconds: 120, enabled: true }) + await page.getByRole('button', { name: '新增规则', exact: true }).click() + await dialog.locator('.el-select').click(); await page.getByRole('option', { name: '终端内存', exact: true }).click() + await expect(dialog.getByText('使用率阈值(%)', { exact: true })).toBeVisible() + await expect(dialog.getByRole('spinbutton', { name: '告警阈值' })).toHaveAttribute('aria-valuemax', '100') + await dialog.getByRole('button', { name: '取消', exact: true }).click() + expect(writes).toHaveLength(1) +}) + +test('过期数据保留告警和最近值,确认不表示恢复', async ({ page }, info) => { + const writes = await fixture(page, { role: 'operator', stale: true }); await page.goto('/monitor') + await expect(page.getByText('数据已过期或缺失;显示最近告警值,等待有效采样', { exact: true })).toBeVisible() + await page.getByRole('button', { name: '确认告警' }).click() + await expect(page.getByRole('cell', { name: '正在告警', exact: true })).toBeVisible() + await expect(page.getByRole('button', { name: '确认告警' })).toHaveCount(0) + await page.getByRole('button', { name: '切换浅色主题' }).click() + await page.setViewportSize({ width: 390, height: 844 }) + await page.screenshot({ path: info.outputPath('terminal-alert-stale-mobile-light.png'), fullPage: true, animations: 'disabled' }) + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(391) + await page.getByRole('tab', { name: '告警规则' }).click() + await expect(page.getByRole('button', { name: '新增规则' })).toHaveCount(0) + expect(writes).toHaveLength(1) +}) + +test('只读人员可看恢复事件和摄氏度,无确认或规则写入口', async ({ page }) => { + const writes = await fixture(page, { role: 'viewer', stale: true, resolved: true }); await page.goto('/monitor') + await expect(page.getByRole('cell', { name: '已恢复', exact: true })).toBeVisible() + await expect(page.getByText('指标恢复', { exact: true })).toBeVisible() + await expect(page.getByText(/数据已过期或缺失/)).toHaveCount(0) + await expect(page.getByRole('button', { name: '确认告警' })).toHaveCount(0) + await page.getByRole('tab', { name: '告警规则' }).click() + await expect(page.getByRole('button', { name: '编辑规则' })).toHaveCount(0) + expect(writes).toEqual([]) +}) + +test('保存错误保留表单和数值,取消不改变规则', async ({ page }) => { + const writes = await fixture(page, { fail: true }); await page.goto('/monitor') + await page.getByRole('tab', { name: '告警规则' }).click(); await page.getByRole('button', { name: '编辑规则' }).click() + const dialog = page.getByRole('dialog', { name: '编辑告警规则' }) + await dialog.getByRole('button', { name: '保存规则', exact: true }).click() + await expect(page.getByText(/温度阈值超出范围/)).toBeVisible() + await expect(dialog).toBeVisible() + await dialog.getByRole('button', { name: '取消', exact: true }).click(); await expect(dialog).toBeHidden() + expect(writes).toHaveLength(1) +}) diff --git a/console/src/api/audit-labels.ts b/console/src/api/audit-labels.ts new file mode 100644 index 0000000..f43b312 --- /dev/null +++ b/console/src/api/audit-labels.ts @@ -0,0 +1 @@ +export const auditActionLabels: Record = { export_diagnostics: '导出诊断包', read_diagnostics: '读取终端诊断', 'batch-start': '批量开机提交', 'batch-shutdown': '批量关机提交', 'batch-reboot': '批量重启提交', reconcile_batch_power: '核对批量电源', 'snapshot-create': '创建快照', 'snapshot-delete': '删除快照', 'snapshot-rollback': '还原快照', reconcile_snapshot: '核对快照', 'configure-hardware': '调整 CPU/内存', 'expand-disk': '磁盘扩容', reconcile_hardware: '核对规格', save_pve_connection: '保存 PVE 接入', create_terminal_policy: '创建终端策略', deploy_terminal_policy: '下发终端策略', terminal_policy_applied: '策略应用成功', terminal_policy_failed: '策略应用失败', update_terminal: '更新终端资料', disable_terminal: '停用终端', reactivate_terminal: '重新激活终端', update_user: '更新用户资料', batch_grant_bindings: '批量分配桌面', batch_revoke_bindings: '批量撤销桌面', create_administrator: '新增管理员', update_administrator: '更新管理员', reset_administrator_password: '重置管理员密码', change_own_password: '修改本人密码', reconcile_clone: '核对克隆', clone: '克隆桌面', register_template: '登记模板', update_template: '更新模板', validate_template: '验证模板', save_pool: '保存桌面池', update_pool_members: '更新池成员', configure_desktop: '配置连接', start: '开机', shutdown: '正常关机', reboot: '重启', 'reset-session': '重置会话', 'collect-logs': '收集日志', sync: '同步资源', login: '登录', logout: '退出登录', create_user: '创建用户', enroll_terminal: '登记终端', update_bindings: '更新授权', import_desktop: '纳管桌面', save_alert_rule: '保存告警规则', acknowledge_alert: '确认告警', export_audit_logs: '导出操作审计', export_tasks: '导出任务记录', revoke_administrator_session: '撤销管理员登录', enable_mfa: '开启双重验证', disable_mfa: '关闭双重验证', rotate_mfa_recovery: '更新恢复码', 'prepare-desktop': '准备独占显卡桌面', 'reconcile-gpu-switch': '核对显卡切换' } diff --git a/console/src/api/index.ts b/console/src/api/index.ts index 7dc414e..ebcda57 100644 --- a/console/src/api/index.ts +++ b/console/src/api/index.ts @@ -2,6 +2,9 @@ import createClient from 'openapi-fetch' import type { paths, components } from './schema' export type PveConnectionInput = components['schemas']['PveConnectionInput'] +export type DiagnosticReport = components['schemas']['DiagnosticReport'] +export type DiagnosticEvent = components['schemas']['DiagnosticEvent'] +export type DiagnosticQuery = NonNullable export type PowerBatchInput = components['schemas']['PowerBatchInput'] export type PowerBatch = components['schemas']['PowerBatch'] export type PowerBatchDetail = components['schemas']['PowerBatchDetail'] @@ -12,6 +15,8 @@ export type SnapshotItem = components['schemas']['SnapshotItem'] export type HardwareInput = components['schemas']['HardwareInput'] export type VMHardware = components['schemas']['VMHardware'] export type Admin = components['schemas']['Administrator'] +export type MFAStatus = components['schemas']['MFAStatus'] +export type MFASetup = components['schemas']['MFASetup'] export type ImportPreview = components['schemas']['CsvImportPreview'] export type ImportResult = components['schemas']['CsvImportResult'] export type CsvImportInput = components['schemas']['CsvImportInput'] @@ -28,6 +33,7 @@ export type PolicyDeployment = components['schemas']['PolicyDeployment'] export type PolicyDeploymentInput = components['schemas']['PolicyDeploymentInput'] export type PolicyResult = components['schemas']['PolicyResult'] export type AdministratorAccount = components['schemas']['AdministratorAccount'] +export type AdministratorSession = components['schemas']['AdministratorSession'] export type CreateAdministrator = components['schemas']['CreateAdministrator'] export type UpdateAdministrator = components['schemas']['UpdateAdministrator'] export type Desktop = components['schemas']['Desktop'] @@ -48,6 +54,8 @@ export type GPUInventory = components['schemas']['GPUInventory'] export type GPUItem = components['schemas']['GPUItem'] export type Task = components['schemas']['Task'] export type Audit = components['schemas']['Audit'] +export type AuditQuery = NonNullable +export type TaskQuery = NonNullable export type Overview = components['schemas']['Overview'] export type Settings = components['schemas']['Settings'] export type BindingInput = components['schemas']['BindingInput'] @@ -80,6 +88,9 @@ async function result(promise: Promise>): Promise { const commandHeaders = () => ({ 'Idempotency-Key': crypto.randomUUID() }) const query = { page: 1, pageSize: 100 } export const api = { + diagnostics: (filters: DiagnosticQuery) => result(client.GET('/diagnostics', { params: { query: filters } })), + exportDiagnostics: (filters: DiagnosticQuery) => result(client.GET('/diagnostics/export', { params: { query: filters }, parseAs: 'blob' })), + collectTerminalLogs: (id: string, key: string) => result(client.POST('/terminals/{id}/commands', { params: { path: { id }, header: { 'Idempotency-Key': key } }, body: { action: 'collect-logs' } })), gpuInventory: (node: string) => result(client.GET('/resources/gpus', { params: { query: { node } } })), pveConnection: () => result(client.GET('/settings/pve')), checkPveConnection: (body: PveConnectionInput) => result(client.POST('/settings/pve/check', { body })), @@ -89,6 +100,7 @@ export const api = { checkDesktopPowerBatch: (body: PowerBatchInput) => result(client.POST('/desktop-batches/check', { body })), submitDesktopPowerBatch: (body: PowerBatchInput, key: string) => result(client.POST('/desktop-batches', { params: { header: { 'Idempotency-Key': key } }, body })), reconcileBatchPower: (id: string) => result(client.POST('/tasks/{id}/batch-power-reconcile', { params: { path: { id } } })), + reconcileGPUPrepare: (id: string) => result(client.POST('/tasks/{id}/gpu-reconcile', { params: { path: { id } } })), desktopSnapshots: (id: string) => result(client.GET('/desktops/{id}/snapshots', { params: { path: { id } } })), checkDesktopSnapshot: (id: string, body: SnapshotInput) => result(client.POST('/desktops/{id}/snapshots/check', { params: { path: { id } }, body })), changeDesktopSnapshot: (id: string, body: SnapshotInput, key: string) => result(client.POST('/desktops/{id}/snapshots/actions', { params: { path: { id }, header: { 'Idempotency-Key': key } }, body })), @@ -112,11 +124,18 @@ export const api = { policyDeployments: (page = 1) => result(client.GET('/terminal-policy-deployments', { params: { query: { ...query, page } } })), deployPolicy: (body: PolicyDeploymentInput, key: string) => result(client.POST('/terminal-policy-deployments', { params: { header: { 'Idempotency-Key': key } }, body })), policyResults: (id: string, page = 1) => result(client.GET('/terminal-policy-deployments/{id}/results', { params: { path: { id }, query: { ...query, page } } })), - login: (username: string, password: string) => result(client.POST('/auth/login', { body: { username, password } })), + login: (username: string, password: string, code?: string) => result(client.POST('/auth/login', { body: { username, password, ...(code ? { code } : {}) } })), + mfaStatus: () => result(client.GET('/auth/mfa')), + beginMFA: (password: string) => result(client.POST('/auth/mfa/setup', { body: { password } })), + confirmMFA: (code: string) => result(client.POST('/auth/mfa/confirm', { body: { code } })), + rotateMFARecovery: (password: string, code: string) => result(client.POST('/auth/mfa/recovery', { body: { password, code } })), + disableMFA: (password: string, code: string) => result(client.DELETE('/auth/mfa', { body: { password, code } })), me: () => result(client.GET('/auth/me')), logout: () => result(client.POST('/auth/logout')), changeOwnPassword: (currentPassword: string, newPassword: string) => result(client.POST('/auth/password', { body: { currentPassword, newPassword } })), administrators: (page = 1) => result(client.GET('/administrators', { params: { query: { ...query, page } } })), + administratorSessions: (id: string, page = 1) => result(client.GET('/administrators/{id}/sessions', { params: { path: { id }, query: { page, pageSize: 20 } } })), + revokeAdministratorSession: (id: string, sessionKey: string) => result(client.DELETE('/administrators/{id}/sessions/{sessionKey}', { params: { path: { id, sessionKey } } })), createAdministrator: (body: CreateAdministrator) => result(client.POST('/administrators', { body })), updateAdministrator: (id: string, body: UpdateAdministrator) => result(client.PUT('/administrators/{id}', { params: { path: { id } }, body })), resetAdministratorPassword: (id: string, password: string) => result(client.POST('/administrators/{id}/password', { params: { path: { id } }, body: { password } })), @@ -127,9 +146,13 @@ export const api = { terminals: (page = 1) => result(client.GET('/terminals', { params: { query: { ...query, page } } })), users: (page = 1) => result(client.GET('/users', { params: { query: { ...query, page } } })), tasks: (page = 1) => result(client.GET('/tasks', { params: { query: { ...query, page } } })), + activeTasks: (page = 1) => result(client.GET('/tasks', { params: { query: { ...query, page, active: true } } })), + taskHistory: (filters: TaskQuery = {}) => result(client.GET('/tasks', { params: { query: filters } })), + exportTasks: (filters: TaskQuery) => result(client.GET('/tasks/export', { params: { query: filters }, parseAs: 'blob' })), taskDiagnostics: (id: string) => result(client.GET('/tasks/{id}/diagnostics', { params: { path: { id } } })), reconcileProvision: (id: string) => result(client.POST('/tasks/{id}/reconcile', { params: { path: { id } } })), - audits: (page = 1) => result(client.GET('/audit-logs', { params: { query: { ...query, page } } })), + audits: (filters: AuditQuery = {}) => result(client.GET('/audit-logs', { params: { query: filters } })), + exportAudits: (filters: AuditQuery) => result(client.GET('/audit-logs/export', { params: { query: filters }, parseAs: 'blob' })), discoverVMs: () => result(client.GET('/resources/vms', { params: { query } })), templates: (page = 1) => result(client.GET('/templates', { params: { query: { ...query, page } } })), discoverTemplates: (page = 1) => result(client.GET('/resources/templates', { params: { query: { ...query, page } } })), @@ -166,4 +189,8 @@ export async function allPages(loader: (page: number) => Promise<{ items: T[] } return items } +export async function recentAndActiveTasks(): Promise { + const [recent, active] = await Promise.all([api.tasks(), allPages(api.activeTasks)]) + return [...new Map([...recent.items, ...active].map(task => [task.id, task])).values()] +} export const errorMessage = (e: unknown) => e instanceof ApiError ? `${e.message}${e.requestId ? `(请求 ${e.requestId})` : ''}` : e instanceof Error ? e.message : '操作失败,请重试' diff --git a/console/src/api/schema.d.ts b/console/src/api/schema.d.ts index 491346d..adabad4 100644 --- a/console/src/api/schema.d.ts +++ b/console/src/api/schema.d.ts @@ -484,6 +484,70 @@ export interface paths { patch?: never; trace?: never; }; + "/auth/mfa": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["mfaStatus"]; + put?: never; + post?: never; + delete: operations["disableMFA"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/mfa/setup": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["beginMFA"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/mfa/confirm": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["confirmMFA"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/mfa/recovery": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["rotateMFARecovery"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/administrators": { parameters: { query?: never; @@ -532,6 +596,38 @@ export interface paths { patch?: never; trace?: never; }; + "/administrators/{id}/sessions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["administratorSessions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/administrators/{id}/sessions/{sessionKey}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["revokeAdministratorSession"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/overview": { parameters: { query?: never; @@ -1044,6 +1140,22 @@ export interface paths { patch?: never; trace?: never; }; + "/tasks/export": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["exportTasks"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/tasks/{id}": { parameters: { query?: never; @@ -1092,6 +1204,40 @@ export interface paths { patch?: never; trace?: never; }; + "/diagnostics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Super administrators and operators only. Exact ID correlation, merged lifecycle events, maximum seven days. Redacts common credential formats. No collection or VM changes. */ + get: operations["diagnostics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/diagnostics/export": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Super administrators and operators only. ZIP of all matching events, up to 2000, with diagnostic JSON and scope notes. Uses the same object/time/source filters, ignores pagination, and records the export audit. */ + get: operations["exportDiagnostics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/audit-logs": { parameters: { query?: never; @@ -1108,6 +1254,23 @@ export interface paths { patch?: never; trace?: never; }; + "/audit-logs/export": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Authenticated administrators of all roles can export matching audit fields (maximum 10000 rows). No pagination or silent truncation. CSV uses UTF-8 BOM and formula-safe cells. Successful preparation is audited. */ + get: operations["exportAudits"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/terminal/register": { parameters: { query?: never; @@ -1172,6 +1335,54 @@ export interface paths { patch?: never; trace?: never; }; + "/terminal/desktop-prepare": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["prepareGPUDesktop"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/terminal/desktop-prepare/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["gpuPrepareStatus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/tasks/{id}/gpu-reconcile": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["reconcileGPUPrepare"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/terminal/commands": { parameters: { query?: never; @@ -1272,6 +1483,32 @@ export interface components { LoginInput: { username: string; password: string; + code?: string; + }; + MFAStatus: { + enabled: boolean; + recoveryRemaining: number; + }; + MFASetupInput: { + /** Format: password */ + password: string; + }; + MFASetup: { + secret: string; + provisioningUri: string; + /** Format: date-time */ + expiresAt: string; + }; + MFAConfirm: { + code: string; + }; + MFACredential: { + /** Format: password */ + password: string; + code: string; + }; + MFARecovery: { + recoveryCodes: string[]; }; LoginResult: { accessToken: string; @@ -1789,7 +2026,7 @@ export interface components { AlertRuleInput: { name: string; /** @enum {string} */ - metric: "node_cpu" | "node_memory" | "node_storage" | "node_unavailable" | "terminal_offline"; + metric: "node_cpu" | "node_memory" | "node_storage" | "node_unavailable" | "terminal_offline" | "terminal_cpu" | "terminal_memory" | "terminal_temperature"; threshold: number; holdSeconds: number; enabled: boolean; @@ -1798,7 +2035,7 @@ export interface components { id: string; name: string; /** @enum {string} */ - metric: "node_cpu" | "node_memory" | "node_storage" | "node_unavailable" | "terminal_offline"; + metric: "node_cpu" | "node_memory" | "node_storage" | "node_unavailable" | "terminal_offline" | "terminal_cpu" | "terminal_memory" | "terminal_temperature"; threshold: number; holdSeconds: number; enabled: boolean; @@ -1824,6 +2061,7 @@ export interface components { /** Format: date-time */ acknowledgedAt: string | null; acknowledgedBy: string; + dataFresh: boolean | null; }; ResourcePoint: { /** Format: date-time */ @@ -1907,6 +2145,16 @@ export interface components { role: "super_admin" | "operator" | "viewer"; enabled: boolean; }; + AdministratorSession: { + key: string; + /** Format: date-time */ + createdAt: string | null; + /** Format: date-time */ + expiresAt: string; + clientIp: string; + userAgent: string; + current: boolean; + }; CreateAdministrator: { username: string; displayName: string; @@ -2006,6 +2254,96 @@ export interface components { fps: number; initialized: boolean; }; + DeviceDesktop: { + id: string; + name: string; + /** @enum {string} */ + os: "Windows" | "Ubuntu" | "麒麟"; + node: string; + vmid: number; + pool: string; + powerState: string; + serviceState: string; + /** @enum {string} */ + provider: "demo" | "proxmox"; + endpoint: string; + app: string; + width: number; + height: number; + fps: number; + /** Format: date-time */ + updatedAt: string; + poolId: string | null; + setupRequired: boolean; + powerOnConnect: boolean; + }; + DeviceDesktopList: { + items: components["schemas"]["DeviceDesktop"][]; + total: number; + page: number; + pageSize: number; + }; + GPUPrepareInput: { + desktopId: string; + }; + GPUPrepareResult: { + taskId: string; + /** @enum {string} */ + status: "queued" | "running" | "success" | "failed" | "timeout"; + message: string; + }; + GPUReconcileResult: { + reconciled: boolean; + vmid: string; + }; + DiagnosticSubject: { + /** @enum {string} */ + kind: "terminal" | "desktop"; + id: string; + name: string; + state: string; + node: string; + vmid: number; + agentVersion: string; + /** Format: date-time */ + lastSeen: string | null; + }; + DiagnosticEvent: { + id: string; + /** @enum {string} */ + source: "tasks" | "audits" | "sessions" | "alerts" | "logs"; + /** Format: date-time */ + time: string; + action: string; + status: string; + targetId: string; + targetName: string; + actor: string; + message: string; + details: string; + }; + DiagnosticReport: { + subject: components["schemas"]["DiagnosticSubject"]; + /** Format: date-time */ + from: string; + /** Format: date-time */ + to: string; + /** Format: date-time */ + observedAt: string; + items: components["schemas"]["DiagnosticEvent"][]; + counts: { + tasks: number; + audits: number; + sessions: number; + alerts: number; + logs: number; + }; + total: number; + page: number; + pageSize: number; + source: string; + notes: string[]; + }; DesktopList: { items: components["schemas"]["Desktop"][]; page: number; @@ -2083,6 +2421,13 @@ export interface components { total: number; requestId: string; }; + AdministratorSessionList: { + items: components["schemas"]["AdministratorSession"][]; + page: number; + pageSize: number; + total: number; + requestId: string; + }; TerminalPolicyList: { items: components["schemas"]["TerminalPolicy"][]; page: number; @@ -5925,6 +6270,525 @@ export interface operations { }; }; }; + mfaStatus: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MFAStatus"]; + }; + }; + /** @description Request failed */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + disableMFA: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MFACredential"]; + }; + }; + responses: { + /** @description Successful response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Request failed */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + beginMFA: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MFASetupInput"]; + }; + }; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MFASetup"]; + }; + }; + /** @description Request failed */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + confirmMFA: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MFAConfirm"]; + }; + }; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MFARecovery"]; + }; + }; + /** @description Request failed */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + rotateMFARecovery: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MFACredential"]; + }; + }; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MFARecovery"]; + }; + }; + /** @description Request failed */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; administrators: { parameters: { query?: { @@ -6348,6 +7212,216 @@ export interface operations { }; }; }; + administratorSessions: { + parameters: { + query?: { + page?: number; + pageSize?: number; + keyword?: string; + status?: string; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdministratorSessionList"]; + }; + }; + /** @description Request failed */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + revokeAdministratorSession: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + sessionKey: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Request failed */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; overview: { parameters: { query?: never; @@ -10041,10 +11115,18 @@ export interface operations { tasks: { parameters: { query?: { + from?: string; + to?: string; page?: number; pageSize?: number; keyword?: string; status?: string; + actor?: string; + action?: string; + kind?: string; + targetId?: string; + /** @description true includes queued, running and unknown outcomes (timeout) */ + active?: boolean; }; header?: never; path?: never; @@ -10144,6 +11226,128 @@ export interface operations { }; }; }; + exportTasks: { + parameters: { + query?: { + from?: string; + to?: string; + actor?: string; + action?: string; + kind?: string; + targetId?: string; + /** @description true includes queued, running and unknown outcomes (timeout) */ + active?: boolean; + keyword?: string; + status?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Complete filtered CSV; maximum 10000 rows. UTC times, raw action/status codes, no provider payload or idempotency keys. */ + 200: { + headers: { + "X-Export-Count"?: number; + [name: string]: unknown; + }; + content: { + "text/csv": string; + }; + }; + /** @description Request failed */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Over 10000 matching tasks */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; task: { parameters: { query?: never; @@ -10453,13 +11657,243 @@ export interface operations { }; }; }; + diagnostics: { + parameters: { + query: { + targetType: "terminal" | "desktop"; + targetId: string; + from?: string; + to?: string; + source?: "" | "tasks" | "audits" | "sessions" | "alerts" | "logs"; + page?: number; + pageSize?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DiagnosticReport"]; + }; + }; + /** @description Request failed */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + exportDiagnostics: { + parameters: { + query: { + targetType: "terminal" | "desktop"; + targetId: string; + from?: string; + to?: string; + source?: "" | "tasks" | "audits" | "sessions" | "alerts" | "logs"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Scoped diagnostic archive */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/zip": string; + }; + }; + /** @description Request failed */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description More than 2000 matching events; narrow time range or source */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; audits: { parameters: { query?: { + from?: string; + to?: string; page?: number; pageSize?: number; keyword?: string; status?: string; + actor?: string; + action?: string; + result?: string; }; header?: never; path?: never; @@ -10559,6 +11993,124 @@ export interface operations { }; }; }; + exportAudits: { + parameters: { + query?: { + from?: string; + to?: string; + actor?: string; + action?: string; + result?: string; + keyword?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Complete filtered CSV, with UTC timestamps and raw action/result codes */ + 200: { + headers: { + "X-Export-Count"?: number; + [name: string]: unknown; + }; + content: { + "text/csv": string; + }; + }; + /** @description Request failed */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Over 10000 matching records; narrow the filter */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; registerDevice: { parameters: { query?: never; @@ -10890,7 +12442,318 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["DesktopList"]; + "application/json": components["schemas"]["DeviceDesktopList"]; + }; + }; + /** @description Request failed */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + prepareGPUDesktop: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["GPUPrepareInput"]; + }; + }; + responses: { + /** @description Successful response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GPUPrepareResult"]; + }; + }; + /** @description Request failed */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + gpuPrepareStatus: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GPUPrepareResult"]; + }; + }; + /** @description Request failed */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Request failed */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + reconcileGPUPrepare: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GPUReconcileResult"]; }; }; /** @description Request failed */ diff --git a/console/src/common/common.css b/console/src/common/common.css new file mode 100644 index 0000000..b47bcb4 --- /dev/null +++ b/console/src/common/common.css @@ -0,0 +1 @@ +.simple-stats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}.simple-stats .panel{padding:22px}.simple-stats span{color:var(--vdi-muted);font-size:13px}.simple-stats strong{display:block;font-size:32px;margin-top:14px}.simple-stats small{font-size:12px;color:var(--vdi-muted);font-weight:400}.simple-section{padding:24px;margin-top:22px}.simple-section h2{font-size:18px;margin-bottom:12px}.simple-shortcuts{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin-top:22px}.simple-shortcuts a{padding:22px;border:1px solid var(--vdi-border);border-radius:12px;background:var(--vdi-panel-2)}.simple-shortcuts strong{display:block;color:var(--vdi-accent);font-size:15px}.simple-shortcuts span{display:block;margin-top:12px;font-size:12px;color:var(--vdi-muted);line-height:1.8}.simple-issues{display:grid;gap:18px;margin:22px 0}.simple-issues a{font-size:14px}.simple-check{display:flex;align-items:flex-start;gap:14px;border-top:1px solid var(--vdi-border);padding:18px 0}.simple-check:first-of-type{margin-top:20px}.simple-check p{color:var(--vdi-muted);line-height:1.8;margin:7px 0 0;font-size:13px}.simple-check strong{font-size:14px}.simple-check .el-tag{flex-shrink:0}.simple-checks{display:grid;margin:20px 0;max-height:45vh;overflow:auto}.simple-checks .el-checkbox{white-space:normal;height:auto;min-height:36px}.simple-checks .el-checkbox__label{white-space:normal}.simple-plan{line-height:1.9;padding:16px;border-radius:12px;background:var(--vdi-panel-2);margin-top:16px}.simple-plan ul{padding-left:20px}.simple-row{display:flex;align-items:center;justify-content:space-between;gap:12px}.simple-section>.el-select{max-width:450px;margin-bottom:18px}@media(max-width:1000px){.simple-shortcuts{grid-template-columns:1fr}.simple-stats{gap:10px}.simple-stats .panel{padding:16px}.simple-stats strong{font-size:24px}}@media(max-width:550px){.simple-stats{grid-template-columns:1fr}.simple-stats strong{margin-top:8px}.simple-section{padding:18px}.simple-check{gap:10px}.simple-section h2{font-size:17px}} diff --git a/console/src/common/diagnosis.test.ts b/console/src/common/diagnosis.test.ts new file mode 100644 index 0000000..1a6c89d --- /dev/null +++ b/console/src/common/diagnosis.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'vitest' +import { diagnoseTerminal } from './diagnosis' +import type { Desktop, Terminal, User } from '../api' +const terminal = { id: 't', name: '盒子', status: 'online', enabled: true } as Terminal +const user = { id: 'u', displayName: '员工', enabled: true, terminalId: 't', desktopIds: ['d'] } as User +const desktop = { id: 'd', name: '桌面', powerState: 'running', serviceState: 'ready', setupRequired: false } as Desktop +describe('终端诊断的连接前提', () => { + test('运行但服务未检测时不能报告全部通过', () => { + const result = diagnoseTerminal(terminal, [user], [{ ...desktop, serviceState: 'unknown' }]) + expect(result.slice(-1)[0]?.ok).toBe(false) + expect(result.slice(-1)[0]?.detail).toContain('不代表一定不可用') + }) + test('已停用设备、员工与待初始化桌面分别说明', () => { + const result = diagnoseTerminal({ ...terminal, enabled: false }, [{ ...user, enabled: false }], [{ ...desktop, setupRequired: true }]) + expect(result.filter(c => !c.ok).map(c => c.title)).toEqual(['设备已启用', '员工已启用', '桌面']) + }) + test('缺少关联时不猜测使用人;失效桌面授权不能报告已分配', () => { + expect(diagnoseTerminal(terminal, [], [desktop])).toHaveLength(3) + expect(diagnoseTerminal(terminal, [user], []).slice(-1)[0]?.ok).toBe(false) + }) +}) diff --git a/console/src/common/diagnosis.ts b/console/src/common/diagnosis.ts new file mode 100644 index 0000000..1b332d9 --- /dev/null +++ b/console/src/common/diagnosis.ts @@ -0,0 +1,20 @@ +import type { Desktop, Terminal, User } from '../api' + +export interface Check { title: string; ok: boolean; detail: string } +export function diagnoseTerminal(terminal: Terminal, users: User[], desktops: Desktop[]): Check[] { + const user = users.find(u => u.terminalId === terminal.id) + const assigned = desktops.filter(d => user?.desktopIds.includes(d.id)) + const checks: Check[] = [ + { title: '设备已启用', ok: terminal.enabled !== false && terminal.status !== 'disabled', detail: terminal.enabled === false || terminal.status === 'disabled' ? '设备已停用,请联系管理员重新激活。' : '设备未被停用。' }, + { title: '设备在线', ok: terminal.status === 'online', detail: terminal.status === 'online' ? '平台收到设备在线心跳。' : terminal.status === 'unregistered' ? '设备尚未激活,请在盒子上配置登记时的激活信息。' : '未收到在线心跳,请检查盒子电源、网络和平台地址。' }, + { title: '已关联员工', ok: !!user, detail: user ? `使用人:${user.displayName}` : '请在员工与桌面中关联此设备。' }, + ] + if (!user) return checks + checks.push({ title: '员工已启用', ok: user.enabled, detail: user.enabled ? '员工可申请桌面连接。' : '员工已停用,请由管理员核查。' }) + checks.push({ title: '已分配桌面', ok: assigned.length > 0, detail: assigned.length ? `已分配 ${assigned.length} 台桌面。` : '请为员工分配已有桌面;失效授权需由管理员清理。' }) + for (const desktop of assigned) { + checks.push({ title: desktop.name, ok: desktop.powerState === 'running' && desktop.serviceState === 'ready' && !desktop.setupRequired, + detail: desktop.setupRequired ? '桌面仍需完成初始化,请联系运维。' : desktop.powerState !== 'running' ? '桌面尚未运行,请先开机。' : desktop.serviceState !== 'ready' ? '桌面已运行,但平台尚未确认连接服务就绪;不代表一定不可用,请检查连接配置或联系运维。' : '桌面运行且连接服务就绪;实际网络和画面仍需连接后验证。' }) + } + return checks +} diff --git a/console/src/components/AdministratorAccounts.vue b/console/src/components/AdministratorAccounts.vue index 893beaf..e95ff79 100644 --- a/console/src/components/AdministratorAccounts.vue +++ b/console/src/components/AdministratorAccounts.vue @@ -5,8 +5,10 @@ import { ElMessage } from 'element-plus' import { api, allPages, errorMessage, type AdministratorAccount, type UpdateAdministrator } from '../api' import { useAuth } from '../stores/auth' import { useAction } from '../composables/useAction' +import AdministratorSessions from './AdministratorSessions.vue' const auth = useAuth(), { confirm } = useAction() const items = ref([]), loading = ref(false), busy = ref(false), error = ref(''), formError = ref(''), keyword = ref('') +const sessionTarget = ref() const visible = ref(false), editing = ref(), resetTarget = ref() const form = reactive({ username: '', displayName: '', role: 'viewer' as AdministratorAccount['role'], enabled: true, password: '', confirmation: '' }) const reset = reactive({ password: '', confirmation: '' }) @@ -16,7 +18,7 @@ const passwordValid = (password: string, confirmation: string) => [...password]. const canSave = computed(() => !!form.displayName.trim() && (!!editing.value || (/^[a-zA-Z0-9][a-zA-Z0-9._-]{2,79}$/.test(form.username) && passwordValid(form.password, form.confirmation)))) async function load(quiet = false) { if (!quiet) loading.value = true; try { items.value = await allPages(api.administrators); error.value = '' } catch (e) { error.value = errorMessage(e) } finally { loading.value = false } } let timer: ReturnType -onMounted(() => { void load(); timer = setInterval(() => { if (!document.hidden && !busy.value && !visible.value && !resetTarget.value) void load(true) }, 5000) }) +onMounted(() => { void load(); timer = setInterval(() => { if (!document.hidden && !busy.value && !visible.value && !resetTarget.value && !sessionTarget.value) void load(true) }, 5000) }) onUnmounted(() => clearInterval(timer)) function edit(item?: AdministratorAccount) { editing.value = item; formError.value = ''; Object.assign(form, { username: item?.username || '', displayName: item?.displayName || '', role: item?.role || 'viewer', enabled: item?.enabled ?? true, password: '', confirmation: '' }); visible.value = true } watch(visible, open => { if (!open) { form.password = ''; form.confirmation = ''; formError.value = '' } }) @@ -44,10 +46,11 @@ async function resetPassword() {
- +

至少 12 个字符,UTF-8 编码不超过 72 字节。保存后该账号所有登录失效。

+ diff --git a/console/src/components/AuditHistory.vue b/console/src/components/AuditHistory.vue new file mode 100644 index 0000000..28c5164 --- /dev/null +++ b/console/src/components/AuditHistory.vue @@ -0,0 +1,70 @@ + + + diff --git a/console/src/components/MFADialog.vue b/console/src/components/MFADialog.vue new file mode 100644 index 0000000..017268e --- /dev/null +++ b/console/src/components/MFADialog.vue @@ -0,0 +1,87 @@ + + + diff --git a/console/src/components/TaskHistory.vue b/console/src/components/TaskHistory.vue new file mode 100644 index 0000000..e56fc1e --- /dev/null +++ b/console/src/components/TaskHistory.vue @@ -0,0 +1,94 @@ + + + diff --git a/console/src/layouts/ConsoleLayout.vue b/console/src/layouts/ConsoleLayout.vue index 848f3bf..0e65910 100644 --- a/console/src/layouts/ConsoleLayout.vue +++ b/console/src/layouts/ConsoleLayout.vue @@ -1,13 +1,24 @@ + diff --git a/console/src/views/CommonHelp.vue b/console/src/views/CommonHelp.vue new file mode 100644 index 0000000..70c5800 --- /dev/null +++ b/console/src/views/CommonHelp.vue @@ -0,0 +1,25 @@ + + diff --git a/console/src/views/CommonPeople.vue b/console/src/views/CommonPeople.vue new file mode 100644 index 0000000..697a5cd --- /dev/null +++ b/console/src/views/CommonPeople.vue @@ -0,0 +1,44 @@ + + diff --git a/console/src/views/CommonRecords.vue b/console/src/views/CommonRecords.vue new file mode 100644 index 0000000..1a8cd24 --- /dev/null +++ b/console/src/views/CommonRecords.vue @@ -0,0 +1,27 @@ + + diff --git a/console/src/views/CommonWorkbench.vue b/console/src/views/CommonWorkbench.vue new file mode 100644 index 0000000..4001611 --- /dev/null +++ b/console/src/views/CommonWorkbench.vue @@ -0,0 +1,16 @@ + + diff --git a/console/src/views/Desktops.vue b/console/src/views/Desktops.vue index 7d45f3e..e94b166 100644 --- a/console/src/views/Desktops.vue +++ b/console/src/views/Desktops.vue @@ -51,7 +51,7 @@ async function submitImport() { if (!form.name.trim() || !form.node) return; if - +

纳管不会修改 VM;使用 Moonlight 前仍需完成 Sunshine 配对。

取消确认纳管
已完成系统身份、IP、Sunshine 服务和终端配对

保存会使旧连接票据失效;有活动连接时请先结束会话。

diff --git a/console/src/views/Diagnostics.vue b/console/src/views/Diagnostics.vue new file mode 100644 index 0000000..0525262 --- /dev/null +++ b/console/src/views/Diagnostics.vue @@ -0,0 +1,94 @@ + + + diff --git a/console/src/views/Login.vue b/console/src/views/Login.vue index 1c06a56..df029f8 100644 --- a/console/src/views/Login.vue +++ b/console/src/views/Login.vue @@ -1,24 +1,25 @@ diff --git a/console/src/views/Settings.vue b/console/src/views/Settings.vue index 1d22734..17129cb 100644 --- a/console/src/views/Settings.vue +++ b/console/src/views/Settings.vue @@ -11,7 +11,7 @@ const planned = ['公共池自动领取与退出还原', '新桌面自动初始