Compare commits

...
3 Commits
Author SHA1 Message Date
josh eb2a852fdc Add experimental metrics insights, comparisons, and scope filters 2026-09-27 22:34:56 -04:00
josh 3922e28ca8 Improve metrics clarity, exploration, and refresh reliability 2026-09-27 22:12:11 -04:00
josh 981fb27c1c Preserve deleted server history
Retain historical server identities, results, and evidence after deletion. Mark archived entries as Deleted and prevent old work from targeting servers that reuse the same name.

Fixes #16.
2026-09-27 21:48:55 -04:00
81 changed files with 2845 additions and 359 deletions
+19
View File
@@ -37,6 +37,14 @@ removal, and restart-recovery state appears alongside affected servers. Fleet
membership and operation progress refresh every ten seconds, with live server
status between refreshes. Failed refreshes keep the last snapshot visible.
**Metrics** explores recorded server use by time range, game, and map. It includes
occupied/idle/unknown timelines, sortable rankings, previous-period comparisons,
map capacity pressure, and an activity heatmap in the selected timezone. Hosting
experiments suggest changes from explicit rules, including tentative advice from
sparse observations; they never change the fleet automatically. Background refresh
keeps the last report visible if it fails. See [Metrics](docs/metrics.md) for the
definitions, evidence limits, and experimental rules.
## Stack
- SvelteKit (adapter-node) + TypeScript
@@ -382,6 +390,17 @@ RCON parsing (fake UDP socket).
Campaigns (`/campaigns`) tracks script/config updates, drift reconciliation, restart progress, file diffs, and rollbacks. Existing campaign records are preserved in the original database tables. The campaign API lives at `/api/campaigns`; legacy detail API endpoints remain available for compatibility.
Deleting a server preserves its name, recorded beta progress and test evidence,
campaign results and file diffs, provisioning and maintenance history, and match
records. Historical entries show **Deleted** and omit live server links and
actions. Reusing the same server name starts a new instance; old records remain
marked deleted, and old work cannot target the replacement. Deleting a participant
pauses an open beta; roll it back and open a new beta to test the replacement.
Normal history retention still applies. Records erased by earlier versions cannot
be restored by this upgrade. Existing history is marked deleted where the server
is already absent; instances that reused a name before upgrading cannot always be
distinguished.
Provisioning (`/provisioning`, `/api/provisioning`) owns Create, Start, Restart, Rebuild, Stop, and Destroy: request persistence, eligibility and occupancy checks, execution, recovery, and history. Campaigns, betas, and scheduled maintenance request lifecycle work through Provisioning and link to the resulting operation. They retain their file, test-evidence, and version-verification workflows. Pending automated restarts appear in Provisioning before execution. History survives server removal. New creation requests return HTTP 202 with an operation ID and open the provisioning detail page. Creation is complete only when Komodo reports healthy and RCON or fresh telemetry verifies game status with an engine map that exactly matches the selected map. Queued requests reserve their server name and port; queued creation and pending health checks survive application restarts. The detail page records startup progress and explains any health or map mismatch that is still blocking completion.
Each server deployment opens a detail page with its flow and timestamped activity. Destroy uses an in-app confirmation modal and immediately returns a queued deployment (`DELETE /api/servers/:name` returns HTTP 202 with `deploymentId`). The worker waits for a fresh zero-player RCON or telemetry report, or a verified stopped container, before teardown. Occupied reports from either source block teardown; servers without current verification remain queued and are checked each worker tick. Waiting requests survive application restarts, while interrupted teardown is marked for inspection. Completed destruction stays on the provisioning detail page.
+109
View File
@@ -0,0 +1,109 @@
Improve Metrics so an operator can quickly answer how much hosting time had players, which maps receive play, where measured capacity stays full, and whether the recorded history supports a decision.
This plan combines three UI/UX reviews (information architecture, visual design, and interaction/accessibility) and two software engineering reviews (frontend and metrics data). The reviewers exchanged proposals and challenged the tradeoffs. No product changes are included.
The review covered the current Svelte page, collector, SQLite aggregation, shared design system, and tests. Desktop (1440px) and mobile (390px) screenshots of the existing build were inspected using an isolated synthetic database. That fixture contained six server names, seven days of samples, an outage, unknown status, and missing capacity. It establishes layout behavior, not production usage patterns.
| Finding | Consequence | Planned response |
| --- | --- | --- |
| “Servers utilized” is occupied time divided by known server time. | It can be mistaken for a percentage of server count. | Name the measure “Time with players” and show its denominator. |
| Four equal cards include complementary occupied/idle percentages. At 390px, the chart heading starts around 970px down the page. | Repeated summary information delays useful analysis, especially on phones. | Two compact headline metrics and a nearby data-quality strip. |
| The chart has endpoint labels but no calibrated vertical ticks. Partial edge periods look like ordinary periods. | A short final bar can look like declining activity. | Add clear axes, explicit partial-period treatment, and inspectable intervals. |
| “Updated” is the report calculation time. | A fresh report can contain old samples. | Separate “Report refreshed” from “Last collected.” |
| Capacity can be absent even when telemetry supplies valid occupancy. | “Unknown” pressure can be mistaken for a failure or no pressure. | Keep known-capacity duration beside every pressure figure and explain unavailable capacity. |
| Tables have fixed ordering and limited comparison affordances. | Operators must scan raw values to identify useful patterns. | Sorting, restrained comparison bars, readable identities, and local search. |
| Metrics aggregate historical samples by server name without an instance identifier. | Linking a row to a current server can connect old history to a replacement. | Keep historical row identities unlinked in the first release. |
| A synthetic 118,806-row fixture took about 0.99–1.35 seconds to read for the seven-day view. | Synchronous aggregation can block other application requests. | Profile and reduce repeated aggregation work before expanding exploration. |
The proposed page composition is:
```text
Metrics Refresh
24 hours / 7 days / 30 days selected range · timezone
Time with players Player-hours
58.6% 1,350.5
555.7 of 948.8 known server-hours Players × observed time
Known status … · Unknown status … · Last collected …
Report refreshed … · Samples retained from …
Server time over time occupied / idle / unknown
[stacked server-hours chart with axes and marked partial periods]
[Period selector] [Previous] [Next] [selected interval and values]
View exact time data
Map play time & capacity
[sortable ranking; pressure and known-capacity time stay together]
Server utilization Find a server…
[sortable table; friendly names and secondary technical IDs]
How these estimates work
```
The example values above illustrate the synthetic preview, not a target or recommendation.
1. Establish a precise, efficient report contract.
Keep the existing 24-hour, seven-day, and 30-day windows, 45-second observation cap, UTC bucket boundaries, and 31-day retention. Add typed report metadata for the latest recorded collection sample, retained sample bounds, and current registered-server count. Return the actual clipped start/end and partial flag for each bucket. Distinguish report calculation time from sample time. Collection stops when no servers are registered, so old history alone does not imply a failed collector. Prefer factual timestamps; do not apply a ticking 45-second stale threshold to a page refreshed only every minute.
Keep the formulas explicit: occupied percentage = occupied server-hours / known server-hours; known server-hours = occupied + idle. Unknown samples are separate. Long collection gaps are excluded, so known / (known + unknown) must not be called collection coverage. Earliest retained sample does not establish first-ever collection or uninterrupted history.
Pressure remains map wall time: “All lobbies full for X during Y with known capacity.” It requires known occupancy and capacity for every tracked lobby in that recorded game/map group. It is not a measure of failed joins or unmet demand. Missing capacity must remain unknown; do not assume four slots or carry old capacity forward without a separate validity policy.
Profile realistic retained histories, then consolidate repeated sample/bucket aggregation while preserving interval clipping and pressure semantics. The current implementation queries each chart bucket separately in addition to summary and table queries. One profiled seven-day read spent roughly 349ms on summary, 158ms on servers, 441ms on maps, and 109ms across chart buckets: reducing bucket queries alone misses most of the cost. Use parity checks against existing calculations before replacing it; evaluate query plans before adding indexes. Avoid transferring raw samples to the browser. Do not introduce permanent rollups or a background analytics subsystem unless profiling justifies them.
Primary files: `src/lib/server/fleet/metrics.ts`, `src/routes/metrics/+page.server.ts`, `tests/unit/metrics.test.ts`. Add a shared metrics report type and range parser where both page and API can use them. Schema or collector changes are conditional on measured need.
2. Make refresh and report states dependable.
Retain server rendering for the initial page. Add a narrow read-only `/api/metrics?days=…` endpoint using the same report builder and range validation for background refresh. Use the existing `pollVisible` helper to avoid overlapping refreshes and refresh on return to a visible tab. The installed SvelteKit invalidation path can navigate into an error page; catching `invalidateAll()` alone does not guarantee preservation of the last successful report.
Store the last successful report, update it only after a successful response, and show refreshing, stale/error, and retry feedback inline. Give requests a timeout, abort on navigation/unmount, and use a request/scope identifier so an old response cannot overwrite a newly selected range. Keep range state in the URL. Preserve table search/sort and selected bucket through refresh; reconcile bucket selection when the range changes. Optional shareable table state should use shallow URL updates without rerunning aggregation. Avoid moving keyboard focus or announcing the entire report each minute.
Distinguish no retained samples, no samples in this selected range, unknown-only observations, unavailable capacity, and a failed report refresh. An empty range should offer a wider range. Do not promise that collection will begin if no servers are registered, or claim “first observations” solely because retention left the table empty.
Primary files: the metrics route, a new `src/routes/api/metrics/+server.ts`, and existing `src/lib/ui/poll.ts` usage. Keep shared-helper changes minimal.
3. Rebuild the summary and timeline around readable evidence.
Replace four equal cards with “Time with players” and “Player-hours.” Put the occupied numerator and known-time denominator beneath the percentage. Keep idle in the chart/table and known/unknown time in a compact strip. Show collection time separately from report refresh; display sparse-history caveats without inventing a confidence score.
Reuse `Stat` with numeric tweening disabled, existing cards/inputs/empty states, and semantic design tokens. Keep blue for occupied time, a quieter neutral for idle, and patterned unknown. Define chart colors as tokens rather than scattered literals. Use short-duration formatting so a real observation never appears as `0.0 h` just because it is brief. Keep player-hours and server-hours explicitly named.
Preserve absolute stacked server-hours as the default chart. Add three to five readable vertical ticks, intermediate time labels, marked partial edge buckets, and a persistent selected-period readout. The readout shows the exact interval in the selected timezone, occupied/idle/unknown server time, player-hours, and occupied percentage of known time. UTC-aligned buckets displayed in a local timezone must not be described as local calendar days.
Use a labelled native period selector and Previous/Next buttons for keyboard and touch access, with pointer selection as a convenience. Keep interactive controls and the readout outside any chart region exposed as `role="img"`. Keep the exact-data table with semantic headers. A bucket with no captured time needs an explicit “No captured time” readout; do not equate it with an idle fleet. Preserve selection by bucket key across minute refreshes.
On phones, keep the summary compact enough to reach the chart without scrolling through four tall cards. Allow controls to wrap and reduce tick density; do not remove units or denominators to make the view fit. Use a small metrics-specific component set rather than introducing a general dashboard framework or chart dependency.
4. Make the map and server rankings useful.
Keep both tables full width. Right-align numeric values, use tabular numerals, add keyboard-operable sort buttons with `aria-sort`, and define deterministic ties and missing-value placement. Keep the map default as player-hours descending. Give the server table an explicit default matching the displayed measure: occupied percentage descending, then known time, with unavailable percentages last.
Add restrained proportional player-hour bars to map rows. Keep pressure duration, its percentage, and known-capacity duration adjacent. Avoid severity colors or “add servers” recommendations without a defined policy and sufficient evidence. Known-capacity duration is a wall-time quantity, not server-hours.
Use friendly server names with raw identifiers available as secondary text. Preserve exact recorded game/map grouping and variants. Current collection stores friendly configured variants when resolvable; legacy engine-map strings can be ambiguous, so do not silently relabel them as one specific variant.
Add clearly labelled table-only search, starting with “Find a server”; it must not imply that the headline metrics or chart were filtered. Keep historical rows unlinked in the first release. A later explicitly labelled “Open current server” action could be added separately, but current registration alone cannot prove historical identity.
On narrow screens retain identity, the primary measure, and its denominator. Make full details available through a compact row disclosure or a labelled, keyboard-scrollable table region with sticky identity. Test which approach fits actual row lengths before choosing it. Empty searches offer a clear/reset action.
5. Validate the completed behavior and update usage documentation.
Extend calculation tests for new metadata and clipped intervals, then compare optimized results to the existing implementation across fixture scenarios. Test refresh errors, retry, visibility changes, overlapping refresh attempts, and responses arriving after a range switch. Prefer behavior checks over tests that duplicate markup.
Review no samples, a first sample with zero elapsed time, sparse history, all-unknown status, telemetry-only occupancy, missing capacity, nonzero durations under a minute, collection outages, changing fleet membership, partial edge buckets, timezone/DST presentation, removed/reused server names, and T6 variants. Verify sorting with unavailable percentages and equal values.
Inspect 390px, tablet, and desktop layouts; keyboard-only selection/sorting/table access; touch selection; readable focus states; reduced motion; and screen-reader descriptions. Ensure minute refresh preserves interaction state and background failures keep the last successful report visible. Benchmark 24-hour, seven-day, and 30-day queries against representative retained data, and check impact on concurrent application requests.
Run the relevant unit tests, `npm run check`, and `npm run build`. Update `docs/metrics.md` to match the labels, states, and counting rules. The review baseline passed the existing four metrics tests and 23 status/observation tests; that does not replace validation of the eventual implementation.
The team accepted two primary metrics, visible data-quality context, an absolute-hours chart, progressive detail, and small native interaction controls. The engineering review changed two early UX ideas: live historical row links are deferred because names can be reused, and “Last collected” replaces wording that could imply a fresh successful game observation. It also added background fetch isolation and measured query work to the implementation scope.
Defer a default percentage-only chart, fleet-wide “all full” KPI, period-over-period deltas, heatmaps, automatic hosting recommendations, and global game/map filters. A percentage-only chart can hide tiny denominators; map pressure intervals overlap and cannot be summed into a meaningful fleet total. Comparisons and heatmaps need fair coverage treatment. Global filters should follow later as a complete query scope applied consistently to summary, timeline, and both tables, with URL state.
Delivery can be split into three reviewable changes: (1) report metadata, measured query improvements, and calculation tests; (2) refresh handling plus summary/timeline and report states; (3) rankings, mobile/accessibility refinement, and documentation. The data contract comes first; frontend layout work can proceed against that agreed contract in parallel. The result should let an operator read utilization correctly, see its evidence limits, inspect a period, and find relevant maps or servers without treating missing data as zero.
After approval, the base was implemented and committed as `3922e28`. The user also authorized all previously deferred features as experiments. The panel reconvened after that commit; [the review and revised decisions](metrics-review.md) supersede the deferrals above while preserving explicit evidence and metric definitions.
+36
View File
@@ -0,0 +1,36 @@
The metrics panel reconvened after commit `3922e28` to review the implemented base and revisit the features deferred in the original plan. The user authorized proceeding with all of them, including experiments based on limited evidence.
The base passed 711 unit tests, Svelte checking with no errors or warnings, and a production build. Firefox checks exercised combined search/sort state, preservation across range changes, pointer and keyboard chart selection, failed background refresh with retained data, successful retry, and 390px layout without page overflow. These checks caught and fixed a shallow-URL reactivity issue before the commit. Calculation parity against the previous implementation held within `1e-7` on a 118,806-row synthetic fixture. Seven-day query time fell from approximately 1,369ms to 367–408ms in the uncontended comparison.
The post-commit engineering and UX reviews found no blocking issue. Two small follow-ups were accepted: neutral copy for samples with zero elapsed time, and a polite announcement when a user selects a chart period. Background refresh should not repeatedly announce the chart.
The team revisited the original objections as design constraints:
| Feature | Original concern | Agreed experiment |
| --- | --- | --- |
| Percentage chart | Normalization hides sample volume. | Default to share of captured server time, including unknown, with an Hours switch and exact durations in the selected-period readout. |
| Fleet pressure score | Map intervals overlap and cannot be added into elapsed fleet time. | Pool full map-hours divided by known-capacity map-hours; name the denominator explicitly and show how many map groups contribute. |
| Previous-period changes | Retained and observed history may differ substantially. | Show comparisons from available samples, including both denominators and a partial-history label. Utilization changes are percentage points. |
| Activity heatmap | Sparse observations can look like stable recurring demand. | Show duration-weighted occupancy by local weekday/hour, with known/unknown time, contributing dates, and no-data cells distinct from observed zero. |
| Hosting recommendations | Occupancy and saturation do not prove unmet demand. | Generate advisory, reversible hosting experiments from visible rules. Include evidence and tentative labels; never automatically create or remove servers. |
| Global filters | Inconsistent scopes would make the page misleading. | Apply exact game/map scope to every aggregate, comparison, heatmap, recommendation, and ranking; preserve collection boundaries before filtering. |
| Historical server links | A name may have been reused for a new instance. | Label the action “Open current server” and show it only when a current registration exists. Historical totals remain grouped by name. |
The agreed hierarchy keeps the two headline cards and core timeline. Compact scope controls sit near the time-range control. Pressure, comparisons, advisory experiments, and the secondary heatmap follow the timeline so the added features do not crowd out the primary task on mobile. Use neutral comparison colors, visible units, and concise evidence next to each recommendation.
The original plan remains as the record of the initial conservative scope. These decisions supersede its deferrals; limited data does not disable a feature when a defined calculation or explicitly tentative experiment is possible. An unavailable denominator still remains unavailable rather than becoming an invented number.
All seven additions were implemented. The expanded full suite passed 731 tests;
subsequent refresh-response hardening passed its focused tests, including a new
malformed-response case. Svelte checking reported no errors or warnings and the
production build passed. Firefox exercised the percentage/Hours switch with
preserved table state, current-registration links, whole-report game/map filters,
empty scopes, heatmap keyboard selection and timezone changes, saved preference
adoption, and explicit timezone precedence. The final mobile report fits at
390px without page overflow.
The combined 30-day report took 443–446ms on the seven-day, 118,806-row fixture.
A separate full-retention fixture with six servers and 535,680 rows took
1.96–3.30 seconds for the combined 30-day report. The queries still run
synchronously; that measured full-retention cost remains an improvement target.
These local synthetic timings are not production guarantees.
+76
View File
@@ -3,6 +3,75 @@
Metrics is a primary navigation tab at `/metrics` (keyboard shortcut: `g m`).
Choose the past 24 hours, 7 days, or 30 days. The page refreshes every minute.
The overview shows **Time with players** (occupied server-hours divided by known
server-hours) and **Player-hours**. Known and unknown status time remain visible
alongside the totals. **Last collected** is the newest recorded batch, including
unknown checks; **Report refreshed** is when this report was calculated. Collection
stops when no servers are registered. Earliest retained observations do not prove
continuous history or the original installation date.
Background refresh keeps the last successful report visible on failure and offers
Retry. Returning to a visible tab refreshes immediately. The selected range is
stored in the URL, and refreshes preserve table controls and chart selection.
Game and map filters apply to the whole report, including its comparisons,
recommendations, and heatmap. Map choices preserve the exact recorded game/map
pair. Range and scope are shareable through the URL. Table searches remain local.
The timeline defaults to **Share of captured time**: each stack includes occupied,
idle, and unknown server time, normalized to 100%. This denominator differs from the
headline utilization percentage, which excludes unknown time. Switch to **Hours**
to compare absolute server time. Periods without captured time remain gaps.
The timeline has labelled axes and marks partial edge periods. Select a period,
use Previous/Next, or select a bar to inspect its exact interval and values. The
time table supplies the same figures for comparison and assistive technology.
Zero captured time is distinct from an observed empty server. Short durations use
seconds or minutes so a real observation is not rounded to zero.
Map and server tables support numeric sorting and local search. Their searches
apply only to the named table. Known-capacity duration stays next to all-full time.
Server identities retain the technical name; rows describe history grouped by
name, which may include different instances if a name was reused. **Open current
server** appears only when that name is currently registered; its destination
represents the current registration rather than every instance in the history.
**Explore this period** adds experimental decision aids below the main timeline:
- **Map capacity pressure** pools all-full map-hours divided by known-capacity
map-hours across the selected map groups. Overlapping map intervals are counted
separately. The score is not elapsed fleet time or a fleet health score; its
denominator and contributing map count remain visible.
- **Previous-period comparisons** use the preceding equally long window with
identical filters. Utilization changes are percentage points; player-hours
changes are relative percentages. A previous observed zero can show **New**.
Missing evidence remains unavailable. Partial retained history is labelled;
a 30-day comparison usually has little previous history because retention is
only 31 days. Both periods' known time is shown. Bounds cannot establish that
collection was uninterrupted, and changes are not adjusted for fleet size.
- **Activity heatmap** groups captured time by weekday and hour in its displayed
timezone. Color represents occupied / known server time. Exact cell values
include unknown time and contributing dates. No-data cells differ from observed
zero utilization; repeated DST hours contribute their actual elapsed time.
- **Hosting experiments** are generated automatically from the selected map
observations. They are advisory and never create, restart, or destroy a server.
Hosting Rules v1 deliberately offer experiments even with small samples:
| Recorded condition | Suggested experiment |
| --- | --- |
| At least 20% of known capacity time had every lobby full | Trial one additional lobby during busy hours. |
| Capacity unavailable and players present for at least 80% of known server time | Tentatively trial an extra lobby using occupancy as a proxy. |
| Players present for at most 10% of known time, with under 1% measured pressure or unavailable capacity | Review one idle lobby or a shorter hosting window while preserving access to the map. |
| Occupancy unavailable | Check telemetry/RCON reporting and watch for known player counts. |
| Other observations | Check missing capacity or keep the lineup and watch peaks. |
Each suggestion includes evidence and an outcome to watch. Unknown capacity,
less than one hour of relevant known evidence, or substantial unknown captured
status makes the suggestion tentative. These thresholds are product hypotheses;
occupancy does not establish failed joins or a need for additional capacity.
Suggestions use historical observations. Check the current lineup before trying
a change; a retained map may no longer have a hosted lobby.
- Utilized means a successful status response reported at least one player.
- Idle means a successful response reported zero players.
- Unknown covers failed checks, provisioning, and unavailable containers; it is excluded from utilization percentages.
@@ -14,3 +83,10 @@ Observations are recorded by the existing status worker. Each remains valid unti
Capacity comes from `sv_maxclients` / `sv_maxClients` in the status response. When unavailable, the UI displays unknown rather than assuming a four-player limit. Capacity coverage is the map time for which every tracked lobby reported both occupancy and capacity.
Collection begins after this feature is installed; previous occupancy cannot be reconstructed. Samples are retained for 31 days and pruned during collection. They survive removal of a server and contain no player identities. The additive database table is created automatically on startup.
`GET /api/metrics` supplies the same report as the page and disables response
caching. Query parameters are `days` (1, 7, or 30), `game`, `map` (requires game),
and `timeZone` (an IANA zone). Invalid ranges and zones fall back to the defaults;
invalid scope syntax is normalized. Fleet collection timestamps remain separate
from selected-scope evidence timestamps. Collection boundaries are determined
before scope filtering, so samples from other groups still end a batch interval.
+2 -2
View File
@@ -82,9 +82,9 @@ export interface BetaTestView {
plan: BetaPlan;
outcome: string;
reason: string | null;
servers: Array<{ server: string; phase: number; passed: boolean; progress: RequirementProgress[]; observingAt?: string | null;
servers: Array<{ server: string; server_deleted_at: string | null; phase: number; passed: boolean; progress: RequirementProgress[]; observingAt?: string | null;
observation?: { lastGameplayAt: string | null; lastEvent: string | null; players: number | null; round: number | null; matchStarted: boolean; gameEnded: boolean; healthy: boolean | null; healthCheckedAt: string | null } }>;
evidence: Array<{ id: number; server: string; received_at: string; kind: string; payload: string }>;
evidence: Array<{ id: number; server: string; server_deleted_at: string | null; received_at: string; kind: string; payload: string }>;
}
export interface BetaPlanIssue { field: string; message: string; requirementId?: string }
+2 -1
View File
@@ -66,9 +66,10 @@ export interface CollectionSummary {
export interface CollectionDetail {
collection: CollectionSummary;
retainedServers: string[];
retainedServerDeletedAt?: Record<string, string>;
queue: { concurrency: number; active: { id: string; server: string; state: string; stage: string }[]; queued: number };
servers: { id: string; server: string; action: 'create' | 'destroy'; port: number | null; map_slug: string | null; state: string; stage: string;
started_at: string; finished_at: string | null; error: string | null; server_exists: number; queuePosition: number | null }[];
started_at: string; finished_at: string | null; error: string | null; server_exists: number; server_deleted_at?: string | null; queuePosition: number | null }[];
}
export interface LaunchPreview {
+1
View File
@@ -22,6 +22,7 @@ export interface PlayerMapRecord extends PlayerStats {
export interface PlayerMatch {
id: number;
server: string;
server_deleted_at?: string | null;
mapName: string;
startedAt: string;
lastSeen: string;
+1
View File
@@ -4,6 +4,7 @@ export const MIN_RECORDED_ROUND = 2;
export interface ServerMatch {
id: number;
server: string;
server_deleted_at?: string | null;
map: string;
startedAt: string;
endedAt: string | null;
+73
View File
@@ -0,0 +1,73 @@
import type { MapMetrics } from './metrics.js';
import { metricDuration, metricPercent } from './ui/metrics.js';
/** Product hypotheses for advisory experiments; these are not demand forecasts. */
export const HOSTING_RULES = {
pressureExpansion: 0.2,
occupancyExpansion: 0.8,
idleUtilization: 0.1,
quietPressure: 0.01,
minimumEvidenceHours: 1
} as const;
export interface MetricRecommendation {
game: string;
map: string;
kind: 'expand' | 'consolidate' | 'capacity' | 'watch';
title: string;
reason: string;
evidence: string;
outcome: string;
tentative: boolean;
}
/** Apply the same visible rules to all selected maps, including sparse observations. */
export function metricRecommendations(maps: MapMetrics[]): MetricRecommendation[] {
return maps.map(map => {
const utilization = map.observed > 0 ? map.active / map.observed : null;
const pressure = map.capacityObserved > 0 ? map.full / map.capacityObserved : null;
const knownShare = map.observed + map.unknown > 0 ? map.observed / (map.observed + map.unknown) : 0;
const base = {
game: map.game,
map: map.map,
evidence: `${metricDuration(map.active)} with players / ${metricDuration(map.observed)} known server time (${metricPercent(map.active, map.observed)}). ` +
(pressure === null ? 'Capacity unavailable.' : `All lobbies full ${metricDuration(map.full)} / ${metricDuration(map.capacityObserved)} known map time (${metricPercent(map.full, map.capacityObserved)}).`),
tentative: map.observed < HOSTING_RULES.minimumEvidenceHours || pressure === null || map.capacityObserved < HOSTING_RULES.minimumEvidenceHours || knownShare < 0.8
};
if (utilization === null) return {
...base, kind: 'capacity' as const, title: map.unknown > 0 ? 'Check occupancy reporting' : 'Let observations accumulate',
reason: map.unknown > 0
? 'Captured status is unknown. Check a server’s telemetry or RCON diagnostics before choosing a hosting change.'
: 'This map has no elapsed observations in the selected window. Wait for another status check or try a wider range.',
outcome: 'Watch for known server time in the next report.', tentative: true
};
if (pressure !== null && pressure >= HOSTING_RULES.pressureExpansion) return {
...base, kind: 'expand' as const, title: 'Trial one additional lobby',
reason: 'Recorded lobbies were all full for at least 20% of known capacity time. Try one extra lobby during busy hours.',
outcome: 'Compare all-full time and use of the additional lobby over the next comparable period.'
};
if (pressure === null && utilization >= HOSTING_RULES.occupancyExpansion) return {
...base, kind: 'expand' as const, title: 'Trial an extra lobby during busy hours',
reason: 'Players were present for at least 80% of known server time. Capacity is unknown, so this experiment uses occupancy as a proxy, not evidence of saturation.',
outcome: 'Check whether players use the extra lobby, and measure capacity if the server reports it.', tentative: true
};
if (utilization <= HOSTING_RULES.idleUtilization && (pressure === null || pressure < HOSTING_RULES.quietPressure)) return {
...base, kind: 'consolidate' as const, title: 'Trial less idle hosting',
reason: 'Players were present for at most 10% of known time, with less than 1% measured pressure or unavailable capacity. Review one idle lobby or a shorter hosting window; preserve access to this map.',
outcome: 'Watch for increased all-full time or loss of player-hours, and restore the lineup if the trial hurts availability.'
};
if (pressure === null) return {
...base, kind: 'capacity' as const, title: 'Check capacity during busy hours',
reason: 'Occupancy is recorded, but player limits are unavailable. Check capacity reporting while keeping the current lineup.',
outcome: 'Look for known-capacity time and all-full periods before the next hosting trial.'
};
return {
...base, kind: 'watch' as const, title: 'Keep the lineup and watch peaks',
reason: 'These observations do not trigger the expansion or idle-hosting rules. Keep the current lineup for another comparison period.',
outcome: 'Watch whether occupied time and measured capacity pressure change.'
};
}).sort((a, b) => {
const priority = { expand: 0, consolidate: 1, capacity: 2, watch: 3 };
return priority[a.kind] - priority[b.kind] || a.map.localeCompare(b.map) || a.game.localeCompare(b.game);
});
}
+109
View File
@@ -0,0 +1,109 @@
import { DEFAULT_TIME_ZONE, validTimeZone } from './time-zone.js';
export type MetricsDays = 1 | 7 | 30;
export function parseMetricsDays(value: string | null | undefined): MetricsDays {
const days = Number(value ?? 7);
return days === 1 || days === 7 || days === 30 ? days : 7;
}
export interface MetricsScope {
game: string | null;
map: string | null;
timeZone: string;
}
export interface MetricsQuery extends MetricsScope {
days: MetricsDays;
}
/** A map is an exact recorded label within a game, never a cross-game match. */
export function parseMetricsQuery(params: URLSearchParams): MetricsQuery {
const requestedGame = params.get('game')?.trim() ?? '';
const game = /^[a-z0-9_-]{1,32}$/.test(requestedGame) ? requestedGame : null;
const requestedMap = params.get('map');
const map = game && requestedMap && requestedMap.trim() && requestedMap.length <= 200 && !/[\u0000-\u001f\u007f]/.test(requestedMap)
? requestedMap : null;
const requestedZone = params.get('timeZone');
return { days: parseMetricsDays(params.get('days')), game, map,
timeZone: validTimeZone(requestedZone) ? requestedZone : DEFAULT_TIME_ZONE };
}
/** All durations are hours. Observed is known server time, not collection coverage. */
export interface Utilization {
observed: number;
active: number;
idle: number;
unknown: number;
playerHours: number;
}
export interface ServerMetrics extends Utilization {
server: string;
/** A current registration with this name exists; historical identity may differ. */
currentRegistered: boolean;
}
export interface MapMetrics extends Utilization {
game: string;
map: string;
/** Map wall time, not summed server time. */
full: number;
capacityObserved: number;
}
export interface MetricPoint extends Utilization {
/** UTC bucket boundary, including when the selected interval starts later. */
at: number;
startAt: number;
endAt: number;
partial: boolean;
}
export interface PreviousMetrics {
since: number;
until: number;
summary: Utilization;
firstSampleAt: number | null;
lastSampleAt: number | null;
/** Retained scope evidence starts late or is absent, not a completeness guarantee. */
partialHistory: boolean;
}
export interface HeatmapCell extends Utilization {
/** Monday = 0, Sunday = 6. */
weekday: number;
hour: number;
/** Distinct local dates with any captured time, including unknown status. */
distinctDates: number;
}
export interface PooledPressure {
/** Share of known map-hours with every tracked lobby full, from 0 to 100. */
value: number | null;
fullMapHours: number;
knownMapHours: number;
coveredMaps: number;
totalMaps: number;
}
export interface MetricsData {
days: MetricsDays;
since: number;
now: number;
/** Retained sample timestamps describe collection, not gameplay freshness. */
firstRetainedSampleAt: number | null;
latestSampleAt: number | null;
registeredServerCount: number;
scope: MetricsScope;
scopeOptions: { games: string[]; maps: { game: string; map: string }[] };
scopeFirstSampleAt: number | null;
scopeLatestSampleAt: number | null;
summary: Utilization;
servers: ServerMetrics[];
maps: MapMetrics[];
points: MetricPoint[];
previous: PreviousMetrics;
heatmap: { timeZone: string; cells: HeatmapCell[] };
pooledPressure: PooledPressure;
}
+1
View File
@@ -22,6 +22,7 @@ export interface PlutoniumVersion {
export interface MaintenanceJob {
id: number;
server: string;
server_deleted_at?: string | null;
state: 'queued' | 'restarting' | 'done' | 'failed' | 'cancelled';
target_revision: number | null;
requested_at: string;
+4
View File
@@ -7,6 +7,7 @@ import { requirement, type BetaPlan, type BetaRequirement } from '../../beta-pla
import { emptyMetrics } from '../fleet/beta-evaluation.js';
import { backfillLeaderboard } from '../fleet/leaderboard-ingest.js';
import { initializeFleetSettings } from './fleet-settings.js';
import { migrateServerHistory } from './server-history.js';
let _db: Database.Database | null = null;
@@ -124,6 +125,8 @@ function backfillMultiScriptJoins(handle: Database.Database): void {
FROM beta_servers bs
JOIN betas b ON b.id = bs.beta_id
WHERE b.script_id IS NOT NULL
AND bs.server_deleted_at IS NULL
AND EXISTS (SELECT 1 FROM servers WHERE name = bs.server)
AND NOT EXISTS (
SELECT 1 FROM beta_server_scripts x
WHERE x.beta_id = bs.beta_id AND x.server = bs.server AND x.script_id = b.script_id
@@ -329,6 +332,7 @@ export function db(): Database.Database {
{ name: 'mode', ddl: "ALTER TABLE server_launch_collections ADD COLUMN mode TEXT NOT NULL DEFAULT 'fill'" },
{ name: 'retained_servers', ddl: "ALTER TABLE server_launch_collections ADD COLUMN retained_servers TEXT NOT NULL DEFAULT '[]'" }
]);
migrateServerHistory(handle);
migrateTable(handle, 'server_collection_previews', [
{ name: 'mode', ddl: "ALTER TABLE server_collection_previews ADD COLUMN mode TEXT NOT NULL DEFAULT 'fill'" },
{ name: 'fleet_snapshot', ddl: 'ALTER TABLE server_collection_previews ADD COLUMN fleet_snapshot TEXT' }
+11 -4
View File
@@ -11,6 +11,7 @@ PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS leaderboard_matches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server TEXT NOT NULL,
server_deleted_at TEXT,
game TEXT NOT NULL,
map TEXT NOT NULL,
map_key TEXT NOT NULL,
@@ -181,7 +182,8 @@ CREATE INDEX IF NOT EXISTS idx_betas_script ON betas(script_id);
CREATE TABLE IF NOT EXISTS beta_servers (
beta_id INTEGER NOT NULL REFERENCES betas(id) ON DELETE CASCADE,
server TEXT NOT NULL REFERENCES servers(name),
server TEXT NOT NULL,
server_deleted_at TEXT,
had_original INTEGER NOT NULL, -- deprecated; see beta_server_scripts
prior_version_id INTEGER REFERENCES script_versions(id), -- deprecated; see beta_server_scripts
stage TEXT NOT NULL, -- 'deploying'|'awaiting-empty'|'restarting'|'awaiting-health'|'awaiting-match'|'ready'|'failed'
@@ -206,7 +208,7 @@ CREATE TABLE IF NOT EXISTS beta_test_plans (
CREATE TABLE IF NOT EXISTS beta_test_runs (
id TEXT PRIMARY KEY,
beta_id INTEGER NOT NULL REFERENCES betas(id) ON DELETE CASCADE,
server TEXT NOT NULL REFERENCES servers(name),
server TEXT NOT NULL,
phase INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
observing_at TEXT,
@@ -261,7 +263,7 @@ CREATE INDEX IF NOT EXISTS idx_beta_scripts_script ON beta_scripts(script_id);
-- lives in beta_file_snapshots.
CREATE TABLE IF NOT EXISTS beta_server_scripts (
beta_id INTEGER NOT NULL REFERENCES betas(id) ON DELETE CASCADE,
server TEXT NOT NULL REFERENCES servers(name),
server TEXT NOT NULL,
script_id INTEGER NOT NULL REFERENCES scripts(id) ON DELETE CASCADE,
had_original INTEGER NOT NULL,
prior_version_id INTEGER REFERENCES script_versions(id),
@@ -302,6 +304,7 @@ CREATE INDEX IF NOT EXISTS idx_deployments_started ON deployments(started_at DES
CREATE TABLE IF NOT EXISTS deployment_servers (
deployment_id TEXT NOT NULL REFERENCES deployments(id) ON DELETE CASCADE,
server TEXT NOT NULL, -- Campaign observation survives a rebuild's registry gap.
server_deleted_at TEXT,
files_changed INTEGER NOT NULL,
restart_state TEXT NOT NULL, -- pending | done | failed | manual | held
players INTEGER,
@@ -373,6 +376,7 @@ CREATE TABLE IF NOT EXISTS plutonium_versions (
CREATE TABLE IF NOT EXISTS maintenance_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server TEXT NOT NULL,
server_deleted_at TEXT,
state TEXT NOT NULL DEFAULT 'queued',
target_revision INTEGER,
requested_at TEXT NOT NULL,
@@ -411,6 +415,7 @@ CREATE INDEX IF NOT EXISTS idx_telemetry_requests_received ON telemetry_requests
CREATE TABLE IF NOT EXISTS server_deployments (
id TEXT PRIMARY KEY,
server TEXT NOT NULL,
server_deleted_at TEXT,
action TEXT NOT NULL CHECK (action IN ('create', 'start', 'restart', 'rebuild', 'stop', 'destroy')),
state TEXT NOT NULL,
stage TEXT NOT NULL,
@@ -486,7 +491,8 @@ CREATE TABLE IF NOT EXISTS server_launch_collections (
name TEXT NOT NULL,
created_at TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'fill',
retained_servers TEXT NOT NULL DEFAULT '[]'
retained_servers TEXT NOT NULL DEFAULT '[]',
retained_server_deletions TEXT NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS server_collection_members (
@@ -629,6 +635,7 @@ CREATE TABLE IF NOT EXISTS metric_samples (
CREATE TABLE IF NOT EXISTS watchdog_incidents (
id TEXT PRIMARY KEY,
server TEXT NOT NULL,
server_deleted_at TEXT,
kind TEXT NOT NULL CHECK (kind IN ('health','drift')),
state TEXT NOT NULL CHECK (state IN ('open','recovering','attention','resolved','dismissed')),
summary TEXT NOT NULL,
+130
View File
@@ -0,0 +1,130 @@
import type Database from 'better-sqlite3';
const historyTables = ['beta_servers', 'deployment_servers', 'server_deployments', 'leaderboard_matches', 'watchdog_incidents', 'maintenance_jobs'];
const now = "strftime('%Y-%m-%dT%H:%M:%fZ','now')";
/** Detach durable beta records without dropping their evidence, indexes, or activity triggers. */
function detachBetaHistory(handle: Database.Database): void {
for (const name of ['beta_servers', 'beta_test_runs', 'beta_server_scripts']) {
const { sql } = handle.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get(name) as { sql: string };
const replacement = sql.replace(/server\s+TEXT NOT NULL REFERENCES servers\(name\)/, 'server TEXT NOT NULL');
if (replacement === sql) continue;
const dependents = handle.prepare("SELECT sql FROM sqlite_master WHERE tbl_name = ? AND type IN ('index','trigger') AND sql IS NOT NULL")
.all(name) as { sql: string }[];
const columns = (handle.prepare(`PRAGMA table_info(${name})`).all() as { name: string }[]).map(col => col.name).join(', ');
handle.pragma('foreign_keys = OFF');
try {
handle.transaction(() => {
handle.exec(replacement.replace(name, name + '_history_new'));
handle.exec(`INSERT INTO ${name}_history_new (rowid, ${columns}) SELECT rowid, ${columns} FROM ${name};
DROP TABLE ${name}; ALTER TABLE ${name}_history_new RENAME TO ${name};`);
for (const dependent of dependents) handle.exec(dependent.sql);
if ((handle.pragma('foreign_key_check') as unknown[]).length) throw new Error('Server history migration found broken references');
})();
} finally { handle.pragma('foreign_keys = ON'); }
}
}
// A rebuild owns the replacement instance. Only its current operation and the
// Watchdog records observing it cross the deliberate gap in the live registry.
const removingRebuild = `SELECT d.id FROM server_deployments d
JOIN server_rebuild_jobs r ON r.deployment_id = d.id
WHERE d.server = OLD.name AND d.server_deleted_at IS NULL AND d.action = 'rebuild'
AND d.state IN ('waiting','running','failed','interrupted')
AND d.stage = 'Removing server files and registry' AND r.removed_at IS NULL
AND NOT EXISTS (SELECT 1 FROM server_deployments newer WHERE newer.server = d.server
AND newer.rowid > d.rowid)`;
/** Installed after additive migrations so existing databases can boot safely. */
export function migrateServerHistory(handle: Database.Database): void {
detachBetaHistory(handle);
handle.transaction(() => {
for (const table of historyTables) {
const columns = handle.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];
if (!columns.some(column => column.name === 'server_deleted_at')) handle.exec(`ALTER TABLE ${table} ADD COLUMN server_deleted_at TEXT`);
}
const collectionColumns = handle.prepare('PRAGMA table_info(server_launch_collections)').all() as { name: string }[];
if (!collectionColumns.some(column => column.name === 'retained_server_deletions')) {
handle.exec("ALTER TABLE server_launch_collections ADD COLUMN retained_server_deletions TEXT NOT NULL DEFAULT '{}'");
}
// Historical unfinished work cannot claim the next server with this name.
handle.exec(`DROP INDEX IF EXISTS idx_maintenance_pending;
CREATE UNIQUE INDEX idx_maintenance_pending ON maintenance_jobs(server)
WHERE state IN ('queued', 'restarting') AND server_deleted_at IS NULL;
DROP INDEX IF EXISTS idx_watchdog_active;
CREATE UNIQUE INDEX idx_watchdog_active ON watchdog_incidents(server,kind)
WHERE state != 'resolved' AND server_deleted_at IS NULL;
DROP TRIGGER IF EXISTS preserve_deleted_server_history;
CREATE TRIGGER preserve_deleted_server_history BEFORE DELETE ON servers
BEGIN
UPDATE server_launch_collections SET retained_server_deletions = json_patch(retained_server_deletions, json_object(OLD.name, ${now}))
WHERE EXISTS (SELECT 1 FROM json_each(retained_servers) WHERE value = OLD.name)
AND NOT EXISTS (SELECT 1 FROM json_each(retained_server_deletions) WHERE key = OLD.name);
UPDATE beta_servers SET server_deleted_at = ${now} WHERE server = OLD.name AND server_deleted_at IS NULL;
UPDATE deployment_servers SET server_deleted_at = ${now}
WHERE server = OLD.name AND server_deleted_at IS NULL AND deployment_id NOT IN (
SELECT campaign_id FROM watchdog_actions WHERE campaign_id IS NOT NULL AND operation_id IN (${removingRebuild}));
UPDATE watchdog_incidents SET server_deleted_at = ${now}
WHERE server = OLD.name AND server_deleted_at IS NULL AND id NOT IN (
SELECT incident_id FROM watchdog_actions WHERE operation_id IN (${removingRebuild}));
UPDATE maintenance_jobs SET server_deleted_at = ${now},
finished_at = CASE WHEN state IN ('queued','restarting') THEN ${now} ELSE finished_at END,
detail = CASE WHEN state IN ('queued','restarting') THEN COALESCE(detail || '; ', '') || 'Server deleted' ELSE detail END,
state = CASE WHEN state IN ('queued','restarting') THEN 'cancelled' ELSE state END
WHERE server = OLD.name AND server_deleted_at IS NULL;
UPDATE leaderboard_matches SET server_deleted_at = ${now},
ended_at = CASE WHEN status = 'active' THEN COALESCE(ended_at, ${now}) ELSE ended_at END,
status = CASE WHEN status = 'active' THEN 'interrupted' ELSE status END
WHERE server = OLD.name AND server_deleted_at IS NULL;
INSERT INTO server_deployment_events (deployment_id, created_at, message)
SELECT id, ${now}, 'Cancelled: server deleted' FROM server_deployments
WHERE server = OLD.name AND server_deleted_at IS NULL AND state IN ('queued','waiting','running','awaiting-health')
AND id NOT IN (${removingRebuild})
AND NOT (action = 'destroy' AND state = 'running' AND stage = 'Removing server files and registry');
UPDATE server_deployments SET state = 'cancelled', error = 'Server deleted', finished_at = ${now}
WHERE server = OLD.name AND server_deleted_at IS NULL AND state IN ('queued','waiting','running','awaiting-health')
AND id NOT IN (${removingRebuild})
AND NOT (action = 'destroy' AND state = 'running' AND stage = 'Removing server files and registry');
UPDATE server_deployments SET server_deleted_at = ${now}
WHERE server = OLD.name AND server_deleted_at IS NULL AND id NOT IN (${removingRebuild});
END;`);
if (!handle.prepare("SELECT 1 FROM settings WHERE key = 'server_history_v1'").get()) {
// Recover the availability of history already orphaned before this
// migration. Queued launches and rebuild registry gaps are not deletions.
const liveRebuilds = `SELECT d.id FROM server_deployments d JOIN server_rebuild_jobs r ON r.deployment_id = d.id
WHERE d.server_deleted_at IS NULL AND d.action = 'rebuild' AND (d.state IN ('waiting','queued','running','awaiting-health')
OR (d.state IN ('failed','interrupted') AND d.stage = 'Removing server files and registry'))
AND NOT EXISTS (SELECT 1 FROM server_deployments newer WHERE newer.server = d.server AND newer.rowid > d.rowid)`;
for (const table of historyTables) {
const exception = table === 'server_deployments'
? `AND (action != 'create' OR state IN ('done','warning') OR EXISTS (
SELECT 1 FROM server_deployments removal WHERE removal.server = server_deployments.server
AND removal.rowid > server_deployments.rowid AND removal.action = 'destroy' AND removal.state IN ('done','warning')))
AND id NOT IN (${liveRebuilds})`
: table === 'deployment_servers'
? `AND deployment_id NOT IN (SELECT campaign_id FROM watchdog_actions WHERE campaign_id IS NOT NULL AND operation_id IN (${liveRebuilds}))`
: table === 'watchdog_incidents'
? `AND id NOT IN (SELECT incident_id FROM watchdog_actions WHERE operation_id IN (${liveRebuilds}))` : '';
handle.exec(`UPDATE ${table} SET server_deleted_at = ${now} WHERE server_deleted_at IS NULL
AND NOT EXISTS (SELECT 1 FROM servers WHERE name = ${table}.server AND destroyed_at IS NULL) ${exception}`);
}
handle.exec(`UPDATE server_launch_collections SET retained_server_deletions = json_patch(retained_server_deletions,
(SELECT json_group_object(value, ${now}) FROM json_each(retained_servers) retained
WHERE NOT EXISTS (SELECT 1 FROM servers WHERE name = retained.value AND destroyed_at IS NULL)
AND NOT EXISTS (SELECT 1 FROM server_deployments d WHERE d.server = retained.value
AND d.server_deleted_at IS NULL AND d.action IN ('create','rebuild')
AND d.state IN ('queued','running','awaiting-health','waiting'))
AND NOT EXISTS (SELECT 1 FROM json_each(retained_server_deletions) WHERE key = retained.value)));`);
handle.exec(`UPDATE maintenance_jobs SET state = 'cancelled', finished_at = ${now}, detail = COALESCE(detail || '; ', '') || 'Server deleted'
WHERE server_deleted_at IS NOT NULL AND state IN ('queued','restarting');
UPDATE leaderboard_matches SET status = 'interrupted', ended_at = COALESCE(ended_at, server_deleted_at)
WHERE server_deleted_at IS NOT NULL AND status = 'active';
INSERT INTO server_deployment_events (deployment_id, created_at, message)
SELECT id, ${now}, 'Cancelled: server deleted' FROM server_deployments
WHERE server_deleted_at IS NOT NULL AND state IN ('queued','waiting','running','awaiting-health');
UPDATE server_deployments SET state = 'cancelled', error = 'Server deleted', finished_at = ${now}
WHERE server_deleted_at IS NOT NULL AND state IN ('queued','waiting','running','awaiting-health');
INSERT INTO settings (key, value) VALUES ('server_history_v1', '1');`);
}
})();
}
+12 -4
View File
@@ -9,6 +9,8 @@ import { applySample, applyGameEvent, emptyMetrics, evaluateRequirements, type T
export interface TestRun { id: string; beta_id: number; server: string; phase: number; created_at: string; observing_at: string | null; metrics: string }
export interface StoredPlan { beta_id: number; plan: string; outcome: string; reason: string | null }
const nowIso = () => new Date().toISOString();
const activeParticipant = (id: number, server: string) => !!db().prepare(`SELECT 1 FROM beta_servers
WHERE beta_id = ? AND server = ? AND server_deleted_at IS NULL`).get(id, server);
export const storedPlan = (id: number) => db().prepare('SELECT * FROM beta_test_plans WHERE beta_id = ?').get(id) as StoredPlan | undefined;
export const currentRun = (id: number, server: string) => db().prepare('SELECT * FROM beta_test_runs WHERE beta_id = ? AND server = ? AND retired_at IS NULL').get(id, server) as TestRun | undefined;
export function activity(id: number, message: string) { db().prepare('INSERT INTO beta_events (beta_id, message) VALUES (?, ?)').run(id, message); }
@@ -27,6 +29,7 @@ export function evidence(run: TestRun, eventId: string, kind: string, payload: u
/** Internal progress record; never sent to or required from the game script. */
export async function prepareRun(betaId: number, server: string, phase = 0): Promise<TestRun> {
if (!activeParticipant(betaId, server)) throw new Error('Original test server was deleted; start a new beta');
db().transaction(() => {
db().prepare('UPDATE beta_test_runs SET retired_at = ? WHERE beta_id = ? AND server = ? AND retired_at IS NULL').run(nowIso(), betaId, server);
db().prepare('INSERT INTO beta_test_runs (id, beta_id, server, phase, created_at, metrics) VALUES (?, ?, ?, ?, ?, ?)')
@@ -41,6 +44,7 @@ export function recordGameEvent(event: GameTelemetry): void {
JOIN beta_test_plans p ON p.beta_id = b.id
JOIN beta_servers s ON s.beta_id = b.id AND s.server = r.server
WHERE r.server = ? AND r.retired_at IS NULL AND b.state IN ('running','ready-promote')
AND s.server_deleted_at IS NULL
AND p.outcome IN ('active','passed') AND s.stage IN ('awaiting-health','observing','ready')
AND s.restarted_at IS NOT NULL`).all(event.server) as TestRun[];
for (const run of runs) {
@@ -53,6 +57,7 @@ export function recordGameEvent(event: GameTelemetry): void {
}
export async function observeRun(run: TestRun, plan: BetaPlan): Promise<void> {
if (!activeParticipant(run.beta_id, run.server)) return;
const m = readMetrics(run);
const row = getServer(run.server);
const server = row ? serverObservation(row) : null;
@@ -74,19 +79,22 @@ export function testView(betaId: number): BetaTestView | null {
const stored = storedPlan(betaId);
if (!stored) return null;
const plan = parseBetaPlan(JSON.parse(stored.plan));
const runs = db().prepare('SELECT * FROM beta_test_runs WHERE beta_id = ? AND retired_at IS NULL ORDER BY server').all(betaId) as TestRun[];
const runs = db().prepare(`SELECT r.*, s.server_deleted_at FROM beta_test_runs r
JOIN beta_servers s ON s.beta_id = r.beta_id AND s.server = r.server
WHERE r.beta_id = ? AND r.retired_at IS NULL ORDER BY r.server`).all(betaId) as Array<TestRun & { server_deleted_at: string | null }>;
return { plan, outcome: stored.outcome, reason: stored.reason,
servers: runs.map(run => {
const metrics = readMetrics(run);
const progress = evaluateRequirements(plan, metrics, run.phase);
const latest = db().prepare("SELECT received_at, kind, payload FROM beta_test_evidence WHERE run_id = ? AND kind IN ('game_start','round_change','heartbeat','game_end') ORDER BY id DESC LIMIT 1").get(run.id) as { received_at: string; kind: string; payload: string } | undefined;
const event = latest ? JSON.parse(latest.payload) : null;
return { server: run.server, phase: run.phase, passed: progress.every(r => r.passed), progress, observingAt: run.observing_at,
return { server: run.server, server_deleted_at: run.server_deleted_at, phase: run.phase, passed: progress.every(r => r.passed), progress, observingAt: run.observing_at,
observation: { lastGameplayAt: latest?.received_at ?? null, lastEvent: latest?.kind ?? null,
players: metrics.match?.players ?? null, round: event?.round ?? null,
matchStarted: metrics.match?.started !== undefined, gameEnded: metrics.match?.ended ?? false,
healthy: metrics.wasHealthy ?? null, healthCheckedAt: metrics.lastSample ? new Date(metrics.lastSample).toISOString() : null } };
}),
evidence: db().prepare(`SELECT e.id, r.server, e.received_at, e.kind, e.payload FROM beta_test_evidence e
JOIN beta_test_runs r ON r.id = e.run_id WHERE r.beta_id = ? ORDER BY e.id DESC LIMIT 200`).all(betaId) as BetaTestView['evidence'] };
evidence: db().prepare(`SELECT e.id, r.server, s.server_deleted_at, e.received_at, e.kind, e.payload FROM beta_test_evidence e
JOIN beta_test_runs r ON r.id = e.run_id JOIN beta_servers s ON s.beta_id = r.beta_id AND s.server = r.server
WHERE r.beta_id = ? ORDER BY e.id DESC LIMIT 200`).all(betaId) as BetaTestView['evidence'] };
}
+45 -4
View File
@@ -148,6 +148,7 @@ export interface BetaRow {
export interface BetaServerRow {
beta_id: number;
server: string;
server_deleted_at: string | null;
had_original: number; // deprecated; per-script data lives in beta_server_scripts
prior_version_id: number | null; // deprecated
stage: BetaStage;
@@ -225,6 +226,13 @@ export function listBetaServers(betaId: number): BetaServerRow[] {
.all(betaId) as BetaServerRow[];
}
/** History retains names, so operational callers must also check the participant's identity. */
export function isActiveBetaServer(betaId: number, server: string): boolean {
return !!db().prepare(`SELECT 1 FROM beta_servers bs JOIN servers s ON s.name = bs.server
WHERE bs.beta_id = ? AND bs.server = ? AND bs.server_deleted_at IS NULL AND s.destroyed_at IS NULL`)
.get(betaId, server);
}
export function listBetaScripts(betaId: number): BetaScriptRow[] {
return db()
.prepare('SELECT * FROM beta_scripts WHERE beta_id = ? ORDER BY script_id')
@@ -456,6 +464,9 @@ export async function deployBetaServers(betaId: number, names: string[]): Promis
return exclusive(betaId, () => deployBetaServersInternal(betaId, names));
}
async function deployBetaServersInternal(betaId: number, names: string[]): Promise<void> {
for (const name of names) {
if (!isActiveBetaServer(betaId, name)) throw new BetaError(`${name}: original beta server was deleted; start a new beta`, 'unknown-server');
}
const resolved = listBetaScripts(betaId).map(r => ({ script: getScript(r.script_id)!, version: getVersion(r.script_version_id)! }));
const serverRows = names.map(name => getServer(name)!);
// Split resolved artifacts into non-collapsing (each writes its own file)
@@ -484,10 +495,11 @@ async function deployBetaServersInternal(betaId: number, names: string[]): Promi
const updateStage = db().prepare(
`UPDATE beta_servers
SET stage = ?, error = ?, deployed_at = ?, updated_at = ?
WHERE beta_id = ? AND server = ?`
WHERE beta_id = ? AND server = ? AND server_deleted_at IS NULL`
);
for (const name of names) {
if (!isActiveBetaServer(betaId, name)) continue;
db().prepare("UPDATE beta_servers SET stage = 'deploying', deployed_at = ? WHERE beta_id = ? AND server = ?")
.run(nowIso(), betaId, name);
const serverRow = serverRows.find((r) => r.name === name)!;
@@ -503,6 +515,7 @@ async function deployBetaServersInternal(betaId: number, names: string[]): Promi
await mkdir(join(dest, dirname(script.target_rel_path)), { recursive: true });
if (existsSync(targetPath)) {
const priorBytes = await readFile(targetPath);
if (!isActiveBetaServer(betaId, name)) break;
insertSnapshot.run(
betaId,
name,
@@ -514,7 +527,9 @@ async function deployBetaServersInternal(betaId: number, names: string[]): Promi
db().prepare('UPDATE beta_server_scripts SET had_original = 1 WHERE beta_id = ? AND server = ? AND script_id = ?').run(betaId, name, script.id);
}
db().prepare('INSERT OR IGNORE INTO beta_file_changes (beta_id, server, rel_path) VALUES (?, ?, ?)').run(betaId, name, script.target_rel_path);
if (!isActiveBetaServer(betaId, name)) break;
await writeFile(targetPath, version.content);
if (!isActiveBetaServer(betaId, name)) break;
recordScript(name, script.target_rel_path, version.id, version.sha256, version.size, `beta:${betaId}`);
} catch (err) {
firstErr = `${script.name}: ${(err as Error).message}`;
@@ -541,6 +556,7 @@ async function deployBetaServersInternal(betaId: number, names: string[]): Promi
const targetPath = join(dest, targetRel);
if (existsSync(targetPath)) {
const priorBytes = await readFile(targetPath);
if (!isActiveBetaServer(betaId, name)) continue;
insertSnapshot.run(
betaId,
name,
@@ -552,7 +568,9 @@ async function deployBetaServersInternal(betaId: number, names: string[]): Promi
}
const composedBuf = Buffer.from(composedResult.text, 'utf8');
db().prepare('INSERT OR IGNORE INTO beta_file_changes (beta_id, server, rel_path) VALUES (?, ?, ?)').run(betaId, name, targetRel);
if (!isActiveBetaServer(betaId, name)) continue;
await writeFile(targetPath, composedBuf);
if (!isActiveBetaServer(betaId, name)) continue;
recordScript(
name,
targetRel,
@@ -568,6 +586,7 @@ async function deployBetaServersInternal(betaId: number, names: string[]): Promi
}
}
const t = nowIso();
if (!isActiveBetaServer(betaId, name)) continue;
if (firstErr) {
updateStage.run('failed', `deploy: ${firstErr}`, t, t, betaId, name);
emit({ type: 'beta-server-stage', betaId, server: name, stage: 'failed', error: `deploy: ${firstErr}` });
@@ -619,8 +638,13 @@ async function rollbackBetaInternal(betaId: number): Promise<RollbackResult> {
);
for (const bs of servers) {
if (!isActiveBetaServer(betaId, bs.server)) {
warnings.push(`${bs.server}: original server was deleted; retained history was skipped`);
continue;
}
if (!bs.deployed_at && bs.stage === 'queued') continue;
for (const { script } of bundle) {
if (!isActiveBetaServer(betaId, bs.server)) break;
if (isCollapsingKind(script.kind)) continue; // handled once per server below
if (storedPlan(betaId) && !db().prepare('SELECT 1 FROM beta_file_changes WHERE beta_id = ? AND server = ? AND rel_path = ?').get(betaId, bs.server, script.target_rel_path)) continue;
const dest = destOfKind(bs.server, script.kind);
@@ -633,7 +657,9 @@ async function rollbackBetaInternal(betaId: number): Promise<RollbackResult> {
| undefined;
if (snap) {
await mkdir(join(dest, dirname(script.target_rel_path)), { recursive: true });
if (!isActiveBetaServer(betaId, bs.server)) break;
await writeFile(targetPath, snap.prior_content);
if (!isActiveBetaServer(betaId, bs.server)) break;
if (bss.prior_version_id) {
recordScript(bs.server, script.target_rel_path, bss.prior_version_id, snap.prior_sha256, snap.prior_size, `rollback:${betaId}`);
} else {
@@ -646,12 +672,14 @@ async function rollbackBetaInternal(betaId: number): Promise<RollbackResult> {
} else {
// No prior file — the beta wrote a fresh file. Drop it.
if (existsSync(targetPath)) await rm(targetPath, { force: true });
if (!isActiveBetaServer(betaId, bs.server)) break;
removeScript(bs.server, script.target_rel_path);
dropped.add(bs.server);
}
}
// Collapsing config layer: single restore for the shared cfg file.
if (!isActiveBetaServer(betaId, bs.server)) continue;
if (hasCollapsing && (!storedPlan(betaId) || db().prepare('SELECT 1 FROM beta_file_changes WHERE beta_id = ? AND server = ? AND rel_path = ?').get(betaId, bs.server, configFileOf(bs.server)))) {
const dest = destOfKind(bs.server, 'config');
const targetRel = configFileOf(bs.server);
@@ -661,7 +689,9 @@ async function rollbackBetaInternal(betaId: number): Promise<RollbackResult> {
| undefined;
if (snap) {
await mkdir(dest, { recursive: true });
if (!isActiveBetaServer(betaId, bs.server)) continue;
await writeFile(targetPath, snap.prior_content);
if (!isActiveBetaServer(betaId, bs.server)) continue;
recordScript(bs.server, targetRel, null, snap.prior_sha256, snap.prior_size, `rollback:${betaId}`);
restored.add(bs.server);
} else if (existsSync(targetPath)) {
@@ -669,6 +699,7 @@ async function rollbackBetaInternal(betaId: number): Promise<RollbackResult> {
// no pre-existing file. Remove the beta-written cfg to leave
// the server clean.
await rm(targetPath, { force: true });
if (!isActiveBetaServer(betaId, bs.server)) continue;
removeScript(bs.server, targetRel);
dropped.add(bs.server);
}
@@ -738,7 +769,8 @@ function promotionPlan(betaId: number): { preview: BetaPromotionPreview; configC
return { script, version };
});
if (!bundle.length) throw new BetaError('This beta has no files.', 'no-scripts');
const tested = new Set(listBetaServers(betaId).map(row => row.server));
const participants = listBetaServers(betaId);
const tested = new Set(participants.filter(row => isActiveBetaServer(betaId, row.server)).map(row => row.server));
const targets = (script: ScriptRow) => [...new Set([...tested, ...(script.scope_type && script.scope_value
? serversMatchingScope(script.scope_type, script.scope_value) : [])])].sort();
const files = bundle.map(({ script, version }) => ({ id: script.id, name: script.name, kind: script.kind,
@@ -746,7 +778,8 @@ function promotionPlan(betaId: number): { preview: BetaPromotionPreview; configC
release_version: version.release_version, sha256: version.sha256,
scope: script.scope_type ? `${script.scope_type}: ${script.scope_value}` : 'Selected test servers only', servers: targets(script) }));
const names = [...new Set(files.flatMap(file => file.servers))].sort();
const blockers: string[] = [];
const blockers: string[] = participants.filter(row => !isActiveBetaServer(betaId, row.server))
.map(row => `${row.server}: original test server was deleted; start a new beta before promoting.`);
const servers = names.map(name => {
const server = getServer(name);
if (!server || server.destroyed_at) blockers.push(`${name}: server is no longer available.`);
@@ -810,7 +843,7 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
version: getVersion(br.script_version_id)!
}));
const betaServerSet = new Set(listBetaServers(betaId).map((r) => r.server));
const betaServerSet = new Set(listBetaServers(betaId).filter(row => isActiveBetaServer(betaId, row.server)).map((r) => r.server));
// Split bundle into non-collapsing (per-file) and collapsing (composed cfg).
const nonCollapsing = bundle.filter((b) => !isCollapsingKind(b.script.kind));
@@ -944,12 +977,16 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
// the captured target lists/content so later membership changes never expand it.
checkReview();
for (const p of plans) {
checkReview();
const dest = destOfKind(p.server, p.script.kind);
try {
await mkdir(join(dest, dirname(p.script.target_rel_path)), { recursive: true });
checkReview();
await writeFile(join(dest, p.script.target_rel_path), p.version.content);
checkReview();
planWritten.set(p, true);
} catch (err) {
if (err instanceof BetaError) throw err;
planWritten.set(p, false);
if (!serverErr.has(p.server)) serverErr.set(p.server, `${p.script.name}: ${(err as Error).message}`);
}
@@ -957,16 +994,20 @@ async function promoteBetaInternal(betaId: number, reviewedToken?: string): Prom
// Write composed cfg to each target.
const cfgWritten = new Map<CfgPlan, boolean>();
for (const cp of cfgPlans) {
checkReview();
try {
const dest = destOfKind(cp.server, 'config');
await mkdir(dest, { recursive: true });
checkReview();
const targetRel = configFileOf(cp.server);
const targetPath = join(dest, targetRel);
// Beta server already has composed content at targetPath; overwrite
// idempotently. Scope-expansion server: write in place.
await writeFile(targetPath, cp.composedContent);
checkReview();
cfgWritten.set(cp, true);
} catch (err) {
if (err instanceof BetaError) throw err;
cfgWritten.set(cp, false);
const msg = `configs: ${(err as Error).message}`;
if (!serverErr.has(cp.server)) serverErr.set(cp.server, msg);
+11 -11
View File
@@ -17,8 +17,8 @@ function coverage(entries: BlueprintEntry[]) {
const registered = db().prepare('SELECT name, game, map_slug, map FROM servers WHERE destroyed_at IS NULL').all() as Server[];
const queued = db().prepare(`SELECT j.server AS name, substr(j.server, 1, 2) AS game, j.map_slug, j.expected_map AS map
FROM server_creation_jobs j JOIN server_deployments d ON d.id = j.deployment_id
WHERE d.state IN ('queued', 'running', 'awaiting-health')`).all() as Server[];
const removing = new Set((db().prepare("SELECT server FROM server_deployments WHERE action = 'destroy' AND state IN ('waiting', 'running')").all() as { server: string }[]).map(row => row.server));
WHERE d.server_deleted_at IS NULL AND d.state IN ('queued', 'running', 'awaiting-health')`).all() as Server[];
const removing = new Set((db().prepare("SELECT server FROM server_deployments WHERE server_deleted_at IS NULL AND action = 'destroy' AND state IN ('waiting', 'running')").all() as { server: string }[]).map(row => row.server));
// A provisioning server can appear in both tables; count its identity once.
const servers = [...new Map([...queued, ...registered].map(server => [server.name, server])).values()];
return entries.map(entry => {
@@ -35,7 +35,7 @@ function coverage(entries: BlueprintEntry[]) {
return matches.length === 1 && matches[0].slug === entry.recipe.mapSlug;
}).map(server => server.name).sort();
const coverage = existingServers.map(name => {
const operation = db().prepare("SELECT id, state FROM server_deployments WHERE server = ? AND action IN ('create','rebuild') AND state IN ('queued', 'running', 'awaiting-health') ORDER BY rowid DESC LIMIT 1").get(name) as { id: string; state: string } | undefined;
const operation = db().prepare("SELECT id, state FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL AND action IN ('create','rebuild') AND state IN ('queued', 'running', 'awaiting-health') ORDER BY rowid DESC LIMIT 1").get(name) as { id: string; state: string } | undefined;
const server = getServer(name);
return { name, health: operation ? operation.state === 'queued' ? 'queued' : 'starting'
: server?.container_state && ['stopped', 'exited', 'created', 'dead'].includes(server.container_state) ? 'stopped'
@@ -49,8 +49,8 @@ function coverage(entries: BlueprintEntry[]) {
function fleetSnapshot() {
return housePartyTargets().map(name => ({ name,
server: db().prepare('SELECT game, map_slug, map, game_port FROM servers WHERE name = ?').get(name) ?? null,
creation: db().prepare("SELECT id FROM server_deployments WHERE server = ? AND action IN ('create','rebuild') ORDER BY rowid DESC LIMIT 1").get(name) ?? null,
removal: db().prepare("SELECT id FROM server_deployments WHERE server = ? AND action = 'destroy' AND state IN ('waiting', 'running')").get(name) ?? null
creation: db().prepare("SELECT id FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL AND action IN ('create','rebuild') ORDER BY rowid DESC LIMIT 1").get(name) ?? null,
removal: db().prepare("SELECT id FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL AND action = 'destroy' AND state IN ('waiting', 'running')").get(name) ?? null
}));
}
@@ -149,8 +149,8 @@ export function queueCollection(input: { previewId?: unknown; keys?: unknown }):
const removalJobs = removals.map((server, position) => {
const ports = (db().prepare(`SELECT game_port AS port FROM servers WHERE name = ? UNION
SELECT j.port FROM server_creation_jobs j JOIN server_deployments d ON d.id = j.deployment_id
WHERE j.server = ? AND d.state IN ('queued', 'running', 'awaiting-health')`).all(server.name, server.name) as { port: number | null }[]).map(row => row.port);
const queued = db().prepare("SELECT id FROM server_deployments WHERE server = ? AND action = 'create' AND state = 'queued'").get(server.name) as { id: string } | undefined;
WHERE j.server = ? AND d.server_deleted_at IS NULL AND d.state IN ('queued', 'running', 'awaiting-health')`).all(server.name, server.name) as { port: number | null }[]).map(row => row.port);
const queued = db().prepare("SELECT id FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL AND action = 'create' AND state = 'queued'").get(server.name) as { id: string } | undefined;
let id: string;
if (queued && !getServer(server.name)) {
// No container or files exist yet: cancel the reservation instead of booting just to remove it.
@@ -210,13 +210,13 @@ export function listCollections(): CollectionSummary[] {
export function getCollection(id: string): CollectionDetail | null {
const collection = db().prepare(summarySql + ' WHERE c.id = ? GROUP BY c.id').get(id) as CollectionSummary | undefined;
if (!collection) return null;
const servers = db().prepare(`SELECT d.id, d.server, d.action, d.state, d.stage, d.started_at, d.finished_at, d.error, j.port, j.map_slug,
EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server) AS server_exists
const servers = db().prepare(`SELECT d.id, d.server, d.server_deleted_at, d.action, d.state, d.stage, d.started_at, d.finished_at, d.error, j.port, j.map_slug,
(d.server_deleted_at IS NULL AND EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server AND s.destroyed_at IS NULL)) AS server_exists
FROM (${membershipSql}) m JOIN server_deployments d ON d.id = m.deployment_id
LEFT JOIN server_creation_jobs j ON j.deployment_id = d.id WHERE m.collection_id = ? ORDER BY d.action DESC, m.position`).all(id) as CollectionDetail['servers'];
const queue = creationQueue();
const positions = new Map(queue.queued.map((job, index) => [job.id, index + 1]));
const stored = db().prepare('SELECT retained_servers FROM server_launch_collections WHERE id = ?').get(id) as { retained_servers: string };
return { collection, retainedServers: JSON.parse(stored.retained_servers), queue: { concurrency: fleetSettings().createConcurrency, active: queue.active, queued: queue.queued.length },
const stored = db().prepare('SELECT retained_servers, retained_server_deletions FROM server_launch_collections WHERE id = ?').get(id) as { retained_servers: string; retained_server_deletions: string };
return { collection, retainedServers: JSON.parse(stored.retained_servers), retainedServerDeletedAt: JSON.parse(stored.retained_server_deletions), queue: { concurrency: fleetSettings().createConcurrency, active: queue.active, queued: queue.queued.length },
servers: servers.map(server => ({ ...server, queuePosition: positions.get(server.id) ?? null })) };
}
+1 -1
View File
@@ -54,7 +54,7 @@ function toPosix(p: string): string {
function hasActiveBeta(server: string): boolean {
return !!db().prepare(`SELECT 1 FROM beta_servers bs JOIN betas b ON b.id = bs.beta_id
WHERE bs.server = ? AND b.state IN ('running', 'ready-promote') LIMIT 1`).get(server);
WHERE bs.server = ? AND bs.server_deleted_at IS NULL AND b.state IN ('running', 'ready-promote') LIMIT 1`).get(server);
}
async function requireServerDirectory(server: string): Promise<void> {
+1 -1
View File
@@ -51,7 +51,7 @@ function fingerprint(report: GameTelemetry): string {
export function recordLeaderboardTelemetry(handle: Database.Database, report: GameTelemetry): void {
const identity = mapIdentity(handle, report);
const signature = fingerprint(report);
let match = handle.prepare('SELECT * FROM leaderboard_matches WHERE server = ? ORDER BY id DESC LIMIT 1')
let match = handle.prepare('SELECT * FROM leaderboard_matches WHERE server = ? AND server_deleted_at IS NULL ORDER BY id DESC LIMIT 1')
.get(report.server) as Match | undefined;
if (match?.last_fingerprint === signature && match.map_key === identity.key) return;
+1 -1
View File
@@ -6,7 +6,7 @@ export function serverMatchHistory(server?: string, limit = 25): { matches: Serv
const where = `WHERE m.highest_round >= ${MIN_RECORDED_ROUND}${server === undefined ? '' : ' AND m.server = ?'}`;
const parameters = server === undefined ? [] : [server];
const total = (handle.prepare(`SELECT COUNT(*) AS total FROM leaderboard_matches m ${where}`).get(...parameters) as { total: number }).total;
const matches = handle.prepare(`SELECT m.id, m.server, m.map_name AS map, m.started_at AS startedAt, m.status,
const matches = handle.prepare(`SELECT m.id, m.server, m.server_deleted_at, m.map_name AS map, m.started_at AS startedAt, m.status,
CASE WHEN m.status = 'completed' THEN m.ended_at END AS endedAt,
m.start_players AS startPlayers,
(SELECT COUNT(*) FROM leaderboard_match_players p WHERE p.match_id = m.id) AS uniquePlayers,
+176 -48
View File
@@ -1,5 +1,9 @@
import { db } from '../db/index.js';
import { ONLINE_MAX_MS } from '../../server-freshness.js';
import { parseMetricsQuery, type HeatmapCell, type MapMetrics, type MetricPoint, type MetricsData,
type MetricsDays, type MetricsQuery, type PreviousMetrics, type ServerMetrics, type Utilization } from '../../metrics.js';
export type { MapMetrics, Utilization } from '../../metrics.js';
export interface MetricSample {
server: string;
@@ -18,61 +22,185 @@ export function recordMetrics(samples: MetricSample[], at = Date.now()): void {
})();
}
export interface Utilization {
observed: number;
active: number;
idle: number;
unknown: number;
playerHours: number;
}
export interface MapMetrics extends Utilization {
game: string;
map: string;
full: number;
capacityObserved: number;
function emptyUtilization(): Utilization {
return { observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 };
}
/** Treat each observation as valid until the next poll, capped at the freshness limit.
* Longer collection gaps are excluded, never interpreted as empty servers. */
export function readMetrics(days: number, now = Date.now()) {
function addObservation(total: Utilization, players: number | null, hours: number): void {
if (players === null) total.unknown += hours;
else {
total.observed += hours;
if (players > 0) total.active += hours;
else if (players === 0) total.idle += hours;
total.playerHours += players * hours;
}
}
/** Each collection is a complete fleet snapshot. Hold it until the next collection,
* capped at the freshness limit; longer gaps contribute no observed or unknown time.
* Read the indexed history once and share the intervals across every aggregation. */
export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now()): MetricsData {
const query = parseMetricsQuery(new URLSearchParams(typeof input === 'number' ? { days: String(input) }
: { days: String(input.days), game: input.game ?? '', map: input.map ?? '', timeZone: input.timeZone }));
const { days, ...scope } = query;
const since = now - days * 86400_000;
const bucketMs = days === 1 ? 3600_000 : 86400_000;
const handle = db();
const cte = `WITH times AS (
SELECT at, LEAD(at, 1, @now) OVER (ORDER BY at) AS next_at
FROM (SELECT DISTINCT at FROM metric_samples WHERE at >= @since - @fresh AND at <= @now)
), samples AS (
SELECT s.*, MAX(0, MIN(t.next_at, s.at + @fresh, @now) - MAX(s.at, @since)) / 3600000.0 AS hours
FROM metric_samples s JOIN times t ON t.at = s.at
)`;
const params = { now, since, fresh: ONLINE_MAX_MS };
const totals = `COALESCE(SUM(CASE WHEN players IS NOT NULL THEN hours ELSE 0 END), 0) AS observed,
COALESCE(SUM(CASE WHEN players > 0 THEN hours ELSE 0 END), 0) AS active,
COALESCE(SUM(CASE WHEN players = 0 THEN hours ELSE 0 END), 0) AS idle,
COALESCE(SUM(CASE WHEN players IS NULL THEN hours ELSE 0 END), 0) AS unknown,
COALESCE(SUM(players * hours), 0) AS playerHours`;
const summary = handle.prepare(`${cte} SELECT ${totals} FROM samples`).get(params) as Utilization;
const servers = handle.prepare(`${cte} SELECT server, ${totals} FROM samples GROUP BY server ORDER BY active DESC, server`).all(params) as (Utilization & { server: string })[];
const maps = handle.prepare(`${cte}, pressure AS (
SELECT game, map, at, MAX(hours) AS hours,
MIN(CASE WHEN players IS NOT NULL AND capacity > 0 THEN 1 ELSE 0 END) AS known,
MIN(CASE WHEN players >= capacity AND capacity > 0 THEN 1 ELSE 0 END) AS full
FROM samples GROUP BY game, map, at
), map_pressure AS (
SELECT game, map, SUM(CASE WHEN known = 1 THEN hours ELSE 0 END) AS capacityObserved,
SUM(CASE WHEN known = 1 AND full = 1 THEN hours ELSE 0 END) AS full
FROM pressure GROUP BY game, map
) SELECT s.game, s.map, ${totals}, p.full, p.capacityObserved
FROM samples s JOIN map_pressure p ON p.game = s.game AND p.map = s.map
GROUP BY s.game, s.map ORDER BY playerHours DESC, s.map`).all(params) as MapMetrics[];
// Split intervals at chart boundaries so a midnight poll cannot shift time into the wrong day.
const points: (Utilization & { at: number })[] = [];
// These small grouped results keep options and retained evidence independent
// of the chosen scope/window without transferring a second raw history scan.
const groups = handle.prepare(`SELECT game, map, MIN(at) AS first, MAX(at) AS latest
FROM metric_samples WHERE at <= ? GROUP BY game, map ORDER BY game, map`).all(now) as
{ game: string; map: string; first: number; latest: number }[];
const matchesScope = (sample: { game: string; map: string }) =>
(scope.game === null || sample.game === scope.game) && (scope.map === null || sample.map === scope.map);
const evidenceBounds = (values: typeof groups) => values.reduce<{ first: number | null; latest: number | null }>((bounds, group) => ({
first: bounds.first === null ? group.first : Math.min(bounds.first, group.first),
latest: bounds.latest === null ? group.latest : Math.max(bounds.latest, group.latest)
}), { first: null, latest: null });
const globalBounds = evidenceBounds(groups);
const scopedBounds = evidenceBounds(groups.filter(matchesScope));
const registered = new Set((handle.prepare('SELECT name FROM servers WHERE destroyed_at IS NULL').all() as { name: string }[])
.map(server => server.name));
const metadata = { firstRetainedSampleAt: globalBounds.first, latestSampleAt: globalBounds.latest,
registeredServerCount: registered.size, scopeFirstSampleAt: scopedBounds.first, scopeLatestSampleAt: scopedBounds.latest };
const scopeOptions = { games: [...new Set(groups.map(group => group.game))], maps: groups.map(({ game, map }) => ({ game, map })) };
const summary = emptyUtilization();
const previous: PreviousMetrics = { since: since - days * 86400_000, until: since, summary: emptyUtilization(),
firstSampleAt: null, lastSampleAt: null, partialHistory: false };
const serverTotals = new Map<string, ServerMetrics>();
const mapTotals = new Map<string, Map<string, MapMetrics>>();
const points: MetricPoint[] = [];
for (let at = Math.floor(since / bucketMs) * bucketMs; at < now; at += bucketMs) {
const end = Math.min(at + bucketMs, now);
const start = Math.max(at, since);
const point = handle.prepare(`${cte} SELECT ${totals} FROM samples`)
.get({ ...params, since: start, now: end }) as Utilization;
points.push({ at, ...point });
points.push({ at, startAt: start, endAt: end, partial: start !== at || end !== at + bucketMs, ...emptyUtilization() });
}
return { days, since, now, summary, servers, maps, points };
const cells: HeatmapCell[] = Array.from({ length: 7 * 24 }, (_, index) => ({
weekday: Math.floor(index / 24), hour: index % 24, distinctDates: 0, ...emptyUtilization()
}));
const cellDates = cells.map(() => new Set<string>());
const calendar = new Intl.DateTimeFormat('en-US', { timeZone: scope.timeZone,
year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short', hour: 'numeric', hourCycle: 'h23' });
const weekdays: Record<string, number> = { Mon: 0, Tue: 1, Wed: 2, Thu: 3, Fri: 4, Sat: 5, Sun: 6 };
let cachedMinute = NaN;
let localCell = { index: 0, date: '' };
interface BatchCounts { known: number; active: number; idle: number; unknown: number; players: number }
function addHeatmap(counts: BatchCounts, start: number, end: number): void {
// Samples are at most 45 seconds. Splitting at UTC minute boundaries
// handles local hour/day boundaries, DST, and fractional-hour zones.
// Cache each minute once across the complete fleet collection batches.
for (let cursor = start; cursor < end;) {
const minute = Math.floor(cursor / 60_000);
if (minute !== cachedMinute) {
const parts = Object.fromEntries(calendar.formatToParts(cursor).map(part => [part.type, part.value]));
localCell = { index: weekdays[parts.weekday] * 24 + Number(parts.hour), date: `${parts.year}-${parts.month}-${parts.day}` };
cachedMinute = minute;
}
const stop = Math.min(end, (minute + 1) * 60_000);
const hours = (stop - cursor) / 3600_000;
const cell = cells[localCell.index];
cell.observed += counts.known * hours;
cell.active += counts.active * hours;
cell.idle += counts.idle * hours;
cell.unknown += counts.unknown * hours;
cell.playerHours += counts.players * hours;
cellDates[localCell.index].add(localCell.date);
cursor = stop;
}
}
type RecordedSample = MetricSample & { at: number };
let batch: RecordedSample[] = [];
function aggregateBatch(nextAt: number): void {
if (!batch.length) return;
const at = batch[0].at;
const samples = scope.game === null ? batch : batch.filter(matchesScope);
if (!samples.length) return;
const end = Math.min(nextAt, at + ONLINE_MAX_MS, now);
const priorHours = Math.max(0, Math.min(end, previous.until) - Math.max(at, previous.since)) / 3600_000;
if (priorHours > 0) {
previous.firstSampleAt ??= at;
previous.lastSampleAt = at;
for (const sample of samples) addObservation(previous.summary, sample.players, priorHours);
}
// Preserve current-window zero-duration rows without bringing rows from
// the separate comparison window into the visible server/map tables.
if (at < since - ONLINE_MAX_MS) return;
const start = Math.max(at, since);
const hours = Math.max(0, end - start) / 3600_000;
const pressure = new Map<MapMetrics, { known: boolean; full: boolean }>();
const counts: BatchCounts = { known: 0, active: 0, idle: 0, unknown: 0, players: 0 };
// Split once for the entire batch; observations crossing UTC hour/day
// boundaries contribute to both buckets without shifting their duration.
const slices: { point: MetricPoint; hours: number }[] = [];
for (let cursor = start; cursor < end;) {
const index = Math.floor((cursor - points[0].at) / bucketMs);
const point = points[index];
const stop = Math.min(end, point.endAt);
slices.push({ point, hours: (stop - cursor) / 3600_000 });
cursor = stop;
}
for (const sample of samples) {
let server = serverTotals.get(sample.server);
if (!server) {
server = { server: sample.server, currentRegistered: registered.has(sample.server), ...emptyUtilization() };
serverTotals.set(sample.server, server);
}
let game = mapTotals.get(sample.game);
if (!game) { game = new Map(); mapTotals.set(sample.game, game); }
let map = game.get(sample.map);
if (!map) {
map = { game: sample.game, map: sample.map, full: 0, capacityObserved: 0, ...emptyUtilization() };
game.set(sample.map, map);
}
addObservation(summary, sample.players, hours);
addObservation(server, sample.players, hours);
addObservation(map, sample.players, hours);
for (const slice of slices) addObservation(slice.point, sample.players, slice.hours);
if (sample.players === null) counts.unknown++;
else {
counts.known++;
counts.players += sample.players;
if (sample.players > 0) counts.active++;
else if (sample.players === 0) counts.idle++;
}
const known = sample.players !== null && sample.capacity !== null && sample.capacity > 0;
const full = known && sample.players! >= sample.capacity!;
const previous = pressure.get(map);
pressure.set(map, { known: known && (previous?.known ?? true), full: full && (previous?.full ?? true) });
}
for (const [map, state] of pressure) {
if (state.known) {
map.capacityObserved += hours;
if (state.full) map.full += hours;
}
}
addHeatmap(counts, start, end);
}
type SampleRow = [at: number, server: string, game: string, map: string, players: number | null, capacity: number | null];
const samples = handle.prepare(`SELECT at, server, game, map, players, capacity FROM metric_samples
WHERE at >= ? AND at <= ? ORDER BY at`).raw().iterate(previous.since - ONLINE_MAX_MS, now) as IterableIterator<SampleRow>;
for (const [at, server, game, map, players, capacity] of samples) {
if (batch.length && at !== batch[0].at) {
aggregateBatch(at);
batch = [];
}
batch.push({ at, server, game, map, players, capacity });
}
aggregateBatch(now);
const compareText = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0;
const servers = [...serverTotals.values()].sort((a, b) => b.active - a.active || compareText(a.server, b.server));
const maps = [...mapTotals.values()].flatMap(game => [...game.values()])
.sort((a, b) => b.playerHours - a.playerHours || compareText(a.map, b.map) || compareText(a.game, b.game));
previous.partialHistory = scopedBounds.first === null || scopedBounds.first > previous.since || previous.firstSampleAt === null;
for (let index = 0; index < cells.length; index++) cells[index].distinctDates = cellDates[index].size;
const fullMapHours = maps.reduce((total, map) => total + map.full, 0);
const knownMapHours = maps.reduce((total, map) => total + map.capacityObserved, 0);
const pooledPressure = { value: knownMapHours > 0 ? 100 * fullMapHours / knownMapHours : null,
fullMapHours, knownMapHours, coveredMaps: maps.filter(map => map.capacityObserved > 0).length, totalMaps: maps.length };
return { days, since, now, ...metadata, scope, scopeOptions, summary, servers, maps, points, previous,
heatmap: { timeZone: scope.timeZone, cells }, pooledPressure };
}
+5 -5
View File
@@ -14,7 +14,7 @@ export function fleetOperations(now = Date.now()): FleetOperation[] {
const campaigns = handle.prepare(`SELECT d.id, d.action, d.started_at, ds.server,
ds.restart_state, ds.error, ds.restarted_at, ds.players
FROM deployments d JOIN deployment_servers ds ON ds.deployment_id = d.id
WHERE ds.restart_state != 'done' OR ds.checked_at >= ? OR d.finished_at >= ? OR d.started_at >= ?`).all(since, since, since) as {
WHERE ds.server_deleted_at IS NULL AND (ds.restart_state != 'done' OR ds.checked_at >= ? OR d.finished_at >= ? OR d.started_at >= ?)`).all(since, since, since) as {
id: string; action: string; started_at: string; server: string; restart_state: string;
error: string | null; restarted_at: string | null; players: number | null;
}[];
@@ -35,10 +35,10 @@ export function fleetOperations(now = Date.now()): FleetOperation[] {
}
// Only the newest lifecycle operation can still require action on a server.
const lifecycle = handle.prepare(`SELECT d.* FROM server_deployments d
WHERE d.state IN ('queued', 'running', 'waiting', 'awaiting-health')
WHERE d.server_deleted_at IS NULL AND (d.state IN ('queued', 'running', 'waiting', 'awaiting-health')
OR (d.state IN ('done', 'warning', 'cancelled') AND d.finished_at >= ?)
OR (d.state IN ('failed', 'interrupted', 'warning') AND NOT EXISTS
(SELECT 1 FROM server_deployments n WHERE n.server = d.server AND n.rowid > d.rowid))`)
(SELECT 1 FROM server_deployments n WHERE n.server = d.server AND n.rowid > d.rowid)))`)
.all(since) as { id: string; server: string; action: string; state: string; stage: string;
error: string | null; started_at: string }[];
for (const row of lifecycle) {
@@ -52,7 +52,7 @@ export function fleetOperations(now = Date.now()): FleetOperation[] {
detail: row.error ?? row.stage, href: `/provisioning/${encodeURIComponent(row.id)}` });
}
const betas = handle.prepare(`SELECT b.id, b.opened_at, bs.server, bs.stage, bs.error
FROM betas b JOIN beta_servers bs ON bs.beta_id = b.id WHERE b.state IN ('running', 'ready-promote')`)
FROM betas b JOIN beta_servers bs ON bs.beta_id = b.id WHERE bs.server_deleted_at IS NULL AND b.state IN ('running', 'ready-promote')`)
.all() as { id: number; opened_at: string; server: string; stage: string; error: string | null }[];
const stages: Record<string, string> = { queued: 'Waiting for an earlier test wave', deploying: 'Deploying beta',
'awaiting-empty': 'Waiting for an empty lobby', restarting: 'Restarting', 'awaiting-health': 'Recovering',
@@ -64,7 +64,7 @@ export function fleetOperations(now = Date.now()): FleetOperation[] {
state: row.stage === 'failed' ? 'attention' : 'active', label: stages[row.stage] ?? row.stage,
detail: row.error ?? recovering.get(row.server)?.error ?? `Beta ${row.id}`, href: `/betas/${row.id}` });
}
const maintenance = handle.prepare(`SELECT m.* FROM maintenance_jobs m WHERE m.state = 'failed'
const maintenance = handle.prepare(`SELECT m.* FROM maintenance_jobs m WHERE m.server_deleted_at IS NULL AND m.state = 'failed'
AND NOT EXISTS (SELECT 1 FROM maintenance_jobs n WHERE n.server = m.server AND n.kind = m.kind AND n.id > m.id)`)
.all() as { id: number; server: string; kind: string; detail: string | null; requested_at: string }[];
for (const job of maintenance) {
+1 -1
View File
@@ -25,7 +25,7 @@ export function getPlayerProfile(game: string, guid: string, requestedOffset = 0
MAX(mp.last_seen_at) AS lastSeen, ${totals} ${scope}
GROUP BY m.map_key ORDER BY highestRound DESC, kills DESC, name COLLATE NOCASE, key`)
.all(game, guid) as PlayerMapRecord[];
const matches = handle.prepare(`SELECT m.id, m.server, m.map_name AS mapName,
const matches = handle.prepare(`SELECT m.id, m.server, m.server_deleted_at, m.map_name AS mapName,
m.started_at AS startedAt, mp.last_seen_at AS lastSeen, m.status, mp.completed,
mp.highest_round AS highestRound, mp.rounds_survived AS roundsSurvived,
mp.kills, mp.headshots, mp.revives, mp.downs, mp.best_score AS bestScore
+1 -1
View File
@@ -3,7 +3,7 @@ import { db } from '../db/index.js';
/** Only the latest lifecycle operation can describe the current server. */
export function isProvisioning(server: string): boolean {
const operation = db().prepare(`SELECT action, state FROM server_deployments
WHERE server = ? ORDER BY rowid DESC LIMIT 1`).get(server) as
WHERE server = ? AND server_deleted_at IS NULL ORDER BY rowid DESC LIMIT 1`).get(server) as
{ action: string; state: string } | undefined;
return !!operation && ['create', 'rebuild', 'start'].includes(operation.action) && ['queued', 'running', 'awaiting-health'].includes(operation.state);
}
+14 -3
View File
@@ -100,8 +100,10 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
const fileRows = db()
.prepare(
`SELECT id, server, verb, rel_path, sha256_before, sha256_after, content_before, content_after
FROM deployment_files WHERE deployment_id = ? ORDER BY server, rel_path`
`SELECT f.id, f.server, f.verb, f.rel_path, f.sha256_before, f.sha256_after, f.content_before, f.content_after
FROM deployment_files f JOIN deployment_servers ds ON ds.deployment_id = f.deployment_id AND ds.server = f.server
JOIN servers s ON s.name = f.server
WHERE f.deployment_id = ? AND ds.server_deleted_at IS NULL AND s.destroyed_at IS NULL ORDER BY f.server, f.rel_path`
)
.all(promoteId) as FileRow[];
@@ -148,20 +150,27 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
const affectedServers: string[] = [];
const perServerFailures = new Map<string, string>();
const active = (server: string) => !!db().prepare(`SELECT 1 FROM deployment_servers ds JOIN servers s ON s.name = ds.server
WHERE ds.deployment_id = ? AND ds.server = ? AND ds.server_deleted_at IS NULL AND s.destroyed_at IS NULL`).get(promoteId, server);
for (const [server, files] of perServer) {
if (!active(server)) continue;
try {
for (const f of files) {
if (!active(server)) break;
const kind = kindForRelPath(f.rel_path);
const dest = destOfKind(server, kind);
const abs = join(dest, f.rel_path);
await mkdir(join(dest, dirname(f.rel_path)), { recursive: true });
if (!active(server)) break;
const owningScript = scriptByTarget.get(f.rel_path);
if (f.verb === 'added') {
if (existsSync(abs)) await rm(abs, { force: true });
if (!active(server)) break;
removeScript(server, f.rel_path);
} else if (f.verb === 'updated' && f.content_before) {
await writeFile(abs, f.content_before);
if (!active(server)) break;
const priorVer = owningScript?.prior_version_id
? getVersion(owningScript.prior_version_id)
: null;
@@ -175,10 +184,11 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
);
} else if (f.verb === 'removed' && f.content_before) {
await writeFile(abs, f.content_before);
if (!active(server)) break;
recordScript(server, f.rel_path, null, f.sha256_before!, f.content_before.byteLength, `rollback:${newDepId}`);
}
}
affectedServers.push(server);
if (active(server)) affectedServers.push(server);
} catch (err) {
perServerFailures.set(server, (err as Error).message);
}
@@ -220,6 +230,7 @@ export async function rollbackPromoteDeployment(promoteId: string): Promise<Roll
);
}
for (const [server, files] of perServer) {
if (!active(server)) continue;
if (perServerFailures.has(server)) {
insertDepServer.run(newDepId, server, 0, 'failed', perServerFailures.get(server)!);
continue;
+2 -2
View File
@@ -207,7 +207,7 @@ export function heldByBeta(server: string): Map<string, number> {
JOIN betas b ON b.id = bsv.beta_id
JOIN beta_scripts bs ON bs.beta_id = b.id
JOIN scripts s ON s.id = bs.script_id
WHERE bsv.server = ? AND b.state IN ('running','ready-promote')`
WHERE bsv.server = ? AND bsv.server_deleted_at IS NULL AND b.state IN ('running','ready-promote')`
)
.all(server) as Array<{ target: string; beta_id: number }>;
const map = new Map<string, number>();
@@ -226,7 +226,7 @@ export function ownerOf(server: string, target: string): number | null {
JOIN betas b ON b.id = bsv.beta_id
JOIN beta_scripts bs ON bs.beta_id = b.id
JOIN scripts s ON s.id = bs.script_id
WHERE bsv.server = ? AND s.target_rel_path = ? AND b.state IN ('running','ready-promote')
WHERE bsv.server = ? AND bsv.server_deleted_at IS NULL AND s.target_rel_path = ? AND b.state IN ('running','ready-promote')
LIMIT 1`
)
.get(server, target) as { beta_id: number } | undefined;
+1 -1
View File
@@ -8,7 +8,7 @@ export interface TelemetryEvidence { players: number; map: string; receivedAt: s
/** Reports from before the latest lifecycle submission describe the old process. */
export function latestGameSubmission(server: string): string | null {
return (db().prepare(`SELECT MAX(at) AS at FROM (
SELECT submitted_at AS at FROM server_deployments WHERE server=? AND action IN ('create','start','restart','rebuild')
SELECT submitted_at AS at FROM server_deployments WHERE server=? AND server_deleted_at IS NULL AND action IN ('create','start','restart','rebuild')
UNION ALL SELECT requested_at AS at FROM restart_rollout WHERE server=?)`).get(server, server) as { at: string | null }).at;
}
+1 -1
View File
@@ -37,7 +37,7 @@ export function saveTelemetry(report: GameTelemetry): GameTelemetry {
} else if (report.event === 'game_end') {
handle.prepare('UPDATE servers SET game_ended_at = ? WHERE name = ?').run(report.receivedAt, report.server);
// Legacy betas also take match completion directly from the script.
if (report.round >= MIN_RECORDED_ROUND) handle.prepare(`UPDATE beta_servers SET match_seen_at = ? WHERE server = ? AND stage = 'awaiting-match'
if (report.round >= MIN_RECORDED_ROUND) handle.prepare(`UPDATE beta_servers SET match_seen_at = ? WHERE server = ? AND server_deleted_at IS NULL AND stage = 'awaiting-match'
AND healthy_at <= ? AND beta_id IN (SELECT id FROM betas WHERE state = 'running')`)
.run(report.receivedAt, report.server, report.receivedAt);
}
+7 -6
View File
@@ -26,7 +26,7 @@ export function setWatchdogEnabled(enabled: boolean) {
}
function incident(id: string) { return db().prepare('SELECT * FROM watchdog_incidents WHERE id = ?').get(id) as WatchdogIncident | undefined; }
function active(server: string, kind: 'health' | 'drift') { return db().prepare("SELECT * FROM watchdog_incidents WHERE server = ? AND kind = ? AND state != 'resolved'").get(server, kind) as WatchdogIncident | undefined; }
function active(server: string, kind: 'health' | 'drift') { return db().prepare("SELECT * FROM watchdog_incidents WHERE server = ? AND server_deleted_at IS NULL AND kind = ? AND state != 'resolved'").get(server, kind) as WatchdogIncident | undefined; }
function event(id: string, message: string) { db().prepare('INSERT INTO watchdog_events(incident_id,created_at,message) VALUES (?,?,?)').run(id, iso(), message); }
function transition(id: string, state: WatchdogState, summary: string) {
const previous = incident(id);
@@ -74,11 +74,11 @@ export function watchdogDetail(id: string): WatchdogDetail | null {
return row ? { incident: row, actions: actions(id), events: db().prepare('SELECT id,created_at,message FROM watchdog_events WHERE incident_id=? ORDER BY id').all(id) as WatchdogDetail['events'] } : null;
}
function hasPending(server: string) {
return !!db().prepare("SELECT 1 FROM server_deployments WHERE server=? AND state IN ('queued','waiting','running','awaiting-health') LIMIT 1").get(server);
return !!db().prepare("SELECT 1 FROM server_deployments WHERE server=? AND server_deleted_at IS NULL AND state IN ('queued','waiting','running','awaiting-health') LIMIT 1").get(server);
}
function protectedWork(server: string): boolean {
return !!db().prepare(`SELECT 1 FROM beta_servers bs JOIN betas b ON b.id=bs.beta_id WHERE bs.server=? AND b.state IN ('running','ready-promote') LIMIT 1`).get(server)
|| !!db().prepare(`SELECT 1 FROM deployment_servers ds WHERE ds.server=? AND ds.restart_state IN ('pending','held','failed','manual')
return !!db().prepare(`SELECT 1 FROM beta_servers bs JOIN betas b ON b.id=bs.beta_id WHERE bs.server=? AND bs.server_deleted_at IS NULL AND b.state IN ('running','ready-promote') LIMIT 1`).get(server)
|| !!db().prepare(`SELECT 1 FROM deployment_servers ds WHERE ds.server=? AND ds.server_deleted_at IS NULL AND ds.restart_state IN ('pending','held','failed','manual')
AND NOT EXISTS (SELECT 1 FROM watchdog_actions a WHERE a.campaign_id=ds.deployment_id) LIMIT 1`).get(server);
}
function addAction(row: WatchdogIncident, operationId: string, action: 'restart' | 'rebuild', campaignId: string | null = null) {
@@ -91,6 +91,7 @@ export function updateWatchdogIncident(id: string, action: 'retry' | 'dismiss')
return db().transaction(() => {
const row = incident(id);
if (!row) return { ok: false as const, status: 404, error: 'Watchdog incident not found.' };
if (row.server_deleted_at) return { ok: false as const, status: 409, error: 'This server was deleted; its recovery history cannot be changed.' };
if (actions(id).some(a => pendingStates.includes(a.operation_state ?? ''))) return { ok: false as const, status: 409, error: 'Provisioning is still active. Complete or cancel that operation first.' };
if (action === 'dismiss') {
if (!['open','attention'].includes(row.state)) return { ok: false as const, status: 409, error: 'Only detected incidents or incidents needing attention can be dismissed.' };
@@ -106,7 +107,7 @@ export function updateWatchdogIncident(id: string, action: 'retry' | 'dismiss')
}
async function reconcileActions(): Promise<void> {
const rows = db().prepare("SELECT * FROM watchdog_incidents WHERE state IN ('recovering','attention')").all() as WatchdogIncident[];
const rows = db().prepare("SELECT * FROM watchdog_incidents WHERE server_deleted_at IS NULL AND state IN ('recovering','attention')").all() as WatchdogIncident[];
for (const row of rows) {
const action = actions(row.id).filter(a => a.cycle === row.cycle).at(-1);
if (!action) continue;
@@ -229,7 +230,7 @@ async function run(forceScan: boolean): Promise<void> {
}
await Promise.all(Array.from({ length: Math.min(4, servers.length) }, pump));
// Preserve incidents for temporarily absent rebuild instances, but close retired registrations.
const abandoned = db().prepare("SELECT * FROM watchdog_incidents WHERE state != 'resolved'").all() as WatchdogIncident[];
const abandoned = db().prepare("SELECT * FROM watchdog_incidents WHERE server_deleted_at IS NULL AND state != 'resolved'").all() as WatchdogIncident[];
for (const row of abandoned) {
if (getServer(row.server) || hasPending(row.server)) continue;
const last = db().prepare('SELECT action,state FROM server_deployments WHERE server=? ORDER BY rowid DESC LIMIT 1').get(row.server) as { action: string; state: string } | undefined;
+7 -6
View File
@@ -39,7 +39,7 @@ export function queueMaintenance(servers: string[], kind: MaintenanceJob['kind']
if (getServer(server)?.desired_state === 'stopped') continue;
added += db().prepare(`INSERT OR IGNORE INTO maintenance_jobs (server, target_revision, requested_at, kind, origin)
VALUES (?, ?, ?, ?, ?)`).run(server, target, nowIso(), kind, origin).changes;
const job = db().prepare("SELECT id FROM maintenance_jobs WHERE server = ? AND state IN ('queued','restarting')").get(server) as { id: number };
const job = db().prepare("SELECT id FROM maintenance_jobs WHERE server = ? AND server_deleted_at IS NULL AND state IN ('queued','restarting')").get(server) as { id: number };
requestRestart(server, { kind: 'maintenance', id: String(job.id) });
}
return added;
@@ -55,13 +55,14 @@ export function queueUpdates(servers?: string[], automatic = false): number {
if (servers && (!servers.length || servers.some(server => !versions.some(v => v.server === server)))) throw new Error('Choose existing fleet servers.');
const candidates = versions.filter(v => v.update_status === 'outdated' && (!servers || servers.includes(v.server)));
const names = candidates.filter(v => !automatic || !db().prepare(`SELECT 1 FROM maintenance_jobs
WHERE server = ? AND target_revision = ? AND kind = 'update'`).get(v.server, freshRelease())).map(v => v.server);
WHERE server = ? AND server_deleted_at IS NULL AND target_revision = ? AND kind = 'update'`).get(v.server, freshRelease())).map(v => v.server);
return names.length ? queueMaintenance(names, 'update', automatic ? 'automatic' : 'manual') : 0;
}
export function retryMaintenance(id: number): number {
const job = db().prepare("SELECT * FROM maintenance_jobs WHERE id = ? AND state = 'failed'").get(id) as MaintenanceJob | undefined;
if (!job) throw new Error('Only failed maintenance can be retried.');
if (job.server_deleted_at) throw new Error('This server was deleted; its maintenance history cannot be retried.');
if (!maintenanceAllowed()) throw new Error('Maintenance is paused.');
if (getServer(job.server)?.desired_state === 'stopped') throw new Error('This server is intentionally stopped.');
// Ambiguous lifecycle failures retain their slot and require diagnostics, not another request.
@@ -82,11 +83,11 @@ export function cancelMaintenance(id: number): boolean {
}
function finish(job: MaintenanceJob, state: 'done' | 'failed' | 'cancelled', detail: string) {
db().prepare('UPDATE maintenance_jobs SET state = ?, detail = ?, finished_at = ? WHERE id = ?')
db().prepare('UPDATE maintenance_jobs SET state = ?, detail = ?, finished_at = ? WHERE id = ? AND server_deleted_at IS NULL')
.run(state, detail, nowIso(), job.id);
}
function hold(job: MaintenanceJob, detail: string) {
db().prepare('UPDATE maintenance_jobs SET detail = ? WHERE id = ?').run(detail, job.id);
db().prepare('UPDATE maintenance_jobs SET detail = ? WHERE id = ? AND server_deleted_at IS NULL').run(detail, job.id);
}
let ticking: Promise<void> | null = null;
@@ -108,7 +109,7 @@ async function runTick(): Promise<void> {
writeSetting('plutonium_schedule', { ...plan, nextAt: nextMaintenanceAt(plan.time, new Date(), plan.timeZone) });
}
})();
const jobs = db().prepare(`SELECT * FROM maintenance_jobs WHERE state IN ('queued','restarting') ORDER BY id`).all() as MaintenanceJob[];
const jobs = db().prepare(`SELECT * FROM maintenance_jobs WHERE server_deleted_at IS NULL AND state IN ('queued','restarting') ORDER BY id`).all() as MaintenanceJob[];
for (const job of jobs) {
try { await processJob(job); }
catch { hold(job, 'Maintenance check failed; waiting for the next check.'); }
@@ -119,7 +120,7 @@ async function runTick(): Promise<void> {
}
async function processJob(job: MaintenanceJob): Promise<void> {
const server = getServer(job.server);
if (!server || server.destroyed_at) return finish(job, 'cancelled', 'Server removed.');
if (job.server_deleted_at || !server || server.destroyed_at) return finish(job, 'cancelled', 'Server removed.');
const operation = requestRestart(job.server, { kind: 'maintenance', id: String(job.id) });
if (['failed','interrupted','cancelled'].includes(operation.state)) {
return finish(job, operation.state === 'cancelled' ? 'cancelled' : 'failed', operation.error ?? operation.stage);
+19 -5
View File
@@ -1,5 +1,5 @@
import { db } from '../db/index.js';
import { betaOperationInFlight, deployBetaServers, getBeta, listBetaServers, rollbackBeta } from '../fleet/beta.js';
import { betaOperationInFlight, deployBetaServers, getBeta, isActiveBetaServer, listBetaServers, rollbackBeta } from '../fleet/beta.js';
import { activity, currentRun, observeRun, prepareRun, readMetrics, saveMetrics, setOutcome, storedPlan, type StoredPlan } from '../fleet/beta-tests.js';
import { coveragePassed, evaluateRequirements } from '../fleet/beta-evaluation.js';
import { parseBetaPlan, type BetaPlan } from '../../beta-plan.js';
@@ -7,9 +7,9 @@ import { emit, type BetaStage } from './bus.js';
import { requestRestart, releaseRestartRequest } from '../provisioning/index.js';
function stage(id: number, server: string, next: BetaStage, error: string | null = null): void {
db().prepare('UPDATE beta_servers SET stage = ?, error = ?, updated_at = ? WHERE beta_id = ? AND server = ?')
const result = db().prepare('UPDATE beta_servers SET stage = ?, error = ?, updated_at = ? WHERE beta_id = ? AND server = ? AND server_deleted_at IS NULL')
.run(next, error, new Date().toISOString(), id, server);
emit({ type: 'beta-server-stage', betaId: id, server, stage: next, error });
if (result.changes) emit({ type: 'beta-server-stage', betaId: id, server, stage: next, error });
}
export function betaState(id: number, ready: boolean): void {
@@ -25,14 +25,16 @@ export function betaState(id: number, ready: boolean): void {
}
export async function rollbackTestPlan(id: number, plan: BetaPlan): Promise<void> {
const servers = listBetaServers(id).filter(s => s.deployed_at);
const servers = listBetaServers(id).filter(s => s.deployed_at && isActiveBetaServer(id, s.server));
const result = await rollbackBeta(id);
if (plan.rollbackRestart === 'when-empty' && servers.length) {
const deploymentId = `dep-beta-rollback-${id}-${Date.now()}`;
db().transaction(() => {
db().prepare("INSERT INTO deployments (id, action, started_at, triggered_by, beta_id) VALUES (?, 'rollback', ?, 'beta-test-plan', ?)")
.run(deploymentId, new Date().toISOString(), id);
for (const server of servers) db().prepare("INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state) VALUES (?, ?, 1, 'pending')").run(deploymentId, server.server);
for (const server of servers) {
if (isActiveBetaServer(id, server.server)) db().prepare("INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state) VALUES (?, ?, 1, 'pending')").run(deploymentId, server.server);
}
})();
activity(id, 'Restored files; rolling restarts will wait for empty servers');
} else activity(id, 'Restored files; manual restarts are required to load them');
@@ -73,12 +75,21 @@ async function advancePlan(stored: StoredPlan): Promise<void> {
const plan = parseBetaPlan(JSON.parse(stored.plan));
const beta = getBeta(id)!;
const servers = listBetaServers(id);
// Preserve the original coverage and evidence. A replacement with the same
// name cannot complete a test that began on a different server.
const missing = servers.filter(server => !isActiveBetaServer(id, server.server));
if (missing.length) {
betaState(id, false);
setOutcome(id, 'paused', `Test server deleted: ${missing.map(server => server.server).join(', ')}. Roll back this beta and start a new test.`);
return;
}
if (stored.outcome !== 'passed' && Date.now() - Date.parse(beta.opened_at) >= plan.timeoutHours * 3_600_000) {
betaState(id, false);
setOutcome(id, 'inconclusive', 'Deadline reached without enough qualifying evidence');
return;
}
for (const server of servers) {
if (!isActiveBetaServer(id, server.server)) return;
const freshPlan = storedPlan(id)!;
if (!['active', 'passed'].includes(freshPlan.outcome)) return;
if (server.stage === 'failed') { await fail(id, plan, `${server.server}: ${server.error ?? 'server failed'}`); return; }
@@ -108,6 +119,7 @@ async function advancePlan(stored: StoredPlan): Promise<void> {
}
}
}
if (!isActiveBetaServer(id, server.server)) return;
if (server.stage === 'awaiting-empty' || server.stage === 'awaiting-health') {
const operation = requestRestart(server.server, { kind: 'beta', id: String(id) });
if (['failed','interrupted','cancelled'].includes(operation.state)) {
@@ -129,6 +141,7 @@ async function advancePlan(stored: StoredPlan): Promise<void> {
const run = currentRun(id, server.server);
if (!run?.observing_at) continue;
await observeRun(run, plan);
if (!isActiveBetaServer(id, server.server)) return;
if (!['active', 'passed'].includes(storedPlan(id)!.outcome)) { betaState(id, false); return; }
const metrics = readMetrics(currentRun(id, server.server)!);
const progress = evaluateRequirements(plan, metrics, run.phase);
@@ -143,6 +156,7 @@ async function advancePlan(stored: StoredPlan): Promise<void> {
} else stage(id, server.server, progress.every(p => p.passed) ? 'ready' : 'observing');
}
const updated = listBetaServers(id);
if (updated.some(server => !isActiveBetaServer(id, server.server))) return;
const queued = updated.filter(s => s.stage === 'queued');
if (queued.length && updated.filter(s => s.stage !== 'queued').every(s => s.stage === 'ready')) {
const wave = queued.slice(0, plan.waveSize || queued.length).map(s => s.server);
+7 -6
View File
@@ -25,7 +25,7 @@ const setStage = () =>
restarted_at = CASE WHEN ? = 'awaiting-health' OR ? = 'ready' THEN COALESCE(restarted_at, ?) ELSE restarted_at END,
healthy_at = CASE WHEN ? = 'awaiting-match' OR (? = 'ready' AND healthy_at IS NULL) THEN COALESCE(healthy_at, ?) ELSE healthy_at END,
match_seen_at = CASE WHEN ? = 'ready' AND match_seen_at IS NULL THEN ? ELSE match_seen_at END
WHERE beta_id = ? AND server = ?`
WHERE beta_id = ? AND server = ? AND server_deleted_at IS NULL`
);
function transition(
@@ -35,7 +35,7 @@ function transition(
error: string | null = null
): void {
const t = nowIso();
setStage().run(
const result = setStage().run(
stage,
error,
t,
@@ -50,17 +50,17 @@ function transition(
betaId,
server
);
emit({ type: 'beta-server-stage', betaId, server, stage, error });
if (result.changes) emit({ type: 'beta-server-stage', betaId, server, stage, error });
}
// Bump beta.state to ready-promote when every one of its beta_servers is ready.
// A single failed row is enough to keep the beta in 'running' (operator retries).
function reconcileBetaState(betaId: number): void {
const rows = db()
.prepare(`SELECT stage FROM beta_servers WHERE beta_id = ?`)
.all(betaId) as Array<{ stage: BetaStage }>;
.prepare(`SELECT stage, server_deleted_at FROM beta_servers WHERE beta_id = ?`)
.all(betaId) as Array<{ stage: BetaStage; server_deleted_at: string | null }>;
if (rows.length === 0) return;
if (rows.every((r) => r.stage === 'ready')) {
if (rows.every((r) => r.stage === 'ready' && !r.server_deleted_at)) {
const upd = db()
.prepare(`UPDATE betas SET state = 'ready-promote' WHERE id = ? AND state = 'running'`)
.run(betaId);
@@ -98,6 +98,7 @@ async function runBetaTick(): Promise<{ processed: number }> {
FROM beta_servers bs
JOIN betas b ON b.id = bs.beta_id
WHERE b.state = 'running'
AND bs.server_deleted_at IS NULL
AND NOT EXISTS (SELECT 1 FROM beta_test_plans p WHERE p.beta_id = b.id)
AND bs.stage IN ('awaiting-empty','restarting','awaiting-health','awaiting-match')
ORDER BY b.id, bs.server`
+3 -3
View File
@@ -19,7 +19,7 @@ export function sweep(): Promise<SweepResult> {
}
async function runSweep(): Promise<SweepResult> {
const pending = db().prepare(`SELECT ds.* FROM deployment_servers ds JOIN deployments d ON d.id = ds.deployment_id
WHERE ds.restart_state IN ('pending','failed') ORDER BY d.started_at, d.id, ds.server`).all() as PendingRow[];
WHERE ds.server_deleted_at IS NULL AND ds.restart_state IN ('pending','failed') ORDER BY d.started_at, d.id, ds.server`).all() as PendingRow[];
const result: SweepResult = { checked: 0, restarted: 0, healthy: 0, held: 0, failed: 0 };
for (const row of pending) {
if (!row.files_changed) continue; // A failed file write is not a restart request.
@@ -40,10 +40,10 @@ async function runSweep(): Promise<SweepResult> {
emit({ type: 'server-state', deploymentId: row.deployment_id, server: row.server, state, players: operation.players, error });
}
db().prepare(`UPDATE deployments SET finished_at = ? WHERE finished_at IS NULL AND NOT EXISTS (
SELECT 1 FROM deployment_servers WHERE deployment_id = deployments.id AND restart_state IN ('pending','failed','held'))`).run(nowIso());
SELECT 1 FROM deployment_servers WHERE deployment_id = deployments.id AND server_deleted_at IS NULL AND restart_state IN ('pending','failed','held'))`).run(nowIso());
db().prepare(`DELETE FROM deployments WHERE started_at < ?
AND NOT EXISTS (SELECT 1 FROM watchdog_actions WHERE campaign_id = deployments.id) AND NOT EXISTS (
SELECT 1 FROM deployment_servers WHERE deployment_id = deployments.id AND restart_state IN ('pending','failed','held'))`)
SELECT 1 FROM deployment_servers WHERE deployment_id = deployments.id AND server_deleted_at IS NULL AND restart_state IN ('pending','failed','held'))`)
.run(new Date(Date.now() - fleetSettings().deployKeepDays * 86_400_000).toISOString());
emit({ type: 'sweep-tick', frozen: false });
return result;
+15 -9
View File
@@ -24,7 +24,7 @@ export function recordDeploymentEvent(id: string, message: string): void {
}
export function getDeployment(id: string) {
const deployment = db().prepare(`SELECT d.*, j.expected_map, EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server) AS server_exists
const deployment = db().prepare(`SELECT d.*, j.expected_map, (d.server_deleted_at IS NULL AND EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server AND s.destroyed_at IS NULL)) AS server_exists
FROM server_deployments d LEFT JOIN server_creation_jobs j ON j.deployment_id = d.id WHERE d.id = ?`).get(id);
if (!deployment) return null;
const events = db().prepare('SELECT id, created_at, message FROM server_deployment_events WHERE deployment_id = ? ORDER BY id').all(id);
@@ -42,14 +42,14 @@ export function getDeployment(id: string) {
export const REMOVING_SERVER = 'Removing server files and registry';
export function canCancelRemoval(id: string): boolean {
return !!db().prepare("SELECT id FROM server_deployments WHERE id = ? AND action = 'destroy' AND state = 'waiting' AND stage != ?").get(id, REMOVING_SERVER);
return !!db().prepare("SELECT id FROM server_deployments WHERE id = ? AND server_deleted_at IS NULL AND action = 'destroy' AND state = 'waiting' AND stage != ?").get(id, REMOVING_SERVER);
}
/** Competes atomically with the worker's claim, before any external removal. */
export function cancelRemoval(id: string): boolean {
return db().transaction(() => {
const result = db().prepare(`UPDATE server_deployments SET state = 'cancelled', stage = 'Removal cancelled', finished_at = ?, error = NULL
WHERE id = ? AND action = 'destroy' AND state = 'waiting' AND stage != ?`).run(new Date().toISOString(), id, REMOVING_SERVER);
WHERE id = ? AND server_deleted_at IS NULL AND action = 'destroy' AND state = 'waiting' AND stage != ?`).run(new Date().toISOString(), id, REMOVING_SERVER);
if (!result.changes) return false;
recordDeploymentEvent(id, 'Queued removal cancelled. The server and its files were kept.');
return true;
@@ -57,12 +57,18 @@ export function cancelRemoval(id: string): boolean {
}
export function claimRemoval(id: string): boolean {
return db().prepare("UPDATE server_deployments SET state = 'running' WHERE id = ? AND action = 'destroy' AND state = 'waiting'").run(id).changes > 0;
return db().prepare(`UPDATE server_deployments SET state = 'running'
WHERE id = ? AND action = 'destroy' AND state = 'waiting'
AND (server_deleted_at IS NULL OR (stage = ?
AND NOT EXISTS (SELECT 1 FROM servers s WHERE s.name = server_deployments.server)
AND NOT EXISTS (SELECT 1 FROM server_deployments newer WHERE newer.server = server_deployments.server
AND newer.rowid > server_deployments.rowid)))`).run(id, REMOVING_SERVER).changes > 0;
}
export function canResumeCleanup(id: string): boolean {
return !!db().prepare(`SELECT d.id FROM server_deployments d
WHERE d.id = ? AND d.action = 'destroy' AND d.state IN ('failed', 'interrupted') AND d.stage = ?
WHERE d.id = ? AND (d.server_deleted_at IS NULL OR NOT EXISTS (SELECT 1 FROM servers s WHERE s.name = d.server))
AND d.action = 'destroy' AND d.state IN ('failed', 'interrupted') AND d.stage = ?
AND NOT EXISTS (SELECT 1 FROM server_deployments newer WHERE newer.server = d.server AND newer.rowid > d.rowid)`)
.get(id, REMOVING_SERVER);
}
@@ -80,7 +86,7 @@ export function canResumeVerification(id: string): boolean {
return !!db().prepare(`SELECT d.id FROM server_deployments d
JOIN server_creation_jobs j ON j.deployment_id = d.id
JOIN servers s ON s.name = j.server AND s.game_port = j.port
WHERE d.id = ? AND (d.action = 'create' OR (d.action = 'rebuild' AND EXISTS (
WHERE d.id = ? AND d.server_deleted_at IS NULL AND (d.action = 'create' OR (d.action = 'rebuild' AND EXISTS (
SELECT 1 FROM server_rebuild_jobs r WHERE r.deployment_id = d.id AND r.removed_at IS NOT NULL))) AND d.state IN ('failed', 'interrupted')
AND NOT EXISTS (SELECT 1 FROM server_deployments newer WHERE newer.server = d.server AND newer.rowid > d.rowid)`)
.get(id);
@@ -88,7 +94,7 @@ export function canResumeVerification(id: string): boolean {
export function canResumeRebuild(id: string): boolean {
return !!db().prepare(`SELECT d.id FROM server_deployments d JOIN server_rebuild_jobs r ON r.deployment_id = d.id
WHERE d.id = ? AND d.state IN ('failed','interrupted') AND d.stage = ? AND r.removed_at IS NULL
WHERE d.id = ? AND d.server_deleted_at IS NULL AND d.state IN ('failed','interrupted') AND d.stage = ? AND r.removed_at IS NULL
AND NOT EXISTS (SELECT 1 FROM server_deployments newer WHERE newer.server = d.server AND newer.rowid > d.rowid)`)
.get(id, REMOVING_SERVER);
}
@@ -120,8 +126,8 @@ export function finishDeployment(id: string, result: { ok: boolean; message?: st
}
export function listDeployments() {
return db().prepare(`SELECT d.*, EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server) AS server_exists,
(d.action = 'destroy' AND d.state = 'waiting' AND d.stage != ?) AS canCancelRemoval
return db().prepare(`SELECT d.*, (d.server_deleted_at IS NULL AND EXISTS(SELECT 1 FROM servers s WHERE s.name = d.server AND s.destroyed_at IS NULL)) AS server_exists,
(d.server_deleted_at IS NULL AND d.action = 'destroy' AND d.state = 'waiting' AND d.stage != ?) AS canCancelRemoval
FROM server_deployments d ORDER BY started_at DESC, rowid DESC`).all(REMOVING_SERVER);
}
+21 -31
View File
@@ -63,7 +63,7 @@ export function nextLobbyNumber(baseName: string, additionalNames: string[] = []
const rows = db()
.prepare(`SELECT name FROM servers WHERE name LIKE ? UNION
SELECT j.server AS name FROM server_creation_jobs j JOIN server_deployments d ON d.id = j.deployment_id
WHERE d.state IN ('queued', 'running', 'awaiting-health') OR (d.action = 'rebuild' AND (d.state = 'waiting' OR (d.state IN ('failed','interrupted') AND d.stage = 'Removing server files and registry')))`)
WHERE d.server_deleted_at IS NULL AND (d.state IN ('queued', 'running', 'awaiting-health') OR (d.action = 'rebuild' AND (d.state = 'waiting' OR (d.state IN ('failed','interrupted') AND d.stage = 'Removing server files and registry'))))`)
.all(`${baseName}_%`) as Array<{ name: string }>;
const taken = new Set<number>();
const prefix = `${baseName}_`;
@@ -87,7 +87,7 @@ export function nextAvailablePort(additionalPorts: number[] = [], releasingServe
const rows = db()
.prepare(`SELECT name, game_port FROM servers WHERE game_port IS NOT NULL UNION
SELECT j.server AS name, j.port AS game_port FROM server_creation_jobs j JOIN server_deployments d ON d.id = j.deployment_id
WHERE d.state IN ('queued', 'running', 'awaiting-health') OR (d.action = 'rebuild' AND (d.state = 'waiting' OR (d.state IN ('failed','interrupted') AND d.stage = 'Removing server files and registry')))`)
WHERE d.server_deleted_at IS NULL AND (d.state IN ('queued', 'running', 'awaiting-health') OR (d.action = 'rebuild' AND (d.state = 'waiting' OR (d.state IN ('failed','interrupted') AND d.stage = 'Removing server files and registry'))))`)
.all() as Array<{ name: string; game_port: number }>;
const taken = new Set([...rows.filter(row => !releasingServers.includes(row.name)).map((r) => r.game_port), ...additionalPorts]);
for (let p = config.portRange.min; p <= config.portRange.max; p++) {
@@ -257,7 +257,7 @@ export function queueRebuildServer(name: string, triggeredBy = 'ui'): CreateServ
/** Caller has just verified emptiness and excluded conflicting work. */
export async function rebuildServer(id: string): Promise<void> {
const job = db().prepare(`SELECT j.*, d.stage FROM server_creation_jobs j JOIN server_deployments d ON d.id = j.deployment_id
WHERE d.id = ? AND d.action = 'rebuild' AND d.state = 'waiting'`).get(id) as (CreationJob & { stage: string }) | undefined;
WHERE d.id = ? AND d.server_deleted_at IS NULL AND d.action = 'rebuild' AND d.state = 'waiting'`).get(id) as (CreationJob & { stage: string }) | undefined;
if (!job) return;
if (creating || destroying.size || creationQueue().active.length >= fleetSettings().createConcurrency) {
// The cleanup checkpoint must survive capacity waits and app restarts.
@@ -487,17 +487,17 @@ const destroying = new Set<string>();
// Persist the request before returning so navigation and client disconnects cannot lose it.
function hasActiveCreation(name: string): boolean {
return !!db().prepare("SELECT 1 FROM server_deployments WHERE server = ? AND action = 'create' AND state IN ('queued', 'running', 'awaiting-health')").get(name);
return !!db().prepare("SELECT 1 FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL AND action = 'create' AND state IN ('queued', 'running', 'awaiting-health')").get(name);
}
export function destroyServer(name: string, triggeredBy = 'ui'): DestroyServerResult & { deploymentId?: string } {
if (db().prepare(`SELECT 1 FROM server_deployments WHERE server = ? AND action IN ('start','restart','stop','rebuild')
if (db().prepare(`SELECT 1 FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL AND action IN ('start','restart','stop','rebuild')
AND state IN ('waiting','queued','running')`).get(name)) {
return { ok: false, kind: 'conflict', message: 'Wait for the current provisioning operation before destroying this server.', warnings: [] };
}
const existing = db().prepare("SELECT id FROM server_deployments WHERE server = ? AND action = 'destroy' AND state IN ('waiting', 'running')").get(name) as { id: string } | undefined;
const existing = db().prepare("SELECT id FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL AND action = 'destroy' AND state IN ('waiting', 'running')").get(name) as { id: string } | undefined;
if (existing) return { ok: true, warnings: [], deploymentId: existing.id };
const previous = db().prepare("SELECT id FROM server_deployments WHERE server = ? ORDER BY rowid DESC LIMIT 1").get(name) as { id: string } | undefined;
const previous = db().prepare("SELECT id FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL ORDER BY rowid DESC LIMIT 1").get(name) as { id: string } | undefined;
if (previous && resumeCleanup(previous.id)) return { ok: true, warnings: [], deploymentId: previous.id };
if (!getServer(name) && !hasActiveCreation(name)) return { ok: false, kind: 'not-found', message: 'Server not found', warnings: [] };
const operation = db().transaction(() => {
@@ -517,13 +517,18 @@ export function processDestructions(): Promise<void> {
}
async function runDestructions(): Promise<void> {
const queued = db().prepare("SELECT id, server, stage FROM server_deployments WHERE action = 'destroy' AND state = 'waiting' ORDER BY started_at").all() as { id: string; server: string; stage: string }[];
const queued = db().prepare(`SELECT id, server, stage, server_deleted_at FROM server_deployments WHERE action = 'destroy' AND state = 'waiting'
AND (server_deleted_at IS NULL OR stage = ?) ORDER BY started_at`).all(REMOVING_SERVER) as { id: string; server: string; stage: string; server_deleted_at: string | null }[];
for (const operation of queued) {
if (creating || destroying.has(operation.server)) continue;
if (!db().prepare("SELECT id FROM server_deployments WHERE id = ? AND state = 'waiting'").get(operation.id)) continue;
destroying.add(operation.server);
try {
if (operation.stage === REMOVING_SERVER) {
if (operation.server_deleted_at && getServer(operation.server)) {
finishDeployment(operation.id, { ok: false, message: 'A replacement server is registered; local cleanup cannot safely resume.' });
continue;
}
const newer = db().prepare(`SELECT 1 FROM server_deployments WHERE server = ? AND action = 'create'
AND rowid > (SELECT rowid FROM server_deployments WHERE id = ?)`).get(operation.server, operation.id);
if (newer) {
@@ -619,25 +624,10 @@ async function cleanupDestroyedServer(name: string, operation: string): Promise<
warnings.push(`rm ${hostDir} failed: ${(err as Error).message}`);
}
// Remove server-owned beta and deployment rows before the registry row.
// Test evidence cascades from runs; inventory and snapshots cascade from servers.
// The registry trigger preserves history and marks its old server identity.
// Live inventory, rollback snapshots, and restart slots still cascade away.
try {
const tx = db().transaction((n: string) => {
// The Watchdog's rebuild campaign observes this exact operation
// through the registry gap and retains its before/after file audit.
for (const table of ['deployment_servers', 'deployment_files']) {
db().prepare(`DELETE FROM ${table} WHERE server = ? AND deployment_id NOT IN (
SELECT campaign_id FROM watchdog_actions WHERE campaign_id IS NOT NULL)`)
.run(n);
}
db().prepare('DELETE FROM beta_servers WHERE server = ?').run(n);
db().prepare('DELETE FROM beta_server_scripts WHERE server = ?').run(n);
db().prepare('DELETE FROM beta_test_runs WHERE server = ?').run(n);
db().prepare('DELETE FROM beta_test_coverage WHERE server = ?').run(n);
db().prepare('DELETE FROM beta_file_changes WHERE server = ?').run(n);
db().prepare('DELETE FROM servers WHERE name = ?').run(n);
});
tx(name);
db().prepare('DELETE FROM servers WHERE name = ?').run(name);
} catch (err) {
return {
ok: false,
@@ -706,7 +696,7 @@ export async function processCreations(): Promise<void> {
export function creationQueue() {
type Entry = { id: string; server: string; state: string; stage: string };
const jobs = db().prepare(`SELECT id, server, state, stage FROM server_deployments
WHERE action IN ('create', 'rebuild') AND state IN ('queued', 'running', 'awaiting-health')
WHERE server_deleted_at IS NULL AND action IN ('create', 'rebuild') AND state IN ('queued', 'running', 'awaiting-health')
ORDER BY started_at, rowid`).all() as Entry[];
return { active: jobs.filter(job => job.state !== 'queued'), queued: jobs.filter(job => job.state === 'queued') };
}
@@ -726,7 +716,7 @@ async function runCreations(): Promise<CreationOutcomes> {
if (creating || destroying.size > 0) return outcomes;
// Restart recovery is observation-only: never repeat external creation or
// deployment. A known interrupted startup must retain its readiness gate.
const interrupted = db().prepare("SELECT id FROM server_deployments WHERE action IN ('create', 'rebuild') AND state = 'interrupted' ORDER BY started_at, rowid").all() as { id: string }[];
const interrupted = db().prepare("SELECT id FROM server_deployments WHERE server_deleted_at IS NULL AND action IN ('create', 'rebuild') AND state = 'interrupted' ORDER BY started_at, rowid").all() as { id: string }[];
for (const { id } of interrupted) {
if (resumeVerification(id)) continue;
// A queued removal does not make the interrupted container disappear.
@@ -748,14 +738,14 @@ async function runCreations(): Promise<CreationOutcomes> {
// Check only previously started servers. Newly launched servers keep their
// slot until a later pass, even if a health probe would answer immediately.
const checking = db().prepare(`SELECT j.*, COALESCE(d.submitted_at, d.started_at) AS submitted_at FROM server_creation_jobs j JOIN server_deployments d ON d.id = j.deployment_id
WHERE d.state = 'awaiting-health' ORDER BY d.started_at, d.rowid`).all() as CreationJob[];
WHERE d.server_deleted_at IS NULL AND d.state = 'awaiting-health' ORDER BY d.started_at, d.rowid`).all() as CreationJob[];
for (const job of checking) {
const id = job.deployment_id;
if (getServer(job.server)?.game_port !== job.port) {
finishDeployment(id, { ok: false, message: 'Server was removed or replaced before creation health checks completed' });
continue;
}
if (db().prepare("SELECT id FROM server_deployments WHERE server = ? AND action = 'destroy' AND state IN ('waiting', 'running')").get(job.server)) continue;
if (db().prepare("SELECT id FROM server_deployments WHERE server = ? AND server_deleted_at IS NULL AND action = 'destroy' AND state IN ('waiting', 'running')").get(job.server)) continue;
try {
const komodo = await komodoInspectHealthy(job.server);
if (!komodo.ok) { deploymentStage(id, 'Waiting for Komodo health: ' + komodo.reason); continue; }
@@ -787,7 +777,7 @@ async function runCreations(): Promise<CreationOutcomes> {
// API calls can cancel a selected job or resume an older startup while
// a previous provision awaits Komodo. Recheck without expanding this pass.
if (creationQueue().active.length >= fleetSettings().createConcurrency) break;
if (!db().prepare("SELECT 1 FROM server_deployments WHERE id = ? AND action IN ('create', 'rebuild') AND state = 'queued'").get(id)) continue;
if (!db().prepare("SELECT 1 FROM server_deployments WHERE id = ? AND server_deleted_at IS NULL AND action IN ('create', 'rebuild') AND state = 'queued'").get(id)) continue;
const blocker = creationBlocker(id);
if (blocker) {
deploymentStage(id, blocker);
+9 -8
View File
@@ -28,7 +28,7 @@ const sourceTables = {
export function requestRestart(server: string, source: RestartSource): LifecycleOperation {
return db().transaction(() => {
const { table, key } = sourceTables[source.kind];
const row = db().prepare(`SELECT provisioning_id, restarted_at FROM ${table} WHERE ${key} = ? AND server = ?`)
const row = db().prepare(`SELECT provisioning_id, restarted_at FROM ${table} WHERE ${key} = ? AND server = ? AND server_deleted_at IS NULL`)
.get(source.id, server) as { provisioning_id: string | null; restarted_at: string | null } | undefined;
if (!row) throw new Error('Restart source no longer exists');
if (row.provisioning_id) {
@@ -45,14 +45,14 @@ export function requestRestart(server: string, source: RestartSource): Lifecycle
// The previous release already recorded some submissions in history.
// Bind to that exact interval, never to a newer restart of the server.
id = (db().prepare(`SELECT d.id FROM restart_rollout r JOIN server_deployments d ON d.id = r.operation_id
WHERE r.server = ? AND d.action = 'restart' AND d.started_at <= ? AND r.requested_at >= ?`)
WHERE r.server = ? AND d.server_deleted_at IS NULL AND d.action = 'restart' AND d.started_at <= ? AND r.requested_at >= ?`)
.get(server, row.restarted_at, row.restarted_at) as { id: string } | undefined)?.id;
if (id) db().prepare('UPDATE server_deployments SET submitted_at = COALESCE(submitted_at, ?) WHERE id = ?').run(row.restarted_at, id);
}
// Only not-yet-submitted campaign changes can share an activation. A later
// file deployment must never be credited to an already running restart.
if (source.kind === 'campaign' && !row.restarted_at) {
id = (db().prepare(`SELECT d.id FROM server_deployments d WHERE d.server = ? AND d.action = 'restart'
id = (db().prepare(`SELECT d.id FROM server_deployments d WHERE d.server = ? AND d.server_deleted_at IS NULL AND d.action = 'restart'
AND d.state = 'waiting' AND d.submitted_at IS NULL
AND EXISTS (SELECT 1 FROM provisioning_sources s WHERE s.operation_id = d.id AND s.kind = 'campaign')
AND NOT EXISTS (SELECT 1 FROM provisioning_sources s WHERE s.operation_id = d.id AND s.kind != 'campaign')
@@ -107,9 +107,9 @@ export function cancelProvisioning(id: string, reason = 'Cancelled by requester'
/** Explicit caller retry starts a new job and keeps the old job as history. */
export function releaseRestartRequest(server: string, source: RestartSource): void {
const { table, key } = sourceTables[source.kind];
const row = db().prepare(`SELECT provisioning_id FROM ${table} WHERE ${key} = ? AND server = ?`).get(source.id, server) as { provisioning_id: string | null } | undefined;
const row = db().prepare(`SELECT provisioning_id FROM ${table} WHERE ${key} = ? AND server = ? AND server_deleted_at IS NULL`).get(source.id, server) as { provisioning_id: string | null } | undefined;
if (row?.provisioning_id) cancelProvisioning(row.provisioning_id, 'Superseded by a fresh caller attempt');
db().prepare(`UPDATE ${table} SET provisioning_id = NULL WHERE ${key} = ? AND server = ?`).run(source.id, server);
db().prepare(`UPDATE ${table} SET provisioning_id = NULL WHERE ${key} = ? AND server = ? AND server_deleted_at IS NULL`).run(source.id, server);
}
export function requestLifecycle(server: string, action: RuntimeAction) {
@@ -117,6 +117,7 @@ export function requestLifecycle(server: string, action: RuntimeAction) {
if (!komodoConfigured() || !rconConfigured()) return { ok: false as const, status: 503, error: 'Configure Komodo and RCON before provisioning.' };
return db().transaction(() => {
const existing = db().prepare(`SELECT id, action FROM server_deployments WHERE server = ?
AND server_deleted_at IS NULL
AND state IN ('queued','waiting','running','awaiting-health') ORDER BY rowid DESC LIMIT 1`).get(server) as { id: string; action: string } | undefined;
if (existing) {
if (existing.action !== action) return { ok: false as const, status: 409, error: 'Another provisioning operation is already pending for this server.' };
@@ -142,7 +143,7 @@ export function requestLifecycle(server: string, action: RuntimeAction) {
type LifecycleRequestResult = { ok: true; deploymentId: string } | { ok: false; status: number; error: string };
function watchdogRequestGuard(server: string, incidentId: string): Extract<LifecycleRequestResult, { ok: false }> | null {
const incident = db().prepare("SELECT kind FROM watchdog_incidents WHERE id = ? AND server = ? AND state IN ('open','recovering')").get(incidentId, server) as { kind: 'health' | 'drift' } | undefined;
const incident = db().prepare("SELECT kind FROM watchdog_incidents WHERE id = ? AND server = ? AND server_deleted_at IS NULL AND state IN ('open','recovering')").get(incidentId, server) as { kind: 'health' | 'drift' } | undefined;
if (!incident) return { ok: false, status: 409, error: 'The Watchdog incident is no longer active.' };
if (!maintenanceAllowed(incident.kind)) {
return { ok: false, status: 409, error: 'This maintenance policy is paused.' };
@@ -183,7 +184,7 @@ export function requestWatchdogLifecycle(server: string, action: 'restart' | 're
if (error) return error;
const pending = db().prepare(`SELECT d.id, d.action, s.source_id FROM server_deployments d
LEFT JOIN provisioning_sources s ON s.operation_id = d.id AND s.kind = 'watchdog'
WHERE d.server = ? AND d.state IN ('queued','waiting','running','awaiting-health') ORDER BY d.rowid DESC LIMIT 1`)
WHERE d.server = ? AND d.server_deleted_at IS NULL AND d.state IN ('queued','waiting','running','awaiting-health') ORDER BY d.rowid DESC LIMIT 1`)
.get(server) as { id: string; action: string; source_id: string | null } | undefined;
if (pending) return pending.source_id === incidentId && pending.action === action
? { ok: true as const, deploymentId: pending.id }
@@ -223,7 +224,7 @@ export function escalateWatchdogRestart(incidentId: string, operationId: string,
if (error) return error;
const pending = db().prepare(`SELECT d.id, d.action, s.source_id FROM server_deployments d
LEFT JOIN provisioning_sources s ON s.operation_id = d.id AND s.kind = 'watchdog'
WHERE d.server = ? AND d.id != ? AND d.state IN ('queued','waiting','running','awaiting-health') LIMIT 1`)
WHERE d.server = ? AND d.server_deleted_at IS NULL AND d.id != ? AND d.state IN ('queued','waiting','running','awaiting-health') LIMIT 1`)
.get(operation.server, operationId) as { id: string; action: string; source_id: string | null } | undefined;
if (pending) return pending.action === 'rebuild' && pending.source_id === incidentId
? { ok: true as const, deploymentId: pending.id }
+16 -13
View File
@@ -7,7 +7,7 @@ import { deploymentStage, finishDeployment } from './history.js';
// Komodo queues restart work. Don't mistake the still-running old container
// for a completed restart when an API kick immediately follows the request.
const HEALTH_POLL_MS = 15_000;
const requestsInFlight = new Set<string>();
const requestsInFlight = new Map<string, string>();
export const restartRequestInFlight = (server: string) => requestsInFlight.has(server);
interface RestartSlot {
@@ -36,14 +36,14 @@ export function restartSettling(server: string): boolean {
}
export function rolloutWaitReason(server: string, operationId = ''): string | null {
if (db().prepare(`SELECT 1 FROM server_deployments WHERE server = ? AND id != ?
if (db().prepare(`SELECT 1 FROM server_deployments WHERE server = ? AND id != ? AND server_deleted_at IS NULL
AND ((action IN ('start','stop','rebuild') AND state IN ('running','awaiting-health'))
OR (action = 'destroy' AND state IN ('waiting','running')))`).get(server, operationId)) {
return 'Waiting for the current provisioning operation';
}
const slot = slotFor(server);
const ownHandoff = slot?.operation_id === operationId && !!db().prepare(`SELECT 1 FROM server_deployments d
WHERE d.id = ? AND d.action IN ('restart','rebuild') AND EXISTS (
WHERE d.id = ? AND d.server_deleted_at IS NULL AND d.action IN ('restart','rebuild') AND EXISTS (
SELECT 1 FROM provisioning_sources s WHERE s.operation_id = d.id AND s.kind = 'watchdog')`).get(operationId);
if (slot?.healthy_at === null && !ownHandoff) {
return `Waiting for the previous restart of this server to recover${slot.error ? `: ${slot.error}` : ''}`;
@@ -61,6 +61,7 @@ export type RolloutRestartResult =
export async function restartInRollout(server: string, options: { operationId: string; action: 'start' | 'restart' }): Promise<RolloutRestartResult> {
const operationId = options.operationId;
const waitReason = db().transaction(() => {
if (!db().prepare('SELECT 1 FROM server_deployments WHERE id = ? AND server_deleted_at IS NULL').get(operationId)) return 'Original server was deleted';
const reason = rolloutWaitReason(server, operationId);
if (reason) return reason;
db().prepare('UPDATE server_deployments SET submitted_at = ? WHERE id = ?').run(new Date().toISOString(), operationId);
@@ -73,35 +74,37 @@ export async function restartInRollout(server: string, options: { operationId: s
}).immediate();
if (waitReason) return { kind: 'queued', message: waitReason };
requestsInFlight.add(server);
requestsInFlight.set(server, operationId);
db().prepare(`UPDATE servers SET container_state = 'restarting', last_error = NULL, last_reply_snippet = NULL,
game_started_at = NULL, game_ended_at = NULL WHERE name = ?`).run(server);
try {
const result = options.action === 'start' ? await komodoRuntimeAction(server, 'start') : await komodoRestart(server);
if (!db().prepare('SELECT 1 FROM server_deployments WHERE id = ? AND server_deleted_at IS NULL').get(operationId)) return { kind: 'failed', message: 'Original server was deleted' };
if (result.ok) {
db().prepare("UPDATE server_deployments SET state = 'awaiting-health' WHERE id = ?").run(operationId);
db().prepare("UPDATE server_deployments SET state = 'awaiting-health' WHERE id = ? AND server_deleted_at IS NULL").run(operationId);
deploymentStage(operationId!, 'Waiting for container and game health (RCON or fresh telemetry)');
return { kind: 'started' };
}
// These failures cannot have queued a remote restart. Other failures
// (especially a timeout) may have: retain capacity until recovery.
if (result.kind === 'no-name' || result.kind === 'unconfigured') {
db().prepare('DELETE FROM restart_rollout WHERE server = ?').run(server);
db().prepare('DELETE FROM restart_rollout WHERE server = ? AND operation_id = ?').run(server, operationId);
} else {
db().prepare('UPDATE restart_rollout SET error = ? WHERE server = ?').run(`komodo: ${result.message}`, server);
db().prepare('UPDATE restart_rollout SET error = ? WHERE server = ? AND operation_id = ?').run(`komodo: ${result.message}`, server, operationId);
}
finishDeployment(operationId!, { ok: false, message: `komodo: ${result.message}` });
return { kind: 'failed', message: `komodo: ${result.message}` };
} catch (err) {
if (!db().prepare('SELECT 1 FROM server_deployments WHERE id = ? AND server_deleted_at IS NULL').get(operationId)) return { kind: 'failed', message: 'Original server was deleted' };
const message = `komodo: ${(err as Error).message}`;
db().prepare('UPDATE restart_rollout SET error = ? WHERE server = ?').run(message, server);
db().prepare('UPDATE restart_rollout SET error = ? WHERE server = ? AND operation_id = ?').run(message, server, operationId);
finishDeployment(operationId!, { ok: false, message });
return { kind: 'failed', message };
} finally {
// Start the grace period at completion, including slow/failed requests.
db().prepare('UPDATE restart_rollout SET requested_at = ? WHERE server = ?')
.run(new Date().toISOString(), server);
requestsInFlight.delete(server);
db().prepare('UPDATE restart_rollout SET requested_at = ? WHERE server = ? AND operation_id = ?')
.run(new Date().toISOString(), server, operationId);
if (requestsInFlight.get(server) === operationId) requestsInFlight.delete(server);
}
}
@@ -121,7 +124,7 @@ async function refresh(): Promise<void> {
if (requestsInFlight.has(slot.server)) continue;
// Recovery capacity transferred to a rebuild remains reserved until
// teardown. Its health is verified by the creation worker, never here.
if (slot.operation_id && db().prepare(`SELECT 1 FROM server_deployments d WHERE d.id = ? AND (
if (slot.operation_id && db().prepare(`SELECT 1 FROM server_deployments d WHERE d.id = ? AND d.server_deleted_at IS NULL AND (
(d.action = 'rebuild' AND d.state IN ('waiting','queued','running','awaiting-health'))
OR (d.action = 'restart' AND d.state = 'waiting' AND d.submitted_at IS NULL AND EXISTS (
SELECT 1 FROM provisioning_sources s WHERE s.operation_id = d.id AND s.kind = 'watchdog')))`)
@@ -146,7 +149,7 @@ async function refresh(): Promise<void> {
WHERE server = ? AND requested_at = ? AND operation_id IS ? AND healthy_at IS NULL`)
.run(now, error ? null : now, players, error, slot.server, slot.requested_at, slot.operation_id);
if (!checked.changes) continue;
if (slot.operation_id && db().prepare("SELECT 1 FROM server_deployments WHERE id = ? AND state = 'awaiting-health'").get(slot.operation_id)) {
if (slot.operation_id && db().prepare("SELECT 1 FROM server_deployments WHERE id = ? AND server_deleted_at IS NULL AND state = 'awaiting-health'").get(slot.operation_id)) {
db().prepare('UPDATE server_deployments SET checked_at = ?, players = ? WHERE id = ?').run(now, players, slot.operation_id);
if (error) deploymentStage(slot.operation_id, 'Waiting for health: ' + error);
else {
+10 -10
View File
@@ -23,7 +23,7 @@ function sourceEligibility(operation: Operation): 'ready' | 'held' | 'cancelled'
if (!sources.length) return 'ready';
// Watchdog intent gates every linked source, including its drift campaign.
for (const source of sources.filter(source => source.kind === 'watchdog')) {
if (!db().prepare("SELECT 1 FROM watchdog_incidents WHERE id = ? AND server = ? AND state IN ('open','recovering')").get(source.source_id, operation.server)
if (!db().prepare("SELECT 1 FROM watchdog_incidents WHERE id = ? AND server = ? AND server_deleted_at IS NULL AND state IN ('open','recovering')").get(source.source_id, operation.server)
|| getServer(operation.server)?.desired_state === 'stopped') return 'cancelled';
const incident = db().prepare('SELECT kind FROM watchdog_incidents WHERE id = ?').get(source.source_id) as { kind: 'health' | 'drift' };
if (!maintenanceAllowed(incident.kind)) return 'held';
@@ -31,21 +31,21 @@ function sourceEligibility(operation: Operation): 'ready' | 'held' | 'cancelled'
let held = false;
for (const source of sources) {
if (source.kind === 'campaign') {
const row = db().prepare('SELECT restart_state, files_changed, provisioning_id FROM deployment_servers WHERE deployment_id = ? AND server = ?')
const row = db().prepare('SELECT restart_state, files_changed, provisioning_id FROM deployment_servers WHERE deployment_id = ? AND server = ? AND server_deleted_at IS NULL')
.get(source.source_id, operation.server) as { restart_state: string; files_changed: number; provisioning_id: string } | undefined;
if (row?.provisioning_id !== operation.id || !row.files_changed) continue;
if (['pending','failed'].includes(row.restart_state)) return 'ready';
if (['held','manual'].includes(row.restart_state)) held = true;
} else if (source.kind === 'beta') {
const row = db().prepare(`SELECT b.state, bs.stage, bs.provisioning_id, p.outcome FROM beta_servers bs JOIN betas b ON b.id = bs.beta_id
LEFT JOIN beta_test_plans p ON p.beta_id = b.id WHERE bs.beta_id = ? AND bs.server = ?`)
LEFT JOIN beta_test_plans p ON p.beta_id = b.id WHERE bs.beta_id = ? AND bs.server = ? AND bs.server_deleted_at IS NULL`)
.get(source.source_id, operation.server) as { state: string; stage: string; provisioning_id: string; outcome: string | null } | undefined;
if (row?.provisioning_id !== operation.id || !['running','ready-promote'].includes(row.state)) continue;
if (!['awaiting-empty','restarting','awaiting-health'].includes(row.stage)) continue;
if (!row.outcome || ['active','passed'].includes(row.outcome)) return 'ready';
if (row.outcome === 'paused') held = true;
} else if (source.kind === 'maintenance') {
const row = db().prepare('SELECT state, provisioning_id, kind, origin FROM maintenance_jobs WHERE id = ?').get(source.source_id) as { state: string; provisioning_id: string; kind: string; origin: string } | undefined;
const row = db().prepare('SELECT state, provisioning_id, kind, origin FROM maintenance_jobs WHERE id = ? AND server_deleted_at IS NULL').get(source.source_id) as { state: string; provisioning_id: string; kind: string; origin: string } | undefined;
if (row?.provisioning_id === operation.id && ['queued','restarting'].includes(row.state)) {
if (getServer(operation.server)?.desired_state === 'stopped') return 'cancelled';
if (!maintenanceAllowed() || (row.kind === 'update' && row.origin === 'automatic' && !maintenanceAllowed('updates'))) return 'held';
@@ -59,7 +59,7 @@ function sourceEligibility(operation: Operation): 'ready' | 'held' | 'cancelled'
function blocker(server: string, id: string): string | null {
const sources = sourcesFor(id);
const rank = priority(sources);
const others = db().prepare(`SELECT id, state, action, rowid FROM server_deployments WHERE server = ? AND id != ?
const others = db().prepare(`SELECT id, state, action, rowid FROM server_deployments WHERE server = ? AND id != ? AND server_deleted_at IS NULL
AND state IN ('queued','waiting','running','awaiting-health')`).all(server, id) as { id: string; state: string; action: string; rowid: number }[];
const own = db().prepare('SELECT rowid FROM server_deployments WHERE id = ?').get(id) as { rowid: number };
if (others.some(other => other.state !== 'waiting' || other.action === 'destroy'
@@ -69,8 +69,8 @@ function blocker(server: string, id: string): string | null {
// Beta activation must not wait on the campaign rows that the beta itself
// holds. Campaign/maintenance callers likewise never block on their own job.
if (rank > 0 && db().prepare(`SELECT 1 FROM beta_servers bs JOIN betas b ON b.id = bs.beta_id
WHERE bs.server = ? AND b.state IN ('running','ready-promote') LIMIT 1`).get(server)) return 'Waiting for beta work on this server';
if (rank > 1 && db().prepare(`SELECT 1 FROM deployment_servers ds WHERE ds.server = ?
WHERE bs.server = ? AND bs.server_deleted_at IS NULL AND b.state IN ('running','ready-promote') LIMIT 1`).get(server)) return 'Waiting for beta work on this server';
if (rank > 1 && db().prepare(`SELECT 1 FROM deployment_servers ds WHERE ds.server = ? AND ds.server_deleted_at IS NULL
AND ds.restart_state IN ('pending','failed','held') AND (ds.provisioning_id IS NULL OR EXISTS (
SELECT 1 FROM server_deployments d WHERE d.id = ds.provisioning_id AND d.state IN ('queued','waiting','running','awaiting-health')))
AND NOT EXISTS (SELECT 1 FROM watchdog_actions a WHERE a.operation_id = ? AND a.campaign_id = ds.deployment_id)
@@ -93,7 +93,7 @@ async function run(): Promise<void> {
await processDestructions();
await refreshRestartRollout();
const operations = db().prepare(`SELECT id, server, action, state, stage FROM server_deployments
WHERE action IN ('start','restart','stop','rebuild') AND (state = 'waiting' OR (action = 'stop' AND state = 'awaiting-health'))
WHERE server_deleted_at IS NULL AND action IN ('start','restart','stop','rebuild') AND (state = 'waiting' OR (action = 'stop' AND state = 'awaiting-health'))
ORDER BY started_at, rowid`).all() as Operation[];
for (const operation of operations) {
try { await processOperation(operation); }
@@ -140,9 +140,9 @@ async function processOperation(operation: Operation): Promise<void> {
}
// Requests can arrive during the probes. Recheck before claiming the action.
if (blocker(server, id) || sourceEligibility(operation) !== 'ready') return;
if (!db().prepare("SELECT 1 FROM server_deployments WHERE id = ? AND state = 'waiting'").get(id)) return;
if (!db().prepare("SELECT 1 FROM server_deployments WHERE id = ? AND server_deleted_at IS NULL AND state = 'waiting'").get(id)) return;
if (action === 'rebuild') { await rebuildServer(id); return; }
const claim = db().prepare("UPDATE server_deployments SET state = 'running' WHERE id = ? AND state = 'waiting'").run(id);
const claim = db().prepare("UPDATE server_deployments SET state = 'running' WHERE id = ? AND server_deleted_at IS NULL AND state = 'waiting'").run(id);
if (!claim.changes) return;
if (action === 'start' || action === 'restart') {
const result = await restartInRollout(server, { operationId: id, action });
+13 -12
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { tick } from 'svelte';
import ServerIdentity from './ServerIdentity.svelte';
import HistoricalServerIdentity from './HistoricalServerIdentity.svelte';
import StatusBadge from './primitives/StatusBadge.svelte';
import ListFooter from './primitives/ListFooter.svelte';
import Button from './primitives/Button.svelte';
@@ -9,12 +9,13 @@
import { coverageSummary, requirementSummary, needsGameTelemetry } from '$lib/beta-plan-summary.js';
import { betaWaitingCondition, betaEvidenceSummary } from '$lib/beta-evidence.js';
import type { BetaRequirement, BetaTestView } from '$lib/beta-plan.js';
let { test, betaId, closed, deadline, retryBlocked, onUpdate }: { test:BetaTestView; betaId:number; closed:boolean; deadline?:string; retryBlocked?:string; onUpdate:()=>Promise<void> } = $props();
let { test, betaId, closed, deadline, retryBlocked, deletedParticipants = false, onUpdate }: { test:BetaTestView; betaId:number; closed:boolean; deadline?:string; retryBlocked?:string; deletedParticipants?:boolean; onUpdate:()=>Promise<void> } = $props();
let notes = $state<Record<string,string>>({});
let evidenceOpen = $state(false), evidenceLimit = $state(25), busy = $state(false), error = $state(''), message = $state('');
let evidenceServer = $state(''), evidenceKind = $state(''), evidenceQuery = $state('');
let evidenceSection = $state<HTMLDetailsElement>();
const canSign = $derived(!closed && ['active','passed'].includes(test.outcome));
const hasDeletedServers = $derived(deletedParticipants || test.servers.some(server => server.server_deleted_at));
const canSign = $derived(!closed && !hasDeletedServers && ['active','passed'].includes(test.outcome));
const expired = $derived(test.outcome === 'inconclusive' || (!!deadline && test.outcome !== 'passed' && Date.parse(deadline) <= Date.now()));
const ranked = $derived([...test.servers].sort((a,b) => (a.progress.some(r => r.failed) ? 0 : a.passed ? 2 : 1) - (b.progress.some(r => r.failed) ? 0 : b.passed ? 2 : 1) || a.server.localeCompare(b.server)));
const filteredEvidence = $derived(test.evidence.filter(event => (!evidenceServer || event.server === evidenceServer)
@@ -43,29 +44,29 @@
<div class="heading"><h2>Test results</h2><StatusBadge state={test.outcome}/></div>
<p>{coverageSummary(test.plan)} · Every requirement must pass on each qualifying server.</p>
{#if !closed}
<div class="actions">
{#if !hasDeletedServers}<div class="actions">
{#if test.outcome === 'paused' && !expired}<Button size="sm" disabled={busy} onclick={()=>action('resume')}>Resume testing</Button>{/if}
{#if ['active','passed'].includes(test.outcome)}<Button size="sm" disabled={busy} onclick={()=>action('pause')}>Pause testing</Button>{/if}
{#if ['paused','failed'].includes(test.outcome) && !expired}<Button size="sm" disabled={busy || !!retryBlocked} onclick={()=>action('retry')}>Start fresh attempt</Button>{/if}
</div>
</div>{/if}
{#if retryBlocked && ['paused','failed'].includes(test.outcome)}<p>{retryBlocked}</p>{/if}
{#if expired}<p class="guidance">The testing window ended. Roll back this beta first, then choose “Reuse files and test plan” to open a new beta with a longer window.</p>
{:else if test.outcome === 'paused'}<p class="guidance">Resume retains completed milestones; continuous health and in-progress timing intervals start fresh. Pausing does not extend the deadline.</p>{/if}
{:else if test.outcome === 'paused' && !hasDeletedServers}<p class="guidance">Resume retains completed milestones; continuous health and in-progress timing intervals start fresh. Pausing does not extend the deadline.</p>{/if}
{/if}
{#if error}<p role="alert" class="failed">{error}</p>{/if}
{#if message}<p role="status">{message}</p>{/if}
{#if !test.servers.length}<p>Waiting for the first server restart. Evidence collection begins with that run. The testing window is already running.</p>{/if}
{#if !test.servers.length}<p>{hasDeletedServers ? 'No test run was recorded before a participating server was deleted.' : 'Waiting for the first server restart. Evidence collection begins with that run. The testing window is already running.'}</p>{/if}
{#each ranked as server (server.server)}
<details class="server" open={!server.passed}>
<summary><ServerIdentity name={server.server}/><span>{server.progress.filter(p=>p.passed).length} / {server.progress.length} checks passed</span>{#if test.plan.requirements.some(r=>r.kind==='recovery')}<span>Recovery cycles: {server.phase}</span>{/if}</summary>
{#if test.plan.requirements.some(needsGameTelemetry)}<p class="observation">{#if server.observation?.lastGameplayAt}Latest gameplay: {server.observation.lastEvent?.replaceAll('_',' ')} · <time datetime={server.observation.lastGameplayAt} title={$timeFormat.dateTime(server.observation.lastGameplayAt)}>{reportAge(server.observation.lastGameplayAt)}</time> · {server.observation.players ?? 'Unknown'} players{#if server.observation.round !== null} · Round {server.observation.round}{/if}{:else}No gameplay reports in this attempt yet.{/if} <a href={`/servers/${encodeURIComponent(server.server)}?tab=diagnostics#telemetry`}>Telemetry diagnostics</a></p>{/if}
<summary><HistoricalServerIdentity name={server.server} deletedAt={server.server_deleted_at}/><span>{server.progress.filter(p=>p.passed).length} / {server.progress.length} checks passed</span>{#if test.plan.requirements.some(r=>r.kind==='recovery')}<span>Recovery cycles: {server.phase}</span>{/if}</summary>
{#if test.plan.requirements.some(needsGameTelemetry)}<p class="observation">{#if server.observation?.lastGameplayAt}Latest gameplay: {server.observation.lastEvent?.replaceAll('_',' ')} · <time datetime={server.observation.lastGameplayAt} title={$timeFormat.dateTime(server.observation.lastGameplayAt)}>{reportAge(server.observation.lastGameplayAt)}</time> · {server.observation.players ?? 'Unknown'} players{#if server.observation.round !== null} · Round {server.observation.round}{/if}{:else}No gameplay reports in this attempt yet.{/if} {#if !server.server_deleted_at}<a href={`/servers/${encodeURIComponent(server.server)}?tab=diagnostics#telemetry`}>Telemetry diagnostics</a>{/if}</p>{/if}
{#each server.progress as progress (progress.id)}
{@const requirement = test.plan.requirements.find(r=>r.id===progress.id)}
{#if requirement}<div class="requirement" class:failed={progress.failed}>
<div class="heading"><strong><span class="result">{progress.passed ? 'Passed' : progress.failed ? 'Failed' : 'Waiting'}</span> {requirementSummary(requirement)}</strong><span>{progress.detail}</span></div>
{#if !progress.passed && !closed}<p>{betaWaitingCondition(requirement,server.observation)}</p>{/if}
{#if !progress.passed && !closed && !hasDeletedServers}<p>{betaWaitingCondition(requirement,server.observation)}</p>{/if}
{#if !['manual','failure-budget'].includes(requirement.kind)}<progress aria-label={requirementSummary(requirement)} value={Math.min(progress.current,progress.target)} max={progress.target}></progress>{/if}
{#if requirement.kind === 'manual' && !progress.passed && !closed}
{#if requirement.kind === 'manual' && !progress.passed && !closed && !hasDeletedServers}
{#if canSign && server.observingAt}<div class="signoff"><label>Tester name and observation<textarea aria-label={`Tester and observation for ${progress.label} on ${server.server}`} rows="2" maxlength="1000" bind:value={notes[`${server.server}:${progress.id}`]} placeholder="Name · what you checked and observed"></textarea></label><Button size="sm" disabled={busy || !notes[`${server.server}:${progress.id}`]?.trim()} onclick={()=>action('sign-off',{server:server.server,requirementId:progress.id,note:notes[`${server.server}:${progress.id}`]})}>Sign off</Button></div>
{:else if canSign}<p>Waiting for this server's restart and health verification to finish before tester sign-off.</p>
{:else}<p>Sign-off is unavailable while this test is {test.outcome}. {test.outcome === 'paused' ? 'Resume testing first.' : 'Use the recovery instructions above.'}</p>{/if}
@@ -81,7 +82,7 @@
{#if evidenceOpen}
<p>Newest first. Includes retained evidence from earlier attempts and recovery cycles; current progress uses only the current attempt.</p>
<div class="filters"><label>Server<select bind:value={evidenceServer}><option value="">All servers</option>{#each [...new Set(test.evidence.map(event=>event.server))] as server}<option value={server}>{server}</option>{/each}</select></label><label>Event type<select bind:value={evidenceKind}><option value="">All events</option><option value="gameplay">Gameplay reports</option><option value="health">Health observations</option><option value="manual-sign-off">Tester sign-offs</option></select></label><label>Search evidence<input type="search" bind:value={evidenceQuery} placeholder="Round, map, tester, observation…"/></label></div>
{#each filteredEvidence.slice(0,evidenceLimit) as event(event.id)}<article class="evidence"><div class="heading"><ServerIdentity name={event.server}/><time datetime={event.received_at}>{$timeFormat.dateTime(event.received_at)}</time></div><p>{betaEvidenceSummary(event)}</p><details><summary>Raw payload · {event.kind}</summary><pre>{event.payload}</pre></details></article>{/each}
{#each filteredEvidence.slice(0,evidenceLimit) as event(event.id)}<article class="evidence"><div class="heading"><HistoricalServerIdentity name={event.server} deletedAt={event.server_deleted_at}/><time datetime={event.received_at}>{$timeFormat.dateTime(event.received_at)}</time></div><p>{betaEvidenceSummary(event)}</p><details><summary>Raw payload · {event.kind}</summary><pre>{event.payload}</pre></details></article>{/each}
<ListFooter total={filteredEvidence.length} bind:limit={evidenceLimit} label="events"/>
{#if !filteredEvidence.length}<p>{test.evidence.length ? 'No evidence matches these filters.' : 'No evidence recorded yet. Check deployment progress and telemetry diagnostics.'}</p>{/if}
{/if}
@@ -0,0 +1,17 @@
<script lang="ts">
import ServerIdentity from './ServerIdentity.svelte';
import Badge from './primitives/Badge.svelte';
let { name, deletedAt, href }: { name: string; deletedAt?: string | null; href?: string } = $props();
</script>
<span class="historical-server">
{#if href && !deletedAt}<a {href}><ServerIdentity {name} /></a>
{:else}<ServerIdentity {name} />{/if}
{#if deletedAt}<Badge>Deleted</Badge>{/if}
</span>
<style>
.historical-server { display: inline-flex; align-items: baseline; flex-wrap: wrap; gap: 6px 8px; min-width: 0; }
a { color: var(--accent-primary-hover); }
</style>
+2 -3
View File
@@ -3,7 +3,7 @@
import type { ServerMatch } from '$lib/match-history.js';
import { pollVisible } from '$ui/poll.js';
import { formatDuration } from '$ui/duration.js';
import { serverTitle } from '$ui/server-labels.js';
import HistoricalServerIdentity from './HistoricalServerIdentity.svelte';
import Card from './primitives/Card.svelte';
import Button from './primitives/Button.svelte';
import EmptyState from './primitives/EmptyState.svelte';
@@ -60,7 +60,7 @@
<tr>
<td><time datetime={match.startedAt}>{$timeFormat.dateTime(match.startedAt)}</time></td>
<td>{#if match.endedAt}<time datetime={match.endedAt}>{$timeFormat.dateTime(match.endedAt)}</time>{:else}—{/if}</td>
{#if server === undefined}<td><a href={`/servers/${encodeURIComponent(match.server)}?tab=matches`}>{serverTitle(match.server)}</a></td>{/if}
{#if server === undefined}<td><HistoricalServerIdentity name={match.server} deletedAt={match.server_deleted_at} href={`/servers/${encodeURIComponent(match.server)}?tab=matches`} /></td>{/if}
<td>{match.map}</td>
<td>{match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}</td>
<td>{match.startPlayers ?? '—'}</td><td>{match.uniquePlayers}</td><td>{match.endPlayers ?? '—'}</td>
@@ -84,5 +84,4 @@
th, td { padding: var(--space-3); text-align: left; border-bottom: 1px solid var(--border-subtle); white-space: nowrap; }
th { color: var(--text-secondary); font-size: var(--text-xs); }
td { font-variant-numeric: tabular-nums; }
a { color: var(--accent-primary-hover); }
</style>
@@ -0,0 +1,179 @@
<script lang="ts">
import type { MetricPoint, MetricsDays } from '$lib/metrics.js';
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
import { createTimeFormatter } from '$lib/time-zone.js';
import { metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
import Button from '$ui/components/primitives/Button.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import Select from '$ui/components/primitives/Select.svelte';
let { points, days, timeZone }: { points: MetricPoint[]; days: MetricsDays; timeZone: string } = $props();
let selectedAt = $state<number | null>(null);
let announcement = $state('');
const format = $derived(createTimeFormatter(timeZone));
const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search);
const mode = $derived(new URLSearchParams(contextSearch).get('chartMode') === 'hours' ? 'hours' : 'percent');
const selectedIndex = $derived.by(() => {
const existing = points.findIndex(point => point.at === selectedAt);
if (existing >= 0) return existing;
for (let i = points.length - 1; i >= 0; i--) if (!points[i].partial) return i;
return Math.max(0, points.length - 1);
});
const selected = $derived(points[selectedIndex]);
const selectedValue = $derived(selected ? String(selected.at) : '');
const ceiling = $derived.by(() => {
if (mode === 'percent') return 100;
const max = Math.max(0, ...points.map(point => point.observed + point.unknown));
if (!max) return 1;
const magnitude = 10 ** Math.floor(Math.log10(max));
return Math.ceil(max / (magnitude / 2)) * (magnitude / 2);
});
const tick = new Intl.NumberFormat('en-US', { maximumSignificantDigits: 3 });
const ticks = $derived([1, .75, .5, .25, 0].map(fraction => ({ fraction, value: `${tick.format(ceiling * fraction)}${mode === 'percent' ? '%' : ''}` })));
const periodOptions = $derived(points.map(point => ({ value: String(point.at), label: `${format.period(point.startAt)}${point.partial ? ' · partial' : ''}` })));
const middle = $derived(points[Math.floor(points.length / 2)]);
const captured = $derived(selected ? selected.observed + selected.unknown : 0);
function setMode(value: 'hours' | 'percent') {
const url = new URL(page.url);
url.search = contextSearch;
if (value === 'percent') url.searchParams.delete('chartMode'); else url.searchParams.set('chartMode', value);
replaceState(url, { ...page.state, metricsTableSearch: url.search });
}
function selectPeriod(at: number) {
selectedAt = at;
const point = points.find(item => item.at === at);
if (!point) return;
announcement = `${format.period(point.startAt)} to ${format.period(point.endAt)}${point.partial ? ', partial period' : ''}. ${point.observed + point.unknown ? `${metricDuration(point.active)} with players, ${metricDuration(point.idle)} idle, ${metricDuration(point.unknown)} unknown.` : 'No captured time.'}`;
}
function segmentHeight(point: MetricPoint, value: number) {
const denominator = mode === 'percent' ? point.observed + point.unknown : ceiling;
return denominator > 0 ? value / denominator * 100 : 0;
}
function move(direction: number) {
const next = points[Math.max(0, Math.min(points.length - 1, selectedIndex + direction))];
if (next) selectPeriod(next.at);
}
function chartKey(event: KeyboardEvent, index: number) {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const next = event.key === 'Home' ? 0 : event.key === 'End' ? points.length - 1 : Math.max(0, Math.min(points.length - 1, index + (event.key === 'ArrowLeft' ? -1 : 1)));
selectPeriod(points[next].at);
(event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLButtonElement>('button')[next]?.focus();
}
</script>
<section class="panel" aria-labelledby="metrics-trend-title">
<div class="heading"><div><h2 id="metrics-trend-title">Server time over time</h2><p>{mode === 'percent' ? 'Share of captured server time, including unknown status.' : `Server-hours per ${days === 1 ? 'hour' : '24-hour period'}.`} Partial periods contain less elapsed time.</p></div>
<div class="mode" role="group" aria-label="Timeline measure"><button type="button" aria-pressed={mode === 'percent'} onclick={() => setMode('percent')}>Percent</button><button type="button" aria-pressed={mode === 'hours'} onclick={() => setMode('hours')}>Hours</button></div>
</div>
<div class="legend"><span><i class="occupied"></i>With players</span><span><i class="idle"></i>Idle</span><span><i class="unknown"></i>Unknown</span><span class="partial-key">* Partial period</span></div>
<div class="axis-title">{mode === 'percent' ? 'Share of captured server time' : 'Server-hours'}</div>
<div class="chart-grid">
<div class="y-axis" aria-hidden="true">{#each ticks as item}<span style:top={`${(1 - item.fraction) * 100}%`}>{item.value}</span>{/each}</div>
<div class="plot" role="group" aria-label="Server time by period. Select a bar or use the period selector below.">
<div class="grid-lines" aria-hidden="true">{#each ticks as item}<span style:top={`${(1 - item.fraction) * 100}%`}></span>{/each}</div>
{#each points as point, index (point.at)}
<button class="bar" class:selected={point.at === selected?.at} class:partial={point.partial} type="button" tabindex={index === selectedIndex ? 0 : -1}
aria-pressed={point.at === selected?.at}
aria-label={`${format.period(point.startAt)}${point.partial ? ', partial period' : ''}: ${point.observed + point.unknown ? `${metricDuration(point.active)} with players, ${metricDuration(point.idle)} idle, ${metricDuration(point.unknown)} unknown` : 'No captured time'}`}
title={`${format.period(point.startAt)} – ${format.period(point.endAt)}${point.partial ? ' · partial period' : ''}`}
onclick={() => selectPeriod(point.at)} onkeydown={event => chartKey(event, index)}>
{#if point.partial}<span class="partial-marker" aria-hidden="true">*</span>{/if}
<span class="segment unknown" style:height={`${segmentHeight(point, point.unknown)}%`}></span>
<span class="segment idle" style:height={`${segmentHeight(point, point.idle)}%`}></span>
<span class="segment occupied" style:height={`${segmentHeight(point, point.active)}%`}></span>
{#if point.observed + point.unknown === 0}<span class="no-time" aria-hidden="true">·</span>{/if}
</button>
{/each}
</div>
<div class="x-axis" aria-hidden="true"><span>{points[0] ? format.period(points[0].startAt) : ''}</span>{#if middle}<span class="middle">{format.period(middle.startAt)}</span>{/if}<span>{points.length ? format.period(points[points.length - 1].endAt) : ''}</span></div>
</div>
{#if selected}
<div class="period-controls">
<div class="period-select"><Field label={`Inspect a period · ${timeZone.replaceAll('_', ' ')}`}><Select value={selectedValue} options={periodOptions} onchange={event => selectPeriod(Number((event.currentTarget as HTMLSelectElement).value))} /></Field></div>
<div class="step-buttons"><Button size="sm" disabled={selectedIndex === 0} onclick={() => move(-1)}>← Previous</Button><Button size="sm" disabled={selectedIndex === points.length - 1} onclick={() => move(1)}>Next →</Button></div>
</div>
<div class="readout" aria-label="Selected period details">
<p class="interval">{format.period(selected.startAt)} – {format.period(selected.endAt)} {#if selected.partial}<span class="partial-label">Partial period</span>{/if}</p>
{#if captured === 0}<p class="empty">No captured time in this period. Missing observations do not mean the fleet was idle.</p>
{:else}<dl>
<div><dt>With players</dt><dd>{metricDuration(selected.active)}<small>{metricPercent(selected.active, captured)} captured · {metricPercent(selected.active, selected.observed)} known</small></dd></div>
<div><dt>Idle</dt><dd>{metricDuration(selected.idle)}<small>{metricPercent(selected.idle, captured)} of captured time</small></dd></div>
<div><dt>Unknown</dt><dd>{metricDuration(selected.unknown)}<small>{metricPercent(selected.unknown, captured)} of captured time</small></dd></div>
<div><dt>Player-hours</dt><dd>{selected.observed ? metricNumber(selected.playerHours) : '—'}</dd></div>
</dl><p class="denominator">{metricDuration(captured)} captured server time · {metricDuration(selected.observed)} known. Percentage bars include unknown time; the occupancy KPI excludes it.</p>{/if}
</div>
{/if}
<span class="sr-only" role="status" aria-live="polite">{announcement}</span>
<details class="time-data"><summary>View exact time data</summary>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
<div class="table-wrap" role="region" aria-label="Time data" tabindex="0"><table>
<caption>Periods in {timeZone.replaceAll('_', ' ')}. Durations are estimated from captured observations.</caption>
<thead><tr><th scope="col">Period</th><th scope="col">With players</th><th scope="col">Idle</th><th scope="col">Unknown</th><th scope="col">Player-hours</th></tr></thead>
<tbody>{#each points as point (point.at)}<tr><th scope="row">{format.period(point.startAt)}<small>to {format.period(point.endAt)}{point.partial ? ' · Partial' : ''}</small></th><td>{metricDuration(point.active)}</td><td>{metricDuration(point.idle)}</td><td>{metricDuration(point.unknown)}</td><td>{point.observed ? metricNumber(point.playerHours) : '—'}</td></tr>{/each}</tbody>
</table></div>
</details>
</section>
<style>
.panel { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--space-5); min-width: 0; }
h2 { margin: 0; font-size: var(--text-lg); }
.heading { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
.mode { display: flex; border: 1px solid var(--border-subtle); padding: 3px; border-radius: var(--radius-md); }
.mode button { border: 0; border-radius: var(--radius-sm); padding: 7px 10px; background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--text-xs); cursor: pointer; }
.mode button[aria-pressed='true'] { background: var(--surface-raised); color: var(--text-primary); }
.mode button:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
.heading p { margin: var(--space-2) 0 var(--space-4); color: var(--text-secondary); font-size: var(--text-sm); }
.legend { display: flex; gap: var(--space-4); flex-wrap: wrap; align-items: center; font-size: var(--text-xs); }
.legend span { display: flex; align-items: center; gap: 6px; }
.legend i { width: 10px; height: 10px; border-radius: 2px; }
.occupied { background: var(--chart-active); }
.idle { background: var(--chart-idle); }
.unknown { background: repeating-linear-gradient(135deg, var(--chart-unknown-base) 0 3px, var(--chart-unknown-stripe) 3px 5px); }
.partial-key { color: var(--text-secondary); }
.axis-title { margin-top: var(--space-5); font-size: 11px; color: var(--text-tertiary); }
.chart-grid { display: grid; grid-template-columns: 44px minmax(0, 1fr); column-gap: 8px; margin-top: 12px; }
.y-axis { position: relative; text-align: right; font-size: 11px; color: var(--text-tertiary); height: 210px; }
.y-axis span { position: absolute; right: 0; transform: translateY(-50%); }
.plot { display: flex; position: relative; height: 210px; gap: 4px; isolation: isolate; border-bottom: 1px solid var(--border-strong); }
.grid-lines { position: absolute; inset: 0; pointer-events: none; z-index: -1; }
.grid-lines span { position: absolute; left: 0; right: 0; border-top: 1px solid var(--border-subtle); }
.bar { appearance: none; position: relative; flex: 1; min-width: 0; border: 0; margin: 0; padding: 0; background: transparent; display: flex; flex-direction: column; justify-content: flex-end; cursor: pointer; border-radius: 2px 2px 0 0; }
.bar.selected { outline: 1px solid var(--text-secondary); outline-offset: 2px; }
.bar:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 2px; }
.bar:hover { background: color-mix(in oklab, var(--accent-primary) 8%, transparent); }
.segment { display: block; width: 100%; flex-shrink: 0; pointer-events: none; }
.no-time { color: var(--text-tertiary); font-size: 20px; line-height: 1; }
.bar.partial { border-inline: 1px dashed var(--border-strong); }
.partial-marker { position: absolute; top: -17px; left: 50%; transform: translateX(-50%); font-size: 15px; color: var(--text-secondary); }
.x-axis { grid-column: 2; display: flex; justify-content: space-between; gap: 16px; color: var(--text-tertiary); font-size: 10px; margin-top: 10px; }
.x-axis span { max-width: 25ch; }
.x-axis span:last-child { text-align: right; }
.x-axis .middle { text-align: center; }
.period-controls { display: flex; align-items: flex-end; gap: var(--space-3); flex-wrap: wrap; margin-top: var(--space-5); }
.period-select { flex: 1 1 250px; max-width: 450px; }
.step-buttons { display: flex; gap: var(--space-2); padding-bottom: 3px; }
.readout { margin-top: var(--space-4); padding: var(--space-4); border-radius: var(--radius-md); background: var(--surface-sunken); border: 1px solid var(--border-subtle); }
.interval { color: var(--text-secondary); font-size: var(--text-xs); margin: 0; line-height: 1.6; }
.partial-label { display: inline-block; margin-left: 4px; padding: 1px 6px; border: 1px dashed var(--border-strong); border-radius: 4px; }
dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-4); margin: var(--space-3) 0 0; }
dt { color: var(--text-tertiary); font-size: var(--text-xs); }
dd { margin: var(--space-1) 0 0; font-variant-numeric: tabular-nums; font-size: var(--text-md); }
small { display: block; color: var(--text-tertiary); font-size: 11px; font-weight: normal; line-height: 1.5; }
.empty { margin: var(--space-3) 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
.denominator { color: var(--text-tertiary); font-size: 11px; line-height: 1.5; margin: var(--space-3) 0 0; }
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
details { margin-top: var(--space-4); font-size: var(--text-sm); }
summary { cursor: pointer; color: var(--text-secondary); padding: 4px 0; }
.table-wrap { max-width: 100%; overflow-x: auto; margin-top: var(--space-3); }
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
table { width: 100%; border-collapse: collapse; font-size: var(--text-xs); }
caption { text-align: left; color: var(--text-tertiary); margin-bottom: 8px; }
th, td { padding: 12px; border-bottom: 1px solid var(--border-subtle); text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
thead th { color: var(--text-secondary); }
th:first-child { text-align: left; }
tbody th { font-weight: var(--weight-regular); }
@media (max-width: 640px) { .panel { padding: var(--space-4); } .legend { gap: 10px; } .chart-grid { grid-template-columns: 32px minmax(0, 1fr); gap: 6px; } .plot { gap: 3px; } .x-axis .middle { display: none; } .x-axis { font-size: 11px; } dl { grid-template-columns: repeat(2, minmax(0, 1fr)); } .readout { padding: var(--space-3); } }
</style>
@@ -0,0 +1,120 @@
<script lang="ts">
import type { MetricsData } from '$lib/metrics.js';
import { createTimeFormatter } from '$lib/time-zone.js';
import { HOSTING_RULES, metricRecommendations } from '$lib/metrics-insights.js';
import { metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
import MetricsHeatmap from './MetricsHeatmap.svelte';
let { report, changingScope = false, onTimeZoneChange }: { report: MetricsData; changingScope?: boolean; onTimeZoneChange: (value: string) => void } = $props();
const format = $derived(createTimeFormatter(report.scope.timeZone));
const prior = $derived(report.previous);
const pressure = $derived(report.pooledPressure);
const recommendations = $derived(metricRecommendations(report.maps));
const utilizationDelta = $derived(report.summary.observed > 0 && prior.summary.observed > 0
? 100 * (report.summary.active / report.summary.observed - prior.summary.active / prior.summary.observed) : null);
const playerDelta = $derived(report.summary.observed <= 0 || prior.summary.observed <= 0 ? null
: prior.summary.playerHours > 0 ? 100 * (report.summary.playerHours / prior.summary.playerHours - 1)
: report.summary.playerHours === 0 ? 0 : null);
const playerNew = $derived(prior.summary.observed > 0 && report.summary.observed > 0 && prior.summary.playerHours === 0 && report.summary.playerHours > 0);
function signed(value: number, unit: string) { return `${value > 0 ? '+' : value < 0 ? '−' : ''}${metricNumber(Math.abs(value))}${unit}`; }
</script>
<section class="explore" aria-labelledby="metrics-explore-title">
<div class="heading"><h2 id="metrics-explore-title">Explore this period</h2><p>Planning signals for the selected scope. Review the evidence before changing hosting.</p></div>
<div class="signals">
<article class="signal" aria-labelledby="pressure-title">
<h3 id="pressure-title">Map capacity pressure</h3>
<strong class="value">{metricPercent(pressure.fullMapHours, pressure.knownMapHours)}</strong>
<p class="evidence">{metricDuration(pressure.fullMapHours)} full / {metricDuration(pressure.knownMapHours)} known map time</p>
<p>Share of pooled known map-hours with all lobbies full. {pressure.coveredMaps} of {pressure.totalMaps} map groups have known capacity.</p>
{#if pressure.knownMapHours === 0}<p class="notice">Capacity pressure is unavailable. Occupancy can still be recorded without a reported player limit.</p>{/if}
<p class="fine">Map groups can overlap in clock time; this is not simultaneous fleet saturation.</p>
</article>
<article class="signal" aria-labelledby="comparison-title">
<div class="comparison-heading"><h3 id="comparison-title">Compared with the previous {report.days === 1 ? '24 hours' : `${report.days} days`}</h3>{#if prior.partialHistory}<span class="badge">Partial previous history</span>{/if}</div>
<div class="deltas">
<div><span>Time with players</span><strong>{utilizationDelta === null ? '—' : signed(utilizationDelta, ' pp')}</strong><small>Percentage points · {metricPercent(prior.summary.active, prior.summary.observed)} → {metricPercent(report.summary.active, report.summary.observed)}</small></div>
<div><span>Player-hours</span><strong>{playerNew ? 'New' : playerDelta === null ? '—' : signed(playerDelta, '%')}</strong><small>{prior.summary.observed > 0 ? metricNumber(prior.summary.playerHours) : '—'} → {report.summary.observed > 0 ? metricNumber(report.summary.playerHours) : '—'}</small></div>
</div>
<p class="evidence">Known server time: {metricDuration(prior.summary.observed)} previous · {metricDuration(report.summary.observed)} current</p>
{#if prior.summary.observed <= 0 || report.summary.observed <= 0}<p class="notice">A comparison needs known occupancy in both windows. The available durations are still shown.</p>
{:else if prior.summary.playerHours === 0 && report.summary.playerHours > 0}<p class="notice">No previous player-hours: a percentage change has no baseline.</p>{/if}
{#if prior.partialHistory}<p class="fine">Retained scope history does not span the full previous window. Deltas use the available observations without extrapolation.</p>{/if}
<details class="comparison-detail"><summary>Compare observation windows</summary>
<dl><div><dt>Previous</dt><dd>{format.period(prior.since)} – {format.period(prior.until)}<small>{metricDuration(prior.summary.active)} with players / {metricDuration(prior.summary.observed)} known · {metricDuration(prior.summary.unknown)} unknown</small></dd></div>
<div><dt>Current</dt><dd>{format.period(report.since)} – {format.period(report.now)}<small>{metricDuration(report.summary.active)} with players / {metricDuration(report.summary.observed)} known · {metricDuration(report.summary.unknown)} unknown</small></dd></div></dl>
<p class="fine">pp means percentage points. Collection gaps, changing fleet size, and retained history can affect both comparisons. Equal windows do not guarantee equal observation coverage.</p>
</details>
</article>
</div>
<div class="suggestions">
<div class="suggestion-heading"><h3>Hosting suggestions</h3><span class="advisory">Advice only · Rules v1</span></div>
<p class="fine">Suggestions use historical observations. Check the current lineup before trying a change. These experiments do not estimate rejected joins or change servers automatically.</p>
{#if recommendations.length}
<div class="recommendations">{#each recommendations as suggestion (JSON.stringify([suggestion.game, suggestion.map]))}
<article class="recommendation">
<div class="map"><h4>{suggestion.map}</h4><small>{suggestion.game.toUpperCase()}</small>{#if suggestion.tentative}<span class="badge">Tentative</span>{/if}</div>
<div class="advice"><strong>{suggestion.title}</strong><p class="evidence">{suggestion.evidence}</p><details><summary>Why & what to watch</summary><p>{suggestion.reason}</p><p>{suggestion.outcome}</p></details></div>
</article>
{/each}</div>
{:else}<p class="empty">No map observations in this scope yet. Select another scope or leave collection running to generate suggestions.</p>{/if}
<details class="rules"><summary>How Rules v1 chooses a suggestion</summary>
<ul>
<li>Trial an additional lobby when all tracked lobbies are full for at least {HOSTING_RULES.pressureExpansion * 100}% of known-capacity time.</li>
<li>When capacity is unavailable, at least {HOSTING_RULES.occupancyExpansion * 100}% occupied known time can suggest a tentative additional-lobby trial. Occupancy does not establish saturation.</li>
<li>Review idle hosting when occupied known time is at most {HOSTING_RULES.idleUtilization * 100}%, and either measured pressure is below {HOSTING_RULES.quietPressure * 100}% or capacity is unavailable.</li>
<li>Missing occupancy prompts a reporting check. Other cases suggest watching hosting or checking capacity reporting.</li>
</ul>
<p>Less than {HOSTING_RULES.minimumEvidenceHours} hour of relevant known time, missing capacity, or substantial unknown status marks advice as tentative. These are trial policies, not validated demand forecasts. Collection gaps are omitted from all rules.</p>
</details>
</div>
<details class="heatmap-disclosure"><summary>View activity by weekday and hour</summary>
<MetricsHeatmap heatmap={report.heatmap} disabled={changingScope} {onTimeZoneChange} />
</details>
</section>
<style>
.explore { min-width: 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow: hidden; }
.heading { padding: var(--space-5) var(--space-5) var(--space-4); }
h2 { margin: 0; font-size: var(--text-lg); }
h3 { margin: 0; font-size: var(--text-md); }
p { margin: var(--space-2) 0 0; line-height: 1.6; font-size: var(--text-xs); color: var(--text-secondary); }
.heading p { font-size: var(--text-sm); }
.signals { display: grid; grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); gap: var(--space-4); padding: 0 var(--space-5) var(--space-5); }
.signal { min-width: 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: var(--space-4); background: var(--surface-sunken); }
.value { display: block; margin-top: var(--space-3); font-size: var(--text-2xl); font-variant-numeric: tabular-nums; }
.evidence { color: var(--text-primary); font-variant-numeric: tabular-nums; }
.fine { color: var(--text-tertiary); font-size: 11px; line-height: 1.6; }
.notice { padding-left: var(--space-2); border-left: 2px solid var(--state-warning); }
.comparison-heading { display: flex; gap: var(--space-2); align-items: flex-start; flex-wrap: wrap; }
.badge { display: inline-block; width: fit-content; color: var(--state-warning); font-size: 10px; border: 1px solid color-mix(in oklab, var(--state-warning) 40%, transparent); padding: 2px 6px; border-radius: 4px; white-space: nowrap; }
.deltas { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-4); margin-top: var(--space-3); }
.deltas span { color: var(--text-secondary); font-size: 11px; }
.deltas strong { display: block; margin-top: 4px; font-size: var(--text-xl); font-variant-numeric: tabular-nums; }
small { display: block; margin-top: 4px; font-size: 11px; color: var(--text-tertiary); font-weight: normal; line-height: 1.6; }
details { font-size: var(--text-xs); }
summary { cursor: pointer; color: var(--text-secondary); line-height: 1.6; }
.comparison-detail { margin-top: var(--space-3); }
dl { margin: var(--space-3) 0 0; display: grid; gap: var(--space-3); font-size: 11px; }
dt { color: var(--text-tertiary); }
dd { margin: 2px 0 0; color: var(--text-secondary); }
.suggestions { padding: var(--space-5); border-top: 1px solid var(--border-subtle); }
.suggestion-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
.advisory { color: var(--text-tertiary); font-size: 11px; }
.recommendations { margin-top: var(--space-3); }
.recommendation { display: grid; grid-template-columns: minmax(130px, .6fr) minmax(0, 2fr); gap: var(--space-4); padding: var(--space-4) 0; border-bottom: 1px solid var(--border-subtle); }
.map h4 { margin: 0; font-size: var(--text-sm); font-weight: var(--weight-medium); overflow-wrap: anywhere; }
.map .badge { margin-top: 5px; }
.advice strong { font-size: var(--text-sm); font-weight: var(--weight-medium); }
.advice p { margin-top: 5px; }
.advice details { margin-top: 6px; }
.rules { margin-top: var(--space-4); color: var(--text-secondary); }
.rules ul { padding-left: var(--space-5); line-height: 1.8; }
.empty { padding: var(--space-4) 0; }
.heatmap-disclosure { border-top: 1px solid var(--border-subtle); padding: var(--space-4) var(--space-5); }
.heatmap-disclosure > summary { font-size: var(--text-sm); font-weight: var(--weight-medium); }
@media (max-width: 760px) { .signals { grid-template-columns: 1fr; } }
@media (max-width: 540px) { .heading, .suggestions { padding: var(--space-4); } .signals { padding: 0 var(--space-4) var(--space-4); gap: var(--space-3); } .recommendation { grid-template-columns: 1fr; gap: var(--space-2); } .map { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; } .map small, .map .badge { margin: 0; } .heatmap-disclosure { padding: var(--space-4); } }
</style>
@@ -0,0 +1,104 @@
<script lang="ts">
import type { MetricsData } from '$lib/metrics.js';
import { availableTimeZones } from '$lib/time-zone.js';
import { metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
import Field from '$ui/components/primitives/Field.svelte';
import Select from '$ui/components/primitives/Select.svelte';
type Heatmap = MetricsData['heatmap'];
type Cell = Heatmap['cells'][number];
let { heatmap, disabled = false, onTimeZoneChange }: { heatmap: Heatmap; disabled?: boolean; onTimeZoneChange: (value: string) => void } = $props();
const weekdays = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const hours = Array.from({ length: 24 }, (_, hour) => hour);
const zones = $derived([...new Set([...availableTimeZones(), heatmap.timeZone])].sort().map(zone => ({ value: zone, label: zone.replaceAll('_', ' ') })));
const dayOptions = weekdays.map((label, day) => ({ value: String(day), label }));
const hourOptions = hours.map(hour => ({ value: String(hour), label: hourLabel(hour) }));
let selectedKey = $state<number | null>(null);
let announcement = $state('');
const cells = $derived(new Map(heatmap.cells.map(cell => [cell.weekday * 24 + cell.hour, cell])));
const selected = $derived(cells.get(selectedKey ?? -1) ?? heatmap.cells.find(cell => cell.observed + cell.unknown > 0) ?? heatmap.cells[0]);
function hourLabel(hour: number) { return `${String(hour).padStart(2, '0')}:00–${String((hour + 1) % 24).padStart(2, '0')}:00`; }
function cellAt(day: number, hour: number): Cell { return cells.get(day * 24 + hour) ?? { weekday: day, hour, active: 0, observed: 0, idle: 0, unknown: 0, playerHours: 0, distinctDates: 0 }; }
function description(cell: Cell) {
return `${weekdays[cell.weekday]}, ${hourLabel(cell.hour)}: ${cell.observed ? `${metricPercent(cell.active, cell.observed)} with players during ${metricDuration(cell.observed)} known server time` : cell.unknown ? `${metricDuration(cell.unknown)} unknown server time` : 'No captured time'}. ${cell.distinctDates} distinct dates.`;
}
function select(day: number, hour: number) { selectedKey = day * 24 + hour; announcement = description(cellAt(day, hour)); }
function move(event: KeyboardEvent, cell: Cell) {
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const day = Math.max(0, Math.min(6, cell.weekday + (event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0)));
const hour = event.key === 'Home' ? 0 : event.key === 'End' ? 23 : Math.max(0, Math.min(23, cell.hour + (event.key === 'ArrowLeft' ? -1 : event.key === 'ArrowRight' ? 1 : 0)));
const key = day * 24 + hour;
select(day, hour);
(event.currentTarget as HTMLElement).closest('table')?.querySelector<HTMLButtonElement>(`button[data-key="${key}"]`)?.focus();
}
</script>
<div class="heatmap">
<div class="heading"><div><h3>Activity by weekday and hour</h3><p>Time with players as a share of known server time, grouped in {heatmap.timeZone.replaceAll('_', ' ')}.</p></div>
<div class="zone"><Field label="Report timezone"><Select value={heatmap.timeZone} options={zones} {disabled} onchange={event => onTimeZoneChange((event.currentTarget as HTMLSelectElement).value)} /></Field></div>
</div>
<div class="legend"><span>0% <i class="scale"></i> 100% with players</span><span><i class="unknown-example"></i>Unknown only</span><span><i class="missing-example">·</i>No captured time</span></div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (The heatmap table must scroll with the keyboard.) -->
<div class="table-wrap" role="region" aria-label="Activity heatmap; use arrow keys between cells or the selectors below" tabindex="0"><table>
<caption class="sr-only">Weekday and local hour activity. Cell details include known time and distinct recorded dates.</caption>
<thead><tr><th scope="col">Day</th>{#each hours as hour}<th scope="col">{String(hour).padStart(2, '0')}</th>{/each}</tr></thead>
<tbody>{#each weekdays as day, weekday}<tr><th scope="row">{day.slice(0, 3)}</th>{#each hours as hour}{@const cell = cellAt(weekday, hour)}
<td><button type="button" class="cell" class:known={cell.observed > 0} class:unknown={cell.observed === 0 && cell.unknown > 0} class:mixed={cell.observed > 0 && cell.unknown > 0} class:missing={cell.observed + cell.unknown === 0}
style:--activity={`${cell.observed > 0 ? Math.min(100, Math.max(0, 100 * cell.active / cell.observed)) : 0}%`}
data-key={weekday * 24 + hour} tabindex={selected?.weekday === weekday && selected?.hour === hour ? 0 : -1}
aria-pressed={selected?.weekday === weekday && selected?.hour === hour} aria-label={description(cell)} title={description(cell)}
onclick={() => select(weekday, hour)} onkeydown={event => move(event, cell)}><span aria-hidden="true">{cell.observed > 0 ? '' : cell.unknown > 0 ? '?' : '·'}</span></button></td>
{/each}</tr>{/each}</tbody>
</table></div>
<p class="note">A striped corner marks additional unknown time. Color uses known time only. Repeated daylight-saving clock hours share a cell; collection gaps stay uncaptured.</p>
{#if selected}
<div class="selectors"><Field label="Inspect weekday"><Select value={String(selected.weekday)} options={dayOptions} onchange={event => select(Number((event.currentTarget as HTMLSelectElement).value), selected.hour)} /></Field><Field label="Inspect hour"><Select value={String(selected.hour)} options={hourOptions} onchange={event => select(selected.weekday, Number((event.currentTarget as HTMLSelectElement).value))} /></Field></div>
<div class="readout"><strong>{weekdays[selected.weekday]} · {hourLabel(selected.hour)} · {heatmap.timeZone.replaceAll('_', ' ')}</strong>
{#if selected.observed + selected.unknown === 0}<p>No captured time for this weekday and hour. This does not mean the fleet was idle.</p>
{:else}<dl>
<div><dt>Time with players</dt><dd>{metricPercent(selected.active, selected.observed)}<small>{metricDuration(selected.active)} of {metricDuration(selected.observed)} known</small></dd></div>
<div><dt>Unknown server time</dt><dd>{metricDuration(selected.unknown)}</dd></div>
<div><dt>Player-hours</dt><dd>{selected.observed ? metricNumber(selected.playerHours) : '—'}</dd></div>
<div><dt>Distinct recorded dates</dt><dd>{selected.distinctDates}</dd></div>
</dl>{/if}
</div>
{/if}
<span class="sr-only" role="status" aria-live="polite">{announcement}</span>
</div>
<style>
.heatmap { min-width: 0; margin-top: var(--space-4); }
.heading { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-4); flex-wrap: wrap; }
h3 { margin: 0; font-size: var(--text-md); }
p { font-size: var(--text-xs); color: var(--text-secondary); margin: var(--space-2) 0 0; line-height: 1.6; }
.zone { flex: 0 1 250px; min-width: 190px; }
.legend { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; margin: var(--space-4) 0 var(--space-3); font-size: 11px; color: var(--text-secondary); }
.legend span { display: flex; gap: 5px; align-items: center; }
.scale { width: 64px; height: 9px; background: linear-gradient(to right, var(--surface-raised), var(--chart-active)); border-radius: 3px; }
.unknown-example, .missing-example { width: 14px; height: 14px; display: inline-flex; justify-content: center; align-items: center; font-style: normal; }
.unknown-example, .cell.unknown { background: repeating-linear-gradient(135deg, var(--chart-unknown-base) 0 3px, var(--chart-unknown-stripe) 3px 5px); }
.missing-example { border: 1px dashed var(--border-control); }
.table-wrap { overflow-x: auto; max-width: 100%; padding: 3px; }
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
table { width: 100%; min-width: 750px; border-collapse: separate; border-spacing: 3px; table-layout: fixed; }
th { color: var(--text-tertiary); font-weight: normal; font-size: 10px; text-align: center; padding: 0 0 3px; }
th:first-child { width: 35px; position: sticky; left: 0; background: var(--surface); z-index: 2; text-align: left; }
td { padding: 0; }
.cell { position: relative; appearance: none; display: block; width: 100%; height: 29px; border: 1px solid var(--border-subtle); border-radius: 3px; background: var(--surface-raised); color: var(--text-secondary); font: inherit; cursor: pointer; overflow: hidden; }
.cell.known { background: color-mix(in oklab, var(--chart-active) var(--activity), var(--surface-raised)); }
.cell.missing { border-style: dashed; background: transparent; }
.cell.mixed::after { content: ''; position: absolute; top: 0; right: 0; width: 7px; height: 7px; background: repeating-linear-gradient(135deg, var(--chart-unknown-base) 0 2px, var(--chart-unknown-stripe) 2px 4px); }
.cell[aria-pressed='true'] { outline: 1px solid var(--text-primary); outline-offset: 1px; }
.cell:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 1px; }
.note { color: var(--text-tertiary); font-size: 11px; }
.selectors { display: grid; grid-template-columns: repeat(2, minmax(0, 190px)); gap: var(--space-3); margin-top: var(--space-4); }
.readout { margin-top: var(--space-3); padding: var(--space-4); background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
.readout > strong { font-size: var(--text-xs); font-weight: var(--weight-medium); color: var(--text-secondary); }
dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-3); margin: var(--space-3) 0 0; }
dt { font-size: 11px; color: var(--text-tertiary); }
dd { margin: 5px 0 0; font-size: var(--text-md); font-variant-numeric: tabular-nums; }
small { display: block; margin-top: 4px; font-size: 11px; color: var(--text-tertiary); }
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 640px) { .zone { flex-basis: 100%; } dl { grid-template-columns: repeat(2, minmax(0, 1fr)); } .readout { padding: var(--space-3); } .selectors { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
</style>
@@ -0,0 +1,143 @@
<script lang="ts">
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
import type { MapMetrics, ServerMetrics } from '$lib/metrics.js';
import { compareMetricValues, metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
import { serverTitle } from '$ui/server-labels.js';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
let { maps, servers }: { maps: MapMetrics[]; servers: ServerMetrics[] } = $props();
const mapColumns = [
{ key: 'map', label: 'Map' }, { key: 'playerHours', label: 'Player-hours' },
{ key: 'active', label: 'With players' }, { key: 'full', label: 'All lobbies full' },
{ key: 'capacityObserved', label: 'Known capacity' }
] as const;
const serverColumns = [
{ key: 'server', label: 'Server' }, { key: 'utilization', label: 'Time with players' },
{ key: 'idle', label: 'Idle' }, { key: 'observed', label: 'Known server time' }, { key: 'unknown', label: 'Unknown' }
] as const;
type MapSort = typeof mapColumns[number]['key'];
type ServerSort = typeof serverColumns[number]['key'];
type Direction = 'asc' | 'desc';
// Shallow navigation changes page.state, not page.url. Store the working
// search string there so filters react immediately and survive Back/Forward.
const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search);
const params = $derived(new URLSearchParams(contextSearch));
const mapQuery = $derived((params.get('mapQ') ?? '').slice(0, 100));
const serverQuery = $derived((params.get('serverQ') ?? '').slice(0, 100));
const mapSort = $derived(mapColumns.find(column => column.key === params.get('mapSort'))?.key ?? 'playerHours');
const serverSort = $derived(serverColumns.find(column => column.key === params.get('serverSort'))?.key ?? 'utilization');
const mapDirection = $derived<Direction>(params.get('mapDirection') === 'asc' ? 'asc' : 'desc');
const serverDirection = $derived<Direction>(params.get('serverDirection') === 'asc' ? 'asc' : 'desc');
const maxPlayerHours = $derived(Math.max(0, ...maps.map(map => map.playerHours)));
const mapRows = $derived(maps.filter(map => `${map.game} ${gameName(map.game)} ${map.map}`.toLowerCase().includes(mapQuery.trim().toLowerCase())).sort((a, b) => {
const result = mapSort === 'map' ? a.map.localeCompare(b.map) * (mapDirection === 'asc' ? 1 : -1)
: compareMetricValues(mapValue(a, mapSort), mapValue(b, mapSort), mapDirection);
return result || a.map.localeCompare(b.map) || a.game.localeCompare(b.game);
}));
const serverRows = $derived(servers.filter(server => `${server.server} ${serverTitle(server.server)}`.toLowerCase().includes(serverQuery.trim().toLowerCase())).sort((a, b) => {
const result = serverSort === 'server' ? serverTitle(a.server).localeCompare(serverTitle(b.server)) * (serverDirection === 'asc' ? 1 : -1)
: compareMetricValues(serverValue(a, serverSort), serverValue(b, serverSort), serverDirection);
return result || b.observed - a.observed || a.server.localeCompare(b.server);
}));
function gameName(game: string) { return game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase(); }
function mapValue(map: MapMetrics, key: Exclude<MapSort, 'map'>): number | null {
return key === 'full' && !map.capacityObserved || key === 'playerHours' && !map.observed ? null : map[key];
}
function serverValue(server: ServerMetrics, key: Exclude<ServerSort, 'server'>): number | null {
if (key === 'utilization' || key === 'idle') return server.observed > 0 ? server[key === 'utilization' ? 'active' : 'idle'] / server.observed : null;
return server[key];
}
function updateContext(changes: Record<string, string>) {
const url = new URL(page.url);
url.search = contextSearch;
for (const [key, value] of Object.entries(changes)) { if (value) url.searchParams.set(key, value); else url.searchParams.delete(key); }
replaceState(url, { ...page.state, metricsTableSearch: url.search });
}
function sortMap(key: MapSort) { updateContext({ mapSort: key, mapDirection: key === mapSort ? mapDirection === 'asc' ? 'desc' : 'asc' : key === 'map' ? 'asc' : 'desc' }); }
function sortServer(key: ServerSort) { updateContext({ serverSort: key, serverDirection: key === serverSort ? serverDirection === 'asc' ? 'desc' : 'asc' : key === 'server' ? 'asc' : 'desc' }); }
</script>
<section class="panel" aria-labelledby="map-metrics-title">
<div class="heading"><div><h2 id="map-metrics-title">Map play time & capacity</h2><p>Compare play time and how often every tracked lobby was full.</p></div>
<div class="search"><Field label="Find a map" hint="Search this table only."><Input type="search" value={mapQuery} placeholder="Map or game…" oninput={event => updateContext({ mapQ: (event.currentTarget as HTMLInputElement).value })} /></Field></div>
</div>
<p class="definition">“All lobbies full” uses map time when every tracked lobby has known occupancy and capacity. It does not measure failed joins or unmet demand.</p>
{#if mapRows.length}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
<div class="table-wrap" role="region" aria-label="Map play time and capacity table" tabindex="0"><table>
<thead><tr>{#each mapColumns as column}<th scope="col" aria-sort={mapSort === column.key ? mapDirection === 'asc' ? 'ascending' : 'descending' : undefined}><button type="button" onclick={() => sortMap(column.key)}>{column.label}<span aria-hidden="true">{mapSort === column.key ? mapDirection === 'asc' ? '↑' : '↓' : '↕'}</span></button></th>{/each}</tr></thead>
<tbody>{#each mapRows as map (JSON.stringify([map.game, map.map]))}<tr>
<th scope="row">{map.map}<small>{gameName(map.game)} · {map.game.toUpperCase()}</small></th>
<td class="player-hours"><span>{map.observed ? metricNumber(map.playerHours) : '—'}</span><div class="comparison" aria-hidden="true"><i style:width={`${maxPlayerHours ? map.playerHours / maxPlayerHours * 100 : 0}%`}></i></div></td>
<td>{metricDuration(map.active)}<small>server time</small></td>
<td>{#if map.capacityObserved > 0}{metricDuration(map.full)}<small>{metricPercent(map.full, map.capacityObserved)} during {metricDuration(map.capacityObserved)} known</small>{:else}<span class="unavailable">Unknown</span><small>No known capacity time</small>{/if}</td>
<td>{metricDuration(map.capacityObserved)}<small>map time</small></td>
</tr>{/each}</tbody>
</table></div>
{:else}<EmptyState title={maps.length ? 'No maps match this search' : 'No map observations'} description={maps.length ? 'Try a recorded map name or game.' : 'Map rows appear when server observations include a map.'}>{#snippet actions()}{#if mapQuery}<Button size="sm" onclick={() => updateContext({ mapQ: '' })}>Clear map search</Button>{/if}{/snippet}</EmptyState>{/if}
<div class="footer"><span>{mapRows.length} of {maps.length} maps</span><span>Missing player limits leave capacity unknown, even when occupancy is available.</span></div>
</section>
<section class="panel" aria-labelledby="server-metrics-title">
<div class="heading"><div><h2 id="server-metrics-title">Server utilization</h2><p>Time with players as a share of each server’s known time.</p></div>
<div class="search"><Field label="Find a server" hint="Search this table only."><Input type="search" value={serverQuery} placeholder="Server name or map…" oninput={event => updateContext({ serverQ: (event.currentTarget as HTMLInputElement).value })} /></Field></div>
</div>
{#if serverRows.length}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
<div class="table-wrap" role="region" aria-label="Server utilization table" tabindex="0"><table>
<thead><tr>{#each serverColumns as column}<th scope="col" aria-sort={serverSort === column.key ? serverDirection === 'asc' ? 'ascending' : 'descending' : undefined}><button type="button" onclick={() => sortServer(column.key)}>{column.label}<span aria-hidden="true">{serverSort === column.key ? serverDirection === 'asc' ? '↑' : '↓' : '↕'}</span></button></th>{/each}</tr></thead>
<tbody>{#each serverRows as server (server.server)}<tr>
<th scope="row"><ServerIdentity name={server.server} /><small class="server-id">{server.server}</small>{#if server.currentRegistered}<a class="current-server" href={`/servers/${encodeURIComponent(server.server)}`}>Open current server <span aria-hidden="true">↗</span></a>{/if}</th>
<td>{metricPercent(server.active, server.observed)}<small>{metricDuration(server.active)} of {metricDuration(server.observed)} known</small></td>
<td>{metricPercent(server.idle, server.observed)}<small>{metricDuration(server.idle)} idle</small></td>
<td>{metricDuration(server.observed)}</td><td>{metricDuration(server.unknown)}</td>
</tr>{/each}</tbody>
</table></div>
{:else}<EmptyState title="No servers match this search" description="Try a friendly map name or technical server ID.">{#snippet actions()}<Button size="sm" onclick={() => updateContext({ serverQ: '' })}>Clear server search</Button>{/snippet}</EmptyState>{/if}
<div class="footer"><span>{serverRows.length} of {servers.length} historical server names</span><span>Names may include multiple registrations. Links open the current registration, which may differ from historical instances.</span></div>
</section>
<style>
.panel { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); min-width: 0; overflow: hidden; }
.heading { padding: var(--space-5); display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); flex-wrap: wrap; }
h2 { font-size: var(--text-lg); margin: 0; }
.heading p { margin: var(--space-2) 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
.search { flex: 0 1 230px; min-width: 180px; }
.definition { margin: 0; padding: 0 var(--space-5) var(--space-4); color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.6; max-width: 110ch; }
.table-wrap { width: 100%; overflow-x: auto; }
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: -3px; }
table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
th, td { padding: 14px var(--space-4); border-bottom: 1px solid var(--border-subtle); text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
th:first-child { text-align: left; min-width: 185px; max-width: 280px; white-space: normal; overflow-wrap: anywhere; }
thead th { color: var(--text-secondary); background: var(--surface-sunken); font-size: var(--text-xs); }
thead button { display: inline-flex; justify-content: flex-end; align-items: center; gap: 6px; color: inherit; background: transparent; font: inherit; padding: 5px 0; border: 0; cursor: pointer; text-align: inherit; white-space: nowrap; }
thead button:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 4px; }
thead button span { color: var(--text-tertiary); }
thead th[aria-sort] { color: var(--text-primary); }
tbody th { font-weight: var(--weight-medium); }
tbody tr:hover { background: color-mix(in oklab, var(--surface-raised) 35%, transparent); }
small { display: block; margin-top: 4px; font-size: 11px; font-weight: normal; line-height: 1.5; color: var(--text-tertiary); }
.server-id { font-family: var(--font-mono); font-size: 10px; }
.current-server { display: inline-block; margin-top: 6px; font-size: 11px; color: var(--accent-primary-hover); text-decoration: none; }
.current-server:hover { text-decoration: underline; }
.current-server:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
.unavailable { color: var(--text-secondary); }
.player-hours { min-width: 120px; }
.comparison { height: 3px; width: 90px; margin: 8px 0 0 auto; background: var(--surface-raised); border-radius: 3px; }
.comparison i { display: block; height: 100%; background: var(--chart-active); border-radius: inherit; opacity: .7; }
.footer { display: flex; justify-content: space-between; flex-wrap: wrap; gap: var(--space-2) var(--space-4); padding: var(--space-4) var(--space-5); font-size: 11px; color: var(--text-tertiary); line-height: 1.6; }
@media (max-width: 640px) {
.heading { padding: var(--space-4); }
.search { flex: 1 1 100%; }
.definition { padding-inline: var(--space-4); }
th, td { padding: 12px; font-size: var(--text-xs); }
th:first-child { position: sticky; left: 0; z-index: 1; min-width: 130px; max-width: 150px; background: var(--surface); box-shadow: 1px 0 var(--border-strong); }
thead th:first-child { background: var(--surface-sunken); }
.footer { padding: var(--space-3) var(--space-4); }
}
</style>
+87
View File
@@ -0,0 +1,87 @@
import type { MetricsData } from '$lib/metrics.js';
import { validTimeZone } from '$lib/time-zone.js';
const record = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value);
const numeric = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
const timestamp = (value: unknown) => value === null || numeric(value);
const totals = (value: unknown) => record(value) && ['observed', 'active', 'idle', 'unknown', 'playerHours'].every(key => numeric(value[key]) && value[key] >= 0);
function validReport(value: unknown): value is MetricsData {
if (!record(value) || ![1, 7, 30].includes(value.days as number) || !numeric(value.now) || !numeric(value.since) || !totals(value.summary)) return false;
if (!record(value.scope) || !validTimeZone(value.scope.timeZone) || ![value.scope.game, value.scope.map].every(item => item === null || typeof item === 'string')) return false;
if (!record(value.scopeOptions) || !Array.isArray(value.scopeOptions.games) || !value.scopeOptions.games.every(item => typeof item === 'string') ||
!Array.isArray(value.scopeOptions.maps) || !value.scopeOptions.maps.every(item => record(item) && typeof item.game === 'string' && typeof item.map === 'string')) return false;
if (!numeric(value.registeredServerCount) || !['firstRetainedSampleAt', 'latestSampleAt', 'scopeFirstSampleAt', 'scopeLatestSampleAt'].every(key => timestamp(value[key]))) return false;
if (!Array.isArray(value.points) || !value.points.every(item => record(item) && totals(item) && numeric(item.at) && numeric(item.startAt) && numeric(item.endAt) && typeof item.partial === 'boolean')) return false;
if (!Array.isArray(value.maps) || !value.maps.every(item => record(item) && totals(item) && typeof item.game === 'string' && typeof item.map === 'string' && numeric(item.full) && numeric(item.capacityObserved))) return false;
if (!Array.isArray(value.servers) || !value.servers.every(item => record(item) && totals(item) && typeof item.server === 'string' && typeof item.currentRegistered === 'boolean')) return false;
if (!record(value.previous) || !totals(value.previous.summary) || !numeric(value.previous.since) || !numeric(value.previous.until) ||
!timestamp(value.previous.firstSampleAt) || !timestamp(value.previous.lastSampleAt) || typeof value.previous.partialHistory !== 'boolean') return false;
if (!record(value.heatmap) || !validTimeZone(value.heatmap.timeZone) || !Array.isArray(value.heatmap.cells) ||
!value.heatmap.cells.every(item => record(item) && totals(item) && numeric(item.weekday) && numeric(item.hour) && numeric(item.distinctDates))) return false;
return record(value.pooledPressure) && (value.pooledPressure.value === null || numeric(value.pooledPressure.value)) &&
['fullMapHours', 'knownMapHours', 'coveredMaps', 'totalMaps'].every(key => numeric((value.pooledPressure as Record<string, unknown>)[key]));
}
interface Options {
getQuery: () => string;
onData: (report: MetricsData) => void;
onError: (message: string | null) => void;
onLoading: (loading: boolean) => void;
fetcher?: typeof fetch;
timeoutMs?: number;
}
/** Keeps background failures and obsolete responses out of the last successful report. */
export function createMetricsRefresh(options: Options) {
const request = options.fetcher ?? fetch;
let generation = 0;
let pending: { query: string; controller: AbortController; promise: Promise<void>; timer?: ReturnType<typeof setTimeout> } | null = null;
function cancel() {
generation++;
if (pending) {
clearTimeout(pending.timer);
pending.controller.abort();
pending = null;
}
options.onLoading(false);
}
function refresh(): Promise<void> {
const query = options.getQuery();
if (pending?.query === query) return pending.promise;
cancel();
const id = generation;
const controller = new AbortController();
let timedOut = false;
const current = () => generation === id && options.getQuery() === query;
options.onLoading(true);
options.onError(null);
const entry = { query, controller, promise: Promise.resolve(), timer: undefined as ReturnType<typeof setTimeout> | undefined };
pending = entry;
entry.timer = setTimeout(() => { timedOut = true; controller.abort(); }, options.timeoutMs ?? 15_000);
entry.promise = (async () => {
try {
const response = await request(`/api/metrics?${query}`, { signal: controller.signal, cache: 'no-store' });
if (!response.ok) throw new Error(`Report refresh failed (${response.status}).`);
const report: unknown = await response.json();
if (!validReport(report)) {
throw new Error('The metrics response was incomplete.');
}
if (current()) options.onData(report);
} catch (error) {
if (current() && (!controller.signal.aborted || timedOut)) {
options.onError(timedOut ? 'Report refresh timed out. Try again.' : error instanceof Error ? error.message : 'Unable to refresh metrics. Try again.');
}
} finally {
clearTimeout(entry.timer);
if (pending === entry) pending = null;
if (current()) options.onLoading(false);
}
})();
return entry.promise;
}
return { refresh, cancel };
}
+32
View File
@@ -0,0 +1,32 @@
const numberFormat = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
export function metricNumber(value: number): string {
if (!Number.isFinite(value)) return '—';
if (value > 0 && value < 0.1) return '<0.1';
return numberFormat.format(value);
}
export function metricPercent(value: number, total: number): string {
if (!Number.isFinite(value) || !Number.isFinite(total) || total <= 0) return '—';
const percent = 100 * value / total;
return percent > 0 && percent < 0.1 ? '<0.1%' : `${percent.toFixed(1)}%`;
}
/** Input is hours; retain short, real observations instead of rounding them to zero. */
export function metricDuration(hours: number): string {
if (!Number.isFinite(hours) || hours < 0) return '—';
if (hours === 0) return '0 h';
const seconds = hours * 3600;
if (seconds < 1) return '<1 s';
if (seconds < 60) return `${numberFormat.format(seconds)} s`;
if (hours < 0.1) return `${numberFormat.format(seconds / 60)} min`;
return `${numberFormat.format(hours)} h`;
}
/** Missing evidence always sorts last, in either direction. */
export function compareMetricValues(left: number | null, right: number | null, direction: 'asc' | 'desc'): number {
const a = left !== null && Number.isFinite(left) ? left : null;
const b = right !== null && Number.isFinite(right) ? right : null;
if (a === null || b === null) return a === b ? 0 : a === null ? 1 : -1;
return (a - b) * (direction === 'asc' ? 1 : -1);
}
+6
View File
@@ -78,6 +78,12 @@
--accent-primary-hover: var(--blue-300);
--accent-primary-active: #398fcf;
/* Metrics: unknown uses a pattern as well as color. */
--chart-active: var(--accent-primary);
--chart-idle: #7b9baf;
--chart-unknown-base: #605b64;
--chart-unknown-stripe: #a7a0ad;
/* Typography — Inter everywhere, mono for data.
Display face lives inside BrandMark; no global --font-display token. */
--font-body: 'Inter', system-ui, -apple-system, sans-serif;
+1
View File
@@ -4,6 +4,7 @@ export type WatchdogState = 'open' | 'recovering' | 'attention' | 'resolved' | '
export interface WatchdogIncident {
id: string;
server: string;
server_deleted_at?: string | null;
kind: 'health' | 'drift';
state: WatchdogState;
summary: string;
+2 -2
View File
@@ -33,7 +33,7 @@ export async function GET({ params }: { params: { id: string } }) {
const events = db().prepare('SELECT id, created_at, message FROM beta_events WHERE beta_id = ? ORDER BY id').all(id);
const campaign = db().prepare("SELECT id FROM deployments WHERE beta_id = ? AND action = 'promote' ORDER BY started_at DESC LIMIT 1").get(id) as { id: string } | undefined;
const promotion = campaign ? { ...campaign,
pending: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND restart_state NOT IN ('done','failed','cancelled')").get(campaign.id) as { n: number }).n,
failed: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND restart_state = 'failed'").get(campaign.id) as { n: number }).n } : null;
pending: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state NOT IN ('done','failed','cancelled')").get(campaign.id) as { n: number }).n,
failed: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state = 'failed'").get(campaign.id) as { n: number }).n } : null;
return json({ beta, scripts, servers, events, test: testView(id), promotion });
}
+3 -2
View File
@@ -19,11 +19,12 @@ export async function POST({ params, request }: { params: { id: string }; reques
if (!body.server) return json({ ok: false, error: 'server is required' }, { status: 400 });
const row = db()
.prepare('SELECT stage, restarted_at, healthy_at FROM beta_servers WHERE beta_id = ? AND server = ?')
.prepare('SELECT stage, restarted_at, healthy_at, server_deleted_at FROM beta_servers WHERE beta_id = ? AND server = ?')
.get(betaId, body.server) as
| { stage: string; restarted_at: string | null; healthy_at: string | null }
| { stage: string; restarted_at: string | null; healthy_at: string | null; server_deleted_at: string | null }
| undefined;
if (!row) return json({ ok: false, error: 'no such beta_server' }, { status: 404 });
if (row.server_deleted_at) return json({ ok: false, error: 'This beta server was deleted; open a new beta for its replacement.' }, { status: 409 });
if (row.stage !== 'failed') return json({ ok: false, error: `stage is ${row.stage}, not failed` }, { status: 409 });
// Best-effort resume point: if we already got past restart, go back to
@@ -17,6 +17,9 @@ export async function POST({ params, request }: { params: { id: string }; reques
if (!['running', 'ready-promote'].includes(beta.state)) return json({ error: 'Beta is closed' }, { status: 409 });
try {
const body = await request.json();
if (db().prepare('SELECT 1 FROM beta_servers WHERE beta_id = ? AND server_deleted_at IS NOT NULL').get(id)) {
return json({ ok: false, error: 'A beta server was deleted. Roll back this beta and open a new one to collect fresh evidence.' }, { status: 409 });
}
const plan = parseBetaPlan(JSON.parse(stored.plan));
if (body.action === 'sign-off') {
if (!['active', 'passed'].includes(stored.outcome)) throw new Error('Resume or retry the test before signing off');
+3 -2
View File
@@ -40,12 +40,13 @@ export async function GET({ url }: { url: URL }) {
const placeholders = ids.map(() => '?').join(',');
const serversByDep = db()
.prepare(
`SELECT deployment_id, server, files_changed, restart_state, players, checked_at, restarted_at, attempts, error
`SELECT deployment_id, server, server_deleted_at, files_changed, restart_state, players, checked_at, restarted_at, attempts, error
FROM deployment_servers WHERE deployment_id IN (${placeholders})`
)
.all(...ids) as Array<{
deployment_id: string;
server: string;
server_deleted_at: string | null;
files_changed: number;
restart_state: string;
players: number | null;
@@ -92,7 +93,7 @@ export async function GET({ url }: { url: URL }) {
servers: grouped.get(d.id) ?? [],
scripts: scriptsByDep.get(d.id) ?? []
}))
.filter((d) => (activeOnly ? d.servers.some((s) => s.restart_state === 'pending' || s.restart_state === 'failed') : true));
.filter((d) => (activeOnly ? d.servers.some((s) => !s.server_deleted_at && (s.restart_state === 'pending' || s.restart_state === 'failed')) : true));
return json({ deployments: rows });
}
+9
View File
@@ -0,0 +1,9 @@
import { json } from '@sveltejs/kit';
import { parseMetricsQuery } from '$lib/metrics.js';
import { readMetrics } from '$lib/server/fleet/metrics.js';
export function GET({ url }: { url: URL }) {
return json(readMetrics(parseMetricsQuery(url.searchParams)), {
headers: { 'cache-control': 'no-store' }
});
}
+9 -6
View File
@@ -11,6 +11,7 @@
import { confirmAction } from '$ui/confirm.js';
import BetaTestProgress from '$ui/components/BetaTestProgress.svelte';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte';
import OperationHistory from '$ui/components/OperationHistory.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import Card from '$ui/components/primitives/Card.svelte';
@@ -21,7 +22,7 @@
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
interface BetaRow { id:number; state:string; opened_at:string; closed_at:string|null; wait_for_health:number; wait_for_match:number; notes?:string|null; }
interface BetaServer { server:string; stage:string; error:string|null; deployed_at:string|null; restarted_at:string|null; healthy_at:string|null; match_seen_at:string|null; provisioning_id:string|null; }
interface BetaServer { server:string; server_deleted_at?:string|null; stage:string; error:string|null; deployed_at:string|null; restarted_at:string|null; healthy_at:string|null; match_seen_at:string|null; provisioning_id:string|null; }
interface BetaFile { id:number; name:string; kind:string; target_rel_path:string; version:{id:number; version:number; release_version:string|null; sha256:string}|null; }
interface Promotion { id:string; pending:number; failed:number; }
let test = $state<BetaTestView|null>(null);
@@ -36,6 +37,7 @@
let now = $state(Date.now());
const id = $derived(Number($page.params.id));
const closed = $derived(beta?.state === 'promoted' || beta?.state === 'rolled-back');
const hasDeletedServers = $derived(servers.some(server => server.server_deleted_at));
const deadline = $derived(beta && test ? new Date(Date.parse(beta.opened_at) + test.plan.timeoutHours * 3600000) : null);
const remaining = $derived(deadline ? Math.max(0, Math.ceil((deadline.getTime() - now) / 60000)) : 0);
const deadlineText = $derived(remaining > 0 ? `${Math.floor(remaining / 60)}h ${remaining % 60}m remaining` : 'Testing window ended');
@@ -43,6 +45,7 @@
const ranked = $derived([...servers].sort((a,b) => (a.stage === 'failed' ? 0 : a.stage === 'ready' ? 2 : 1) - (b.stage === 'failed' ? 0 : b.stage === 'ready' ? 2 : 1) || a.server.localeCompare(b.server)));
const waiting = $derived(ranked.filter(server => server.stage !== 'ready'));
const nextAction = $derived(closed ? beta?.state === 'promoted' ? 'Follow the campaign to check activation on additional servers.' : 'Reuse these files and this plan when you are ready to test again.'
: hasDeletedServers ? 'A participating server was deleted. Its results remain below. Roll back, then reuse this plan to test the current servers.'
: test?.outcome === 'inconclusive' ? 'The deadline passed without enough evidence. Roll back, then reuse this plan with a longer testing window.'
: test?.outcome === 'paused' ? 'Resume to continue collecting evidence, or start a fresh attempt. The original deadline still applies.'
: test?.outcome === 'failed' ? 'Investigate the failed check before a fresh attempt, or roll back.'
@@ -91,7 +94,7 @@
async function rollback() {
const reject = beta?.state === 'ready-promote';
const restarts = test?.plan.rollbackRestart === 'manual' ? 'Restart servers manually to load the restored files.' : 'Restarts wait for empty servers.';
if (!await confirmAction({title:reject?'Reject beta?':'Roll back beta?',message:`Restore the original files on ${servers.length} beta servers${reject ? ' and mark these versions as test-failed' : ''}. ${restarts}`,confirmLabel:reject?'Reject beta':'Roll back',danger:true})) return;
if (!await confirmAction({title:reject?'Reject beta?':'Roll back beta?',message:`Restore the original files on ${servers.filter(server => !server.server_deleted_at).length} beta servers${reject ? ' and mark these versions as test-failed' : ''}. ${restarts}`,confirmLabel:reject?'Reject beta':'Roll back',danger:true})) return;
acting = 'rollback'; flash = ''; success = '';
try {
const response = await fetch(`/api/betas/${id}/rollback`,{method:'POST'}), body = await response.json();
@@ -136,7 +139,7 @@
</div>
{#if !closed && test}<p class="muted">Waiting for empty servers uses this window. Pausing or retrying does not extend the deadline.</p>{/if}
<div class="actions">
{#if !closed}<Button variant="primary" onclick={reviewPromotion} disabled={beta.state !== 'ready-promote' || acting !== null}>Review promotion</Button><Button variant="danger" onclick={rollback} disabled={acting !== null} loading={acting === 'rollback'}>{beta.state === 'ready-promote' ? 'Reject beta' : 'Roll back'}</Button>{/if}
{#if !closed}<Button variant="primary" onclick={reviewPromotion} disabled={hasDeletedServers || beta.state !== 'ready-promote' || acting !== null}>Review promotion</Button><Button variant="danger" onclick={rollback} disabled={acting !== null} loading={acting === 'rollback'}>{beta.state === 'ready-promote' ? 'Reject beta' : 'Roll back'}</Button>{/if}
{#if closed}<Button href={`/betas/new?reuse=${id}`}>Reuse files and test plan</Button>{/if}
{#if promotion}<Button href={`/campaigns/${encodeURIComponent(promotion.id)}`}>View campaign</Button>{/if}
</div>
@@ -146,13 +149,13 @@
</Card>
{#if !closed && waiting.length}
<section aria-label="Outstanding servers"><h2>Outstanding conditions</h2><div class="server-list">
{#each waiting as server}<div class="server-row"><a href={`/servers/${encodeURIComponent(server.server)}`}><ServerIdentity name={server.server} /></a><StatusBadge state={server.stage} /><p>{server.error ?? outstanding(server.server)}</p><a href={server.error ? `/servers/${encodeURIComponent(server.server)}?tab=diagnostics` : '#test-results'}>{server.error ? 'Diagnose' : 'View checks'}</a></div>{/each}
{#each waiting as server}<div class="server-row"><HistoricalServerIdentity name={server.server} deletedAt={server.server_deleted_at} href={`/servers/${encodeURIComponent(server.server)}`} /><StatusBadge state={server.stage} /><p>{server.error ?? (server.server_deleted_at ? 'This server was deleted. Recorded progress is retained.' : outstanding(server.server))}</p>{#if server.error && !server.server_deleted_at}<a href={`/servers/${encodeURIComponent(server.server)}?tab=diagnostics`}>Diagnose</a>{:else if test}<a href="#test-results">View checks</a>{/if}</div>{/each}
</div></section>
{/if}
{#if test}<div id="test-results"><Card padding="lg"><BetaTestProgress {test} betaId={id} {closed} deadline={deadline?.toISOString()} retryBlocked={servers.some(server=>server.error?.startsWith('deploy:')) ? 'Deployment was incomplete. Roll back before opening a fresh beta.' : undefined} onUpdate={load} /></Card></div>{/if}
{#if test}<div id="test-results"><Card padding="lg"><BetaTestProgress {test} betaId={id} {closed} deletedParticipants={hasDeletedServers} deadline={deadline?.toISOString()} retryBlocked={servers.some(server=>server.error?.startsWith('deploy:')) ? 'Deployment was incomplete. Roll back before opening a fresh beta.' : undefined} onUpdate={load} /></Card></div>{/if}
<details class="ui-panel"><summary>Files and versions ({scripts.length})</summary><div class="file-list">{#each scripts as file}<div><a href={`/${file.kind === 'baseline' ? 'configs' : file.kind + 's'}/${file.id}`}>{file.name}</a><strong>{artifactVersionLabel(file.version)}</strong><code>{file.target_rel_path}</code><span>{file.version?.sha256.slice(0,12)}</span></div>{/each}</div></details>
<details class="ui-panel"><summary>Deployment progress ({servers.length} servers)</summary><div class="server-list">
{#each ranked as server}<div class="server-row"><a href={`/servers/${encodeURIComponent(server.server)}`}><ServerIdentity name={server.server}/></a><StatusBadge state={server.stage}/><p>Files {server.deployed_at ? 'installed' : 'pending'} · Restart {server.restarted_at ? 'submitted' : 'pending'}{#if beta.wait_for_health} · Health {server.healthy_at ? 'verified' : 'pending'}{/if}{#if beta.wait_for_match} · Match {server.match_seen_at ? 'observed' : 'pending'}{/if}</p>{#if server.provisioning_id}<a href={`/provisioning/${encodeURIComponent(server.provisioning_id)}`}>View provisioning</a>{/if}{#if server.error}<p class="ui-error">{server.error}</p>{/if}{#if server.stage === 'failed' && !test && !closed}<Button size="sm" onclick={() => retry(server.server)} disabled={acting !== null} loading={acting === `retry:${server.server}`}>Retry server</Button>{/if}</div>{/each}
{#each ranked as server}<div class="server-row"><HistoricalServerIdentity name={server.server} deletedAt={server.server_deleted_at} href={`/servers/${encodeURIComponent(server.server)}`}/><StatusBadge state={server.stage}/><p>Files {server.deployed_at ? 'installed' : 'pending'} · Restart {server.restarted_at ? 'submitted' : 'pending'}{#if beta.wait_for_health} · Health {server.healthy_at ? 'verified' : 'pending'}{/if}{#if beta.wait_for_match} · Match {server.match_seen_at ? 'observed' : 'pending'}{/if}</p>{#if server.provisioning_id}<a href={`/provisioning/${encodeURIComponent(server.provisioning_id)}`}>View provisioning</a>{/if}{#if server.error}<p class="ui-error">{server.error}</p>{/if}{#if server.stage === 'failed' && !server.server_deleted_at && !test && !closed}<Button size="sm" onclick={() => retry(server.server)} disabled={acting !== null} loading={acting === `retry:${server.server}`}>Retry server</Button>{/if}</div>{/each}
</div></details>
<OperationHistory {events} active={!closed} showOverview={false}/>
</div>
+6 -4
View File
@@ -6,6 +6,7 @@
import { pollVisible } from '$ui/poll.js';
import { confirmAction } from '$ui/confirm.js';
import { serverTitle } from '$ui/server-labels.js';
import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
@@ -15,6 +16,7 @@
interface ServerRow {
server: string;
server_deleted_at?: string | null;
restart_state: string;
files_changed: number;
players: number | null;
@@ -76,7 +78,7 @@
onMount(() => pollVisible(loadCampaigns, 5000));
function isActive(d: Deployment): boolean {
return d.servers.some((s) => s.restart_state === 'pending' || s.restart_state === 'failed');
return d.servers.some((s) => !s.server_deleted_at && (s.restart_state === 'pending' || s.restart_state === 'failed'));
}
function ago(iso: string): string {
const d = Date.now() - new Date(iso).getTime();
@@ -108,7 +110,7 @@
ev.preventDefault();
ev.stopPropagation();
const summary = scriptSummary(d) || 'this promote';
if (!await confirmAction({ title:'Roll back promotion?', message:`Restore the previous version of ${summary} on ${d.servers.length} servers.`, confirmLabel:'Roll back', danger:true })) return;
if (!await confirmAction({ title:'Roll back promotion?', message:`Restore the previous version of ${summary} on ${d.servers.filter(server => !server.server_deleted_at).length} servers.`, confirmLabel:'Roll back', danger:true })) return;
rollingBack = d.id;
flash = null; flashId = d.id; flashError = false;
try {
@@ -211,7 +213,7 @@
{/if}
</div>
<div class="row-head-r">
{#if d.action === 'promote' && !d.rolled_back_by}
{#if d.action === 'promote' && !d.rolled_back_by && d.servers.some(server => !server.server_deleted_at)}
<Button
variant="danger"
size="sm"
@@ -233,7 +235,7 @@
title="{s.server}: {s.restart_state}{s.error ? ' — ' + s.error : ''}"
>
<span class="dot"></span>
<a class="node-label" href="/servers/{encodeURIComponent(s.server)}?tab=diagnostics">{serverTitle(s.server)}{s.players !== null && s.players > 0 ? ` (${s.players} players at last check)` : ''}</a><StatusBadge state={s.restart_state} />
<span class="node-label"><HistoricalServerIdentity name={s.server} deletedAt={s.server_deleted_at} href={`/servers/${encodeURIComponent(s.server)}?tab=diagnostics`} />{s.players !== null && s.players > 0 ? ` (${s.players} players at last check)` : ''}</span><StatusBadge state={s.restart_state} />
</span>
{/each}
</div>
+9 -8
View File
@@ -3,7 +3,7 @@
import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js';
import Dialog from '$ui/components/primitives/Dialog.svelte';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import { page } from '$app/stores';
import DiffViewer from '$ui/components/DiffViewer.svelte';
@@ -22,7 +22,7 @@
size_before: number | null; size_after: number | null;
}
interface ServerRow {
deployment_id: string; server: string; files_changed: number;
deployment_id: string; server: string; server_deleted_at?: string | null; files_changed: number;
restart_state: string; players: number | null;
checked_at: string | null; restarted_at: string | null; provisioning_id: string | null;
attempts: number; error: string | null;
@@ -48,7 +48,8 @@
let openFile = $state<{ id: number; before: string | null; after: string | null; verb: string; relPath: string } | null>(null);
let retryFile = $state<FileRow | null>(null);
const completed = $derived(detail?.servers.filter(server => server.restart_state === 'done').length ?? 0);
const unresolved = $derived(detail?.servers.filter(server => server.restart_state !== 'done').length ?? 0);
const unresolved = $derived(detail?.servers.filter(server => !server.server_deleted_at && server.restart_state !== 'done').length ?? 0);
const deletedIncomplete = $derived(detail?.servers.filter(server => server.server_deleted_at && server.restart_state !== 'done').length ?? 0);
const rankedServers = $derived([...(detail?.servers ?? [])].sort((a, b) => {
const rank: Record<string, number> = { failed: 0, manual: 1, held: 2, pending: 3, done: 4 };
return (rank[a.restart_state] ?? 3) - (rank[b.restart_state] ?? 3);
@@ -122,7 +123,7 @@
{/snippet}
</SectionHeader>
<section class="outcome" aria-label="Campaign outcome"><h2>{completed} / {detail.servers.length} servers activated{unresolved ? ` · ${unresolved} unresolved` : ''}</h2><p>{detail.watchdogIncidentId ? 'Maintenance repairs drift by deleting the existing instance files and rebuilding with the latest promoted setup after verifying a safe server state.' : 'Files are staged before activation. Servers restart when empty, then their health is verified.'}</p>{#if unresolved}<p>Review failed or waiting servers below. Diagnose connectivity or inspect startup logs from the affected row.</p>{:else}<p>Every recorded server completed activation.</p>{/if}</section>
<section class="outcome" aria-label="Campaign outcome"><h2>{completed} / {detail.servers.length} servers activated{unresolved ? ` · ${unresolved} unresolved` : ''}{deletedIncomplete ? ` · ${deletedIncomplete} deleted before activation` : ''}</h2><p>{detail.watchdogIncidentId ? 'Maintenance repairs drift by deleting the existing instance files and rebuilding with the latest promoted setup after verifying a safe server state.' : 'Files are staged before activation. Servers restart when empty, then their health is verified.'}</p>{#if unresolved || deletedIncomplete}<p>Review the recorded results below.{#if deletedIncomplete} Deleted servers retain their last recorded progress.{/if}{#if unresolved} Diagnose connectivity or inspect startup logs from the affected row.{/if}</p>{:else}<p>Every recorded server completed activation.</p>{/if}</section>
{#if detail.watchdogIncidentId}<p class="watchdog-source">Started by Maintenance · <a href={`/maintenance/${encodeURIComponent(detail.watchdogIncidentId)}`}>View incident and recovery history</a></p>{/if}
@@ -144,7 +145,7 @@
{#each rankedServers as s (s.server)}
<div class="srv-row">
<div class="srv-l">
<a class="srv-name" href="/servers/{s.server}"><ServerIdentity name={s.server} /></a>
<span class="srv-name"><HistoricalServerIdentity name={s.server} deletedAt={s.server_deleted_at} href={`/servers/${encodeURIComponent(s.server)}`} /></span>
{#if s.error}
<span class="err">{s.error}</span>
{/if}
@@ -159,7 +160,7 @@
{#if s.restarted_at}
<span class="when">restarted {fmt(s.restarted_at)}</span>
{/if}
{#if s.restart_state !== 'done'}<a href="/servers/{encodeURIComponent(s.server)}?tab=diagnostics">Diagnose</a><a href="/servers/{encodeURIComponent(s.server)}?tab=logs">Open logs</a>{/if}
{#if s.restart_state !== 'done' && !s.server_deleted_at}<a href="/servers/{encodeURIComponent(s.server)}?tab=diagnostics">Diagnose</a><a href="/servers/{encodeURIComponent(s.server)}?tab=logs">Open logs</a>{/if}
</div>
</div>
{/each}
@@ -176,7 +177,7 @@
{:else}
{#each [...byServer.entries()] as [srv, files] (srv)}
<div class="file-group">
<h3><ServerIdentity name={srv} /></h3>
<h3><HistoricalServerIdentity name={srv} deletedAt={detail.servers.find(server => server.server === srv)?.server_deleted_at} /></h3>
<div class="file-rows">
{#each files as f (f.id)}
<button class="file-row" onclick={() => loadDiff(f)}>
@@ -195,7 +196,7 @@
{/if}
</section>
<OperationHistory steps={campaignFlow(detail.servers, Boolean(detail.deployment.finished_at))} events={detail.events} active={detail.servers.some(s => s.restart_state === 'pending' || s.restart_state === 'failed')} />
<OperationHistory steps={campaignFlow(detail.servers, Boolean(detail.deployment.finished_at))} events={detail.events} active={detail.servers.some(s => !s.server_deleted_at && (s.restart_state === 'pending' || s.restart_state === 'failed'))} />
<Dialog bind:open={diffOpen} title={openFile?.relPath ?? 'File comparison'} wide>
{#if diffError}<p class="ui-error" role="alert">{diffError}</p><Button onclick={() => { if (retryFile) void loadDiff(retryFile); }}>Retry comparison</Button>
{:else if diffLoading}<EmptyState title="Loading comparison…" />
@@ -5,6 +5,7 @@
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import CopyValue from '$ui/components/CopyValue.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
let { data }: { data: { profile: PlayerProfile } } = $props();
const profile = $derived(data.profile);
@@ -76,7 +77,7 @@
<table>
<thead><tr><th scope="col">Map / server</th><th scope="col">Match started</th><th scope="col">Match status</th>{#each historyColumns as key}<th scope="col">{leaderboardMetrics.find(metric => metric.key === key)!.label}</th>{/each}</tr></thead>
<tbody>{#each profile.matches as match (match.id)}
<tr><th scope="row">{match.mapName}<small>{match.server}</small></th><td>{date(match.startedAt)}</td>
<tr><th scope="row">{match.mapName}<small>{match.server} {#if match.server_deleted_at}<Badge>Deleted</Badge>{/if}</small></th><td>{date(match.startedAt)}</td>
<td class="status">{match.status}<small>{match.completed ? 'Completion credited' : 'No completion credit'}</small></td>
{#each historyColumns as key}<td class:best={key === 'highestRound'}>{number.format(match[key])}</td>{/each}
</tr>
+10 -9
View File
@@ -9,6 +9,7 @@
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
import { serverTitle } from '$ui/server-labels.js';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Tabs from '$ui/components/primitives/Tabs.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
@@ -40,16 +41,16 @@
const versions = $derived(data?.runtime.servers.filter(v => !server || `${v.server} ${serverTitle(v.server)}`.toLowerCase().includes(server.toLowerCase())) ?? []);
const outdated = $derived(versions.filter(v => v.update_status === 'outdated'));
const unknown = $derived(versions.filter(v => v.update_status === 'unknown'));
const activeJobs = $derived(data?.runtime.jobs.filter(j => ['queued', 'restarting'].includes(j.state)) ?? []);
const activeJobs = $derived(data?.runtime.jobs.filter(j => !j.server_deleted_at && ['queued', 'restarting'].includes(j.state)) ?? []);
const activities = $derived([
...(data?.recovery.incidents ?? []).map(i => ({
key: i.id, kind: i.kind, server: i.server, at: i.first_seen_at,
key: i.id, kind: i.kind, server: i.server, server_deleted_at: i.server_deleted_at, at: i.first_seen_at,
title: i.kind === 'health' ? 'Health recovery' : 'Configuration drift',
state: ['open', 'recovering'].includes(i.state) ? 'active' : i.state === 'attention' ? 'attention' : 'done',
label: watchdogStateLabels[i.state], detail: i.state === 'recovering' ? i.operation_stage ?? i.summary : i.summary, href: `/maintenance/${encodeURIComponent(i.id)}`, job: null
})),
...(data?.runtime.jobs ?? []).map(j => ({
key: `job-${j.id}`, kind: j.kind === 'schedule' ? 'schedule' : 'updates', server: j.server, at: j.requested_at,
key: `job-${j.id}`, kind: j.kind === 'schedule' ? 'schedule' : 'updates', server: j.server, server_deleted_at: j.server_deleted_at, at: j.requested_at,
title: j.kind === 'schedule' ? 'Scheduled restart' : j.kind === 'update' ? 'Plutonium update' : 'Legacy maintenance',
state: ['queued', 'restarting'].includes(j.state) ? 'active' : j.state === 'failed' ? 'attention' : 'done',
label: j.state === 'failed' ? 'Needs attention' : j.state === 'done' ? 'Completed' : j.state === 'cancelled' ? 'Cancelled' : j.state === 'restarting' ? 'Recovering' : 'Queued',
@@ -58,12 +59,12 @@
}))
].sort((a, b) => b.at.localeCompare(a.at)));
const isLatest = (item: (typeof activities)[number]) => !item.job || !data?.runtime.jobs.some(j => j.server === item.server && j.kind === item.job!.kind && j.id > item.job!.id);
const attention = $derived(activities.filter(a => a.state === 'attention' && isLatest(a)).length);
const waiting = $derived(activities.filter(a => a.state === 'active').length);
const attention = $derived(activities.filter(a => !a.server_deleted_at && a.state === 'attention' && isLatest(a)).length);
const waiting = $derived(activities.filter(a => !a.server_deleted_at && a.state === 'active').length);
const matching = $derived(activities.filter(a =>
(kind === 'all' || a.kind === kind) &&
(!server || `${a.server} ${serverTitle(a.server)}`.toLowerCase().includes(server.toLowerCase())) &&
(view === 'overview' ? a.state !== 'done' && isLatest(a) : status === 'all' || a.state === status)
(view === 'overview' ? !a.server_deleted_at && a.state !== 'done' && isLatest(a) : status === 'all' || a.state === status)
));
$effect(() => {
const params = $page.url.searchParams;
@@ -186,12 +187,12 @@
<h2>{view === 'overview' ? 'Active work & attention' : 'Maintenance activity'}</h2>
<div class="activity">{#each matching.slice(0, limit) as item (item.key)}
<article class="card" id={item.key}>
<div class="row-heading"><h3>{item.title} · <ServerIdentity name={item.server} /></h3><Badge variant={item.state === 'attention' ? 'danger' : item.state === 'active' ? 'info' : 'neutral'}>{item.label}</Badge></div>
<div class="row-heading"><h3>{item.title} · <HistoricalServerIdentity name={item.server} deletedAt={item.server_deleted_at} /></h3><Badge variant={item.state === 'attention' ? 'danger' : item.state === 'active' ? 'info' : 'neutral'}>{item.label}</Badge></div>
{#if item.detail}<p>{item.detail}</p>{/if}
<p class="muted">{$timeFormat.dateTime(item.at)}{item.job?.target_revision ? ` · Expected r${item.job.target_revision} or newer` : ''}</p>
<div class="links">{#if item.href}<a href={item.href}>{item.job ? 'View restart operation' : 'View evidence and recovery'}</a>{/if}
{#if item.job?.state === 'queued'}<Button size="sm" disabled={busy} onclick={() => act({ action: 'cancel', id: item.job!.id })}>Cancel restart</Button>{/if}
{#if item.job?.state === 'failed' && item.job.operation_state === 'done'}<Button size="sm" disabled={busy || data.policies.paused} onclick={() => act({ action: 'retry', id: item.job!.id })}>Retry after inspection</Button>{/if}
{#if item.job?.state === 'queued' && !item.server_deleted_at}<Button size="sm" disabled={busy} onclick={() => act({ action: 'cancel', id: item.job!.id })}>Cancel restart</Button>{/if}
{#if item.job?.state === 'failed' && !item.server_deleted_at && item.job.operation_state === 'done'}<Button size="sm" disabled={busy || data.policies.paused} onclick={() => act({ action: 'retry', id: item.job!.id })}>Retry after inspection</Button>{/if}
</div>
</article>
{:else}<EmptyState title={view === 'overview' ? 'No active maintenance in this view' : 'No matching activity'} description="Findings, scheduled restarts, and update verification appear here." />{/each}</div>
+6 -5
View File
@@ -33,10 +33,11 @@
const actions = $derived(detail?.actions.slice().reverse() ?? []);
const events = $derived(detail?.events.slice().reverse() ?? []);
const activeOperation = $derived(actions.some(action => ['queued', 'waiting', 'running', 'awaiting-health'].includes(action.operation_state ?? '')));
const canRetry = $derived(incident?.state === 'attention' || incident?.state === 'dismissed');
const canDismiss = $derived(incident?.state === 'open' || incident?.state === 'attention');
const canRetry = $derived(!incident?.server_deleted_at && (incident?.state === 'attention' || incident?.state === 'dismissed'));
const canDismiss = $derived(!incident?.server_deleted_at && (incident?.state === 'open' || incident?.state === 'attention'));
const explanation = $derived.by(() => {
if (!incident) return '';
if (incident.server_deleted_at) return 'This server was deleted. Its recorded finding, evidence, and recovery results are preserved below.';
if (incident.state === 'resolved') return incident.kind === 'drift' ? 'The observed file drift has cleared.' : 'The server passed the checks for this incident.';
if (incident.state === 'dismissed') return 'This incident was dismissed. Retry to allow automatic recovery again.';
if (incident.state === 'attention') return 'Automatic recovery needs attention. Review the failed operation and server diagnostics before retrying.';
@@ -107,11 +108,11 @@
{#if incident && detail}
<section class="summary" aria-label="Incident status">
<div class="row-heading"><WatchdogStatus state={incident.state} /><a href={`/servers/${encodeURIComponent(incident.server)}`}>View server</a></div>
<div class="row-heading"><WatchdogStatus state={incident.state} />{#if incident.server_deleted_at}<Badge>Deleted</Badge>{:else}<a href={`/servers/${encodeURIComponent(incident.server)}`}>View server</a>{/if}</div>
<h2>{incident.summary}</h2>
<p>{explanation}</p>
{#if activeOperation && (canRetry || canDismiss)}<p>Complete or cancel the active provisioning operation before retrying or dismissing this incident.</p>{/if}
{#if incident.kind === 'drift'}<p><strong>Drift repair deletes the existing instance and all of its files, then rebuilds it with the latest promoted setup.</strong> The repair is recorded as a campaign.</p>{:else if incident.state !== 'resolved'}<p>Automatic health recovery first tries a restart, then rebuilds if needed. A rebuild deletes the existing instance and all of its files and installs the latest promoted setup.</p>{/if}
{#if !incident.server_deleted_at}{#if incident.kind === 'drift'}<p><strong>Drift repair deletes the existing instance and all of its files, then rebuilds it with the latest promoted setup.</strong> The repair is recorded as a campaign.</p>{:else if incident.state !== 'resolved'}<p>Automatic health recovery first tries a restart, then rebuilds if needed. A rebuild deletes the existing instance and all of its files and installs the latest promoted setup.</p>{/if}{/if}
<dl>
<div><dt>First detected</dt><dd>{$timeFormat.dateTime(incident.first_seen_at)}</dd></div>
<div><dt>Last observed</dt><dd>{$timeFormat.dateTime(incident.last_seen_at)}</dd></div>
@@ -119,7 +120,7 @@
{#if evidence.containerState}<div><dt>Observed Komodo state</dt><dd>{evidence.containerState}</dd></div>{/if}
</dl>
{#if evidence.reason && evidence.reason !== incident.summary}<p class="observed-reason">Observed reason: {evidence.reason}</p>{/if}
<div class="links"><a href={`/servers/${encodeURIComponent(incident.server)}?tab=diagnostics`}>Diagnose connection</a><a href={`/servers/${encodeURIComponent(incident.server)}?tab=logs`}>Open server logs</a></div>
{#if !incident.server_deleted_at}<div class="links"><a href={`/servers/${encodeURIComponent(incident.server)}?tab=diagnostics`}>Diagnose connection</a><a href={`/servers/${encodeURIComponent(incident.server)}?tab=logs`}>Open server logs</a></div>{/if}
</section>
<section aria-labelledby="recovery-title">
+2 -2
View File
@@ -1,7 +1,7 @@
import { readMetrics } from '$lib/server/fleet/metrics.js';
import { parseMetricsQuery } from '$lib/metrics.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ url }) => {
const requested = Number(url.searchParams.get('days') ?? 7);
return readMetrics([1, 7, 30].includes(requested) ? requested : 7);
return readMetrics(parseMetricsQuery(url.searchParams));
};
+188 -85
View File
@@ -1,109 +1,212 @@
<script lang="ts">
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte';
import { invalidateAll } from '$app/navigation';
import { onMount, untrack } from 'svelte';
import { page } from '$app/state';
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
import { timeZone } from '$ui/stores/time-zone.js';
import { createTimeFormatter } from '$lib/time-zone.js';
import { pollVisible } from '$ui/poll.js';
import { metricDuration, metricNumber, metricPercent } from '$ui/metrics.js';
import { createMetricsRefresh } from '$ui/metrics-refresh.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Card from '$ui/components/primitives/Card.svelte';
import Stat from '$ui/components/primitives/Stat.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import MetricsChart from '$ui/components/metrics/MetricsChart.svelte';
import MetricsExplore from '$ui/components/metrics/MetricsExplore.svelte';
import MetricsTables from '$ui/components/metrics/MetricsTables.svelte';
import type { MetricsData } from '$lib/metrics.js';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const percent = (value: number, total: number) => total > 0 ? `${(100 * value / total).toFixed(1)}%` : '—';
const hours = (value: number) => `${value.toFixed(1)} h`;
const peak = $derived(Math.max(1, ...data.points.map(p => p.active + p.idle + p.unknown)));
const label = (at: number) => $timeFormat.period(at);
onMount(() => {
const timer = setInterval(() => { if (!document.hidden) void invalidateAll(); }, 60_000);
return () => clearInterval(timer);
let snapshot = $state<MetricsData | null>(null);
let refreshing = $state(false);
let refreshError = $state<string | null>(null);
let scopeError = $state('');
let scopeNavigating = $state(false);
let mounted = $state(false);
let attemptedPreference = $state<string | null>(null);
const report = $derived(snapshot ?? data);
const format = $derived(createTimeFormatter(report.scope.timeZone));
const captured = $derived(report.summary.observed + report.summary.unknown);
const contextSearch = $derived((page.state as { metricsTableSearch?: string }).metricsTableSearch ?? page.url.search);
const gameOptions = $derived([{ value: '', label: 'All games' }, ...[...new Set([...report.scopeOptions.games, ...(report.scope.game ? [report.scope.game] : [])])].map(game => ({ value: game, label: gameName(game) }))]);
const mapOptions = $derived([
{ value: '', label: 'All maps' },
...report.scopeOptions.maps.filter(map => !report.scope.game || map.game === report.scope.game).map(map => ({ value: JSON.stringify([map.game, map.map]), label: `${map.map}${report.scope.game ? '' : ` · ${gameName(map.game)}`}` })),
...(report.scope.map && !report.scopeOptions.maps.some(map => map.game === report.scope.game && map.map === report.scope.map) ? [{ value: JSON.stringify([report.scope.game, report.scope.map]), label: report.scope.map }] : [])
]);
const mapValue = $derived(report.scope.map ? JSON.stringify([report.scope.game, report.scope.map]) : '');
const scopeLabel = $derived(report.scope.map ? `${report.scope.map} · ${gameName(report.scope.game!)}` : report.scope.game ? gameName(report.scope.game) : 'All games and maps');
const refresh = createMetricsRefresh({
getQuery: () => {
const params = new URLSearchParams({ days: String(data.days), timeZone: data.scope.timeZone });
if (data.scope.game) params.set('game', data.scope.game);
if (data.scope.map) params.set('map', data.scope.map);
return params.toString();
},
onData: value => { snapshot = value; },
onError: value => { refreshError = value; },
onLoading: value => { refreshing = value; }
});
beforeNavigate(() => { refresh.cancel(); scopeNavigating = true; });
afterNavigate(() => { scopeNavigating = false; attemptedPreference = null; });
$effect(() => {
// Navigation owns the new report; an older background request cannot replace it.
void data;
untrack(() => { refresh.cancel(); snapshot = null; refreshError = null; });
});
$effect(() => {
// Layout initializes the saved preference on mount; react when it arrives.
const preferredZone = $timeZone;
if (mounted && !scopeNavigating && !new URLSearchParams(contextSearch).has('timeZone') && preferredZone !== data.scope.timeZone && preferredZone !== attemptedPreference) {
untrack(() => { attemptedPreference = preferredZone; void changeScope({ timeZone: preferredZone }, true); });
}
});
onMount(() => {
mounted = true;
const stop = pollVisible(() => scopeNavigating ? undefined : refresh.refresh(), 60_000, false);
return () => { stop(); refresh.cancel(); };
});
function gameName(game: string) { return game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase(); }
async function changeScope(changes: Record<string, string>, replace = false) {
const params = new URLSearchParams(contextSearch);
if (!params.has('timeZone')) params.set('timeZone', report.scope.timeZone);
for (const [key, value] of Object.entries(changes)) { if (value) params.set(key, value); else params.delete(key); }
refresh.cancel();
scopeError = '';
scopeNavigating = true;
try { await goto(`/metrics?${params}`, { keepFocus: true, noScroll: true, replaceState: replace }); }
catch { scopeError = 'Could not change the report scope. The previous report is still shown.'; }
finally { scopeNavigating = false; }
}
function changeMap(value: string) {
if (!value) return changeScope({ map: '' });
const [game, map] = JSON.parse(value) as [string, string];
return changeScope({ game, map });
}
function rangeHref(days: number): string {
const params = new URLSearchParams(contextSearch);
params.set('days', String(days));
params.set('timeZone', report.scope.timeZone);
return `/metrics?${params}`;
}
</script>
<svelte:head><title>Metrics · Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Metrics" subtitle="Plan hosting around server utilization, map play time, and capacity pressure." />
<SectionHeader title="Metrics" subtitle="Understand server use, map play time, and measured capacity pressure.">
{#snippet actions()}<Button size="sm" loading={refreshing} disabled={scopeNavigating} onclick={() => refresh.refresh()}>Refresh</Button>{/snippet}
</SectionHeader>
<div class="toolbar">
<nav aria-label="Metrics time range">
{#each [1, 7, 30] as days}
<a href={`/metrics?days=${days}`} aria-current={data.days === days ? 'page' : undefined}>{days === 1 ? '24 hours' : `${days} days`}</a>
<a href={rangeHref(days)} aria-current={report.days === days ? 'page' : undefined} data-sveltekit-keepfocus data-sveltekit-noscroll>{days === 1 ? '24 hours' : `${days} days`}</a>
{/each}
</nav>
<span>Updated {$timeFormat.time(data.now)} · refreshes every minute</span>
<span class="range">{format.period(report.since)} – {format.period(report.now)}</span>
</div>
{#if data.servers.length === 0}
<section><h2>Collecting your first observations</h2><p>Metrics start with the next server status checks. Leave the manager running to build a history of busy hours and map demand.</p></section>
{:else}
<div class="stats">
<section><span>Servers utilized</span><strong>{percent(data.summary.active, data.summary.observed)}</strong><small>{hours(data.summary.active)} with players</small></section>
<section><span>Servers idle</span><strong>{percent(data.summary.idle, data.summary.observed)}</strong><small>{hours(data.summary.idle)} empty</small></section>
<section><span>Player time</span><strong>{hours(data.summary.playerHours)}</strong><small>Players × observed time</small></section>
<section><span>Unknown status</span><strong>{hours(data.summary.unknown)}</strong><small>Excluded from utilization percentages</small></section>
<div class="scope-controls" aria-label="Filter the entire metrics report">
<Field label="Game"><Select value={report.scope.game ?? ''} options={gameOptions} disabled={scopeNavigating} onchange={event => changeScope({ game: (event.currentTarget as HTMLSelectElement).value, map: '' })} /></Field>
<Field label="Map"><Select value={mapValue} options={mapOptions} disabled={scopeNavigating} onchange={event => changeMap((event.currentTarget as HTMLSelectElement).value)} /></Field>
<p class="scope-note" role="status">{scopeNavigating ? 'Updating report…' : `${scopeLabel} · applies to every metric, chart, and table`}</p>
</div>
{#if scopeError}<p class="notice" role="alert">{scopeError}</p>{/if}
{#if refreshError}
<div class="refresh-error" role="alert">
<div><strong>Could not refresh metrics.</strong> {refreshError}<small>Showing the report from {format.dateTime(report.now)}.</small></div>
<Button size="sm" loading={refreshing} onclick={() => refresh.refresh()}>Retry</Button>
</div>
<section>
<h2>Utilization over time</h2>
<p>Server-hours per {data.days === 1 ? 'hour' : '24-hour period'}. Period starts are shown in {$timeZone.replaceAll('_', ' ')}. Taller bars mean more time under observation.</p>
<div class="legend"><span><i class="active"></i>Utilized</span><span><i class="idle"></i>Idle</span><span><i class="unknown"></i>Unknown</span></div>
<div class="chart" role="img" aria-label="Stacked server-hours chart. Exact values are in the time data table below.">
{#each data.points as point}
<div class="bar" title={`${label(point.at)}: ${hours(point.active)} utilized, ${hours(point.idle)} idle, ${hours(point.unknown)} unknown`}>
<div class="unknown" style:height={`${point.unknown / peak * 100}%`}></div>
<div class="idle" style:height={`${point.idle / peak * 100}%`}></div>
<div class="active" style:height={`${point.active / peak * 100}%`}></div>
</div>
{/each}
</div>
<div class="axis"><span>{label(data.points[0].at)}</span><span>0–{hours(peak)} per bar</span><span>{label(data.points[data.points.length - 1].at)}</span></div>
<details><summary>View time data</summary><div class="table-wrap"><table><thead><tr><th>Period start ({$timeZone.replaceAll('_', ' ')})</th><th>Utilized</th><th>Idle</th><th>Unknown</th><th>Player time</th></tr></thead><tbody>{#each data.points as point}<tr><td>{label(point.at)}</td><td>{hours(point.active)}</td><td>{hours(point.idle)}</td><td>{hours(point.unknown)}</td><td>{hours(point.playerHours)}</td></tr>{/each}</tbody></table></div></details>
</section>
<section>
<h2>Map play time & capacity pressure</h2>
<p>Ranked by player-hours. “All lobbies full” measures time when every tracked lobby for a map has known capacity and is full. It indicates capacity pressure; failed joins and unmet demand are not tracked.</p>
<div class="table-wrap"><table>
<thead><tr><th>Map</th><th>Player time</th><th>Utilized server time</th><th>All lobbies full</th><th>Capacity coverage</th></tr></thead>
<tbody>{#each data.maps as map}<tr><td>{map.map}<small>{map.game.toUpperCase()}</small></td><td>{hours(map.playerHours)}</td><td>{hours(map.active)}</td><td>{map.capacityObserved > 0 ? `${hours(map.full)} (${percent(map.full, map.capacityObserved)})` : 'Unknown'}</td><td>{hours(map.capacityObserved)}</td></tr>{/each}</tbody>
</table></div>
<p class="note">Capacity coverage is map time with a known player limit and status for every tracked lobby. Servers that do not report a player limit cannot establish whether all lobbies are full.</p>
</section>
<section>
<h2>Server utilization</h2>
<div class="table-wrap"><table><thead><tr><th>Server</th><th>Utilized</th><th>Idle</th><th>Observed time</th><th>Unknown</th></tr></thead>
<tbody>{#each data.servers as server}<tr><td>{server.server}</td><td>{percent(server.active, server.observed)}</td><td>{percent(server.idle, server.observed)}</td><td>{hours(server.observed)}</td><td>{hours(server.unknown)}</td></tr>{/each}</tbody>
</table></div>
</section>
{/if}
<p class="note">Estimates use periodic server status observations. Each observation covers at most 45 seconds; longer collection gaps are excluded. History begins when collection is enabled and is retained for 31 days. Player time includes all players reported by the server.</p>
{#if captured > 0}
<div class="stats">
<Card>
<Stat label="Time with players" value={metricPercent(report.summary.active, report.summary.observed)} size="lg" tween={false} />
<p class="stat-context">{metricDuration(report.summary.active)} of {metricDuration(report.summary.observed)} known server time</p>
{#if report.summary.observed === 0}<p class="stat-note">Occupancy is unknown for all captured time.</p>{/if}
</Card>
<Card>
<Stat label="Player-hours" value={report.summary.observed > 0 ? metricNumber(report.summary.playerHours) : '—'} size="lg" tween={false} />
<p class="stat-context">Players × known time · one player for one hour = 1</p>
</Card>
</div>
{/if}
<div class="context" aria-label="About this report">
{#if captured > 0}<span><strong>{metricDuration(report.summary.observed)}</strong> known server time</span><span><strong>{metricDuration(report.summary.unknown)}</strong> unknown status</span>{/if}
<span>Scope last collected <strong>{report.scopeLatestSampleAt === null ? 'No retained samples' : format.dateTime(report.scopeLatestSampleAt)}</strong></span>
<span>Fleet last collected <strong>{report.latestSampleAt === null ? 'No retained samples' : format.dateTime(report.latestSampleAt)}</strong></span>
<span>Report refreshed {format.time(report.now)} · refreshes every minute while visible</span>
{#if report.scopeFirstSampleAt !== null}<span>Scope retained samples from {format.dateTime(report.scopeFirstSampleAt)}</span>{/if}
</div>
{#if captured === 0}
<Card>
<EmptyState title={report.latestSampleAt === null ? 'No retained observations yet' : report.servers.length > 0 ? 'Waiting for observed time' : 'No captured time in this scope'}
description={report.latestSampleAt === null
? report.registeredServerCount > 0 ? 'Leave the manager running to collect server status and build a history.' : 'Register a server to begin collecting utilization history.'
: report.servers.length > 0 ? 'Captured samples have no elapsed time in this window. Time builds between status checks.'
: 'There are no elapsed observations for the selected game, map, and time window. Try another scope or a wider range.'}>
{#snippet actions()}
{#if report.latestSampleAt !== null && report.days < 30}<Button href={rangeHref(30)} size="sm">View 30 days</Button>{/if}
{#if report.scope.game || report.scope.map}<Button size="sm" onclick={() => changeScope({ game: '', map: '' })}>View all games</Button>{/if}
{#if report.registeredServerCount === 0}<Button href="/" size="sm">View fleet</Button>{/if}
{/snippet}
</EmptyState>
</Card>
{:else}
{#if report.registeredServerCount === 0}
<p class="notice">No servers are currently registered. This report shows retained history; new samples resume when servers are registered.</p>
{:else if report.latestSampleAt !== null && report.now - report.latestSampleAt > 5 * 60_000}
<p class="notice">No new collection samples in the last five minutes. This report still includes the retained observations shown below.</p>
{/if}
{#if report.summary.observed === 0}<p class="notice">All captured server status is unknown. A dash means occupancy could not be calculated; it does not mean servers were empty.</p>{/if}
<MetricsChart points={report.points} days={report.days} timeZone={report.scope.timeZone} />
{/if}
<MetricsExplore {report} changingScope={scopeNavigating} onTimeZoneChange={value => changeScope({ timeZone: value })} />
{#if captured > 0}<MetricsTables maps={report.maps} servers={report.servers} />{/if}
<details class="methodology">
<summary>How these estimates work</summary>
<p>Time with players is occupied server time divided by known server time. Known time includes occupied and idle observations; unknown status and collection gaps are excluded from that percentage.</p>
<p>Each periodic observation covers at most 45 seconds. Longer collection gaps are excluded. History is retained for 31 days. Player-hours include all players reported by the server and are estimates, not individual session records.</p>
<p>Timeline buckets are aligned to UTC, with labels shown in {report.scope.timeZone.replaceAll('_', ' ')}. Daily buckets are 24-hour periods, not local calendar days. The first and latest buckets can be partial. Percentage bars divide occupied, idle, and unknown time by captured time; the headline occupancy percentage uses known time only.</p>
<p>Historical server rows are grouped by name and may include multiple registrations using that name. Map groups preserve the recorded game and map label.</p>
</details>
</div>
<style>
.page { display: grid; gap: var(--space-6); min-width: 0; }
section { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--space-5); min-width: 0; }
h2 { font-size: var(--text-lg); margin: 0 0 12px; }
p { color: var(--text-secondary); margin: 8px 0 16px; }
.toolbar, .axis, .legend { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.toolbar { align-items: center; }
.toolbar span, .axis, .note, small { color: var(--text-tertiary); font-size: var(--text-sm); }
nav { display: flex; gap: 8px; }
nav a { padding: 8px 12px; border-radius: var(--radius-md); text-decoration: none; }
.page { display: grid; gap: var(--space-5); min-width: 0; }
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
.scope-controls { display: grid; grid-template-columns: minmax(140px, 220px) minmax(180px, 300px) minmax(140px, 1fr); gap: var(--space-3); align-items: end; }
.scope-note { color: var(--text-secondary); font-size: var(--text-xs); margin: 0 0 8px; line-height: 1.5; }
nav { display: flex; gap: var(--space-1); padding: 4px; border: 1px solid var(--border-subtle); background: var(--surface); border-radius: var(--radius-md); }
nav a { padding: 9px 12px; color: var(--text-secondary); border-radius: var(--radius-sm); text-decoration: none; white-space: nowrap; font-size: var(--text-sm); }
nav a[aria-current] { background: var(--surface-raised); color: var(--text-primary); }
.stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
.stats section { display: grid; gap: 8px; }
strong { font-size: 30px; font-variant-numeric: tabular-nums; }
small { display: block; }
.legend { justify-content: flex-start; font-size: var(--text-sm); }
.legend span { display: flex; align-items: center; gap: 6px; }
i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; }
.active { background: var(--accent-primary); }
.idle { background: #7b9baf; }
.unknown { background: repeating-linear-gradient(135deg, #605b64 0 3px, #a7a0ad 3px 5px); }
.chart { display: flex; height: 200px; gap: 4px; border-bottom: 1px solid var(--border-strong); margin: 20px 0 8px; }
.bar { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: flex-end; }
details { margin-top: 20px; } summary { cursor: pointer; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; text-align: left; font-size: var(--text-sm); }
th, td { padding: 12px; border-bottom: 1px solid var(--border-subtle); font-variant-numeric: tabular-nums; }
th { color: var(--text-secondary); white-space: nowrap; }
@media (max-width: 800px) { .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 450px) { .stats { grid-template-columns: 1fr; } }
nav a:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
.range, .context, .methodology, .notice { color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.6; }
.range { max-width: 50ch; }
.stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-4); }
.stat-context { margin: 10px 0 0; font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.5; }
.stat-note { margin: 6px 0 0; color: var(--text-tertiary); font-size: var(--text-xs); }
.context { display: flex; flex-wrap: wrap; column-gap: var(--space-5); row-gap: var(--space-1); padding: 0 2px; }
.context strong { color: var(--text-secondary); font-weight: var(--weight-medium); }
.refresh-error { display: flex; justify-content: space-between; align-items: center; gap: var(--space-4); border: 1px solid var(--state-warning); padding: var(--space-4); border-radius: var(--radius-md); font-size: var(--text-sm); }
.refresh-error small { display: block; margin-top: var(--space-1); color: var(--text-secondary); }
.notice { margin: 0; border-left: 2px solid var(--state-warning); padding: var(--space-2) var(--space-3); color: var(--text-secondary); }
.methodology { padding: var(--space-3) 2px; }
.methodology summary { cursor: pointer; color: var(--text-secondary); }
.methodology p { max-width: 110ch; margin: var(--space-3) 0 0; }
@media (max-width: 540px) {
.scope-controls { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.scope-note { grid-column: 1 / -1; margin: 0; }
.page { gap: var(--space-4); }
.stats { gap: var(--space-2); }
.stats :global(.card) { padding: var(--space-3); }
.stats :global(.value) { font-size: var(--text-xl); }
.stats :global(.label) { font-size: 11px; }
.stat-context { font-size: var(--text-xs); }
.context { font-size: 11px; column-gap: var(--space-3); }
.refresh-error { align-items: flex-start; flex-wrap: wrap; }
}
</style>
+6 -6
View File
@@ -7,7 +7,7 @@
import Tabs from '$ui/components/primitives/Tabs.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte';
import { serverTitle } from '$ui/server-labels.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
@@ -22,7 +22,7 @@
let lifecycleOpen = $state(false);
interface Deployment {
id: string; server: string; action: ProvisioningAction;
id: string; server: string; server_deleted_at?: string | null; action: ProvisioningAction;
state: 'queued' | 'awaiting-health' | 'waiting' | 'running' | 'done' | 'failed' | 'warning' | 'interrupted' | 'cancelled';
stage: string; started_at: string; finished_at: string | null;
triggered_by: string; error: string | null; warnings: string; server_exists: number;
@@ -72,10 +72,10 @@
let action = $state('all');
let search = $state('');
let refreshing = false;
let active = $derived(deployments.filter(d => ['queued', 'awaiting-health', 'running', 'waiting'].includes(d.state)));
let active = $derived(deployments.filter(d => !d.server_deleted_at && ['queued', 'awaiting-health', 'running', 'waiting'].includes(d.state)));
const needsAttention = (deployment: Deployment) => ['failed', 'interrupted', 'warning'].includes(deployment.state);
const attention = $derived(deployments.filter(needsAttention));
let history = $derived(deployments.filter(d => !['queued', 'awaiting-health', 'running', 'waiting'].includes(d.state) &&
let history = $derived(deployments.filter(d => (d.server_deleted_at || !['queued', 'awaiting-health', 'running', 'waiting'].includes(d.state)) &&
(historyState === 'all' || needsAttention(d)) &&
(action === 'all' || d.action === action) && (d.server + ' ' + serverTitle(d.server)).toLowerCase().includes(search.toLowerCase())));
async function load() {
@@ -151,7 +151,7 @@
<article class="deployment-row"><a class="row-link" href="/provisioning/{d.id}">
<div class="row-head">
<div><Badge variant={d.action === 'create' ? 'info' : 'neutral'}>{actionLabels[d.action]}</Badge>
<strong class="server"><ServerIdentity name={d.server} /></strong>
<strong class="server"><HistoricalServerIdentity name={d.server} deletedAt={d.server_deleted_at} /></strong>
</div>
<StatusBadge state={d.state} />
</div>
@@ -159,7 +159,7 @@
<div class="meta"><span>Started {fmt(d.started_at)}</span>{#if d.finished_at}<span>Finished {fmt(d.finished_at)}</span>{/if}<span>By {d.triggered_by}</span></div>
{#if d.error}<p class="error">{d.error}</p>{/if}
{#each warnings(d) as warning}<p class="error">{warning}</p>{/each}
</a>{#if d.canCancelRemoval}<div class="row-actions"><Button size="sm" disabled={cancelling} onclick={() => cancelRequests([d.id])}>Cancel queued removal</Button></div>{/if}</article>
</a>{#if d.canCancelRemoval && !d.server_deleted_at}<div class="row-actions"><Button size="sm" disabled={cancelling} onclick={() => cancelRequests([d.id])}>Cancel queued removal</Button></div>{/if}</article>
{/snippet}
<style>
+9 -8
View File
@@ -18,7 +18,7 @@
import { actionLabels, actionCompleted, type ProvisioningAction } from '$lib/provisioning.js';
interface Deployment {
id: string; expected_map?: string; server: string; action: ProvisioningAction; state: string;
id: string; expected_map?: string; server: string; server_deleted_at?: string | null; action: ProvisioningAction; state: string;
stage: string; started_at: string; finished_at: string | null;
triggered_by: string; error: string | null; warnings: string; server_exists: number;
}
@@ -30,7 +30,7 @@
let open = $state(false);
let resuming = $state(false);
let deployment = $derived(detail?.deployment);
let active = $derived(['queued', 'awaiting-health', 'waiting', 'running'].includes(deployment?.state ?? ''));
let active = $derived(!deployment?.server_deleted_at && ['queued', 'awaiting-health', 'waiting', 'running'].includes(deployment?.state ?? ''));
let warnings = $derived.by(() => {
try { return JSON.parse(deployment?.warnings ?? '[]') as string[]; } catch { return []; }
});
@@ -58,6 +58,7 @@
});
let explanation = $derived.by(() => {
if (!deployment) return '';
if (deployment.server_deleted_at) return 'This server was deleted. Its recorded operation and results are preserved below.';
if (detail?.canResumeRebuild) return 'The old Komodo instance was deleted. Resume file cleanup, then create a fresh instance in the reserved server slot.';
if (['start', 'restart', 'stop'].includes(deployment.action)) {
if (deployment.state === 'done') return deployment.action === 'stop' ? 'Komodo confirmed the container is stopped. Start it again when ready.' : 'The container is healthy and RCON responds.';
@@ -138,16 +139,16 @@
{#if deployment}
<section class="summary">
<div class="top"><StatusBadge state={deployment.state} />
{#if deployment.server_exists}<a href="/servers/{encodeURIComponent(deployment.server)}">View server</a>{/if}
{#if deployment.server_deleted_at}<Badge>Deleted</Badge>{:else if deployment.server_exists}<a href="/servers/{encodeURIComponent(deployment.server)}">View server</a>{/if}
</div>
<h2>{deployment.state === 'done' ? actionCompleted[deployment.action] : deployment.stage}</h2>
<p class="explanation" aria-live="polite">{explanation}</p>
<div class="timing"><span>Total time <strong>{duration(deployment.started_at, deployment.finished_at ? new Date(deployment.finished_at).getTime() : now)}</strong></span>{#if active && stageStarted}<span>This step <strong>{duration(stageStarted, now)}</strong></span>{/if}</div>
{#if detail?.canResumeVerification}<div><Button disabled={resuming} loading={resuming} onclick={resume}>Resume health verification</Button></div>{/if}
{#if detail?.canResumeRebuild}<div><Button disabled={resuming} loading={resuming} onclick={resume}>Resume rebuild cleanup</Button></div>{/if}
{#if detail?.canResumeCleanup}<div><Button disabled={resuming} loading={resuming} onclick={resume}>Resume cleanup</Button></div>{/if}
{#if detail?.canCancelRemoval}<div><Button disabled={resuming} loading={resuming} onclick={cancelRemoval}>Cancel queued removal</Button><p class="muted">Available until removal begins. Cancelling keeps the server and its files.</p></div>{/if}
{#if deployment.server_exists && deployment.state !== 'done' && deployment.state !== 'cancelled'}<div class="recovery-links"><a href="/servers/{encodeURIComponent(deployment.server)}?tab=logs">Open startup logs</a><a href="/servers/{encodeURIComponent(deployment.server)}?tab=diagnostics">Diagnose connection</a>{#if deployment.stage.includes('map')}<a href="/servers/{encodeURIComponent(deployment.server)}?tab=config">Review map configuration</a>{/if}</div>{/if}
{#if detail?.canResumeVerification && !deployment.server_deleted_at}<div><Button disabled={resuming} loading={resuming} onclick={resume}>Resume health verification</Button></div>{/if}
{#if detail?.canResumeRebuild && !deployment.server_deleted_at}<div><Button disabled={resuming} loading={resuming} onclick={resume}>Resume rebuild cleanup</Button></div>{/if}
{#if detail?.canResumeCleanup && !deployment.server_deleted_at}<div><Button disabled={resuming} loading={resuming} onclick={resume}>Resume cleanup</Button></div>{/if}
{#if detail?.canCancelRemoval && !deployment.server_deleted_at}<div><Button disabled={resuming} loading={resuming} onclick={cancelRemoval}>Cancel queued removal</Button><p class="muted">Available until removal begins. Cancelling keeps the server and its files.</p></div>{/if}
{#if deployment.server_exists && !deployment.server_deleted_at && deployment.state !== 'done' && deployment.state !== 'cancelled'}<div class="recovery-links"><a href="/servers/{encodeURIComponent(deployment.server)}?tab=logs">Open startup logs</a><a href="/servers/{encodeURIComponent(deployment.server)}?tab=diagnostics">Diagnose connection</a>{#if deployment.stage.includes('map')}<a href="/servers/{encodeURIComponent(deployment.server)}?tab=config">Review map configuration</a>{/if}</div>{/if}
{#if deployment.error}<p role="alert" class="error">{deployment.error}</p>{/if}
{#each warnings as warning}<p class="warning">{warning}</p>{/each}
{#if deployment.expected_map}<p>Expected map: <strong>{deployment.expected_map}</strong></p>{/if}
@@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte';
import { page } from '$app/stores';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
@@ -13,12 +14,12 @@
let error = $state('');
let requestNumber = 0;
let collection = $derived(detail?.collection);
const launching = (server: { state: string }) => ['running', 'awaiting-health'].includes(server.state);
const launching = (server: { state: string; server_deleted_at?: string | null }) => !server.server_deleted_at && ['running', 'awaiting-health'].includes(server.state);
const needsAttention = (server: { state: string }) => ['failed', 'interrupted', 'warning', 'cancelled'].includes(server.state);
const exact = $derived(collection?.mode === 'exact');
const currentServers = $derived(detail?.servers.filter(launching) ?? []);
const otherLaunches = $derived(detail?.queue.active.filter(active => !detail?.servers.some(server => server.id === active.id)) ?? []);
const firstQueued = $derived(detail?.servers.find(server => server.state === 'queued'));
const firstQueued = $derived(detail?.servers.find(server => !server.server_deleted_at && server.state === 'queued'));
const labels: Record<string, string> = { queued: 'Queued', running: 'Launching', 'awaiting-health': 'Checking readiness', done: 'Ready', warning: 'Needs review', failed: 'Failed', interrupted: 'Interrupted', waiting: 'Waiting for empty server', cancelled: 'Cancelled' };
const waitingForPort = (server: CollectionServer) => server.state === 'queued' && (server.stage.startsWith('Waiting for removal') || server.stage.startsWith('Waiting for port'));
function stateLabel(server: CollectionServer) {
@@ -81,7 +82,7 @@
{#each currentServers as server (server.id)}
<a class="current-launch" href="/provisioning/{server.id}">
<span class="activity" aria-hidden="true"></span>
<div><strong>{server.action === 'destroy' ? 'Removing: ' : ''}<ServerIdentity name={server.server} /></strong><p>{server.state === 'awaiting-health' ? 'Checking the container, connection, and expected map.' : server.stage}</p></div>
<div><strong>{server.action === 'destroy' ? 'Removing: ' : ''}<HistoricalServerIdentity name={server.server} deletedAt={server.server_deleted_at} /></strong><p>{server.state === 'awaiting-health' ? 'Checking the container, connection, and expected map.' : server.stage}</p></div>
<span class="operation-link">Follow progress <span aria-hidden="true">→</span></span>
</a>
{/each}
@@ -100,7 +101,7 @@
{#if exact && detail.retainedServers.length}
<details><summary>{detail.retainedServers.length} retained server{detail.retainedServers.length === 1 ? '' : 's'} (unchanged)</summary>
<ul>{#each detail.retainedServers as name}<li><ServerIdentity {name} /> <span class="muted">{name}</span></li>{/each}</ul>
<ul>{#each detail.retainedServers as name}<li><HistoricalServerIdentity {name} deletedAt={detail.retainedServerDeletedAt?.[name]} /> <span class="muted">{name}</span></li>{/each}</ul>
</details>
{/if}
<section class="queue" aria-labelledby="queue-title">
@@ -111,7 +112,7 @@
<a class="server" href="/provisioning/{server.id}">
<span class="order" class:ready={server.state === 'done'} aria-hidden="true">{server.state === 'done' ? '✓' : String(index + 1).padStart(2, '0')}</span>
<div class="server-content">
<div class="server-heading"><strong><ServerIdentity name={server.server} /></strong><Badge variant={needsAttention(server) ? 'danger' : server.state === 'done' ? 'online' : launching(server) ? 'info' : 'neutral'}>{stateLabel(server)}</Badge></div>
<div class="server-heading"><strong><HistoricalServerIdentity name={server.server} deletedAt={server.server_deleted_at} /></strong><Badge variant={needsAttention(server) ? 'danger' : server.state === 'done' ? 'online' : launching(server) ? 'info' : 'neutral'}>{stateLabel(server)}</Badge></div>
<p class:error={!!server.error}>{server.error ?? (server.state === 'queued' ? queueHint(server) : server.state === 'done' ? server.action === 'destroy' ? server.stage : 'Startup checks passed.' : server.stage)}</p>
<div class="server-meta"><span>{server.action === 'destroy' ? 'Remove server' : `Create server · Port ${server.port}`}</span><span>{needsAttention(server) ? 'Review & recover' : 'View operation'} <span aria-hidden="true">→</span></span></div>
</div>
+1 -1
View File
@@ -33,7 +33,7 @@ describe('beta evidence explanations', () => {
expect(betaWaitingCondition(r, { ...observation, players: 3 })).toContain('next report arrives');
});
it('summarizes telemetry while keeping arbitrary or legacy payloads readable', () => {
const event = { id: 1, server: 'town', received_at: '', kind: 'game_end', payload: JSON.stringify({ round: 25, players: [{ name: 'A' }], map: 'zm_transit' }) };
const event = { id: 1, server: 'town', server_deleted_at: null, received_at: '', kind: 'game_end', payload: JSON.stringify({ round: 25, players: [{ name: 'A' }], map: 'zm_transit' }) };
expect(betaEvidenceSummary(event)).toBe('Game ended at round 25 with 1 players on zm_transit.');
expect(betaEvidenceSummary({ ...event, kind: 'old-event', payload: 'invalid' })).toBe('old event');
});
+44 -4
View File
@@ -103,7 +103,7 @@ describe('queued removal cancellation', () => {
});
describe('destruction cleanup recovery', () => {
it('removes T6 servers with beta test runs and their dependent evidence', async () => {
it('removes T6 servers while preserving beta progress and test evidence', async () => {
const { createServer, destroyServer, processDestructions } = await import('../../src/lib/server/fleet/create.js');
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { db } = await import('../../src/lib/server/db/index.js');
@@ -119,6 +119,7 @@ describe('destruction cleanup recovery', () => {
const { script, version } = createScript({ name: 'test script', kind: 'script', scopeType: 'game', scopeValue: 't6', targetRelPath: 'test.gsc', content: Buffer.from('main(){}') });
const beta = db().prepare("INSERT INTO betas (script_id, script_version_id, trigger_kind, state) VALUES (?, ?, 'manual', 'running')").run(script.id, version.id).lastInsertRowid;
for (const server of [name, serverName]) {
db().prepare("INSERT INTO beta_servers (beta_id, server, had_original, stage, healthy_at) VALUES (?, ?, 0, 'ready', '2026-09-18')").run(beta, server);
db().prepare("INSERT INTO beta_test_runs (id, beta_id, server, created_at) VALUES (?, ?, ?, '2026-09-18')").run(server, beta, server);
db().prepare("INSERT INTO beta_test_evidence (run_id, event_id, received_at, kind, payload) VALUES (?, 'event', '2026-09-18', 'health', '{}')").run(server);
db().prepare('INSERT INTO beta_test_coverage (beta_id, server) VALUES (?, ?)').run(beta, server);
@@ -128,10 +129,12 @@ describe('destruction cleanup recovery', () => {
vi.mocked(komodoInspectHealthy).mockResolvedValue({ ok: false, reason: 'gone' });
await processDestructions();
expect(getDeployment(queued.deploymentId!)?.deployment).toMatchObject({ state: 'done', server_exists: 0 });
for (const table of ['beta_test_runs', 'beta_test_coverage', 'beta_file_changes']) {
expect(db().prepare(`SELECT server FROM ${table}`).all()).toEqual([{ server: serverName }]);
for (const table of ['beta_servers', 'beta_test_runs', 'beta_test_coverage', 'beta_file_changes']) {
expect(db().prepare(`SELECT server FROM ${table} ORDER BY server`).all()).toEqual([{ server: serverName }, { server: name }]);
}
expect(db().prepare('SELECT run_id FROM beta_test_evidence').all()).toEqual([{ run_id: serverName }]);
expect(db().prepare('SELECT run_id FROM beta_test_evidence ORDER BY run_id').all()).toEqual([{ run_id: serverName }, { run_id: name }]);
expect(db().prepare('SELECT stage, healthy_at, server_deleted_at FROM beta_servers WHERE server = ?').get(name))
.toEqual({ stage: 'ready', healthy_at: '2026-09-18', server_deleted_at: expect.any(String) });
expect(db().pragma('foreign_key_check')).toEqual([]);
});
@@ -177,6 +180,43 @@ describe('destruction cleanup recovery', () => {
expect(getDeployment(first.deploymentId!)?.deployment).toMatchObject({ state: 'waiting', stage: REMOVING_SERVER });
});
it('resumes cleanup after crashing between registry deletion and the final result', async () => {
const { createServer, destroyServer, processDestructions } = await import('../../src/lib/server/fleet/create.js');
const { claimRemoval, deploymentStage, interruptDeployments, getDeployment, resumeCleanup, REMOVING_SERVER } = await import('../../src/lib/server/fleet/deployments.js');
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const { komodoDestroyDeployment, komodoDeleteDeployment } = await import('../../src/lib/server/integrations/komodo.js');
await createServer(input);
const { deploymentId } = destroyServer(serverName);
claimRemoval(deploymentId!);
deploymentStage(deploymentId!, REMOVING_SERVER);
db().prepare('DELETE FROM servers WHERE name = ?').run(serverName);
closeDb();
interruptDeployments();
expect(getDeployment(deploymentId!)?.deployment).toMatchObject({ state: 'interrupted', server_deleted_at: expect.any(String) });
expect(resumeCleanup(deploymentId!)).toBe(true);
await processDestructions();
expect(getDeployment(deploymentId!)?.deployment).toMatchObject({ state: 'done', server_deleted_at: expect.any(String), server_exists: 0 });
expect(komodoDestroyDeployment).not.toHaveBeenCalled();
expect(komodoDeleteDeployment).not.toHaveBeenCalled();
});
it.each(['before', 'after'] as const)('protects a replacement registered %s an archived cleanup resume', async timing => {
const { createServer, destroyServer, processDestructions } = await import('../../src/lib/server/fleet/create.js');
const { claimRemoval, deploymentStage, interruptDeployments, resumeCleanup, REMOVING_SERVER } = await import('../../src/lib/server/fleet/deployments.js');
const { db } = await import('../../src/lib/server/db/index.js');
await createServer(input);
const { deploymentId } = destroyServer(serverName);
claimRemoval(deploymentId!);
deploymentStage(deploymentId!, REMOVING_SERVER);
db().prepare('DELETE FROM servers WHERE name = ?').run(serverName);
interruptDeployments();
if (timing === 'after') expect(resumeCleanup(deploymentId!)).toBe(true);
db().prepare('INSERT INTO servers (name, komodo_name) VALUES (?, ?)').run(serverName, 'replacement');
if (timing === 'before') expect(resumeCleanup(deploymentId!)).toBe(false);
await processDestructions();
expect(db().prepare('SELECT komodo_name FROM servers WHERE name = ?').get(serverName)).toEqual({ komodo_name: 'replacement' });
});
it('refuses cleanup before confirmed deletion or after a newer creation', async () => {
const { createServer, destroyServer } = await import('../../src/lib/server/fleet/create.js');
const { beginDeployment, deploymentStage, finishDeployment, resumeCleanup, REMOVING_SERVER } = await import('../../src/lib/server/fleet/deployments.js');
+1 -1
View File
@@ -364,7 +364,7 @@ describe('shared creation queue', () => {
await processCreations();
db().prepare('DELETE FROM servers WHERE name = ?').run(jobs[0].name);
await processCreations();
expect(await states()).toEqual(['failed', 'awaiting-health']);
expect(await states()).toEqual(['cancelled', 'awaiting-health']);
});
it('never verifies a replacement registered under the same server name', async () => {
+128
View File
@@ -0,0 +1,128 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { defaultPlan, requirement } from '../../src/lib/beta-plan.js';
import { gameEvent } from '../helpers/telemetry.js';
let fixture: Fixture;
const names = ['t5_zombie_theater_1', 't5_zombie_cosmodrome_1'];
const now = () => new Date().toISOString();
const path = (server: string) => join(fixture.t5, server, 'runtime/plutonium/storage/t5/raw/scripts/sp/history.gsc');
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture(names);
vi.stubEnv('KOMODO_ADDRESS', ''); vi.stubEnv('RCON_PASSWORD', '');
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
await seedServerRegistry();
});
afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); });
async function open(planned = false, servers = [names[0]]) {
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { openBeta } = await import('../../src/lib/server/fleet/beta.js');
const { script, version } = createScript({ name: 'Retained history', targetRelPath: 'history.gsc',
scopeType: 'game', scopeValue: 't5', content: Buffer.from('beta content') });
const input = { scripts: [{ scriptId: script.id, scriptVersionId: version.id }], servers,
waitForHealth: false, waitForMatch: false,
...(planned ? { testPlan: { ...defaultPlan(), requirements: [requirement('round', 'round')] } } : {}) };
const { betaId } = await openBeta(input);
return { betaId, input };
}
async function replace(server: string) {
const { db } = await import('../../src/lib/server/db/index.js');
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
db().prepare('DELETE FROM servers WHERE name = ?').run(server);
await seedServerRegistry();
await writeFile(path(server), 'replacement content');
}
describe('deleted server operational guards', () => {
it('does not credit replacement matches to a legacy beta or keep its old server hold', async () => {
const { betaId, input } = await open();
const { db } = await import('../../src/lib/server/db/index.js');
const { betaTick } = await import('../../src/lib/server/pipeline/beta-runner.js');
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
const { openBeta, listBetaServers, getBeta } = await import('../../src/lib/server/fleet/beta.js');
db().prepare("UPDATE beta_servers SET stage = 'awaiting-match', healthy_at = ? WHERE beta_id = ?").run(now(), betaId);
await replace(names[0]);
saveTelemetry({ ...gameEvent('game_end', 99), receivedAt: now() });
await betaTick();
expect(listBetaServers(betaId)[0]).toMatchObject({ stage: 'awaiting-match', match_seen_at: null, server_deleted_at: expect.any(String) });
expect(getBeta(betaId)?.state).toBe('running');
await expect(openBeta(input)).resolves.toMatchObject({ servers: [names[0]] });
});
it('freezes test evidence, pauses original coverage, and releases the replacement for new beta work', async () => {
const { betaId, input } = await open(true);
const { db } = await import('../../src/lib/server/db/index.js');
const { prepareRun, recordGameEvent, testView } = await import('../../src/lib/server/fleet/beta-tests.js');
const { betaTick } = await import('../../src/lib/server/pipeline/beta-runner.js');
const { deployBetaServers, rollbackBeta, openBeta, listBetaServers, getBeta } = await import('../../src/lib/server/fleet/beta.js');
const { heldByBeta } = await import('../../src/lib/server/fleet/servers.js');
await prepareRun(betaId, names[0]);
db().prepare("UPDATE beta_servers SET stage = 'observing', restarted_at = ? WHERE beta_id = ?").run(now(), betaId);
db().prepare('UPDATE beta_test_runs SET observing_at = ? WHERE beta_id = ?').run(now(), betaId);
recordGameEvent({ ...gameEvent('round_change', 5), receivedAt: now() });
const before = testView(betaId)!;
expect(before.servers[0].progress[0].current).toBe(5);
await replace(names[0]);
recordGameEvent({ ...gameEvent('round_change', 99), receivedAt: now() });
await betaTick();
const after = testView(betaId)!;
expect(after).toMatchObject({ outcome: 'paused', reason: expect.stringContaining('Test server deleted') });
expect(after.servers[0]).toMatchObject({ server_deleted_at: expect.any(String), progress: before.servers[0].progress });
expect(after.evidence).toHaveLength(before.evidence.length);
expect(after.evidence[0].server_deleted_at).toEqual(expect.any(String));
expect(getBeta(betaId)?.state).toBe('running');
expect(listBetaServers(betaId)[0].stage).toBe('observing');
expect(heldByBeta(names[0]).size).toBe(0);
expect(db().prepare('SELECT id FROM server_deployments').all()).toEqual([]);
await expect(deployBetaServers(betaId, [names[0]])).rejects.toThrow('original beta server was deleted');
await rollbackBeta(betaId);
expect(await readFile(path(names[0]), 'utf8')).toBe('replacement content');
await expect(openBeta(input)).resolves.toMatchObject({ servers: [names[0]] });
});
it('does not treat a same-name replacement as tested or promote from deleted coverage', async () => {
const { betaId } = await open();
const { db } = await import('../../src/lib/server/db/index.js');
const { previewBetaPromotion, promoteBeta } = await import('../../src/lib/server/fleet/beta.js');
db().prepare("UPDATE beta_servers SET stage = 'ready' WHERE beta_id = ?").run(betaId);
db().prepare("UPDATE betas SET state = 'ready-promote' WHERE id = ?").run(betaId);
const reviewed = previewBetaPromotion(betaId);
await replace(names[0]);
const preview = previewBetaPromotion(betaId);
expect(preview.servers.find(server => server.name === names[0])?.tested).toBe(false);
expect(preview.blockers).toEqual([expect.stringContaining('original test server was deleted')]);
await expect(promoteBeta(betaId, reviewed.token)).rejects.toMatchObject({ kind: 'preview-changed' });
await expect(promoteBeta(betaId, preview.token)).rejects.toMatchObject({ kind: 'invalid-state' });
expect(await readFile(path(names[0]), 'utf8')).toBe('replacement content');
expect(db().prepare('SELECT id FROM deployments').all()).toEqual([]);
});
it('skips deleted campaign targets during restart and rollback while restoring live targets', async () => {
for (const name of names) await writeFile(path(name), 'original content');
const { betaId } = await open();
const { db } = await import('../../src/lib/server/db/index.js');
const { promoteBeta } = await import('../../src/lib/server/fleet/beta.js');
const { rollbackPromoteDeployment } = await import('../../src/lib/server/fleet/rollback.js');
const { sweep } = await import('../../src/lib/server/pipeline/sweep.js');
const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js');
db().prepare("UPDATE beta_servers SET stage = 'ready', restarted_at = ? WHERE beta_id = ?").run(now(), betaId);
db().prepare("UPDATE betas SET state = 'ready-promote' WHERE id = ?").run(betaId);
const campaign = await promoteBeta(betaId);
await replace(names[1]);
await sweep();
expect(db().prepare('SELECT id FROM server_deployments').all()).toEqual([]);
expect(() => requestRestart(names[1], { kind: 'campaign', id: campaign.deploymentId })).toThrow('Restart source no longer exists');
const result = await rollbackPromoteDeployment(campaign.deploymentId);
expect(result.affectedServers).toEqual([names[0]]);
expect(await readFile(path(names[0]), 'utf8')).toBe('original content');
expect(await readFile(path(names[1]), 'utf8')).toBe('replacement content');
expect(db().prepare('SELECT server FROM deployment_servers WHERE deployment_id = ?').all(result.deploymentId)).toEqual([{ server: names[0] }]);
});
});
@@ -588,6 +588,8 @@ describe('matching a blueprint exactly', () => {
db().exec('ALTER TABLE server_collection_previews DROP COLUMN mode');
db().exec('ALTER TABLE server_collection_previews DROP COLUMN fleet_snapshot');
db().exec('ALTER TABLE server_launch_collections DROP COLUMN mode');
// Recreate the older schema, which predates the history preservation trigger.
db().exec('DROP TRIGGER preserve_deleted_server_history');
db().exec('ALTER TABLE server_launch_collections DROP COLUMN retained_servers');
closeDb();
const { previewCollection } = await import('../../src/lib/server/fleet/collections.js');
@@ -0,0 +1,13 @@
import { expect, it } from 'vitest';
import { render } from 'svelte/server';
import HistoricalServerIdentity from '../../src/lib/ui/components/HistoricalServerIdentity.svelte';
it('retains deleted identities without linking to a replacement using the same server name', () => {
const props = { name: 't5_zombie_theater_1', href: '/servers/t5_zombie_theater_1' };
const live = render(HistoricalServerIdentity, { props }).body;
expect(live).toContain('href="/servers/t5_zombie_theater_1"');
const deleted = render(HistoricalServerIdentity, { props: { ...props, deletedAt: '2026-09-27T12:00:00Z' } }).body;
expect(deleted).toContain('t5_zombie_theater_1');
expect(deleted).toContain('Deleted');
expect(deleted).not.toContain('href=');
});
+24
View File
@@ -294,6 +294,30 @@ it('backfills valid saved telemetry once while skipping malformed and legacy sna
expect(await records()).toEqual([expect.objectContaining({ kills: 150, headshots: 80, rounds_survived: 1 })]);
});
it('keeps deleted match identities and starts a separate match when the server name is reused', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
await report('heartbeat', 8, [player(undefined, { kills: 120 })]);
const original = serverMatchHistory(server).matches[0];
db().prepare('DELETE FROM servers WHERE name = ?').run(server);
await seedServerRegistry();
// Even the same report bytes must belong to the replacement's own match.
await report('heartbeat', 8, [player(undefined, { kills: 120 })]);
closeDb();
const history = serverMatchHistory(server).matches;
expect(history).toHaveLength(2);
expect(history.find(match => match.id === original.id)).toMatchObject({
server, server_deleted_at: expect.any(String), status: 'interrupted'
});
expect(history.find(match => match.id !== original.id)).toMatchObject({ server, server_deleted_at: null, status: 'active' });
const profile = getPlayerProfile('t5', player().guid)!;
expect(profile.stats.gamesPlayed).toBe(2);
expect(profile.matches.find(match => match.id === original.id)?.server_deleted_at).toEqual(expect.any(String));
expect(profile.stats.kills).toBe(240);
});
it('does not fabricate players for an empty match', async () => {
await report('game_start', 0, []);
await report('game_end', 0, []);
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { metricRecommendations } from '../../src/lib/metrics-insights.js';
import type { MapMetrics } from '../../src/lib/metrics.js';
const map = (values: Partial<MapMetrics> = {}): MapMetrics => ({
game: 't5', map: 'Moon', observed: 10, active: 5, idle: 5, unknown: 0,
playerHours: 10, full: 0, capacityObserved: 5, ...values
});
describe('advisory hosting experiments', () => {
it('uses map capacity time rather than summed server time for expansion', () => {
const [result] = metricRecommendations([map({ observed: 100, active: 50, idle: 50, full: 1, capacityObserved: 4 })]);
expect(result.kind).toBe('expand');
expect(result.evidence).toContain('25.0%');
expect(result.tentative).toBe(false);
});
it('offers a tentative trial even with a tiny recorded sample', () => {
const [result] = metricRecommendations([map({ observed: 1 / 120, active: 1 / 120, idle: 0, full: 1 / 120, capacityObserved: 1 / 120 })]);
expect(result.kind).toBe('expand');
expect(result.tentative).toBe(true);
expect(result.evidence).toContain('30 s');
});
it('uses explicitly tentative occupancy advice when capacity is missing', () => {
const [result] = metricRecommendations([map({ active: 8, idle: 2, capacityObserved: 0 })]);
expect(result.kind).toBe('expand');
expect(result.tentative).toBe(true);
expect(result.reason).toContain('not evidence of saturation');
});
it('does not confuse unknown occupancy with an idle fleet', () => {
const [result] = metricRecommendations([map({ observed: 0, active: 0, idle: 0, unknown: 10, playerHours: 0, capacityObserved: 0 })]);
expect(result.kind).toBe('capacity');
expect(result.title).toBe('Check occupancy reporting');
expect(result.tentative).toBe(true);
});
it('keeps advice scoped to input maps and considers idle trials with incomplete capacity', () => {
const [result] = metricRecommendations([map({ game: 't6', map: 'Town', active: 1, idle: 9, capacityObserved: 0 })]);
expect(result).toMatchObject({ game: 't6', map: 'Town', kind: 'consolidate', tentative: true });
expect(result.reason).toContain('preserve access');
expect(metricRecommendations([])).toEqual([]);
});
it('distinguishes a zero-duration sample from failed occupancy reporting', () => {
const [result] = metricRecommendations([map({ observed: 0, active: 0, idle: 0, playerHours: 0, capacityObserved: 0 })]);
expect(result.title).toBe('Let observations accumulate');
expect(result.reason).toContain('no elapsed observations');
});
it('leaves the lineup alone when measured evidence does not trigger a rule', () => {
expect(metricRecommendations([map({ active: 9, idle: 1, full: 0 })])[0].kind).toBe('watch');
});
});
+88
View File
@@ -0,0 +1,88 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createMetricsRefresh } from '../../src/lib/ui/metrics-refresh.js';
afterEach(() => vi.useRealTimers());
const totals = { observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 };
const report = (days: number) => ({ days, now: 1000, since: 0, summary: totals, points: [], maps: [], servers: [],
scope: { game: null, map: null, timeZone: 'UTC' }, scopeOptions: { games: [], maps: [] },
registeredServerCount: 0, firstRetainedSampleAt: null, latestSampleAt: null, scopeFirstSampleAt: null, scopeLatestSampleAt: null,
previous: { since: -1000, until: 0, summary: totals, firstSampleAt: null, lastSampleAt: null, partialHistory: true },
heatmap: { timeZone: 'UTC', cells: [] },
pooledPressure: { value: null, fullMapHours: 0, knownMapHours: 0, coveredMaps: 0, totalMaps: 0 }
});
function setup(fetcher: typeof fetch) {
let query = 'days=7';
const onData = vi.fn(), onError = vi.fn(), onLoading = vi.fn();
return { ...createMetricsRefresh({ getQuery: () => query, onData, onError, onLoading, fetcher, timeoutMs: 100 }),
onData, onError, onLoading, setQuery: (value: string) => { query = value; } };
}
describe('metrics background refresh', () => {
it('keeps the previous report after failure and accepts a successful retry', async () => {
const fetcher = vi.fn().mockResolvedValueOnce(new Response('', { status: 503 }))
.mockResolvedValueOnce(Response.json(report(7)));
const refresh = setup(fetcher);
await refresh.refresh();
expect(refresh.onData).not.toHaveBeenCalled();
expect(refresh.onError).toHaveBeenLastCalledWith('Report refresh failed (503).');
await refresh.refresh();
expect(refresh.onData).toHaveBeenLastCalledWith(report(7));
expect(refresh.onError).toHaveBeenLastCalledWith(null);
expect(refresh.onLoading).toHaveBeenLastCalledWith(false);
});
it('coalesces duplicate requests and ignores old replies even when transport ignores abort', async () => {
const resolve: ((r: Response) => void)[] = [];
const fetcher = vi.fn(() => new Promise<Response>(done => resolve.push(done)));
const refresh = setup(fetcher);
const old = refresh.refresh();
expect(refresh.refresh()).toBe(old);
expect(fetcher).toHaveBeenCalledTimes(1);
refresh.setQuery('days=1');
const next = refresh.refresh();
resolve[1](Response.json(report(1)));
await next;
resolve[0](Response.json(report(7)));
await old;
expect(refresh.onData).toHaveBeenCalledTimes(1);
expect(refresh.onData).toHaveBeenLastCalledWith(report(1));
});
it('preserves the report when an HTTP success lacks the expanded report contract', async () => {
const fetcher = vi.fn().mockResolvedValueOnce(Response.json({ ...report(7), heatmap: null }))
.mockResolvedValueOnce(Response.json({ ...report(7), scope: { game: null, map: null, timeZone: 'Invalid/Zone' } }))
.mockResolvedValueOnce(Response.json(report(7)));
const refresh = setup(fetcher);
await refresh.refresh();
await refresh.refresh();
expect(refresh.onData).not.toHaveBeenCalled();
expect(refresh.onError).toHaveBeenLastCalledWith('The metrics response was incomplete.');
await refresh.refresh();
expect(refresh.onData).toHaveBeenCalledTimes(1);
});
it('suppresses errors from a cancelled request', async () => {
let reject!: (error: Error) => void;
const refresh = setup(vi.fn(() => new Promise<Response>((_, fail) => { reject = fail; })));
const pending = refresh.refresh();
refresh.cancel();
reject(new Error('Obsolete response'));
await pending;
expect(refresh.onData).not.toHaveBeenCalled();
expect(refresh.onError.mock.calls).toEqual([[null]]);
expect(refresh.onLoading).toHaveBeenLastCalledWith(false);
});
it('times out with retry feedback and clears loading', async () => {
vi.useFakeTimers();
const fetcher = vi.fn((_input, init) => new Promise<Response>((_, reject) => {
init?.signal?.addEventListener('abort', () => reject(new Error('aborted')));
})) as typeof fetch;
const refresh = setup(fetcher);
const pending = refresh.refresh();
await vi.advanceTimersByTimeAsync(100);
await pending;
expect(refresh.onError).toHaveBeenLastCalledWith('Report refresh timed out. Try again.');
expect(refresh.onLoading).toHaveBeenLastCalledWith(false);
});
});
+295 -1
View File
@@ -1,9 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { parseMetricsQuery } from '../../src/lib/metrics.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { await fixture.cleanup(); });
afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); });
describe('historical metrics', () => {
it('weights occupancy by time and excludes failed checks and collection gaps', async () => {
@@ -56,4 +57,297 @@ describe('historical metrics', () => {
recordMetrics([sample], at + 32 * 86400_000);
expect(db().prepare('SELECT COUNT(*) AS n FROM metric_samples').get()).toEqual({ n: 1 });
});
it('distinguishes retained history, an empty range, and current collection targets', async () => {
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const { db } = await import('../../src/lib/server/db/index.js');
const at = Date.UTC(2026, 0, 2);
expect(readMetrics(1, at)).toMatchObject({ firstRetainedSampleAt: null, latestSampleAt: null,
registeredServerCount: 0, servers: [], maps: [] });
db().prepare('INSERT INTO servers (name, komodo_name, destroyed_at) VALUES (?, ?, ?)').run('current', 'current', null);
db().prepare('INSERT INTO servers (name, komodo_name, destroyed_at) VALUES (?, ?, ?)').run('removed', 'removed', new Date(at).toISOString());
const sample = { server: 'removed', game: 't6', map: 'Town', players: 1, capacity: null };
recordMetrics([sample], at);
const emptyRange = readMetrics(1, at + 2 * 86400_000);
expect(emptyRange).toMatchObject({ firstRetainedSampleAt: at, latestSampleAt: at,
registeredServerCount: 1, servers: [], maps: [], summary: { observed: 0, unknown: 0 } });
expect(emptyRange.points.every(point => point.observed === 0 && point.unknown === 0)).toBe(true);
});
it('keeps unknown status separate from no samples and preserves zero elapsed observations', async () => {
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const at = Date.UTC(2026, 0, 2, 12);
recordMetrics([{ server: 'one', game: 't5', map: 'Moon', players: null, capacity: null }], at);
const initial = readMetrics(1, at);
expect(initial.latestSampleAt).toBe(at);
expect(initial.servers).toHaveLength(1);
expect(initial.summary).toEqual({ observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 });
const later = readMetrics(1, at + 120_000);
expect(later.summary.observed).toBe(0);
expect(later.summary.unknown * 3600).toBeCloseTo(45);
expect(later.maps[0].capacityObserved).toBe(0);
});
it('returns clipped interval boundaries and marks only partial UTC buckets', async () => {
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 0, 8, 12, 30);
for (const days of [1, 7, 30] as const) {
const result = readMetrics(days, now);
expect(result.points[0].startAt).toBe(result.since);
expect(result.points.at(-1)?.endAt).toBe(now);
expect(result.points[0].partial).toBe(true);
expect(result.points.at(-1)?.partial).toBe(true);
expect(result.points.slice(1, -1).every(point => !point.partial)).toBe(true);
expect(result.points.reduce((ms, point) => ms + point.endAt - point.startAt, 0)).toBe(days * 86400_000);
}
expect(readMetrics(1, Date.UTC(2026, 0, 8, 12)).points.every(point => !point.partial)).toBe(true);
});
it('uses complete collection boundaries across fleet changes and keeps map pressure in wall time', async () => {
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const at = Date.UTC(2026, 0, 2, 23, 59, 40);
const one = { server: 'one', game: 't6', map: 'Town', players: 4, capacity: 4 };
const two = { ...one, server: 'two' };
recordMetrics([one, two], at);
// Removing a lobby ends its previous observation at the next complete collection.
recordMetrics([{ ...one, map: 'Farm', players: 0 }], at + 30_000);
// A collection outage is excluded; an identically named map in another game remains separate.
recordMetrics([{ ...one, game: 't5', players: 2, capacity: null }], at + 120_000);
const result = readMetrics(1, at + 150_000);
expect(result.summary.active * 3600).toBeCloseTo(90);
expect(result.summary.idle * 3600).toBeCloseTo(45);
expect(result.summary.playerHours * 3600).toBeCloseTo(300);
expect(result.servers.find(server => server.server === 'two')!.observed * 3600).toBeCloseTo(30);
const town = result.maps.find(map => map.game === 't6' && map.map === 'Town')!;
expect(town.observed * 3600).toBeCloseTo(60);
expect(town.capacityObserved * 3600).toBeCloseTo(30);
expect(town.full * 3600).toBeCloseTo(30);
expect(result.maps.find(map => map.game === 't5')!.capacityObserved).toBe(0);
for (const field of ['observed', 'active', 'idle', 'unknown', 'playerHours'] as const) {
expect(result.points.reduce((sum, point) => sum + point[field], 0)).toBeCloseTo(result.summary[field]);
expect(result.servers.reduce((sum, server) => sum + server[field], 0)).toBeCloseTo(result.summary[field]);
expect(result.maps.reduce((sum, map) => sum + map[field], 0)).toBeCloseTo(result.summary[field]);
}
});
it('does not include future samples in collection metadata or aggregates', async () => {
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const at = Date.UTC(2026, 0, 2, 12);
const sample = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: 4 };
recordMetrics([sample], at);
recordMetrics([{ ...sample, players: 4 }], at + 60_000);
const result = readMetrics(1, at + 30_000);
expect(result.latestSampleAt).toBe(at);
expect(result.summary.playerHours * 3600).toBeCloseTo(30);
});
it('shares the range parser and response contract between SSR and the uncached refresh endpoint', async () => {
const { recordMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const { GET } = await import('../../src/routes/api/metrics/+server.js');
const { load } = await import('../../src/routes/metrics/+page.server.js');
const at = Date.UTC(2026, 0, 2, 12);
recordMetrics([{ server: 'one', game: 't5', map: 'Moon', players: 2, capacity: 4 }], at);
vi.spyOn(Date, 'now').mockReturnValue(at + 30_000);
for (const range of ['1', '7', '30', 'invalid', '365', '-1', '']) {
const url = new URL(`http://localhost/metrics?days=${range}`);
const response = GET({ url });
const payload = await response.json();
expect(response.headers.get('cache-control')).toBe('no-store');
expect(payload).toEqual(await load({ url } as Parameters<typeof load>[0]));
expect(payload.days).toBe(['1', '7', '30'].includes(range) ? Number(range) : 7);
}
expect((await GET({ url: new URL('http://localhost/api/metrics') }).json()).days).toBe(7);
for (const search of ['days=1&game=t5&map=Moon&timeZone=Asia%2FKathmandu', 'days=7&map=Moon', 'game=t6&timeZone=Invalid']) {
const url = new URL(`http://localhost/metrics?${search}`);
const payload = await GET({ url }).json();
const { days, ...scope } = parseMetricsQuery(url.searchParams);
expect(payload).toEqual(await load({ url } as Parameters<typeof load>[0]));
expect(payload).toMatchObject({ days, scope });
}
});
it('normalizes malformed scopes consistently and requires a game for a map selection', () => {
expect(parseMetricsQuery(new URLSearchParams('days=365&map=Moon&timeZone=Invalid/Zone')))
.toEqual({ days: 7, game: null, map: null, timeZone: 'America/New_York' });
expect(parseMetricsQuery(new URLSearchParams({ game: 't5; DROP TABLE', map: 'Moon' }))).toMatchObject({ game: null, map: null });
expect(parseMetricsQuery(new URLSearchParams({ game: ' t5 ', map: 'Moon', timeZone: 'Asia/Kathmandu', days: '30' })))
.toEqual({ days: 30, game: 't5', map: 'Moon', timeZone: 'Asia/Kathmandu' });
for (const map of ['', ' ', 'a'.repeat(201), 'Moon\nTown']) {
expect(parseMetricsQuery(new URLSearchParams({ game: 't5', map })).map).toBeNull();
}
expect(parseMetricsQuery(new URLSearchParams({ game: 't5', map: 'Not recorded yet' })).map).toBe('Not recorded yet');
});
it('filters every derived value by exact game and map while preserving unfiltered collection boundaries', async () => {
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 0, 3, 12);
const since = now - 86400_000;
const moon = { server: 'one', game: 't5', map: 'Moon', players: 4, capacity: 4 };
const other = { ...moon, server: 'other', game: 't6', players: 2 };
recordMetrics([moon, other], since - 30_000);
recordMetrics([other], since);
recordMetrics([moon, other, { ...moon, server: 'town', map: 'Town', players: 0 }], now - 60_000);
recordMetrics([other], now - 45_000);
recordMetrics([{ ...moon, players: 0 }, other], now - 30_000);
const query = parseMetricsQuery(new URLSearchParams({ days: '1', game: 't5', map: 'Moon', timeZone: 'UTC' }));
const result = readMetrics(query, now);
expect(result.scope).toEqual({ game: 't5', map: 'Moon', timeZone: 'UTC' });
expect(result.summary.active * 3600).toBeCloseTo(15);
expect(result.summary.idle * 3600).toBeCloseTo(30);
expect(result.summary.playerHours * 3600).toBeCloseTo(60);
expect(result.previous.summary.active * 3600).toBeCloseTo(30);
expect(result.previous.summary.playerHours * 3600).toBeCloseTo(120);
expect(result.servers.map(server => server.server)).toEqual(['one']);
expect(result.maps.map(map => [map.game, map.map])).toEqual([['t5', 'Moon']]);
expect(result.scopeOptions).toEqual({ games: ['t5', 't6'], maps: [
{ game: 't5', map: 'Moon' }, { game: 't5', map: 'Town' }, { game: 't6', map: 'Moon' }
] });
expect(result.scopeFirstSampleAt).toBe(since - 30_000);
expect(result.scopeLatestSampleAt).toBe(now - 30_000);
expect(result.pooledPressure.value).toBeCloseTo(100 / 3);
for (const field of ['observed', 'active', 'idle', 'unknown', 'playerHours'] as const) {
expect(result.points.reduce((sum, point) => sum + point[field], 0)).toBeCloseTo(result.summary[field]);
expect(result.heatmap.cells.reduce((sum, cell) => sum + cell[field], 0)).toBeCloseTo(result.summary[field]);
}
const gameOnly = readMetrics({ ...query, map: null }, now);
expect(gameOnly.maps.map(map => map.map).sort()).toEqual(['Moon', 'Town']);
const missing = readMetrics({ ...query, map: 'Unrecorded' }, now);
expect(missing).toMatchObject({ scopeFirstSampleAt: null, scopeLatestSampleAt: null,
summary: { observed: 0, unknown: 0 }, maps: [], servers: [], pooledPressure: { value: null },
previous: { firstSampleAt: null, lastSampleAt: null, partialHistory: true } });
expect(missing.scopeOptions).toEqual(result.scopeOptions);
});
it('separates scoped evidence timestamps from global collection timestamps', async () => {
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const at = Date.UTC(2026, 0, 3, 12);
const sample = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: 4 };
recordMetrics([{ ...sample, game: 't6' }], at - 60_000);
recordMetrics([sample], at - 30_000);
recordMetrics([{ ...sample, game: 't6' }], at);
const result = readMetrics(parseMetricsQuery(new URLSearchParams({ game: 't5' })), at + 30_000);
expect(result.firstRetainedSampleAt).toBe(at - 60_000);
expect(result.latestSampleAt).toBe(at);
expect(result.scopeFirstSampleAt).toBe(at - 30_000);
expect(result.scopeLatestSampleAt).toBe(at - 30_000);
});
it('compares adjacent equal windows and splits an observation at their shared boundary', async () => {
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 0, 3, 12);
const since = now - 86400_000;
const sample = { server: 'one', game: 't5', map: 'Moon', players: 2, capacity: 4 };
recordMetrics([sample], since - 15_000);
recordMetrics([{ ...sample, players: 0 }], since + 15_000);
const result = readMetrics(1, now);
expect(result.previous.since).toBe(now - 2 * 86400_000);
expect(result.previous.until).toBe(result.since);
expect(result.previous.summary.active * 3600).toBeCloseTo(15);
expect(result.summary.active * 3600).toBeCloseTo(15);
expect(result.summary.idle * 3600).toBeCloseTo(45);
expect(result.previous).toMatchObject({ firstSampleAt: since - 15_000, lastSampleAt: since - 15_000, partialHistory: true });
});
it('returns the available prior evidence when a thirty-day comparison exceeds retained history', async () => {
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 2, 3, 12);
const day = 86400_000;
const sample = { server: 'one', game: 't5', map: 'Moon', players: 2, capacity: 4 };
recordMetrics([sample], now - 31 * day + 30_000);
recordMetrics([{ ...sample, players: 0 }], now - 30 * day + 30_000);
recordMetrics([sample], now - 30_000);
const result = readMetrics(30, now);
expect(result.previous).toMatchObject({ since: now - 60 * day, until: now - 30 * day, partialHistory: true });
expect(result.previous.summary.active * 3600).toBeCloseTo(45);
expect(result.previous.firstSampleAt).toBe(now - 31 * day + 30_000);
expect(result.summary.active * 3600).toBeCloseTo(30);
expect(result.summary.idle * 3600).toBeCloseTo(45);
});
it('marks the retained start bound separately from gaps inside the previous window', async () => {
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const now = Date.UTC(2026, 0, 3, 12);
const priorStart = now - 2 * 86400_000;
const sample = { server: 'one', game: 't5', map: 'Moon', players: null, capacity: null };
recordMetrics([sample], priorStart - 15_000);
recordMetrics([sample], now - 30_000);
const result = readMetrics(1, now);
expect(result.previous.partialHistory).toBe(false);
expect(result.previous.summary.unknown * 3600).toBeCloseTo(30);
expect(result.previous.summary.observed).toBe(0);
expect(result.previous.firstSampleAt).toBe(priorStart - 15_000);
// Having the left edge says nothing about the uncaptured time after it.
expect(result.previous.summary.unknown).toBeLessThan(24);
});
it('pools pressure by known map-hours rather than server-hours or fleet elapsed time', async () => {
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const at = Date.UTC(2026, 0, 3, 12);
const full = { server: 'one', game: 't5', map: 'Moon', players: 4, capacity: 4 };
recordMetrics([full, { ...full, server: 'two' }, { ...full, server: 'three', map: 'Town', players: 0 },
{ ...full, server: 'four', map: 'Farm', players: 2, capacity: null }], at);
const result = readMetrics(1, at + 30_000);
expect(result.pooledPressure).toMatchObject({ value: 50, coveredMaps: 2, totalMaps: 3 });
expect(result.pooledPressure.fullMapHours * 3600).toBeCloseTo(30);
expect(result.pooledPressure.knownMapHours * 3600).toBeCloseTo(60);
expect(result.summary.observed * 3600).toBeCloseTo(120);
const empty = readMetrics(parseMetricsQuery(new URLSearchParams({ days: '1', game: 't5', map: 'Town' })), at + 30_000);
expect(empty.pooledPressure.value).toBe(0);
const missing = readMetrics(parseMetricsQuery(new URLSearchParams({ days: '1', game: 't5', map: 'Farm' })), at + 30_000);
expect(missing.pooledPressure.value).toBeNull();
});
it.each([
{ zone: 'America/New_York', at: '2026-03-08T06:59:45Z', first: [6, 1], second: [6, 3] },
{ zone: 'Asia/Kolkata', at: '2026-01-01T18:29:45Z', first: [3, 23], second: [4, 0] },
{ zone: 'Asia/Kathmandu', at: '2026-01-01T18:14:45Z', first: [3, 23], second: [4, 0] },
{ zone: 'Australia/Lord_Howe', at: '2026-10-03T15:29:45Z', first: [6, 1], second: [6, 2] }
])('splits heatmap time across local boundaries in $zone', async ({ zone, at: timestamp, first, second }) => {
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const at = Date.parse(timestamp);
recordMetrics([{ server: 'one', game: 't5', map: 'Moon', players: 2, capacity: 4 }], at);
const result = readMetrics(parseMetricsQuery(new URLSearchParams({ days: '1', timeZone: zone })), at + 30_000);
expect(result.heatmap.timeZone).toBe(zone);
expect(result.heatmap.cells).toHaveLength(168);
for (const [weekday, hour] of [first, second]) {
const cell = result.heatmap.cells.find(cell => cell.weekday === weekday && cell.hour === hour)!;
expect(cell.observed * 3600).toBeCloseTo(15);
expect(cell.playerHours * 3600).toBeCloseTo(30);
expect(cell.distinctDates).toBe(1);
}
expect(result.heatmap.cells.reduce((sum, cell) => sum + cell.observed, 0)).toBeCloseTo(result.summary.observed);
});
it('weights repeated fall-back hours and counts their local date once', async () => {
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const at = Date.parse('2026-11-01T05:30:00Z');
const sample = { server: 'one', game: 't5', map: 'Moon', players: 2, capacity: 4 };
recordMetrics([sample], at);
recordMetrics([{ ...sample, players: 0 }], at + 3600_000);
const result = readMetrics(parseMetricsQuery(new URLSearchParams({ days: '1', timeZone: 'America/New_York' })), at + 3630_000);
const cell = result.heatmap.cells.find(cell => cell.weekday === 6 && cell.hour === 1)!;
expect(cell.observed * 3600).toBeCloseTo(75);
expect(cell.active * 3600).toBeCloseTo(45);
expect(cell.idle * 3600).toBeCloseTo(30);
expect(cell.distinctDates).toBe(1);
expect(result.heatmap.cells.filter(cell => cell.observed > 0)).toHaveLength(1);
});
it('counts unknown heatmap evidence and distinguishes retained names from current registrations', async () => {
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const { db } = await import('../../src/lib/server/db/index.js');
const at = Date.UTC(2026, 0, 3, 12);
const sample = { server: 'reused', game: 't5', map: 'Moon', players: null, capacity: null };
recordMetrics([sample, { ...sample, server: 'removed' }, { ...sample, server: 'tombstone' }], at);
// A newly registered name can link to the current server without asserting
// that its earlier historical observations belong to this registration.
db().prepare('INSERT INTO servers (name, komodo_name, created_at) VALUES (?, ?, ?)').run('reused', 'reused', new Date(at + 15_000).toISOString());
db().prepare('INSERT INTO servers (name, komodo_name, destroyed_at) VALUES (?, ?, ?)').run('tombstone', 'tombstone', new Date(at).toISOString());
const result = readMetrics(1, at + 30_000);
expect(result.servers.find(server => server.server === 'reused')!.currentRegistered).toBe(true);
expect(result.servers.filter(server => server.server !== 'reused').every(server => !server.currentRegistered)).toBe(true);
expect(result.heatmap.cells.reduce((sum, cell) => sum + cell.unknown, 0) * 3600).toBeCloseTo(90);
expect(result.heatmap.cells.reduce((sum, cell) => sum + cell.observed, 0)).toBe(0);
expect(result.heatmap.cells.find(cell => cell.unknown > 0)!.distinctDates).toBe(1);
});
});
+1 -1
View File
@@ -219,7 +219,7 @@ describe('provisioning operations', () => {
expect(destroyServer(server)).toMatchObject({ ok: true });
vi.mocked(komodoInspectHealthy).mockResolvedValue({ ok: false, state: 'exited', reason: 'stopped' });
await processDestructions();
expect(await operation(id)).toMatchObject({ state: 'failed', error: 'Server destroyed before operation verification completed' });
expect(await operation(id)).toMatchObject({ state: 'cancelled', error: 'Server deleted', server_deleted_at: expect.any(String) });
});
it('validates lifecycle API input and returns the durable operation ID', async () => {
+25
View File
@@ -44,6 +44,31 @@ afterEach(async () => {
function nextTick() { vi.setSystemTime(Date.now() + 15_000); }
it('ignores a late restart reply after deletion without changing a replacement recovery slot', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
const { beginDeployment } = await import('../../src/lib/server/provisioning/history.js');
const { restartInRollout } = await import('../../src/lib/server/provisioning/restarts.js');
const { komodoRestart } = await import('../../src/lib/server/integrations/komodo.js');
let complete!: (value: { ok: true }) => void;
vi.mocked(komodoRestart).mockImplementationOnce(() => new Promise(resolve => { complete = resolve; }));
const oldOperation = beginDeployment(servers[0], 'restart');
const pending = restartInRollout(servers[0], { operationId: oldOperation, action: 'restart' });
db().prepare('DELETE FROM servers WHERE name = ?').run(servers[0]);
await seedServerRegistry();
const replacement = beginDeployment(servers[0], 'restart');
const requestedAt = new Date().toISOString();
db().prepare('INSERT INTO restart_rollout (server, requested_at, operation_id) VALUES (?, ?, ?)')
.run(servers[0], requestedAt, replacement);
nextTick();
complete({ ok: true });
expect(await pending).toMatchObject({ kind: 'failed' });
expect(db().prepare('SELECT state, server_deleted_at FROM server_deployments WHERE id = ?').get(oldOperation))
.toMatchObject({ state: 'cancelled', server_deleted_at: expect.any(String) });
expect(db().prepare('SELECT operation_id, requested_at, error FROM restart_rollout WHERE server = ?').get(servers[0]))
.toEqual({ operation_id: replacement, requested_at: requestedAt, error: null });
});
async function deployment(id = 'dep-1', targets = servers, action = 'promote') {
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare('INSERT INTO deployments (id, action, started_at) VALUES (?, ?, ?)')
+150
View File
@@ -0,0 +1,150 @@
import Database from 'better-sqlite3';
import { afterEach, describe, expect, it } from 'vitest';
import schema from '../../src/lib/server/db/schema.sql?raw';
import { migrateServerHistory } from '../../src/lib/server/db/server-history.js';
let handle: Database.Database;
afterEach(() => handle?.close());
function database(legacy = false) {
handle = new Database(':memory:');
let sql = schema;
if (legacy) {
sql = sql.replace(/^\s*server_deleted_at TEXT,\n/gm, '');
for (const name of ['beta_servers', 'beta_test_runs', 'beta_server_scripts']) {
sql = sql.replace(new RegExp(`(CREATE TABLE IF NOT EXISTS ${name} \\([\\s\\S]*?server\\s+TEXT NOT NULL)(,)`), '$1 REFERENCES servers(name)$2');
}
}
handle.exec(sql);
return handle;
}
function seedHistory(db: Database.Database) {
db.exec(`INSERT INTO servers (name, komodo_name, game_port) VALUES ('server', 'server', 28960);
INSERT INTO scripts (id, name, target_rel_path) VALUES (1, 'script', 'test.gsc');
INSERT INTO script_versions (id, script_id, version, sha256, size, content) VALUES (1, 1, 1, 'hash', 4, X'74657374');
INSERT INTO betas (id, script_id, script_version_id, trigger_kind, state) VALUES (1, 1, 1, 'ui', 'promoted');
INSERT INTO beta_servers (beta_id, server, had_original, stage, healthy_at) VALUES (1, 'server', 0, 'ready', 'healthy');
INSERT INTO beta_server_scripts (beta_id, server, script_id, had_original) VALUES (1, 'server', 1, 0);
INSERT INTO beta_test_runs (id, beta_id, server, created_at, metrics) VALUES ('run', 1, 'server', 'before', '{"rounds":25}');
INSERT INTO beta_test_evidence (run_id, event_id, received_at, kind, payload) VALUES ('run', 'event', 'before', 'match_end', '{"round":25}');
INSERT INTO beta_test_coverage (beta_id, server, game, map) VALUES (1, 'server', 't5', 'theater');
INSERT INTO beta_file_changes (beta_id, server, rel_path) VALUES (1, 'server', 'test.gsc');
INSERT INTO deployments (id, action, started_at, finished_at) VALUES ('campaign', 'promote', 'before', 'after');
INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state, restarted_at) VALUES ('campaign', 'server', 1, 'done', 'restarted');
INSERT INTO deployment_files (deployment_id, server, verb, rel_path, content_after) VALUES ('campaign', 'server', 'updated', 'test.gsc', X'74657374');
INSERT INTO server_deployments (id, server, action, state, stage, started_at, finished_at, triggered_by) VALUES ('created', 'server', 'create', 'done', 'Ready', 'before', 'after', 'ui');
INSERT INTO maintenance_jobs (server, state, requested_at, finished_at, detail) VALUES ('server', 'done', 'before', 'after', 'Updated');
INSERT INTO watchdog_incidents (id, server, kind, state, summary, first_seen_at, last_seen_at) VALUES ('incident', 'server', 'health', 'attention', 'Unhealthy', 'before', 'after');
INSERT INTO leaderboard_matches (server, game, map, map_key, map_name, started_at, last_received_at, highest_round, last_event, last_fingerprint)
VALUES ('server', 't5', 'theater', 'theater', 'Kino', 'before', 'after', 25, 'heartbeat', 'hash');`);
}
describe('durable server history', () => {
it('migrates legacy foreign keys without losing evidence, activity, or row ordering', () => {
const db = database(true);
seedHistory(db);
const events = db.prepare('SELECT * FROM beta_events ORDER BY id').all();
const runs = db.prepare('SELECT rowid, * FROM beta_test_runs').all();
const servers = db.prepare('SELECT rowid, beta_id, server, stage, healthy_at FROM beta_servers').all();
migrateServerHistory(db);
migrateServerHistory(db);
expect(db.prepare('SELECT rowid, * FROM beta_test_runs').all()).toEqual(runs);
expect(db.prepare('SELECT rowid, beta_id, server, stage, healthy_at FROM beta_servers').all()).toEqual(servers);
expect(db.prepare('SELECT * FROM beta_events ORDER BY id').all()).toEqual(events);
db.prepare("UPDATE beta_servers SET stage = 'failed', error = 'historical failure'").run();
expect(db.prepare('SELECT message FROM beta_events ORDER BY id DESC LIMIT 1').get()).toEqual({ message: 'server: Failed — historical failure' });
db.prepare("DELETE FROM servers WHERE name = 'server'").run();
expect(db.prepare('SELECT metrics FROM beta_test_runs').get()).toEqual({ metrics: '{"rounds":25}' });
expect(db.prepare('SELECT payload FROM beta_test_evidence').get()).toEqual({ payload: '{"round":25}' });
expect(db.prepare('SELECT had_original, prior_version_id FROM beta_server_scripts').get()).toEqual({ had_original: 0, prior_version_id: null });
expect(db.pragma('foreign_keys', { simple: true })).toBe(1);
expect(db.pragma('foreign_key_check')).toEqual([]);
});
it('preserves names and results permanently while freeing the same name and port', () => {
const db = database();
migrateServerHistory(db);
seedHistory(db);
db.exec(`INSERT INTO server_launch_collections (id, name, created_at, retained_servers) VALUES ('collection', 'Collection', 'before', '["server"]');`);
db.prepare("DELETE FROM servers WHERE name = 'server'").run();
for (const table of ['beta_servers', 'deployment_servers', 'server_deployments', 'maintenance_jobs', 'watchdog_incidents', 'leaderboard_matches']) {
expect(db.prepare(`SELECT server, server_deleted_at FROM ${table}`).get()).toEqual({ server: 'server', server_deleted_at: expect.any(String) });
}
const previous = db.prepare('SELECT server_deleted_at FROM beta_servers').get();
const retained = db.prepare('SELECT retained_server_deletions FROM server_launch_collections').get() as { retained_server_deletions: string };
expect(JSON.parse(retained.retained_server_deletions)).toEqual({ server: expect.any(String) });
expect(db.prepare('SELECT stage, healthy_at FROM beta_servers').get()).toEqual({ stage: 'ready', healthy_at: 'healthy' });
expect(db.prepare('SELECT restart_state, restarted_at FROM deployment_servers').get()).toEqual({ restart_state: 'done', restarted_at: 'restarted' });
expect(db.prepare('SELECT content_after FROM deployment_files').get()).toEqual({ content_after: Buffer.from('test') });
expect(db.prepare('SELECT status, highest_round, ended_at FROM leaderboard_matches').get()).toEqual({ status: 'interrupted', highest_round: 25, ended_at: expect.any(String) });
db.exec(`INSERT INTO servers (name, komodo_name, game_port) VALUES ('server', 'replacement', 28960);
INSERT INTO maintenance_jobs (server, state, requested_at) VALUES ('server', 'queued', 'new');
INSERT INTO watchdog_incidents (id, server, kind, state, summary, first_seen_at, last_seen_at) VALUES ('replacement', 'server', 'health', 'open', 'New incident', 'new', 'new');`);
migrateServerHistory(db);
expect(db.prepare('SELECT server_deleted_at FROM beta_servers').get()).toEqual(previous);
expect(db.prepare('SELECT retained_server_deletions FROM server_launch_collections').get()).toEqual(retained);
expect(db.prepare("SELECT server_deleted_at FROM watchdog_incidents WHERE id = 'replacement'").get()).toEqual({ server_deleted_at: null });
});
it('cancels old pending work without rewriting completed outcomes', () => {
const db = database();
migrateServerHistory(db);
seedHistory(db);
db.exec(`INSERT INTO maintenance_jobs (server, state, requested_at) VALUES ('server', 'queued', 'pending');
INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by) VALUES ('pending', 'server', 'restart', 'waiting', 'Waiting for empty', 'pending', 'ui');
INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by) VALUES ('removal', 'server', 'destroy', 'running', 'Removing server files and registry', 'pending', 'ui');
DELETE FROM servers WHERE name = 'server';`);
expect(db.prepare("SELECT state, stage, error FROM server_deployments WHERE id = 'pending'").get())
.toEqual({ state: 'cancelled', stage: 'Waiting for empty', error: 'Server deleted' });
expect(db.prepare("SELECT message FROM server_deployment_events WHERE deployment_id = 'pending'").get()).toEqual({ message: 'Cancelled: server deleted' });
expect(db.prepare('SELECT state, detail FROM maintenance_jobs ORDER BY id').all()).toEqual([{ state: 'done', detail: 'Updated' }, { state: 'cancelled', detail: 'Server deleted' }]);
expect(db.prepare("SELECT state, finished_at FROM server_deployments WHERE id = 'created'").get()).toEqual({ state: 'done', finished_at: 'after' });
expect(db.prepare("SELECT state, server_deleted_at FROM server_deployments WHERE id = 'removal'").get()).toEqual({ state: 'running', server_deleted_at: expect.any(String) });
});
it('keeps only the current rebuild and its observing campaign and incident attached', () => {
const db = database();
migrateServerHistory(db);
seedHistory(db);
db.exec(`INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by) VALUES ('rebuild', 'server', 'rebuild', 'running', 'Removing server files and registry', 'now', 'watchdog:incident');
INSERT INTO server_rebuild_jobs (deployment_id) VALUES ('rebuild');
INSERT INTO deployments (id, action, started_at) VALUES ('watchdog', 'rebuild', 'now');
INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state) VALUES ('watchdog', 'server', 1, 'pending');
INSERT INTO watchdog_actions (id, incident_id, action, operation_id, campaign_id, created_at) VALUES ('action', 'incident', 'rebuild', 'rebuild', 'watchdog', 'now');
DELETE FROM servers WHERE name = 'server';`);
expect(db.prepare("SELECT state, server_deleted_at FROM server_deployments WHERE id = 'rebuild'").get()).toEqual({ state: 'running', server_deleted_at: null });
expect(db.prepare("SELECT server_deleted_at FROM deployment_servers WHERE deployment_id = 'watchdog'").get()).toEqual({ server_deleted_at: null });
expect(db.prepare("SELECT server_deleted_at FROM watchdog_incidents WHERE id = 'incident'").get()).toEqual({ server_deleted_at: null });
expect(db.prepare("SELECT server_deleted_at FROM deployment_servers WHERE deployment_id = 'campaign'").get()).toEqual({ server_deleted_at: expect.any(String) });
db.exec(`UPDATE server_rebuild_jobs SET removed_at = 'now'; UPDATE server_deployments SET state = 'queued' WHERE id = 'rebuild';
DELETE FROM settings WHERE key = 'server_history_v1';`);
migrateServerHistory(db);
expect(db.prepare("SELECT server_deleted_at FROM server_deployments WHERE id = 'rebuild'").get()).toEqual({ server_deleted_at: null });
expect(db.prepare("SELECT server_deleted_at FROM deployment_servers WHERE deployment_id = 'watchdog'").get()).toEqual({ server_deleted_at: null });
});
it('backfills missing historical servers while excluding initial launches with no deletion evidence', () => {
const db = database();
db.exec(`INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by) VALUES
('old', 'gone', 'create', 'done', 'Ready', 'old', 'ui'),
('pending', 'new', 'create', 'queued', 'Queued', 'new', 'ui'),
('failed', 'failed', 'create', 'failed', 'Creating', 'new', 'ui'),
('interrupted-create', 'removed', 'create', 'interrupted', 'Creating', 'before', 'ui'),
('superseded', 'removed', 'rebuild', 'failed', 'Removing server files and registry', 'before', 'ui'),
('destroyed', 'removed', 'destroy', 'done', 'Removing server files and registry', 'after', 'ui');
INSERT INTO server_rebuild_jobs (deployment_id) VALUES ('superseded');
INSERT INTO servers (name, komodo_name, destroyed_at) VALUES ('tombstone', 'tombstone', 'old');
INSERT INTO maintenance_jobs (server, state, requested_at) VALUES ('gone', 'queued', 'old'), ('tombstone', 'done', 'old');
INSERT INTO server_launch_collections (id, name, created_at, retained_servers) VALUES ('old', 'Old collection', 'old', '["tombstone","gone","new"]');`);
migrateServerHistory(db);
expect(db.prepare("SELECT server_deleted_at FROM server_deployments WHERE id = 'old'").get()).toEqual({ server_deleted_at: expect.any(String) });
for (const id of ['pending', 'failed']) expect(db.prepare('SELECT server_deleted_at FROM server_deployments WHERE id = ?').get(id)).toEqual({ server_deleted_at: null });
expect(db.prepare("SELECT server_deleted_at FROM server_deployments WHERE id = 'interrupted-create'").get()).toEqual({ server_deleted_at: expect.any(String) });
expect(db.prepare("SELECT state, server_deleted_at FROM server_deployments WHERE id = 'superseded'").get()).toEqual({ state: 'failed', server_deleted_at: expect.any(String) });
expect(db.prepare("SELECT state, server_deleted_at FROM maintenance_jobs WHERE server = 'gone'").get()).toEqual({ state: 'cancelled', server_deleted_at: expect.any(String) });
expect(db.prepare("SELECT state, server_deleted_at FROM maintenance_jobs WHERE server = 'tombstone'").get()).toEqual({ state: 'done', server_deleted_at: expect.any(String) });
const collection = db.prepare('SELECT retained_server_deletions FROM server_launch_collections').get() as { retained_server_deletions: string };
expect(JSON.parse(collection.retained_server_deletions)).toEqual({ tombstone: expect.any(String), gone: expect.any(String) });
});
});
+57
View File
@@ -0,0 +1,57 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
const server = 't5_zombie_theater_1';
const at = '2026-09-27T12:00:00.000Z';
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([server]);
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
await seedServerRegistry();
});
afterEach(async () => fixture.cleanup());
it('exposes retained campaign and provisioning history without rebinding links to a reused name', async () => {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
const { beginDeployment, finishDeployment, getDeployment, listDeployments } = await import('../../src/lib/server/provisioning/history.js');
const operation = beginDeployment(server, 'restart');
finishDeployment(operation, { ok: true });
db().prepare("INSERT INTO deployments (id, action, started_at, finished_at) VALUES ('history-campaign', 'promote', ?, ?)").run(at, at);
db().prepare("INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state) VALUES ('history-campaign', ?, 1, 'done')").run(server);
db().prepare("INSERT INTO deployment_files (deployment_id, server, verb, rel_path, content_before, content_after) VALUES ('history-campaign', ?, 'updated', 'script.gsc', ?, ?)")
.run(server, Buffer.from('before'), Buffer.from('after'));
db().prepare('DELETE FROM servers WHERE name = ?').run(server);
await seedServerRegistry();
closeDb();
const { GET: campaignGET } = await import('../../src/routes/api/campaigns/[id]/+server.js');
const detail = await (await campaignGET({ params: { id: 'history-campaign' } })).json();
expect(detail.servers).toEqual([expect.objectContaining({ server, restart_state: 'done', server_deleted_at: expect.any(String) })]);
expect(detail.files).toEqual([expect.objectContaining({ server, rel_path: 'script.gsc', size_before: 6, size_after: 5 })]);
const { GET: listGET } = await import('../../src/routes/api/campaigns/+server.js');
const list = await (await listGET({ url: new URL('http://localhost/api/campaigns') })).json();
expect(list.deployments[0].servers[0].server_deleted_at).toEqual(expect.any(String));
expect(getDeployment(operation)?.deployment).toMatchObject({ server, state: 'done', server_exists: 0, server_deleted_at: expect.any(String) });
expect(listDeployments()).toContainEqual(expect.objectContaining({ id: operation, server_exists: 0 }));
const replacement = beginDeployment(server, 'restart');
expect(getDeployment(replacement)?.deployment).toMatchObject({ server_exists: 1, server_deleted_at: null });
});
it('rejects historical maintenance retries and allows a new incident for a reused server name', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
const job = db().prepare("INSERT INTO maintenance_jobs (server, requested_at, state, detail) VALUES (?, ?, 'failed', 'Original result')").run(server, at).lastInsertRowid;
db().prepare("INSERT INTO watchdog_incidents (id, server, kind, state, summary, first_seen_at, last_seen_at) VALUES ('old-incident', ?, 'health', 'attention', 'Original finding', ?, ?)")
.run(server, at, at);
db().prepare('DELETE FROM servers WHERE name = ?').run(server);
await seedServerRegistry();
const { retryMaintenance, plutoniumStatus } = await import('../../src/lib/server/maintenance/runtime.js');
const { updateWatchdogIncident, watchdogDetail } = await import('../../src/lib/server/maintenance/recovery.js');
expect(() => retryMaintenance(Number(job))).toThrow('deleted');
expect(plutoniumStatus().jobs).toContainEqual(expect.objectContaining({ id: Number(job), state: 'failed', detail: 'Original result', server_deleted_at: expect.any(String) }));
expect(updateWatchdogIncident('old-incident', 'retry')).toMatchObject({ ok: false, status: 409 });
expect(watchdogDetail('old-incident')?.incident).toMatchObject({ state: 'attention', summary: 'Original finding', server_deleted_at: expect.any(String) });
expect(() => db().prepare("INSERT INTO watchdog_incidents (id, server, kind, state, summary, first_seen_at, last_seen_at) VALUES ('new-incident', ?, 'health', 'open', 'New finding', ?, ?)")
.run(server, at, at)).not.toThrow();
});
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { compareMetricValues, metricDuration, metricNumber, metricPercent } from '../../src/lib/ui/metrics.js';
describe('metric presentation', () => {
it('distinguishes unavailable evidence, zero, and tiny nonzero values', () => {
expect(metricPercent(0, 0)).toBe('—');
expect(metricPercent(0, 1)).toBe('0.0%');
expect(metricPercent(0.0001, 1)).toBe('<0.1%');
expect(metricDuration(0)).toBe('0 h');
expect(metricDuration(0.1 / 3600)).toBe('<1 s');
expect(metricDuration(45 / 3600)).toBe('45 s');
expect(metricDuration(90 / 3600)).toBe('1.5 min');
expect(metricNumber(0.0125)).toBe('<0.1');
});
it('sorts numerically while putting unavailable observations last in both directions', () => {
const values = [2, null, 100, 0, 10];
expect([...values].sort((a, b) => compareMetricValues(a, b, 'desc'))).toEqual([100, 10, 2, 0, null]);
expect([...values].sort((a, b) => compareMetricValues(a, b, 'asc'))).toEqual([0, 2, 10, 100, null]);
});
});
+3 -3
View File
@@ -195,7 +195,7 @@ describe('Watchdog detection and ownership', () => {
});
describe('Watchdog recovery persistence', () => {
it('resolves an attention incident after an intentional successful destroy', async () => {
it('preserves the original finding as deleted history after an intentional successful destroy', async () => {
const { removeServerKey } = await import('../../src/lib/server/fleet/server-keys.js');
const { destroyServer, processDestructions } = await import('../../src/lib/server/provisioning/instances.js');
removeServerKey(server);
@@ -209,8 +209,8 @@ describe('Watchdog recovery persistence', () => {
expect(operations()).toEqual([expect.objectContaining({ action: 'destroy', state: 'done' })]);
expect(database.db().prepare('SELECT name FROM servers WHERE name = ?').get(server)).toBeUndefined();
await watchdog.watchdogTick(true);
expect(watchdog.watchdogDetail(id)?.incident).toMatchObject({ state: 'resolved', finished_at: expect.any(String) });
expect(watchdog.watchdogDetail(id)?.events.at(-1)?.message).toMatch(/destroyed|registered|retired/i);
expect(watchdog.watchdogDetail(id)?.incident).toMatchObject({ state: 'attention', server_deleted_at: expect.any(String) });
expect(watchdog.updateWatchdogIncident(id, 'retry')).toMatchObject({ ok: false, status: 409 });
});
it('requests one restart, escalates once to rebuild, and retains exhausted attention across reopening', async () => {