diff --git a/Docs/Configuration.md b/Docs/Configuration.md index d06e153..db785cd 100644 --- a/Docs/Configuration.md +++ b/Docs/Configuration.md @@ -81,6 +81,11 @@ environment variables can contain secrets. Stored Quasar, server, and world backups are written to `Quasar:BackupDirectory`. Change it from **Backup → Stored backups**, or edit `appsettings.json` directly. +Quasar config backups contain Quasar-managed configuration/catalog files only; +server backups contain one server definition plus non-cache Dedicated Server and +Magnetar app data; world backups contain world save files. Restored server +definitions are written with `Off` goal state so they do not auto-start before +matching world files are restored. Leave it empty to use the default `Backups` folder under the Quasar data directory. Set it to an absolute path to place backups on another disk or a mounted network share: diff --git a/Docs/QuasarArchitecture.md b/Docs/QuasarArchitecture.md index ce7f029..3227c68 100644 --- a/Docs/QuasarArchitecture.md +++ b/Docs/QuasarArchitecture.md @@ -854,7 +854,7 @@ As of this document: - neutral light/dark theming exists with local-storage persistence - config editing is now migrated out of Python into Quasar-managed JSON profiles and rendered runtime artifacts. Profiles cover Quasar root settings, server password (rendered to DS-compatible hash/salt), and DS-visible SE session settings including block type world limits; on server start Quasar writes session settings and mods into the world's authoritative `Sandbox_config.sbc` as well as the runtime DS config. - file watching/reload now exists for manual edits to Quasar-managed server/profile JSON -- backup/restore now exists as versioned ZIP archives for Quasar configuration, server runtime state, and world-only data. Configuration backups cover servers, config profiles, world-template definitions, branding, and singleton settings files, with manual download/upload and semantic-version compatibility checks. The Backup page lists every configured server with per-row Back up server / Restore server / Back up world / Restore world actions; restore buttons use the latest matching stored archive for that server and backup kind. Automatic backup rules are configured separately for Quasar config, server backups, and world backups, each with its own schedule and retention. Server backups include server definition plus non-cache Dedicated Server and Magnetar app data; world backups restore world files while keeping existing config, using the latest Space Engineers `Backup` snapshot when present so backups can be taken while servers run. +- backup/restore now exists as versioned ZIP archives for Quasar configuration, server runtime state, and world-only data. Configuration backups cover servers, config profiles, world-template definitions, branding, and singleton settings files, with manual download/upload and semantic-version compatibility checks. The Backup page lists every configured server with per-row Back up server / Restore server / Back up world / Restore world actions; restore buttons use the latest matching stored archive for that server and backup kind. Automatic backup rules are configured separately for Quasar config, server backups, and world backups, each with its own schedule and retention. Quasar config backups include Quasar-managed catalog/config files only; server backups include the server definition plus non-cache Dedicated Server and Magnetar app data but not world saves; world backups include world save files and keep existing server/world config, using the latest Space Engineers `Backup` snapshot when present so backups can be taken while servers run. Restored server definitions from config or server backups are forced to `Off` goal state so restore cannot trigger a failed start loop before matching world files are restored. - per-server CPU affinity pinning now exists (cpuset strings applied via `taskset` on Linux and `Process.ProcessorAffinity` on Windows), enforced by the supervisor on process start and reconcile alongside process priority; Linux priority elevation can use the optional setuid `/usr/local/bin/quasar-renice` helper instead of granting `CAP_SYS_NICE` to the whole Quasar service - per-server managed .NET runtime selection now exists on Windows, where Quasar installs both Magnetar builds side-by-side (`MagnetarInterim.exe` on .NET 10, the default, and `MagnetarLegacy.exe` on .NET Framework 4.8) and the runtime resolver launches the build chosen by `DedicatedServerDefinition.ManagedRuntime`; non-Windows hosts always run the .NET 10 build - runtime config preparation now derives a unique `SteamPort` (`ServerPort + 1000`) and `RemoteApiPort` (`ServerPort + 2000`) per server so multiple servers co-hosted on one machine never collide on the SE defaults (8766 / 8080) diff --git a/Docs/Reference/Index.md b/Docs/Reference/Index.md index d0bf84d..bd65a7f 100644 --- a/Docs/Reference/Index.md +++ b/Docs/Reference/Index.md @@ -41,7 +41,7 @@ Every documented source file (209 total), alphabetical by path. See the [TOC](TO | [Quasar.Agent/AgentProfilerPatches.cs](files/Quasar.Agent/AgentProfilerPatches.cs.md) | [Quasar.Agent](Modules/Quasar.Agent.md) | class | Mode-aware Harmony patch registrar for Quasar's profiler telemetry. `SafeContinuous` patches only named high-level Space Engineers server methods for low-overhead continuous timing. `DeepContinuous` keeps those patches and adds detailed network-event method hooks plus IL call-site transpilers for session components, replication simulation, entity update dispatch, parallel waits/callbacks, and physics stepping internals. `Off` skips profiler patches. | | [Quasar.Agent/AgentProfilerTranspiler.cs](files/Quasar.Agent/AgentProfilerTranspiler.cs.md) | [Quasar.Agent](Modules/Quasar.Agent.md) | class | Generic Harmony `CodeInstruction` transpiler used by deep profiler mode. It wraps selected `call` / `callvirt` instructions with `AgentProfiler.BeginCallSite` and `AgentProfiler.EndCallSite`, giving Quasar-native call-site attribution without external patch-manager MSIL helpers. | | [Quasar.Agent/EntityInspector.cs](files/Quasar.Agent/EntityInspector.cs.md) | [Quasar.Agent](Modules/Quasar.Agent.md) | class | `EntityInspector` is an internal static helper that queries and manipulates live `MyEntity` instances on the game thread, mapping them to transport-friendly `EntitySummary` DTOs for the Quasar admin UI. It supports paginated, filtered entity listing and direct entity deletion. | -| [Quasar.Agent/GameBridge.cs](files/Quasar.Agent/GameBridge.cs.md) | [Quasar.Agent](Modules/Quasar.Agent.md) | class | `GameBridge` is the central game-thread façade for `AgentConnection`. It collects session telemetry (metrics, current profiler mode/snapshot, human players, hidden NPC/bot player ids, kicked players, chat, deaths, plugins), builds `AgentHello` / `AgentSnapshot` wire messages, and executes server commands (chat, blocking save, save-and-stop, profiler mode change, kick, ban, promote, clear-kick-cooldown, entity list/delete) by marshalling work onto the game thread via `MySandboxGame.Invoke`. Save/stop commands route through Magnetar PluginSdk `ServerControl` so Quasar observes completed disk saves before treating the command as successful. Metrics include process CPU derived from `Process.TotalProcessorTime`, simspeed/sim CPU from `Sync`, memory, human player count, PCU, active entities/grids, total blocks, and floating objects. It enumerates loaded plugins from `MyPlugins.Plugins` (including Pulsar child plugins) for runtime inventory, dedupes configured fallback plugin paths against loaded plugins by path stem, parent dev-folder name, manifest ``, and manifest ``, and exposes plugin configuration through `IQuasarConfigProvider` or Magnetar PluginSdk `PluginConfig` reflection. Chat history normalizes dedicated-server/Good.bot messages to author `Server` and marks `ChatMessageSnapshot.IsServerMessage`. | +| [Quasar.Agent/GameBridge.cs](files/Quasar.Agent/GameBridge.cs.md) | [Quasar.Agent](Modules/Quasar.Agent.md) | class | `GameBridge` is the central game-thread façade for `AgentConnection`. It collects session telemetry (metrics, current profiler mode/snapshot, human players, hidden NPC/bot player ids, kicked players, chat, deaths, plugins), builds `AgentHello` / `AgentSnapshot` wire messages, and executes server commands (chat, blocking save, save-and-stop, profiler mode change, kick, ban, promote, clear-kick-cooldown, entity list/delete) by marshalling work onto the game thread via `MySandboxGame.Invoke`. Save/stop commands route through Magnetar PluginSdk `ServerControl` so Quasar observes completed disk saves before treating the command as successful. Metrics include process CPU derived from `Process.TotalProcessorTime`, simspeed/sim CPU from `Sync`, memory, human player count, PCU, active entities/grids, total blocks, floating objects, latest world-save time, and unsaved game-time progress since the last checkpoint. It enumerates loaded plugins from `MyPlugins.Plugins` (including Pulsar child plugins) for runtime inventory, dedupes configured fallback plugin paths against loaded plugins by path stem, parent dev-folder name, manifest ``, and manifest ``, and exposes plugin configuration through `IQuasarConfigProvider` or Magnetar PluginSdk `PluginConfig` reflection. Chat history normalizes dedicated-server/Good.bot messages to author `Server` and marks `ChatMessageSnapshot.IsServerMessage`. | | [Quasar.Agent/PluginLogOutbox.cs](files/Quasar.Agent/PluginLogOutbox.cs.md) | [Quasar.Agent](Modules/Quasar.Agent.md) | class | Bounded, thread-safe buffer that captures plugin log lines emitted in-process by the PluginSdk Quasar log sink and hands them to `AgentConnection` in batches for streaming to Quasar (as `PluginLogBatch` / `WireMessageKind.PluginLogs`). The buffer survives Quasar outages: lines accumulate while disconnected and are flushed on reconnect, so the supervisor's "Recent plugin logs" panel is backfilled rather than losing everything captured while Quasar was down. | | [Quasar.Agent/Quasar.Agent.csproj](files/Quasar.Agent/Quasar.Agent.csproj.md) | [Quasar.Agent](Modules/Quasar.Agent.md) | project file | MSBuild project file for `Quasar.Agent`, a `netstandard2.0` class library (x64-only) that produces `Quasar.Agent.dll` — the Magnetar/Space Engineers plugin assembly. All game and PluginSdk references are `Private="False"` (provided by the host at runtime). Harmony is a package dependency because the agent applies profiler patches in-process. | | [Quasar.Agent/StopCommand.cs](files/Quasar.Agent/StopCommand.cs.md) | [Quasar.Agent](Modules/Quasar.Agent.md) | class | `StopCommand` is a Quasar-owned PluginSdk command module for the root `!stop` in-game admin command. It overrides Magnetar's earlier `stop` root by being registered later from `AdminPlugin`, acknowledges the caller, reports an admin stop to Quasar through a static hook wired by `AdminPlugin`, then calls `ServerControl.SaveAndQuit()` on a worker task so the world is saved and the dedicated server process exits. | @@ -51,7 +51,7 @@ Every documented source file (209 total), alphabetical by path. See the [TOC](TO | [Quasar.Bootstrap/Quasar.Bootstrap.csproj](files/Quasar.Bootstrap/Quasar.Bootstrap.csproj.md) | [Quasar.Bootstrap](Modules/Quasar.Bootstrap.md) | project file | MSBuild project file for `Quasar.Bootstrap`, a `net10.0` console executable that targets `linux-x64` and `win-x64`. RID-targeted publish restores and publishes the `Quasar` worker as a single-file sub-app into a `WebService/` subfolder. | | [Quasar/Components/App.razor](files/Quasar/Components/App.razor.md) | [Quasar.Components](Modules/Quasar.Components.md) | Blazor component | The root HTML document component for the Blazor Server application. It renders the full `` skeleton, wires MudBlazor, ApexCharts and app CSS, loads the Blazor WebAssembly/server JS runtime, and hosts `` and `` as the two top-level interactive components. | | [Quasar/Components/Dashboard/ServerCard.razor](files/Quasar/Components/Dashboard/ServerCard.razor.md) | [Quasar.Components](Modules/Quasar.Components.md) | Blazor component | Card component for a single managed server shown on the Dashboard card layout. Displays the server display name, status chip (OFF / STARTING / CONNECTING / OPEN / STOPPING / RESTARTING / CRASHED / FAULTED), host/world caption, last message or health summary, management icon buttons (console, clone, template, edit, delete), lifecycle action buttons, and `ServerDetailPanel` body content. The Start button can be disabled by the dashboard while managed runtime prerequisites are still preparing. | -| [Quasar/Components/Dashboard/ServerDetailPanel.razor](files/Quasar/Components/Dashboard/ServerDetailPanel.razor.md) | [Quasar.Components](Modules/Quasar.Components.md) | Blazor component | Detail panel embedded inside `ServerCard`. When the agent snapshot is absent it shows a waiting/error message and basic process state chips. When a snapshot is present it renders live metrics chips, Refresh/Save buttons, a chat broadcast field, a players table with player identity/status columns and a rightmost unlabeled action menu column, a recent-chat list, and recent command results. The Plugins chip compares loaded runtime plugins against the assigned config profile's selected plugins, displays `loaded/total`, and turns warning-colored when the loaded count differs from the configured total; if no profile is available it falls back to the aggregate agent plugin metric. Server-authored chat (`IsServerMessage`, SteamId 0, `Good.bot`, or `Server`) is displayed as `Server`. In both the snapshot-present and snapshot-absent states, an outlined "Affinity " chip (Memory icon) is shown in the metrics chip rows when `Server.CpuAffinity` is set, and mod-download failures captured from runtime output are shown as an explicit error alert. | +| [Quasar/Components/Dashboard/ServerDetailPanel.razor](files/Quasar/Components/Dashboard/ServerDetailPanel.razor.md) | [Quasar.Components](Modules/Quasar.Components.md) | Blazor component | Detail panel embedded inside `ServerCard`. When the agent snapshot is absent it shows a waiting/error message and basic process state chips. When a snapshot is present it renders live metrics chips, Refresh/Save buttons, a chat broadcast field, a players table with player identity/status columns and a rightmost unlabeled action menu column, a recent-chat list, and recent command results. The Plugins chip compares loaded runtime plugins against the assigned config profile's selected plugins, displays `loaded/total`, and turns warning-colored when the loaded count differs from the configured total; if no profile is available it falls back to the aggregate agent plugin metric. The Save chip shows save-in-progress state, or the latest world-save local time plus unsaved in-game progress as `MM:SS`, with a tooltip containing the full local timestamp. Server-authored chat (`IsServerMessage`, SteamId 0, `Good.bot`, or `Server`) is displayed as `Server`. In both the snapshot-present and snapshot-absent states, an outlined "Affinity " chip (Memory icon) is shown in the metrics chip rows when `Server.CpuAffinity` is set, and mod-download failures captured from runtime output are shown as an explicit error alert. | | [Quasar/Components/Layout/BrandingHeadContent.razor](files/Quasar/Components/Layout/BrandingHeadContent.razor.md) | [Quasar.Components](Modules/Quasar.Components.md) | Blazor component | A lightweight head-content component that reactively updates the page favicon whenever `BrandingService` fires its `Changed` event. Rendered inside `MainLayout` so the favicon tracks live branding changes without a full page reload. | | [Quasar/Components/Layout/MainLayout.razor](files/Quasar/Components/Layout/MainLayout.razor.md) | [Quasar.Components](Modules/Quasar.Components.md) | Blazor component | Top-level application shell layout. Provides the MudBlazor theme/provider setup, theme-configured hover-list CSS variables, a responsive app bar with branding, update notification bell, theme-mode switcher, auth (login/logout) controls, and a Quasar power dialog trigger. It hosts a collapsible side drawer with `NavMenu`, the main content area that renders `@Body`, and a full-screen overlay while Quasar is restarting or shutting down. | | [Quasar/Components/Layout/MainLayout.razor.css](files/Quasar/Components/Layout/MainLayout.razor.css.md) | [Quasar.Components](Modules/Quasar.Components.md) | CSS | Scoped CSS for `MainLayout.razor`. Styles the brand logo mark in the app bar and the Blazor framework error banner. | @@ -150,7 +150,7 @@ Every documented source file (209 total), alphabetical by path. See the [TOC](TO | [Quasar/Services/Backup/AutomaticBackupService.cs](files/Quasar/Services/Backup/AutomaticBackupService.cs.md) | [Quasar.Services.Core](Modules/Quasar.Services.Core.md) | class | Background scheduler and manual backup queue that writes Quasar config, server, and world backups into the configured backup directory. Scheduled runs follow separate `QuasarBackupSettings` rules and prune to each rule's retention count; manual UI starts are queued so Blazor page navigation is not blocked by ZIP creation. | | [Quasar/Services/Backup/BackupCompatibility.cs](files/Quasar/Services/Backup/BackupCompatibility.cs.md) | [Quasar.Services.Core](Modules/Quasar.Services.Core.md) | class (static) + record struct | Applies semantic-versioning rules deciding whether a backup may restore into the running Quasar. Same `Major.Minor` is always allowed (patch may differ in either direction); an older `Major.Minor` is allowed only if a forward migration path exists; a newer `Major.Minor` is rejected (no cross-major.minor downgrade). | | [Quasar/Services/Backup/BackupFormatMigrations.cs](files/Quasar/Services/Backup/BackupFormatMigrations.cs.md) | [Quasar.Services.Core](Modules/Quasar.Services.Core.md) | class (static) | Registry of forward upgrade steps that migrate backup contents from one major.minor release to the next. The registry is currently empty, so only same-major.minor restores are accepted today. | -| [Quasar/Services/Backup/QuasarBackupService.cs](files/Quasar/Services/Backup/QuasarBackupService.cs.md) | [Quasar.Services.Core](Modules/Quasar.Services.Core.md) | class | Builds and restores ZIP backups for three scopes: Quasar configuration, server runtime state, and world-only data. Configuration backups still capture Quasar's own singleton/config/catalog files, including known-player rows, known-player retention settings, and data-handling consent; server backups include the server definition plus non-cache Dedicated Server and Magnetar app data; world backups restore world files while excluding `Sandbox_config.sbc*`. Stored backup writes use the configured `WebServiceOptions.BackupDirectory` and publish atomically by writing `final.zip.tmp` in that same directory first, then renaming it to `final.zip` only after the archive is complete. | +| [Quasar/Services/Backup/QuasarBackupService.cs](files/Quasar/Services/Backup/QuasarBackupService.cs.md) | [Quasar.Services.Core](Modules/Quasar.Services.Core.md) | class | Builds and restores ZIP backups for three scopes: Quasar configuration, server runtime state, and world-only data. Configuration backups still capture Quasar's own singleton/config/catalog files, including known-player rows, known-player retention settings, and data-handling consent; server backups include the server definition plus non-cache Dedicated Server and Magnetar app data; world backups restore world files while excluding `Sandbox_config.sbc*`. Restored server definitions are rewritten with `GoalState = Off` and `AutoStart = false`, including definitions restored through a Quasar configuration backup. Stored backup writes use the configured `WebServiceOptions.BackupDirectory` and publish atomically by writing `final.zip.tmp` in that same directory first, then renaming it to `final.zip` only after the archive is complete. | | [Quasar/Services/Backup/QuasarBackupSettingsService.cs](files/Quasar/Services/Backup/QuasarBackupSettingsService.cs.md) | [Quasar.Services.Core](Modules/Quasar.Services.Core.md) | class | Singleton store for automatic-backup rules and the stored-backup folder setting. Persists schedule rules to `backup-settings.json` (`MagnetarPaths.GetQuasarBackupSettingsPath()`) in the Quasar data directory and picks up external schedule edits via a debounced (250 ms) `FileSystemWatcher`, mirroring `BrandingService`. It also patches `Quasar:BackupDirectory` in the data-directory `appsettings.json` for the Backup page and applies the resolved path to the live `WebServiceOptions`. | | [Quasar/Services/Backup/ServerRestoreCoordinator.cs](files/Quasar/Services/Backup/ServerRestoreCoordinator.cs.md) | [Quasar.Services.Core](Modules/Quasar.Services.Core.md) | class | `ServerRestoreCoordinator` tracks which managed server unique names currently have a backup restore in progress. Restores rewrite server files in place, so callers can use this coordinator to prevent a server start or a second restore from racing against the same server data. | | [Quasar/Services/BrandingPresets.cs](files/Quasar/Services/BrandingPresets.cs.md) | [Quasar.Services.Core](Modules/Quasar.Services.Core.md) | class | `BrandingPresets` is a static catalogue of the four built-in UI theme presets (Quasar Default, Midnight Blue, Slate, High Contrast). It exposes `GetLightPalette` / `GetDarkPalette` factory methods that layer identity/surface colour overrides on top of the base `ThemePalette.QuasarLight()` / `QuasarDark()` palettes, keeping all variants internally coherent. `BrandingPresetDefinition` is the companion display record. | diff --git a/Docs/Reference/Modules/Quasar.Agent.md b/Docs/Reference/Modules/Quasar.Agent.md index 108a2d4..be181ab 100644 --- a/Docs/Reference/Modules/Quasar.Agent.md +++ b/Docs/Reference/Modules/Quasar.Agent.md @@ -16,7 +16,7 @@ The plugin loaded inside each Space Engineers Dedicated Server (`netstandard2.0` | [Quasar.Agent/AgentProfilerPatches.cs](../files/Quasar.Agent/AgentProfilerPatches.cs.md) | class | Mode-aware Harmony patch registrar for Quasar's profiler telemetry. `SafeContinuous` patches only named high-level Space Engineers server methods for low-overhead continuous timing. `DeepContinuous` keeps those patches and adds detailed network-event method hooks plus IL call-site transpilers for session components, replication simulation, entity update dispatch, parallel waits/callbacks, and physics stepping internals. `Off` skips profiler patches. | | [Quasar.Agent/AgentProfilerTranspiler.cs](../files/Quasar.Agent/AgentProfilerTranspiler.cs.md) | class | Generic Harmony `CodeInstruction` transpiler used by deep profiler mode. It wraps selected `call` / `callvirt` instructions with `AgentProfiler.BeginCallSite` and `AgentProfiler.EndCallSite`, giving Quasar-native call-site attribution without external patch-manager MSIL helpers. | | [Quasar.Agent/EntityInspector.cs](../files/Quasar.Agent/EntityInspector.cs.md) | class | `EntityInspector` is an internal static helper that queries and manipulates live `MyEntity` instances on the game thread, mapping them to transport-friendly `EntitySummary` DTOs for the Quasar admin UI. It supports paginated, filtered entity listing and direct entity deletion. | -| [Quasar.Agent/GameBridge.cs](../files/Quasar.Agent/GameBridge.cs.md) | class | `GameBridge` is the central game-thread façade for `AgentConnection`. It collects session telemetry (metrics, current profiler mode/snapshot, human players, hidden NPC/bot player ids, kicked players, chat, deaths, plugins), builds `AgentHello` / `AgentSnapshot` wire messages, and executes server commands (chat, blocking save, save-and-stop, profiler mode change, kick, ban, promote, clear-kick-cooldown, entity list/delete) by marshalling work onto the game thread via `MySandboxGame.Invoke`. Save/stop commands route through Magnetar PluginSdk `ServerControl` so Quasar observes completed disk saves before treating the command as successful. Metrics include process CPU derived from `Process.TotalProcessorTime`, simspeed/sim CPU from `Sync`, memory, human player count, PCU, active entities/grids, total blocks, and floating objects. It enumerates loaded plugins from `MyPlugins.Plugins` (including Pulsar child plugins) for runtime inventory, dedupes configured fallback plugin paths against loaded plugins by path stem, parent dev-folder name, manifest ``, and manifest ``, and exposes plugin configuration through `IQuasarConfigProvider` or Magnetar PluginSdk `PluginConfig` reflection. Chat history normalizes dedicated-server/Good.bot messages to author `Server` and marks `ChatMessageSnapshot.IsServerMessage`. | +| [Quasar.Agent/GameBridge.cs](../files/Quasar.Agent/GameBridge.cs.md) | class | `GameBridge` is the central game-thread façade for `AgentConnection`. It collects session telemetry (metrics, current profiler mode/snapshot, human players, hidden NPC/bot player ids, kicked players, chat, deaths, plugins), builds `AgentHello` / `AgentSnapshot` wire messages, and executes server commands (chat, blocking save, save-and-stop, profiler mode change, kick, ban, promote, clear-kick-cooldown, entity list/delete) by marshalling work onto the game thread via `MySandboxGame.Invoke`. Save/stop commands route through Magnetar PluginSdk `ServerControl` so Quasar observes completed disk saves before treating the command as successful. Metrics include process CPU derived from `Process.TotalProcessorTime`, simspeed/sim CPU from `Sync`, memory, human player count, PCU, active entities/grids, total blocks, floating objects, latest world-save time, and unsaved game-time progress since the last checkpoint. It enumerates loaded plugins from `MyPlugins.Plugins` (including Pulsar child plugins) for runtime inventory, dedupes configured fallback plugin paths against loaded plugins by path stem, parent dev-folder name, manifest ``, and manifest ``, and exposes plugin configuration through `IQuasarConfigProvider` or Magnetar PluginSdk `PluginConfig` reflection. Chat history normalizes dedicated-server/Good.bot messages to author `Server` and marks `ChatMessageSnapshot.IsServerMessage`. | | [Quasar.Agent/PluginLogOutbox.cs](../files/Quasar.Agent/PluginLogOutbox.cs.md) | class | Bounded, thread-safe buffer that captures plugin log lines emitted in-process by the PluginSdk Quasar log sink and hands them to `AgentConnection` in batches for streaming to Quasar (as `PluginLogBatch` / `WireMessageKind.PluginLogs`). The buffer survives Quasar outages: lines accumulate while disconnected and are flushed on reconnect, so the supervisor's "Recent plugin logs" panel is backfilled rather than losing everything captured while Quasar was down. | | [Quasar.Agent/Quasar.Agent.csproj](../files/Quasar.Agent/Quasar.Agent.csproj.md) | project file | MSBuild project file for `Quasar.Agent`, a `netstandard2.0` class library (x64-only) that produces `Quasar.Agent.dll` — the Magnetar/Space Engineers plugin assembly. All game and PluginSdk references are `Private="False"` (provided by the host at runtime). Harmony is a package dependency because the agent applies profiler patches in-process. | | [Quasar.Agent/StopCommand.cs](../files/Quasar.Agent/StopCommand.cs.md) | class | `StopCommand` is a Quasar-owned PluginSdk command module for the root `!stop` in-game admin command. It overrides Magnetar's earlier `stop` root by being registered later from `AdminPlugin`, acknowledges the caller, reports an admin stop to Quasar through a static hook wired by `AdminPlugin`, then calls `ServerControl.SaveAndQuit()` on a worker task so the world is saved and the dedicated server process exits. | diff --git a/Docs/Reference/Modules/Quasar.Components.md b/Docs/Reference/Modules/Quasar.Components.md index bb9458c..37dd233 100644 --- a/Docs/Reference/Modules/Quasar.Components.md +++ b/Docs/Reference/Modules/Quasar.Components.md @@ -10,7 +10,7 @@ The Blazor Server user interface, built with MudBlazor. Routable pages cover the | --- | --- | --- | | [Quasar/Components/App.razor](../files/Quasar/Components/App.razor.md) | Blazor component | The root HTML document component for the Blazor Server application. It renders the full `` skeleton, wires MudBlazor, ApexCharts and app CSS, loads the Blazor WebAssembly/server JS runtime, and hosts `` and `` as the two top-level interactive components. | | [Quasar/Components/Dashboard/ServerCard.razor](../files/Quasar/Components/Dashboard/ServerCard.razor.md) | Blazor component | Card component for a single managed server shown on the Dashboard card layout. Displays the server display name, status chip (OFF / STARTING / CONNECTING / OPEN / STOPPING / RESTARTING / CRASHED / FAULTED), host/world caption, last message or health summary, management icon buttons (console, clone, template, edit, delete), lifecycle action buttons, and `ServerDetailPanel` body content. The Start button can be disabled by the dashboard while managed runtime prerequisites are still preparing. | -| [Quasar/Components/Dashboard/ServerDetailPanel.razor](../files/Quasar/Components/Dashboard/ServerDetailPanel.razor.md) | Blazor component | Detail panel embedded inside `ServerCard`. When the agent snapshot is absent it shows a waiting/error message and basic process state chips. When a snapshot is present it renders live metrics chips, Refresh/Save buttons, a chat broadcast field, a players table with player identity/status columns and a rightmost unlabeled action menu column, a recent-chat list, and recent command results. The Plugins chip compares loaded runtime plugins against the assigned config profile's selected plugins, displays `loaded/total`, and turns warning-colored when the loaded count differs from the configured total; if no profile is available it falls back to the aggregate agent plugin metric. Server-authored chat (`IsServerMessage`, SteamId 0, `Good.bot`, or `Server`) is displayed as `Server`. In both the snapshot-present and snapshot-absent states, an outlined "Affinity " chip (Memory icon) is shown in the metrics chip rows when `Server.CpuAffinity` is set, and mod-download failures captured from runtime output are shown as an explicit error alert. | +| [Quasar/Components/Dashboard/ServerDetailPanel.razor](../files/Quasar/Components/Dashboard/ServerDetailPanel.razor.md) | Blazor component | Detail panel embedded inside `ServerCard`. When the agent snapshot is absent it shows a waiting/error message and basic process state chips. When a snapshot is present it renders live metrics chips, Refresh/Save buttons, a chat broadcast field, a players table with player identity/status columns and a rightmost unlabeled action menu column, a recent-chat list, and recent command results. The Plugins chip compares loaded runtime plugins against the assigned config profile's selected plugins, displays `loaded/total`, and turns warning-colored when the loaded count differs from the configured total; if no profile is available it falls back to the aggregate agent plugin metric. The Save chip shows save-in-progress state, or the latest world-save local time plus unsaved in-game progress as `MM:SS`, with a tooltip containing the full local timestamp. Server-authored chat (`IsServerMessage`, SteamId 0, `Good.bot`, or `Server`) is displayed as `Server`. In both the snapshot-present and snapshot-absent states, an outlined "Affinity " chip (Memory icon) is shown in the metrics chip rows when `Server.CpuAffinity` is set, and mod-download failures captured from runtime output are shown as an explicit error alert. | | [Quasar/Components/Layout/BrandingHeadContent.razor](../files/Quasar/Components/Layout/BrandingHeadContent.razor.md) | Blazor component | A lightweight head-content component that reactively updates the page favicon whenever `BrandingService` fires its `Changed` event. Rendered inside `MainLayout` so the favicon tracks live branding changes without a full page reload. | | [Quasar/Components/Layout/MainLayout.razor](../files/Quasar/Components/Layout/MainLayout.razor.md) | Blazor component | Top-level application shell layout. Provides the MudBlazor theme/provider setup, theme-configured hover-list CSS variables, a responsive app bar with branding, update notification bell, theme-mode switcher, auth (login/logout) controls, and a Quasar power dialog trigger. It hosts a collapsible side drawer with `NavMenu`, the main content area that renders `@Body`, and a full-screen overlay while Quasar is restarting or shutting down. | | [Quasar/Components/Layout/MainLayout.razor.css](../files/Quasar/Components/Layout/MainLayout.razor.css.md) | CSS | Scoped CSS for `MainLayout.razor`. Styles the brand logo mark in the app bar and the Blazor framework error banner. | diff --git a/Docs/Reference/Modules/Quasar.Services.Core.md b/Docs/Reference/Modules/Quasar.Services.Core.md index 2b50a1c..c3d97d1 100644 --- a/Docs/Reference/Modules/Quasar.Services.Core.md +++ b/Docs/Reference/Modules/Quasar.Services.Core.md @@ -14,7 +14,7 @@ The heart of the supervisor and its supporting services. `DedicatedServerSupervi | [Quasar/Services/Backup/AutomaticBackupService.cs](../files/Quasar/Services/Backup/AutomaticBackupService.cs.md) | class | Background scheduler and manual backup queue that writes Quasar config, server, and world backups into the configured backup directory. Scheduled runs follow separate `QuasarBackupSettings` rules and prune to each rule's retention count; manual UI starts are queued so Blazor page navigation is not blocked by ZIP creation. | | [Quasar/Services/Backup/BackupCompatibility.cs](../files/Quasar/Services/Backup/BackupCompatibility.cs.md) | class (static) + record struct | Applies semantic-versioning rules deciding whether a backup may restore into the running Quasar. Same `Major.Minor` is always allowed (patch may differ in either direction); an older `Major.Minor` is allowed only if a forward migration path exists; a newer `Major.Minor` is rejected (no cross-major.minor downgrade). | | [Quasar/Services/Backup/BackupFormatMigrations.cs](../files/Quasar/Services/Backup/BackupFormatMigrations.cs.md) | class (static) | Registry of forward upgrade steps that migrate backup contents from one major.minor release to the next. The registry is currently empty, so only same-major.minor restores are accepted today. | -| [Quasar/Services/Backup/QuasarBackupService.cs](../files/Quasar/Services/Backup/QuasarBackupService.cs.md) | class | Builds and restores ZIP backups for three scopes: Quasar configuration, server runtime state, and world-only data. Configuration backups still capture Quasar's own singleton/config/catalog files, including known-player rows, known-player retention settings, and data-handling consent; server backups include the server definition plus non-cache Dedicated Server and Magnetar app data; world backups restore world files while excluding `Sandbox_config.sbc*`. Stored backup writes use the configured `WebServiceOptions.BackupDirectory` and publish atomically by writing `final.zip.tmp` in that same directory first, then renaming it to `final.zip` only after the archive is complete. | +| [Quasar/Services/Backup/QuasarBackupService.cs](../files/Quasar/Services/Backup/QuasarBackupService.cs.md) | class | Builds and restores ZIP backups for three scopes: Quasar configuration, server runtime state, and world-only data. Configuration backups still capture Quasar's own singleton/config/catalog files, including known-player rows, known-player retention settings, and data-handling consent; server backups include the server definition plus non-cache Dedicated Server and Magnetar app data; world backups restore world files while excluding `Sandbox_config.sbc*`. Restored server definitions are rewritten with `GoalState = Off` and `AutoStart = false`, including definitions restored through a Quasar configuration backup. Stored backup writes use the configured `WebServiceOptions.BackupDirectory` and publish atomically by writing `final.zip.tmp` in that same directory first, then renaming it to `final.zip` only after the archive is complete. | | [Quasar/Services/Backup/QuasarBackupSettingsService.cs](../files/Quasar/Services/Backup/QuasarBackupSettingsService.cs.md) | class | Singleton store for automatic-backup rules and the stored-backup folder setting. Persists schedule rules to `backup-settings.json` (`MagnetarPaths.GetQuasarBackupSettingsPath()`) in the Quasar data directory and picks up external schedule edits via a debounced (250 ms) `FileSystemWatcher`, mirroring `BrandingService`. It also patches `Quasar:BackupDirectory` in the data-directory `appsettings.json` for the Backup page and applies the resolved path to the live `WebServiceOptions`. | | [Quasar/Services/Backup/ServerRestoreCoordinator.cs](../files/Quasar/Services/Backup/ServerRestoreCoordinator.cs.md) | class | `ServerRestoreCoordinator` tracks which managed server unique names currently have a backup restore in progress. Restores rewrite server files in place, so callers can use this coordinator to prevent a server start or a second restore from racing against the same server data. | | [Quasar/Services/BrandingPresets.cs](../files/Quasar/Services/BrandingPresets.cs.md) | class | `BrandingPresets` is a static catalogue of the four built-in UI theme presets (Quasar Default, Midnight Blue, Slate, High Contrast). It exposes `GetLightPalette` / `GetDarkPalette` factory methods that layer identity/surface colour overrides on top of the base `ThemePalette.QuasarLight()` / `QuasarDark()` palettes, keeping all variants internally coherent. `BrandingPresetDefinition` is the companion display record. | diff --git a/Docs/Reference/data/manifest.json b/Docs/Reference/data/manifest.json index 81f3532..81c1503 100644 --- a/Docs/Reference/data/manifest.json +++ b/Docs/Reference/data/manifest.json @@ -194,8 +194,8 @@ "path": "Magnetar.Protocol/Model/ServerMetrics.cs", "name": "ServerMetrics.cs", "ext": ".cs", - "size": 849, - "sha256": "e9dd97459bac561844b2d5144b70bc7fd4ea67c108970e3b4462b1787a21fca5", + "size": 978, + "sha256": "1762a165e9c9298da81e6721df2a7996c372d7fb90df35f6f1b6e48794623316", "module": "Magnetar.Protocol", "tier": 1, "status": "pending" @@ -294,8 +294,8 @@ "path": "Quasar.Agent/AdminPlugin.cs", "name": "AdminPlugin.cs", "ext": ".cs", - "size": 9283, - "sha256": "ef5df725a171bd9fffa6622e2044e4b2a06c6d3ae18193291c8ac3b35fdd5f97", + "size": 9315, + "sha256": "777ed6a9d842f4fe296540a1c44f94c1d29713fb217d2c88ffdf784986a8b778", "module": "Quasar.Agent", "tier": 1, "status": "pending" @@ -374,8 +374,8 @@ "path": "Quasar.Agent/GameBridge.cs", "name": "GameBridge.cs", "ext": ".cs", - "size": 48838, - "sha256": "d9c50539d19f417aab67fe5fc36e31a39f92919b349e50ddf1e2420f6941e97e", + "size": 55243, + "sha256": "97cea2944a2e766fbd17c4dd00f9278fc487341c6a24250c802747ede26dfc17", "module": "Quasar.Agent", "tier": 1, "status": "pending" @@ -474,8 +474,8 @@ "path": "Quasar/Components/Dashboard/ServerDetailPanel.razor", "name": "ServerDetailPanel.razor", "ext": ".razor", - "size": 19065, - "sha256": "306ac78815ca295a1eb045ccbe1a0c6cca992f514b096227eb6fa454c34f63bd", + "size": 21240, + "sha256": "eec30a0007460ef05d912e1be2f95bd48ed5f338f0437f4c898ec56c2bef4cee", "module": "Quasar.Components", "tier": 2, "status": "pending" @@ -534,8 +534,8 @@ "path": "Quasar/Components/Layout/QuasarControlDialog.razor", "name": "QuasarControlDialog.razor", "ext": ".razor", - "size": 6732, - "sha256": "4c3ba899ab19f25f47f7a463affdee77cd6fd876fcc74eb1b68eadfc2a2aefba", + "size": 6725, + "sha256": "8593f30e9e630e2463ffcce850094462f7c89ea961a3efe5fea2adddc35c8fd1", "module": "Quasar.Components", "tier": 2, "status": "pending" @@ -624,8 +624,8 @@ "path": "Quasar/Components/Pages/Backup.razor", "name": "Backup.razor", "ext": ".razor", - "size": 42741, - "sha256": "9d5aa0bb6438efcb3791eacfe0c04d468b7088ac09e4ef95386f016e6f384896", + "size": 44139, + "sha256": "fe31ae29ccc2e8d5dd839ce662a2ee1f6c24989d8b466f3c95f4a51399318078", "module": "Quasar.Components", "tier": 2, "status": "pending" @@ -1464,8 +1464,8 @@ "path": "Quasar/Services/Backup/QuasarBackupService.cs", "name": "QuasarBackupService.cs", "ext": ".cs", - "size": 43738, - "sha256": "e4e7f1a208abfe8b095a1dd46d0d72fd52fd23710bf5b653c29beb085da7680b", + "size": 45761, + "sha256": "550c740402b9ca600005f294b18e89de3c23a725e68a17eb8ebeb560c45c04bc", "module": "Quasar.Services.Core", "tier": 2, "status": "pending" diff --git a/Docs/Reference/data/module_index.json b/Docs/Reference/data/module_index.json index e30f1f7..1f5ae77 100644 --- a/Docs/Reference/data/module_index.json +++ b/Docs/Reference/data/module_index.json @@ -266,7 +266,7 @@ "name": "GameBridge.cs", "kind": "class", "tier": 1, - "summary": "`GameBridge` is the central game-thread fa\u00e7ade for `AgentConnection`. It collects session telemetry (metrics, current profiler mode/snapshot, human players, hidden NPC/bot player ids, kicked players, chat, deaths, plugins), builds `AgentHello` / `AgentSnapshot` wire messages, and executes server commands (chat, blocking save, save-and-stop, profiler mode change, kick, ban, promote, clear-kick-cooldown, entity list/delete) by marshalling work onto the game thread via `MySandboxGame.Invoke`. Save/stop commands route through Magnetar PluginSdk `ServerControl` so Quasar observes completed disk saves before treating the command as successful. Metrics include process CPU derived from `Process.TotalProcessorTime`, simspeed/sim CPU from `Sync`, memory, human player count, PCU, active entities/grids, total blocks, and floating objects. It enumerates loaded plugins from `MyPlugins.Plugins` (including Pulsar child plugins) for runtime inventory, dedupes configured fallback plugin paths against loaded plugins by path stem, parent dev-folder name, manifest ``, and manifest ``, and exposes plugin configuration through `IQuasarConfigProvider` or Magnetar PluginSdk `PluginConfig` reflection. Chat history normalizes dedicated-server/Good.bot messages to author `Server` and marks `ChatMessageSnapshot.IsServerMessage`." + "summary": "`GameBridge` is the central game-thread fa\u00e7ade for `AgentConnection`. It collects session telemetry (metrics, current profiler mode/snapshot, human players, hidden NPC/bot player ids, kicked players, chat, deaths, plugins), builds `AgentHello` / `AgentSnapshot` wire messages, and executes server commands (chat, blocking save, save-and-stop, profiler mode change, kick, ban, promote, clear-kick-cooldown, entity list/delete) by marshalling work onto the game thread via `MySandboxGame.Invoke`. Save/stop commands route through Magnetar PluginSdk `ServerControl` so Quasar observes completed disk saves before treating the command as successful. Metrics include process CPU derived from `Process.TotalProcessorTime`, simspeed/sim CPU from `Sync`, memory, human player count, PCU, active entities/grids, total blocks, floating objects, latest world-save time, and unsaved game-time progress since the last checkpoint. It enumerates loaded plugins from `MyPlugins.Plugins` (including Pulsar child plugins) for runtime inventory, dedupes configured fallback plugin paths against loaded plugins by path stem, parent dev-folder name, manifest ``, and manifest ``, and exposes plugin configuration through `IQuasarConfigProvider` or Magnetar PluginSdk `PluginConfig` reflection. Chat history normalizes dedicated-server/Good.bot messages to author `Server` and marks `ChatMessageSnapshot.IsServerMessage`." }, { "path": "Quasar.Agent/PluginLogOutbox.cs", @@ -340,7 +340,7 @@ "name": "ServerDetailPanel.razor", "kind": "Blazor component", "tier": 2, - "summary": "Detail panel embedded inside `ServerCard`. When the agent snapshot is absent it shows a waiting/error message and basic process state chips. When a snapshot is present it renders live metrics chips, Refresh/Save buttons, a chat broadcast field, a players table with player identity/status columns and a rightmost unlabeled action menu column, a recent-chat list, and recent command results. The Plugins chip compares loaded runtime plugins against the assigned config profile's selected plugins, displays `loaded/total`, and turns warning-colored when the loaded count differs from the configured total; if no profile is available it falls back to the aggregate agent plugin metric. Server-authored chat (`IsServerMessage`, SteamId 0, `Good.bot`, or `Server`) is displayed as `Server`. In both the snapshot-present and snapshot-absent states, an outlined \"Affinity \" chip (Memory icon) is shown in the metrics chip rows when `Server.CpuAffinity` is set, and mod-download failures captured from runtime output are shown as an explicit error alert." + "summary": "Detail panel embedded inside `ServerCard`. When the agent snapshot is absent it shows a waiting/error message and basic process state chips. When a snapshot is present it renders live metrics chips, Refresh/Save buttons, a chat broadcast field, a players table with player identity/status columns and a rightmost unlabeled action menu column, a recent-chat list, and recent command results. The Plugins chip compares loaded runtime plugins against the assigned config profile's selected plugins, displays `loaded/total`, and turns warning-colored when the loaded count differs from the configured total; if no profile is available it falls back to the aggregate agent plugin metric. The Save chip shows save-in-progress state, or the latest world-save local time plus unsaved in-game progress as `MM:SS`, with a tooltip containing the full local timestamp. Server-authored chat (`IsServerMessage`, SteamId 0, `Good.bot`, or `Server`) is displayed as `Server`. In both the snapshot-present and snapshot-absent states, an outlined \"Affinity \" chip (Memory icon) is shown in the metrics chip rows when `Server.CpuAffinity` is set, and mod-download failures captured from runtime output are shown as an explicit error alert." }, { "path": "Quasar/Components/Layout/BrandingHeadContent.razor", @@ -955,7 +955,7 @@ "name": "QuasarBackupService.cs", "kind": "class", "tier": 2, - "summary": "Builds and restores ZIP backups for three scopes: Quasar configuration, server runtime state, and world-only data. Configuration backups still capture Quasar's own singleton/config/catalog files, including known-player rows, known-player retention settings, and data-handling consent; server backups include the server definition plus non-cache Dedicated Server and Magnetar app data; world backups restore world files while excluding `Sandbox_config.sbc*`. Stored backup writes use the configured `WebServiceOptions.BackupDirectory` and publish atomically by writing `final.zip.tmp` in that same directory first, then renaming it to `final.zip` only after the archive is complete." + "summary": "Builds and restores ZIP backups for three scopes: Quasar configuration, server runtime state, and world-only data. Configuration backups still capture Quasar's own singleton/config/catalog files, including known-player rows, known-player retention settings, and data-handling consent; server backups include the server definition plus non-cache Dedicated Server and Magnetar app data; world backups restore world files while excluding `Sandbox_config.sbc*`. Restored server definitions are rewritten with `GoalState = Off` and `AutoStart = false`, including definitions restored through a Quasar configuration backup. Stored backup writes use the configured `WebServiceOptions.BackupDirectory` and publish atomically by writing `final.zip.tmp` in that same directory first, then renaming it to `final.zip` only after the archive is complete." }, { "path": "Quasar/Services/Backup/QuasarBackupSettingsService.cs", diff --git a/Docs/Reference/files/Magnetar.Protocol/Model/ServerMetrics.cs.md b/Docs/Reference/files/Magnetar.Protocol/Model/ServerMetrics.cs.md index 179dd84..6b97b81 100644 --- a/Docs/Reference/files/Magnetar.Protocol/Model/ServerMetrics.cs.md +++ b/Docs/Reference/files/Magnetar.Protocol/Model/ServerMetrics.cs.md @@ -19,6 +19,8 @@ Class `ServerMetrics` (concrete, no base type): | `SimCpuLoadPercent` | `float` | CPU load attributed to the simulation thread. | | `ServerCpuLoadPercent` | `float` | Total server process CPU load. | | `IsSaveInProgress` | `bool` | True while a world save is running. | +| `LastWorldSaveUtc` | `DateTimeOffset?` | Latest known world-save timestamp in UTC; `null` until the agent can read or observe one. | +| `UnsavedGameTimeSeconds` | `long?` | In-game elapsed seconds since the saved checkpoint's `ElapsedGameTime`; used by the dashboard as unsaved progress duration. | | `UsedPcu` | `int` | PCU currently consumed. | | `TotalPcu` | `int` | PCU limit configured on the server. | | `MemoryWorkingSetMb` | `long?` | Process working set in MB; `null` if unavailable. | diff --git a/Docs/Reference/files/Quasar.Agent/AdminPlugin.cs.md b/Docs/Reference/files/Quasar.Agent/AdminPlugin.cs.md index 232a2bf..9ced8b0 100644 --- a/Docs/Reference/files/Quasar.Agent/AdminPlugin.cs.md +++ b/Docs/Reference/files/Quasar.Agent/AdminPlugin.cs.md @@ -14,7 +14,7 @@ Fields: `_bridge` (`GameBridge`), `_connection` (`AgentConnection`), `_outbox` ( |---|---| | `Init(object gameServer)` | Reads `AgentOptions.FromEnvironment()`, calls `AgentProfiler.Configure(options)` and `AgentProfilerPatches.Apply(options)`, registers `StopCommand` through PluginSdk `ServerCommands`, builds `GameBridge`; creates and `Start()`s `PluginLogOutbox` (before the connection loop); constructs `AgentConnection(bridge, WebServiceLocator, options, outbox)`, assigns `StopCommand.AdminStopRequested = ReportAdminStop`, and starts the connection; subscribes `MyVisualScriptLogicProvider.PlayerDied` as a fallback and `ServerControl.Terminating`. | | `Update()` | Delegates to `GameBridge.Update()` each game tick, then periodically scans online human players to hook their current `IMyCharacter.CharacterDied` event. | -| `Dispose()` | Unsubscribes process/session events and character death handlers, clears the `StopCommand` hook, stops the connection, disposes the outbox, unpatches profiler hooks, nulls references. | +| `Dispose()` | Unsubscribes process/session events and character death handlers, clears the `StopCommand` hook, stops the connection, disposes the outbox and bridge, unpatches profiler hooks, nulls references. | | `OnServerTerminating(ServerTerminationKind kind)` | If `kind == Shutdown` and `!_bridge.QuasarRequestedStop`, calls `ReportAdminStop()` as a fallback for admin/console shutdowns outside the `!stop` command. | | `ReportAdminStop()` | Locks `_adminStopSync` and sends `AgentConnection.TrySendAdminStop()` until one attempt succeeds, so `!stop` can report early while the termination fallback can retry if the socket was not open yet. | | `RefreshDeathSubscriptions()` | Once per second, scans `MySession.Static.Players.GetOnlinePlayers()`, skips bots/NPC identities, and hooks each player's current character. | diff --git a/Docs/Reference/files/Quasar.Agent/GameBridge.cs.md b/Docs/Reference/files/Quasar.Agent/GameBridge.cs.md index fcffa15..afe61bb 100644 --- a/Docs/Reference/files/Quasar.Agent/GameBridge.cs.md +++ b/Docs/Reference/files/Quasar.Agent/GameBridge.cs.md @@ -3,7 +3,7 @@ **Module:** Quasar.Agent **Kind:** class **Tier:** 1 ## Summary -`GameBridge` is the central game-thread façade for `AgentConnection`. It collects session telemetry (metrics, current profiler mode/snapshot, human players, hidden NPC/bot player ids, kicked players, chat, deaths, plugins), builds `AgentHello` / `AgentSnapshot` wire messages, and executes server commands (chat, blocking save, save-and-stop, profiler mode change, kick, ban, promote, clear-kick-cooldown, entity list/delete) by marshalling work onto the game thread via `MySandboxGame.Invoke`. Save/stop commands route through Magnetar PluginSdk `ServerControl` so Quasar observes completed disk saves before treating the command as successful. Metrics include process CPU derived from `Process.TotalProcessorTime`, simspeed/sim CPU from `Sync`, memory, human player count, PCU, active entities/grids, total blocks, and floating objects. It enumerates loaded plugins from `MyPlugins.Plugins` (including Pulsar child plugins) for runtime inventory, dedupes configured fallback plugin paths against loaded plugins by path stem, parent dev-folder name, manifest ``, and manifest ``, and exposes plugin configuration through `IQuasarConfigProvider` or Magnetar PluginSdk `PluginConfig` reflection. Chat history normalizes dedicated-server/Good.bot messages to author `Server` and marks `ChatMessageSnapshot.IsServerMessage`. +`GameBridge` is the central game-thread façade for `AgentConnection`. It collects session telemetry (metrics, current profiler mode/snapshot, human players, hidden NPC/bot player ids, kicked players, chat, deaths, plugins), builds `AgentHello` / `AgentSnapshot` wire messages, and executes server commands (chat, blocking save, save-and-stop, profiler mode change, kick, ban, promote, clear-kick-cooldown, entity list/delete) by marshalling work onto the game thread via `MySandboxGame.Invoke`. Save/stop commands route through Magnetar PluginSdk `ServerControl` so Quasar observes completed disk saves before treating the command as successful. Metrics include process CPU derived from `Process.TotalProcessorTime`, simspeed/sim CPU from `Sync`, memory, human player count, PCU, active entities/grids, total blocks, floating objects, latest world-save time, and unsaved game-time progress since the last checkpoint. It enumerates loaded plugins from `MyPlugins.Plugins` (including Pulsar child plugins) for runtime inventory, dedupes configured fallback plugin paths against loaded plugins by path stem, parent dev-folder name, manifest ``, and manifest ``, and exposes plugin configuration through `IQuasarConfigProvider` or Magnetar PluginSdk `PluginConfig` reflection. Chat history normalizes dedicated-server/Good.bot messages to author `Server` and marks `ChatMessageSnapshot.IsServerMessage`. ## Structure **Namespace:** `Quasar.Agent` @@ -15,6 +15,7 @@ |---|---| | `QuasarRequestedStop` (property) | True once a `StopServer` command was received from Quasar | | `GameBridge(object gameServer)` | Reads `MAGNETAR_HOST_ID` and `QUASAR_UNIQUE_NAME` env vars; captures plugin version only when explicit `AssemblyInformationalVersion` metadata is present | +| `Dispose()` | Unsubscribes the `MySession.OnSaved` handler used to update world-save telemetry. | | `Update()` | Called each game tick; marks the game thread for profiler attribution, advances continuous profiler publishing, and throttles snapshot refresh to ≤1 Hz via `_lastSnapshotUtc` | | `GetHello()` | Returns a cached `AgentHello`; thread-safe via `_sync` lock | | `GetSnapshot()` | Returns a cached `AgentSnapshot`; thread-safe via `_sync` lock | @@ -50,6 +51,7 @@ - `Sandbox.Game.Gui` — chat channel types - `Sandbox.Game.Multiplayer` — `Sync` - `Sandbox.Game.World` — `MySession`, `MyAsyncSaving` +- `Sandbox.Engine.Networking` — `MyLocalCache` checkpoint loading for initial save timestamp/progress baseline - `VRage.Game.ModAPI` — `MyPromoteLevel` - `Newtonsoft.Json` — payload serialization @@ -63,5 +65,6 @@ - `ApplyConfigJson` for SDK configs copies only properties decorated with `[ConfigOption]` to preserve non-option fields. - Runtime plugin inventory uses `EnumeratePlugins()` so Magnetar/Pulsar-loaded plugins appear even when `MySandboxGame.ConfigDedicated.Plugins` is empty; configured plugin paths are still added as `declared` fallback rows only when not already represented by a loaded plugin. XML manifest fallback rows are matched against loaded plugins by full path, path stem, parent dev-folder/source name, ``, and ``, preventing duplicate local dev-folder rows. - `SaveWorld` returns success only after `ServerControl.SaveWorld()` reports completion; `StopServer` marks `_quasarRequestedStop` and calls `ServerControl.SaveAndQuit()` so Magnetar owns the save, plugin disposal, and process exit path. +- World-save telemetry is initialized once per `MySession.CurrentPath` by loading `Sandbox.sbc` through `MyLocalCache.LoadCheckpoint`, updated optimistically from `MySession.OnSaved`, then refreshed from the checkpoint once async save progress ends; `UnsavedGameTimeSeconds` is based on `MySession.ElapsedGameTime`, so offline wall-clock time is not counted as unsaved progress. - Private `GetServerName(MySession)` reports `MySandboxGame.ConfigDedicated?.ServerName` (the configured server name shown in the server browser), falling back only to `Space Engineers {processId}`. It deliberately does **not** fall back to `session?.Name`, which is the loaded world/save name (matching the world template) rather than the server — this keeps the per-server name used by the UI's server filters distinct from the world name. - Private `GetPlayers(MySession)` and `GetOnlinePlayerCount(MySession)` filter online `MyPlayer` entries to human players only before filling `AgentSnapshot.Players` or `ServerMetrics.PlayersOnline`: SteamId must be non-zero, `IsBot` must be false, and `MySession.Players.IdentityIsNpc(identityId)` must be false. Filtered ids are reported through `AgentSnapshot.HiddenPlayerSteamIds` / `HiddenPlayerIdentityIds` so Quasar can remove stale known-player rows. Wolves, spiders, and NPC identities remain available through entity inspection, but do not appear in Quasar player rows or player counts. diff --git a/Docs/Reference/files/Quasar/Components/Dashboard/ServerDetailPanel.razor.md b/Docs/Reference/files/Quasar/Components/Dashboard/ServerDetailPanel.razor.md index 54e6ca6..7c17181 100644 --- a/Docs/Reference/files/Quasar/Components/Dashboard/ServerDetailPanel.razor.md +++ b/Docs/Reference/files/Quasar/Components/Dashboard/ServerDetailPanel.razor.md @@ -3,7 +3,7 @@ **Module:** Quasar.Components **Kind:** Blazor component **Tier:** 2 ## Summary -Detail panel embedded inside `ServerCard`. When the agent snapshot is absent it shows a waiting/error message and basic process state chips. When a snapshot is present it renders live metrics chips, Refresh/Save buttons, a chat broadcast field, a players table with player identity/status columns and a rightmost unlabeled action menu column, a recent-chat list, and recent command results. The Plugins chip compares loaded runtime plugins against the assigned config profile's selected plugins, displays `loaded/total`, and turns warning-colored when the loaded count differs from the configured total; if no profile is available it falls back to the aggregate agent plugin metric. Server-authored chat (`IsServerMessage`, SteamId 0, `Good.bot`, or `Server`) is displayed as `Server`. In both the snapshot-present and snapshot-absent states, an outlined "Affinity " chip (Memory icon) is shown in the metrics chip rows when `Server.CpuAffinity` is set, and mod-download failures captured from runtime output are shown as an explicit error alert. +Detail panel embedded inside `ServerCard`. When the agent snapshot is absent it shows a waiting/error message and basic process state chips. When a snapshot is present it renders live metrics chips, Refresh/Save buttons, a chat broadcast field, a players table with player identity/status columns and a rightmost unlabeled action menu column, a recent-chat list, and recent command results. The Plugins chip compares loaded runtime plugins against the assigned config profile's selected plugins, displays `loaded/total`, and turns warning-colored when the loaded count differs from the configured total; if no profile is available it falls back to the aggregate agent plugin metric. The Save chip shows save-in-progress state, or the latest world-save local time plus unsaved in-game progress as `MM:SS`, with a tooltip containing the full local timestamp. Server-authored chat (`IsServerMessage`, SteamId 0, `Good.bot`, or `Server`) is displayed as `Server`. In both the snapshot-present and snapshot-absent states, an outlined "Affinity " chip (Memory icon) is shown in the metrics chip rows when `Server.CpuAffinity` is set, and mod-download failures captured from runtime output are shown as an explicit error alert. ## Structure No `@page` route — used as a child component. @@ -30,6 +30,7 @@ No `@page` route — used as a child component. - `SendChatAsync()` — validates and dispatches `ServerCommandType.SendChat`. - `HandleChatKeyDownAsync` — triggers send on Enter key. - `FormatDuration(int)` / `FormatTimestamp(long)` / `FormatChatAuthor` — display helpers, including server-message author normalization. +- `GetSaveChipText()` / `GetSaveTooltipText()` / `GetSaveChipColor()` / `FormatMinuteSecondDuration(long)` — render the world-save chip from `ServerMetrics.IsSaveInProgress`, `LastWorldSaveUtc`, and `UnsavedGameTimeSeconds`. - `GetMaxPlayers()` — checks config profile first, falls back to snapshot metrics. - `GetPluginLoadSummary()` / `GetPluginLoadText()` / `GetPluginLoadColor()` — derive the Plugins chip from loaded runtime plugin IDs/display names against the assigned profile's selected plugin IDs/display names, and mark mismatches with `Color.Warning`. - `GetWaitingText()` — state-dependent placeholder message. For a `Running` process with no snapshot yet (agent reconnecting) it reads "Connecting. Waiting for Quasar.Agent to reconnect."; for `Starting`/`Restarting` it reads "Starting. Waiting for Quasar.Agent and first game snapshot.". @@ -38,7 +39,7 @@ No `@page` route — used as a child component. **Static field:** `PromoteLevels = ["None", "Scripter", "Moderator", "SpaceMaster", "Admin"]`. -**MudBlazor components used:** `MudAlert`, `MudStack`, `MudChip`, `MudButton`, `MudTextField`, `MudTable`, `MudTh`, `MudTd`, `MudMenu`, `MudMenuItem`, `MudDivider`, `MudText`. +**MudBlazor components used:** `MudAlert`, `MudStack`, `MudChip`, `MudTooltip`, `MudButton`, `MudTextField`, `MudTable`, `MudTh`, `MudTd`, `MudMenu`, `MudMenuItem`, `MudDivider`, `MudText`. ## Dependencies - [`Quasar/Services/AgentRegistry.cs`](../../Services/AgentRegistry.cs.md) — command dispatch and agent lookup diff --git a/Docs/Reference/files/Quasar/Components/Layout/QuasarControlDialog.razor.md b/Docs/Reference/files/Quasar/Components/Layout/QuasarControlDialog.razor.md index d279f18..b9af8fb 100644 --- a/Docs/Reference/files/Quasar/Components/Layout/QuasarControlDialog.razor.md +++ b/Docs/Reference/files/Quasar/Components/Layout/QuasarControlDialog.razor.md @@ -18,7 +18,7 @@ No route; rendered through `IDialogService.ShowAsync()`. **Actions:** - Restart Quasar — confirms that servers continue to run, the UI briefly disconnects, and the worker is re-adopted after restart. - Shutdown Quasar — confirms that the web UI/supervisor stops while servers remain detached, and explains the agent offline grace period. -- Shutdown all servers normally — confirms that Quasar stays online and each running server receives a normal graceful stop request. +- Save and stop all servers — confirms that Quasar stays online and each running server receives a normal save-and-stop request. ## Dependencies - [`Quasar/Components/Layout/QuasarControlAction.cs`](QuasarControlAction.cs.md) diff --git a/Docs/Reference/files/Quasar/Components/Pages/Backup.razor.md b/Docs/Reference/files/Quasar/Components/Pages/Backup.razor.md index 9384e36..115429d 100644 --- a/Docs/Reference/files/Quasar/Components/Pages/Backup.razor.md +++ b/Docs/Reference/files/Quasar/Components/Pages/Backup.razor.md @@ -14,7 +14,7 @@ UI sections (`MudGrid`): 1. **Create & restore** — "Create backup" links to `/api/backup/download`; restore via `MudFileUpload` (`.zip`, max 10 GB) → `RestoreFromUploadAsync`; shows the last restore-report alert with a restart recommendation. Explains that Quasar configuration backups cover app settings/catalog data, while server backups cover non-cache runtime state and world backups cover save files. 2. **Version compatibility** — same major.minor restores fully, older is upgraded via forward migration, newer is rejected; data-protection keys are excluded so the Steam Workshop API key is re-entered on a different machine. 3. **Server & world backups** — sortable table populated from `DedicatedServerCatalog`, one row per server, with unique name, display name, and a rightmost unlabeled action group. Each row has Back up server (`AutomaticBackup.QueueServerBackup`), Restore server (latest matching stored server backup), Back up world (`AutomaticBackup.QueueWorldBackup`), and Restore world (latest matching stored world backup). Server/world backup buttons only enqueue background work and return immediately; server backups include server definition plus non-cache Dedicated Server/Magnetar app data, while world backups contain save files and exclude `Sandbox_config.sbc*`. -4. **Automatic backups** — three `MudExpansionPanel` rules: Quasar config, Servers, and Worlds. Each rule has its own enable switch, Frequency select (Hourly/Daily/Weekly), `MudTimePicker` time-of-day (Daily/Weekly), day-of-week select (Weekly), and retention numeric (config keeps last N total; server/world keep last N per server). Panels load expanded only when their rule is enabled. Buttons save all rules or enqueue enabled rules to run immediately in the background. +4. **Automatic backups** — three `MudExpansionPanel` rules: Quasar config, Servers, and Worlds. Each rule starts with explanatory copy describing what that backup scope contains: Quasar-managed config/catalog files, per-server non-cache DS/Magnetar app data, or world save files. Each rule has its own enable switch, Frequency select (Hourly/Daily/Weekly), `MudTimePicker` time-of-day (Daily/Weekly), day-of-week select (Weekly), and retention numeric (config keeps last N total; server/world keep last N per server). Panels load expanded only when their rule is enabled. Buttons save all rules or enqueue enabled rules to run immediately in the background. 5. **Stored backups** — shows the resolved configured backup folder with `CopyablePath`, an editable `Backup folder` field bound to `Quasar:BackupDirectory`, Browse (`FolderPickerDialog` with `RequireWorldFolder=false`), Use default, and Save folder actions. A `QUASAR_BACKUP_DIR` environment override makes the editor read-only. The stored-backup table lists Created / Type / Server / Size / Name columns, defaulting newest first, with tooltip-wrapped Download (`/api/backup/download/{name}`), Restore and Delete actions in a rightmost unlabeled action column. `@code`: subscribes to `BackupSettingsService.Changed`, `BackupSettingsService.BackupDirectoryChanged`, `ServerCatalog.Changed`, `BackupService.Changed`, and `AutomaticBackup.QueuedBackupCompleted`; `LoadSettingsDraft` populates separate time-picker drafts for the three rules; `LoadBackupDirectoryDraft` reads appsettings/env override state for the folder editor; `RefreshServers`, `RefreshBackups` (`BackupService.ListBackups()` sorted by timestamp descending), `SaveSettingsAsync`, `SaveBackupDirectoryAsync`, `BrowseBackupDirectoryAsync`, `UseDefaultBackupDirectoryAsync`, `MakeBackupNowAsync` (`AutomaticBackup.QueueEnabledBackupsNow()`), row-scoped `MakeServerBackupNowAsync` / `MakeWorldBackupNowAsync` queue calls, latest-backup restore helpers, `RestoreFromUploadAsync` / `RestoreFromStoredAsync` (with backup-kind-specific confirm dialogs), `DeleteAsync` (confirm), and `FormatSize` / `FormatBackupType` helpers. `BackupService.Changed` refreshes the stored-backup list as each ZIP is atomically published; queued-job completion shows the success/error snackbar; backup-folder saves refresh the list against the newly active directory. @@ -33,4 +33,4 @@ UI sections (`MudGrid`): - External: MudBlazor ## Notes -Download endpoints are policy-gated in `Program.cs`. Configuration restore overwrites settings sharing an ID with the backup (merge) and recommends a Quasar restart; server/world restore overwrites files for the target server and asks the operator to restart that server as needed. Manual stored-backup creation is fire-and-forget from the Blazor circuit, so page navigation is not blocked by ZIP creation. Folder saves create the target directory before applying it live; existing ZIPs are not moved between folders. +Download endpoints are policy-gated in `Program.cs`. Restore confirmations describe the selected scope: configuration backups merge matching IDs, server backups restore the server definition plus non-cache app data, and world backups restore save files while keeping existing config. Restored server definitions are written back with stopped intent by `QuasarBackupService`, so a restored backup cannot re-arm an `On` goal state before matching world files are restored. Manual stored-backup creation is fire-and-forget from the Blazor circuit, so page navigation is not blocked by ZIP creation. Folder saves create the target directory before applying it live; existing ZIPs are not moved between folders. diff --git a/Docs/Reference/files/Quasar/Services/Backup/QuasarBackupService.cs.md b/Docs/Reference/files/Quasar/Services/Backup/QuasarBackupService.cs.md index 3ff1f6e..4a5c356 100644 --- a/Docs/Reference/files/Quasar/Services/Backup/QuasarBackupService.cs.md +++ b/Docs/Reference/files/Quasar/Services/Backup/QuasarBackupService.cs.md @@ -3,7 +3,7 @@ **Module:** Quasar.Services.Backup **Kind:** class **Tier:** 1 ## Summary -Builds and restores ZIP backups for three scopes: Quasar configuration, server runtime state, and world-only data. Configuration backups still capture Quasar's own singleton/config/catalog files, including known-player rows, known-player retention settings, and data-handling consent; server backups include the server definition plus non-cache Dedicated Server and Magnetar app data; world backups restore world files while excluding `Sandbox_config.sbc*`. Stored backup writes use the configured `WebServiceOptions.BackupDirectory` and publish atomically by writing `final.zip.tmp` in that same directory first, then renaming it to `final.zip` only after the archive is complete. +Builds and restores ZIP backups for three scopes: Quasar configuration, server runtime state, and world-only data. Configuration backups still capture Quasar's own singleton/config/catalog files, including known-player rows, known-player retention settings, and data-handling consent; server backups include the server definition plus non-cache Dedicated Server and Magnetar app data; world backups restore world files while excluding `Sandbox_config.sbc*`. Restored server definitions are rewritten with `GoalState = Off` and `AutoStart = false`, including definitions restored through a Quasar configuration backup. Stored backup writes use the configured `WebServiceOptions.BackupDirectory` and publish atomically by writing `final.zip.tmp` in that same directory first, then renaming it to `final.zip` only after the archive is complete. ## Structure Namespace: `Quasar.Services.Backup` @@ -30,9 +30,9 @@ Const `CurrentFormatVersion = 1`. `BackupDirectory` exposes the resolved configu | `RestoreFromFileAsync(string fileName, CancellationToken)` | `Task` restoring from a stored backup file. | | `RestoreAsync(Stream zipStream, CancellationToken)` | `Task`; copies to a temporary seekable file, reads the manifest, validates via `BackupCompatibility.Evaluate`, then dispatches restore by `BackupKind`. | -Constructor deps: `ILogger`, `WebServiceOptions _options`, `IWebHostEnvironment environment` (to resolve webRoot for the branding dir via `MagnetarPaths.GetQuasarBrandingDirectory(webRootPath)`), `KnownPlayerCatalog _knownPlayers`, `QuasarDevFolderCatalog _devFolders`, `DedicatedServerCatalog _servers`. +Constructor deps: `ILogger`, `WebServiceOptions _options`, `IWebHostEnvironment environment` (to resolve webRoot for the branding dir via `MagnetarPaths.GetQuasarBrandingDirectory(webRootPath)`), `KnownPlayerCatalog _knownPlayers`, `QuasarDevFolderCatalog _devFolders`, `DedicatedServerCatalog _servers`, `DedicatedServerSupervisor _supervisor`, `ServerRestoreCoordinator _restoreCoordinator`. -Configuration restore merges by overwriting files at their on-disk path (configs/templates/servers with new IDs added, matching IDs replaced). Server restore writes server/config/runtime entries to the target server paths; world restore requires the target server to exist and skips world config. Zip-slip guards keep all entries inside their resolved target roots. Configuration restore calls `_knownPlayers.ReloadFromDisk()` and `_devFolders.ReloadFromDisk()` (catalogs without a file watcher) and returns a report with `RestartRecommended = true`. +Configuration restore merges by overwriting files at their on-disk path (configs/templates/servers with new IDs added, matching IDs replaced). Server definition entries under `data/Magnetars/**/server.json` and `server/server.json` are deserialized and reserialized with stopped intent instead of being extracted raw. Server restore writes server/config/runtime entries to the target server paths; world restore requires the target server to exist and skips world config. Zip-slip guards keep all entries inside their resolved target roots. Configuration restore calls `_knownPlayers.ReloadFromDisk()` and `_devFolders.ReloadFromDisk()` (catalogs without a file watcher) and returns a report with `RestartRecommended = true`. ## Dependencies - [`Magnetar.Protocol/Runtime/MagnetarPaths.cs`](../../../Magnetar.Protocol/Runtime/MagnetarPaths.cs.md) diff --git a/Docs/StateMachines/BackupJobs.md b/Docs/StateMachines/BackupJobs.md index 61227b0..648f41c 100644 --- a/Docs/StateMachines/BackupJobs.md +++ b/Docs/StateMachines/BackupJobs.md @@ -47,6 +47,25 @@ world) has its own schedule and retention. --- +## Backup scopes and restore behavior + +- `Configuration` backups contain Quasar-managed catalog and settings files: + server definitions, config profiles, world-template definitions, branding, + Discord, players, security/RBAC and other singleton settings. They do not + include Dedicated Server app data, Magnetar app data or world save files. +- `Server` backups contain one server definition plus that server's non-cache + Dedicated Server and Magnetar app data. They do not include world save files. +- `World` backups contain world save files for one server. They use the latest + valid Space Engineers `Backup` snapshot when one exists and leave the current + server/world config in place on restore. + +Restored server definitions are always written with `GoalState = Off` and +`AutoStart = false`, both for server backups and configuration backups that +contain server definitions. This prevents restore from re-starting a server +before its matching world files have been restored. + +--- + ## Restore compatibility Restore is gated by a semantic-version check diff --git a/Magnetar.Protocol/Model/ServerMetrics.cs b/Magnetar.Protocol/Model/ServerMetrics.cs index 7ff09c2..2415d22 100644 --- a/Magnetar.Protocol/Model/ServerMetrics.cs +++ b/Magnetar.Protocol/Model/ServerMetrics.cs @@ -1,3 +1,5 @@ +using System; + namespace Magnetar.Protocol.Model; public class ServerMetrics @@ -16,6 +18,10 @@ public class ServerMetrics public bool IsSaveInProgress { get; set; } + public DateTimeOffset? LastWorldSaveUtc { get; set; } + + public long? UnsavedGameTimeSeconds { get; set; } + public int UsedPcu { get; set; } public int TotalPcu { get; set; } diff --git a/Quasar.Agent/AdminPlugin.cs b/Quasar.Agent/AdminPlugin.cs index 320c9e4..02d11f6 100644 --- a/Quasar.Agent/AdminPlugin.cs +++ b/Quasar.Agent/AdminPlugin.cs @@ -60,6 +60,7 @@ public void Dispose() _connection = null; _outbox?.Dispose(); _outbox = null; + _bridge?.Dispose(); _bridge = null; AgentProfilerPatches.Dispose(); } diff --git a/Quasar.Agent/GameBridge.cs b/Quasar.Agent/GameBridge.cs index e76bb22..80081ad 100644 --- a/Quasar.Agent/GameBridge.cs +++ b/Quasar.Agent/GameBridge.cs @@ -17,6 +17,7 @@ using PluginSdk; using PluginSdk.Config; using Sandbox; +using Sandbox.Engine.Networking; using Sandbox.Engine.Multiplayer; using Sandbox.Game.Entities; using Sandbox.Game.Gui; @@ -29,7 +30,7 @@ namespace Quasar.Agent { - public class GameBridge + public class GameBridge : IDisposable { private static readonly TimeSpan SnapshotInterval = TimeSpan.FromSeconds(1); private const string ServerChatAuthorName = "Server"; @@ -57,6 +58,12 @@ public class GameBridge private readonly string _uniqueName; private readonly string _pluginVersion; private readonly ConcurrentQueue _deathQueue = new ConcurrentQueue(); + private readonly object _saveSync = new object(); + private string _worldSavePath = string.Empty; + private bool _worldSaveStateLoaded; + private bool _lastSaveInProgress; + private DateTimeOffset? _lastWorldSaveUtc; + private long? _lastWorldSaveElapsedGameTicks; private long _lastWorkingSetBytes; private TimeSpan _lastProcessCpuTime; private DateTime _lastProcessCpuSampleUtc = DateTime.MinValue; @@ -83,6 +90,12 @@ public GameBridge(object gameServer) ?? $"unmanaged-{_hostId}-{_processId}") .Trim(); _pluginVersion = GetAgentVersion(); + MySession.OnSaved += OnWorldSaved; + } + + public void Dispose() + { + MySession.OnSaved -= OnWorldSaved; } private static string GetAgentVersion() @@ -575,6 +588,8 @@ private ServerMetrics BuildMetrics(MySession session) else usedPcu = session.GlobalBlockLimits?.PCUBuilt ?? 0; + var isSaveInProgress = session.IsSaveInProgress || MyAsyncSaving.InProgress; + var worldSaveTelemetry = GetWorldSaveTelemetry(session, isSaveInProgress); int? activeGridCount = null; int? activeEntityCount = null; int? totalBlockCount = null; @@ -619,7 +634,9 @@ private ServerMetrics BuildMetrics(MySession session) SimSpeed = Sync.ServerSimulationRatio, SimCpuLoadPercent = (float)Math.Round(Sync.ServerCPULoad, 1), ServerCpuLoadPercent = processCpuLoadPercent, - IsSaveInProgress = session.IsSaveInProgress || MyAsyncSaving.InProgress, + IsSaveInProgress = isSaveInProgress, + LastWorldSaveUtc = worldSaveTelemetry.LastWorldSaveUtc, + UnsavedGameTimeSeconds = worldSaveTelemetry.UnsavedGameTimeSeconds, UsedPcu = usedPcu > 0 ? usedPcu : gridPcu, TotalPcu = session.Settings.TotalPCU, MemoryWorkingSetMb = _lastWorkingSetBytes >> 20, @@ -633,6 +650,154 @@ private ServerMetrics BuildMetrics(MySession session) }; } + private void OnWorldSaved(bool success, string sessionPath) + { + if (!success) + return; + + var session = MySession.Static; + var normalizedPath = NormalizeSessionPath(!string.IsNullOrWhiteSpace(sessionPath) + ? sessionPath + : session?.CurrentPath); + long? elapsedGameTicks = session == null ? (long?)null : session.ElapsedGameTime.Ticks; + + lock (_saveSync) + { + _worldSavePath = normalizedPath; + _worldSaveStateLoaded = true; + _lastWorldSaveUtc = DateTimeOffset.UtcNow; + _lastWorldSaveElapsedGameTicks = elapsedGameTicks; + } + } + + private WorldSaveTelemetry GetWorldSaveTelemetry(MySession session, bool isSaveInProgress) + { + var sessionPath = NormalizeSessionPath(session?.CurrentPath); + ObserveSaveProgress(sessionPath, isSaveInProgress); + EnsureWorldSaveStateLoaded(sessionPath); + + DateTimeOffset? lastWorldSaveUtc; + long? lastWorldSaveElapsedGameTicks; + lock (_saveSync) + { + lastWorldSaveUtc = _lastWorldSaveUtc; + lastWorldSaveElapsedGameTicks = _lastWorldSaveElapsedGameTicks; + } + + long? unsavedGameTimeSeconds = null; + if (session != null && lastWorldSaveElapsedGameTicks.HasValue) + { + var unsavedTicks = session.ElapsedGameTime.Ticks - lastWorldSaveElapsedGameTicks.Value; + unsavedGameTimeSeconds = Math.Max(0, unsavedTicks / TimeSpan.TicksPerSecond); + } + + return new WorldSaveTelemetry + { + LastWorldSaveUtc = lastWorldSaveUtc, + UnsavedGameTimeSeconds = unsavedGameTimeSeconds, + }; + } + + private void ObserveSaveProgress(string sessionPath, bool isSaveInProgress) + { + lock (_saveSync) + { + if (_lastSaveInProgress && !isSaveInProgress && !string.IsNullOrWhiteSpace(sessionPath)) + { + _worldSaveStateLoaded = false; + _lastWorldSaveUtc = null; + _lastWorldSaveElapsedGameTicks = null; + } + + _lastSaveInProgress = isSaveInProgress; + } + } + + private void EnsureWorldSaveStateLoaded(string sessionPath) + { + var shouldLoad = false; + lock (_saveSync) + { + if (!string.Equals(_worldSavePath, sessionPath, StringComparison.Ordinal)) + { + _worldSavePath = sessionPath; + _worldSaveStateLoaded = false; + _lastWorldSaveUtc = null; + _lastWorldSaveElapsedGameTicks = null; + } + + shouldLoad = !_worldSaveStateLoaded && !string.IsNullOrWhiteSpace(sessionPath); + } + + if (!shouldLoad) + return; + + var checkpoint = TryLoadWorldSaveCheckpoint(sessionPath); + lock (_saveSync) + { + if (!string.Equals(_worldSavePath, sessionPath, StringComparison.Ordinal)) + return; + + _worldSaveStateLoaded = true; + _lastWorldSaveUtc = checkpoint?.LastWorldSaveUtc; + _lastWorldSaveElapsedGameTicks = checkpoint?.LastWorldSaveElapsedGameTicks; + } + } + + private static WorldSaveCheckpoint TryLoadWorldSaveCheckpoint(string sessionPath) + { + try + { + var checkpoint = MyLocalCache.LoadCheckpoint(sessionPath, out _); + var lastWorldSaveUtc = NormalizeSaveTime(checkpoint?.LastSaveTime ?? default); + + return new WorldSaveCheckpoint + { + LastWorldSaveUtc = lastWorldSaveUtc, + LastWorldSaveElapsedGameTicks = lastWorldSaveUtc.HasValue + ? checkpoint?.ElapsedGameTime + : null, + }; + } + catch + { + return null; + } + } + + private static DateTimeOffset? NormalizeSaveTime(DateTime saveTime) + { + if (saveTime == default) + return null; + + if (saveTime.Kind == DateTimeKind.Unspecified) + saveTime = DateTime.SpecifyKind(saveTime, DateTimeKind.Local); + + return new DateTimeOffset(saveTime).ToUniversalTime(); + } + + private static string NormalizeSessionPath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return string.Empty; + + var trimmed = path.Trim(); + if (trimmed.EndsWith(".sbc", StringComparison.OrdinalIgnoreCase)) + trimmed = Path.GetDirectoryName(trimmed) ?? string.Empty; + + if (string.IsNullOrWhiteSpace(trimmed)) + return string.Empty; + + try + { + return Path.GetFullPath(trimmed); + } + catch + { + return trimmed; + } + } + private float GetProcessCpuLoadPercent(Process process) { var now = DateTime.UtcNow; @@ -1254,6 +1419,20 @@ private string GetWorldName(MySession session) ?? "Unknown World"; } + private sealed class WorldSaveCheckpoint + { + public DateTimeOffset? LastWorldSaveUtc { get; set; } + + public long? LastWorldSaveElapsedGameTicks { get; set; } + } + + private sealed class WorldSaveTelemetry + { + public DateTimeOffset? LastWorldSaveUtc { get; set; } + + public long? UnsavedGameTimeSeconds { get; set; } + } + private static ServerCommandResult CreateResult(ServerCommandEnvelope command, bool success, string message, string payload = null) { return new ServerCommandResult diff --git a/Quasar/Components/Dashboard/ServerDetailPanel.razor b/Quasar/Components/Dashboard/ServerDetailPanel.razor index f9c4783..095407d 100644 --- a/Quasar/Components/Dashboard/ServerDetailPanel.razor +++ b/Quasar/Components/Dashboard/ServerDetailPanel.razor @@ -59,6 +59,17 @@ else Mods @Agent.Snapshot.Metrics.ModsLoaded Plugins @GetPluginLoadText(pluginLoadSummary) Uptime @FormatDuration(Agent.Snapshot.Metrics.UptimeSeconds) + @if (GetSaveChipText() is { Length: > 0 } saveChipText) + { + + + @saveChipText + + + } @if (!string.IsNullOrWhiteSpace(Server?.CpuAffinity)) { Affinity @Server.CpuAffinity @@ -286,6 +297,62 @@ else return $"{duration.Seconds}s"; } + private string? GetSaveChipText() + { + var metrics = Agent?.Snapshot?.Metrics; + if (metrics is null) + return null; + + if (metrics.IsSaveInProgress) + return "Save in progress"; + + if (metrics.LastWorldSaveUtc is not { } lastSaveUtc) + return null; + + var localTime = lastSaveUtc.ToLocalTime().ToString("HH:mm:ss"); + if (metrics.UnsavedGameTimeSeconds is long unsavedSeconds && unsavedSeconds > 0) + return $"Saved {localTime} · Unsaved {FormatMinuteSecondDuration(unsavedSeconds)}"; + + return $"Saved {localTime}"; + } + + private string GetSaveTooltipText() + { + var metrics = Agent?.Snapshot?.Metrics; + if (metrics is null) + return string.Empty; + + if (metrics.IsSaveInProgress) + return "World save in progress."; + + if (metrics.LastWorldSaveUtc is not { } lastSaveUtc) + return string.Empty; + + var savedAt = lastSaveUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"); + if (metrics.UnsavedGameTimeSeconds is long unsavedSeconds) + { + return $"Latest world save: {savedAt}. Unsaved game progress: {FormatMinuteSecondDuration(unsavedSeconds)}."; + } + + return $"Latest world save: {savedAt}."; + } + + private Color GetSaveChipColor() + { + var metrics = Agent?.Snapshot?.Metrics; + return metrics?.IsSaveInProgress == true ? Color.Info : Color.Default; + } + + private static string FormatMinuteSecondDuration(long totalSeconds) + { + if (totalSeconds <= 0) + return "00:00"; + + var minutes = totalSeconds / 60; + var seconds = totalSeconds % 60; + return $"{minutes:00}:{seconds:00}"; + } + private int GetMaxPlayers() { var configuredMaxPlayers = GetServerConfigProfile()?.SessionSettings.MaxPlayers; diff --git a/Quasar/Components/Layout/QuasarControlDialog.razor b/Quasar/Components/Layout/QuasarControlDialog.razor index a31ee75..48d9678 100644 --- a/Quasar/Components/Layout/QuasarControlDialog.razor +++ b/Quasar/Components/Layout/QuasarControlDialog.razor @@ -41,7 +41,7 @@ OnClick="@(() => Select(QuasarControlAction.ShutdownAllServers))" Class="quasar-control-action"> - Shutdown all servers normally + Save and stop all servers Quasar keeps running and tracks the shutdown progress. @@ -109,14 +109,14 @@ private string ConfirmationButtonText => _pendingAction switch { QuasarControlAction.RestartQuasar => "Restart Quasar", - QuasarControlAction.ShutdownAllServers => "Shutdown Servers", + QuasarControlAction.ShutdownAllServers => "Save and Stop Servers", _ => "Shutdown Quasar", }; private string ConfirmationLead => _pendingAction switch { QuasarControlAction.RestartQuasar => "Running servers continue to run.", - QuasarControlAction.ShutdownAllServers => "Every running server will be shut down normally.", + QuasarControlAction.ShutdownAllServers => "Every running server will save and stop.", _ => "Quasar will stop, but running servers continue.", }; @@ -125,7 +125,7 @@ QuasarControlAction.RestartQuasar => "The Quasar web worker exits and the launcher starts it again. The web UI briefly disconnects, then reloads and re-adopts running servers by process id. Use this after restoring Quasar configuration from backup.", QuasarControlAction.ShutdownAllServers => - "Quasar stays online. Each running server receives a normal graceful stop request, and its goal state is set to Off so Quasar does not restart it.", + "Quasar stays online. Each running server receives a normal save-and-stop request, and its goal state is set to Off so Quasar does not restart it.", _ => $"The Quasar web UI and supervisor stop. Running servers are left detached and can continue for {FormatOfflineGrace()} without Quasar before their agent offline policy takes over. Start Quasar again to re-adopt them.", }; diff --git a/Quasar/Components/Pages/Backup.razor b/Quasar/Components/Pages/Backup.razor index b0dece7..2e2b90e 100644 --- a/Quasar/Components/Pages/Backup.razor +++ b/Quasar/Components/Pages/Backup.razor @@ -178,6 +178,11 @@ + + Backs up Quasar configuration only: server definitions, config profiles, world templates, + Discord, branding, players, security/RBAC and other Quasar catalogs. It does not include + Dedicated Server app data, Magnetar app data or world save files. + @@ -219,6 +224,10 @@ + + Backs up each server's Quasar server definition plus non-cache Dedicated Server and + Magnetar app data. It does not include world save files; use world backups for saves. + @@ -260,6 +269,11 @@ + + Backs up each server's world save files only. When a Space Engineers Backup + snapshot exists, Quasar uses the latest valid snapshot so running servers can be backed up + without copying the live save folder. + @@ -753,7 +767,7 @@ { var confirmed = await DialogService.ShowMessageBoxAsync( "Restore backup?", - "This restores the selected ZIP according to its backup type. Configuration backups merge by ID; server and world backups overwrite files for their target server. Continue?", + "This restores the selected ZIP according to its backup type. Configuration backups merge by ID; server backups restore server definition plus non-cache app data; world backups restore save files. Restored server definitions come back stopped. Continue?", yesText: "Restore", cancelText: "Cancel"); return confirmed == true; @@ -764,11 +778,11 @@ var message = backup.Kind switch { QuasarBackupKind.Server => - $"This overwrites server files, world files and config for '{backup.ServerDisplayName ?? backup.ServerUniqueName ?? backup.Name}'. Continue?", + $"This restores the server definition plus non-cache Dedicated Server and Magnetar app data for '{backup.ServerDisplayName ?? backup.ServerUniqueName ?? backup.Name}'. The server will be restored stopped. Continue?", QuasarBackupKind.World => - $"This overwrites world files for '{backup.ServerDisplayName ?? backup.ServerUniqueName ?? backup.Name}' but keeps existing config. Continue?", + $"This overwrites world save files for '{backup.ServerDisplayName ?? backup.ServerUniqueName ?? backup.Name}' but keeps existing server and world config. Continue?", _ => - "This overwrites Quasar settings that share an ID with the backup. Items with different IDs are kept (merge). Continue?", + "This overwrites Quasar settings that share an ID with the backup. Items with different IDs are kept (merge). Restored server definitions come back stopped. Continue?", }; var confirmed = await DialogService.ShowMessageBoxAsync( diff --git a/Quasar/Services/Backup/QuasarBackupService.cs b/Quasar/Services/Backup/QuasarBackupService.cs index b2241af..2cfee65 100644 --- a/Quasar/Services/Backup/QuasarBackupService.cs +++ b/Quasar/Services/Backup/QuasarBackupService.cs @@ -419,7 +419,16 @@ private QuasarRestoreReport RestoreConfigurationArchive( } Directory.CreateDirectory(Path.GetDirectoryName(target)!); - entry.ExtractToFile(target, overwrite: true); + if (IsConfigurationServerDefinitionEntry(entry.FullName)) + { + if (!TryRestoreServerDefinitionEntry(entry, target, cancellationToken)) + continue; + } + else + { + entry.ExtractToFile(target, overwrite: true); + } + restored++; } @@ -714,8 +723,12 @@ private int RestoreServerEntries( } string? destination = null; + var restoreServerDefinition = false; if (string.Equals(entry.FullName, ServerDefinitionEntryName, StringComparison.Ordinal)) + { destination = MagnetarPaths.GetQuasarServerDefinitionPath(target.UniqueName); + restoreServerDefinition = true; + } else if (entry.FullName.StartsWith(DedicatedServerPrefix, StringComparison.Ordinal)) destination = ResolvePrefixedExtractionTarget(entry.FullName, DedicatedServerPrefix, target.DedicatedServerAppDataPath); else if (entry.FullName.StartsWith(DedicatedConfigPrefix, StringComparison.Ordinal)) @@ -737,13 +750,50 @@ private int RestoreServerEntries( } Directory.CreateDirectory(Path.GetDirectoryName(destination)!); - entry.ExtractToFile(destination, overwrite: true); + if (restoreServerDefinition) + { + if (!TryRestoreServerDefinitionEntry(entry, destination, cancellationToken)) + continue; + } + else + { + entry.ExtractToFile(destination, overwrite: true); + } + restored++; } return restored; } + private bool TryRestoreServerDefinitionEntry( + ZipArchiveEntry entry, + string destination, + CancellationToken cancellationToken) + { + try + { + cancellationToken.ThrowIfCancellationRequested(); + using var stream = entry.Open(); + var definition = JsonSerializer.Deserialize(stream, JsonOptions); + if (definition is null) + { + _logger.LogWarning("Skipping empty server definition backup entry {Entry}.", entry.FullName); + return false; + } + + definition.GoalState = DedicatedServerGoalState.Off; + definition.AutoStart = false; + File.WriteAllBytes(destination, JsonSerializer.SerializeToUtf8Bytes(definition, JsonOptions)); + return true; + } + catch (JsonException exception) + { + _logger.LogWarning(exception, "Skipping invalid server definition backup entry {Entry}.", entry.FullName); + return false; + } + } + private int RestoreWorldEntries( ZipArchive archive, DedicatedServerDefinition target, @@ -798,11 +848,18 @@ private int RestoreWorldEntries( var fullTarget = Path.GetFullPath(Path.Combine(baseDirectory, relative)); - // Zip-slip guard: the resolved path must stay inside its base directory. - if (!fullTarget.StartsWith(EnsureTrailingSeparator(baseDirectory), StringComparison.Ordinal)) - return null; + return IsPathWithinRoot(fullTarget, Path.GetFullPath(baseDirectory)) ? fullTarget : null; + } + + private static bool IsConfigurationServerDefinitionEntry(string entryName) + { + if (!entryName.StartsWith(DataPrefix, StringComparison.Ordinal)) + return false; - return fullTarget; + var relative = entryName[DataPrefix.Length..]; + var normalized = ToEntryPath(relative); + return StartsWithEntrySegment(normalized, "Magnetars") && + string.Equals(Path.GetFileName(normalized), "server.json", StringComparison.OrdinalIgnoreCase); } private static string? ResolvePrefixedExtractionTarget(string entryName, string prefix, string baseDirectory)