Compare commits
11
Commits
45e9bcd4ce
...
6146bef51d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6146bef51d | ||
|
|
5b3e8cb1a3 | ||
|
|
70185eb98c | ||
|
|
0ed3cd6b47 | ||
|
|
05bdeaf624 | ||
|
|
c39563daef | ||
|
|
bcecc42dd1 | ||
|
|
bab78038dc | ||
|
|
e4f37f6964 | ||
|
|
9999e80226 | ||
|
|
e35b84475d |
@@ -39,9 +39,9 @@ Open the dashboard and create your first administrator with the setup token.
|
||||
Remove the token from `.env` afterward and recreate the controller with
|
||||
`docker compose up -d`.
|
||||
|
||||
1. Upload your T5 or T6 folder, including DLC, under **Library → Game Files**.
|
||||
1. Upload your T5 or T6 folder, including DLC, under **Releases → Game files**.
|
||||
2. Add a machine under **Fleet → Hosts** and run its generated enrollment command.
|
||||
3. Save your Plutonium keys under **Administration → Keys**.
|
||||
3. Save your Plutonium keys under **Admin → Keys**.
|
||||
4. Wait for host setup to finish, then launch a server or collection.
|
||||
|
||||
See the [setup guide](docs/guides/getting-started.md) for telemetry, storage,
|
||||
|
||||
@@ -26,6 +26,54 @@ separate database when testing enrollment. HTTP login is for local development;
|
||||
production agent transport requires HTTPS. See [authentication](../guides/authentication.md)
|
||||
for access and recovery details.
|
||||
|
||||
## Synthetic UI preview
|
||||
|
||||
Create a disposable fixture without connecting game hosts:
|
||||
|
||||
```sh
|
||||
fixture_parent=$(mktemp -d "${TMPDIR:-/tmp}/ec-ui.XXXXXX")
|
||||
npm run seed:fixture -- --db "$fixture_parent/preview/cherry.db" --watch
|
||||
```
|
||||
|
||||
The command prints the generated paths. Leave that terminal open to refresh
|
||||
synthetic observations every 15 seconds. In a second terminal, load the printed
|
||||
environment file and start the controller on localhost:
|
||||
|
||||
```sh
|
||||
source /absolute/path/printed/above/preview.env
|
||||
npm run dev -- --host 127.0.0.1 --port 3000
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:3000/login`. The private `fixture.json` next to the database
|
||||
contains the `demo` account's generated password. It is not printed in command
|
||||
output. To run a fixed snapshot instead of a live feeder, use a fresh directory
|
||||
and replace `--watch` with `--now 2026-10-07T12:00:00Z`. Domain data is deterministic
|
||||
for that timestamp; account credentials are always generated separately. Old
|
||||
snapshots naturally show stale observations when viewed against today's clock.
|
||||
|
||||
The fixture contains 3 hosts, 12 canonical Black Ops I/II servers, and 500 match
|
||||
records: 494 historical and 6 live. It includes solo through four-player squads,
|
||||
unknown starting counts, enough players and matches to exercise pagination,
|
||||
rosters, seven days of utilization and capacity samples, and retained Activity
|
||||
from campaigns, betas, host operations, maintenance, recovery, and autoscaling.
|
||||
The fleet has 6 live matches, 2 empty lobbies, a preparing server, and 3 problems:
|
||||
a stopped server, a server not responding, and a server on the offline host.
|
||||
Script and config downloads contain harmless synthetic text.
|
||||
|
||||
`--db` must be an absolute path in a **new directory** whose parent exists. The
|
||||
seeder refuses existing directories, databases, sidecars and symlinks, and never
|
||||
uses an inherited `DB_PATH`. It runs the application's database migrations with
|
||||
isolated paths, without starting the web server or workers. A failed seed removes
|
||||
only its newly reserved directory. Completed fixtures remain after Ctrl+C.
|
||||
|
||||
The preview controller runs its usual local workers. Automatic maintenance and
|
||||
autoscaling are paused; all seeded operations are terminal. Hostnames, runtime
|
||||
images, library IDs and credentials are synthetic, so there are no runnable game
|
||||
files or authenticated agents. Use this environment only for local UI review.
|
||||
The feeder checks the fixture marker and restores only its known observations,
|
||||
including the deliberately offline host. Stop both terminals when finished and
|
||||
remove the generated fixture directory.
|
||||
|
||||
## Checks and builds
|
||||
|
||||
```sh
|
||||
|
||||
@@ -38,3 +38,72 @@ Preserve visible keyboard focus, associated field labels and separate navigation
|
||||
and action targets. Avoid nested buttons or placing a button inside a link.
|
||||
Allow long server identities to wrap and tab/filter rows to scroll on narrow
|
||||
screens.
|
||||
|
||||
## Navigation
|
||||
|
||||
Use the shared navigation metadata for Fleet, Releases, Activity, Players, and
|
||||
Admin. Campaigns stays under Releases beside Scripts, Configs, Plugins, Game
|
||||
files, and Betas. The Servers page is the Fleet landing page. Desktop navigation
|
||||
uses a collapsible sidebar; mobile navigation uses a modal drawer.
|
||||
|
||||
Use Cmd/Ctrl K to search pages, servers, hosts, players, and recorded matches.
|
||||
Common actions open their existing forms and retain role checks and confirmations.
|
||||
Keyboard shortcut help is available from the account menu and with `?`.
|
||||
|
||||
Detail pages use `Breadcrumbs`. Its parent link restores the validated `returnTo`
|
||||
URL, including filters, sorting, pagination, and anchors. Supply `fallback` when
|
||||
the default parent also needs filters. Do not add a duplicate Back link.
|
||||
|
||||
### Activity history
|
||||
|
||||
Use `ActivityLog` for the fleet timeline and scoped history. The server History
|
||||
tab fixes the server filter; Maintenance fixes the maintenance/recovery source.
|
||||
The API counts and pages retained records together, with stable ordering. Parent
|
||||
operations represent their host receipts and maintenance verification so a single
|
||||
user action does not appear multiple times. Routine successful host checks stay
|
||||
in the technical Host operation history. Keep detail links contextual so their
|
||||
parent breadcrumb restores filters and pagination.
|
||||
|
||||
### Type and numbers
|
||||
|
||||
Use the shared `--text-*` scale, with 12 px as the minimum. Mobile inputs use
|
||||
`--text-input` (16 px) to avoid browser zoom. `npm run check:styles`, also included
|
||||
in `npm run check`, rejects raw pixel font sizes. Use Inter with tabular figures
|
||||
for counts, scores, durations and charts; reserve mono for code, IDs, paths and
|
||||
logs. Static values use text or state colors; the accent marks links and controls.
|
||||
|
||||
### Product language
|
||||
|
||||
| Concept | Visible label |
|
||||
| --- | --- |
|
||||
| A managed game instance | Server; use the friendly map and lobby name |
|
||||
| Creating a server | Create server |
|
||||
| Removing a server | Remove server |
|
||||
| Favourite server | Pin |
|
||||
| Game does not answer | Not responding |
|
||||
| Games | Black Ops I / Black Ops II; BO1 / BO2 in compact labels |
|
||||
| Releasing file changes | Campaigns |
|
||||
| Player completion evidence | Present in final report / Not present in final report |
|
||||
|
||||
Keep internal values such as `t5`, `t6`, `destroy`, instance IDs, and filenames in
|
||||
API fields and technical details. User-facing actions use the labels above.
|
||||
Player presence in the final report does not establish a win or continuous
|
||||
survival. A missing timestamp is **Not recorded**, not **Never**.
|
||||
|
||||
Use `RelativeTime` for observed history: seconds, minutes or hours for the last
|
||||
24 hours, Yesterday for the preceding calendar day, then the shared absolute
|
||||
format. It includes the full timestamp in a tooltip and follows the account's
|
||||
timezone. Scheduled future actions use `timeFormat.dateTime` directly. Database
|
||||
timestamps without an offset are UTC; never parse them as browser-local time.
|
||||
Receipt freshness uses the browser clock and shows Paused or a retry reason
|
||||
when updates are unavailable. These clocks are not live regions.
|
||||
|
||||
Use `plural` or `countLabel` from `src/lib/ui/plural.ts` for count-dependent
|
||||
language, including verbs: `plural(count, 'needs', 'need')`. The helper uses
|
||||
`Intl.PluralRules`; supply irregular plural forms explicitly. Keep separators
|
||||
inside string expressions when a Svelte conditional would trim their leading
|
||||
space, for example `{offline ? ' · Offline' : ''}`.
|
||||
|
||||
Recommend an agent upgrade only when its reported version is known to lack a
|
||||
needed capability. Missing calibration samples can simply mean the host is
|
||||
still collecting measurements.
|
||||
|
||||
@@ -20,7 +20,7 @@ their access checks. HTTPS requirements still apply. See the
|
||||
password. Only the holder of the token can create the first administrator.
|
||||
4. Remove `EC_SETUP_TOKEN` after setup and restart/recreate the controller.
|
||||
Setup remains closed once any user exists, even if the token is still set.
|
||||
5. Use **Administration → Users** to create additional accounts. Share temporary
|
||||
5. Use **Admin → Users** to create additional accounts. Share temporary
|
||||
passwords privately; each user must change theirs on first login.
|
||||
|
||||
Existing installations become protected on upgrade. Existing game servers and
|
||||
|
||||
@@ -37,7 +37,7 @@ It fills deficits without changing existing servers' files.
|
||||
plan. Review the change summary and the map table grouped by game.
|
||||
4. Check the required key coverage for each game. Available keys count once
|
||||
across the full lineup, and every new server needs a key. If a game is short,
|
||||
open **Administration → Keys** to add keys or wait for assigned keys to be
|
||||
open **Admin → Keys** to add keys or wait for assigned keys to be
|
||||
released. Returning to the dialog rechecks the pools and fleet.
|
||||
5. Review the capacity summary. Expand its details for CPU, memory, ports, and
|
||||
startup slots. Capacity estimates are advisory: queued launches can wait for
|
||||
@@ -142,7 +142,7 @@ All collections and individual launches share one startup queue, ordered by requ
|
||||
time. By default, one server starts at a time. Its startup slot remains occupied
|
||||
until the host agent confirms container health, game status and the selected map
|
||||
are verified, and RCON reports a current Plutonium revision.
|
||||
Only then can the next queued server start. Set **Concurrent server startups** in **Administration → Settings** to
|
||||
Only then can the next queued server start. Set **Concurrent server startups** in **Admin → Settings** to
|
||||
a positive integer to allow more simultaneous startups. Each worker pass admits
|
||||
at most the available slots, even if an attempt fails immediately.
|
||||
|
||||
@@ -190,7 +190,7 @@ against the remaining fleet.
|
||||
2. Choose a game and map. The dialog automatically checks the current promoted
|
||||
setup, latest game files, available lobby, and game key pool as selections change.
|
||||
3. Review the readiness summary and resolve any displayed blockers. When no game
|
||||
key is available, open **Administration → Keys** and add one or wait for a key
|
||||
key is available, open **Admin → Keys** and add one or wait for a key
|
||||
to be released. Returning to the launch dialog rechecks availability; **Retry checks**
|
||||
also runs the checks again.
|
||||
4. If needed, expand **Advanced settings** for runtime and network overrides.
|
||||
|
||||
@@ -46,13 +46,13 @@ Proxy the HTTPS address to controller port 3000. Open the dashboard and follow
|
||||
`.env` and run `docker compose up -d` to recreate the controller. Setup stays
|
||||
closed once an account exists.
|
||||
|
||||
Add other users under **Administration → Users**. The
|
||||
Add other users under **Admin → Users**. The
|
||||
[access guide](authentication.md) explains roles, proxy configuration, and
|
||||
password recovery.
|
||||
|
||||
## Prepare a game host
|
||||
|
||||
1. Open **Library → Game Files** and upload the complete local folder for T5 or
|
||||
1. Open **Releases → Game files** and upload the complete local folder for T5 or
|
||||
T6, including DLC. Uploads can run before any host is enrolled. The latest
|
||||
verified upload becomes that game's default files.
|
||||
2. Open **Fleet → Hosts → Add Host**, configure the host's game-port range and
|
||||
@@ -67,7 +67,7 @@ base game files; each instance has its own writable data. See
|
||||
|
||||
## Launch your first server
|
||||
|
||||
1. Save Plutonium server keys in the T5 or T6 pool under **Administration → Keys**.
|
||||
1. Save Plutonium server keys in the T5 or T6 pool under **Admin → Keys**.
|
||||
2. Open **Create server** and choose a game and map. Readiness checks run
|
||||
automatically. For a reusable lineup, use **Fleet → Collections** instead.
|
||||
3. Resolve any launch blockers and choose **Launch server**. Provisioning queues
|
||||
|
||||
+16
-12
@@ -1,10 +1,14 @@
|
||||
# Leaderboard
|
||||
|
||||
Open **Leaderboard** in the main navigation to compare players across the fleet,
|
||||
with one entry per GUID combining Black Ops I (T5) and Black Ops II (T6). Game
|
||||
and map filters limit which matches contribute to each player's statistics and
|
||||
with one entry per GUID combining Black Ops I (T5) and Black Ops II (T6). Game, map,
|
||||
and starting squad-size filters limit which matches contribute to each player's statistics and
|
||||
rank. Choose a ranking, search a name or GUID, and share the page URL with its
|
||||
filters intact. Results refresh while the page is visible.
|
||||
filters intact. Results refresh while the page is visible. The page opens on a
|
||||
recorded map by default; choose **All maps** for a fleet-wide comparison. A URL
|
||||
with `map=` explicitly selects all maps. **Players** selects Solo, Duo, Trio or
|
||||
Four players using the recorded starting roster; unknown starting counts appear
|
||||
only under All squad sizes. The JSON API still defaults to all maps.
|
||||
|
||||
## Qualifying matches
|
||||
|
||||
@@ -29,13 +33,13 @@ rounds per game, and completion rate. Each map has its own complete set of
|
||||
records; map records and match history identify their game. Match history shows
|
||||
individual counters, server, date and completion credit in pages of 25 matches
|
||||
across both games. Map names link back to that map's leaderboard in its game.
|
||||
Use **Refresh stats** to load the latest reports. Ratios with a zero denominator
|
||||
Profiles update every 30 seconds while visible; the freshness indicator offers Retry if an update fails. Ratios with a zero denominator
|
||||
display a dash. Lifetime statistics and per-map records always cover all recorded
|
||||
maps across both games.
|
||||
|
||||
Opening a player from the leaderboard preserves the originating game, map,
|
||||
Opening a player from the leaderboard preserves the originating game, map, squad size,
|
||||
metric, search and page in the **Leaderboard** return link. The profile's
|
||||
**Selected leaderboard** controls initialize from that game/map/metric and show
|
||||
**Filters** controls initialize from that game/map/squad/metric and show
|
||||
the player's rank within that competition, independent of the original search.
|
||||
These controls also filter match history; lifetime totals stay unchanged.
|
||||
History pagination retains the selection and return link. Map links preserve
|
||||
@@ -43,9 +47,9 @@ the selected ranking metric. A player without qualifying matches in a selection
|
||||
can still view their lifetime profile and choose another scope.
|
||||
|
||||
On phones, per-map records and match history use compact cards with the map,
|
||||
game, highest round and kills up front. Expand **All stats and record runs**
|
||||
or **More match stats** for the remaining counters and source evidence. Desktop
|
||||
profiles retain the complete tables; both views use the same history filters
|
||||
game, record values and match identity up front. Record values link directly to
|
||||
their completed match, where the full evidence is available. Desktop profiles
|
||||
use compact tables; both views use the same history filters
|
||||
and pagination. History dates say **First observed**, since a missing start
|
||||
report can make the first recorded observation later than the actual game start.
|
||||
|
||||
@@ -53,9 +57,8 @@ report can make the first recorded observation later than the actual game start.
|
||||
|
||||
Highest-round, best-game-kill and highest-score records link to the completed
|
||||
match that earned them on the leaderboard and in lifetime/per-map profile stats.
|
||||
Each source shows its map, game, completion time, server, and whether the match
|
||||
start was recorded. **Run details** shows when the player was first observed:
|
||||
at the recorded start, at a reported round, or unknown for older stored records.
|
||||
Each source names its match and map. Open the match to see completion time,
|
||||
server, whether its start was recorded, and player participation evidence.
|
||||
Presence is not proof of continuous survival. Equal personal bests link to the
|
||||
earliest recorded completion, then the lowest match ID when times tie.
|
||||
|
||||
@@ -206,6 +209,7 @@ Use URL encoding for map keys and search text.
|
||||
| --- | --- | --- |
|
||||
| `game` | Both games | Exactly `t5` or `t6`. An empty value is invalid. |
|
||||
| `map` | All maps | Opaque `maps[].key`, at most 1024 characters. Empty means no map filter. An unknown key or a game/map mismatch returns an empty selection, not an error. |
|
||||
| `players` | All squad sizes | Exactly `1`, `2`, `3`, or `4`, matching the recorded starting player count. Unknown counts contribute only when this filter is absent. |
|
||||
| `metric` | `highestRound` | One of the metric keys below. An empty or unknown value is invalid. |
|
||||
| `q` | No search | At most 100 characters before trimming. Matches a literal substring of the current name or GUID, ignoring ASCII letter case. Surrounding whitespace is trimmed; whitespace-only input means no search. `%` and `_` are literal characters, not wildcards. |
|
||||
| `limit` | `50` | Integer from 1 to 100. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintenance
|
||||
|
||||
Open **Operations → Maintenance** to keep existing servers healthy and current.
|
||||
Open **Activity → Maintenance** to keep existing servers healthy and current.
|
||||
Maintenance owns observations, policies, schedules, requests, and verification of
|
||||
maintenance outcomes. Provisioning owns lifecycle execution, player checks,
|
||||
conflicts, rolling capacity, container recovery, and persistent operation history.
|
||||
@@ -10,12 +10,18 @@ Campaigns retain managed-file deployment evidence; Collections define lineups.
|
||||
|
||||
- **Overview** shows active work, attention, upcoming restarts, and Plutonium version
|
||||
coverage. Filter by capability or server. **Check now** refreshes health, drift,
|
||||
the production release, and running revisions.
|
||||
the production release, and running revisions. Failed checks are grouped by
|
||||
cause with links to affected servers; raw paths and responses stay in
|
||||
**Technical details**. Identical revision states share one summary row. Expand
|
||||
it for each server’s check and pending restart, or use **Retry version check**
|
||||
when the running revision is unverified.
|
||||
- **Policies & schedules** controls health recovery, configuration drift, automatic
|
||||
Plutonium updates, and the optional daily restart schedule independently.
|
||||
- **Activity** combines recovery findings, update results, and scheduled restarts.
|
||||
Follow a finding for evidence and attempts, or its Provisioning operation for
|
||||
execution progress. Cancel unsubmitted runtime restarts here.
|
||||
- **Activity** uses the shared Activity log, scoped to maintenance and recovery.
|
||||
Filter retained findings, update results, and scheduled restarts in one timeline.
|
||||
Follow a finding for evidence or an operation for execution progress. Use
|
||||
Overview’s **Active work & attention** to cancel waiting work or retry failed
|
||||
verification after inspection.
|
||||
|
||||
Fleet and server details link their revision indicators to Maintenance. Failed
|
||||
runtime maintenance appears in Fleet attention, even when the underlying restart
|
||||
@@ -98,7 +104,7 @@ Schedules survive application restarts. Missed days, including while globally
|
||||
paused, become one run on resume. Pending work is deduplicated per server. The
|
||||
worker starts on the first application request, so keep the app running and
|
||||
reachable. Finished scheduled and legacy maintenance activity is retained for
|
||||
30 days by default (configurable in Administration → Settings); update attempt records remain durable.
|
||||
30 days by default (configurable in Admin → Settings); update attempt records remain durable.
|
||||
|
||||
## Health recovery and configuration drift
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ 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 default to 31 days of retention; Administration → Settings can change this period. Cleanup runs during collection and in the background. They survive removal of a server and contain no player identities. The additive database table is created automatically on startup.
|
||||
Collection begins after this feature is installed; previous occupancy cannot be reconstructed. Samples default to 31 days of retention; Admin → Settings can change this period. Cleanup runs during collection and in the background. They survive removal of a server and contain no player identities. The additive database table is created automatically on startup.
|
||||
|
||||
## API
|
||||
|
||||
|
||||
@@ -555,7 +555,7 @@ on heartbeats, including vanished caches. The latest published version of each g
|
||||
is always retained. With agent 1.4.7 or newer, an older host copy is automatically
|
||||
eligible for removal once no live instance reservation on that host references it.
|
||||
The game-file minimum count, supersession grace period and explicit protections in
|
||||
Administration → Settings also apply. Stopped,
|
||||
Admin → Settings also apply. Stopped,
|
||||
provisioning, and rebuilding instances still pin their version. The agent also
|
||||
refuses deletion if any Docker container (including stopped containers) mounts
|
||||
those files. Cleanup includes partial downloads and retries after interruption.
|
||||
|
||||
+17
-12
@@ -1,11 +1,16 @@
|
||||
# Provisioning
|
||||
|
||||
Open **Operations → Provisioning** for instance lifecycle work and persistent
|
||||
activity. **Fleet → Collections** owns reusable lineups and collection launch
|
||||
Open **Activity → Activity log** for retained lifecycle, host, campaign, and
|
||||
maintenance history. Use **Manage servers** from that page or command search
|
||||
for server queues, bulk cancellation, and lifecycle actions. **Fleet → Collections** owns reusable lineups and collection launch
|
||||
progress; their individual operations remain visible in Provisioning.
|
||||
Provisioning owns the queues, safety checks, execution, and
|
||||
recovery for all six actions. Create a server using **Create server**. For an existing
|
||||
server, choose **Manage server** or open its **Server actions** menu.
|
||||
server, use its visible **Restart server** or **Start server** action, or open
|
||||
**More actions**. Only actions supported by current host and container evidence
|
||||
are shown. Confirmations name the server and its current players and round;
|
||||
disruptive actions still wait for a fresh empty-lobby check. **Manage server**
|
||||
on the operations page offers the same actions for a selected server.
|
||||
|
||||
**Create server** opens one dialog with game and map choices followed by automatic
|
||||
readiness checks. Blockers explain what needs attention before launch. Runtime
|
||||
@@ -29,18 +34,18 @@ retries when a response is lost.
|
||||
| Restart | Wait until empty, restart the existing container, and verify container health and RCON. |
|
||||
| Rebuild | Wait until empty, replace the instance through the host agent, preserving its server identity and port while advancing its generation. Verify container health, game status, map, and Plutonium revision. |
|
||||
| Stop | Wait until empty, stop the container, and verify that the host agent reports it stopped. Keep the instance for a later Start. |
|
||||
| Destroy | Wait until empty, remove the container and its files through the host agent, then retire the instance and registration. |
|
||||
| Remove | Wait until empty, remove the container and its files through the host agent, then retire the instance and registration. |
|
||||
|
||||
## Safety and verification
|
||||
|
||||
### Beta ownership and rebuilds
|
||||
|
||||
Servers linked to a running or ready-to-promote beta cannot be destroyed or
|
||||
Servers linked to a running or ready-to-promote beta cannot be removed or
|
||||
rebuilt. Promote or roll back the beta first, including paused or failed test
|
||||
plans. Fleet removal and exact collection launches queue nothing if any server
|
||||
selected for removal belongs to an active beta. Provisioning checks again before
|
||||
teardown; already queued removals wait while their server belongs to an active
|
||||
beta. Opening a beta also requires cancelling or finishing any pending destruction
|
||||
beta. Opening a beta also requires cancelling or finishing any pending removal
|
||||
or rebuild on its selected servers.
|
||||
|
||||
Rebuild requires confirmation because **no instance data is preserved**. Its fresh
|
||||
@@ -83,11 +88,11 @@ same key. Confirmed removal releases it for another launch. Failed or ambiguous
|
||||
creation keeps the assignment while cleanup may still be required.
|
||||
|
||||
An unstarted creation cancelled before dispatch releases its key immediately.
|
||||
Administrators can add and remove available keys in **Administration → Keys**;
|
||||
Administrators can add and remove available keys in **Admin → Keys**;
|
||||
assigned keys cannot be removed. Existing keys are migrated into their game
|
||||
pool, retaining assignments for existing servers and pending work. If a legacy
|
||||
rebuild has an uncertain result and used a different key, both possible keys
|
||||
remain reserved until that server is destroyed. Labels are for identification
|
||||
remain reserved until that server is removed. Labels are for identification
|
||||
only and do not change the hostname registered with Plutonium.
|
||||
|
||||
### Player checks and administrator overrides
|
||||
@@ -210,7 +215,7 @@ heartbeat or completion-time promises.
|
||||
`DELETE /api/servers/:name`.
|
||||
- `/api/provisioning/:id`, `/:id/resume`, `/:id/cancel`, `/preview`, and
|
||||
`/house-party` preserve the existing operation, recovery, removal cancellation,
|
||||
launch preview, and fleet destruction APIs.
|
||||
launch preview, and fleet removal APIs.
|
||||
- `POST /api/provisioning/:id/cancel` cancels any active create, start, restart,
|
||||
rebuild, stop, or destroy job, including health verification. It releases that
|
||||
job's recovery capacity and prevents further steps; completed effects are not
|
||||
@@ -248,9 +253,9 @@ See [native host management](native-hosts.md) for command delivery and recovery.
|
||||
|
||||
## Concurrency limits
|
||||
|
||||
Administration → Settings separates Concurrency and Retention, with independent
|
||||
save actions. Existing fleet restart, startup and destruction limits continue to
|
||||
apply. Additional per-host restart, startup and destruction limits, a combined
|
||||
Admin → Settings groups Concurrency separately from history and storage settings,
|
||||
with one Save changes action for all pending edits. Existing fleet restart, startup
|
||||
and removal limits continue to apply. Additional per-host restart, startup and removal limits, a combined
|
||||
lifecycle limit, a rebuild limit, and a limit on hosts preparing game files default
|
||||
to 0 (no additional cap), preserving existing throughput on upgrade.
|
||||
|
||||
|
||||
+18
-6
@@ -1,18 +1,30 @@
|
||||
# Fleet settings
|
||||
|
||||
Administration → Settings has independent **Concurrency** and **Retention**
|
||||
sections. Settings persist in SQLite, apply without restarting the controller,
|
||||
and are writable only by administrators. Saving one section preserves the other
|
||||
section's saved values. Upgrades preserve existing values and add new defaults.
|
||||
Admin → Settings groups fleet limits and retention by the work they affect.
|
||||
Search by name or description, or choose a category in the section list. Each row
|
||||
shows its default, effect, and whether it has changed. Switching categories or
|
||||
searching keeps your pending edits.
|
||||
|
||||
The sticky **Save changes** bar saves all pending changes together. **Discard**
|
||||
restores the last saved values. Settings persist in SQLite, apply without restarting
|
||||
the controller, and are writable only by administrators. Upgrades preserve existing
|
||||
values and add new defaults.
|
||||
|
||||
Use **Forever** for history with no age expiry and **No limit** for optional
|
||||
concurrency or telemetry count caps. **No age expiry** on artifact versions still
|
||||
allows the version-count policy to remove old unused versions. **No delay** applies
|
||||
to the game-file grace period; **No minimum** applies to minimum retained counts.
|
||||
Turning these modes off restores the last finite value. Numeric fields show the
|
||||
unit beside the value.
|
||||
|
||||
## Concurrency
|
||||
|
||||
The existing fleet restart, startup and destruction limits default to 1. Additional
|
||||
The existing fleet restart, startup and removal limits default to 1. Additional
|
||||
limits default to 0 (no additional cap):
|
||||
|
||||
- Restarts per host, including starts and rebuild recovery.
|
||||
- Startups per host, including creation and rebuilds.
|
||||
- Destructions per host, including migration source removal.
|
||||
- Removals per host, including migration source removal.
|
||||
- Combined lifecycle work across create/start/stop/restart/rebuild/destroy.
|
||||
- Rebuilds across the fleet, including host drain migrations.
|
||||
- Hosts downloading or verifying game files simultaneously, including manual imports.
|
||||
|
||||
+4
-3
@@ -8,12 +8,12 @@
|
||||
"dev": "vite dev",
|
||||
"build": "vite build && npm run build:reports -- --outDir build/reports && npm run build:launch -- --outDir build/launch",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check": "npm run check:styles && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"benchmark:leaderboard": "node scripts/benchmark-leaderboard.mjs",
|
||||
"diagnostics:database": "node scripts/database-diagnostics.mjs",
|
||||
"seed:fixture": "tsx scripts/seed-fixture.ts",
|
||||
"seed:fixture": "node --import tsx scripts/seed-fixture.ts",
|
||||
"agent:package": "python3 agent/package_release.py",
|
||||
"prebuild": "npm run agent:package",
|
||||
"test:agent": "python3 -m unittest discover -s agent/tests -v",
|
||||
@@ -21,7 +21,8 @@
|
||||
"build:launch": "vite build --config vite.launch.config.ts",
|
||||
"predev": "npm run build:reports && npm run build:launch",
|
||||
"pretest": "npm run build:reports && npm run build:launch",
|
||||
"pretest:watch": "npm run build:reports && npm run build:launch"
|
||||
"pretest:watch": "npm run build:reports && npm run build:launch",
|
||||
"check:styles": "node scripts/check-ui-styles.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/alfa-slab-one": "^5.3.0",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = fileURLToPath(new URL('../src/', import.meta.url));
|
||||
const failures = [];
|
||||
async function visit(directory) {
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) await visit(file);
|
||||
else if (/\.(svelte|css)$/.test(entry.name) && entry.name !== 'tokens.css') {
|
||||
const source = await readFile(file, 'utf8');
|
||||
for (const match of source.matchAll(/font-size\s*:\s*[\d.]+px\b/g)) {
|
||||
const line = source.slice(0, match.index).split('\n').length;
|
||||
failures.push(`${path.relative(process.cwd(), file)}:${line}: use a shared type token (12 px minimum): ${match[0]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await visit(root);
|
||||
const tokens = await readFile(path.join(root, 'lib/ui/styles/tokens.css'), 'utf8');
|
||||
for (const match of tokens.matchAll(/--text-(?:xs|sm|base|md|lg|xl|2xl|3xl|input):\s*([\d.]+)px/g)) {
|
||||
if (Number(match[1]) < 12) failures.push(`Type tokens must be at least 12 px: ${match[0]}`);
|
||||
}
|
||||
if (failures.length) { console.error(failures.join('\n')); process.exitCode = 1; }
|
||||
else console.log('UI styles: shared font sizes and 12 px minimum verified.');
|
||||
@@ -0,0 +1,235 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { zombieMapBySlug } from '../src/lib/map-aliases.js';
|
||||
|
||||
export const FIXTURE_KEY = 'development_fixture_v1';
|
||||
export const FIXTURE_VERSION = 'electric-cherry-ui-v1';
|
||||
const MINUTE = 60_000;
|
||||
const DAY = 24 * 60 * MINUTE;
|
||||
const GIB = 1024 ** 3;
|
||||
const image = 'fixture.invalid/electric-cherry/preview:local';
|
||||
|
||||
type Scenario = { game: 't5' | 't6'; slug: string; players: number; round: number; minutes: number; state: 'active' | 'idle' | 'stopped' | 'stale' | 'starting' | 'host-offline' };
|
||||
const scenarios: Scenario[] = [
|
||||
{ game: 't5', slug: 'theater', players: 4, round: 27, minutes: 76, state: 'active' },
|
||||
{ game: 't5', slug: 'cosmodrome', players: 2, round: 16, minutes: 39, state: 'active' },
|
||||
{ game: 't5', slug: 'moon', players: 0, round: 0, minutes: 0, state: 'idle' },
|
||||
{ game: 't5', slug: 'temple', players: 3, round: 32, minutes: 84, state: 'active' },
|
||||
{ game: 't5', slug: 'pentagon', players: 0, round: 0, minutes: 0, state: 'stopped' },
|
||||
{ game: 't5', slug: 'coast', players: 0, round: 0, minutes: 0, state: 'stale' },
|
||||
{ game: 't6', slug: 'tomb', players: 3, round: 22, minutes: 58, state: 'active' },
|
||||
{ game: 't6', slug: 'town', players: 2, round: 12, minutes: 23, state: 'active' },
|
||||
{ game: 't6', slug: 'buried', players: 0, round: 0, minutes: 0, state: 'idle' },
|
||||
{ game: 't6', slug: 'prison', players: 4, round: 41, minutes: 119, state: 'active' },
|
||||
{ game: 't6', slug: 'nuked', players: 0, round: 0, minutes: 0, state: 'starting' },
|
||||
{ game: 't6', slug: 'highrise', players: 0, round: 0, minutes: 0, state: 'host-offline' }
|
||||
];
|
||||
|
||||
export const fixtureServers = scenarios.map((scenario, index) => ({ ...scenario,
|
||||
name: `${scenario.game}_zombie_${scenario.slug}_1`, host: `fixture-host-${index === 11 ? 3 : index % 2 + 1}`,
|
||||
instance: `fixture-instance-${index + 1}`, port: 28960 + index,
|
||||
map: zombieMapBySlug(scenario.game, scenario.slug)!
|
||||
}));
|
||||
const names = ['Jamie Cross', 'Alex', 'Sam', 'Morgan', 'Riley', 'Taylor', 'Jordan', 'Casey', 'Quinn', 'Avery', 'Drew', 'Kai'];
|
||||
const iso = (at: number) => new Date(at).toISOString();
|
||||
const player = (index: number) => ({ guid: index === 0 ? 'fixture-jamie' : `fixture-player-${index}`,
|
||||
name: index === 0 ? names[0] : `${names[index % names.length]} ${Math.floor(index / names.length) + 1}` });
|
||||
const rosterFor = (server: typeof fixtureServers[number], index: number) => Array.from({ length: server.players }, (_, slot) => ({ ...player(slot === 0 ? 0 : index * 4 + slot),
|
||||
ping: 29 + slot * 7, kills: (slot + 1) * 137, headshots: (slot + 1) * 41, downs: slot % 2, revives: slot, score: (slot + 1) * 4100 }));
|
||||
const seenAt = (server: Scenario, now: number) => iso(now - (['stopped', 'host-offline'].includes(server.state) ? 15 * MINUTE : server.state === 'stale' ? 105_000 : 0));
|
||||
const containerState = (server: Scenario) => server.state === 'stopped' ? 'exited' : server.state === 'starting' ? 'starting' : 'running';
|
||||
const instanceStates = (host: string, now: number) => fixtureServers.filter(server => server.host === host).map(server => ({
|
||||
instanceId: server.instance, generation: 1, state: containerState(server), healthy: !['stopped', 'starting'].includes(server.state),
|
||||
observedAt: iso(now - (host === 'fixture-host-3' ? 10 * MINUTE : 0))
|
||||
}));
|
||||
|
||||
/** Uses only a caller-owned, fully migrated database. No host or worker code is imported. */
|
||||
export function seedFixtureData(handle: Database.Database, now: number): void {
|
||||
if (!Number.isFinite(now)) throw new Error('Invalid fixture time.');
|
||||
const occupied = ['servers', 'leaderboard_matches', 'ec_hosts', 'auth_users', 'scripts', 'server_deployments', 'deployments', 'betas', 'game_file_libraries']
|
||||
.some(table => handle.prepare(`SELECT 1 FROM ${table} LIMIT 1`).get());
|
||||
if (occupied || handle.prepare('SELECT 1 FROM settings WHERE key=?').get(FIXTURE_KEY)) throw new Error('Fixture seeding requires a new database.');
|
||||
const insert = (table: string, values: Record<string, unknown>) => {
|
||||
const keys = Object.keys(values);
|
||||
handle.prepare(`INSERT INTO ${table} (${keys.join(',')}) VALUES (${keys.map(() => '?').join(',')})`).run(...Object.values(values));
|
||||
};
|
||||
const started = iso(now - 30 * DAY);
|
||||
const rememberPlayer = handle.prepare(`INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at) VALUES(?,?,?,?,?)
|
||||
ON CONFLICT(game,guid) DO UPDATE SET first_seen_at=MIN(first_seen_at,excluded.first_seen_at),last_seen_at=MAX(last_seen_at,excluded.last_seen_at)`);
|
||||
handle.transaction(() => {
|
||||
insert('settings', { key: FIXTURE_KEY, value: FIXTURE_VERSION });
|
||||
// Preview observations must never opt into automatic lifecycle changes.
|
||||
for (const [key, value] of Object.entries({ maintenance_paused: '1', maintenance_health: '0', maintenance_drift: '0', maintenance_updates: '0', watchdog_enabled: '0', autoscaling_paused: '1' })) {
|
||||
handle.prepare('INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(key, value);
|
||||
}
|
||||
for (const [index, name] of ['Chicago · Primary', 'Dallas · Overflow', 'Frankfurt · Europe'].entries()) {
|
||||
const id = `fixture-host-${index + 1}`;
|
||||
insert('ec_hosts', { id, name, hostname: `host-${index + 1}.fixture.invalid`, created_at: started, enrolled_at: started,
|
||||
last_heartbeat: iso(now - (index === 2 ? 10 * MINUTE : 0)), inventory: JSON.stringify({ protocolVersion: 1, agentVersion: '1.7.0', cpuCount: 16,
|
||||
cpuUsagePercent: 24 + index * 9, memoryTotal: 32 * GIB, memoryAvailable: (20 - index * 3) * GIB,
|
||||
storageTotal: 500 * GIB, storageAvailable: 240 * GIB, dockerRunning: true, directoriesWritable: true,
|
||||
runtimeImages: [image], libraries: [{ id: 'fixture-t5', state: 'ready' }, { id: 'fixture-t6', state: 'ready' }],
|
||||
capabilities: ['instance.heartbeat-state'], instanceStates: instanceStates(id, now) }) });
|
||||
// Deliberately not a token hash; no bearer token can authenticate as these agents.
|
||||
insert('ec_host_credentials', { id: `fixture-credential-${index + 1}`, host_id: id, token_hash: `invalid-fixture-${index + 1}`, created_at: started });
|
||||
for (let minute = 0; minute < 7 * 1440; minute += 15) {
|
||||
insert('ec_host_resource_samples', { host_id: id, sampled_at: iso(now - minute * MINUTE), cpu_count: 16,
|
||||
cpu_usage_percent: 28 + index * 6 + 10 * Math.sin(minute / 95), memory_total: 32 * GIB,
|
||||
memory_available: Math.round((18 - index * 2 + Math.sin(minute / 110)) * GIB) });
|
||||
}
|
||||
}
|
||||
let liveId = 495;
|
||||
for (const [index, server] of fixtureServers.entries()) {
|
||||
const beginning = iso(now - server.minutes * MINUTE - 12_000);
|
||||
const roster = rosterFor(server, index);
|
||||
insert('servers', { name: server.name, game: server.game, map: server.map.canonical, map_slug: server.slug, game_mode: 'zombie',
|
||||
game_port: server.port, game_id: server.game === 't5' ? 't5sp' : 't6zm', image,
|
||||
created_by: 'development fixture', created_at: started, last_players: server.players, last_map: server.map.canonical, last_gametype: 'zclassic',
|
||||
last_roster: JSON.stringify(roster), last_seen_at: seenAt(server, now),
|
||||
last_checked_at: iso(now), last_error: server.state === 'stale' ? 'timeout: no reply from game server' : null,
|
||||
rcon_failures: server.state === 'stale' ? 2 : 0,
|
||||
container_state: containerState(server),
|
||||
container_checked_at: iso(now), desired_state: server.state === 'stopped' ? 'stopped' : 'running',
|
||||
active_since: server.players ? beginning : null, inactive_since: server.players ? null : iso(now - 5 * MINUTE),
|
||||
game_started_at: server.players ? beginning : null });
|
||||
insert('native_instances', { id: server.instance, server: server.name, host_id: server.host,
|
||||
library_id: `fixture-${server.game}`, image, port: server.port, created_at: started });
|
||||
insert('plutonium_versions', { server: server.name, revision: 4878, checked_at: iso(now), error: null });
|
||||
if (server.state === 'active' || server.state === 'idle') {
|
||||
const matchId = server.players ? liveId++ : null;
|
||||
const payload = { server: server.name, map: server.map.canonical, event: 'heartbeat', round: server.round, players: roster, receivedAt: iso(now),
|
||||
timing: { matchId, players: Object.fromEntries(roster.map(p => [p.guid, { serverJoinedAt: beginning, matchJoinedAt: beginning, roundStarted: 1 }])) } };
|
||||
insert('server_telemetry', { server: server.name, round: server.round, players: server.players, received_at: iso(now), payload: JSON.stringify(payload) });
|
||||
if (matchId !== null) {
|
||||
insert('leaderboard_matches', { id: matchId, server: server.name, game: server.game, map: server.map.canonical,
|
||||
map_key: server.slug, map_name: server.map.friendly, started_at: beginning, last_received_at: iso(now), status: 'active',
|
||||
start_observed: 1, start_players: server.players, highest_round: server.round, last_event: 'heartbeat', last_fingerprint: `fixture-live-${index}` });
|
||||
for (const p of roster) {
|
||||
rememberPlayer.run(server.game, p.guid, p.name, beginning, iso(now));
|
||||
insert('leaderboard_match_players', { match_id: matchId, guid: p.guid, highest_round: server.round, rounds_survived: server.round,
|
||||
kills: p.kills, headshots: p.headshots, downs: p.downs, revives: p.revives, best_score: p.score,
|
||||
last_round: server.round, present: 1, has_stats: 1, completed: 0, first_seen_at: beginning, last_seen_at: iso(now),
|
||||
first_observed_round: 1, present_at_start: 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const phase of ['startup', 'running']) {
|
||||
for (let minute = 0; minute < 60; minute++) insert('ec_instance_resource_samples', {
|
||||
host_id: server.host, instance_id: server.instance, generation: 1, phase, bucket: iso(now - minute * MINUTE),
|
||||
cpu_cores: phase === 'startup' ? 0.7 + index / 40 : 0.25 + index / 50,
|
||||
memory_bytes: Math.round((phase === 'startup' ? 1.1 : 0.7) * GIB) });
|
||||
}
|
||||
}
|
||||
for (let index = 0; index < 494; index++) {
|
||||
// A large Kino duo cohort exercises ranking and profile pagination.
|
||||
const server = fixtureServers[index < 64 ? 0 : index % fixtureServers.length];
|
||||
const squad = index < 64 ? 2 : Math.floor(index / 12) % 4 + 1;
|
||||
const unknownStart = index >= 64 && index % 37 === 0;
|
||||
const interrupted = index >= 64 && index % 31 === 0;
|
||||
const end = now - (180 + index * 37) * MINUTE;
|
||||
const beginning = end - (30 + index % 100) * MINUTE;
|
||||
const round = 10 + index * 7 % 56;
|
||||
insert('leaderboard_matches', { id: index + 1, server: server.name, game: server.game, map: server.map.canonical,
|
||||
map_key: server.slug, map_name: server.map.friendly, started_at: iso(beginning), ended_at: iso(end), last_received_at: iso(end),
|
||||
status: interrupted ? 'interrupted' : 'completed', start_observed: unknownStart ? 0 : 1, start_players: unknownStart ? null : squad,
|
||||
highest_round: round, last_event: interrupted ? 'interrupted' : 'game_end', last_fingerprint: `fixture-match-${index + 1}` });
|
||||
for (let slot = 0; slot < squad; slot++) {
|
||||
const p = player(slot === 0 ? 0 : (index + slot * 19) % 80 + 1);
|
||||
rememberPlayer.run(server.game, p.guid, p.name, iso(beginning), iso(end));
|
||||
insert('leaderboard_match_players', { match_id: index + 1, guid: p.guid, highest_round: round, rounds_survived: round - (unknownStart ? 4 : 0),
|
||||
kills: round * 35 + slot * 71, headshots: round * 9, downs: index % 4, revives: slot + index % 3,
|
||||
best_score: round * 2100 + slot * 1000, last_round: round, present: slot > 0 && index % 9 === 0 ? 0 : 1, has_stats: 1,
|
||||
completed: !interrupted && !(slot > 0 && index % 9 === 0) ? 1 : 0, first_seen_at: iso(beginning), last_seen_at: iso(end),
|
||||
first_observed_round: unknownStart ? 5 : 1, present_at_start: unknownStart ? null : 1 });
|
||||
}
|
||||
}
|
||||
const metric = handle.prepare('INSERT INTO metric_samples(at,server,game,map,players,capacity) VALUES(?,?,?,?,?,4)');
|
||||
for (let minute = 0; minute < 7 * 1440; minute++) {
|
||||
for (const [index, server] of fixtureServers.entries()) {
|
||||
const count = Math.floor((Math.sin(minute / 90 + index) + 1) * 2.49);
|
||||
metric.run(now - minute * MINUTE, server.name, server.game, server.map.friendly,
|
||||
minute % 720 < 8 && index === 5 ? null : count);
|
||||
}
|
||||
}
|
||||
for (const scope of ['*', ...[1, 2, 3].map(index => `fixture-host-${index}`)]) for (const phase of ['startup', 'running']) {
|
||||
insert('ec_resource_allowances', { scope, phase, cpu_cores: phase === 'startup' ? 1 : 0.5,
|
||||
memory_bytes: Math.round((phase === 'startup' ? 1.2 : 0.8) * GIB), samples: 240,
|
||||
window_start: iso(now - DAY), observed_at: iso(now), computed_at: iso(now), usage_margin: 1 });
|
||||
}
|
||||
// Real artifact blobs, so details and downloads work without game files.
|
||||
for (const [index, kind] of ['script', 'baseline', 'config'].entries()) {
|
||||
const content = Buffer.from(kind === 'script' ? '// Synthetic UI fixture; no gameplay behavior.\n' : '// Synthetic UI fixture configuration.\nset sv_maxclients 4\n');
|
||||
const hash = createHash('sha256').update(content).digest('hex');
|
||||
handle.prepare('INSERT OR IGNORE INTO file_blobs(sha256,size,content) VALUES(?,?,?)').run(hash, content.length, content);
|
||||
insert('scripts', { id: index + 1, name: ['Fixture telemetry', 'Fixture Black Ops I baseline', 'Fixture lobby settings'][index],
|
||||
target_rel_path: index === 0 ? 'fixture.gsc' : index === 1 ? 'dedicated_sp.cfg' : 'fixture.cfg',
|
||||
scope_type: 'game', scope_value: 't5', current_version_id: index + 1, kind, created_at: started });
|
||||
insert('script_versions', { id: index + 1, script_id: index + 1, version: 1, sha256: hash, content_hash: hash,
|
||||
size: content.length, notes: 'Synthetic preview content.', state: 'tested', kind, uploaded_at: started });
|
||||
}
|
||||
for (let index = 0; index < 36; index++) {
|
||||
const failed = index % 7 === 0;
|
||||
const server = fixtureServers[failed ? 5 : index % fixtureServers.length];
|
||||
const id = `fixture-operation-${index + 1}`;
|
||||
const state = failed ? 'failed' : index % 11 === 0 ? 'cancelled' : 'done';
|
||||
insert('server_deployments', { id, server: server.name, action: ['restart', 'start', 'rebuild', 'stop'][index % 4], state,
|
||||
stage: failed ? 'Verification failed' : state === 'done' ? 'Completed' : 'Cancelled before dispatch',
|
||||
started_at: iso(now - (60 + index * 2) * MINUTE), finished_at: iso(now - (59 + index * 2) * MINUTE), triggered_by: 'Fixture operator',
|
||||
error: failed ? 'Synthetic verification timeout. No host command was sent.' : null, warnings: '[]' });
|
||||
insert('native_provisioning', { deployment_id: id, instance_id: server.instance, generation: 1 });
|
||||
insert('server_deployment_events', { deployment_id: id, created_at: iso(now - (59 + index * 2) * MINUTE), message: 'Synthetic retained history; no command was sent.' });
|
||||
}
|
||||
for (let index = 0; index < 8; index++) {
|
||||
const server = fixtureServers[index % fixtureServers.length];
|
||||
insert('ec_host_operations', { id: `fixture-host-command-${index + 1}`, host_id: server.host, instance_id: server.instance,
|
||||
server_name: server.name, kind: index % 2 ? 'instance.inspect' : 'instance.restart', payload: '{}',
|
||||
state: index % 3 ? 'completed' : 'failed', result: '{}', error: index % 3 ? null : 'Synthetic command failure.',
|
||||
created_at: iso(now - (90 + index * 3) * MINUTE), updated_at: iso(now - (89 + index * 3) * MINUTE), source: 'Fixture operator' });
|
||||
}
|
||||
for (let index = 0; index < 3; index++) {
|
||||
const server = fixtureServers[index === 0 ? 1 : index === 1 ? 5 : index];
|
||||
insert('maintenance_jobs', { id: index + 1, server: server.name, state: index === 1 ? 'failed' : 'done', target_revision: 4878,
|
||||
requested_at: iso(now - (120 + index * 4) * MINUTE), finished_at: iso(now - (119 + index * 4) * MINUTE),
|
||||
detail: index === 1 ? 'Synthetic update could not be verified.' : 'Synthetic scheduled restart completed.', kind: 'schedule', origin: 'manual',
|
||||
provisioning_id: index === 0 ? 'fixture-operation-2' : null });
|
||||
insert('watchdog_incidents', { id: `fixture-recovery-${index + 1}`, server: server.name, kind: 'health', state: 'resolved',
|
||||
summary: 'Synthetic recovery finding resolved.', details: '{}', first_seen_at: iso(now - (130 + index * 4) * MINUTE),
|
||||
last_seen_at: iso(now - (129 + index * 4) * MINUTE), finished_at: iso(now - (129 + index * 4) * MINUTE) });
|
||||
insert('deployments', { id: `fixture-campaign-${index + 1}`, action: 'drift', started_at: iso(now - (140 + index * 4) * MINUTE),
|
||||
finished_at: iso(now - (139 + index * 4) * MINUTE), triggered_by: 'Fixture operator' });
|
||||
insert('deployment_servers', { deployment_id: `fixture-campaign-${index + 1}`, server: server.name, files_changed: 1, restart_state: 'done', players: 0 });
|
||||
}
|
||||
for (let index = 0; index < 2; index++) {
|
||||
insert('betas', { id: index + 1, script_id: 1, script_version_id: 1, trigger_kind: 'ui', state: index ? 'rolled-back' : 'promoted',
|
||||
opened_at: iso(now - (150 + index * 3) * MINUTE), closed_at: iso(now - (149 + index * 3) * MINUTE), notes: 'Synthetic completed beta.' });
|
||||
insert('beta_scripts', { beta_id: index + 1, script_id: 1, script_version_id: 1, kind: 'script' });
|
||||
insert('beta_servers', { beta_id: index + 1, server: fixtureServers[index].name, had_original: 0, stage: 'ready', updated_at: iso(now - (149 + index * 3) * MINUTE) });
|
||||
}
|
||||
insert('provisioning_sources', { operation_id: 'fixture-operation-2', kind: 'maintenance', source_id: '1', server: fixtureServers[1].name });
|
||||
insert('provisioning_sources', { operation_id: 'fixture-operation-3', kind: 'autoscaling', source_id: 'fixture-paused-plan', server: fixtureServers[2].name });
|
||||
})();
|
||||
}
|
||||
|
||||
/** Refreshes existing synthetic observations only; never queues work or writes real files. */
|
||||
export function refreshFixtureData(handle: Database.Database, now: number): void {
|
||||
const marker = handle.prepare('SELECT value FROM settings WHERE key=?').get(FIXTURE_KEY) as { value: string } | undefined;
|
||||
if (marker?.value !== FIXTURE_VERSION) throw new Error('Refusing to refresh an unmarked database.');
|
||||
if (!Number.isFinite(now)) throw new Error('Invalid fixture time.');
|
||||
handle.transaction(() => {
|
||||
for (const [index, server] of fixtureServers.entries()) {
|
||||
handle.prepare(`UPDATE servers SET last_seen_at=?,last_checked_at=?,container_checked_at=?,container_state=?,
|
||||
last_players=?,last_map=?,last_roster=?,last_error=?,rcon_failures=?,last_reply_snippet=NULL WHERE name=?`)
|
||||
.run(seenAt(server, now), iso(now), iso(now), containerState(server), server.players, server.map.canonical,
|
||||
JSON.stringify(rosterFor(server, index)), server.state === 'stale' ? 'timeout: no reply from game server' : null,
|
||||
server.state === 'stale' ? 2 : 0, server.name);
|
||||
handle.prepare(`UPDATE server_telemetry SET received_at=?,payload=json_set(payload,'$.receivedAt',?) WHERE server=?`).run(iso(now), iso(now), server.name);
|
||||
handle.prepare("UPDATE leaderboard_matches SET last_received_at=? WHERE server=? AND status='active' AND id BETWEEN 495 AND 500").run(iso(now), server.name);
|
||||
handle.prepare('UPDATE plutonium_versions SET checked_at=? WHERE server=?').run(iso(now), server.name);
|
||||
}
|
||||
for (let index = 1; index <= 3; index++) {
|
||||
const id = `fixture-host-${index}`;
|
||||
handle.prepare("UPDATE ec_hosts SET last_heartbeat=?,inventory=json_set(inventory,'$.instanceStates',json(?)) WHERE id=?")
|
||||
.run(iso(now - (index === 3 ? 10 * MINUTE : 0)), JSON.stringify(instanceStates(id, now)), id);
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { mkdir, open, realpath, rm } from 'node:fs/promises';
|
||||
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
||||
|
||||
export interface FixtureOptions { dbPath: string; now: number; watch: boolean }
|
||||
|
||||
export function parseFixtureOptions(args: string[], clock = Date.now()): FixtureOptions {
|
||||
let dbPath = '', now = clock, watch = false, explicitTime = false;
|
||||
const seen = new Set<string>();
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
const option = args[index];
|
||||
if (seen.has(option)) throw new Error(`Duplicate option: ${option}`);
|
||||
seen.add(option);
|
||||
if (option === '--watch') { watch = true; continue; }
|
||||
if (option !== '--db' && option !== '--now') throw new Error(`Unknown option: ${option}`);
|
||||
const value = args[++index];
|
||||
if (!value || value.startsWith('--')) throw new Error(`Missing value for ${option}.`);
|
||||
if (option === '--db') dbPath = value;
|
||||
else {
|
||||
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(value)
|
||||
|| !Number.isFinite(Date.parse(value))) throw new Error('--now must be an ISO timestamp with a timezone.');
|
||||
now = Date.parse(value); explicitTime = true;
|
||||
}
|
||||
}
|
||||
if (!dbPath || !isAbsolute(dbPath)) throw new Error('Provide --db with an absolute path inside a new fixture directory. DB_PATH is never used.');
|
||||
if (!Number.isFinite(now)) throw new Error('Invalid fixture clock.');
|
||||
if (watch && explicitTime) throw new Error('Use --watch for live time or --now for a fixed snapshot, separately.');
|
||||
return { dbPath: resolve(dbPath), now, watch };
|
||||
}
|
||||
|
||||
/** An atomic new-directory reservation rejects existing DBs, sidecars and symlinks together. */
|
||||
export async function reserveFixture(dbPath: string): Promise<{ root: string; dbPath: string }> {
|
||||
if (!isAbsolute(dbPath)) throw new Error('Fixture database path must be absolute.');
|
||||
const requested = resolve(dbPath);
|
||||
const requestedRoot = dirname(requested);
|
||||
// The destination's parent must already exist. Canonicalize /tmp on macOS,
|
||||
// but never follow a pre-existing fixture directory or database symlink.
|
||||
const parent = await realpath(dirname(requestedRoot));
|
||||
const root = join(parent, basename(requestedRoot));
|
||||
try { await mkdir(root, { mode: 0o700 }); }
|
||||
catch (cause) {
|
||||
if ((cause as NodeJS.ErrnoException).code === 'EEXIST') throw new Error('Fixture directory already exists. Choose a new directory; existing data is never overwritten.');
|
||||
throw cause;
|
||||
}
|
||||
const target = join(root, basename(requested));
|
||||
try {
|
||||
const file = await open(target, 'wx', 0o600);
|
||||
await file.close();
|
||||
} catch (error) {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
return { root, dbPath: target };
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { createServer as createHttpServer } from 'node:http';
|
||||
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createServer, type ViteDevServer } from 'vite';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { FIXTURE_KEY, FIXTURE_VERSION, fixtureServers, refreshFixtureData, seedFixtureData } from './fixture-data.js';
|
||||
import { parseFixtureOptions, reserveFixture } from './fixture-options.js';
|
||||
|
||||
const help = `Create an isolated synthetic UI preview database.
|
||||
|
||||
npm run seed:fixture -- --db /tmp/ec-preview/cherry.db [--now 2026-10-07T12:00:00Z]
|
||||
npm run seed:fixture -- --db /tmp/ec-live-preview/cherry.db --watch
|
||||
|
||||
The database's directory must not exist; its parent must exist. Existing files,
|
||||
databases, symlinks and environment DB_PATH are never reused. --watch refreshes
|
||||
only this new fixture's observations every 15 seconds until Ctrl+C. --now fixes
|
||||
all sample times for reproducible snapshots and cannot be combined with --watch.
|
||||
No web server, worker, game agent or network request is started by this command.
|
||||
`;
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(help); return; }
|
||||
const options = parseFixtureOptions(args);
|
||||
process.umask(0o077);
|
||||
const destination = await reserveFixture(options.dbPath);
|
||||
let vite: ViteDevServer | undefined;
|
||||
let closeDb: (() => void) | undefined;
|
||||
let complete = false;
|
||||
try {
|
||||
const paths = { DB_PATH: destination.dbPath, SERVERS_ROOT: join(destination.root, 't5'), T6_SERVERS_ROOT: join(destination.root, 't6'),
|
||||
BETA_ARCHIVE_DIR: join(destination.root, 'beta-archive'), GAME_FILES_ROOT: join(destination.root, 'game-files'), SQLITE_TMPDIR: join(destination.root, 'sqlite-tmp') };
|
||||
for (const [key, value] of Object.entries(paths)) {
|
||||
process.env[key] = value;
|
||||
if (key !== 'DB_PATH') await mkdir(value, { mode: 0o700 });
|
||||
}
|
||||
Object.assign(process.env, { RCON_PASSWORD: '', RCON_HOST: '127.0.0.1', EC_AUTH_ALLOW_HTTP: 'true',
|
||||
DEPLOY_KEEP_DAYS: '7', DEPLOYMENT_RESTART_CONCURRENCY: '1', DEPLOYMENT_CREATE_CONCURRENCY: '1',
|
||||
EC_SQLITE_MMAP_MB: '0', PLUTAINER_IMAGE: 'fixture.invalid/electric-cherry/preview:local' });
|
||||
const repository = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
vite = await createServer({ root: repository, cacheDir: join(destination.root, 'vite-cache'), configFile: false, envFile: false, appType: 'custom',
|
||||
// Vite 5 otherwise opens its HMR WebSocket port even with hmr:false.
|
||||
// Attach it to an HTTP server that is deliberately never listened on.
|
||||
server: { middlewareMode: true, hmr: { server: createHttpServer() }, watch: null }, optimizeDeps: { noDiscovery: true },
|
||||
resolve: { alias: { $lib: join(repository, 'src/lib'), $ui: join(repository, 'src/lib/ui') } },
|
||||
logLevel: 'error' });
|
||||
// Load the real migrations through Vite's ?raw support. Importing hooks
|
||||
// would start queues and agents; this script never loads the app router.
|
||||
const database = await vite.ssrLoadModule('/src/lib/server/db/index.ts') as { db: () => Database.Database; closeDb: () => void };
|
||||
closeDb = database.closeDb;
|
||||
const handle = database.db();
|
||||
const hosts = await vite.ssrLoadModule('/src/lib/server/hosts/schema.ts');
|
||||
const auth = await vite.ssrLoadModule('/src/lib/server/auth/schema.ts');
|
||||
const files = await vite.ssrLoadModule('/src/lib/server/game-files/schema.ts');
|
||||
hosts.ensureHostSchema(handle); auth.ensureAuthSchema(handle); files.ensureGameFileSchema(handle);
|
||||
seedFixtureData(handle, options.now);
|
||||
const password = randomBytes(24).toString('base64url');
|
||||
const { hashPassword } = await vite.ssrLoadModule('/src/lib/server/auth/passwords.js');
|
||||
handle.prepare(`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
|
||||
VALUES('fixture-admin','demo','Demo operator',?,'admin',?,?)`).run(await hashPassword(password), options.now, options.now);
|
||||
if ((handle.pragma('foreign_key_check') as unknown[]).length || handle.pragma('integrity_check', { simple: true }) !== 'ok') {
|
||||
throw new Error('Fixture integrity validation failed.');
|
||||
}
|
||||
const environment = { ...paths, RCON_PASSWORD: '', RCON_HOST: '127.0.0.1', EC_AUTH_ALLOW_HTTP: 'true',
|
||||
PLUTAINER_IMAGE: process.env.PLUTAINER_IMAGE! };
|
||||
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
||||
await writeFile(join(destination.root, 'preview.env'), Object.entries(environment).map(([key, value]) => `export ${key}=${shellQuote(value)}`).join('\n') + '\n', { flag: 'wx', mode: 0o600 });
|
||||
await writeFile(join(destination.root, 'fixture.json'), JSON.stringify({ format: FIXTURE_VERSION, marker: FIXTURE_KEY,
|
||||
dbPath: destination.dbPath, createdAt: new Date(options.now).toISOString(), username: 'demo', password,
|
||||
hosts: 3, servers: fixtureServers.map(server => server.name), matches: 500,
|
||||
note: 'Synthetic local data only. Host credentials cannot authenticate; game images and library IDs are fictitious.' }, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
||||
handle.pragma('wal_checkpoint(TRUNCATE)');
|
||||
complete = true;
|
||||
console.log(`Created ${destination.dbPath}\n3 hosts · 12 servers · 500 matches · 7 days of metrics\nLocal sign-in details: ${join(destination.root, 'fixture.json')}\nPreview environment: ${join(destination.root, 'preview.env')}`);
|
||||
if (options.watch) {
|
||||
refreshFixtureData(handle, Date.now());
|
||||
console.log('Refreshing synthetic observations every 15 seconds. Leave this terminal open; Ctrl+C stops the feeder.');
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
const finish = (error?: unknown) => { clearInterval(timer); process.off('SIGINT', stop); process.off('SIGTERM', stop); if (error) reject(error); else resolvePromise(); };
|
||||
const stop = () => finish();
|
||||
const timer = setInterval(() => { try { refreshFixtureData(handle, Date.now()); } catch (error) { finish(error); } }, 15_000);
|
||||
process.on('SIGINT', stop); process.on('SIGTERM', stop);
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
try { closeDb?.(); }
|
||||
finally {
|
||||
try { await vite?.close(); }
|
||||
finally { if (!complete) await rm(destination.root, { recursive: true, force: true }); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(`Fixture creation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
export const activityTypes = {
|
||||
create: 'Create server', start: 'Start server', restart: 'Restart server', rebuild: 'Rebuild server',
|
||||
stop: 'Stop server', remove: 'Remove server', update: 'Game update', files: 'File changes',
|
||||
health: 'Health finding', drift: 'File drift', host: 'Host operation', beta: 'Beta test'
|
||||
} as const;
|
||||
export type ActivityType = keyof typeof activityTypes;
|
||||
export const activitySources = {
|
||||
provisioning: 'Server operations', maintenance: 'Maintenance & recovery', recovery: 'Recovery',
|
||||
campaign: 'Campaigns', beta: 'Betas', host: 'Hosts', autoscaling: 'Autoscaling'
|
||||
} as const;
|
||||
export type ActivitySource = keyof typeof activitySources;
|
||||
export const activityStates = {
|
||||
queued: 'Queued', running: 'In progress', succeeded: 'Completed', warning: 'Completed with warnings',
|
||||
failed: 'Failed', cancelled: 'Cancelled', attention: 'Needs attention', unknown: 'Unknown'
|
||||
} as const;
|
||||
export type ActivityState = keyof typeof activityStates;
|
||||
|
||||
export interface ActivityRow {
|
||||
id: string;
|
||||
at: string;
|
||||
type: ActivityType;
|
||||
source: ActivitySource;
|
||||
/** A shared operation can have more than one caller; filters match any membership. */
|
||||
sources: ActivitySource[];
|
||||
state: ActivityState;
|
||||
rawState: string;
|
||||
title: string;
|
||||
detail: string | null;
|
||||
server: string | null;
|
||||
serverDeletedAt: string | null;
|
||||
hostId: string | null;
|
||||
hostName: string | null;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface ActivityQuery {
|
||||
server?: string;
|
||||
host?: string;
|
||||
type?: ActivityType;
|
||||
source?: ActivitySource;
|
||||
state?: ActivityState;
|
||||
days?: number;
|
||||
q?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
export interface ActivityPage {
|
||||
rows: ActivityRow[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
query: ActivityQuery;
|
||||
}
|
||||
|
||||
function selection<T extends string>(value: string | null, labels: Record<T, string>): T | undefined {
|
||||
return value !== null && Object.hasOwn(labels, value) ? value as T : undefined;
|
||||
}
|
||||
function integer(value: string | null, fallback: number, min: number, max: number): number {
|
||||
if (value === null || !/^\d+$/.test(value)) return fallback;
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback;
|
||||
}
|
||||
|
||||
/** Server and host are exact identities; q searches friendly names and safe summaries. */
|
||||
export function parseActivityQuery(params: URLSearchParams): ActivityQuery {
|
||||
const query: ActivityQuery = {
|
||||
limit: integer(params.get('limit'), 25, 1, 100),
|
||||
offset: integer(params.get('offset'), 0, 0, 1_000_000_000)
|
||||
};
|
||||
for (const key of ['server', 'host', 'q'] as const) {
|
||||
const value = params.get(key)?.trim().slice(0, key === 'q' ? 200 : 255);
|
||||
if (value) query[key] = value;
|
||||
}
|
||||
query.type = selection(params.get('type'), activityTypes);
|
||||
query.source = selection(params.get('source'), activitySources);
|
||||
query.state = selection(params.get('state'), activityStates);
|
||||
// Missing or zero days means all retained history, including removed servers.
|
||||
const days = integer(params.get('days'), 0, 0, 36_500);
|
||||
if (days) query.days = days;
|
||||
return query;
|
||||
}
|
||||
|
||||
export function normalizeActivityQuery(query: ActivityQuery): ActivityQuery {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) if (value !== undefined) params.set(key, String(value));
|
||||
return parseActivityQuery(params);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { mapTitle, serverTitle } from './ui/server-labels.js';
|
||||
|
||||
export interface FleetServer {
|
||||
plutonium?: import('./plutonium.js').PlutoniumVersion;
|
||||
host_id?: string | null;
|
||||
host?: Pick<import('./hosts.js').Host, 'id' | 'name' | 'connectionState' | 'connected' | 'lastHeartbeat'> | null;
|
||||
container_state?: string | null;
|
||||
container_checked_at?: string | null;
|
||||
last_checked_at?: string | null;
|
||||
@@ -21,6 +23,8 @@ export interface FleetServer {
|
||||
map_slug?: string | null;
|
||||
game_mode?: string | null;
|
||||
last_players: number | null;
|
||||
capacity?: number | null;
|
||||
capacity_observed_at?: string | null;
|
||||
last_map: string | null;
|
||||
last_gametype: string | null;
|
||||
last_seen_at: string | null;
|
||||
|
||||
@@ -101,10 +101,10 @@ export const fleetSettingLabels: Record<keyof FleetSettings, string> = {
|
||||
observationKeepDays: 'Routine operation history retention',
|
||||
restartConcurrency: 'Concurrent restarts',
|
||||
createConcurrency: 'Concurrent server startups',
|
||||
destroyConcurrency: 'Concurrent server destructions',
|
||||
destroyConcurrency: 'Concurrent server removals',
|
||||
restartConcurrencyPerHost: 'Concurrent restarts per host',
|
||||
createConcurrencyPerHost: 'Concurrent startups per host',
|
||||
destroyConcurrencyPerHost: 'Concurrent destructions per host',
|
||||
destroyConcurrencyPerHost: 'Concurrent server removals per host',
|
||||
lifecycleConcurrency: 'Combined lifecycle limit',
|
||||
rebuildConcurrency: 'Concurrent rebuilds',
|
||||
preparationConcurrency: 'Hosts preparing game files simultaneously',
|
||||
@@ -137,6 +137,56 @@ export function settingMinimum(key: keyof FleetSettings): number {
|
||||
return ['restartConcurrency','createConcurrency','destroyConcurrency','gameFileKeepCount'].includes(key) ? 1 : 0;
|
||||
}
|
||||
|
||||
export interface SettingsGroup {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
keys: (keyof FleetSettings)[];
|
||||
}
|
||||
|
||||
/** Presentation groups do not change the saved schema or the section PATCH API. */
|
||||
export const settingsGroups: SettingsGroup[] = [
|
||||
{ id: 'concurrency', title: 'Concurrency', description: 'Limit simultaneous server operations and game-file preparation.', keys: concurrencyKeys },
|
||||
{ id: 'operation-history', title: 'Operation history', description: 'Campaigns, server operations, maintenance, and routine checks.', keys: [
|
||||
'deployKeepDays', 'provisioningKeepDays', 'actionKeepDays', 'observationKeepDays', 'failureKeepDays',
|
||||
'maintenanceKeepDays', 'recoveryKeepDays', 'betaKeepDays', 'historyKeepCount'
|
||||
] },
|
||||
{ id: 'matches-players', title: 'Matches & players', description: 'Saved matches, player records, and leaderboard history.', keys: ['matchKeepDays'] },
|
||||
{ id: 'telemetry-metrics', title: 'Telemetry & metrics', description: 'Game diagnostics, fleet activity, and host resource samples.', keys: [
|
||||
'telemetryKeepDays', 'telemetryMaxPerServer', 'telemetryMaxTotal', 'metricsKeepDays', 'hostResourceKeepDays', 'instanceResourceKeepDays'
|
||||
] },
|
||||
{ id: 'files-versions', title: 'Files & versions', description: 'File snapshots, saved versions, game files, and abandoned uploads.', keys: [
|
||||
'fileContentKeepDays', 'payloadKeepDays', 'versionKeepDays', 'versionKeepCount', 'gameFileGraceDays', 'gameFileKeepCount', 'uploadKeepDays'
|
||||
] },
|
||||
{ id: 'accounts-audit', title: 'Accounts & audit', description: 'Sign-in and account administration records.', keys: ['auditKeepDays'] }
|
||||
];
|
||||
|
||||
/** Zero has a different meaning for age limits, count caps, and minimums. */
|
||||
export function settingZeroMode(key: keyof FleetSettings): string | null {
|
||||
if (settingMinimum(key) > 0) return null;
|
||||
if (additionalConcurrencyKeys.includes(key) || key === 'telemetryMaxPerServer' || key === 'telemetryMaxTotal') return 'No limit';
|
||||
if (key === 'versionKeepDays') return 'No age expiry';
|
||||
if (key === 'gameFileGraceDays') return 'No delay';
|
||||
if (key === 'versionKeepCount' || key === 'historyKeepCount') return 'No minimum';
|
||||
return key.endsWith('Days') ? 'Forever' : null;
|
||||
}
|
||||
|
||||
export function settingUnit(key: keyof FleetSettings): 'days' | 'records' | 'versions' | 'operations' | 'hosts' {
|
||||
if (key.endsWith('Days')) return 'days';
|
||||
if (key === 'telemetryMaxPerServer' || key === 'telemetryMaxTotal' || key === 'historyKeepCount') return 'records';
|
||||
if (key === 'versionKeepCount' || key === 'gameFileKeepCount') return 'versions';
|
||||
return key === 'preparationConcurrency' ? 'hosts' : 'operations';
|
||||
}
|
||||
|
||||
const settingNumbers = new Intl.NumberFormat('en-US', { maximumFractionDigits: 20 });
|
||||
export function settingDisplayValue(key: keyof FleetSettings, value: number): string {
|
||||
if (!Number.isFinite(value)) return '—';
|
||||
const zeroMode = settingZeroMode(key);
|
||||
if (value === 0 && zeroMode) return zeroMode;
|
||||
const unit = settingUnit(key);
|
||||
return `${settingNumbers.format(value)} ${value === 1 ? unit.slice(0, -1) : unit}`;
|
||||
}
|
||||
|
||||
export const settingHelp: Partial<Record<keyof FleetSettings, string>> = {
|
||||
provisioningKeepDays: 'Completed lifecycle history. Recovery, source workflows and cancellation dependencies remain protected.',
|
||||
actionKeepDays: 'Completed agent actions. Referenced commands and retry evidence remain protected. Expired actions keep compact receipts to prevent duplicate execution.',
|
||||
@@ -147,15 +197,15 @@ export const settingHelp: Partial<Record<keyof FleetSettings, string>> = {
|
||||
instanceResourceKeepDays: 'Minute resource samples. Capacity learning continues to use its independent 24-hour window.',
|
||||
metricsKeepDays: 'Historical fleet occupancy and utilization samples.',
|
||||
telemetryKeepDays: 'Raw received telemetry diagnostics. Independent of saved matches. Count limits also apply.',
|
||||
telemetryMaxPerServer: 'Count cap per server, in addition to the age limit. 0 disables this count cap.',
|
||||
telemetryMaxTotal: 'Fleet-wide count cap for telemetry diagnostics. 0 disables this count cap.',
|
||||
telemetryMaxPerServer: 'Count cap per server, in addition to the age limit.',
|
||||
telemetryMaxTotal: 'Fleet-wide count cap for telemetry diagnostics, in addition to the age limit.',
|
||||
matchKeepDays: 'Completed and interrupted matches and player details. Expiry changes leaderboards and player totals; no lifetime aggregates are preserved.',
|
||||
betaKeepDays: 'Closed betas, revisions and test evidence, after linked campaigns and recovery work no longer need them.',
|
||||
fileContentKeepDays: 'Expired campaign file bodies are removed while summaries remain. Rollback becomes unavailable for those campaigns. Active beta snapshots remain protected.',
|
||||
payloadKeepDays: 'Expire file bodies in completed launch recipes, commands and receipts while keeping their history. Unfinished work and protected records keep their files. 0 keeps files indefinitely.',
|
||||
versionKeepDays: 'Unused script, plugin, configuration and baseline versions. Current and referenced versions are always protected.',
|
||||
payloadKeepDays: 'Expire file bodies in completed launch recipes, commands and receipts while keeping their history. Unfinished work and protected records keep their files.',
|
||||
versionKeepDays: 'Unused script, plugin, configuration and baseline versions. With no age expiry, the version count policy may still remove older unused versions. Current and referenced versions are always protected.',
|
||||
versionKeepCount: 'Keep at least this many recent versions per artifact. With no age expiry, a positive count removes older unused versions.',
|
||||
gameFileGraceDays: 'Delay removal after a version becomes superseded. Latest files, referenced versions and offline host copies remain protected. 0 adds no delay.',
|
||||
gameFileGraceDays: 'Delay removal after a version becomes superseded. Latest files, referenced versions and offline host copies remain protected.',
|
||||
gameFileKeepCount: 'Keep this many recent published versions per game, in addition to every referenced or protected version.',
|
||||
uploadKeepDays: 'Remove incomplete browser uploads after this many days without activity. Active transfers and publication are protected.',
|
||||
auditKeepDays: 'Recorded authentication and account-administration events.',
|
||||
@@ -167,9 +217,9 @@ export const settingHelp: Partial<Record<keyof FleetSettings, string>> = {
|
||||
restartConcurrencyPerHost: 'Additional recovery limit on each host, including starts and rebuilds.',
|
||||
createConcurrencyPerHost: 'Additional startup limit on each host, including rebuilds.',
|
||||
destroyConcurrencyPerHost: 'Additional removal limit on each host, including migration source removal.',
|
||||
lifecycleConcurrency: 'Combined fleet limit for creates, starts, stops, restarts, rebuilds and destructions. Each workflow counts once.',
|
||||
lifecycleConcurrency: 'Combined fleet limit for creates, starts, stops, restarts, rebuilds and removals. Each workflow counts once.',
|
||||
rebuildConcurrency: 'Additional fleet limit for rebuilds, including host drain migrations.',
|
||||
preparationConcurrency: 'Maximum hosts downloading or verifying game files at once. Interrupted transfers keep their slot until reconciled.',
|
||||
deployKeepDays: 'Eligible campaign records and file diffs. Pending, unresolved failed, held and recovery-linked campaigns are protected.',
|
||||
observationKeepDays: 'Successful monitoring and file-read records. Failures and actions are retained. 0 keeps records indefinitely.'
|
||||
observationKeepDays: 'Successful monitoring and file-read records. Failures and actions are retained.'
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export type GameLibraryGame = 't5' | 't6';
|
||||
export const GAME_FILE_GAMES = [
|
||||
{ id: 't5', name: 'Black Ops I (T5)' },
|
||||
{ id: 't6', name: 'Black Ops II (T6)' }
|
||||
{ id: 't5', name: 'Black Ops I' },
|
||||
{ id: 't6', name: 'Black Ops II' }
|
||||
] as const;
|
||||
export type GameLibraryState = 'importing' | 'ready' | 'failed';
|
||||
export type LibraryCacheState = 'missing' | 'downloading' | 'verifying' | 'ready' | 'failed' | 'outdated';
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export const hostOperationTitles: Record<string, string> = {
|
||||
'instance.create': 'Create server container', 'instance.rebuild': 'Rebuild server container',
|
||||
'instance.start': 'Start server container', 'instance.restart': 'Restart server container',
|
||||
'instance.stop': 'Stop server container', 'instance.remove': 'Remove server container',
|
||||
'instance.cancel-create': 'Cancel server creation', 'instance.inspect': 'Check server container',
|
||||
'host.inspect': 'Check host', 'host.decommission': 'Remove host', 'runtime.pull': 'Prepare container runtime',
|
||||
'library.download': 'Download game files', 'library.import': 'Import game files',
|
||||
'plutonium.prepare': 'Prepare game update', 'files.stage': 'Stage server files',
|
||||
'files.apply': 'Apply server files', 'files.read': 'Read server files', 'files.list': 'List server files',
|
||||
'files.stat': 'Check server files', 'files.manifest': 'Check file versions', 'files.remove': 'Remove server files',
|
||||
'rcon.status-batch': 'Check game status', 'rcon.execute': 'Game command', 'logs.read': 'Read server logs'
|
||||
};
|
||||
|
||||
export function hostOperationTitle(kind: string): string {
|
||||
return hostOperationTitles[kind] ?? 'Host operation';
|
||||
}
|
||||
+2
-2
@@ -130,10 +130,10 @@ export interface HostOperationSummary extends Omit<HostOperation, 'payload' | 'r
|
||||
routine: boolean;
|
||||
}
|
||||
export type HostOperationFilter = 'important' | 'routine' | 'all';
|
||||
export interface HostOperationScope { host?: string; server?: string }
|
||||
export interface HostOperationScope { host?: string; server?: string; operation?: string }
|
||||
export function hostOperationScope(params: URLSearchParams): HostOperationScope {
|
||||
const scope: HostOperationScope = {};
|
||||
for (const key of ['host', 'server'] as const) {
|
||||
for (const key of ['host', 'server', 'operation'] as const) {
|
||||
const value = params.get(key);
|
||||
if (value && value.length <= 512 && !/[\x00-\x1f\x7f]/.test(value)) scope[key] = value;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { leaderboardMetrics } from './leaderboard.js';
|
||||
|
||||
const origin = 'https://electric-cherry.invalid';
|
||||
const contextKeys = ['game', 'map', 'metric', 'q', 'offset'] as const;
|
||||
const contextKeys = ['game', 'map', 'players', 'metric', 'q', 'offset'] as const;
|
||||
|
||||
/** Only retain a local leaderboard destination and its supported view state. */
|
||||
export function leaderboardReturnHref(value: string | null | undefined): string {
|
||||
@@ -12,7 +12,7 @@ export function leaderboardReturnHref(value: string | null | undefined): string
|
||||
const params = new URLSearchParams();
|
||||
for (const key of contextKeys) {
|
||||
const entry = url.searchParams.get(key);
|
||||
if (entry) params.set(key, entry);
|
||||
if (entry || key === 'map' && entry !== null) params.set(key, entry!);
|
||||
}
|
||||
return `/leaderboard${params.size ? `?${params}` : ''}`;
|
||||
} catch { return '/leaderboard'; }
|
||||
@@ -26,9 +26,11 @@ export function playerContextHref(guid: string, source: string): string {
|
||||
const game = original.get('game');
|
||||
const map = original.get('map');
|
||||
const metric = original.get('metric');
|
||||
const players = original.get('players');
|
||||
// Match the leaderboard's displayed defaults for older or hand-edited URLs.
|
||||
if (game === 't5' || game === 't6') params.set('game', game);
|
||||
if (map && map.length <= 1024) params.set('map', map);
|
||||
if (map !== null && map.length <= 1024) params.set('map', map);
|
||||
if (players && /^[1-4]$/.test(players)) params.set('players', players);
|
||||
if (metric && leaderboardMetrics.some(item => item.key === metric)) params.set('metric', metric);
|
||||
return `/leaderboard/players/${encodeURIComponent(guid)}?${params}`;
|
||||
}
|
||||
@@ -37,7 +39,7 @@ export function profileViewHref(current: URL, changes: Record<string, string>):
|
||||
const params = new URLSearchParams(current.search);
|
||||
params.delete('offset');
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
if (value) params.set(key, value);
|
||||
if (value || key === 'map') params.set(key, value);
|
||||
else params.delete(key);
|
||||
}
|
||||
return `${current.pathname}${params.size ? `?${params}` : ''}`;
|
||||
|
||||
@@ -101,9 +101,12 @@ export interface LeaderboardMap {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type LeaderboardPlayerCount = 1 | 2 | 3 | 4;
|
||||
|
||||
export interface LeaderboardOptions {
|
||||
game?: string;
|
||||
map?: string;
|
||||
players?: LeaderboardPlayerCount;
|
||||
metric?: LeaderboardMetric;
|
||||
q?: string;
|
||||
limit?: number;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const origin = 'https://electric-cherry.invalid';
|
||||
const pagePath = /^\/(?:$|operations|account|capacity|metrics|server-keys|settings(?:\/users)?|servers\/[^/]+|hosts(?:\/[^/]+)?|collections(?:\/launches\/[^/]+)?|(?:scripts|configs|plugins|betas|campaigns|maintenance|provisioning|matches)(?:\/[^/]+)?|game-files|leaderboard(?:\/players\/[^/]+(?:\/[^/]+)?)?)$/;
|
||||
const pagePath = /^\/(?:$|activity|operations|autoscaling|account|capacity|metrics|server-keys|settings(?:\/users)?|servers\/[^/]+|hosts(?:\/[^/]+)?|collections(?:\/launches\/[^/]+)?|(?:scripts|configs|plugins|betas|campaigns|maintenance|provisioning|matches)(?:\/[^/]+)?|game-files|leaderboard(?:\/players\/[^/]+(?:\/[^/]+)?)?)$/;
|
||||
|
||||
/** Only return to application pages, never an external URL or a mutation/API endpoint. */
|
||||
export function safeReturnHref(value: string | null | undefined, fallback: string): string {
|
||||
@@ -30,6 +30,6 @@ export function returnLabel(href: string): string {
|
||||
if (path.startsWith('/matches/')) return 'Back to match';
|
||||
if (path === '/hosts/operations') return 'Back to host operation history';
|
||||
if (path.startsWith('/hosts/')) return 'Back to host';
|
||||
const names: Record<string, string> = { '': 'Fleet', operations: 'Operations', collections: 'Collections', hosts: 'Hosts', capacity: 'Capacity', scripts: 'Scripts', configs: 'Configs', plugins: 'Plugins', 'game-files': 'Game Files', betas: 'Betas', campaigns: 'Campaigns', maintenance: 'Maintenance', provisioning: 'Provisioning', leaderboard: 'Leaderboard', matches: 'Match History', metrics: 'Metrics' };
|
||||
const names: Record<string, string> = { '': 'Servers', activity: 'Activity', operations: 'Activity', autoscaling: 'Autoscaling', collections: 'Collections', hosts: 'Hosts', capacity: 'Capacity', scripts: 'Scripts', configs: 'Configs', plugins: 'Plugins', 'game-files': 'Game Files', betas: 'Betas', campaigns: 'Campaigns', maintenance: 'Maintenance', provisioning: 'Server queues', leaderboard: 'Leaderboard', matches: 'Match History', metrics: 'Metrics' };
|
||||
return `Back to ${names[path.split('/')[1]] ?? 'previous page'}`;
|
||||
}
|
||||
|
||||
+17
-18
@@ -5,34 +5,33 @@ export interface NavigationGroup { id: string; label: string; admin?: boolean; i
|
||||
|
||||
export const navigationGroups: NavigationGroup[] = [
|
||||
{ id: 'fleet', label: 'Fleet', items: [
|
||||
{ label: 'Overview', href: '/', shortcut: 'f', matches: path => path === '/' || path.startsWith('/servers/') },
|
||||
{ label: 'Collections', href: '/collections' },
|
||||
{ label: 'Autoscaling', href: '/autoscaling' },
|
||||
{ label: 'Servers', href: '/', shortcut: 'f', matches: path => path === '/' || path.startsWith('/servers/') },
|
||||
{ label: 'Hosts', href: '/hosts', shortcut: 'o', matches: path => path === '/hosts' || /^\/hosts\/(?!operations(?:\/|$))/.test(path) },
|
||||
{ label: 'Capacity', href: '/capacity' }
|
||||
{ label: 'Capacity', href: '/capacity' },
|
||||
{ label: 'Autoscaling', href: '/autoscaling' },
|
||||
{ label: 'Collections', href: '/collections' }
|
||||
] },
|
||||
{ id: 'library', label: 'Library', items: [
|
||||
{ id: 'releases', label: 'Releases', items: [
|
||||
{ label: 'Scripts', href: '/scripts', shortcut: 's' },
|
||||
{ label: 'Configs', href: '/configs', shortcut: 'c' },
|
||||
{ label: 'Plugins', href: '/plugins', shortcut: 'p' },
|
||||
{ label: 'Game Files', href: '/game-files' }
|
||||
] },
|
||||
{ id: 'operations', label: 'Operations', items: [
|
||||
{ label: 'Provisioning', href: '/provisioning', shortcut: 'd', matches: path => /^\/(provisioning|deployments)(\/|$)/.test(path) },
|
||||
{ label: 'Game files', href: '/game-files' },
|
||||
{ label: 'Betas', href: '/betas', shortcut: 'b' },
|
||||
{ label: 'Campaigns', href: '/campaigns', shortcut: 'a' },
|
||||
{ label: 'Maintenance', href: '/maintenance', shortcut: 'w', matches: path => /^\/(maintenance|watchdog)(\/|$)/.test(path) },
|
||||
{ label: 'Host operation history', href: '/hosts/operations' }
|
||||
{ label: 'Campaigns', href: '/campaigns', shortcut: 'a' }
|
||||
] },
|
||||
{ id: 'analytics', label: 'Analytics', items: [
|
||||
{ label: 'Metrics', href: '/metrics', shortcut: 'm' },
|
||||
{ id: 'activity', label: 'Activity', items: [
|
||||
{ label: 'Activity log', href: '/activity', shortcut: 'd', matches: path => path === '/activity' || path === '/operations' || path === '/hosts/operations' || /^\/(provisioning|deployments)(\/|$)/.test(path) },
|
||||
{ label: 'Maintenance', href: '/maintenance', shortcut: 'w', matches: path => /^\/(maintenance|watchdog)(\/|$)/.test(path) }
|
||||
] },
|
||||
{ id: 'players', label: 'Players', items: [
|
||||
{ label: 'Leaderboard', href: '/leaderboard', shortcut: 'l' },
|
||||
{ label: 'Match History', href: '/matches' }
|
||||
{ label: 'Matches', href: '/matches' },
|
||||
{ label: 'Usage', href: '/metrics', shortcut: 'm' }
|
||||
] },
|
||||
{ id: 'administration', label: 'Administration', admin: true, items: [
|
||||
{ label: 'Settings', href: '/settings', shortcut: 'h', matches: path => path === '/settings' },
|
||||
{ id: 'admin', label: 'Admin', admin: true, items: [
|
||||
{ label: 'Users', href: '/settings/users' },
|
||||
{ label: 'Keys', href: '/server-keys', shortcut: 'k' },
|
||||
{ label: 'Users', href: '/settings/users' }
|
||||
{ label: 'Settings', href: '/settings', shortcut: 'h', matches: path => path === '/settings' }
|
||||
] }
|
||||
];
|
||||
|
||||
|
||||
@@ -2,16 +2,16 @@ export const provisioningActions = ['create', 'start', 'restart', 'rebuild', 'st
|
||||
export type ProvisioningAction = typeof provisioningActions[number];
|
||||
export type RuntimeAction = Exclude<ProvisioningAction, 'create' | 'destroy'>;
|
||||
export const actionLabels: Record<ProvisioningAction, string> = {
|
||||
create: 'Create', start: 'Start', restart: 'Restart', rebuild: 'Rebuild', stop: 'Stop', destroy: 'Destroy'
|
||||
create: 'Create', start: 'Start', restart: 'Restart', rebuild: 'Rebuild', stop: 'Stop', destroy: 'Remove'
|
||||
};
|
||||
export const actionCompleted: Record<ProvisioningAction, string> = {
|
||||
create: 'Server creation completed', start: 'Server started', restart: 'Server restarted',
|
||||
rebuild: 'Server rebuilt', stop: 'Server stopped', destroy: 'Server destroyed'
|
||||
rebuild: 'Server rebuilt', stop: 'Server stopped', destroy: 'Server removed'
|
||||
};
|
||||
export const runtimeActions: RuntimeAction[] = ['start', 'restart', 'rebuild', 'stop'];
|
||||
export const runtimeDescriptions: Record<RuntimeAction, string> = {
|
||||
start: 'Start the existing container and verify its health.',
|
||||
restart: 'Restart the container after the server is empty, then verify its health.',
|
||||
rebuild: 'Destroy the instance and delete all of its server files, then create a fresh instance in the same server slot and port with the latest promoted setup. No instance data is retained. Uses the key saved separately in Keys. Waits until empty, then verifies health and map.',
|
||||
rebuild: 'Remove the instance and delete all of its server files, then create a fresh instance in the same server slot and port with the latest promoted setup. No instance data is retained. Uses the key saved separately in Keys. Waits until empty, then verifies health and map.',
|
||||
stop: 'Stop the container after the server is empty. Its files, key, and registration are retained so you can start it again.'
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { T5_ZOMBIE_MAPS, T6_ZOMBIE_MAPS } from './map-aliases.js';
|
||||
import { gameTitle } from './ui/server-labels.js';
|
||||
|
||||
export interface LibraryVersion {
|
||||
id: number; version: number; sha256: string; size: number;
|
||||
@@ -30,7 +31,7 @@ export function mapLabel(canonical: string): string {
|
||||
return matches.length > 1 ? `${matches[0].friendly} + ${matches.length - 1} variants` : matches[0]?.friendly ?? canonical;
|
||||
}
|
||||
export function scopeLabel(s: Pick<LibraryScript, 'scope_type' | 'scope_value'>): string {
|
||||
return s.scope_type === 'game' ? (s.scope_value ?? '').toUpperCase()
|
||||
return s.scope_type === 'game' ? gameTitle(s.scope_value)
|
||||
: s.scope_type === 'map' ? mapLabel(s.scope_value ?? '') : 'Unscoped';
|
||||
}
|
||||
export interface BetaSelection { scriptId: number; versionId: number; }
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { freshStatusTimestamp } from './server-freshness.js';
|
||||
|
||||
export interface PlayerCapacityObservation {
|
||||
capacity: number | null;
|
||||
capacity_observed_at: string | null;
|
||||
}
|
||||
|
||||
/** A newer roster must never renew the capacity reported by an older process. */
|
||||
export function currentCapacity(server: Partial<PlayerCapacityObservation> & { telemetry_after?: string | null }, now: number): number | null {
|
||||
const value = server.capacity;
|
||||
const observedAt = server.capacity_observed_at;
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0
|
||||
|| !freshStatusTimestamp(observedAt ?? null, now)) return null;
|
||||
if (server.telemetry_after && !(Date.parse(observedAt!) > Date.parse(server.telemetry_after))) return null;
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { hostOperationTitles } from '../host-operation-labels.js';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { normalizeActivityQuery, type ActivityPage, type ActivityQuery, type ActivityRow, type ActivitySource } from '../activity.js';
|
||||
import { serverTitle } from '../ui/server-labels.js';
|
||||
import { db } from './db/index.js';
|
||||
import { ensureHostSchema } from './hosts/schema.js';
|
||||
import { importantOperationSql } from './hosts/observations.js';
|
||||
|
||||
const initialized = new WeakSet<Database.Database>();
|
||||
const quote = (value: string) => `'${value.replaceAll("'", "''")}'`;
|
||||
const labelCase = (column: string, labels: Record<string, string>, fallback: string) =>
|
||||
`CASE ${column} ${Object.entries(labels).map(([key, value]) => `WHEN ${quote(key)} THEN ${quote(value)}`).join(' ')} ELSE ${quote(fallback)} END`;
|
||||
|
||||
// Old attempts may no longer be the native_provisioning pointer. Their stable
|
||||
// idempotency keys still identify the same retained controller operation.
|
||||
const lifecycleKey = `CASE
|
||||
WHEN substr(o.idempotency_key,-6)=':stage' THEN substr(o.idempotency_key,1,length(o.idempotency_key)-6)
|
||||
${[':migration-create:', ':migrate-remove:'].map(marker => `WHEN instr(o.idempotency_key,${quote(marker)})>0
|
||||
AND substr(o.idempotency_key,instr(o.idempotency_key,${quote(marker)})+${marker.length})<>''
|
||||
AND substr(o.idempotency_key,instr(o.idempotency_key,${quote(marker)})+${marker.length}) NOT GLOB '*[^0-9]*'
|
||||
THEN substr(o.idempotency_key,1,instr(o.idempotency_key,${quote(marker)})-1)`).join('\n')}
|
||||
ELSE o.idempotency_key END`;
|
||||
|
||||
/** One row per user-level operation or finding; no command payloads or observations are read. */
|
||||
const activitySql = `WITH
|
||||
links AS (
|
||||
SELECT operation_id,kind,source_id,server FROM provisioning_sources
|
||||
UNION SELECT id,substr(triggered_by,1,instr(triggered_by,':')-1),substr(triggered_by,instr(triggered_by,':')+1),server
|
||||
FROM server_deployments WHERE substr(triggered_by,1,instr(triggered_by,':')-1) IN ('maintenance','watchdog','campaign','beta','autoscaling')
|
||||
UNION SELECT provisioning_id,'maintenance',CAST(id AS TEXT),server FROM maintenance_jobs WHERE provisioning_id IS NOT NULL
|
||||
UNION SELECT provisioning_id,'campaign',deployment_id,server FROM deployment_servers WHERE provisioning_id IS NOT NULL
|
||||
UNION SELECT provisioning_id,'beta',CAST(beta_id AS TEXT),server FROM beta_servers WHERE provisioning_id IS NOT NULL
|
||||
UNION SELECT a.operation_id,'watchdog',i.id,i.server FROM watchdog_actions a JOIN watchdog_incidents i ON i.id=a.incident_id
|
||||
),
|
||||
memberships AS (
|
||||
SELECT operation_id,'|' || replace(group_concat(DISTINCT CASE kind WHEN 'watchdog' THEN 'recovery' ELSE kind END),',','|') || '|' AS sources
|
||||
FROM links GROUP BY operation_id
|
||||
),
|
||||
maintenance_links AS (
|
||||
SELECT DISTINCT l.operation_id,m.id,m.state,m.kind,m.target_revision
|
||||
FROM links l JOIN maintenance_jobs m ON l.kind='maintenance' AND l.source_id=CAST(m.id AS TEXT) AND l.server=m.server
|
||||
JOIN server_deployments d ON d.id=l.operation_id
|
||||
WHERE m.provisioning_id=l.operation_id OR (m.provisioning_id IS NULL AND NOT EXISTS (
|
||||
SELECT 1 FROM links newer JOIN server_deployments n ON n.id=newer.operation_id
|
||||
WHERE newer.kind='maintenance' AND newer.source_id=l.source_id AND newer.server=l.server
|
||||
AND (n.started_at>d.started_at OR (n.started_at=d.started_at AND n.rowid>d.rowid))))
|
||||
),
|
||||
maintenance_results AS (
|
||||
SELECT operation_id,MAX(state='failed') AS failed,MAX(state IN ('queued','restarting')) AS pending,
|
||||
MAX(state='cancelled') AS cancelled,MAX(kind='update') AS is_update,MAX(target_revision) AS revision
|
||||
FROM maintenance_links GROUP BY operation_id
|
||||
),
|
||||
covered_host AS (
|
||||
SELECT n.operation_id AS id FROM native_provisioning n JOIN server_deployments d ON d.id=n.deployment_id WHERE n.operation_id IS NOT NULL
|
||||
UNION SELECT n.stage_operation_id FROM native_provisioning n JOIN server_deployments d ON d.id=n.deployment_id WHERE n.stage_operation_id IS NOT NULL
|
||||
UNION SELECT m.remove_operation_id FROM ec_host_migrations m JOIN server_deployments d ON d.id=m.deployment_id WHERE m.remove_operation_id IS NOT NULL
|
||||
),
|
||||
items AS (
|
||||
SELECT 'provisioning:' || d.id AS id,d.started_at AS at,
|
||||
CASE WHEN m.is_update=1 THEN 'update' WHEN d.action='destroy' THEN 'remove' ELSE d.action END AS type,
|
||||
COALESCE(ms.sources,'|provisioning|') AS sources,
|
||||
CASE WHEN d.state IN ('failed','interrupted','cancelled','warning') THEN d.state
|
||||
WHEN m.failed=1 THEN 'failed' WHEN m.pending=1 AND d.state='done' THEN 'verifying'
|
||||
WHEN m.cancelled=1 THEN 'cancelled' ELSE d.state END AS rawState,
|
||||
CASE WHEN m.is_update=1 THEN 'Update game' ELSE ${labelCase('d.action', { create: 'Create server', start: 'Start server', restart: 'Restart server', rebuild: 'Rebuild server', stop: 'Stop server', destroy: 'Remove server' }, 'Server operation')} END AS title,
|
||||
CASE WHEN m.failed=1 AND d.state='done' THEN 'The restart completed, but maintenance verification failed.'
|
||||
WHEN m.pending=1 AND d.state='done' THEN 'The restart completed. Maintenance verification is still in progress.'
|
||||
WHEN m.revision IS NOT NULL THEN 'Target game revision r' || m.revision || '.'
|
||||
WHEN d.state='waiting' THEN 'Waiting for eligibility and available capacity.'
|
||||
WHEN d.state='awaiting-health' THEN 'Checking that the game is responding.' ELSE NULL END AS detail,
|
||||
d.server,d.server_deleted_at AS serverDeletedAt,COALESCE(n.host_id,mg.source_host_id) AS hostId,mg.source_host_id AS secondaryHostId,
|
||||
'provisioning' AS route,d.id AS recordId
|
||||
FROM server_deployments d LEFT JOIN memberships ms ON ms.operation_id=d.id
|
||||
LEFT JOIN maintenance_results m ON m.operation_id=d.id
|
||||
LEFT JOIN native_provisioning p ON p.deployment_id=d.id LEFT JOIN native_instances n ON n.id=p.instance_id
|
||||
LEFT JOIN ec_host_migrations mg ON mg.deployment_id=d.id
|
||||
UNION ALL
|
||||
SELECT 'maintenance:' || m.id,m.requested_at,CASE WHEN m.kind='update' THEN 'update' ELSE 'restart' END,
|
||||
'|maintenance|',m.state,CASE WHEN m.kind='update' THEN 'Update game' WHEN m.kind='schedule' THEN 'Scheduled server maintenance' ELSE 'Server maintenance' END,
|
||||
CASE WHEN m.target_revision IS NOT NULL THEN 'Target game revision r' || m.target_revision || '.' ELSE NULL END,
|
||||
m.server,m.server_deleted_at,NULL,NULL,'maintenance-job',CAST(m.id AS TEXT)
|
||||
FROM maintenance_jobs m WHERE NOT EXISTS (SELECT 1 FROM maintenance_links l WHERE l.id=m.id)
|
||||
UNION ALL
|
||||
SELECT 'recovery:' || i.id,i.first_seen_at,i.kind,'|recovery|',i.state,
|
||||
CASE WHEN i.kind='health' THEN 'Game health finding' ELSE 'Server files differ from the promoted setup' END,
|
||||
CASE WHEN i.state='recovering' THEN 'Recovery is in progress.' WHEN i.state='attention' THEN 'Automatic recovery needs review.'
|
||||
WHEN i.state='resolved' THEN 'The finding was resolved.' WHEN i.state='dismissed' THEN 'The finding was dismissed.' ELSE 'A check found an issue that needs review.' END,
|
||||
i.server,i.server_deleted_at,NULL,NULL,'recovery',i.id
|
||||
FROM watchdog_incidents i
|
||||
UNION ALL
|
||||
SELECT 'campaign:' || d.id || ':' || ds.server,d.started_at,CASE WHEN d.action='drift' THEN 'drift' ELSE 'files' END,
|
||||
CASE WHEN d.triggered_by='watchdog' OR EXISTS (SELECT 1 FROM watchdog_actions a WHERE a.campaign_id=d.id)
|
||||
THEN '|campaign|recovery|' ELSE '|campaign|' END,ds.restart_state,
|
||||
CASE d.action WHEN 'promote' THEN 'Promote files' WHEN 'rollback' THEN 'Roll back files' ELSE 'Reconcile files' END,
|
||||
CAST(ds.files_changed AS TEXT) || CASE WHEN ds.files_changed=1 THEN ' file changed.' ELSE ' files changed.' END,
|
||||
ds.server,ds.server_deleted_at,NULL,NULL,'campaign',d.id
|
||||
FROM deployments d JOIN deployment_servers ds ON ds.deployment_id=d.id
|
||||
UNION ALL
|
||||
SELECT 'beta:' || b.id || ':' || bs.server,b.opened_at,'beta','|beta|',
|
||||
CASE WHEN b.state IN ('promoted','rolled-back','cancelled') THEN b.state ELSE bs.stage END,
|
||||
'Beta test #' || b.id,NULL,bs.server,bs.server_deleted_at,NULL,NULL,'beta',CAST(b.id AS TEXT)
|
||||
FROM betas b JOIN beta_servers bs ON bs.beta_id=b.id
|
||||
UNION ALL
|
||||
SELECT 'host:' || o.id,o.created_at,'host','|host|',
|
||||
CASE WHEN o.state='completed' AND o.routine=1 AND o.observation_success=0 THEN 'observation-failed'
|
||||
WHEN o.state='completed' AND COALESCE(o.error,'')<>'' THEN 'warning' ELSE o.state END,
|
||||
${labelCase('o.kind', hostOperationTitles, 'Host operation')},
|
||||
CASE WHEN o.routine=1 AND o.state='completed' AND o.observation_success=0 THEN 'The command completed, but the check reported a problem.'
|
||||
WHEN o.state='unknown' THEN 'The host has not confirmed the result.' ELSE NULL END,
|
||||
COALESCE(o.server_name,n.server),s.destroyed_at,o.host_id,NULL,'host',o.id
|
||||
FROM ec_host_operations o LEFT JOIN native_instances n ON n.id=o.instance_id
|
||||
LEFT JOIN servers s ON s.name=COALESCE(o.server_name,n.server)
|
||||
WHERE o.history_expired_at IS NULL AND ${importantOperationSql}
|
||||
AND NOT EXISTS (SELECT 1 FROM covered_host c WHERE c.id=o.id)
|
||||
AND NOT (o.kind IN ('files.stage','instance.create','instance.rebuild','instance.start','instance.restart','instance.stop','instance.remove','instance.cancel-create')
|
||||
AND EXISTS (SELECT 1 FROM server_deployments d WHERE d.id=${lifecycleKey}))
|
||||
),
|
||||
normalized AS (
|
||||
SELECT i.*,h.name AS hostName,
|
||||
CASE WHEN rawState IN ('queued','pending','waiting','held','awaiting-empty','awaiting-match') THEN 'queued'
|
||||
WHEN rawState IN ('running','accepted','preparing','staging','testing','observing','deploying','restarting','recovering','awaiting-health','verifying','rolling-back') THEN 'running'
|
||||
WHEN rawState IN ('done','completed','resolved','ready','ready-promote','promoted','rolled-back') THEN 'succeeded'
|
||||
WHEN rawState IN ('failed','interrupted','observation-failed') THEN 'failed'
|
||||
WHEN rawState IN ('cancelled','dismissed','skipped') THEN 'cancelled'
|
||||
WHEN rawState IN ('manual','attention','open') THEN 'attention'
|
||||
WHEN rawState='warning' THEN 'warning' ELSE 'unknown' END AS state
|
||||
FROM items i LEFT JOIN ec_hosts h ON h.id=i.hostId
|
||||
)
|
||||
`;
|
||||
|
||||
type SqlRow = Omit<ActivityRow, 'source' | 'sources' | 'href'> & { sources: string; route: string; recordId: string; secondaryHostId: string | null };
|
||||
const sourceOrder: ActivitySource[] = ['maintenance', 'recovery', 'campaign', 'beta', 'autoscaling', 'provisioning', 'host'];
|
||||
function present(row: SqlRow): ActivityRow {
|
||||
const sources = sourceOrder.filter(source => row.sources.includes(`|${source}|`));
|
||||
const href = row.route === 'maintenance-job' ? `/maintenance?view=overview&job=${encodeURIComponent(row.recordId)}#job-${row.recordId}`
|
||||
: row.route === 'recovery' ? `/maintenance/${encodeURIComponent(row.recordId)}`
|
||||
: row.route === 'host' ? `/hosts/operations?operation=${encodeURIComponent(row.recordId)}&filter=all`
|
||||
: `/${row.route === 'campaign' ? 'campaigns' : row.route === 'beta' ? 'betas' : 'provisioning'}/${encodeURIComponent(row.recordId)}`;
|
||||
return { id: row.id, at: row.at, type: row.type, source: sources[0] ?? 'provisioning', sources,
|
||||
state: row.state, rawState: row.rawState, title: row.title, detail: row.detail,
|
||||
server: row.server, serverDeletedAt: row.serverDeletedAt, hostId: row.hostId, hostName: row.hostName, href };
|
||||
}
|
||||
|
||||
/** All predicates, totals and pagination operate on the same retained SQL union. */
|
||||
export function activityHistory(input: ActivityQuery = {}, now = Date.now()): ActivityPage {
|
||||
const query = normalizeActivityQuery(input);
|
||||
const handle = db();
|
||||
ensureHostSchema(handle);
|
||||
if (!initialized.has(handle)) {
|
||||
handle.function('activity_server_title', { deterministic: true }, (name: unknown) => typeof name === 'string' ? serverTitle(name) : '');
|
||||
initialized.add(handle);
|
||||
}
|
||||
const conditions: string[] = [];
|
||||
const bindings: Record<string, string | number> = {};
|
||||
for (const key of ['server', 'type', 'state'] as const) if (query[key]) { conditions.push(`${key}=@${key}`); bindings[key] = query[key]; }
|
||||
if (query.host) { conditions.push('(hostId=@host OR secondaryHostId=@host)'); bindings.host = query.host; }
|
||||
if (query.source) {
|
||||
// Maintenance is the user-facing umbrella; Recovery remains available as a narrower filter.
|
||||
conditions.push(query.source === 'maintenance' ? "(instr(sources,'|maintenance|')>0 OR instr(sources,'|recovery|')>0)" : 'instr(sources,@source)>0');
|
||||
if (query.source !== 'maintenance') bindings.source = `|${query.source}|`;
|
||||
}
|
||||
if (query.days) { conditions.push('julianday(at)>=julianday(@since)'); bindings.since = new Date(now - query.days * 86_400_000).toISOString(); }
|
||||
if (query.q) {
|
||||
conditions.push("instr(lower(COALESCE(server,'') || ' ' || activity_server_title(server) || ' ' || COALESCE(hostName,'') || ' ' || COALESCE(hostId,'') || ' ' || title || ' ' || COALESCE(detail,'') || ' ' || id),@q)>0");
|
||||
bindings.q = query.q.toLocaleLowerCase();
|
||||
}
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const limit = query.limit!;
|
||||
return handle.transaction(() => {
|
||||
const { total } = handle.prepare(`${activitySql} SELECT COUNT(*) AS total FROM normalized ${where}`).get(bindings) as { total: number };
|
||||
const offset = Math.min(query.offset!, Math.max(0, Math.ceil(total / limit) - 1) * limit);
|
||||
const rows = handle.prepare(`${activitySql} SELECT id,at,type,sources,state,rawState,title,detail,server,serverDeletedAt,hostId,hostName,secondaryHostId,route,recordId
|
||||
FROM normalized ${where} ORDER BY julianday(at) DESC,id DESC LIMIT @limit OFFSET @offset`)
|
||||
.all({ ...bindings, limit, offset }) as SqlRow[];
|
||||
return { rows: rows.map(present), total, limit, offset, query: { ...query, offset } };
|
||||
})();
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import type { PersonalBestMetric, PersonalBestRecords, RecordEvidence } from '../../leaderboard.js';
|
||||
import type { LeaderboardPlayerCount, PersonalBestMetric, PersonalBestRecords, RecordEvidence } from '../../leaderboard.js';
|
||||
import { MIN_RECORDED_ROUND } from '../../match-history.js';
|
||||
|
||||
interface RecordRequest extends Record<PersonalBestMetric, number> {
|
||||
guid: string;
|
||||
game?: string;
|
||||
map?: string;
|
||||
players?: LeaderboardPlayerCount;
|
||||
}
|
||||
|
||||
/** Fetch evidence only for requested players and scopes, never rank the whole fleet's history. */
|
||||
@@ -15,9 +16,9 @@ export function getPersonalBestRecords(handle: Database.Database, requests: Reco
|
||||
for (let offset = 0; offset < requests.length; offset += 100) {
|
||||
const batch = requests.slice(offset, offset + 100);
|
||||
const parameters = batch.flatMap((request, index) => [index, request.guid, request.game ?? null,
|
||||
request.map ?? null, request.highestRound, request.bestKills, request.bestScore]);
|
||||
const rows = handle.prepare(`WITH requested (requestId, guid, game, map, highestRound, bestKills, bestScore) AS (
|
||||
VALUES ${batch.map(() => '(?, ?, ?, ?, ?, ?, ?)').join(', ')}
|
||||
request.map ?? null, request.players ?? null, request.highestRound, request.bestKills, request.bestScore]);
|
||||
const rows = handle.prepare(`WITH requested (requestId, guid, game, map, players, highestRound, bestKills, bestScore) AS (
|
||||
VALUES ${batch.map(() => '(?, ?, ?, ?, ?, ?, ?, ?)').join(', ')}
|
||||
), candidates AS (
|
||||
SELECT r.requestId, m.id AS matchId, m.game, m.map_key AS mapKey, m.map_name AS mapName,
|
||||
m.server, m.started_at AS startedAt, m.ended_at AS endedAt, m.start_observed AS startObserved,
|
||||
@@ -30,6 +31,7 @@ export function getPersonalBestRecords(handle: Database.Database, requests: Reco
|
||||
WHERE m.game IN ('t5', 't6') AND m.status = 'completed' AND mp.has_stats = 1
|
||||
AND m.highest_round >= ${MIN_RECORDED_ROUND} AND mp.highest_round >= ${MIN_RECORDED_ROUND}
|
||||
AND (r.game IS NULL OR m.game = r.game) AND (r.map IS NULL OR m.map_key = r.map)
|
||||
AND (r.players IS NULL OR m.start_players = r.players)
|
||||
AND (mp.highest_round = r.highestRound OR mp.kills = r.bestKills OR mp.best_score = r.bestScore)
|
||||
), valuesByMetric AS (
|
||||
SELECT *, 'highestRound' AS metric, highestRound AS value FROM candidates WHERE highestRound = targetRound
|
||||
|
||||
@@ -32,6 +32,20 @@ export function isLeaderboardMetric(value: string): value is LeaderboardMetric {
|
||||
return metrics.has(value);
|
||||
}
|
||||
|
||||
/** Pick a usable initial map without ranking every player across the fleet first. */
|
||||
export function defaultLeaderboardMap(options: Pick<LeaderboardOptions, 'game' | 'players'> = {}): string | null {
|
||||
const conditions = ["m.game IN ('t5', 't6')", "m.status = 'completed'", `m.highest_round >= ${MIN_RECORDED_ROUND}`,
|
||||
`EXISTS (SELECT 1 FROM leaderboard_match_players mp WHERE mp.match_id = m.id
|
||||
AND mp.has_stats = 1 AND mp.highest_round >= ${MIN_RECORDED_ROUND})`];
|
||||
const parameters: Array<string | number> = [];
|
||||
if (options.game) { conditions.push('m.game = ?'); parameters.push(options.game); }
|
||||
if (options.players) { conditions.push('m.start_players = ?'); parameters.push(options.players); }
|
||||
const row = db().prepare(`SELECT m.map_key AS key FROM leaderboard_matches m
|
||||
WHERE ${conditions.join(' AND ')} ORDER BY m.game, m.map_name COLLATE NOCASE, m.map_key, m.id DESC LIMIT 1`)
|
||||
.get(...parameters) as { key: string } | undefined;
|
||||
return row?.key ?? null;
|
||||
}
|
||||
|
||||
/** Rank each GUID once within the selected matches; player search keeps their actual rank. */
|
||||
export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardResponse {
|
||||
return readLeaderboard(options);
|
||||
@@ -39,13 +53,13 @@ export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardRes
|
||||
|
||||
/** Select the exact player after ranking everyone eligible in the requested scope. */
|
||||
export function getPlayerLeaderboardEntry(guid: string,
|
||||
options: Pick<LeaderboardOptions, 'game' | 'map' | 'metric'> = {}): LeaderboardEntry | null {
|
||||
return readLeaderboard({ game: options.game, map: options.map, metric: options.metric, limit: 1 }, guid);
|
||||
options: Pick<LeaderboardOptions, 'game' | 'map' | 'players' | 'metric'> = {}): LeaderboardEntry | null {
|
||||
return readLeaderboard({ game: options.game, map: options.map, players: options.players, metric: options.metric, limit: 1 }, guid);
|
||||
}
|
||||
|
||||
function addRecordSources(handle: ReturnType<typeof db>, entries: RankedEntry[], options: LeaderboardOptions): LeaderboardEntry[] {
|
||||
const records = getPersonalBestRecords(handle, entries.map(entry => ({
|
||||
...entry, game: options.game || undefined, map: options.map || undefined
|
||||
...entry, game: options.game || undefined, map: options.map || undefined, players: options.players
|
||||
})));
|
||||
return entries.map((entry, index) => ({ ...entry, records: records[index] }));
|
||||
}
|
||||
@@ -59,9 +73,10 @@ function readLeaderboard(options: LeaderboardOptions, exactGuid?: string): Leade
|
||||
const offset = Number.isSafeInteger(options.offset) ? Math.min(MAX_LEADERBOARD_OFFSET, Math.max(0, options.offset!)) : 0;
|
||||
const conditions = ["m.game IN ('t5', 't6')", "m.status = 'completed'", 'mp.has_stats = 1', `m.highest_round >= ${MIN_RECORDED_ROUND}`,
|
||||
`mp.highest_round >= ${MIN_RECORDED_ROUND}`];
|
||||
const parameters: string[] = [];
|
||||
const parameters: Array<string | number> = [];
|
||||
if (options.game) { conditions.push('m.game = ?'); parameters.push(options.game); }
|
||||
if (options.map) { conditions.push('m.map_key = ?'); parameters.push(options.map); }
|
||||
if (options.players) { conditions.push('m.start_players = ?'); parameters.push(options.players); }
|
||||
const qualification = isEfficiencyMetric(metric)
|
||||
? `WHERE measures.gamesPlayed >= ${EFFICIENCY_MIN_GAMES} AND measures.${metric} IS NOT NULL`
|
||||
+ (metric === 'headshotRate' ? ` AND measures.kills >= ${EFFICIENCY_MIN_KILLS}` : '')
|
||||
@@ -101,7 +116,7 @@ function readLeaderboard(options: LeaderboardOptions, exactGuid?: string): Leade
|
||||
catch { /* Standalone history tools can operate without cache metadata. */ }
|
||||
let cache = scopeCaches.get(handle);
|
||||
if (!cache) { cache = new Map(); scopeCaches.set(handle, cache); }
|
||||
const cacheKey = JSON.stringify([options.game ?? '', options.map ?? '', metric]);
|
||||
const cacheKey = JSON.stringify([options.game ?? '', options.map ?? '', options.players ?? '', metric]);
|
||||
let saved = cache.get(cacheKey);
|
||||
if (!saved || revision < 0 || saved.revision !== revision) {
|
||||
const rows = handle.prepare(`${aggregated}, playerSummary AS (
|
||||
|
||||
@@ -42,7 +42,7 @@ interface Record {
|
||||
/** A pass shares its reads; each asynchronous evidence boundary refreshes in one joined query. */
|
||||
export class MonitoringSnapshot {
|
||||
private records = new Map<string, Record>();
|
||||
constructor() { ensureHostSchema(db()); this.refresh(); }
|
||||
constructor(serverName?: string) { ensureHostSchema(db()); this.refresh(serverName); }
|
||||
refresh(server?: string): void {
|
||||
const rows = server === undefined ? db().prepare(sql + ' WHERE s.destroyed_at IS NULL ORDER BY s.name').all() as Row[]
|
||||
: db().prepare(sql + ' WHERE s.name=? AND s.destroyed_at IS NULL').all(server) as Row[];
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { PlayerCapacityObservation } from '../../server-capacity.js';
|
||||
import { monitoringStatus } from './health-observations.js';
|
||||
import { MonitoringSnapshot } from './monitoring-snapshot.js';
|
||||
|
||||
/** Read only the accepted, current-instance sample; never dispatch a probe. */
|
||||
export function observedPlayerCapacity(server: string, snapshot: MonitoringSnapshot): PlayerCapacityObservation {
|
||||
const game = monitoringStatus(server, snapshot)?.game;
|
||||
const capacity = game?.result.ok ? game.result.status.capacity : null;
|
||||
return typeof capacity === 'number' && Number.isSafeInteger(capacity) && capacity > 0
|
||||
? { capacity, capacity_observed_at: game!.observedAt }
|
||||
: { capacity: null, capacity_observed_at: null };
|
||||
}
|
||||
|
||||
/** One joined identity read for a fleet, or a single server on a detail route. */
|
||||
export function playerCapacityReader(serverName?: string) {
|
||||
const snapshot = new MonitoringSnapshot(serverName);
|
||||
return (server: string) => observedPlayerCapacity(server, snapshot);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ const scope = `FROM leaderboard_match_players mp JOIN leaderboard_matches m ON m
|
||||
AND m.highest_round >= ${MIN_RECORDED_ROUND} AND mp.highest_round >= ${MIN_RECORDED_ROUND}`;
|
||||
|
||||
export function getPlayerProfile(guid: string, requestedOffset = 0,
|
||||
options: Pick<LeaderboardOptions, 'game' | 'map'> = {}): PlayerProfile | null {
|
||||
options: Pick<LeaderboardOptions, 'game' | 'map' | 'players'> = {}): PlayerProfile | null {
|
||||
const handle = db();
|
||||
// Keep game-specific ingestion identities, but show one identity with the latest reported name.
|
||||
const player = handle.prepare(`SELECT guid, name, MIN(first_seen_at) OVER () AS firstSeen,
|
||||
@@ -22,11 +22,12 @@ export function getPlayerProfile(guid: string, requestedOffset = 0,
|
||||
if (!player) return null;
|
||||
const stats = handle.prepare(`SELECT ${totals} ${scope}`).get(guid) as PlayerStats;
|
||||
if (!stats.gamesPlayed) return null;
|
||||
const historyParameters = [guid];
|
||||
const historyParameters: Array<string | number> = [guid];
|
||||
let historyScope = scope;
|
||||
if (options.game) { historyScope += ' AND m.game = ?'; historyParameters.push(options.game); }
|
||||
if (options.map) { historyScope += ' AND m.map_key = ?'; historyParameters.push(options.map); }
|
||||
const matchesTotal = options.game || options.map
|
||||
if (options.players) { historyScope += ' AND m.start_players = ?'; historyParameters.push(options.players); }
|
||||
const matchesTotal = options.game || options.map || options.players
|
||||
? (handle.prepare(`SELECT COUNT(*) AS total ${historyScope}`).get(...historyParameters) as { total: number }).total
|
||||
: stats.gamesPlayed;
|
||||
const limit = 25;
|
||||
|
||||
@@ -6,11 +6,14 @@ import { runningVersions } from '../integrations/plutonium.js';
|
||||
import { listHosts } from '../hosts/index.js';
|
||||
import type { NativeInstance } from '../backends/native.js';
|
||||
import { serverOperations } from './overview.js';
|
||||
import { hostListStatus } from '../../ui/host-overview.js';
|
||||
import { playerCapacityReader } from './player-capacity.js';
|
||||
|
||||
export function fleetSnapshot(serverName?: string) {
|
||||
const bindings = serverName === undefined ? [] : [serverName];
|
||||
const rconHost = config.rcon.host;
|
||||
const hosts = listHosts();
|
||||
const serverNow = Date.now();
|
||||
const hostsById = new Map(hosts.map(({ inventory: _inventory, ...host }) => [host.id, host]));
|
||||
const versions = new Map(runningVersions(serverName).map(version => [version.server, version]));
|
||||
const instances = new Map((db().prepare(`SELECT * FROM native_instances WHERE retired_at IS NULL ${serverName === undefined ? '' : 'AND server = ?'}`)
|
||||
@@ -25,12 +28,14 @@ export function fleetSnapshot(serverName?: string) {
|
||||
const entries = holds.get(row.server) ?? []; entries.push({ target: row.target, betaId: row.betaId }); holds.set(row.server, entries);
|
||||
}
|
||||
const observe = serverName === undefined ? observationReader() : serverObservation;
|
||||
const capacity = playerCapacityReader(serverName);
|
||||
const selected = serverName === undefined ? undefined : getServer(serverName);
|
||||
const rows = serverName === undefined ? listServers() : selected && !selected.destroyed_at ? [selected] : [];
|
||||
const servers = rows.map((s) => {
|
||||
const instance = s.native_instance_id ? instances.get(s.name) : null;
|
||||
return {
|
||||
...observe(s),
|
||||
...capacity(s.name),
|
||||
host: s.host_id ? hostsById.get(s.host_id) ?? null : null,
|
||||
network: instance ? { bindAddress: instance.bind_address, localPort: instance.port, publicAddress: instance.public_address, publicPort: instance.public_port } : null,
|
||||
plutonium: versions.get(s.name),
|
||||
@@ -41,7 +46,10 @@ export function fleetSnapshot(serverName?: string) {
|
||||
});
|
||||
return {
|
||||
servers,
|
||||
serverNow: Date.now(),
|
||||
serverNow,
|
||||
// Include hosts without assigned servers. Re-evaluate heartbeat age on
|
||||
// every existing snapshot refresh rather than adding a navigation poll.
|
||||
hostAttention: hosts.filter(host => ['attention', 'offline'].includes(hostListStatus(host, serverNow).key)).length,
|
||||
reservedNames: serverName === undefined ? (db().prepare("SELECT j.server 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: string }[]).map(row => row.server) : [],
|
||||
integrations: {
|
||||
native: true,
|
||||
|
||||
@@ -120,7 +120,7 @@ function libraryForGame(game: GameLibraryGame, now: string): string {
|
||||
/** Register a folder on the controller; no host or Docker volume is needed. */
|
||||
export function createLibraryUpload(input: Record<string, unknown>): GameLibraryVersion {
|
||||
const game = input.game;
|
||||
if (game !== 't5' && game !== 't6') throw new Error('Choose T5 or T6 game files.');
|
||||
if (game !== 't5' && game !== 't6') throw new Error('Choose Black Ops I or Black Ops II game files.');
|
||||
const files = validateUploadPlan(input.files);
|
||||
const handle = database();
|
||||
const id = randomUUID(); const now = new Date().toISOString();
|
||||
@@ -220,7 +220,7 @@ export async function requestBrowserPublication(id: string) {
|
||||
|
||||
export function createLibraryImport(input: Record<string, unknown>): GameLibraryVersion {
|
||||
const game = input.game;
|
||||
if (game !== 't5' && game !== 't6') throw new Error('Choose T5 or T6 game files.');
|
||||
if (game !== 't5' && game !== 't6') throw new Error('Choose Black Ops I or Black Ops II game files.');
|
||||
const hostId = textField(input.hostId, 'source host', 128);
|
||||
const host = getHost(hostId);
|
||||
if (!host || !host.connected || !host.credentialActive || host.mode === 'disabled') throw new Error('Choose an online, enrolled source host.');
|
||||
|
||||
@@ -438,11 +438,15 @@ export function listHostOperationHistory(page = 1, filter: HostOperationFilter =
|
||||
const parameters: string[] = [];
|
||||
if (scope.host) { conditions.push('o.host_id=?'); parameters.push(scope.host); }
|
||||
if (scope.server) { conditions.push('o.server_name=?'); parameters.push(scope.server); }
|
||||
if (scope.operation) { conditions.push('o.id=?'); parameters.push(scope.operation); }
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
return handle.transaction((): HostOperationHistoryPage => {
|
||||
const countColumn = filter === 'all' ? 'total' : filter;
|
||||
const total = (handle.prepare(`SELECT ${countColumn} AS total FROM ec_host_operation_history_counts WHERE host_id=? AND server_name=?`)
|
||||
.get(scope.host ?? '', scope.server ?? '') as { total: number } | undefined)?.total ?? 0;
|
||||
// An exact operation seeks its unique ID instead of paging through history.
|
||||
const total = scope.operation
|
||||
? (handle.prepare(`SELECT 1 AS total FROM ec_host_operations o ${where}`).get(...parameters) as { total: number } | undefined)?.total ?? 0
|
||||
: (handle.prepare(`SELECT ${countColumn} AS total FROM ec_host_operation_history_counts WHERE host_id=? AND server_name=?`)
|
||||
.get(scope.host ?? '', scope.server ?? '') as { total: number } | undefined)?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const currentPage = Math.min(totalPages, Number.isSafeInteger(page) && page > 0 ? page : 1);
|
||||
const operations = handle.prepare(`SELECT o.id,o.host_id AS hostId,h.name AS hostName,o.kind,
|
||||
|
||||
@@ -162,7 +162,7 @@ export function hostPreparationStatus(host: Host): HostPreparation {
|
||||
items.push({ kind: 'library', label: game.name, state: 'blocked', detail: `Upload and publish ${game.name} game files before this host can be enabled.` });
|
||||
}
|
||||
} else if (!items.some(item => item.kind === 'library')) items.push({ kind: 'library', label: 'Game files', state: 'waiting',
|
||||
detail: 'Upload T5 or T6 game files; hosts download the latest available files automatically.' });
|
||||
detail: 'Upload Black Ops I or Black Ops II game files; hosts download the latest available files automatically.' });
|
||||
// Cache health is visible, but never makes a usable host ineligible or
|
||||
// delays re-enabling: a lobby's normal updater handles cache misses.
|
||||
const required = items.filter(item => item.kind !== 'plutonium');
|
||||
|
||||
@@ -3,8 +3,8 @@ import { watchdogOverview, watchdogTick } from './recovery.js';
|
||||
import { maintenanceTick, plutoniumStatus } from './runtime.js';
|
||||
import type { MaintenanceOverview } from '../../maintenance.js';
|
||||
|
||||
export function maintenanceOverview(): MaintenanceOverview {
|
||||
return { policies: maintenancePolicies(), recovery: watchdogOverview(), runtime: plutoniumStatus() };
|
||||
export function maintenanceOverview(selectedJob: number | null = null): MaintenanceOverview {
|
||||
return { policies: maintenancePolicies(), recovery: watchdogOverview(), runtime: plutoniumStatus(selectedJob) };
|
||||
}
|
||||
export async function runMaintenance(): Promise<void> {
|
||||
// One failing observer must not prevent the other capabilities from reconciling.
|
||||
|
||||
@@ -18,14 +18,14 @@ export function saveMaintenanceSchedule(enabled: boolean, time: string, timeZone
|
||||
const nextAt = nextMaintenanceAt(time, new Date(), timeZone);
|
||||
writeSetting('plutonium_schedule', { enabled, time, timeZone, nextAt: enabled ? nextAt : null });
|
||||
}
|
||||
export function plutoniumStatus(): PlutoniumStatus {
|
||||
export function plutoniumStatus(selectedJob: number | null = null): PlutoniumStatus {
|
||||
return {
|
||||
release: release(), schedule: schedule(), servers: runningVersions(),
|
||||
jobs: db().prepare(`SELECT m.*, d.stage AS operation_stage, d.state AS operation_state
|
||||
FROM maintenance_jobs m LEFT JOIN server_deployments d ON d.id = m.provisioning_id
|
||||
WHERE m.state IN ('queued','restarting')
|
||||
OR (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))
|
||||
OR m.id IN (SELECT id FROM maintenance_jobs ORDER BY id DESC LIMIT 100) ORDER BY m.id DESC`).all() as MaintenanceJob[]
|
||||
OR m.id IN (SELECT id FROM maintenance_jobs ORDER BY id DESC LIMIT 100) OR m.id = ? ORDER BY m.id DESC`).all(selectedJob) as MaintenanceJob[]
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface ServerStatusEntry {
|
||||
rconStatus?: GameObservation;
|
||||
telemetry?: GameTelemetry;
|
||||
players: number | null;
|
||||
capacity?: number | null;
|
||||
capacityObservedAt?: string | null;
|
||||
map: string | null;
|
||||
gametype: string | null;
|
||||
roster: StatusPlayer[] | null;
|
||||
|
||||
@@ -19,6 +19,8 @@ import { runMaintenance } from '../maintenance/index.js';
|
||||
import { cleanupControllerLibraries } from '../game-files/index.js';
|
||||
import { trackActivity } from './activity.js';
|
||||
import { MonitoringSnapshot } from '../fleet/monitoring-snapshot.js';
|
||||
import { observedPlayerCapacity } from '../fleet/player-capacity.js';
|
||||
import type { PlayerCapacityObservation } from '../../server-capacity.js';
|
||||
import { withHostRequestContext } from '../hosts/request-context.js';
|
||||
import { withOperationSource } from '../hosts/operation-context.js';
|
||||
|
||||
@@ -125,7 +127,7 @@ function queueStatus(srv: ServerRow, snapshot: MonitoringSnapshot, probe?: () =>
|
||||
capacity = await withOperationSource('Status monitor', () => refreshServerStatus(server, snapshot, probe));
|
||||
// Publish this server's latest evidence without waiting for the fleet.
|
||||
const current = snapshot.get(server)?.server;
|
||||
if (current) publishStatus(statusEntry(current, snapshot.observe));
|
||||
if (current) publishStatus(statusEntry(current, snapshot.observe, observedPlayerCapacity(server, snapshot)));
|
||||
} catch (err) { console.error(`[worker] status error for ${server}:`, err); }
|
||||
finally { pendingStatus.delete(server); finish(capacity); }
|
||||
});
|
||||
@@ -252,9 +254,10 @@ async function refreshServerStatus(server: string, snapshot: MonitoringSnapshot,
|
||||
return capacity;
|
||||
}
|
||||
|
||||
export function statusEntry(srv: ServerRow, observe = serverObservation): ServerStatusEntry {
|
||||
export function statusEntry(srv: ServerRow, observe = serverObservation, capacity?: PlayerCapacityObservation): ServerStatusEntry {
|
||||
const final = observe(srv);
|
||||
return { server: srv.name, players: final.last_players, map: final.last_map ?? srv.map,
|
||||
return { server: srv.name, players: final.last_players, capacity: capacity?.capacity ?? null,
|
||||
capacityObservedAt: capacity?.capacity_observed_at ?? null, map: final.last_map ?? srv.map,
|
||||
gametype: final.last_gametype, roster: final.roster, health: final.health, lastSeenAt: final.last_seen_at,
|
||||
lastError: final.last_error, replySnippet: final.replySnippet,
|
||||
endpoint: srv.game_port ? { host: srv.rcon_host ?? config.rcon.host, port: srv.game_port } : null, reason: final.last_error ? final.last_error.split(':')[0] as RconFailure : null,
|
||||
|
||||
+30
-2
@@ -17,7 +17,7 @@ export function availableTimeZones(): string[] {
|
||||
return [...new Set([DEFAULT_TIME_ZONE, 'UTC', ...zones])].sort();
|
||||
}
|
||||
|
||||
type Timestamp = string | number | Date | null | undefined;
|
||||
export type Timestamp = string | number | Date | null | undefined;
|
||||
|
||||
/** Database timestamps without an offset are UTC, never browser-local time. */
|
||||
function parseTimestamp(value: Exclude<Timestamp, null | undefined>): Date {
|
||||
@@ -28,6 +28,12 @@ function parseTimestamp(value: Exclude<Timestamp, null | undefined>): Date {
|
||||
return new Date(value);
|
||||
}
|
||||
|
||||
export function timestampISO(value: Timestamp): string | undefined {
|
||||
if (value === null || value === undefined || value === '') return undefined;
|
||||
const parsed = parseTimestamp(value);
|
||||
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : undefined;
|
||||
}
|
||||
|
||||
export function createTimeFormatter(zone: string) {
|
||||
const timeZone = validTimeZone(zone) ? zone : DEFAULT_TIME_ZONE;
|
||||
const date: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric' };
|
||||
@@ -40,12 +46,34 @@ export function createTimeFormatter(zone: string) {
|
||||
return Number.isFinite(parsed.getTime()) ? format.format(parsed) : '—';
|
||||
};
|
||||
}
|
||||
const absolute = formatter({ ...date, hour: 'numeric', minute: '2-digit', timeZoneName: 'short' });
|
||||
const clock = formatter({ hour: 'numeric', minute: '2-digit' });
|
||||
const calendar = new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: 'numeric', day: 'numeric' });
|
||||
const calendarDay = (value: Date) => {
|
||||
const parts = Object.fromEntries(calendar.formatToParts(value).map(part => [part.type, part.value]));
|
||||
return Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day));
|
||||
};
|
||||
function relative(value: Timestamp, now = Date.now()): string {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
const parsed = parseTimestamp(value);
|
||||
if (!Number.isFinite(parsed.getTime()) || !Number.isFinite(now)) return '—';
|
||||
const age = now - parsed.getTime();
|
||||
if (age < 0) return absolute(parsed);
|
||||
if (age < 5_000) return 'Just now';
|
||||
if (age < 60_000) return `${Math.floor(age / 1000)} s ago`;
|
||||
if (age < 3_600_000) return `${Math.floor(age / 60_000)} min ago`;
|
||||
if (age < 86_400_000) return `${Math.floor(age / 3_600_000)} h ago`;
|
||||
// Calendar arithmetic keeps Yesterday correct across DST and year changes.
|
||||
if (calendarDay(new Date(now)) - calendarDay(parsed) === 86_400_000) return `Yesterday, ${clock(parsed)}`;
|
||||
return absolute(parsed);
|
||||
}
|
||||
return {
|
||||
relative,
|
||||
dateTime: formatter({ ...date, ...time }),
|
||||
date: formatter(date),
|
||||
time: formatter(time),
|
||||
clockTime: formatter({ hour: 'numeric', minute: '2-digit', second: '2-digit' }),
|
||||
minuteTime: formatter({ hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }),
|
||||
period: formatter({ ...date, hour: 'numeric', minute: '2-digit', timeZoneName: 'short' })
|
||||
period: absolute
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { parseActivityQuery, type ActivityQuery, type ActivitySource } from '../activity.js';
|
||||
import { createTimeFormatter } from '../time-zone.js';
|
||||
|
||||
export interface ActivityScope { server?: string; source?: ActivitySource }
|
||||
const keys = ['server', 'host', 'type', 'source', 'state', 'days', 'q', 'limit', 'offset'] as const;
|
||||
|
||||
/** Scoped views always retain their server/source, including after reset or a pasted URL. */
|
||||
export function activityQuery(url: URL, scope: ActivityScope = {}): ActivityQuery {
|
||||
const params = new URLSearchParams(url.searchParams);
|
||||
if (scope.server !== undefined) params.set('server', scope.server);
|
||||
if (scope.source !== undefined) params.set('source', scope.source);
|
||||
return parseActivityQuery(params);
|
||||
}
|
||||
|
||||
export function activityApiHref(url: URL, scope: ActivityScope = {}): string {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(activityQuery(url, scope))) {
|
||||
if (value !== undefined) params.set(key, String(value));
|
||||
}
|
||||
return `/api/activity?${params}`;
|
||||
}
|
||||
|
||||
/** Preserve the containing tab and return context while paging or changing filters. */
|
||||
export function activityHistoryHref(url: URL, changes: Partial<Record<keyof ActivityQuery, string>>): string {
|
||||
const next = new URL(url);
|
||||
if (!Object.hasOwn(changes, 'offset')) next.searchParams.delete('offset');
|
||||
for (const key of keys) {
|
||||
if (changes[key] === undefined) continue;
|
||||
const value = changes[key]?.trim();
|
||||
if (value && !(['offset', 'days'].includes(key) && value === '0')) next.searchParams.set(key, value);
|
||||
else next.searchParams.delete(key);
|
||||
}
|
||||
return next.pathname + next.search + next.hash;
|
||||
}
|
||||
|
||||
/** Recent entries scan as relative time; exact timestamps stay on the time element. */
|
||||
export function activityTimeLabel(value: string, zone: string, now: number): string {
|
||||
const label = createTimeFormatter(zone).relative(value, now);
|
||||
return label === '—' ? 'Time unavailable' : label;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { statusPresentation, type StatusTone } from './status.js';
|
||||
import { plural } from './plural.js';
|
||||
|
||||
export type CampaignServerGroup = 'attention' | 'processing' | 'queued' | 'complete' | 'inactive';
|
||||
|
||||
@@ -29,7 +30,7 @@ export function campaignOutcome(campaign: Pick<CampaignSummary, 'servers' | 'rol
|
||||
const completed = campaign.servers.filter(server => server.restart_state === 'done').length;
|
||||
const live = campaign.servers.filter(server => !server.server_deleted_at);
|
||||
const attention = live.filter(server => ['failed', 'held', 'manual'].includes(server.restart_state));
|
||||
if (attention.length) return { status: 'attention', label: `${attention.length} need attention`, tone: attention.some(server => server.restart_state === 'failed') ? 'danger' : 'warning', completed };
|
||||
if (attention.length) return { status: 'attention', label: `${attention.length} ${plural(attention.length, 'needs', 'need')} attention`, tone: attention.some(server => server.restart_state === 'failed') ? 'danger' : 'warning', completed };
|
||||
if (live.some(server => server.restart_state === 'pending')) return { status: 'progress', label: `${completed}/${campaign.servers.length} complete`, tone: 'info', completed };
|
||||
if (campaign.rolled_back_by) return { status: 'rolled-back', label: 'Rolled back', tone: 'neutral', completed };
|
||||
if (campaign.servers.length && completed === campaign.servers.length) return { status: 'complete', label: 'Complete', tone: 'online', completed };
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { UserRole } from '../auth.js';
|
||||
import { visibleNavigation } from '../navigation.js';
|
||||
import { playerProfileHref } from '../leaderboard.js';
|
||||
import { serverTitle } from './server-labels.js';
|
||||
|
||||
export type PaletteGroup = 'Pages' | 'Actions' | 'Servers' | 'Hosts' | 'Players' | 'Matches';
|
||||
export interface PaletteItem { key: string; group: PaletteGroup; label: string; detail: string; href: string }
|
||||
export interface PaletteServer { name: string; map?: string | null; last_map?: string | null; host?: { name: string } | null }
|
||||
export interface PaletteHost { id: string; name: string; hostname?: string; connectionState?: string }
|
||||
export interface PaletteDirectory { servers: PaletteServer[]; hosts: PaletteHost[]; unavailable: string[] }
|
||||
type PaletteFetch = (input: string, init: RequestInit) => Promise<Response>;
|
||||
export const paletteGroups: PaletteGroup[] = ['Actions', 'Pages', 'Servers', 'Hosts', 'Players', 'Matches'];
|
||||
|
||||
function matches(query: string, ...values: Array<string | null | undefined>) {
|
||||
const text = values.filter(Boolean).join(' ').toLocaleLowerCase();
|
||||
return query.trim().toLocaleLowerCase().split(/\s+/).every(word => text.includes(word));
|
||||
}
|
||||
|
||||
export function palettePageResults(role: UserRole, query: string): PaletteItem[] {
|
||||
const actions: PaletteItem[] = role === 'viewer' ? [] : [{ key: 'action:create', group: 'Actions', label: 'Create server', detail: 'Choose a map and host', href: '/?action=create' }, { key: 'action:manage', group: 'Actions', label: 'Manage servers', detail: 'Server queues and lifecycle actions', href: '/provisioning' }];
|
||||
if (role === 'admin') actions.push({ key: 'action:add-host', group: 'Actions', label: 'Add host', detail: 'Enroll a host agent', href: '/hosts?action=add' });
|
||||
return [...actions.filter(item => matches(query, item.label, item.detail)),
|
||||
...visibleNavigation(role).flatMap(group => group.items.filter(item => matches(query, item.label, group.label)).map(item => ({
|
||||
key: `page:${item.href}`, group: 'Pages' as const, label: item.label, detail: group.label, href: item.href
|
||||
})))];
|
||||
}
|
||||
|
||||
export function paletteDirectoryResults(query: string, directory: PaletteDirectory | null): PaletteItem[] {
|
||||
if (!query.trim() || !directory) return [];
|
||||
const servers: PaletteItem[] = directory.servers.filter(server => matches(query, serverTitle(server.name), server.name, server.map, server.last_map, server.host?.name))
|
||||
.slice(0, 6).map(server => ({ key: `server:${server.name}`, group: 'Servers', label: serverTitle(server.name),
|
||||
detail: server.host?.name ?? 'Game server', href: `/servers/${encodeURIComponent(server.name)}` }));
|
||||
const hosts: PaletteItem[] = directory.hosts.filter(host => matches(query, host.name, host.hostname, host.id))
|
||||
.slice(0, 6).map(host => ({ key: `host:${host.id}`, group: 'Hosts', label: host.name,
|
||||
detail: host.connectionState === 'offline' ? 'Host agent offline' : 'Host', href: `/hosts/${encodeURIComponent(host.id)}` }));
|
||||
return [...servers, ...hosts];
|
||||
}
|
||||
|
||||
/** Match IDs can be opened directly without downloading match history. */
|
||||
export function paletteMatchJump(query: string): PaletteItem[] {
|
||||
const id = /^(?:match\s*#?\s*|#)?([1-9]\d*)$/i.exec(query.trim())?.[1];
|
||||
if (!id || !Number.isSafeInteger(Number(id))) return [];
|
||||
return [{ key: `match:${id}`, group: 'Matches', label: `Open match #${id}`, detail: 'Go to a recorded match', href: `/matches/${id}` }];
|
||||
}
|
||||
|
||||
async function read(url: string, signal: AbortSignal, fetcher: PaletteFetch) {
|
||||
const response = await fetcher(url, { signal, cache: 'no-store' });
|
||||
if (!response.ok) throw new Error('Search request failed.');
|
||||
const body: unknown = await response.json();
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Search response is unavailable.');
|
||||
return body as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Load only the current directory; historical entities use bounded queries below. */
|
||||
export async function loadPaletteDirectory(signal: AbortSignal, fetcher: PaletteFetch = fetch): Promise<PaletteDirectory> {
|
||||
const [servers, hosts] = await Promise.allSettled([
|
||||
read('/api/servers', signal, fetcher), read('/api/hosts', signal, fetcher)
|
||||
]);
|
||||
return {
|
||||
servers: servers.status === 'fulfilled' && Array.isArray(servers.value.servers) ? servers.value.servers.filter(server => server && typeof server.name === 'string') : [],
|
||||
hosts: hosts.status === 'fulfilled' && Array.isArray(hosts.value.hosts) ? hosts.value.hosts.filter(host => host && typeof host.id === 'string' && typeof host.name === 'string') : [],
|
||||
unavailable: [servers.status === 'rejected' ? 'Server' : '', hosts.status === 'rejected' ? 'Host' : ''].filter(Boolean)
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchPaletteRecords(query: string, signal: AbortSignal, fetcher: PaletteFetch = fetch): Promise<{ items: PaletteItem[]; unavailable: string[] }> {
|
||||
const term = query.trim().slice(0, 100);
|
||||
if (term.length < 2) return { items: [], unavailable: [] };
|
||||
const playersParams = new URLSearchParams({ q: term, limit: '5', metric: 'highestRound' });
|
||||
const matchesParams = new URLSearchParams({ q: term, limit: '5', status: 'completed' });
|
||||
const [players, recordedMatches] = await Promise.allSettled([
|
||||
read(`/api/leaderboard?${playersParams}`, signal, fetcher), read(`/api/matches?${matchesParams}`, signal, fetcher)
|
||||
]);
|
||||
const items: PaletteItem[] = [];
|
||||
if (players.status === 'fulfilled' && Array.isArray(players.value.entries)) {
|
||||
for (const player of players.value.entries.slice(0, 5)) {
|
||||
if (!player || typeof player.guid !== 'string' || typeof player.name !== 'string') continue;
|
||||
items.push({ key: `player:${player.guid}`, group: 'Players', label: player.name, detail: 'Player profile', href: playerProfileHref(player.guid) });
|
||||
}
|
||||
}
|
||||
if (recordedMatches.status === 'fulfilled' && Array.isArray(recordedMatches.value.matches)) {
|
||||
for (const match of recordedMatches.value.matches.slice(0, 5)) {
|
||||
if (!match || !Number.isSafeInteger(match.id) || match.id < 1 || match.status !== 'completed' || typeof match.map !== 'string' || typeof match.server !== 'string') continue;
|
||||
items.push({ key: `match:${match.id}`, group: 'Matches', label: `${match.map} · Match #${match.id}`, detail: serverTitle(match.server), href: `/matches/${match.id}` });
|
||||
}
|
||||
}
|
||||
return { items, unavailable: [players.status === 'rejected' ? 'Player' : '', recordedMatches.status === 'rejected' ? 'Match' : ''].filter(Boolean) };
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { activityTypes, activitySources, activityStates, type ActivityPage, type ActivityQuery, type ActivitySource } from '$lib/activity.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { activityApiHref, activityHistoryHref, activityQuery } from '$ui/activity-history.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import { fleet } from '$ui/stores/fleet.js';
|
||||
import { uiNow } from '$ui/stores/servers.js';
|
||||
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import Dialog from './primitives/Dialog.svelte';
|
||||
import EmptyState from './primitives/EmptyState.svelte';
|
||||
import StatusBadge from './primitives/StatusBadge.svelte';
|
||||
import Freshness from './Freshness.svelte';
|
||||
import HistoricalServerIdentity from './HistoricalServerIdentity.svelte';
|
||||
|
||||
let { server, source, initial }: { server?: string; source?: ActivitySource; initial?: ActivityPage } = $props();
|
||||
const id = $props.id();
|
||||
const headingId = `${id}-activity-heading`;
|
||||
let data = $state<ActivityPage | undefined>(untrack(() => initial));
|
||||
let loading = $state(!untrack(() => initial));
|
||||
let error = $state('');
|
||||
let retry = $state(0);
|
||||
let filtersOpen = $state(false);
|
||||
let checkedAt = $state<number | null>(untrack(() => initial ? Date.now() : null));
|
||||
let results = $state<HTMLOListElement>();
|
||||
let search = $state(untrack(() => activityQuery($page.url, { server, source }).q ?? ''));
|
||||
let pendingUrl: URL | undefined;
|
||||
let navigationId = 0;
|
||||
const filters = $derived(activityQuery($page.url, { server, source }));
|
||||
const apiUrl = $derived(activityApiHref($page.url, { server, source }));
|
||||
let loadedUrl = $state(untrack(() => initial ? apiUrl : ''));
|
||||
const filterCount = $derived([filters.type, filters.state, source === undefined ? filters.source : '', server === undefined ? filters.server : '', filters.host, filters.days, filters.q].filter(Boolean).length);
|
||||
const serverOptions = $derived.by(() => {
|
||||
const names = new Set([...$fleet.servers.map(item => item.name), ...(data?.rows.flatMap(row => row.server ? [row.server] : []) ?? [])]);
|
||||
if (filters.server) names.add(filters.server);
|
||||
return [...names].sort((a, b) => serverTitle(a).localeCompare(serverTitle(b)));
|
||||
});
|
||||
const hostOptions = $derived.by(() => {
|
||||
const hosts = new Map<string, string>();
|
||||
for (const item of $fleet.servers) if (item.host) hosts.set(item.host.id, item.host.name);
|
||||
for (const row of data?.rows ?? []) if (row.hostId) hosts.set(row.hostId, row.hostName ?? 'Historical host');
|
||||
if (filters.host && !hosts.has(filters.host)) hosts.set(filters.host, 'Selected host');
|
||||
return [...hosts].sort((a, b) => a[1].localeCompare(b[1]));
|
||||
});
|
||||
|
||||
$effect(() => { search = filters.q ?? ''; });
|
||||
$effect(() => {
|
||||
const report = initial;
|
||||
if (report) untrack(() => { data = report; loadedUrl = apiUrl; loading = false; error = ''; checkedAt = Date.now(); });
|
||||
});
|
||||
$effect(() => {
|
||||
const url = apiUrl;
|
||||
retry;
|
||||
untrack(() => { if (loadedUrl !== url) loading = true; error = ''; });
|
||||
return pollVisible(async signal => {
|
||||
try {
|
||||
const response = await fetch(url, { signal, cache: 'no-store' });
|
||||
if (!response.ok) throw new Error('Could not load activity.');
|
||||
const body: ActivityPage = await response.json();
|
||||
if (signal.aborted || apiUrl !== url) return;
|
||||
// Keep a focused operation link in place when newer rows arrive.
|
||||
if (loadedUrl !== url || !results?.contains(document.activeElement)) { data = body; checkedAt = Date.now(); }
|
||||
loadedUrl = url; loading = false; error = '';
|
||||
} catch (cause) {
|
||||
if (signal.aborted || apiUrl !== url) return;
|
||||
error = cause instanceof Error ? cause.message : 'Could not load activity.';
|
||||
loading = false;
|
||||
return false;
|
||||
}
|
||||
}, 10_000, untrack(() => loadedUrl !== url || retry > 0));
|
||||
});
|
||||
function update(changes: Partial<Record<keyof ActivityQuery, string>>) {
|
||||
const href = activityHistoryHref(pendingUrl ?? $page.url, changes);
|
||||
pendingUrl = new URL(href, $page.url);
|
||||
const id = ++navigationId;
|
||||
void goto(href, { noScroll: true, keepFocus: true })
|
||||
.catch(() => { error = 'Could not update activity filters. Please try again.'; })
|
||||
.finally(() => { if (id === navigationId) pendingUrl = undefined; });
|
||||
}
|
||||
function reset() { update({ type: '', state: '', source: '', server: '', host: '', days: '', q: '' }); }
|
||||
function submitSearch() { update({ q: search.trim() }); }
|
||||
</script>
|
||||
|
||||
<section class="activity-log" aria-labelledby={headingId}>
|
||||
<h2 class="sr-only" id={headingId}>Activity history</h2>
|
||||
{#snippet filterFields(suffix: string)}
|
||||
<div class="filters">
|
||||
<label for={`activity-type${suffix}`}>Type<select id={`activity-type${suffix}`} value={filters.type ?? ''} onchange={event => update({ type: event.currentTarget.value })}><option value="">All types</option>{#each Object.entries(activityTypes) as [value, label]}<option {value}>{label}</option>{/each}</select></label>
|
||||
<label for={`activity-state${suffix}`}>State<select id={`activity-state${suffix}`} value={filters.state ?? ''} onchange={event => update({ state: event.currentTarget.value })}><option value="">All states</option>{#each Object.entries(activityStates) as [value, label]}<option {value}>{label}</option>{/each}</select></label>
|
||||
{#if source === undefined}<label for={`activity-source${suffix}`}>Source<select id={`activity-source${suffix}`} value={filters.source ?? ''} onchange={event => update({ source: event.currentTarget.value })}><option value="">All sources</option>{#each Object.entries(activitySources) as [value, label]}<option {value}>{label}</option>{/each}</select></label>{/if}
|
||||
{#if server === undefined}<label for={`activity-server${suffix}`}>Server<select id={`activity-server${suffix}`} value={filters.server ?? ''} onchange={event => update({ server: event.currentTarget.value })}><option value="">All servers</option>{#each serverOptions as name}<option value={name}>{serverTitle(name)}</option>{/each}</select></label>{/if}
|
||||
<label for={`activity-host${suffix}`}>Host<select id={`activity-host${suffix}`} value={filters.host ?? ''} onchange={event => update({ host: event.currentTarget.value })}><option value="">All hosts</option>{#each hostOptions as [value, label]}<option {value}>{label}</option>{/each}</select></label>
|
||||
<label for={`activity-days${suffix}`}>Date range<select id={`activity-days${suffix}`} value={filters.days ?? 0} onchange={event => update({ days: event.currentTarget.value })}><option value="0">All retained history</option><option value="1">Last 24 hours</option><option value="7">Last 7 days</option><option value="30">Last 30 days</option><option value="90">Last 90 days</option>{#if filters.days && ![1, 7, 30, 90].includes(filters.days)}<option value={filters.days}>Last {filters.days} days</option>{/if}</select></label>
|
||||
<form class="search" role="search" onsubmit={event => { event.preventDefault(); submitSearch(); }}><label for={`activity-search${suffix}`}>Search activity<input id={`activity-search${suffix}`} type="search" bind:value={search} maxlength="200" placeholder="Server, host, or activity…" /></label><Button type="submit" size="sm">Search</Button></form>
|
||||
</div>
|
||||
{/snippet}
|
||||
<div class="desktop-filters">{@render filterFields('')}</div>
|
||||
<div class="mobile-filters"><span>{data?.total ?? '—'} {data?.total === 1 ? 'activity' : 'activities'}{source ? ` · ${activitySources[source]}` : ''}</span><Button size="sm" onclick={() => filtersOpen = true}>Filters{filterCount ? ` · ${filterCount}` : ''}</Button></div>
|
||||
<Dialog bind:open={filtersOpen} title="Activity filters" description="Choose the activity to show." drawer>
|
||||
{@render filterFields('-mobile')}
|
||||
{#snippet actions()}<Button variant="ghost" onclick={reset}>Clear filters</Button><Button variant="primary" onclick={() => { if (search.trim() !== (filters.q ?? '')) submitSearch(); filtersOpen = false; }}>Show results</Button>{/snippet}
|
||||
</Dialog>
|
||||
<div class="context"><span>Newest first · Times in {$timeZone}</span>{#if filterCount}<button class="text-button" onclick={reset}>Clear filters</button>{/if}{#if !error}<Freshness updatedAt={checkedAt} intervalLabel="Updates every 10 seconds" />{/if}</div>
|
||||
{#if error}<div class="error" role="alert"><span>{error}{loadedUrl === apiUrl && data ? ' Showing the last successful update.' : ''}</span><Button size="sm" onclick={() => retry++}>Retry</Button></div>{/if}
|
||||
{#if loading}<EmptyState title="Loading activity…" />
|
||||
{:else if loadedUrl !== apiUrl && error}<EmptyState title="Activity unavailable" description="Retry to load this selection." />
|
||||
{:else if data?.rows.length}
|
||||
<ol class="activity-list" bind:this={results} aria-label="Newest activity first">
|
||||
{#each data.rows as row (row.id)}
|
||||
<li class="activity-row">
|
||||
<div class="event"><h3><a href={contextHref(row.href, $page.url)} aria-label={`${row.title}${row.server ? ` · ${serverTitle(row.server)}` : row.hostName ? ` · ${row.hostName}` : ''} · ${$timeFormat.period(row.at)}`}>{row.title}</a></h3>{#if row.detail}<p>{row.detail}</p>{/if}
|
||||
<div class="resources">{#if row.server && server === undefined}<HistoricalServerIdentity name={row.server} deletedAt={row.serverDeletedAt} href={contextHref(`/servers/${encodeURIComponent(row.server)}`, $page.url)} />{/if}
|
||||
{#if row.hostName}<span class="host-name">{row.hostName}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="outcome"><StatusBadge state={row.state === 'succeeded' ? 'done' : row.state} /><time datetime={row.at} title={$timeFormat.dateTime(row.at)} aria-label={$timeFormat.dateTime(row.at)}>{$timeFormat.relative(row.at, $uiNow)}</time>
|
||||
<span class="sources">{(row.sources.length ? row.sources : [row.source]).map(item => activitySources[item]).join(' · ')}</span>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{:else if !error}<EmptyState title={filterCount ? 'No matching activity' : 'No activity recorded yet'} description={filterCount ? 'Try another type, state, server, host, or date range.' : 'Server operations, file changes, maintenance, and host work appear here.'}>
|
||||
{#snippet actions()}{#if filterCount}<Button size="sm" onclick={reset}>Clear filters</Button>{/if}{/snippet}
|
||||
</EmptyState>{/if}
|
||||
{#if data && !loading && loadedUrl === apiUrl && (data.total > 0 || data.offset > 0)}
|
||||
<div class="pagination"><span>{data.rows.length ? `${data.offset + 1}–${data.offset + data.rows.length} of ${data.total}` : 'No activities on this page'}</span>
|
||||
<label>Per page<select aria-label="Activities per page" value={data.limit} onchange={event => update({ limit: event.currentTarget.value })}>{#each [...new Set([25, 50, 100, data.limit])].sort((a, b) => a - b) as size}<option value={size}>{size}</option>{/each}</select></label>
|
||||
{#if data.total > data.limit || data.offset > 0}<nav aria-label="Activity pages"><Button size="sm" disabled={data.offset === 0} href={activityHistoryHref($page.url, { offset: String(Math.max(0, data.offset - data.limit)) })}>Previous</Button><Button size="sm" disabled={data.offset + data.limit >= data.total} href={activityHistoryHref($page.url, { offset: String(data.offset + data.limit) })}>Next</Button></nav>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.activity-log { display: grid; gap: var(--space-4); min-width: 0; }
|
||||
.filters { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); align-items: end; gap: var(--space-3); }
|
||||
label { display: grid; gap: 6px; color: var(--text-secondary); font-size: var(--text-xs); min-width: 0; }
|
||||
select, input { width: 100%; min-height: 40px; padding: 8px 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: var(--text-primary); font: inherit; font-size: var(--text-sm); }
|
||||
.search { grid-column: span 2; display: flex; align-items: end; gap: var(--space-2); min-width: 0; }
|
||||
.search label { flex: 1; }
|
||||
.context, .pagination { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2) var(--space-4); font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.context > :last-child { margin-left: auto; }
|
||||
.text-button { padding: 4px 0; border: 0; background: none; color: var(--accent-primary-hover); cursor: pointer; font: inherit; }
|
||||
.activity-list { padding: 0; margin: 0; list-style: none; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; }
|
||||
.activity-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(180px, auto); gap: var(--space-3); padding: var(--space-4); }
|
||||
.activity-row + .activity-row { border-top: 1px solid var(--border-subtle); }
|
||||
.activity-row:focus-within { background: var(--surface-raised); }
|
||||
h3, p { margin: 0; }
|
||||
h3 { font-size: var(--text-sm); font-weight: var(--weight-medium); }
|
||||
a { color: var(--accent-primary-hover); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
a:focus-visible, button:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
|
||||
.event { min-width: 0; overflow-wrap: anywhere; }
|
||||
.event p { margin-top: var(--space-1); color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
|
||||
.resources { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-1) var(--space-3); margin-top: var(--space-2); font-size: var(--text-xs); }
|
||||
.host-name, .sources, time { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.outcome { display: flex; flex-direction: column; align-items: flex-end; gap: var(--space-2); text-align: right; }
|
||||
time { font-variant-numeric: tabular-nums; }
|
||||
.sources { max-width: 32ch; overflow-wrap: anywhere; }
|
||||
.pagination { justify-content: space-between; }
|
||||
.pagination label { display: flex; align-items: center; gap: var(--space-2); }
|
||||
.pagination select { width: auto; min-height: 36px; }
|
||||
.pagination nav { display: flex; gap: var(--space-2); }
|
||||
.error { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-3); color: var(--state-warning); font-size: var(--text-sm); }
|
||||
.mobile-filters { display: none; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
||||
@media (max-width: 640px) {
|
||||
.desktop-filters { display: none; }
|
||||
.mobile-filters { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); font-size: var(--text-sm); }
|
||||
.filters { grid-template-columns: minmax(0, 1fr); }
|
||||
.search { grid-column: auto; }
|
||||
.context { gap: var(--space-1) var(--space-2); }
|
||||
.context > :last-child { margin-left: 0; }
|
||||
.activity-row { grid-template-columns: minmax(0, 1fr); padding: var(--space-3); gap: var(--space-2); }
|
||||
.outcome { flex-direction: row; flex-wrap: wrap; align-items: center; text-align: left; }
|
||||
.sources { max-width: none; }
|
||||
.pagination { gap: var(--space-3); }
|
||||
.pagination nav { margin-left: auto; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import Dialog from './primitives/Dialog.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
let { open = $bindable(false), onadded }: { open?: boolean; onadded: () => Promise<unknown> } = $props();
|
||||
@@ -30,7 +31,7 @@
|
||||
<p role="status">{name} created. Run this command with administrator access on that machine.</p>
|
||||
<textarea readonly aria-label="Host installation command" rows="6" value={enrollment.command}></textarea>
|
||||
<div class="actions"><Button onclick={copy}>{copied ? 'Copied' : 'Copy command'}</Button><Button variant="primary" href={`/hosts/${enrollment.hostId}?tab=setup`}>Open host setup</Button></div>
|
||||
<p class="muted">This single-use command expires {new Date(enrollment.expiresAt).toLocaleString()}. Generate a new installer from host settings if it expires.</p>
|
||||
<p class="muted">This single-use command expires {$timeFormat.dateTime(enrollment.expiresAt)}. Generate a new installer from host settings if it expires.</p>
|
||||
<Button variant="ghost" onclick={() => { enrollment = null; name = ''; copied = false; error = ''; }}>Add another host</Button>
|
||||
{:else}
|
||||
<p>Use a Debian 12 or 13 amd64 machine. The agent connects over HTTPS and prepares the runtime and game files automatically.</p>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import BackLink from '$ui/components/BackLink.svelte';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { confirmAction } from '$ui/confirm.js';
|
||||
@@ -288,7 +288,6 @@
|
||||
<Breadcrumbs items={[{ label: crumbLabel, href: basePath }, { label: a.name }]} />
|
||||
{/snippet}
|
||||
</SectionHeader>
|
||||
<BackLink fallback={basePath} />
|
||||
|
||||
{#if scopeComplete}<div class="scope-complete" role="status"><strong>Scope saved.</strong> {scopeComplete.composed ? `Intended configuration may change on ${scopeComplete.count} eligible servers.` : scopeComplete.detached ? `Inventory tracking detached on ${scopeComplete.detached} servers; live files remain.` : `${scopeComplete.count} servers are in the old or new scope.`} Maintenance detects resulting drift and rebuilds eligible servers when automation is enabled. <a href={scopeComplete.href}>View drift recovery →</a></div>{/if}
|
||||
{#if latestVersion}<Card padding="lg"><div class="release-summary"><div><h2>Latest {artifactVersionLabel(latestVersion)}</h2><StatusBadge state={latestVersion.id === a.current_version_id ? 'promoted' : latestVersion.state} /><p>{latestVersion.notes || 'No version notes provided.'}</p></div>{#if runningBetaId !== null}<Button variant="primary" href={contextHref(`/betas/${runningBetaId}`, $page.url)}>View active beta</Button>{:else if latestVersion.id !== a.current_version_id}<Button variant="primary" href={contextHref(betaSelectionHref([{ scriptId: id, versionId: latestVersion.id }]), $page.url)}>Test latest version</Button>{:else}<span class="dim">Latest version is promoted</span>{/if}</div></Card>{/if}
|
||||
@@ -355,7 +354,7 @@
|
||||
</div></details>
|
||||
</Card>
|
||||
|
||||
{#if installedVersions.length}<details class="installed"><summary>Recorded installed files · {installedVersions.length} servers</summary><p>Last recorded file writes, not a verification of the currently running process.</p><ul>{#each installedVersions as installed}<li><a href={'/servers/' + encodeURIComponent(installed.server)}>{installed.server}</a> · v{installed.version} · {$timeFormat.dateTime(installed.recordedAt)}</li>{/each}</ul></details>{:else if family !== 'config'}<p class="dim">No installed file versions recorded. Eligibility does not mean a file is installed.</p>{/if}
|
||||
{#if installedVersions.length}<details class="installed"><summary>Recorded installed files · {installedVersions.length} servers</summary><p>Last recorded file writes, not a verification of the currently running process.</p><ul>{#each installedVersions as installed}<li><a href={'/servers/' + encodeURIComponent(installed.server)}>{serverTitle(installed.server)}</a> · v{installed.version} · <RelativeTime value={installed.recordedAt} /></li>{/each}</ul></details>{:else if family !== 'config'}<p class="dim">No installed file versions recorded. Eligibility does not mean a file is installed.</p>{/if}
|
||||
|
||||
{#if a.kind === 'config' || a.kind === 'baseline'}
|
||||
<ConfigEditor {id} kind={a.kind} {versions} currentVersionId={a.current_version_id}
|
||||
@@ -377,7 +376,7 @@
|
||||
<span class="v-notes">{v.notes ?? ''}
|
||||
{#if v.source}<a href={v.source.release_url} target="_blank" rel="noreferrer">GitHub · {v.source.asset_name} ↗</a>{/if}
|
||||
</span>
|
||||
<time class="v-when" datetime={v.uploaded_at}>{$timeFormat.dateTime(v.uploaded_at)}</time>
|
||||
<span class="v-when"><RelativeTime value={v.uploaded_at} /></span>
|
||||
{#if a.current_version_id === v.id}
|
||||
<Badge variant="online" size="sm">Promoted</Badge>
|
||||
{:else}
|
||||
|
||||
@@ -218,9 +218,9 @@
|
||||
<style>
|
||||
.builder { display: grid; gap: 16px; }
|
||||
h4, h5, p, dl, dd { margin: 0; }
|
||||
h4 { font-size: 19px; font-weight: 600; }
|
||||
h5 { font-size: 15px; font-weight: 550; line-height: 1.5; }
|
||||
p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
|
||||
h4 { font-size: var(--text-lg); font-weight: 600; }
|
||||
h5 { font-size: var(--text-base); font-weight: 550; line-height: 1.5; }
|
||||
p { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; }
|
||||
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
.eyebrow { display: block; font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .1em; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; }
|
||||
.pill { padding: 5px 9px; border-radius: 99px; font-size: var(--text-xs); color: var(--text-secondary); border: 1px solid var(--border-strong); white-space: nowrap; }
|
||||
@@ -229,32 +229,32 @@
|
||||
.preset:hover { border-color: var(--accent-primary-hover); }
|
||||
.preset.selected { border-color: var(--accent-primary); background: color-mix(in oklab, var(--accent-primary) 10%, var(--surface-sunken)); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent-primary) 30%, transparent); }
|
||||
.preset-top { display: flex; align-items: center; justify-content: space-between; }
|
||||
.preset-number, .step-number { font-family: var(--font-mono); color: var(--text-secondary); font-size: 12px; }
|
||||
.choice { width: 19px; height: 19px; border: 1px solid var(--border-strong); border-radius: 50%; display: grid; place-items: center; font-size: 12px; }
|
||||
.preset-number, .step-number { font-family: var(--font-numeric); color: var(--text-secondary); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
|
||||
.choice { width: 19px; height: 19px; border: 1px solid var(--border-strong); border-radius: 50%; display: grid; place-items: center; font-size: var(--text-xs); }
|
||||
.selected .choice { background: var(--accent-primary); color: var(--text-on-accent); border-color: var(--accent-primary); }
|
||||
.preset > strong { font-size: 15px; }
|
||||
.preset-description { font-size: 12px; line-height: 1.6; color: var(--text-secondary); }
|
||||
.preset ul { padding-left: 16px; margin: 2px 0 10px; font-size: 12px; line-height: 1.65; flex: 1; }
|
||||
.preset > strong { font-size: var(--text-base); }
|
||||
.preset-description { font-size: var(--text-xs); line-height: 1.6; color: var(--text-secondary); }
|
||||
.preset ul { padding-left: 16px; margin: 2px 0 10px; font-size: var(--text-xs); line-height: 1.65; flex: 1; }
|
||||
.preset li + li { margin-top: 5px; }
|
||||
.preset li::marker { color: var(--accent-primary-hover); }
|
||||
.preset-footer { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; border-top: 1px solid var(--border-subtle); padding-top: 12px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.preset-footer > span { color: var(--accent-primary-hover); }
|
||||
.feedback { color: var(--accent-primary-hover); font-size: 12px; min-height: 18px; }
|
||||
.feedback { color: var(--accent-primary-hover); font-size: var(--text-xs); min-height: 18px; }
|
||||
.error { color: var(--state-danger); }
|
||||
.active-plan { display: grid; gap: 18px; padding: 22px; background: color-mix(in oklab, var(--surface-raised) 25%, var(--surface)); border: 1px solid var(--border-strong); border-radius: var(--radius-md); }
|
||||
.plan-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; padding: 16px 0; border-top: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle); }
|
||||
dt { color: var(--text-secondary); font-size: var(--text-xs); margin-bottom: 6px; }
|
||||
dd { font-size: 13px; font-weight: 550; line-height: 1.5; }
|
||||
dd { font-size: var(--text-sm); font-weight: 550; line-height: 1.5; }
|
||||
dd small { font-size: var(--text-xs); font-weight: 400; color: var(--text-secondary); }
|
||||
.plan-caption { font-size: 12px; }
|
||||
.telemetry-note { border-left: 2px solid var(--accent-primary); background: color-mix(in oklab, var(--accent-primary) 6%, transparent); padding: 10px 12px; font-size: 12px; }
|
||||
.plan-caption { font-size: var(--text-xs); }
|
||||
.telemetry-note { border-left: 2px solid var(--accent-primary); background: color-mix(in oklab, var(--accent-primary) 6%, transparent); padding: 10px 12px; font-size: var(--text-xs); }
|
||||
.telemetry-note span { color: var(--accent-primary-hover); margin-right: 5px; }
|
||||
.requirements { display: grid; gap: 10px; }
|
||||
.requirement { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface-sunken); min-width: 0; overflow: hidden; }
|
||||
.requirement-heading { display: flex; align-items: center; gap: 14px; padding: 16px; }
|
||||
.requirement-copy { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
.requirement-copy .eyebrow { font-size: var(--text-xs); margin-bottom: 4px; }
|
||||
.remove { padding: 3px 8px; font-size: 21px; background: transparent; border: 0; color: var(--text-secondary); }
|
||||
.remove { padding: 3px 8px; font-size: var(--text-lg); background: transparent; border: 0; color: var(--text-secondary); }
|
||||
.remove:hover { color: var(--state-danger); }
|
||||
.editor { border-top: 1px solid var(--border-subtle); }
|
||||
.editor > summary { padding: 10px 16px; font-size: var(--text-xs); color: var(--accent-primary-hover); }
|
||||
@@ -262,24 +262,24 @@
|
||||
.editor-fields { display: grid; gap: 12px; padding: 6px 16px 16px; }
|
||||
.fields, .add-row { display: flex; flex-wrap: wrap; gap: 12px; align-items: end; }
|
||||
.add-row label { flex: 1 1 220px; }
|
||||
label { display: grid; gap: 7px; flex: 1 1 180px; min-width: 0; font-size: 12px; color: var(--text-secondary); }
|
||||
label { display: grid; gap: 7px; flex: 1 1 180px; min-width: 0; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
input, select, button { color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-strong); border-radius: 6px; padding: 10px 12px; min-width: 0; font: inherit; }
|
||||
input, select { width: 100%; font-size: 13px; min-height: 40px; box-sizing: border-box; border-color: var(--border-control); }
|
||||
input, select { width: 100%; font-size: var(--text-sm); min-height: 40px; box-sizing: border-box; border-color: var(--border-control); }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
button:focus-visible, summary:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 3px; }
|
||||
.secondary { background: var(--surface-raised); font-size: 12px; min-height: 40px; }
|
||||
.secondary { background: var(--surface-raised); font-size: var(--text-xs); min-height: 40px; }
|
||||
.secondary:hover:not(:disabled) { border-color: var(--accent-primary-hover); }
|
||||
.policy-settings { display: grid; gap: 8px; }
|
||||
.policy { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||||
.policy > summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 18px; font-size: 13px; font-weight: 550; }
|
||||
.policy > summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 18px; font-size: var(--text-sm); font-weight: 550; }
|
||||
.policy small { display: block; font-size: var(--text-xs); color: var(--text-secondary); font-weight: 400; line-height: 1.6; margin-top: 5px; }
|
||||
.edit-hint { color: var(--accent-primary-hover); font-size: var(--text-xs); }
|
||||
.policy-body { padding: 0 18px 18px; display: grid; gap: 12px; }
|
||||
.policy-body.fields { display: flex; }
|
||||
summary { cursor: pointer; }
|
||||
.empty-plan { padding: 22px; text-align: center; border: 1px dashed var(--border-strong); border-radius: var(--radius-md); }
|
||||
.empty-plan strong { display: block; margin-bottom: 8px; font-size: 14px; }
|
||||
.empty-plan strong { display: block; margin-bottom: 8px; font-size: var(--text-base); }
|
||||
@media (max-width: 850px) { .presets { grid-template-columns: 1fr; } .preset { padding: 16px; } .plan-facts { grid-template-columns: 1fr; gap: 12px; } }
|
||||
@media (max-width: 480px) { .active-plan { padding: 14px; } .requirement-heading { padding: 12px; gap: 10px; } .editor > summary > span { display: block; margin: 5px 0 0; } .add-row { align-items: stretch; } .add-row button { width: 100%; } }
|
||||
@media (prefers-reduced-motion: reduce) { .preset { transition: none; } }
|
||||
|
||||
@@ -95,6 +95,6 @@
|
||||
<style>
|
||||
.files, label { display:grid; gap:12px; } .files { margin:20px 0; }
|
||||
.files label { padding:16px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); }
|
||||
p { line-height:1.6; } code, .hash { overflow-wrap:anywhere; } .hash, label span { color:var(--text-secondary); font-size:13px; }
|
||||
p { line-height:1.6; } code, .hash { overflow-wrap:anywhere; } .hash, label span { color:var(--text-secondary); font-size: var(--text-sm); }
|
||||
li { margin:8px 0; } textarea { width:100%; } section { margin-top:24px; }
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { tick } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
@@ -43,7 +45,6 @@
|
||||
&& (!evidenceKind || (evidenceKind === 'gameplay' ? ['game_start','round_change','heartbeat','game_end'].includes(event.kind) : evidenceKind === 'health' ? ['health','status-sample'].includes(event.kind) : event.kind === evidenceKind))
|
||||
&& (!evidenceQuery || `${betaEvidenceSummary(event)} ${event.payload}`.toLowerCase().includes(evidenceQuery.toLowerCase()))));
|
||||
$effect(() => { evidenceServer; evidenceKind; evidenceQuery; evidenceLimit = 25; });
|
||||
function reportAge(at:string) { const minutes=Math.max(0,Math.floor((Date.now()-Date.parse(at))/60000));return minutes<1?'less than a minute ago':minutes<60?`${minutes}m ago`:`${Math.floor(minutes/60)}h ${minutes%60}m ago`; }
|
||||
export async function action(action:string, extra:Record<string,string>={}) {
|
||||
if (action === 'retry' && !await confirmAction({title:'Start a fresh test attempt?',message:`Reset current progress and checklist sign-offs on ${test.servers.length} deployed servers. They will restart when empty and must collect fresh evidence. Previous evidence remains in history. The deadline stays ${deadline ? $timeFormat.dateTime(deadline) : 'unchanged'}.`,confirmLabel:'Reset and restart testing'})) return;
|
||||
busy = true; error = ''; message = '';
|
||||
@@ -121,8 +122,8 @@
|
||||
<summary>Evidence ({test.evidence.length}{test.evidence.length===200 ? ' most recent' : ''})</summary>
|
||||
{#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"><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}
|
||||
<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}>{serverTitle(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"><HistoricalServerIdentity name={event.server} deletedAt={event.server_deleted_at}/><span class="time"><RelativeTime value={event.received_at} /></span></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}
|
||||
@@ -130,7 +131,7 @@
|
||||
</section>
|
||||
|
||||
{#snippet checks(server: BetaTestView['servers'][number])}
|
||||
{#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}
|
||||
{#if test.plan.requirements.some(needsGameTelemetry)}<p class="observation">{#if server.observation?.lastGameplayAt}Latest gameplay: {server.observation.lastEvent?.replaceAll('_',' ')} · <span class="time"><RelativeTime value={server.observation.lastGameplayAt} /></span> · {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}>
|
||||
@@ -157,16 +158,16 @@
|
||||
.server-detail { background:var(--surface); }
|
||||
.deployment-details,.deployment-links { display:flex; flex-wrap:wrap; gap:8px 20px; margin:12px 0; font-size:var(--text-sm); }
|
||||
@media(max-width:720px) { table { min-width:620px; } }
|
||||
section { display:grid; gap:16px; } h2,p { margin:0; } h2 { font-size:18px; }
|
||||
p,span,time { font-size:13px; line-height:1.6; color:var(--text-secondary); } a { color:var(--accent-primary-hover); }
|
||||
section { display:grid; gap:16px; } h2,p { margin:0; } h2 { font-size: var(--text-md); }
|
||||
p,span,.time { font-size: var(--text-sm); line-height:1.6; color:var(--text-secondary); } a { color:var(--accent-primary-hover); }
|
||||
.heading,.actions,.filters { display:flex; flex-wrap:wrap; align-items:center; gap:12px; } .heading { justify-content:space-between; }
|
||||
.server { border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:16px; }
|
||||
.server > summary { display:flex; flex-wrap:wrap; gap:8px 16px; } summary { cursor:pointer; font-size:14px; } .server > summary::before { content:'▸'; } .server[open] > summary::before { content:'▾'; }
|
||||
.server > summary { display:flex; flex-wrap:wrap; gap:8px 16px; } summary { cursor:pointer; font-size: var(--text-base); } .server > summary::before { content:'▸'; } .server[open] > summary::before { content:'▾'; }
|
||||
.requirement { display:grid; gap:8px; margin-top:20px; border-top:1px solid var(--border-subtle); padding-top:16px; } .result { font-weight:600; margin-right:6px; color:inherit; }
|
||||
progress { width:100%; height:8px; accent-color:var(--state-online); } .failed { color:var(--state-danger); }
|
||||
.observation,.guidance { margin-top:12px; } .evidence-link { justify-self:start; padding:6px 0; color:var(--accent-primary-hover); border:0; background:transparent; cursor:pointer; font-size:12px; }
|
||||
.observation,.guidance { margin-top:12px; } .evidence-link { justify-self:start; padding:6px 0; color:var(--accent-primary-hover); border:0; background:transparent; cursor:pointer; font-size: var(--text-xs); }
|
||||
.signoff { display:flex; gap:12px; align-items:end; flex-wrap:wrap; } .signoff label { flex:1 1 240px; }
|
||||
label { display:grid; gap:6px; font-size:13px; color:var(--text-secondary); } input,select,textarea { min-height:40px; padding:8px 10px; border:1px solid var(--border-control); border-radius:6px; background:var(--surface-sunken); color:var(--text-primary); font:inherit; max-width:100%; } textarea { resize:vertical; width:100%; box-sizing:border-box; }
|
||||
label { display:grid; gap:6px; font-size: var(--text-sm); color:var(--text-secondary); } input,select,textarea { min-height:40px; padding:8px 10px; border:1px solid var(--border-control); border-radius:6px; background:var(--surface-sunken); color:var(--text-primary); font:inherit; max-width:100%; } textarea { resize:vertical; width:100%; box-sizing:border-box; }
|
||||
.filters { margin:16px 0; align-items:end; } .filters label { flex:1 1 180px; min-width:0; }
|
||||
.evidence { display:grid; gap:8px; padding:16px 0; border-bottom:1px solid var(--border-subtle); } .evidence-section { scroll-margin-top:80px; } pre { white-space:pre-wrap; overflow-wrap:anywhere; font-size:12px; } .evidence-section > p { margin-top:12px; }
|
||||
.evidence { display:grid; gap:8px; padding:16px 0; border-bottom:1px solid var(--border-subtle); } .evidence-section { scroll-margin-top:80px; } pre { white-space:pre-wrap; overflow-wrap:anywhere; font-size: var(--text-xs); } .evidence-section > p { margin-top:12px; }
|
||||
</style>
|
||||
|
||||
@@ -98,20 +98,20 @@
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.validation { padding: 12px; border-left: 3px solid var(--state-warning); color: var(--text-primary); font-size: 14px; line-height: 1.6; }
|
||||
.validation { padding: 12px; border-left: 3px solid var(--state-warning); color: var(--text-primary); font-size: var(--text-base); line-height: 1.6; }
|
||||
dialog { width: min(820px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); }
|
||||
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }.builder { padding: 28px; display: grid; gap: 20px; }
|
||||
header, .section-title, .actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }.actions { justify-content: flex-end; }
|
||||
.eyebrow { display: block; color: var(--text-secondary); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .12em; margin-bottom: 8px; } h2, h3, p { margin: 0; } h2 { font-size: 25px; letter-spacing: -.03em; } h3 { font-size: 16px; }.muted { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
|
||||
form, .lineup { display: grid; gap: 18px; }.identity { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } label { display: grid; gap: 7px; font-size: 12px; min-width: 0; } input, select { width: 100%; min-width: 0; box-sizing: border-box; padding: 11px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: inherit; font: inherit; }
|
||||
.lineup { border-block: 1px solid var(--border-subtle); padding: 20px 0; }.map-row { display: grid; grid-template-columns: 24px 1fr 1.5fr 80px 24px; gap: 12px; align-items: end; padding: 14px; background: var(--surface-sunken); border-radius: var(--radius-md); }.number { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-secondary); align-self: center; }.count { font-size: 12px; padding: 6px 10px; border: 1px solid var(--border-strong); border-radius: 99px; }.close, .remove { background: none; border: 0; color: var(--text-secondary); font-size: 25px; cursor: pointer; }.remove { padding: 7px 0; }.error { color: var(--state-danger); font-size: 13px; }button:disabled { opacity: .5; cursor: default; }
|
||||
.eyebrow { display: block; color: var(--text-secondary); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .12em; margin-bottom: 8px; } h2, h3, p { margin: 0; } h2 { font-size: var(--text-xl); letter-spacing: -.03em; } h3 { font-size: var(--text-md); }.muted { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; }
|
||||
form, .lineup { display: grid; gap: 18px; }.identity { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } label { display: grid; gap: 7px; font-size: var(--text-xs); min-width: 0; } input, select { width: 100%; min-width: 0; box-sizing: border-box; padding: 11px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: inherit; font: inherit; }
|
||||
.lineup { border-block: 1px solid var(--border-subtle); padding: 20px 0; }.map-row { display: grid; grid-template-columns: 24px 1fr 1.5fr 80px 24px; gap: 12px; align-items: end; padding: 14px; background: var(--surface-sunken); border-radius: var(--radius-md); }.number { font-family: var(--font-numeric); font-size: var(--text-xs); color: var(--text-secondary); align-self: center; font-variant-numeric: tabular-nums; }.count { font-size: var(--text-xs); padding: 6px 10px; border: 1px solid var(--border-strong); border-radius: 99px; }.close, .remove { background: none; border: 0; color: var(--text-secondary); font-size: var(--text-xl); cursor: pointer; }.remove { padding: 7px 0; }.error { color: var(--state-danger); font-size: var(--text-sm); }button:disabled { opacity: .5; cursor: default; }
|
||||
@media (max-width: 620px) {
|
||||
.builder { padding: 20px; }
|
||||
header { display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: start; }
|
||||
.close, .remove { min-width: 44px; }
|
||||
.identity { grid-template-columns: 1fr; }
|
||||
.map-row { grid-template-columns: minmax(0, 1fr) 44px; }
|
||||
.number { display: none; }
|
||||
.number { display: none; font-variant-numeric: tabular-nums; }
|
||||
.map-row label:first-of-type, .map-row label:nth-of-type(2) { grid-column: 1 / -1; }
|
||||
.actions { align-items: stretch; flex-direction: column; }
|
||||
}
|
||||
|
||||
@@ -21,18 +21,17 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-family: var(--font-display);
|
||||
color: var(--cream);
|
||||
letter-spacing: var(--tracking-display);
|
||||
font-family: 'Alfa Slab One', Georgia, serif;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.12em;
|
||||
line-height: 1;
|
||||
animation: cherry-fizz 6s ease-in-out infinite;
|
||||
}
|
||||
.mark-sm .word { font-size: 14px; letter-spacing: 1.5px; }
|
||||
.mark-md .word { font-size: 18px; }
|
||||
.mark-lg .word { font-size: 32px; letter-spacing: 3px; }
|
||||
.mark-sm .word { font-size: var(--text-sm); letter-spacing: 1.5px; }
|
||||
.mark-md .word { font-size: var(--text-md); }
|
||||
.mark-lg .word { font-size: var(--text-2xl); letter-spacing: 3px; }
|
||||
|
||||
.bolt {
|
||||
fill: var(--spark-500);
|
||||
fill: var(--accent-primary-hover);
|
||||
filter: drop-shadow(0 0 5px rgba(124, 212, 255, 0.75));
|
||||
}
|
||||
.mark-sm .bolt { width: 12px; height: 20px; }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import type { CapacitySamplePage } from '$lib/hosts.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
|
||||
@@ -54,7 +55,7 @@
|
||||
<table><caption>CPU and memory peaks per container and minute; times are local.</caption>
|
||||
<thead><tr><th scope="col">Time</th>{#if scope === 'fleet'}<th scope="col">Host</th>{/if}<th scope="col">Server</th><th scope="col">Phase</th><th scope="col">CPU cores</th><th scope="col">Memory</th></tr></thead>
|
||||
<tbody>{#each result.samples as sample}
|
||||
<tr><td><time datetime={sample.timestamp}>{new Date(sample.timestamp).toLocaleString()}</time></td>
|
||||
<tr><td><RelativeTime value={sample.timestamp} /></td>
|
||||
{#if scope === 'fleet'}<td>{sample.hostName ?? sample.hostId}</td>{/if}
|
||||
<td>{sample.server ?? sample.instanceId}<small title={sample.instanceId}>Generation {sample.generation}</small></td>
|
||||
<td>{sample.phase === 'startup' ? 'Starting' : 'Running'}</td><td>{sample.cpuCores.toFixed(3)}</td><td>{(sample.memoryBytes / 1048576).toFixed(1)} MiB</td></tr>
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import Freshness from './Freshness.svelte';
|
||||
|
||||
let { server, history }: { server?: string; history?: ContextualChatEntry[] } = $props();
|
||||
let { server, history, hostOffline = false, hostName }: { server?: string; history?: ContextualChatEntry[]; hostOffline?: boolean; hostName?: string } = $props();
|
||||
let updatedAt = $state<number | null>(null);
|
||||
let liveEntries = $state<ContextualChatEntry[]>([]);
|
||||
const entries = $derived(history ?? liveEntries);
|
||||
let timeline = $derived(groupChatTimeline(entries));
|
||||
@@ -24,7 +26,7 @@
|
||||
let controller: AbortController | null = null;
|
||||
|
||||
async function refresh(manual = false) {
|
||||
if (!server) return;
|
||||
if (!server || hostOffline) return;
|
||||
if (loading) {
|
||||
if (manual) manualRefreshing = true;
|
||||
return;
|
||||
@@ -37,11 +39,11 @@
|
||||
const response = await fetch(`/api/servers/${encodeURIComponent(server)}/chat`, { signal: request.signal });
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok || !data?.ok) throw new Error('Could not refresh chat. Try again shortly.');
|
||||
if (request.signal.aborted) return;
|
||||
if (request.signal.aborted || hostOffline) return;
|
||||
if (paused && !manual) return;
|
||||
liveEntries = data.entries ?? [];
|
||||
missing = !!data.missing;
|
||||
liveLoaded = true;
|
||||
liveLoaded = true; updatedAt = Date.now();
|
||||
error = '';
|
||||
await tick();
|
||||
if (follow && body) body.scrollTop = body.scrollHeight;
|
||||
@@ -50,6 +52,8 @@
|
||||
} finally { loading = false; manualRefreshing = false; }
|
||||
}
|
||||
|
||||
$effect(() => { if (hostOffline) controller?.abort(); });
|
||||
|
||||
function onScroll() {
|
||||
if (body) follow = body.scrollHeight - body.scrollTop - body.clientHeight < 40;
|
||||
}
|
||||
@@ -93,17 +97,17 @@
|
||||
|
||||
<section aria-label="Player chat">
|
||||
<div class="chat-head">
|
||||
<div class="chat-title"><h2>Chat</h2><span class="update-cadence">{server ? paused ? 'Paused' : 'Live · 5s' : 'Match history'}</span></div>
|
||||
<div class="chat-title"><h2>Chat</h2>{#if !server}<span class="update-cadence">Match history</span>{:else if hostOffline}<span class="update-cadence">Host offline</span>{:else if paused}<span class="update-cadence">Paused</span>{:else if !error}<Freshness {updatedAt} intervalLabel="every 5 s" />{/if}</div>
|
||||
{#if server}
|
||||
<div class="actions">
|
||||
{#if hasMessages}<Button size="sm" variant="ghost" onclick={() => paused = !paused}>{paused ? 'Resume' : 'Pause'}</Button>{/if}
|
||||
{#if !follow}<Button size="sm" variant="ghost" onclick={latest}>Latest messages</Button>{/if}
|
||||
<Button size="sm" onclick={() => refresh(true)} loading={manualRefreshing} ariaLabel={manualRefreshing ? 'Refreshing chat' : 'Refresh chat'}>Refresh</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if error}<p class="ui-error" role="alert">{error} {loaded ? 'Showing the last loaded messages.' : ''}</p>{/if}
|
||||
{#if !loaded && !error}
|
||||
{#if hostOffline}<p class="outage">Chat resumes when {hostName ?? 'the host agent'} reconnects.{loaded ? ' Showing the last loaded messages.' : ''}</p>
|
||||
{:else if error}<p class="ui-error" role="alert">Chat updates paused. {loaded ? 'Showing the last loaded messages.' : 'Could not load messages.'} <Button size="sm" onclick={() => refresh(true)} loading={manualRefreshing}>Retry</Button></p>{/if}
|
||||
{#if !loaded && !error && !hostOffline}
|
||||
<p class="empty" role="status">Loading chat…</p>
|
||||
{:else if loaded && !hasMessages}
|
||||
<p class="empty">{history !== undefined ? 'No chat messages were saved for this match.' : missing ? 'Waiting for the game log. Player messages will appear here.' : 'No player messages in this match yet.'}</p>
|
||||
@@ -152,12 +156,13 @@
|
||||
.chat-title { gap: var(--space-2) var(--space-3); }
|
||||
h2 { margin: 0; font-size: var(--text-lg); }
|
||||
.update-cadence { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.outage { color: var(--state-warning); font-size: var(--text-sm); line-height: var(--leading-body); }
|
||||
.empty { color: var(--text-secondary); font-size: var(--text-sm); padding: 8px 0; margin-bottom: 0; }
|
||||
.messages { max-height: 360px; overflow: auto; margin-top: var(--space-4); }
|
||||
.messages:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--accent-primary); border-radius: var(--radius-sm); }
|
||||
ol { list-style: none; padding: 0; margin: 0; }
|
||||
.message { display: grid; grid-template-columns: 6rem minmax(0, 1fr); align-items: baseline; column-gap: var(--space-4); padding: var(--space-3) 0; }
|
||||
.message-clock, .number { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
.message-clock, .number { font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }
|
||||
.message-clock { font-size: var(--text-xs); color: var(--text-secondary); white-space: nowrap; }
|
||||
.message-content { min-width: 0; }
|
||||
.message-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--space-1) var(--space-3); }
|
||||
@@ -165,7 +170,7 @@
|
||||
.context { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--space-1) var(--space-3); margin: 0; color: var(--text-secondary); font-size: var(--text-xs); font-weight: var(--weight-regular); }
|
||||
.context > div { display: flex; align-items: baseline; gap: var(--space-1); white-space: nowrap; }
|
||||
.context > div + div::before { content: '·'; margin-right: var(--space-1); color: var(--text-tertiary); }
|
||||
.context dd { margin: 0; font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
.context dd { margin: 0; font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }
|
||||
.message p { margin: var(--space-1) 0 0; font-size: var(--text-base); line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.match-events { padding: var(--space-1) 0; }
|
||||
.match-divider { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) 0; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { UserRole } from '$lib/auth.js';
|
||||
import Dialog from './primitives/Dialog.svelte';
|
||||
import { loadPaletteDirectory, paletteDirectoryResults, paletteGroups, paletteMatchJump, palettePageResults, searchPaletteRecords,
|
||||
type PaletteDirectory, type PaletteItem } from '$ui/command-palette.js';
|
||||
|
||||
let { open = $bindable(false), role }: { open?: boolean; role: UserRole } = $props();
|
||||
const id = $props.id();
|
||||
let input = $state<HTMLInputElement>();
|
||||
let query = $state('');
|
||||
let directory = $state<PaletteDirectory | null>(null);
|
||||
let records = $state<PaletteItem[]>([]);
|
||||
let directoryLoading = $state(false);
|
||||
let recordsLoading = $state(false);
|
||||
let recordErrors = $state<string[]>([]);
|
||||
let selected = $state('');
|
||||
let navigationError = $state('');
|
||||
let navigating = $state(false);
|
||||
const items = $derived.by(() => {
|
||||
const unique = new Map<string, PaletteItem>();
|
||||
for (const item of [...palettePageResults(role, query), ...paletteDirectoryResults(query, directory), ...paletteMatchJump(query), ...records]) unique.set(item.key, item);
|
||||
return paletteGroups.flatMap(group => [...unique.values()].filter(item => item.group === group));
|
||||
});
|
||||
const activeIndex = $derived(Math.max(0, items.findIndex(item => item.key === selected)));
|
||||
const unavailable = $derived([...(directory?.unavailable ?? []), ...recordErrors]);
|
||||
const searching = $derived(directoryLoading || recordsLoading);
|
||||
|
||||
$effect(() => {
|
||||
if (!open) { query = ''; records = []; directory = null; navigationError = ''; return; }
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 15_000);
|
||||
directoryLoading = true;
|
||||
void loadPaletteDirectory(controller.signal).then(result => { if (!cancelled) directory = result; })
|
||||
.finally(() => { clearTimeout(timeout); if (!cancelled) directoryLoading = false; });
|
||||
void tick().then(() => { if (!cancelled) input?.focus(); });
|
||||
return () => { cancelled = true; controller.abort(); clearTimeout(timeout); };
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const term = query.trim();
|
||||
selected = ''; records = []; recordErrors = [];
|
||||
if (!open || term.length < 2) { recordsLoading = false; return; }
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
recordsLoading = true;
|
||||
const debounce = setTimeout(() => {
|
||||
timeout = setTimeout(() => controller.abort(), 15_000);
|
||||
void searchPaletteRecords(term, controller.signal).then(result => {
|
||||
if (!cancelled) { records = result.items; recordErrors = result.unavailable; }
|
||||
}).finally(() => { clearTimeout(timeout); if (!cancelled) recordsLoading = false; });
|
||||
}, 220);
|
||||
return () => { cancelled = true; clearTimeout(debounce); clearTimeout(timeout); controller.abort(); };
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const option = activeIndex;
|
||||
if (!open || !items.length) return;
|
||||
void tick().then(() => document.getElementById(`${id}-option-${option}`)?.scrollIntoView({ block: 'nearest' }));
|
||||
});
|
||||
|
||||
async function choose(item: PaletteItem) {
|
||||
if (navigating) return;
|
||||
navigating = true; navigationError = '';
|
||||
try { await goto(item.href); open = false; }
|
||||
catch { navigationError = 'Could not open that result. Try again.'; }
|
||||
finally { navigating = false; }
|
||||
}
|
||||
|
||||
function keydown(event: KeyboardEvent) {
|
||||
if (!items.length || navigating) return;
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
const movement = event.key === 'ArrowDown' ? 1 : -1;
|
||||
selected = items[(activeIndex + movement + items.length) % items.length].key;
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault(); void choose(items[activeIndex]);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog bind:open title="Search and commands" description="Jump to a page, server, host, player, or match.">
|
||||
<div class="palette">
|
||||
<label class="sr-only" for={`${id}-search`}>Search Electric Cherry</label>
|
||||
<input bind:this={input} bind:value={query} id={`${id}-search`} type="search" maxlength="100" placeholder="Search Electric Cherry…"
|
||||
role="combobox" aria-autocomplete="list" aria-expanded={open} aria-controls={`${id}-results`}
|
||||
aria-activedescendant={items.length ? `${id}-option-${activeIndex}` : undefined} aria-describedby={`${id}-hint`}
|
||||
autocomplete="off" onkeydown={keydown} />
|
||||
<p class="hint" id={`${id}-hint`}>Try a lobby, player name, or #match number. <span>↑ ↓ to choose · Enter to open</span></p>
|
||||
<div class="results" id={`${id}-results`} role="listbox" aria-label="Search results" aria-busy={searching || navigating}>
|
||||
{#each paletteGroups as group}
|
||||
{@const groupItems = items.filter(item => item.group === group)}
|
||||
{#if groupItems.length}
|
||||
<div role="group" aria-labelledby={`${id}-${group}`}>
|
||||
<div class="group-label" id={`${id}-${group}`}>{group}</div>
|
||||
{#each groupItems as item (item.key)}
|
||||
{@const index = items.indexOf(item)}
|
||||
<button type="button" id={`${id}-option-${index}`} role="option" tabindex="-1" aria-selected={index === activeIndex}
|
||||
class:active={index === activeIndex} disabled={navigating} onclick={() => void choose(item)} onpointermove={() => selected = item.key}>
|
||||
<span class="result-label">{item.label}</span><span class="result-detail">{item.detail}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<p class="status" role="status">{searching ? 'Searching…' : items.length ? `${items.length} results` : 'No matches. Try a different name or a match number.'}</p>
|
||||
{#if unavailable.length}<p class="error">{unavailable.join(', ')} search is unavailable. Other results are still available.</p>{/if}
|
||||
{#if navigationError}<p class="error" role="alert">{navigationError}</p>{/if}
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<style>
|
||||
.palette { display:grid; gap:var(--space-3); min-width:0; }
|
||||
input { width:100%; min-height:44px; padding:var(--space-3); font:inherit; font-size:var(--text-base); color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); }
|
||||
.hint,.status,.error { margin:0; color:var(--text-secondary); font-size:var(--text-xs); line-height:1.5; }
|
||||
.hint span { display:block; }
|
||||
.results { min-height:40px; max-height:min(55dvh,480px); overflow:auto; overscroll-behavior:contain; position:relative; }
|
||||
.group-label { padding:var(--space-3) var(--space-2) var(--space-1); font-size:var(--text-xs); color:var(--text-tertiary); font-weight:var(--weight-semibold); }
|
||||
button { display:grid; gap:var(--space-1); width:100%; min-height:48px; text-align:left; border:1px solid transparent; border-radius:var(--radius-sm); padding:var(--space-2) var(--space-3); background:transparent; color:var(--text-primary); font:inherit; cursor:pointer; }
|
||||
button.active { background:var(--surface-raised); border-color:var(--accent-primary); }
|
||||
button:disabled { cursor:wait; }
|
||||
.result-label { font-size:var(--text-sm); overflow-wrap:anywhere; }
|
||||
.result-detail { color:var(--text-secondary); font-size:var(--text-xs); overflow-wrap:anywhere; }
|
||||
.error { color:var(--state-warning); }
|
||||
.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; border:0; }
|
||||
</style>
|
||||
@@ -144,7 +144,7 @@
|
||||
.setting summary::-webkit-details-marker { display: none; }
|
||||
.setting-main { display: flex; gap: 12px; justify-content: space-between; flex-wrap: wrap; }
|
||||
code, strong { font: var(--text-xs)/1.6 var(--font-mono); overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
strong { color: var(--accent-primary-hover); }
|
||||
strong { color: var(--text-primary); }
|
||||
.source-label { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: var(--text-xs); color: var(--text-secondary); margin-top: 6px; }
|
||||
.source-label::before { content: '▸'; } .setting[open] .source-label::before { content: '▾'; }
|
||||
b { font-weight: 400; color: var(--state-warning); }
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import ConfigTextEditor from './ConfigTextEditor.svelte';
|
||||
import ConfigInspector from './ConfigInspector.svelte';
|
||||
import { libraryLimit, saveLibraryContext } from '$ui/library-context.js';
|
||||
import { gameTitle } from '$ui/server-labels.js';
|
||||
|
||||
interface Version { id: number; version: number; state: 'untested' | 'tested' | 'test-failed'; uploaded_at: string; }
|
||||
interface Artifact {
|
||||
@@ -106,7 +107,7 @@
|
||||
|
||||
<div class="page">
|
||||
<SectionHeader title="Configs" subtitle="Manage game baselines and scoped changes.">
|
||||
{#snippet actions()}<Button variant="primary" onclick={() => openCreate()}>New config</Button>{/snippet}
|
||||
{#snippet actions()}{#if view === 'inspect' || loading || artifacts.length || error}<Button variant="primary" onclick={() => openCreate()}>New config</Button>{/if}{/snippet}
|
||||
</SectionHeader>
|
||||
<div class="navigation"><Tabs tabs={[{id:'library',label:'Library'},{id:'inspect',label:'Inspect a server'}]} bind:value={view} ariaLabel="Config views" idPrefix="config-tabs" panelId="config-panel" /><Button variant="ghost" size="sm" onclick={load} loading={loading}>Refresh</Button></div>
|
||||
<div id="config-panel" role="tabpanel" aria-labelledby={'config-tabs-' + view} class="config-panel">
|
||||
@@ -116,15 +117,19 @@
|
||||
{:else}
|
||||
{#if loading || artifacts.length || error}<div class="library-toolbar">
|
||||
<input type="search" class="search" aria-label="Search configs" bind:value={query} placeholder="Search configs, maps, or servers…" />
|
||||
<select aria-label="Filter by game" bind:value={game}><option value="">All games</option>{#each gameOptions as option}<option value={option}>{option.toUpperCase()}</option>{/each}</select>
|
||||
<select aria-label="Filter by game" bind:value={game}><option value="">All games</option>{#each gameOptions as option}<option value={option}>{gameTitle(option)}</option>{/each}</select>
|
||||
<select aria-label="Filter by scope" bind:value={scope}><option value="">All scopes</option><option value="game">Game</option><option value="map">Map</option></select>
|
||||
<select aria-label="Filter by status" bind:value={status}><option value="">All statuses</option><option value="pending">Awaiting promotion</option><option value="promoted">Has promoted version</option><option value="beta">Active beta</option><option value="failed">Test failed</option></select>
|
||||
{#if query || game || scope || status}<Button size="sm" variant="ghost" onclick={resetFilters}>Clear</Button>{/if}
|
||||
{#if filtered.length && (query || game || scope || status)}<Button size="sm" variant="ghost" onclick={resetFilters}>Clear</Button>{/if}
|
||||
</div>{/if}
|
||||
{#if loading}<div class="loading" role="status">Loading your config library…</div>
|
||||
{:else if !artifacts.length && !error}
|
||||
<div class="welcome"><span class="eyebrow">Start with a foundation</span><h2>Your fleet, configured your way.</h2><p>Add a baseline for each game, then layer on small, focused changes for a game or map. Every change gets its own version.</p><Button variant="primary" onclick={() => openCreate('baseline')}>Create your first baseline</Button></div>
|
||||
{:else if !filtered.length && !error}<EmptyState title="No matching configs" description="Try a different search or clear your filters." />
|
||||
<EmptyState title="No configs yet" description="Configs store game baselines and scoped changes. Create a baseline to start managing configuration versions.">
|
||||
{#snippet actions()}<Button variant="primary" onclick={() => openCreate('baseline')}>Create baseline</Button>{/snippet}
|
||||
</EmptyState>
|
||||
{:else if !filtered.length && !error}<EmptyState title="No matching configs" description="Try a different search or clear your filters.">
|
||||
{#snippet actions()}<Button onclick={resetFilters}>Clear filters</Button>{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
{#if filteredBaselines.length}
|
||||
<section class="section"><h2>Baselines <span>{filteredBaselines.length}</span></h2>{@render configTable(filteredBaselines)}</section>
|
||||
@@ -143,7 +148,7 @@
|
||||
<thead><tr><th>Config</th><th>Scope</th><th>Version / status</th><th>Eligible servers</th></tr></thead>
|
||||
<tbody>{#each rows as artifact (artifact.id)}<tr>
|
||||
<td><a class="name" href={contextHref(`/configs/${artifact.id}`, $page.url)}>{artifact.name}</a></td>
|
||||
<td>{artifact.scope_type === 'game' ? `All ${artifact.scope_value?.toUpperCase()} servers` : artifact.scope_type === 'map' ? mapLabel(artifact.scope_value ?? '') : 'Unscoped'}</td>
|
||||
<td>{artifact.scope_type === 'game' ? `All ${gameTitle(artifact.scope_value)} servers` : artifact.scope_type === 'map' ? mapLabel(artifact.scope_value ?? '') : 'Unscoped'}</td>
|
||||
<td><LibraryVersions current={artifact.currentVersion} latest={artifact.latestVersion} betaId={artifact.runningBetaId} /></td>
|
||||
<td class="numeric">{artifact.servers.length}</td>
|
||||
</tr>{/each}</tbody>
|
||||
@@ -154,7 +159,7 @@
|
||||
<form onsubmit={create}>
|
||||
<div class="dialog-heading"><div><span class="eyebrow">Create version 1</span><h2 id="new-config-title">New config</h2></div><Button variant="ghost" onclick={() => dialog.close()} disabled={saving} ariaLabel="Close new config">✕</Button></div>
|
||||
<div class="kind-picker"><button type="button" class:active={kind === 'config'} aria-pressed={kind === 'config'} onclick={() => kind = 'config'} disabled={saving}><strong>Overlay</strong><span>Override selected settings</span></button><button type="button" class:active={kind === 'baseline'} aria-pressed={kind === 'baseline'} onclick={() => kind = 'baseline'} disabled={saving}><strong>Baseline</strong><span>A complete config for a game</span></button></div>
|
||||
<div class="form-grid"><label>Name<input bind:value={name} placeholder={kind === 'config' ? 'No AFK timeout' : 'T5 baseline'} required disabled={saving} /></label><label>Game<select aria-label="Game" bind:value={scopeGame} onchange={() => scopeMap = ''} required disabled={saving}><option value="" disabled>Choose a game</option>{#each gameOptions as option}<option value={option}>{option.toUpperCase()}</option>{/each}</select></label>
|
||||
<div class="form-grid"><label>Name<input bind:value={name} placeholder={kind === 'config' ? 'No AFK timeout' : 'Black Ops I baseline'} required disabled={saving} /></label><label>Game<select aria-label="Game" bind:value={scopeGame} onchange={() => scopeMap = ''} required disabled={saving}><option value="" disabled>Choose a game</option>{#each gameOptions as option}<option value={option}>{gameTitle(option)}</option>{/each}</select></label>
|
||||
{#if kind === 'config'}<label>Apply to<select aria-label="Apply to" bind:value={scopeType} disabled={saving}><option value="game">Whole game</option><option value="map">One map</option></select></label>{#if scopeType === 'map'}<label>Map<select aria-label="Map" bind:value={scopeMap} required disabled={saving || !scopeGame}><option value="" disabled>Choose a map</option>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as option}<option value={option}>{option}</option>{/each}</select></label>{/if}{/if}
|
||||
</div>
|
||||
{#if baselineExists}<p class="warning">This game already has a baseline. Open it from the library to save a new version.</p>{/if}
|
||||
@@ -181,8 +186,6 @@
|
||||
.section { display: flex; flex-direction: column; gap: 14px; }
|
||||
h2 { font-size: var(--text-md); margin: 0; letter-spacing: var(--tracking-tight); } h2 span { font-size: var(--text-xs); color: var(--text-secondary); font-weight: 400; margin-left: 8px; }
|
||||
.loading { padding: 60px 0; text-align: center; color: var(--text-secondary); }
|
||||
.welcome { padding: 56px 32px; border-radius: var(--radius-lg); background: var(--surface); border: 1px solid var(--border-strong); text-align: center; }
|
||||
.welcome h2 { margin-top: 12px; font-size: var(--text-2xl); } .welcome p { color: var(--text-secondary); max-width: 540px; margin: 16px auto 24px; }
|
||||
.eyebrow { color: var(--accent-primary-hover); font-size: var(--text-xs); }
|
||||
.error { color: var(--state-danger); padding: 12px; background: var(--surface-raised); border-radius: var(--radius-sm); font-size: var(--text-sm); }
|
||||
.warning { color: var(--state-warning); font-size: var(--text-sm); }
|
||||
@@ -199,7 +202,6 @@
|
||||
.dialog-actions { flex-wrap: wrap; } .dialog-actions p { flex: 1 1 240px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
@media (max-width:600px) { .form-grid { grid-template-columns:1fr; } form { padding:18px; } .navigation { gap:4px; flex-wrap:wrap; } }
|
||||
.config-panel { display:grid; gap:20px; }
|
||||
.welcome { padding:24px; }
|
||||
.loading { padding:24px 0; }
|
||||
input:not([type='checkbox']):not([type='file']), select { border-color: var(--border-control); }
|
||||
</style>
|
||||
|
||||
@@ -286,7 +286,7 @@
|
||||
fieldset label.selected { background: var(--surface-raised); border-color: var(--accent-primary); }
|
||||
fieldset input { margin: 0; }
|
||||
.change-summary { display: grid; gap: 8px; padding: 14px 16px; background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||||
.change-summary > strong { font-size: 17px; line-height: 1.4; }
|
||||
.change-summary > strong { font-size: var(--text-md); line-height: 1.4; }
|
||||
.readiness { display: flex; flex-wrap: wrap; gap: 6px 16px; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.readiness:empty { display: none; }
|
||||
p { margin: 0; font-size: var(--text-sm); line-height: 1.5; }
|
||||
|
||||
@@ -209,7 +209,7 @@
|
||||
{#snippet actions()}
|
||||
<div class="launch-actions">
|
||||
<p class:ready={canLaunch && !unconfirmedLaunch} role="status">{footerStatus}</p>
|
||||
<div><Button variant="ghost" disabled={launching} onclick={() => open = false}>Cancel</Button><Button variant="primary" loading={launching} disabled={!canLaunch} onclick={launch}>{unconfirmedLaunch ? 'Retry launch' : 'Launch server'}</Button></div>
|
||||
<div><Button variant="ghost" disabled={launching} onclick={() => open = false}>Cancel</Button><Button variant="primary" loading={launching} disabled={!canLaunch} onclick={launch}>{unconfirmedLaunch ? 'Retry creation' : 'Create server'}</Button></div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Dialog>
|
||||
@@ -220,7 +220,7 @@
|
||||
label { display: grid; gap: 8px; color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
select { width: 100%; }
|
||||
.summary { display: grid; gap: 8px; padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface-sunken); }
|
||||
.summary > strong { font-size: 17px; line-height: 1.4; }
|
||||
.summary > strong { font-size: var(--text-md); line-height: 1.4; }
|
||||
p { margin: 0; font-size: var(--text-sm); line-height: 1.5; }
|
||||
.muted { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.readiness { display: flex; flex-wrap: wrap; gap: 8px 14px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { lifecycleImpact } from '$ui/lifecycle-actions.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import Dialog from './primitives/Dialog.svelte';
|
||||
|
||||
let { open = $bindable(false), name, players }: { open?: boolean; name: string; players: number | null } = $props();
|
||||
let dialog = $state<HTMLDialogElement>();
|
||||
let { open = $bindable(false), name, players, round = null }: { open?: boolean; name: string; players: number | null; round?: number | null } = $props();
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
$effect(() => {
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) { error = ''; dialog.showModal(); }
|
||||
else if (!open && dialog.open) dialog.close();
|
||||
if (open) error = '';
|
||||
});
|
||||
|
||||
async function destroy() {
|
||||
@@ -19,7 +19,7 @@
|
||||
try {
|
||||
const res = await fetch(`/api/servers/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||||
const body = await res.json();
|
||||
if (!res.ok || !body.ok || !body.deploymentId) throw new Error(body.error ?? 'Could not queue destruction. Please retry.');
|
||||
if (!res.ok || !body.ok || !body.deploymentId) throw new Error(body.error ?? 'Could not queue server removal. Please retry.');
|
||||
await goto(`/provisioning/${encodeURIComponent(body.deploymentId)}`);
|
||||
open = false;
|
||||
} catch (err) { error = (err as Error).message; }
|
||||
@@ -27,33 +27,24 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} aria-labelledby="destroy-title" onclose={() => open = false}
|
||||
oncancel={(event) => { if (submitting) event.preventDefault(); }}>
|
||||
<form onsubmit={(event) => { event.preventDefault(); void destroy(); }}>
|
||||
<h2 id="destroy-title">Queue server removal?</h2>
|
||||
<p class="name">{name}</p>
|
||||
<p>This stops and removes the container, its writable server files, and campaign details. Shared game libraries, provisioning history, and the saved server key are retained.</p>
|
||||
<div class="notice">
|
||||
{#if players !== null && players > 0}<strong>{players} player{players === 1 ? '' : 's'} currently online.</strong>{/if}
|
||||
Removal waits until a live check confirms the server is empty. Follow progress or cancel the waiting request on the provisioning page.
|
||||
</div>
|
||||
<Dialog bind:open title={`Remove ${serverTitle(name)}?`} busy={submitting}>
|
||||
<div class="fields">
|
||||
<p class="notice">{lifecycleImpact('destroy', players, round)}</p>
|
||||
<p>This stops and removes the container, its writable server files, and registration. Shared game libraries, recorded history, and the saved server key are retained.</p>
|
||||
<p>Follow progress or cancel the waiting request on the provisioning page.</p>
|
||||
<p class="irreversible">You can cancel while waiting. Once removal begins, it cannot be undone.</p>
|
||||
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
||||
<footer>
|
||||
<Button variant="ghost" disabled={submitting} onclick={() => open = false}>Cancel</Button>
|
||||
<Button variant="danger" type="submit" loading={submitting}>Queue server removal</Button>
|
||||
</footer>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
{#snippet actions()}
|
||||
<Button disabled={submitting} onclick={() => open = false}>Cancel</Button>
|
||||
<Button variant="danger" disabled={submitting} loading={submitting} onclick={destroy}>Remove server when empty</Button>
|
||||
{/snippet}
|
||||
</Dialog>
|
||||
|
||||
<style>
|
||||
dialog { width: min(520px, calc(100vw - 32px)); padding: 0; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); color: var(--text-primary); }
|
||||
dialog::backdrop { background: rgb(0 0 0 / .65); backdrop-filter: blur(4px); }
|
||||
form { padding: 28px; } h2 { margin: 0 0 12px; font-size: 22px; }
|
||||
p { line-height: 1.6; color: var(--text-secondary); }
|
||||
.name { font-family: var(--font-mono); color: var(--text-primary); overflow-wrap: anywhere; }
|
||||
.fields { display: grid; gap: 16px; }
|
||||
p { margin: 0; line-height: 1.6; color: var(--text-secondary); }
|
||||
.notice { padding: 16px; background: var(--surface-raised); border-radius: var(--radius-md); line-height: 1.6; }
|
||||
strong { display: block; color: var(--state-warning); margin-bottom: 4px; }
|
||||
.irreversible { font-size: 13px; } .error { color: var(--state-danger); }
|
||||
footer { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 12px; margin-top: 24px; }
|
||||
.notice { color: var(--text-primary); }
|
||||
.irreversible { font-size: var(--text-sm); } .error { color: var(--state-danger); }
|
||||
</style>
|
||||
|
||||
@@ -51,9 +51,9 @@
|
||||
|
||||
<style>
|
||||
.diff {
|
||||
background: var(--bg-page);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
margin: 10px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -61,14 +61,14 @@
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-nav);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-size: var(--text-sm);
|
||||
align-items: center;
|
||||
}
|
||||
.verb {
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--r-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
@@ -78,35 +78,35 @@
|
||||
.verb-added {
|
||||
background: rgba(124, 212, 255, 0.10);
|
||||
border-color: rgba(124, 212, 255, 0.3);
|
||||
color: var(--spark-500);
|
||||
color: var(--state-online);
|
||||
}
|
||||
.verb-updated {
|
||||
background: rgba(124, 192, 245, 0.10);
|
||||
border-color: rgba(124, 192, 245, 0.28);
|
||||
color: var(--cream);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.verb-removed {
|
||||
background: rgba(255, 48, 80, 0.1);
|
||||
border-color: rgba(255, 48, 80, 0.3);
|
||||
color: var(--danger-500);
|
||||
color: var(--state-danger);
|
||||
}
|
||||
.verb-held {
|
||||
background: rgba(255, 176, 32, 0.1);
|
||||
border-color: rgba(255, 176, 32, 0.3);
|
||||
color: var(--amber-500);
|
||||
color: var(--state-warning);
|
||||
}
|
||||
.path { color: var(--ink-100); font-family: var(--font-mono); font-size: var(--text-mono); }
|
||||
.path { color: var(--text-primary); font-family: var(--font-mono); font-size: var(--text-sm); }
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-mono);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-mono);
|
||||
color: var(--ink-300);
|
||||
color: var(--text-secondary);
|
||||
max-height: 540px;
|
||||
overflow: auto;
|
||||
}
|
||||
.ln { display: block; }
|
||||
.ln-add { background: rgba(124, 212, 255, 0.08); color: var(--spark-400); }
|
||||
.ln-add { background: color-mix(in srgb, var(--state-online) 8%, transparent); color: var(--state-online); }
|
||||
.ln-del { background: rgba(255, 48, 80, 0.08); color: #ff8a9a; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
let { updatedAt = null, error = '', intervalLabel = '', onRetry, paused = false, retryDisabled = false }:
|
||||
{ updatedAt?: number | null; error?: string; intervalLabel?: string; onRetry?: () => void; paused?: boolean; retryDisabled?: boolean } = $props();
|
||||
let now = $state(Date.now());
|
||||
onMount(() => { const timer = setInterval(() => now = Date.now(), 1000); return () => clearInterval(timer); });
|
||||
// These are local receipt times, so their age uses the browser clock. The
|
||||
// shared formatter still supplies the same language and exact tooltip.
|
||||
const known = $derived(updatedAt !== null && Number.isFinite(updatedAt));
|
||||
</script>
|
||||
<div class="freshness" class:failed={!!error}>
|
||||
{#if error}<span>{error}</span>{#if onRetry}<button type="button" disabled={retryDisabled} onclick={onRetry}>Retry</button>{/if}
|
||||
{:else}<span>{paused ? 'Paused' : known ? 'Live' : 'Waiting for update'}{#if known}{' · updated '}<time datetime={new Date(updatedAt!).toISOString()} title={$timeFormat.dateTime(updatedAt!)}>{$timeFormat.relative(updatedAt!, now)}</time>{/if}{!paused && intervalLabel ? ` · ${intervalLabel}` : ''}</span>{/if}
|
||||
</div>
|
||||
<style>
|
||||
.freshness { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
|
||||
.failed { color: var(--state-warning); }
|
||||
button { background: none; border: 0; padding: var(--space-1); color: var(--accent-primary-hover); font: inherit; text-decoration: underline; cursor: pointer; }
|
||||
</style>
|
||||
@@ -27,7 +27,7 @@
|
||||
{@const host = hosts.find(host => host.id === cache.hostId)}
|
||||
{@const waiting = latest && cache.status === 'missing'}
|
||||
<tr>
|
||||
<td><a href={`/hosts/${cache.hostId}`}>{host?.name ?? cache.hostId}</a>{#if !host?.connected}<span class="muted"> · Offline</span>{/if}</td>
|
||||
<td><a href={`/hosts/${cache.hostId}`}>{host?.name ?? cache.hostId}</a>{#if !host?.connected}<span class="muted">{' · Offline'}</span>{/if}</td>
|
||||
<td><Badge variant={cache.status === 'failed' ? 'danger' : cache.status === 'ready' ? 'online' : 'neutral'}>{waiting ? 'Waiting' : cache.status[0].toUpperCase() + cache.status.slice(1)}</Badge></td>
|
||||
<td>{#if cache.status === 'ready'}<details><summary>Transfer details</summary><GameFileTransferProgress {cache} connected={host?.connected ?? false} /></details>
|
||||
{:else if cache.status !== 'missing'}<GameFileTransferProgress {cache} connected={host?.connected ?? false} />{/if}
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
import type { Host } from '$lib/hosts.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { hostListStatus, hostReadingsStale, hostCpuUsage, hostUsedSpace } from '$ui/host-overview.js';
|
||||
import { plural } from '$ui/plural.js';
|
||||
import Badge from './primitives/Badge.svelte';
|
||||
let { hosts, now, returnTo = '/hosts' }: { hosts: (Host & { serverCount: number; playerCount: number; unreportedInstances: number })[]; now: number; returnTo?: string } = $props();
|
||||
</script>
|
||||
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Host</th><th>Status</th><th title="Assigned instances, including stopped instances">Instances</th><th title="Players reported by online instances">Players</th><th>CPU</th><th>Memory <span>used / total</span></th><th>Disk <span>used / total</span></th></tr></thead>
|
||||
<thead><tr><th>Host</th><th>Status</th><th title="Assigned servers, including stopped servers">Servers</th><th title="Players reported by online servers">Players</th><th>CPU</th><th>Memory <span>used / total</span></th><th>Disk <span>used / total</span></th></tr></thead>
|
||||
<tbody>{#each hosts as host (host.id)}
|
||||
{@const status = hostListStatus(host, now)}
|
||||
{@const stale = hostReadingsStale(host, now)}
|
||||
@@ -15,8 +16,8 @@
|
||||
<td><a class="name" href={contextHref(`/hosts/${host.id}`, returnTo)}>{host.name}</a>{#if host.hostname && host.hostname !== host.name}<span class="secondary">{host.hostname}</span>{/if}
|
||||
{#if stale && host.lastHeartbeat}<span class="secondary">Resource readings stale</span>{/if}</td>
|
||||
<td class="status"><Badge variant={status.tone}>{status.label}</Badge>{#if status.detail}<a class="reason" href={contextHref(`/hosts/${host.id}?tab=${status.tab}`, returnTo)}>{status.detail}</a>{/if}</td>
|
||||
<td><a href={contextHref(`/hosts/${host.id}?tab=servers`, returnTo)} aria-label={`${host.serverCount} assigned instances on ${host.name}`}>{host.serverCount}</a></td>
|
||||
<td title={host.unreportedInstances ? `Player counts unavailable for ${host.unreportedInstances} of ${host.serverCount} instances` : 'Players reported by online instances'}>{host.unreportedInstances === host.serverCount && host.serverCount > 0 ? '—' : `${host.playerCount}${host.unreportedInstances ? '+' : ''}`}</td>
|
||||
<td><a href={contextHref(`/hosts/${host.id}?tab=servers`, returnTo)} aria-label={`${host.serverCount} assigned ${plural(host.serverCount, 'server')} on ${host.name}`}>{host.serverCount}</a></td>
|
||||
<td title={host.unreportedInstances ? `Player counts unavailable for ${host.unreportedInstances} of ${host.serverCount} ${plural(host.serverCount, 'server')}` : 'Players reported by online servers'}>{host.unreportedInstances === host.serverCount && host.serverCount > 0 ? '—' : `${host.playerCount}${host.unreportedInstances ? '+' : ''}`}</td>
|
||||
<td class:stale>{hostCpuUsage(host.inventory.cpuUsagePercent)}</td>
|
||||
<td class:stale>{hostUsedSpace(host.inventory.memoryTotal, host.inventory.memoryAvailable)}</td>
|
||||
<td class:stale>{hostUsedSpace(host.inventory.storageTotal, host.inventory.storageAvailable)}</td>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</script>
|
||||
|
||||
<div class="preparation" aria-live="polite">
|
||||
<p>The default runtime and the latest available T5 and T6 game files are prepared automatically. Re-enabling a host requires a fresh runtime pull and verification of both BO1 (T5) and BO2 (T6) libraries before it accepts deployments. Running servers keep their current files.</p>
|
||||
<p>The default runtime and the latest available Black Ops I and Black Ops II game files are prepared automatically. Re-enabling a host requires a fresh runtime pull and verification of both game libraries before it accepts deployments. Running servers keep their current files.</p>
|
||||
<p>Supported hosts also cache Plutonium files to speed up new deployments. This optional cache refreshes when a new production release is detected; deployments can proceed while it is unavailable.</p>
|
||||
{#if host.preparation}
|
||||
<p class="status" class:blocked={host.preparation.state === 'blocked'}>Automatic setup: {host.preparation.state === 'ready' ? 'Ready' : host.preparation.state === 'preparing' ? 'In progress' : host.preparation.state === 'blocked' ? 'Needs attention' : 'Waiting'}</p>
|
||||
@@ -25,7 +25,7 @@
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}<p>Setup progress will appear after the host connects.</p>{/if}
|
||||
<p><a href="/game-files">Upload and view T5 / T6 game files →</a></p>
|
||||
<p><a href="/game-files">Upload and view game files →</a></p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import { isDialogBackdrop } from '$ui/dialog.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
|
||||
@@ -36,7 +37,7 @@
|
||||
const body = await res.json();
|
||||
if (!res.ok) {
|
||||
if (res.status === 409) servers = null;
|
||||
throw new Error(body.error ?? 'Could not queue destruction.');
|
||||
throw new Error(body.error ?? 'Could not queue server removal.');
|
||||
}
|
||||
open = false;
|
||||
onqueued(body.deployments.length);
|
||||
@@ -45,21 +46,21 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} aria-label="Destroy fleet" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
|
||||
<dialog bind:this={dialog} aria-label="Remove fleet servers" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
|
||||
<div class="content">
|
||||
<header><h2>Queue fleet removal</h2></header>
|
||||
<p>Queue destruction of every server, including servers being created. Each waits until a fresh player count confirms it is empty. Unreachable servers stay queued.</p>
|
||||
<p>Destruction removes containers, server files, and fleet registrations. Saved keys and collections are kept.</p>
|
||||
<header><h2>Remove fleet servers?</h2></header>
|
||||
<p>Queue removal of every server, including servers being created. Each waits until a fresh player count confirms it is empty. Unreachable servers stay queued.</p>
|
||||
<p>Removal deletes containers, server files, and fleet registrations. Saved keys and collections are kept.</p>
|
||||
<p>In Provisioning’s Active tab, cancel individual waiting requests or all waiting removals. Once a server begins removal, that removal cannot be cancelled or undone.</p>
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
{#if servers}
|
||||
<strong>{servers.length ? `${servers.length} server${servers.length === 1 ? '' : 's'} to destroy` : 'The house is already empty.'}</strong>
|
||||
{#if servers.length}<ul>{#each servers as server}<li>{server}</li>{/each}</ul><p class="caption">Existing destruction requests are reused. Servers added after this request are not included.</p>{/if}
|
||||
<strong>{servers.length ? `${servers.length} server${servers.length === 1 ? '' : 's'} to remove` : 'No servers to remove.'}</strong>
|
||||
{#if servers.length}<ul>{#each servers as server}<li>{serverTitle(server)}</li>{/each}</ul><p class="caption">Existing removal requests are reused. Servers added after this request are not included.</p>{/if}
|
||||
{:else}<p>{busy ? 'Reviewing the fleet…' : 'Refresh the preview to review the current fleet.'}</p>{/if}
|
||||
<div class="actions">
|
||||
<Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button>
|
||||
{#if !servers}<Button disabled={busy} onclick={review}>Refresh preview</Button>
|
||||
{:else}<Button variant="danger" loading={busy} disabled={busy || !servers.length} onclick={launch}>Queue destruction of {servers.length} server{servers.length === 1 ? '' : 's'}</Button>{/if}
|
||||
{:else}<Button variant="danger" loading={busy} disabled={busy || !servers.length} onclick={launch}>Remove {servers.length} server{servers.length === 1 ? '' : 's'}</Button>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -67,9 +68,9 @@
|
||||
<style>
|
||||
dialog { width: min(620px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); color: var(--text-primary); }
|
||||
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(5px); }
|
||||
.content { display: grid; gap: 20px; padding: 28px; } h2, p { margin: 0; } h2 { font-size: 25px; }
|
||||
p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; } .error { color: var(--state-danger); } .caption { font-size: 12px; }
|
||||
.content { display: grid; gap: 20px; padding: 28px; } h2, p { margin: 0; } h2 { font-size: var(--text-xl); }
|
||||
p { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; } .error { color: var(--state-danger); } .caption { font-size: var(--text-xs); }
|
||||
ul { margin: 0; padding: 12px 12px 12px 30px; max-height: 220px; overflow: auto; background: var(--surface-sunken); border-radius: var(--radius-md); }
|
||||
li { padding: 5px 0; font-family: var(--font-mono); font-size: 12px; overflow-wrap: anywhere; }
|
||||
li { padding: 5px 0; font-family: var(--font-body); font-size: var(--text-xs); overflow-wrap: anywhere; }
|
||||
.actions { display: flex; gap: 12px; justify-content: flex-end; flex-wrap: wrap; }
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import type { LaunchRecipeSummary } from '$lib/deployment-plan.js';
|
||||
let { recipe, historical = false, selectionStatus = null }: { recipe: LaunchRecipeSummary; historical?: boolean; selectionStatus?: 'queued' | 'attempted' | null } = $props();
|
||||
</script>
|
||||
@@ -14,7 +14,7 @@
|
||||
<details>
|
||||
<summary>{recipe.files.length} managed file{recipe.files.length === 1 ? '' : 's'} {historical ? 'recorded for this deployment' : 'in the current setup'}</summary>
|
||||
<ul>{#each recipe.files as file}<li><div><strong>{file.name}</strong><span>{file.version !== null ? `v${file.version}` : 'Generated'} · {file.kind} · {file.size.toLocaleString()} bytes</span></div><code>{file.path}</code><small title={file.sha256}>SHA-256 {file.sha256.slice(0, 12)}</small></li>{/each}</ul>
|
||||
<p>{historical ? 'Recorded' : 'Previewed'} {$timeFormat.dateTime(recipe.capturedAt)}. {historical ? selectionStatus === 'queued' ? 'Versions are selected again when creation begins. This queued capture does not pin them.' : selectionStatus === 'attempted' ? 'These files were selected for this attempt; this does not confirm installation succeeded. Launch again uses the latest promoted setup.' : 'This records the saved setup for this deployment. Launch again uses the latest promoted setup for this map.' : 'This preview shows the promoted setup at the time above. The deployment history records the files selected when provisioning begins.'}</p>
|
||||
<p>{historical ? 'Recorded' : 'Previewed'} <RelativeTime value={recipe.capturedAt} />. {historical ? selectionStatus === 'queued' ? 'Versions are selected again when creation begins. This queued capture does not pin them.' : selectionStatus === 'attempted' ? 'These files were selected for this attempt; this does not confirm installation succeeded. Launch again uses the latest promoted setup.' : 'This records the saved setup for this deployment. Launch again uses the latest promoted setup for this map.' : 'This preview shows the promoted setup at the time above. The deployment history records the files selected when provisioning begins.'}</p>
|
||||
<small class="fingerprint" title={recipe.fingerprint}>Recipe {recipe.fingerprint.slice(0, 16)}</small>
|
||||
</details>
|
||||
</div>
|
||||
@@ -23,12 +23,12 @@
|
||||
.recipe { display: grid; gap: 16px; min-width: 0; }
|
||||
.recipe-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
.eyebrow { display: block; font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .12em; color: var(--text-secondary); margin-bottom: 6px; }
|
||||
h3 { margin: 0; font-size: 22px; letter-spacing: -.03em; }
|
||||
.game { border: 1px solid var(--border-strong); border-radius: 99px; padding: 5px 10px; font-size: 12px; white-space: nowrap; }
|
||||
dl { display: grid; gap: 12px; margin: 0; } dt { color: var(--text-secondary); font-size: 12px; } dd { margin: 4px 0 0; font-size: 13px; overflow-wrap: anywhere; line-height: 1.5; }
|
||||
h3 { margin: 0; font-size: var(--text-xl); letter-spacing: -.03em; }
|
||||
.game { border: 1px solid var(--border-strong); border-radius: 99px; padding: 5px 10px; font-size: var(--text-xs); white-space: nowrap; }
|
||||
dl { display: grid; gap: 12px; margin: 0; } dt { color: var(--text-secondary); font-size: var(--text-xs); } dd { margin: 4px 0 0; font-size: var(--text-sm); overflow-wrap: anywhere; line-height: 1.5; }
|
||||
.mono, code, .fingerprint { font-family: var(--font-mono); }
|
||||
details { border-top: 1px solid var(--border-subtle); padding-top: 14px; } summary { cursor: pointer; font-size: 13px; }
|
||||
details { border-top: 1px solid var(--border-subtle); padding-top: 14px; } summary { cursor: pointer; font-size: var(--text-sm); }
|
||||
ul { list-style: none; padding: 0; margin: 12px 0; } li { display: grid; gap: 5px; padding: 12px 0; border-bottom: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
|
||||
li > div { display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap; } strong { font-size: 13px; font-weight: 550; } span, code, small { font-size: var(--text-xs); } li span, small { color: var(--text-secondary); }
|
||||
p { color: var(--text-secondary); font-size: 12px; line-height: 1.6; }
|
||||
li > div { display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap; } strong { font-size: var(--text-sm); font-weight: 550; } span, code, small { font-size: var(--text-xs); } li span, small { color: var(--text-secondary); }
|
||||
p { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.6; }
|
||||
</style>
|
||||
|
||||
@@ -1,41 +1,79 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { runtimeActions, actionLabels, runtimeDescriptions, type RuntimeAction } from '$lib/provisioning.js';
|
||||
import { currentRound, freshServer, type FleetServer } from '$lib/fleet-overview.js';
|
||||
import { actionLabels, runtimeDescriptions, type RuntimeAction } from '$lib/provisioning.js';
|
||||
import { applicableRuntimeActions, lifecycleImpact } from '$ui/lifecycle-actions.js';
|
||||
import { serverHealth } from '$ui/server-health.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import { serverTimeNow, syncServerTime, uiNow } from '$ui/stores/servers.js';
|
||||
import Dialog from './primitives/Dialog.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
|
||||
let { open = $bindable(false), initialServer = '', initialAction = 'start' }:
|
||||
{ open?: boolean; initialServer?: string; initialAction?: RuntimeAction } = $props();
|
||||
let servers = $state<{ name: string }[]>([]);
|
||||
let server = $state('');
|
||||
let action = $state<RuntimeAction | 'destroy'>('start');
|
||||
let { open = $bindable(false), initialServer = '', initialAction = 'start', serverContext = null, players = null, round = null }:
|
||||
{ open?: boolean; initialServer?: string; initialAction?: RuntimeAction; serverContext?: FleetServer | null; players?: number | null; round?: number | null } = $props();
|
||||
let servers = $state<FleetServer[]>([]);
|
||||
let server = $state(untrack(() => initialServer));
|
||||
let action = $state<RuntimeAction | 'destroy'>(untrack(() => initialAction));
|
||||
let busy = $state(false);
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
let confirmed = $state(false);
|
||||
const selected = $derived(serverContext?.name === server ? serverContext : servers.find(item => item.name === server));
|
||||
const available = $derived(selected ? applicableRuntimeActions(selected, $uiNow) : []);
|
||||
const applicable = $derived(action === 'destroy' || available.includes(action));
|
||||
const destructive = $derived(action === 'rebuild' || action === 'destroy');
|
||||
const health = $derived(selected ? serverHealth(selected, $uiNow) : null);
|
||||
const impactPlayers = $derived(initialServer ? players : selected && health?.state === 'online' && freshServer(selected, $uiNow) ? selected.last_players : null);
|
||||
const impactRound = $derived(initialServer ? round : selected ? currentRound(selected, selected.telemetry, $uiNow) : null);
|
||||
const title = $derived(initialServer ? `${actionLabels[action]} ${serverTitle(initialServer)}?` : 'Manage server');
|
||||
const submitLabel = $derived(action === 'start' ? 'Start server' : action === 'destroy' ? 'Remove server when empty' : `${actionLabels[action]} when empty`);
|
||||
$effect(() => {
|
||||
if (open) { server = initialServer; action = initialAction; confirmed = false; error = ''; void load(); }
|
||||
if (!open) return;
|
||||
const fixedServer = initialServer;
|
||||
const requestedAction = initialAction;
|
||||
const controller = new AbortController();
|
||||
// Live context and match reports update the copy and eligibility without
|
||||
// reopening the dialog or clearing a deliberate confirmation.
|
||||
untrack(() => {
|
||||
server = fixedServer; action = requestedAction; confirmed = false; error = ''; servers = [];
|
||||
loading = false;
|
||||
if (!fixedServer || serverContext?.name !== fixedServer) void load(fixedServer, controller.signal);
|
||||
});
|
||||
return () => controller.abort();
|
||||
});
|
||||
async function load() {
|
||||
async function load(fixedServer: string, signal: AbortSignal) {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetch('/api/servers');
|
||||
const response = await fetch(fixedServer ? `/api/servers/${encodeURIComponent(fixedServer)}` : '/api/servers', { signal });
|
||||
if (!response.ok) throw new Error('Could not load servers.');
|
||||
servers = (await response.json()).servers;
|
||||
} catch (err) { error = (err as Error).message; }
|
||||
finally { loading = false; }
|
||||
const body = await response.json();
|
||||
if (signal.aborted) return;
|
||||
servers = body.servers;
|
||||
syncServerTime(body.serverNow);
|
||||
} catch (err) { if (!signal.aborted) error = (err as Error).message; }
|
||||
finally { if (!signal.aborted) loading = false; }
|
||||
}
|
||||
function chooseServer(event: Event & { currentTarget: EventTarget & HTMLSelectElement }) {
|
||||
server = event.currentTarget.value;
|
||||
confirmed = false; error = '';
|
||||
const item = servers.find(item => item.name === server);
|
||||
const actions = item ? applicableRuntimeActions(item, serverTimeNow()) : [];
|
||||
action = actions[0] ?? 'destroy';
|
||||
}
|
||||
async function submit() {
|
||||
if (busy || !server || (destructive && !confirmed)) return;
|
||||
if (action !== 'destroy' && (!selected || !applicableRuntimeActions(selected, serverTimeNow()).includes(action))) {
|
||||
error = 'The server status has changed. Close this dialog and choose an action from its current status.';
|
||||
return;
|
||||
}
|
||||
busy = true; error = '';
|
||||
try {
|
||||
const response = action === 'destroy'
|
||||
? await fetch(`/api/servers/${encodeURIComponent(server)}`, { method: 'DELETE' })
|
||||
: await fetch('/api/provisioning', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ server, action }) });
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not queue this operation.');
|
||||
if (!response.ok || !body.deploymentId) throw new Error(body.error ?? 'Could not queue this operation.');
|
||||
await goto(`/provisioning/${encodeURIComponent(body.deploymentId)}`);
|
||||
open = false;
|
||||
} catch (err) { error = (err as Error).message; }
|
||||
@@ -43,23 +81,34 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog bind:open title="Manage server" {busy}>
|
||||
<Dialog bind:open {title} {busy}>
|
||||
<div class="fields">
|
||||
<label>Server <select bind:value={server} disabled={busy || loading} onchange={() => confirmed = false}>
|
||||
{#if !initialServer}
|
||||
<label>Server <select bind:value={server} disabled={busy || loading} onchange={chooseServer}>
|
||||
<option value="">{loading ? 'Loading servers…' : 'Choose a server'}</option>
|
||||
{#each servers as item}<option value={item.name}>{serverTitle(item.name)} · {item.name}</option>{/each}
|
||||
{#each servers as item (item.name)}<option value={item.name}>{serverTitle(item.name)}</option>{/each}
|
||||
</select></label>
|
||||
<label>Action <select bind:value={action} disabled={busy} onchange={() => confirmed = false}>
|
||||
{#each runtimeActions as value}<option value={value}>{actionLabels[value]}</option>{/each}
|
||||
<option value="destroy">Destroy</option>
|
||||
<label>Action <select bind:value={action} disabled={busy || !server || loading} onchange={() => { confirmed = false; error = ''; }}>
|
||||
{#if !server}<option value="start">Choose a server first</option>{/if}
|
||||
{#if server && !applicable}<option value={action} disabled>{actionLabels[action]} unavailable</option>{/if}
|
||||
{#each available as value}<option value={value}>{actionLabels[value]}</option>{/each}
|
||||
{#if server}<option value="destroy">Remove server</option>{/if}
|
||||
</select></label>
|
||||
<p>{action === 'destroy' ? 'Destroy the container and remove its writable server files and registration after the server is empty. Shared game libraries, provisioning history, and the key saved separately in Keys are retained.' : runtimeDescriptions[action]}</p>
|
||||
{#if destructive}<label class="confirmation"><input type="checkbox" bind:checked={confirmed} disabled={busy} /> I understand that all files and data in this instance will be permanently deleted.</label>{/if}
|
||||
{/if}
|
||||
{#if loading}<p role="status">Checking the latest server status…</p>
|
||||
{:else if server}
|
||||
{#if applicable}
|
||||
<p class="impact">{lifecycleImpact(action, impactPlayers, impactRound)}</p>
|
||||
<p>{action === 'destroy' ? 'Remove the container, its writable server files, and registration after the server is empty. Shared game libraries, recorded history, and the key saved separately in Keys are retained.' : runtimeDescriptions[action]}</p>
|
||||
{#if action !== 'start'}<p class="help">Follow progress or cancel the waiting request on the provisioning page.</p>{/if}
|
||||
{:else}<p role="status">{actionLabels[action]} is unavailable. {health?.reason ?? 'The current server status could not be verified.'} Close this dialog and choose an action from its current status.</p>{/if}
|
||||
{#if applicable && destructive}<label class="confirmation"><input type="checkbox" bind:checked={confirmed} disabled={busy} /> I understand that this instance’s writable files and data will be permanently deleted.</label>{/if}
|
||||
{/if}
|
||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||
</div>
|
||||
{#snippet actions()}
|
||||
<Button disabled={busy} onclick={() => open = false}>Cancel</Button>
|
||||
<Button variant={destructive ? 'danger' : 'primary'} disabled={busy || loading || !server || (destructive && !confirmed)} loading={busy} onclick={submit}>Queue {actionLabels[action].toLowerCase()}</Button>
|
||||
<Button variant={destructive ? 'danger' : 'primary'} disabled={busy || loading || !server || !applicable || (destructive && !confirmed)} loading={busy} onclick={submit}>{submitLabel}</Button>
|
||||
{/snippet}
|
||||
</Dialog>
|
||||
|
||||
@@ -67,4 +116,6 @@
|
||||
.fields { display: grid; gap: 16px; } label { display: grid; gap: 8px; }
|
||||
select { padding: 10px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); color: var(--text-primary); }
|
||||
p { margin: 0; line-height: 1.6; color: var(--text-secondary); } .confirmation { display: flex; align-items: flex-start; gap: 12px; line-height: 1.5; }
|
||||
.impact { padding: var(--space-4); border-radius: var(--radius-md); background: var(--surface-raised); color: var(--text-primary); }
|
||||
.help { font-size: var(--text-sm); }
|
||||
</style>
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { createLogRefresh, type LogResponse } from '$ui/log-refresh.js';
|
||||
import { stripGameColorCodes } from '$ui/log-text.js';
|
||||
import Tabs from './primitives/Tabs.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import Freshness from './Freshness.svelte';
|
||||
import RelativeTime from './RelativeTime.svelte';
|
||||
const uid = $props.id();
|
||||
|
||||
interface TabDef {
|
||||
@@ -22,6 +25,7 @@
|
||||
|
||||
const active = $derived(tabs.find(tab => tab.key === $page.url.searchParams.get('log'))?.key ?? tabs[0]?.key ?? '');
|
||||
let data = $state<Record<string, LogResponse | null>>({});
|
||||
let checkedAt = $state<Record<string, number>>({});
|
||||
|
||||
function chooseLog(key: string) {
|
||||
const url = new URL($page.url); url.searchParams.set('log', key);
|
||||
@@ -37,11 +41,12 @@
|
||||
let stickToBottom = $state(true);
|
||||
|
||||
const refresh = createLogRefresh({
|
||||
onReset: () => { data = {}; refreshError = ''; copyStatus = ''; },
|
||||
onReset: () => { data = {}; checkedAt = {}; refreshError = ''; copyStatus = ''; },
|
||||
onLoading: value => { loading = value; },
|
||||
onError: message => { refreshError = message; },
|
||||
onData: (key, body) => {
|
||||
data = { ...data, [key]: body };
|
||||
checkedAt = { ...checkedAt, [key]: Date.now() };
|
||||
const currentServer = server;
|
||||
void tick().then(() => {
|
||||
if (server === currentServer && active === key && stickToBottom && !query && bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
|
||||
@@ -97,15 +102,6 @@
|
||||
untrack(() => refresh.configure(target, auto, interval));
|
||||
});
|
||||
|
||||
function ago(iso: string | null | undefined): string {
|
||||
if (!iso) return 'never';
|
||||
const d = Date.now() - new Date(iso).getTime();
|
||||
if (d < 60_000) return `${Math.max(1, Math.floor(d / 1000))}s ago`;
|
||||
if (d < 3_600_000) return `${Math.floor(d / 60_000)}m ago`;
|
||||
if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`;
|
||||
return `${Math.floor(d / 86_400_000)}d ago`;
|
||||
}
|
||||
|
||||
function fmtBytes(n: number | undefined): string {
|
||||
if (n === undefined || n === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
@@ -115,7 +111,7 @@
|
||||
}
|
||||
|
||||
let current = $derived(data[active] ?? null);
|
||||
const visibleLines = $derived((current?.lines ?? []).filter(line => !query || line.toLowerCase().includes(query.toLowerCase())));
|
||||
const visibleLines = $derived((current?.lines ?? []).map(stripGameColorCodes).filter(line => !query || line.toLowerCase().includes(query.toLowerCase())));
|
||||
</script>
|
||||
|
||||
<div class="log-panel">
|
||||
@@ -123,25 +119,26 @@
|
||||
<Tabs tabs={tabs.map(t => ({id:t.key,label:t.label}))} value={active} onchange={chooseLog} idPrefix={uid} panelId={uid + '-panel'} ariaLabel="Log files" size="sm" />
|
||||
<div class="right">
|
||||
{#if current?.mtime}
|
||||
<span class="meta">{fmtBytes(current.size)} · updated {ago(current.mtime)}</span>
|
||||
<span class="meta">{fmtBytes(current.size)} · File changed <RelativeTime value={current.mtime} /></span>
|
||||
{/if}
|
||||
<label class="auto">
|
||||
<input type="checkbox" checked={autoRefresh} onchange={toggleAuto} />
|
||||
Auto-refresh · {pollMs / 1000}s
|
||||
Auto-refresh
|
||||
</label>
|
||||
<button class="refresh" onclick={() => refresh.refresh()} disabled={loading}>
|
||||
{#if !autoRefresh}<button class="refresh" onclick={() => refresh.refresh()} disabled={loading}>
|
||||
{loading ? 'Refreshing…' : 'Refresh'}
|
||||
</button>
|
||||
</button>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-tools"><label class="log-search">Search loaded lines<input type="search" bind:value={query} placeholder="Find text in this log" /></label><details class="log-actions"><summary>More</summary><div class="action-items"><Button size="sm" onclick={copyLink}>Copy link</Button><Button size="sm" disabled={!current?.lines?.length} onclick={() => copy(visibleLines.join('\n'), 'Log text copied.')}>Copy {query ? 'matches' : 'log'}</Button><Button size="sm" disabled={!current?.lines?.length} onclick={download}>Download tail</Button></div></details></div>
|
||||
<Freshness updatedAt={checkedAt[active] ?? null} paused={!autoRefresh} intervalLabel={`Updates every ${pollMs / 1000} seconds`} error={refreshError ? 'Log updates interrupted.' : ''} onRetry={() => void refresh.refresh()} retryDisabled={loading} />
|
||||
<div class="log-tools"><label class="log-search">Search loaded lines<input type="search" bind:value={query} placeholder="Find text in this log" /></label><details class="log-actions"><summary>More</summary><div class="action-items"><Button size="sm" onclick={copyLink}>Copy link</Button><Button size="sm" disabled={!current?.lines?.length} onclick={() => copy(visibleLines.join('\n'), 'Log text copied.')}>Copy {query ? 'matches' : 'log'}</Button><Button size="sm" disabled={!current?.lines?.length} onclick={download}>Download raw tail</Button></div></details></div>
|
||||
{#if copyStatus}<p class="copy-status" role="status">{copyStatus}</p>{/if}
|
||||
<div class="follow-state"><span>{!autoRefresh ? 'Refresh paused' : 'Auto-refresh on'} · {query ? `${visibleLines.length} matching lines` : stickToBottom ? 'Following latest lines' : 'Reading earlier lines'}</span>{#if !stickToBottom || query}<Button size="sm" variant="ghost" onclick={jumpToLatest}>Jump to latest</Button>{/if}</div>
|
||||
|
||||
<div id={uid + '-panel'} role="tabpanel" aria-labelledby={uid + '-' + active}>
|
||||
{#if refreshError}<div class="ui-error" role="alert">Couldn’t load logs. {current ? 'Showing the last loaded log.' : ''}<Button size="sm" onclick={() => refresh.refresh()} disabled={loading}>Retry</Button><details><summary>Technical details</summary>{refreshError}</details></div>{/if}
|
||||
{#if refreshError}<div class="ui-error" role="alert">Couldn’t load logs. {current ? 'Showing the last loaded log.' : ''}<details><summary>Technical details</summary>{refreshError}</details></div>{/if}
|
||||
{#if !current && !refreshError}
|
||||
<div class="empty">{loading ? 'loading…' : 'No log loaded. Select Refresh to load it.'}</div>
|
||||
<div class="empty">{loading ? 'Loading log…' : autoRefresh ? 'Waiting for the first log update.' : 'No log loaded. Select Refresh to load it.'}</div>
|
||||
{:else if current && !current.ok}
|
||||
<div class="error">error: {current.reason ?? 'unknown'}</div>
|
||||
{:else if current?.missing}
|
||||
@@ -185,7 +182,7 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -196,46 +193,46 @@
|
||||
gap: 14px;
|
||||
}
|
||||
.meta {
|
||||
color: var(--ink-500);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-mono-sm);
|
||||
color: var(--text-tertiary);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.auto {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--ink-500);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-mono-sm);
|
||||
color: var(--text-tertiary);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.auto input { accent-color: var(--accent); }
|
||||
.auto input { accent-color: var(--accent-primary); }
|
||||
.refresh {
|
||||
background: var(--bg-card);
|
||||
color: var(--ink-100);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 12px;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.refresh:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--accent-glow);
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.refresh:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.body {
|
||||
margin: 0;
|
||||
padding: 16px 18px;
|
||||
background: var(--bg-page);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--ink-300);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-mono);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-mono);
|
||||
max-height: 560px;
|
||||
overflow: auto;
|
||||
@@ -245,28 +242,28 @@
|
||||
.empty, .error {
|
||||
padding: 28px 16px;
|
||||
text-align: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-mono);
|
||||
border-radius: var(--r-sm);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.empty {
|
||||
color: var(--ink-500);
|
||||
background: var(--bg-nav);
|
||||
border: 1px dashed var(--border);
|
||||
color: var(--text-tertiary);
|
||||
background: var(--surface);
|
||||
border: 1px dashed var(--border-subtle);
|
||||
}
|
||||
.empty code {
|
||||
color: var(--cream-dim);
|
||||
font-size: var(--text-mono-sm);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.error {
|
||||
color: var(--danger-500);
|
||||
color: var(--state-danger);
|
||||
background: rgba(255, 48, 80, 0.08);
|
||||
border: 1px solid rgba(255, 48, 80, 0.25);
|
||||
}
|
||||
.foot {
|
||||
color: var(--ink-500);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-mono-sm);
|
||||
color: var(--text-tertiary);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
text-align: right;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
@@ -6,11 +7,13 @@
|
||||
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
|
||||
import type { MatchHistoryResponse } from '$lib/match-history.js';
|
||||
import { matchApiParams, matchHistoryHref, matchPageSize, readMatchQuery, type MatchHistoryQuery, type MatchSort } from '$lib/match-history-query.js';
|
||||
import { matchInstanceLabel, matchStartedLabel } from '$ui/match-history.js';
|
||||
import { matchInstanceLabel } from '$ui/match-history.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { formatDuration } from '$ui/duration.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import EmptyState from './primitives/EmptyState.svelte';
|
||||
import Dialog from './primitives/Dialog.svelte';
|
||||
import Freshness from './Freshness.svelte';
|
||||
|
||||
let { server, initial }: { server?: string; initial?: MatchHistoryResponse } = $props();
|
||||
let data = $state<MatchHistoryResponse | undefined>(untrack(() => initial));
|
||||
@@ -18,6 +21,8 @@
|
||||
let error = $state('');
|
||||
let search = $state(untrack(() => matchApiParams($page.url).get('q') ?? ''));
|
||||
let retry = $state(0);
|
||||
let filtersOpen = $state(false);
|
||||
let checkedAt = $state<number | null>(untrack(() => initial ? Date.now() : null));
|
||||
let tableRegion = $state<HTMLDivElement>();
|
||||
let pendingUrl: URL | undefined;
|
||||
let navigationId = 0;
|
||||
@@ -29,13 +34,14 @@
|
||||
return `${endpoint}?${params}`;
|
||||
});
|
||||
let loadedUrl = $state(untrack(() => initial ? apiUrl : ''));
|
||||
const filterCount = $derived([filters.game, filters.map, filters.q, filters.days, filters.status].filter(Boolean).length);
|
||||
const hasFilters = $derived(!!(filters.game || filters.map || filters.q || filters.days || filters.status));
|
||||
const sorts: Array<{ key: MatchSort; label: string }> = [{ key: 'started', label: 'Started' }, { key: 'players', label: 'Players' }, { key: 'duration', label: 'Duration' }, { key: 'round', label: 'Round' }];
|
||||
|
||||
$effect(() => { search = filters.q; });
|
||||
$effect(() => {
|
||||
const report = initial;
|
||||
if (report) untrack(() => { data = report; loadedUrl = apiUrl; loading = false; error = ''; });
|
||||
if (report) untrack(() => { data = report; loadedUrl = apiUrl; loading = false; error = ''; checkedAt = Date.now(); });
|
||||
});
|
||||
$effect(() => {
|
||||
const url = apiUrl;
|
||||
@@ -49,7 +55,7 @@
|
||||
if (signal.aborted || url !== apiUrl) return;
|
||||
// Do not move keyboard users to another match while they are following a link.
|
||||
if (loadedUrl !== url || !tableRegion?.contains(document.activeElement)) data = body;
|
||||
loadedUrl = url; loading = false; error = '';
|
||||
loadedUrl = url; loading = false; error = ''; checkedAt = Date.now();
|
||||
} catch (cause) {
|
||||
if (signal.aborted || url !== apiUrl) return;
|
||||
error = cause instanceof Error ? cause.message : 'Could not load match history.';
|
||||
@@ -71,27 +77,33 @@
|
||||
</script>
|
||||
|
||||
<section class="history" aria-label="Match history">
|
||||
<div class="filters">
|
||||
{#if server === undefined}
|
||||
<label>Game<select value={filters.game} onchange={event => update({ game: event.currentTarget.value, map: '' })}><option value="">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
|
||||
{/if}
|
||||
<label>Map<select value={filters.map} onchange={event => update({ map: event.currentTarget.value })}>
|
||||
<option value="">All maps</option>
|
||||
{#if filters.map && !data?.maps.some(map => map.key === filters.map)}<option value={filters.map}>{filters.map}</option>{/if}
|
||||
{#each data?.maps ?? [] as map (map.key)}<option value={map.key}>{map.name}{!filters.game && server === undefined ? ` · ${map.game.toUpperCase()}` : ''}</option>{/each}
|
||||
</select></label>
|
||||
{#if server === undefined}
|
||||
<form role="search" onsubmit={event => { event.preventDefault(); update({ q: search.trim() }); }}>
|
||||
<label for="match-search">Instance<input id="match-search" type="search" bind:value={search} maxlength="100" placeholder="Find an instance…" aria-describedby="match-search-help" /></label>
|
||||
<span id="match-search-help" class="sr-only">Press Enter to search by instance name.</span>
|
||||
</form>
|
||||
{/if}
|
||||
<label>Date range<select value={filters.days} onchange={event => update({ days: event.currentTarget.value })}><option value="0">All time</option><option value="7">Last 7 days</option><option value="30">Last 30 days</option><option value="90">Last 90 days</option></select></label>
|
||||
<details class="more-filters"><summary>More filters{filters.status ? ' (1)' : ''}</summary>
|
||||
<label>Status<select value={filters.status} onchange={event => update({ status: event.currentTarget.value })}><option value="">All statuses</option><option value="completed">Completed</option><option value="active">In progress</option><option value="interrupted">Interrupted</option></select></label>
|
||||
</details>
|
||||
</div>
|
||||
<div class="context"><span>Times in {$timeZone}{filters.days ? ` · Matches started in the last ${filters.days} days` : ''}</span>{#if hasFilters}<button class="text-button" onclick={reset}>Clear filters</button>{/if}</div>
|
||||
{#snippet filterFields(suffix: string)}
|
||||
<div class="filters">
|
||||
{#if server === undefined}<label for={`match-game${suffix}`}>Game<select id={`match-game${suffix}`} value={filters.game} onchange={event => update({ game: event.currentTarget.value, map: '' })}><option value="">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>{/if}
|
||||
<label for={`match-map${suffix}`}>Map<select id={`match-map${suffix}`} value={filters.map} onchange={event => update({ map: event.currentTarget.value })}>
|
||||
<option value="">All maps</option>
|
||||
{#if filters.map && !data?.maps.some(map => map.key === filters.map)}<option value={filters.map}>{filters.map}</option>{/if}
|
||||
{#each data?.maps ?? [] as map (map.key)}<option value={map.key}>{map.name}{!filters.game && server === undefined ? ` · ${map.game === 't5' ? 'BO1' : 'BO2'}` : ''}</option>{/each}
|
||||
</select></label>
|
||||
{#if server === undefined}<form role="search" onsubmit={event => { event.preventDefault(); update({ q: search.trim() }); if (suffix) filtersOpen = false; }}>
|
||||
<label for={`match-search${suffix}`}>Server<input id={`match-search${suffix}`} type="search" bind:value={search} maxlength="100" placeholder="Find a server…" aria-describedby={`match-search-help${suffix}`} /></label>
|
||||
<span id={`match-search-help${suffix}`} class="sr-only">Press Enter to search by server name.</span>
|
||||
</form>{/if}
|
||||
<label for={`match-days${suffix}`}>Date range<select id={`match-days${suffix}`} value={filters.days} onchange={event => update({ days: event.currentTarget.value })}><option value="0">All time</option><option value="7">Last 7 days</option><option value="30">Last 30 days</option><option value="90">Last 90 days</option></select></label>
|
||||
<label for={`match-status${suffix}`}>Status<select id={`match-status${suffix}`} value={filters.status} onchange={event => update({ status: event.currentTarget.value })}><option value="">All statuses</option><option value="completed">Completed</option><option value="active">In progress</option><option value="interrupted">Interrupted</option></select></label>
|
||||
{#if suffix}
|
||||
<label for={`match-sort${suffix}`}>Sort by<select id={`match-sort${suffix}`} value={filters.sort} onchange={event => update({ sort: event.currentTarget.value })}>{#each sorts as item}<option value={item.key}>{item.label}</option>{/each}</select></label>
|
||||
<label for={`match-order${suffix}`}>Order<select id={`match-order${suffix}`} value={filters.direction} onchange={event => update({ direction: event.currentTarget.value })}><option value="desc">Highest or newest first</option><option value="asc">Lowest or oldest first</option></select></label>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
<div class="desktop-filters">{@render filterFields('')}</div>
|
||||
<div class="mobile-filters"><span>{data?.total ?? '—'} matches{filters.map ? ` · ${data?.maps.find(map => map.key === filters.map)?.name ?? 'Selected map'}` : ''}</span><Button size="sm" onclick={() => filtersOpen = true}>Filters{filterCount ? ` · ${filterCount}` : ''}</Button></div>
|
||||
<Dialog bind:open={filtersOpen} title="Match filters" description="Choose the matches to show." drawer>
|
||||
{@render filterFields('-mobile')}
|
||||
{#snippet actions()}<Button variant="ghost" onclick={reset}>Clear filters</Button><Button variant="primary" onclick={() => filtersOpen = false}>Show results</Button>{/snippet}
|
||||
</Dialog>
|
||||
<div class="context"><span>Times in {$timeZone}{filters.days ? ` · Last ${filters.days} days` : ''}</span>{#if hasFilters}<button class="text-button" onclick={reset}>Clear filters</button>{/if}{#if !error}<Freshness updatedAt={checkedAt} intervalLabel="Updates every 10 seconds" />{/if}</div>
|
||||
{#if error}<div class="error" role="alert">{error} <Button size="sm" onclick={() => retry++}>Retry</Button></div>{/if}
|
||||
{#if loading}<EmptyState title="Loading matches…" />
|
||||
{:else if loadedUrl !== apiUrl && error}<EmptyState title="Match history unavailable" description="Retry to load this selection." />
|
||||
@@ -110,13 +122,13 @@
|
||||
</tr></thead>
|
||||
<tbody>{#each data.matches as match (match.id)}
|
||||
<tr>
|
||||
<td><time datetime={match.startedAt} title={$timeFormat.dateTime(match.startedAt)}>{matchStartedLabel(match.startedAt, $timeZone)}</time></td>
|
||||
<td><RelativeTime value={match.startedAt} /></td>
|
||||
<td class="match">
|
||||
{#if match.status === 'completed'}<a class="match-link" href={contextHref(`/matches/${match.id}`, $page.url)} aria-label={`View ${match.map} match from ${$timeFormat.dateTime(match.startedAt)}`}>{match.map}</a>
|
||||
{#if match.status === 'completed'}<a class="match-link" href={contextHref(`/matches/${match.id}`, $page.url)} aria-label={`View ${match.map} match ${match.id} from ${$timeFormat.dateTime(match.startedAt)}`}>{match.map} · Match #{match.id}</a>
|
||||
{:else}<span class="match-name">{match.map}</span>{/if}
|
||||
<small class="mobile-match-context"><RelativeTime value={match.startedAt} /> · {match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}{match.durationMs !== null ? ` · ${formatDuration(match.durationMs)}` : ''}</small>
|
||||
{#if server === undefined}<div class="instance">
|
||||
{#if !match.server_deleted_at}<a href={contextHref(`/servers/${encodeURIComponent(match.server)}?tab=matches`, $page.url)} title={match.server}>{matchInstanceLabel(match)}</a>
|
||||
{:else}<span title={match.server}>{matchInstanceLabel(match)}</span><span> · Historical instance</span>{/if}
|
||||
<span title={match.server}>{matchInstanceLabel(match)}</span>{#if match.server_deleted_at}<span> · Historical instance</span>{/if}
|
||||
</div>{/if}
|
||||
</td>
|
||||
<td><span class="status" class:active={match.status === 'active'} class:interrupted={match.status === 'interrupted'}>{match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}</span></td>
|
||||
@@ -143,12 +155,10 @@
|
||||
label, form { flex: 1; min-width: 145px; }
|
||||
label { display: flex; flex-direction: column; gap: 6px; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
input, select { width: 100%; min-height: 40px; padding: 8px 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: var(--text-primary); font: inherit; font-size: var(--text-sm); }
|
||||
.more-filters { padding-top: 25px; font-size: var(--text-sm); }
|
||||
.more-filters label { margin-top: 10px; }
|
||||
summary { cursor: pointer; color: var(--text-secondary); }
|
||||
.context, .pagination { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3); font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.pagination > div { display: flex; gap: var(--space-2); }
|
||||
.table-scroll { overflow-x: auto; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); }
|
||||
.table-scroll { position: relative; overflow-x: auto; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); }
|
||||
.table-scroll:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
||||
table { width: 100%; border-collapse: collapse; background: var(--surface); font-size: var(--text-sm); }
|
||||
th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-subtle); white-space: nowrap; }
|
||||
@@ -165,7 +175,6 @@
|
||||
a:hover { text-decoration: underline; }
|
||||
a:focus-visible, button:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
|
||||
.instance, small { display: block; margin-top: 4px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.instance a { color: inherit; text-decoration: none; }
|
||||
.status { color: var(--text-secondary); }
|
||||
.status.active { color: var(--accent-primary-hover); }
|
||||
.status.interrupted { color: var(--state-warning); }
|
||||
@@ -173,5 +182,17 @@
|
||||
.about p { max-width: 90ch; line-height: 1.6; }
|
||||
.error { display: flex; align-items: center; gap: var(--space-3); color: var(--state-warning); font-size: var(--text-sm); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
||||
@media (max-width: 640px) { .filters > label, .filters > form { flex-basis: 45%; min-width: 120px; } .more-filters { padding-top: 0; width: 100%; } th, td { padding: 10px 12px; } }
|
||||
.mobile-filters, .mobile-match-context { display: none; }
|
||||
@media (max-width: 640px) {
|
||||
.desktop-filters { display: none; }
|
||||
.mobile-match-context { display: block; }
|
||||
.mobile-filters { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); font-size: var(--text-sm); }
|
||||
.filters { display: grid; grid-template-columns: minmax(0, 1fr); }
|
||||
.filters > label, .filters > form { min-width: 0; }
|
||||
.context { gap: var(--space-1) var(--space-2); }
|
||||
th, td { padding: 10px 12px; }
|
||||
th:nth-child(1), td:nth-child(1) { display: none; }
|
||||
th:nth-child(3), td:nth-child(3), th:nth-child(5), td:nth-child(5) { display: none; }
|
||||
.match { min-width: 130px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { gameTitle } from '$ui/server-labels.js';
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import type { Host, NativeDeploymentSelection } from '$lib/hosts.js';
|
||||
import type { GameLibrary } from '$lib/game-files.js';
|
||||
import type { ZombieGame } from '$lib/map-aliases.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { latestGameFiles, selectLatestGameFiles, type NativeHostSelectionStatus } from '$ui/native-game-files.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
let { games, selection = $bindable(), ready = $bindable(false), status = $bindable(), disabled = false, network = false, compact = false, refreshVersion = 0, onchange }:
|
||||
{ games: ZombieGame[]; selection: NativeDeploymentSelection; ready?: boolean; status?: NativeHostSelectionStatus; disabled?: boolean; network?: boolean; compact?: boolean; refreshVersion?: number; onchange?: () => void } = $props();
|
||||
let hosts = $state<Host[]>([]); let libraries = $state<GameLibrary[]>([]); let loading = $state(true); let error = $state('');
|
||||
@@ -103,30 +104,30 @@
|
||||
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
||||
{#if loading}<p role="status">Loading host readiness and game files…</p>{/if}
|
||||
<p>Hosts are assigned when provisioning starts. You can queue launches while hosts are busy or preparing; each server waits for an eligible host and available capacity.</p>
|
||||
{#if !loading && !hosts.length}<p><a href="/hosts" target="_blank" rel="noopener">Add a host</a>. Its runtime and latest T5 and T6 files will be prepared automatically.</p>{/if}
|
||||
{#if !loading && !hosts.length}<p><a href="/hosts" target="_blank" rel="noopener">Add a host</a>. Its runtime and latest Black Ops I and Black Ops II files will be prepared automatically.</p>{/if}
|
||||
{#if !loading}{#each games as game}
|
||||
<div class="game-files">
|
||||
<strong>{game === 't5' ? 'Black Ops I (T5)' : 'Black Ops II (T6)'} files</strong>
|
||||
<strong>{game === 't5' ? 'Black Ops I' : 'Black Ops II'} files</strong>
|
||||
{#if latest[game]}
|
||||
<p>Latest upload · {$timeFormat.dateTime(latest[game]?.publishedAt ?? latest[game]?.createdAt)}</p>
|
||||
<p>Latest upload · <RelativeTime value={latest[game]?.publishedAt ?? latest[game]?.createdAt} /></p>
|
||||
<p role="status">{cacheReady(game) ? 'Latest files ready on an eligible host.' : 'Launches can be queued now and will wait for a host to prepare the runtime and latest files.'}</p>
|
||||
{:else}
|
||||
<p>No files uploaded yet. <a href="/game-files" target="_blank" rel="noopener">Upload {game.toUpperCase()} files</a> to prepare your hosts.</p>
|
||||
<p>No files uploaded yet. <a href="/game-files" target="_blank" rel="noopener">Upload {gameTitle(game, true)} files</a> to prepare your hosts.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}{/if}
|
||||
<p>Hosts keep the latest T5 and T6 files ready automatically. <a href="/game-files" target="_blank" rel="noopener">Upload game files</a> when they change. Existing servers keep their current files.</p>
|
||||
<p>Hosts keep the latest Black Ops I and Black Ops II files ready automatically. <a href="/game-files" target="_blank" rel="noopener">Upload game files</a> when they change. Existing servers keep their current files.</p>
|
||||
<details><summary>Advanced: runtime override</summary><p>The configured default runtime is prepared automatically. Custom images must first be prepared by an admin from the destination host page.</p>{@render runtimeFields()}</details>
|
||||
{#if network}<details><summary>Network overrides</summary>{@render networkFields()}</details>{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.game-files { display:grid; gap:5px; } .game-files strong { font-size:13px; font-weight:600; }
|
||||
.game-files { display:grid; gap:5px; } .game-files strong { font-size: var(--text-sm); font-weight:600; }
|
||||
.advanced { border-top:1px solid var(--border-subtle); padding-top:16px; }
|
||||
.advanced summary { color:var(--text-secondary); }
|
||||
.advanced-fields { padding-top:2px; }
|
||||
fieldset { margin:18px 0 0; padding:0; border:0; min-width:0; }
|
||||
legend { padding:0; font-size:13px; font-weight:600; }
|
||||
.selection { display:grid; gap:14px; padding:20px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface-sunken); } .title { display:flex; gap:12px; justify-content:space-between; align-items:center; } h3 { font-size:var(--text-sm); margin:0; } label { display:grid; gap:7px; font-size:13px; } input { width:100%; box-sizing:border-box; padding:11px; color:var(--text-primary); background:var(--surface); border:1px solid var(--border-control); border-radius:var(--radius-sm); font:inherit; } p { color:var(--text-secondary); font-size:12px; line-height:1.6; margin:0; } a,button { color:var(--accent-primary-hover); } button { background:none; border:0; cursor:pointer; font:inherit; font-size:12px; } button:disabled { opacity:.5; cursor:default; } .error { color:var(--state-danger); } summary { cursor:pointer; font-size:13px; } details label { margin-top:14px; } details p { margin-top:12px; }
|
||||
legend { padding:0; font-size: var(--text-sm); font-weight:600; }
|
||||
.selection { display:grid; gap:14px; padding:20px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface-sunken); } .title { display:flex; gap:12px; justify-content:space-between; align-items:center; } h3 { font-size:var(--text-sm); margin:0; } label { display:grid; gap:7px; font-size: var(--text-sm); } input { width:100%; box-sizing:border-box; padding:11px; color:var(--text-primary); background:var(--surface); border:1px solid var(--border-control); border-radius:var(--radius-sm); font:inherit; } p { color:var(--text-secondary); font-size: var(--text-xs); line-height:1.6; margin:0; } a,button { color:var(--accent-primary-hover); } button { background:none; border:0; cursor:pointer; font:inherit; font-size: var(--text-xs); } button:disabled { opacity:.5; cursor:default; } .error { color:var(--state-danger); } summary { cursor:pointer; font-size: var(--text-sm); } details label { margin-top:14px; } details p { margin-top:12px; }
|
||||
</style>
|
||||
|
||||
@@ -1,61 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import type { UserRole } from '$lib/auth.js';
|
||||
import { navigationItemActive, navigationLocation, visibleNavigation } from '$lib/navigation.js';
|
||||
import { navigationItemActive, visibleNavigation } from '$lib/navigation.js';
|
||||
import { serverHealth } from '$ui/server-health.js';
|
||||
import { fleet, fleetServers } from '$ui/stores/fleet.js';
|
||||
import { uiNow } from '$ui/stores/servers.js';
|
||||
|
||||
let { role, variant = 'primary' }: { role: UserRole; variant?: 'primary' | 'secondary' } = $props();
|
||||
let navigation = $state<HTMLElement>();
|
||||
let { role, onNavigate }: { role: UserRole; onNavigate?: () => void } = $props();
|
||||
const id = $props.id();
|
||||
const groups = $derived(visibleNavigation(role));
|
||||
const location = $derived(navigationLocation($page.url.pathname, role));
|
||||
|
||||
// Keep the current section or page visible in horizontally scrolling rows.
|
||||
$effect(() => {
|
||||
const current = location;
|
||||
const node = navigation;
|
||||
if (!current || !node) return;
|
||||
let cancelled = false;
|
||||
let observer: ResizeObserver | undefined;
|
||||
void tick().then(() => {
|
||||
if (cancelled) return;
|
||||
const selected = node.querySelector<HTMLElement>('[aria-current]');
|
||||
if (!selected) return;
|
||||
const reveal = () => {
|
||||
const bounds = node.getBoundingClientRect();
|
||||
const target = selected.getBoundingClientRect();
|
||||
if (target.left < bounds.left) node.scrollLeft -= bounds.left - target.left;
|
||||
else if (target.right > bounds.right) node.scrollLeft += target.right - bounds.right;
|
||||
};
|
||||
reveal();
|
||||
observer = new ResizeObserver(reveal);
|
||||
observer.observe(node);
|
||||
});
|
||||
return () => { cancelled = true; observer?.disconnect(); };
|
||||
const attention = $derived.by(() => {
|
||||
if (!$fleet.receivedAt) return null;
|
||||
const statuses = $fleetServers.map(server => ({ server, state: serverHealth(server, $uiNow).state }));
|
||||
const servers = statuses.filter(({ server, state }) => !['online', 'starting', 'provisioning'].includes(state)
|
||||
|| $fleet.operations.some(operation => operation.server === server.name && operation.state === 'attention')).length;
|
||||
const hosts = $fleet.hostAttention ?? new Set(statuses.filter(({ server, state }) => server.host && state === 'host-offline').map(({ server }) => server.host!.id)).size;
|
||||
const maintenance = $fleet.operations.filter(operation => operation.kind === 'maintenance' && operation.state === 'attention').length;
|
||||
return { '/': servers, '/hosts': hosts, '/maintenance': maintenance } as Record<string, number>;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if variant === 'primary'}
|
||||
<nav class="primary" aria-label="Primary" bind:this={navigation}>
|
||||
{#each groups as group (group.id)}
|
||||
<a href={group.items[0].href} aria-current={location?.group.id === group.id ? 'location' : undefined}>{group.label}</a>
|
||||
{/each}
|
||||
</nav>
|
||||
{:else if location}
|
||||
<nav class="secondary" aria-label={`${location.group.label} pages`} bind:this={navigation}>
|
||||
{#each location.group.items as item (item.href)}
|
||||
<a href={item.href} aria-current={navigationItemActive(item, $page.url.pathname) ? 'page' : undefined}>{item.label}</a>
|
||||
{/each}
|
||||
</nav>
|
||||
{/if}
|
||||
<nav aria-label="Main navigation">
|
||||
{#each groups as group (group.id)}
|
||||
<section aria-labelledby={`${id}-${group.id}`}>
|
||||
<h2 id={`${id}-${group.id}`}>{group.label}</h2>
|
||||
<ul>{#each group.items as item (item.href)}
|
||||
<li><a href={item.href} aria-current={navigationItemActive(item, $page.url.pathname) ? 'page' : undefined}
|
||||
aria-label={attention && attention[item.href] > 0 ? `${item.label}, ${attention[item.href]} ${attention[item.href] === 1 ? 'needs' : 'need'} attention` : undefined} onclick={() => onNavigate?.()}>
|
||||
<span>{item.label}</span>
|
||||
{#if attention && attention[item.href] > 0}<span class="attention" aria-hidden="true">{attention[item.href]}</span>{/if}
|
||||
</a></li>
|
||||
{/each}</ul>
|
||||
</section>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
nav { display:flex; align-items:stretch; gap:4px; min-width:0; max-width:100%; overflow-x:auto; overscroll-behavior-x:contain; scrollbar-width:thin; }
|
||||
a { display:flex; align-items:center; flex-shrink:0; min-height:44px; padding:10px 16px; border-bottom:2px solid transparent; color:var(--text-secondary); text-decoration:none; white-space:nowrap; font-size:var(--text-sm); font-weight:500; }
|
||||
a:hover { color:var(--text-primary); background:var(--surface-raised); }
|
||||
a:focus-visible { outline-offset:-3px; }
|
||||
a[aria-current] { color:var(--accent-primary-hover); border-bottom-color:var(--accent-primary); }
|
||||
.primary a { min-height:59px; }
|
||||
.secondary { margin-bottom:var(--space-6); border-bottom:1px solid var(--border-subtle); }
|
||||
@media (max-width:999px) { .primary a { min-height:48px; } }
|
||||
@media (max-width:720px) { a { padding-inline:12px; } }
|
||||
nav { display: grid; gap: var(--space-4); min-width: 0; }
|
||||
h2 { margin: 0 0 var(--space-1); padding: 0 var(--space-3); color: var(--text-tertiary); font-size: var(--text-xs); font-weight: var(--weight-semibold); letter-spacing: var(--tracking-label); text-transform: uppercase; }
|
||||
ul { list-style: none; padding: 0; margin: 0; }
|
||||
a { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); min-height: 32px; padding: 5px var(--space-3); border-radius: var(--radius-sm); color: var(--text-secondary); font-size: var(--text-sm); text-decoration: none; }
|
||||
a:hover { color: var(--text-primary); background: var(--surface-raised); }
|
||||
a[aria-current] { color: var(--text-primary); background: var(--surface-raised); box-shadow: inset 2px 0 var(--accent-primary); font-weight: var(--weight-semibold); }
|
||||
a:focus-visible { outline-offset: -2px; }
|
||||
.attention { color: var(--state-warning); font-variant-numeric: tabular-nums; font-size: var(--text-xs); font-weight: var(--weight-semibold); }
|
||||
@media (max-width: 960px) { a { min-height: 44px; } }
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import type { ActivityEvent, OperationStep } from '$lib/operation-flow.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
@@ -26,14 +26,14 @@
|
||||
</section>
|
||||
{/if}
|
||||
<section>
|
||||
{#if showOverview && latest}<p class="latest"><strong>Latest update</strong> {latest.message}<time datetime={latest.created_at}>{$timeFormat.dateTime(latest.created_at)}</time></p>{/if}
|
||||
{#if showOverview && latest}<p class="latest"><strong>Latest update</strong> {latest.message}<span class="time"><RelativeTime value={latest.created_at} /></span></p>{/if}
|
||||
<details>
|
||||
<summary>Activity history · {events.length} events{active ? ' · updates automatically' : ''}</summary>
|
||||
{#if events.length}
|
||||
<div class="tools"><label>Search activity<input type="search" bind:value={query} oninput={() => limit = 25} placeholder="Server, stage, or message" /></label><label>Order<select bind:value={newestFirst}><option value={true}>Newest first</option><option value={false}>Oldest first</option></select></label></div>
|
||||
<ol class="timeline">
|
||||
{#each visible as event (event.id)}
|
||||
<li><time datetime={event.created_at}>{$timeFormat.dateTime(event.created_at)}</time><p>{event.message}</p></li>
|
||||
<li><span class="time"><RelativeTime value={event.created_at} /></span><p>{event.message}</p></li>
|
||||
{/each}
|
||||
</ol>
|
||||
{#if !matching.length}<p class="muted">No matching activity.</p>{/if}
|
||||
@@ -47,22 +47,22 @@
|
||||
|
||||
<style>
|
||||
section { display: grid; gap: 16px; }
|
||||
h2 { font-size: 18px; margin: 0; }
|
||||
h2 { font-size: var(--text-md); margin: 0; }
|
||||
details { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 16px; }
|
||||
summary { cursor: pointer; font-size: var(--text-sm); font-weight: var(--weight-semibold); }
|
||||
details[open] summary { margin-bottom: 16px; }
|
||||
.latest { padding: 12px 16px; background: var(--surface); border-radius: var(--radius-md); font-size: var(--text-sm); }
|
||||
.latest strong { display: block; margin-bottom: 4px; }
|
||||
.latest time { display: block; margin-top: 4px; }
|
||||
.latest .time { display: block; margin-top: 4px; }
|
||||
.tools { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 20px; }
|
||||
label { display: grid; gap: 6px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
label:first-child { flex: 1; min-width: 180px; }
|
||||
input, select { background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-control); padding: 9px 12px; border-radius: var(--radius-sm); font: inherit; }
|
||||
time, .muted { color: var(--text-secondary); font-size: 13px; }
|
||||
.time, .muted { color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
p { margin: 0; line-height: 1.6; }
|
||||
.flow { display: flex; flex-wrap: wrap; gap: 12px; padding: 0; margin: 0; list-style: none; }
|
||||
.flow li { display: flex; align-items: center; gap: 10px; background: var(--surface); border-radius: var(--radius-md); padding: 12px; font-size: 13px; border: 1px solid var(--border-subtle); color: var(--text-secondary); }
|
||||
.flow span { color: var(--accent-primary-hover); font-family: var(--font-mono); }
|
||||
.flow li { display: flex; align-items: center; gap: 10px; background: var(--surface); border-radius: var(--radius-md); padding: 12px; font-size: var(--text-sm); border: 1px solid var(--border-subtle); color: var(--text-secondary); }
|
||||
.flow span { color: var(--text-primary); font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }
|
||||
.flow .done { color: var(--text-primary); }
|
||||
.flow .current { color: var(--text-primary); border-color: var(--accent-primary); }
|
||||
.flow .failed { color: var(--state-danger); border-color: var(--state-danger); }
|
||||
|
||||
@@ -45,10 +45,10 @@
|
||||
|
||||
<style>
|
||||
.wrapper {
|
||||
background: var(--bg-nav);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
padding: var(--pad-card);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
@@ -63,66 +63,66 @@
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--ink-300);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.empty {
|
||||
color: var(--ink-500);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-mono);
|
||||
color: var(--text-tertiary);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
margin: 0;
|
||||
padding: 14px 0;
|
||||
}
|
||||
.rows { display: grid; gap: 10px; }
|
||||
.row {
|
||||
display: block;
|
||||
color: var(--ink-100);
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
padding: 12px 14px;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-subtle);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.row:hover { border-color: var(--accent); }
|
||||
.row:hover { border-color: var(--accent-primary); }
|
||||
.row-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.action { font-family: var(--font-mono); font-size: var(--text-mono); color: var(--cream); }
|
||||
.count { color: var(--ink-500); font-size: var(--text-mono-sm); font-family: var(--font-mono); }
|
||||
.action { font-family: var(--font-body); font-size: var(--text-sm); color: var(--text-primary); }
|
||||
.count { color: var(--text-tertiary); font-size: var(--text-xs); font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.chip {
|
||||
padding: 3px 9px;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: var(--text-mono-sm);
|
||||
font-family: var(--font-mono);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
font-family: var(--font-body);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.chip-done {
|
||||
background: rgba(124, 212, 255, 0.10);
|
||||
border-color: rgba(124, 212, 255, 0.3);
|
||||
color: var(--spark-500);
|
||||
background: color-mix(in srgb, var(--state-online) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--state-online) 30%, transparent);
|
||||
color: var(--state-online);
|
||||
}
|
||||
.chip-failed {
|
||||
background: rgba(255, 48, 80, 0.1);
|
||||
border-color: rgba(255, 48, 80, 0.3);
|
||||
color: var(--danger-500);
|
||||
color: var(--state-danger);
|
||||
}
|
||||
.chip-pending {
|
||||
background: rgba(255, 176, 32, 0.1);
|
||||
border-color: rgba(255, 176, 32, 0.3);
|
||||
color: var(--amber-500);
|
||||
color: var(--state-warning);
|
||||
}
|
||||
.chip-held {
|
||||
background: rgba(124, 192, 245, 0.10);
|
||||
border-color: rgba(124, 192, 245, 0.28);
|
||||
color: var(--cream);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.chip-manual {
|
||||
background: var(--bg-nav);
|
||||
border-color: var(--border);
|
||||
color: var(--ink-500);
|
||||
background: var(--surface);
|
||||
border-color: var(--border-subtle);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,66 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { playerStatMetrics, type PlayerMapRecord, type PersonalBestMetric, type LeaderboardMetric } from '$lib/leaderboard.js';
|
||||
import { type PlayerMapRecord, type LeaderboardMetric } from '$lib/leaderboard.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import RecordSource from '$ui/components/RecordSource.svelte';
|
||||
|
||||
let { maps, metric = 'highestRound' }: { maps: PlayerMapRecord[]; metric?: LeaderboardMetric } = $props();
|
||||
let { maps, metric = 'highestRound', returnTo }: { maps: PlayerMapRecord[]; metric?: LeaderboardMetric; returnTo?: string } = $props();
|
||||
const number = new Intl.NumberFormat('en-US');
|
||||
const gameName = (game: string) => game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase();
|
||||
const otherMetrics = playerStatMetrics.filter(metric => metric.key !== 'highestRound' && metric.key !== 'kills');
|
||||
const recordMetrics = ['highestRound', 'bestKills', 'bestScore'] as const satisfies readonly PersonalBestMetric[];
|
||||
const records = [{ key: 'highestRound', label: 'Best round' }, { key: 'bestKills', label: 'Best kills' }, { key: 'bestScore', label: 'Best score' }] as const;
|
||||
</script>
|
||||
|
||||
<div class="map-cards">
|
||||
{#each maps as map (`${map.game}:${map.key}`)}
|
||||
<article class="map-card">
|
||||
<header>
|
||||
<p class="game">{gameName(map.game)}</p>
|
||||
<h3><a href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key, metric })}`}>{map.name}</a></h3>
|
||||
<p class="last-played">Last played <time datetime={map.lastSeen}>{$timeFormat.dateTime(map.lastSeen)}</time></p>
|
||||
</header>
|
||||
<dl class="primary-stats">
|
||||
<div><dt>Highest round</dt><dd class="best">{number.format(map.highestRound)}</dd></div>
|
||||
<div><dt>Total kills</dt><dd>{number.format(map.kills)}</dd></div>
|
||||
</dl>
|
||||
<details class="more-stats">
|
||||
<summary aria-label={`All stats and record runs for ${map.name}, ${gameName(map.game)}`}>All stats and record runs</summary>
|
||||
<dl class="additional-stats">
|
||||
{#each otherMetrics as metric (metric.key)}
|
||||
<div><dt>{metric.label}</dt><dd>{number.format(map[metric.key])}</dd></div>
|
||||
{/each}
|
||||
</dl>
|
||||
<div class="record-runs">
|
||||
{#each recordMetrics as key (key)}
|
||||
<div class="record-run">
|
||||
<h4>{playerStatMetrics.find(metric => metric.key === key)!.label} · {number.format(map[key])}</h4>
|
||||
<RecordSource evidence={map.records[key]} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</details>
|
||||
<article>
|
||||
<header><h3><a href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key, metric })}`}>{map.name}</a></h3><span>{map.game === 't5' ? 'Black Ops I' : 'Black Ops II'} · {number.format(map.gamesPlayed)} {map.gamesPlayed === 1 ? 'game' : 'games'}</span></header>
|
||||
<dl>{#each records as record}<div><dt>{record.label}</dt><dd><a href={contextHref(`/matches/${map.records[record.key].matchId}`, returnTo ?? "/leaderboard")} aria-label={`${map.name} ${record.label}: ${number.format(map[record.key])}, Match #${map.records[record.key].matchId}`} title={map.records[record.key].endedAt ? $timeFormat.dateTime(map.records[record.key].endedAt!) : 'Completed match'}>{number.format(map[record.key])}</a></dd></div>{/each}</dl>
|
||||
<p>{number.format(map.kills)} total kills · {number.format(map.roundsSurvived)} observed rounds</p>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.map-cards { min-width: 0; }
|
||||
.map-card { min-width: 0; padding: var(--space-4); border-top: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
|
||||
h3, h4, p, dl, dd { margin: 0; }
|
||||
h3 { margin-top: var(--space-1); font-size: var(--text-md); line-height: 1.5; }
|
||||
h4 { font-size: var(--text-sm); line-height: 1.5; }
|
||||
article { min-width: 0; padding: var(--space-4); border-top: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
|
||||
h3, p, dl, dd { margin: 0; }
|
||||
h3 { font-size: var(--text-md); }
|
||||
header span, p, dt { color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
|
||||
dl { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-2); margin: var(--space-3) 0; }
|
||||
dd { font-family: var(--font-numeric); font-size: var(--text-lg); font-variant-numeric: tabular-nums; margin-top: var(--space-1); }
|
||||
dd a { display: inline-flex; align-items: center; min-height: 44px; }
|
||||
a { color: var(--accent-primary-hover); }
|
||||
.game, .last-played { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.5; }
|
||||
.last-played { margin-top: var(--space-2); }
|
||||
.primary-stats, .additional-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); }
|
||||
.primary-stats { margin-top: var(--space-4); }
|
||||
dt { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.5; }
|
||||
dd { margin-top: var(--space-1); font-family: var(--font-mono); font-size: var(--text-sm); font-variant-numeric: tabular-nums; }
|
||||
.primary-stats dd { font-size: var(--text-xl); }
|
||||
.best { color: var(--accent-primary-hover); }
|
||||
.more-stats { margin-top: var(--space-2); }
|
||||
summary { min-height: 44px; padding: var(--space-3) 0; box-sizing: border-box; color: var(--accent-primary-hover); font-size: var(--text-sm); line-height: 1.5; cursor: pointer; }
|
||||
.record-runs { margin-top: var(--space-4); }
|
||||
.record-run { padding-top: var(--space-3); border-top: 1px solid var(--border-subtle); }
|
||||
.record-run + .record-run { margin-top: var(--space-4); }
|
||||
a:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; border-radius: var(--radius-sm); }
|
||||
</style>
|
||||
|
||||
@@ -1,64 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { playerStatMetrics, type PlayerMatch } from '$lib/leaderboard.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||
|
||||
let { matches }: { matches: PlayerMatch[] } = $props();
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import type { PlayerMatch } from '$lib/leaderboard.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { playerCompletionLabel } from '$ui/player-match.js';
|
||||
let { matches, returnTo }: { matches: PlayerMatch[]; returnTo?: string } = $props();
|
||||
const number = new Intl.NumberFormat('en-US');
|
||||
const gameName = (game: string) => game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase();
|
||||
const otherMetrics = ['roundsSurvived', 'headshots', 'revives', 'downs', 'bestScore'] as const;
|
||||
</script>
|
||||
|
||||
<div class="match-cards">
|
||||
{#each matches as match (match.id)}
|
||||
<article class="match-card">
|
||||
<header>
|
||||
<p class="game">{gameName(match.game)}</p>
|
||||
<h3>{#if match.status === 'completed'}<a href={`/matches/${match.id}`} aria-label={`View ${match.mapName} match from ${$timeFormat.dateTime(match.startedAt)}`}>{match.mapName}</a>{:else}{match.mapName}{/if}</h3>
|
||||
<p class="server">{match.server} {#if match.server_deleted_at}<Badge>Deleted</Badge>{/if}</p>
|
||||
</header>
|
||||
<dl class="match-info">
|
||||
<div><dt>First observed</dt><dd><time datetime={match.startedAt}>{$timeFormat.dateTime(match.startedAt)}</time></dd></div>
|
||||
<div><dt>Match status</dt><dd><span class="status">{match.status}</span> · {match.completed ? 'Completion credited' : 'No completion credit'}</dd></div>
|
||||
</dl>
|
||||
<dl class="primary-stats">
|
||||
<div><dt>Highest round</dt><dd class="best">{number.format(match.highestRound)}</dd></div>
|
||||
<div><dt>Kills</dt><dd>{number.format(match.kills)}</dd></div>
|
||||
</dl>
|
||||
<details class="more-stats">
|
||||
<summary aria-label={`More match stats for ${match.mapName} from ${$timeFormat.dateTime(match.startedAt)}`}>More match stats</summary>
|
||||
<dl class="additional-stats">
|
||||
{#each otherMetrics as key (key)}
|
||||
<div><dt>{playerStatMetrics.find(metric => metric.key === key)!.label}</dt><dd>{number.format(match[key])}</dd></div>
|
||||
{/each}
|
||||
</dl>
|
||||
<p class="observation">Last observed <time datetime={match.lastSeen}>{$timeFormat.dateTime(match.lastSeen)}</time></p>
|
||||
<p class="match-id">Match #{match.id}</p>
|
||||
</details>
|
||||
<article>
|
||||
<h3>{#if match.status === 'completed'}<a href={contextHref(`/matches/${match.id}`, returnTo ?? "/leaderboard")}>{match.mapName} · Match #{match.id}</a>{:else}{match.mapName} · Match #{match.id}{/if}</h3>
|
||||
<p>{serverTitle(match.server)}{match.server_deleted_at ? ' · Removed server' : ''}</p>
|
||||
<p><RelativeTime value={match.startedAt} /> · {playerCompletionLabel(match)}</p>
|
||||
<dl><div><dt>Round</dt><dd>{number.format(match.highestRound)}</dd></div><div><dt>Kills</dt><dd>{number.format(match.kills)}</dd></div><div><dt>Revives</dt><dd>{number.format(match.revives)}</dd></div></dl>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.match-cards { min-width: 0; }
|
||||
.match-card { min-width: 0; padding: var(--space-4); border-top: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
|
||||
article { min-width: 0; padding: var(--space-4); border-top: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
|
||||
h3, p, dl, dd { margin: 0; }
|
||||
h3 { margin-top: var(--space-1); font-size: var(--text-md); line-height: 1.5; }
|
||||
a { color: var(--accent-primary-hover); }
|
||||
.game, .server, .observation, .match-id { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.5; }
|
||||
.server { margin-top: var(--space-2); }
|
||||
.match-info { display: grid; gap: var(--space-2); margin-top: var(--space-3); }
|
||||
dt { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.5; }
|
||||
dd { margin-top: var(--space-1); font-size: var(--text-sm); line-height: 1.5; }
|
||||
.status { text-transform: capitalize; }
|
||||
.primary-stats, .additional-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); }
|
||||
.primary-stats { margin-top: var(--space-4); }
|
||||
.primary-stats dd, .additional-stats dd { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
.primary-stats dd { font-size: var(--text-xl); }
|
||||
.best { color: var(--accent-primary-hover); }
|
||||
.more-stats { margin-top: var(--space-2); }
|
||||
summary { min-height: 44px; padding: var(--space-3) 0; box-sizing: border-box; color: var(--accent-primary-hover); font-size: var(--text-sm); line-height: 1.5; cursor: pointer; }
|
||||
.observation { margin-top: var(--space-4); }
|
||||
.match-id { margin-top: var(--space-1); }
|
||||
a:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; border-radius: var(--radius-sm); }
|
||||
h3 { font-size: var(--text-sm); line-height: var(--leading-body); }
|
||||
h3 a { display: inline-flex; align-items: center; min-height: 44px; color: var(--accent-primary-hover); }
|
||||
p, dt { color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
|
||||
dl { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-2); margin-top: var(--space-3); }
|
||||
dd { margin-top: var(--space-1); font-family: var(--font-numeric); font-size: var(--text-lg); font-variant-numeric: tabular-nums; }
|
||||
</style>
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
<span class="eyebrow">{report.event === 'game_end' ? 'Final scoreboard' : fresh ? 'Live scoreboard' : 'Last reported scoreboard'}</span>
|
||||
<div class="match-title">
|
||||
<h2 title={report.map}>{mapLabel ?? mapTitle(report.map)}</h2>
|
||||
{#if report.event !== 'game_start'}<Badge variant="accent" size="md">Round {report.round}</Badge>{/if}
|
||||
{#if report.event !== 'game_start'}<Badge size="md">Round {report.round}</Badge>{/if}
|
||||
{#if displayElapsed}<span class="match-time">Match {displayElapsed}{fresh ? '' : ' at last report'}</span>{/if}
|
||||
{#if report.event === 'game_end'}<Badge>Game ended</Badge>
|
||||
{:else if report.event === 'game_start'}<Badge>Game started</Badge>{/if}
|
||||
@@ -140,8 +140,8 @@
|
||||
.timing-note { margin: var(--space-2) 0 0; font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||
.guid { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); margin-top: var(--space-1); color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
.stat { text-align: right; white-space: nowrap; }
|
||||
td.stat { font: var(--text-sm) var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
.score { color: var(--accent-primary-hover); font-weight: var(--weight-semibold); }
|
||||
td.stat { font: var(--text-sm) var(--font-numeric); font-variant-numeric: tabular-nums; }
|
||||
.score { color: var(--text-primary); font-weight: var(--weight-semibold); }
|
||||
.unreported { color: var(--text-tertiary); }
|
||||
tbody tr:last-child > * { border-bottom: none; }
|
||||
tbody tr:hover > * { background: var(--surface-raised); }
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
</select></label>
|
||||
{#if !pluginId}
|
||||
<label>Plugin name<input bind:value={name} disabled={busy} placeholder="t6-gsc-utils" /></label>
|
||||
<label>Deploy scope<select bind:value={game} disabled={busy}><option value="">Unscoped — select servers in a beta</option><option value="t5">T5 · Black Ops I</option><option value="t6">T6 · Black Ops II</option></select></label>
|
||||
<label>Deploy scope<select bind:value={game} disabled={busy}><option value="">Unscoped — select servers in a beta</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
|
||||
{/if}
|
||||
</div>
|
||||
{#if targetPath}<p>New versions deploy to <code>{targetPath}</code>.</p>{/if}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import type { ProvisioningDetail, ProvisioningEvent } from '$lib/provisioning-detail.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { isProvisioningEventPage } from '$ui/provisioning-presentation.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
<div class="heading"><div><span class="eyebrow">Activity</span><h2 id="activity-title">Recent updates</h2></div><span class="count">{detail.activity.total} event{detail.activity.total === 1 ? '' : 's'}</span></div>
|
||||
<p class="muted">{open ? 'Recent updates stay in place while you read the full history.' : 'Newest first · updates automatically'}</p>
|
||||
{#if recent.length}<ol class="timeline recent">
|
||||
{#each recent as event (event.id)}<li><time datetime={event.created_at}>{$timeFormat.dateTime(event.created_at)}</time><p>{event.message}</p></li>{/each}
|
||||
{#each recent as event (event.id)}<li><span class="time"><RelativeTime value={event.created_at} /></span><p>{event.message}</p></li>{/each}
|
||||
</ol>{:else}<p class="empty">No detailed activity was recorded. The recorded outcome and timestamps are preserved above.</p>{/if}
|
||||
|
||||
<details class="history" ontoggle={toggle}>
|
||||
@@ -98,7 +98,7 @@
|
||||
{#if loading && !loaded}<p class="muted" role="status">Loading activity…</p>
|
||||
{:else if loaded && !entries.length}<p class="empty">{appliedQuery ? 'No activity matches this search.' : 'No detailed activity recorded.'}</p>
|
||||
{:else}<ol class="timeline">
|
||||
{#each entries as event (event.id)}<li><time datetime={event.created_at}>{$timeFormat.dateTime(event.created_at)}</time><p>{event.message}</p></li>{/each}
|
||||
{#each entries as event (event.id)}<li><span class="time"><RelativeTime value={event.created_at} /></span><p>{event.message}</p></li>{/each}
|
||||
</ol>{/if}
|
||||
{#if loaded}<div class="footer"><span>Showing {entries.length} of {total} matching events</span>{#if cursor !== null}<Button size="sm" loading={loading} disabled={loading} onclick={() => load(true)}>Show more activity</Button>{/if}</div>{/if}
|
||||
</div>
|
||||
@@ -111,7 +111,7 @@
|
||||
.eyebrow { display: block; font-size: var(--text-xs); color: var(--text-tertiary); margin-bottom: var(--space-2); }
|
||||
h2 { margin: 0; font-size: var(--text-lg); letter-spacing: var(--tracking-tight); }
|
||||
p { margin: 0; line-height: 1.65; overflow-wrap: anywhere; font-size: var(--text-sm); }
|
||||
.muted, .count, time, .footer { font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.muted, .count, .time, .footer { font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.empty { padding: var(--space-4); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); color: var(--text-secondary); }
|
||||
.timeline { list-style: none; margin: 0 0 0 var(--space-1); padding: 0 0 0 var(--space-5); border-left: 1px solid var(--border-strong); }
|
||||
.timeline li { padding: 0 0 var(--space-5); position: relative; }.timeline li:last-child { padding-bottom: 0; }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import type { ProvisioningDetail } from '$lib/provisioning-detail.js';
|
||||
import { provisioningGroups, progressIcons, progressStateLabels } from '$ui/provisioning-presentation.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import Badge from './primitives/Badge.svelte';
|
||||
|
||||
let { detail }: { detail: ProvisioningDetail } = $props();
|
||||
@@ -37,7 +37,7 @@
|
||||
{#each group.steps as step (step.key)}
|
||||
<li aria-current={step.state === 'current' || step.state === 'waiting' ? 'step' : undefined}>
|
||||
<span class="step-icon state-{step.state}" aria-hidden="true">{progressIcons[step.state]}</span>
|
||||
<div><strong>{step.label}</strong><span class="step-meta state-{step.state}">{progressStateLabels[step.state]}{#if step.at} · <time datetime={step.at}>{$timeFormat.dateTime(step.at)}</time>{/if}</span></div>
|
||||
<div><strong>{step.label}</strong><span class="step-meta state-{step.state}">{progressStateLabels[step.state]}{#if step.at} · <span class="time"><RelativeTime value={step.at} /></span>{/if}</span></div>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
@@ -49,17 +49,17 @@
|
||||
<div class="command" class:uncertain={progress.command.state === 'uncertain'}>
|
||||
<h3>{progress.command.state === 'attempted' ? 'Command submission attempted' : progress.command.state === 'accepted' ? 'Remote request accepted' : progress.command.state === 'rejected' ? 'Remote request rejected' : 'Remote response uncertain'}</h3>
|
||||
<p>{progress.command.message}</p>
|
||||
<time datetime={progress.command.at}>{$timeFormat.dateTime(progress.command.at)}</time>
|
||||
<span class="time"><RelativeTime value={progress.command.at} /></span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if progress.checkpoints.some(checkpoint => !groups.some(group => group.steps.some(step => step.key === checkpoint.key)))}
|
||||
<details class="milestones"><summary>Recorded checkpoints</summary><ul>
|
||||
{#each progress.checkpoints.filter(checkpoint => !groups.some(group => group.steps.some(step => step.key === checkpoint.key))) as checkpoint (checkpoint.key)}
|
||||
<li><strong>{checkpoint.label}</strong><time datetime={checkpoint.at}>{$timeFormat.dateTime(checkpoint.at)}</time></li>
|
||||
<li><strong>{checkpoint.label}</strong><span class="time"><RelativeTime value={checkpoint.at} /></span></li>
|
||||
{/each}
|
||||
</ul></details>
|
||||
{/if}
|
||||
<div class="evidence-heading"><h3>Latest recorded checks</h3>{#if progress.observedAt}<span>Evaluated <time datetime={progress.observedAt}>{$timeFormat.dateTime(progress.observedAt)}</time></span>{/if}</div>
|
||||
<div class="evidence-heading"><h3>Latest recorded checks</h3>{#if progress.observedAt}<span>Evaluated <span class="time"><RelativeTime value={progress.observedAt} /></span></span>{/if}</div>
|
||||
{#if progress.checks.length}
|
||||
<div class="checks">
|
||||
{#each progress.checks as check (check.key)}
|
||||
@@ -70,7 +70,7 @@
|
||||
<div><dt>Observed</dt><dd>{check.actual ?? 'Unknown'}</dd></div>
|
||||
</dl>{/if}
|
||||
{#if check.message}<p>{check.message}</p>{/if}
|
||||
<div class="evidence-meta"><span>{check.source ? sources[check.source] : 'Source not recorded'}</span><span>Checked <time datetime={check.checkedAt}>{$timeFormat.dateTime(check.checkedAt)}</time></span>{#if check.receivedAt}<span>Evidence received <time datetime={check.receivedAt}>{$timeFormat.dateTime(check.receivedAt)}</time></span>{/if}</div>
|
||||
<div class="evidence-meta"><span>{check.source ? sources[check.source] : 'Source not recorded'}</span><span>Checked <span class="time"><RelativeTime value={check.checkedAt} /></span></span>{#if check.receivedAt}<span>Evidence received <span class="time"><RelativeTime value={check.receivedAt} /></span></span>{/if}</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -93,7 +93,7 @@
|
||||
summary { cursor: pointer; }
|
||||
.phases summary { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-5); list-style: none; }
|
||||
.phases summary::-webkit-details-marker { display: none; }
|
||||
.phases summary::after { content: '+'; color: var(--text-tertiary); font-size: 18px; }
|
||||
.phases summary::after { content: '+'; color: var(--text-tertiary); font-size: var(--text-md); }
|
||||
.phases details[open] > summary::after { content: '−'; }
|
||||
summary:focus-visible { outline: 2px solid var(--border-focus); outline-offset: -3px; border-radius: var(--radius-sm); }
|
||||
.phase-icon { flex: 0 0 26px; display: grid; place-items: center; width: 26px; height: 26px; border: 1px solid var(--border-strong); border-radius: var(--radius-pill); font-size: var(--text-sm); }
|
||||
@@ -105,11 +105,11 @@
|
||||
.steps strong { display: block; font-size: var(--text-sm); font-weight: var(--weight-medium); line-height: 1.5; }
|
||||
.step-meta { display: block; font-size: var(--text-xs); margin-top: var(--space-1); line-height: 1.6; }
|
||||
.state-current { color: var(--accent-primary-hover); }.state-waiting { color: var(--state-warning); }.state-failed { color: var(--state-danger); }.state-done { color: var(--state-online); }
|
||||
.state-unknown, .state-pending, .state-cancelled, time { color: var(--text-secondary); }
|
||||
.state-unknown, .state-pending, .state-cancelled, .time { color: var(--text-secondary); }
|
||||
.command, .legacy { display: grid; gap: var(--space-2); padding: var(--space-4); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||||
.command.uncertain { border-color: color-mix(in srgb, var(--state-warning) 45%, var(--border-subtle)); }
|
||||
p { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.65; overflow-wrap: anywhere; }
|
||||
time, .evidence-meta, .evidence-heading span, .milestones { font-size: var(--text-xs); }
|
||||
.time, .evidence-meta, .evidence-heading span, .milestones { font-size: var(--text-xs); }
|
||||
.evidence-heading { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: var(--space-2); padding-top: var(--space-2); }
|
||||
.evidence-heading span { color: var(--text-secondary); }
|
||||
.checks { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; }
|
||||
|
||||
@@ -2,36 +2,10 @@
|
||||
import type { RecordEvidence } from '$lib/leaderboard.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
|
||||
let { evidence, returnTo, disclosure = true }: { evidence: RecordEvidence; returnTo?: string; disclosure?: boolean } = $props();
|
||||
const gameName = $derived(evidence.game === 't5' ? 'Black Ops I' : 'Black Ops II');
|
||||
let { evidence, returnTo, metric = 'Record' }: { evidence: RecordEvidence; returnTo?: string; metric?: string; disclosure?: boolean } = $props();
|
||||
</script>
|
||||
|
||||
<div class="record-source">
|
||||
<a href={returnTo ? contextHref(`/matches/${evidence.matchId}`, returnTo) : `/matches/${evidence.matchId}`}>{evidence.mapName} · {gameName}</a>
|
||||
<span>{#if evidence.endedAt}Completed <time datetime={evidence.endedAt}>{$timeFormat.dateTime(evidence.endedAt)}</time>{:else}Completed · End time unknown{/if}</span>
|
||||
{#snippet runDetails()}
|
||||
<dl>
|
||||
<div><dt>Instance</dt><dd>{evidence.server}</dd></div>
|
||||
<div><dt>{evidence.startObserved ? 'Match started' : 'Match first observed'}</dt><dd><time datetime={evidence.startedAt}>{$timeFormat.dateTime(evidence.startedAt)}</time></dd></div>
|
||||
<div><dt>Player participation</dt><dd>{evidence.presentAtStart ? 'Present at recorded start' : evidence.firstObservedRound === null ? 'Participation start unknown' : `First observed at round ${evidence.firstObservedRound}`}</dd></div>
|
||||
</dl>
|
||||
<p>Recorded presence does not establish continuous survival.</p>
|
||||
{/snippet}
|
||||
{#if disclosure}<details><summary>Run details</summary>{@render runDetails()}</details>
|
||||
{:else}{@render runDetails()}{/if}
|
||||
</div>
|
||||
|
||||
<span class="record-source"><a href={returnTo ? contextHref(`/matches/${evidence.matchId}`, returnTo) : `/matches/${evidence.matchId}`} aria-label={`${metric}: ${evidence.mapName}, Match #${evidence.matchId}`} title={evidence.endedAt ? `Completed ${$timeFormat.dateTime(evidence.endedAt)}` : 'Completed match; end time unknown'}>Match #{evidence.matchId} · {evidence.mapName}</a></span>
|
||||
<style>
|
||||
.record-source { margin-top: var(--space-2); max-width: 24rem; white-space: normal; overflow-wrap: anywhere; font-family: var(--font-body); font-size: var(--text-xs); font-weight: normal; line-height: 1.5; color: var(--text-secondary); }
|
||||
.record-source { display: block; margin-top: var(--space-1); white-space: normal; overflow-wrap: anywhere; font-family: var(--font-body); font-size: var(--text-xs); font-weight: normal; line-height: var(--leading-body); }
|
||||
a { color: var(--accent-primary-hover); }
|
||||
span { display: block; margin-top: 3px; color: var(--text-tertiary); }
|
||||
details { margin-top: var(--space-1); }
|
||||
summary { cursor: pointer; }
|
||||
dl { margin: var(--space-2) 0; }
|
||||
dl > div + div { margin-top: var(--space-2); }
|
||||
dt { color: var(--text-tertiary); }
|
||||
dd { margin: 0; }
|
||||
p { margin: var(--space-2) 0 0; color: var(--text-tertiary); }
|
||||
a:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; border-radius: var(--radius-sm); }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { timestampISO, type Timestamp } from '$lib/time-zone.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { uiNow } from '$ui/stores/servers.js';
|
||||
|
||||
let { value, missing = '—', class: className }: { value: Timestamp; missing?: string; class?: string } = $props();
|
||||
const iso = $derived(timestampISO(value));
|
||||
const now = $derived(browser ? $uiNow : Date.now());
|
||||
</script>
|
||||
|
||||
{#if iso}<time class={className} datetime={iso} title={$timeFormat.dateTime(value)}>{$timeFormat.relative(value, now)}</time>{:else}<span class={className}>{missing}</span>{/if}
|
||||
@@ -11,7 +11,7 @@
|
||||
</script>
|
||||
|
||||
{#if report && (report.event === 'round_change' || report.event === 'heartbeat') && fresh}
|
||||
<div class="round" aria-live="polite">
|
||||
<div class="round">
|
||||
<strong>Round {report.round}</strong>
|
||||
<span title={$timeFormat.dateTime(report.receivedAt)}>Last reported {elapsedSince(report.receivedAt, $uiNow)} ago</span>
|
||||
</div>
|
||||
@@ -19,6 +19,6 @@
|
||||
|
||||
<style>
|
||||
.round { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2); }
|
||||
strong { color: var(--text-primary); font-family: var(--font-mono); }
|
||||
strong { color: var(--text-primary); font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }
|
||||
span { color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { tick } from 'svelte';
|
||||
import { plural } from '$ui/plural.js';
|
||||
import { gameTitle } from '$ui/server-labels.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import { betaSelectionHref, mapLabel, scopeLabel, type BetaSelection, type LibraryScript } from '$lib/script-library.js';
|
||||
let { scripts, onuploaded }: { scripts: LibraryScript[]; onuploaded: () => Promise<void> } = $props();
|
||||
@@ -176,7 +178,7 @@
|
||||
{#if hasNew}
|
||||
<label>Scope for new scripts<select bind:value={scopeType} disabled={busy} onchange={() => { scopeGame = ''; scopeMap = ''; }}><option value="none">Unscoped · choose beta targets</option><option value="game">Game</option><option value="map">Map</option></select></label>
|
||||
{#if scopeType !== 'none'}
|
||||
<label>Game<select bind:value={scopeGame} disabled={busy || !scopeLoaded} onchange={() => scopeMap = ''}><option value="">Choose a game…</option>{#each scopeOptions.games as g}<option value={g}>{g.toUpperCase()}</option>{/each}</select></label>
|
||||
<label>Game<select bind:value={scopeGame} disabled={busy || !scopeLoaded} onchange={() => scopeMap = ''}><option value="">Choose a game…</option>{#each scopeOptions.games as g}<option value={g}>{gameTitle(g)}</option>{/each}</select></label>
|
||||
{#if scopeType === 'map'}<label>Map<select bind:value={scopeMap} disabled={busy || !scopeGame}><option value="">Choose a map…</option>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as m}<option value={m}>{mapLabel(m)}</option>{/each}</select></label>{/if}
|
||||
{#if scopeValue}<p class="note">{matches.length} matching servers: {matches.map(s => s.name).join(', ') || 'None'}</p>{/if}
|
||||
{#if scopeType === 'map' && scopeGame === 't6'}<p class="note">Map scope includes every server using this engine map, including its Survival, Grief, or other variants.</p>{/if}
|
||||
@@ -187,7 +189,7 @@
|
||||
{/if}
|
||||
{#if message}<p class="notice" role="status">{message}</p>{/if}
|
||||
<footer>
|
||||
{#if inspecting || busy}<p class="batch-progress" role="status">{inspecting ? `Checked ${checkedCount} of ${queue.length} files` : `Uploaded ${results.length} of ${queue.length} files`}{activeFile ? ` · ${activeFile}` : ''}</p>{:else if queue.length}<p class="batch-progress">{results.length} saved · {pending.length} remaining{queue.some(q => q.error) ? ` · ${queue.filter(q => q.error).length} need attention` : ''}</p>{/if}
|
||||
{#if inspecting || busy}<p class="batch-progress" role="status">{inspecting ? `Checked ${checkedCount} of ${queue.length} ${plural(queue.length, 'file')}` : `Uploaded ${results.length} of ${queue.length} ${plural(queue.length, 'file')}`}{activeFile ? ` · ${activeFile}` : ''}</p>{:else if queue.length}{@const failed = queue.filter(q => q.error).length}<p class="batch-progress">{results.length} saved · {pending.length} remaining{failed ? ` · ${failed} ${plural(failed, 'needs', 'need')} attention` : ''}</p>{/if}
|
||||
{#if pending.length}<Button disabled={!ready} loading={busy} onclick={() => upload(false)}>Upload{results.length ? ' remaining' : ''}</Button><Button variant="primary" disabled={!ready} onclick={() => upload(true)}>Upload & prepare beta →</Button>
|
||||
{:else if testable.length}<Button variant="primary" href={betaSelectionHref(testable)}>Prepare beta with {testable.length} script{testable.length === 1 ? '' : 's'} →</Button>{/if}
|
||||
</footer>
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import RoundTelemetry from './RoundTelemetry.svelte';
|
||||
import ServerIdentity from './ServerIdentity.svelte';
|
||||
import { freshStatusTimestamp } from '$lib/server-freshness.js';
|
||||
import type { RosterPlayer, Health } from '$ui/stores/servers.js';
|
||||
import { uiNow } from '$ui/stores/servers.js';
|
||||
import { gametypeLabel } from '$ui/gametype.js';
|
||||
import { telemetryByServer, uiNow } from '$ui/stores/servers.js';
|
||||
import { elapsedSince } from '$ui/duration.js';
|
||||
import { mapTitle, serverTitle, serverIdentity } from '$ui/server-labels.js';
|
||||
import { mapTitle, serverIdentity, serverTitle } from '$ui/server-labels.js';
|
||||
import { decodeRconError, rconFixHint } from '$ui/rconError.js';
|
||||
import Card from './primitives/Card.svelte';
|
||||
import Badge from './primitives/Badge.svelte';
|
||||
import Stat from './primitives/Stat.svelte';
|
||||
import StatusPip from './primitives/StatusPip.svelte';
|
||||
import StatusBadge from './primitives/StatusBadge.svelte';
|
||||
|
||||
interface Held {
|
||||
target: string;
|
||||
betaId: number;
|
||||
}
|
||||
interface Held { target: string; betaId: number; }
|
||||
interface Props {
|
||||
telemetry?: import('$lib/telemetry.js').GameTelemetry;
|
||||
name: string;
|
||||
@@ -36,319 +31,143 @@
|
||||
gameEndedAt?: string | null;
|
||||
compactEmpty?: boolean;
|
||||
mapLabelOverride?: string;
|
||||
round?: number | null;
|
||||
capacity?: number | null;
|
||||
pinned?: boolean;
|
||||
onPin?: () => void;
|
||||
longestMatch?: boolean;
|
||||
}
|
||||
let {
|
||||
telemetry,
|
||||
name,
|
||||
players = null,
|
||||
map = null,
|
||||
gametype = null,
|
||||
roster = null,
|
||||
health = 'offline',
|
||||
lastSeenAt = null,
|
||||
lastError = null,
|
||||
held = [],
|
||||
lastRestartedAt = null,
|
||||
restarting = false,
|
||||
activeSince = null,
|
||||
inactiveSince = null,
|
||||
gameStartedAt = null,
|
||||
gameEndedAt = null,
|
||||
compactEmpty = false,
|
||||
mapLabelOverride
|
||||
telemetry, name, players = null, map = null, roster = null, health = 'offline',
|
||||
lastSeenAt = null, lastError = null, held = [], restarting = false,
|
||||
inactiveSince = null, gameStartedAt = null, gameEndedAt = null, compactEmpty = true,
|
||||
mapLabelOverride, round, capacity = null, pinned = false, onPin, longestMatch = false
|
||||
}: Props = $props();
|
||||
|
||||
function ago(iso: string | null, now: number): string {
|
||||
if (!iso) return 'never';
|
||||
const d = now - new Date(iso).getTime();
|
||||
if (d < 60_000) return `${Math.max(1, Math.floor(d / 1000))}s ago`;
|
||||
if (d < 3_600_000) return `${Math.floor(d / 60_000)}m ago`;
|
||||
if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`;
|
||||
return `${Math.floor(d / 86_400_000)}d ago`;
|
||||
}
|
||||
|
||||
let topRoster = $derived(
|
||||
(roster ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
||||
.slice(0, 4)
|
||||
);
|
||||
let showRoster = $derived(health === 'online' || health === 'stale');
|
||||
let title = $derived(serverTitle(name));
|
||||
let mapLabel = $derived(mapLabelOverride ?? mapTitle(map));
|
||||
const showMap = $derived(mapLabel !== '—' && mapLabel !== serverIdentity(name).map);
|
||||
let gtLabel = $derived(gametypeLabel(gametype));
|
||||
|
||||
let activeElapsed = $derived(activeSince ? elapsedSince(activeSince, $uiNow) : null);
|
||||
let resetting = $derived(!!gameEndedAt && players !== 0);
|
||||
let resetElapsed = $derived(resetting ? elapsedSince(gameEndedAt, $uiNow) : null);
|
||||
let inactiveElapsed = $derived(inactiveSince ? elapsedSince(inactiveSince, $uiNow) : null);
|
||||
let gameInProgress = $derived(!!gameStartedAt && !gameEndedAt && !!activeSince);
|
||||
let gameElapsed = $derived(gameInProgress ? elapsedSince(gameStartedAt, $uiNow) : null);
|
||||
let showTiming = $derived(
|
||||
health === 'online' && (resetting || activeElapsed !== null || inactiveElapsed !== null || gameElapsed !== null)
|
||||
);
|
||||
|
||||
// Shared-element transition to /servers/{name} — Chromium only, others
|
||||
// fall through to a plain nav.
|
||||
let cardStyle = $derived(`view-transition-name: server-${name.replace(/[^a-z0-9_-]/gi, '-')};`);
|
||||
const id = $props.id();
|
||||
const number = new Intl.NumberFormat('en-US');
|
||||
const identity = $derived(serverIdentity(name));
|
||||
const title = $derived(mapLabelOverride ?? identity.map);
|
||||
const game = $derived(identity.game === 'Black Ops I' ? 'BO1' : identity.game === 'Black Ops II' ? 'BO2' : identity.game);
|
||||
const compact = $derived(compactEmpty && health === 'online' && players === 0 && !restarting);
|
||||
const state = $derived(restarting ? 'restarting' : compact ? 'ready' : health);
|
||||
const error = $derived(decodeRconError(lastError));
|
||||
const mapLabel = $derived(mapTitle(map));
|
||||
const showMap = $derived(mapLabel !== '—' && mapLabel !== title);
|
||||
const topRoster = $derived((roster ?? []).slice().sort((a, b) => (b.score ?? 0) - (a.score ?? 0)).slice(0, 4));
|
||||
const report = $derived.by(() => {
|
||||
const streamed = $telemetryByServer.get(name);
|
||||
return streamed && (!telemetry || Date.parse(streamed.receivedAt) > Date.parse(telemetry.receivedAt)) ? streamed : telemetry;
|
||||
});
|
||||
const reportedRound = $derived(report && (report.event === 'heartbeat' || report.event === 'round_change')
|
||||
&& freshStatusTimestamp(report.receivedAt, $uiNow)
|
||||
&& (!gameStartedAt || Date.parse(report.receivedAt) >= Date.parse(gameStartedAt))
|
||||
? report.round : null);
|
||||
const currentRound = $derived(!gameEndedAt ? (round !== undefined ? round : reportedRound) : null);
|
||||
const gameElapsed = $derived(gameStartedAt && !gameEndedAt ? elapsedSince(gameStartedAt, $uiNow) : null);
|
||||
const emptyElapsed = $derived(elapsedSince(inactiveSince, $uiNow));
|
||||
const knownCapacity = $derived(capacity !== null && Number.isFinite(capacity) && capacity > 0 ? capacity : null);
|
||||
const cardStyle = $derived(`view-transition-name: server-${name.replace(/[^a-z0-9_-]/gi, '-')};`);
|
||||
</script>
|
||||
|
||||
<Card
|
||||
href={contextHref(`/servers/${encodeURIComponent(name)}`, $page.url)}
|
||||
elevation="interactive"
|
||||
padding="md"
|
||||
class={restarting ? 'restarting' : health === 'offline' ? 'offline' : ''}
|
||||
ariaLabel="Server {title}, {health}"
|
||||
style={cardStyle}
|
||||
>
|
||||
{#if restarting}
|
||||
<span class="restart-bar" aria-hidden="true"></span>
|
||||
{/if}
|
||||
|
||||
<Card as="article" padding={compact ? 'sm' : 'md'} class={`server-card ${compact ? 'compact' : ''} ${health === 'offline' ? 'offline' : ''}`} ariaLabelledby={`${id}-title`} style={cardStyle}>
|
||||
<div class="head">
|
||||
<div class="name-row">
|
||||
<StatusPip {health} />
|
||||
<strong class="name"><ServerIdentity {name} mapLabel={mapLabelOverride} /></strong>
|
||||
<a class="identity" href={contextHref(`/servers/${encodeURIComponent(name)}`, $page.url)} aria-labelledby={`${id}-title${identity.lobby !== null ? ` ${id}-lobby` : ''}`}>
|
||||
<h3 id={`${id}-title`}>{title}</h3>
|
||||
<span class="identity-meta">
|
||||
{#if identity.lobby !== null}<span id={`${id}-lobby`}>Lobby {identity.lobby}</span>{/if}
|
||||
{#if game}<span class="game" title={identity.game}>{game}</span>{/if}
|
||||
{#if compact}<span class="empty-duration">{emptyElapsed ? `Empty for ${emptyElapsed}` : 'Empty lobby'}</span>{/if}
|
||||
</span>
|
||||
</a>
|
||||
<div class="head-actions">
|
||||
<StatusBadge {state} />
|
||||
{#if onPin}<button class="pin" type="button" aria-pressed={pinned} aria-label={`${pinned ? 'Unpin' : 'Pin'} ${serverTitle(name)}`} title={pinned ? 'Unpin server' : 'Pin server'} onclick={onPin}><span aria-hidden="true">{pinned ? '★' : '☆'}</span></button>{/if}
|
||||
</div>
|
||||
{#if health === 'provisioning'}
|
||||
<Badge variant="info" uppercase>Provisioning</Badge>
|
||||
{:else if health === 'starting'}
|
||||
<Badge variant="info" uppercase>Starting</Badge>
|
||||
{:else if health === 'offline'}
|
||||
<Badge variant="danger" uppercase>offline</Badge>
|
||||
</div>
|
||||
|
||||
{#if !compact}
|
||||
{#if longestMatch && health === 'online'}<div class="highlight"><Badge>Longest match</Badge></div>{/if}
|
||||
{#if showMap}<p class="map-note">{health === 'online' ? 'Current map' : 'Last map'}: {mapLabel}</p>{/if}
|
||||
{#if health === 'online'}
|
||||
<dl class="metrics">
|
||||
<div><dt>Players</dt><dd title={knownCapacity === null ? 'Capacity unavailable' : `${knownCapacity} player capacity`}>{players ?? '—'}<span class="capacity">/{knownCapacity ?? '?'}</span></dd></div>
|
||||
<div><dt>Round</dt><dd>{currentRound ?? '—'}</dd></div>
|
||||
<div><dt>Match time</dt><dd>{gameElapsed ?? '—'}</dd></div>
|
||||
</dl>
|
||||
{#if gameEndedAt && players !== 0}<p class="match-note">Match ended · waiting for the next match</p>{/if}
|
||||
{:else if health === 'stale'}
|
||||
<Badge variant="warning">Stale</Badge>
|
||||
{:else}
|
||||
<div class="players" aria-live="polite">
|
||||
<Stat
|
||||
value={players ?? '—'}
|
||||
size="md"
|
||||
tone={players !== null && players > 0 ? 'online' : 'primary'}
|
||||
tween
|
||||
/>
|
||||
</div>
|
||||
<p class="problem-note"><strong>Current player count unknown.</strong> Last reported: {players ?? 'unknown'}{players !== null ? ` ${players === 1 ? 'player' : 'players'}` : ''}{lastSeenAt ? ` · ${elapsedSince(lastSeenAt, $uiNow)} ago` : ''}.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if gtLabel || showMap}<div class="meta">{#if showMap}<span>{health === 'online' ? 'Current map' : 'Last map'}: {mapLabel}</span>{/if}{#if gtLabel}<span class="gt">{gtLabel}</span>{/if}</div>{/if}
|
||||
|
||||
{#if health === 'stale'}
|
||||
<div class="stale-note"><strong>Players unknown</strong><span>Last known: {players ?? 'unknown'}{players !== null ? ' players' : ''}{lastSeenAt ? ` · ${ago(lastSeenAt, $uiNow)}` : ''}</span></div>
|
||||
{#if topRoster.length}<div class="roster-heading">Last reported players</div>{/if}
|
||||
{/if}
|
||||
|
||||
{#if health === 'online' && players !== null && players > 0 && gameInProgress}<RoundTelemetry {name} report={telemetry} since={gameStartedAt} />{/if}
|
||||
|
||||
{#if showTiming && !(compactEmpty && players === 0)}
|
||||
<div class="timing">
|
||||
{#if resetting}
|
||||
<div class="metric">
|
||||
<span class="metric-label">Resetting</span>
|
||||
<span class="metric-value">{resetElapsed}</span>
|
||||
{#if health === 'online' || health === 'stale'}
|
||||
{#if topRoster.length}
|
||||
<div class="roster">
|
||||
<table aria-label={health === 'stale' ? 'Last reported players' : 'Current players'}>
|
||||
<thead><tr><th scope="col">Player</th><th scope="col">Score</th><th scope="col">Ping <span class="ping-unit">(ms)</span></th></tr></thead>
|
||||
<tbody>{#each topRoster as player (player.guid ?? player.num)}<tr>
|
||||
<td title={player.name ?? undefined}>{player.name || '—'}</td>
|
||||
<td>{player.score === null ? '—' : number.format(player.score)}</td>
|
||||
<td>{player.ping === 999 ? 'Connecting' : player.ping === null ? '—' : number.format(player.ping)}</td>
|
||||
</tr>{/each}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else if activeElapsed}
|
||||
<div class="metric">
|
||||
<span class="metric-label">Active</span>
|
||||
<span class="metric-value">{activeElapsed}</span>
|
||||
</div>
|
||||
{:else if inactiveElapsed}
|
||||
<div class="metric">
|
||||
<span class="metric-label">Inactive</span>
|
||||
<span class="metric-value">{inactiveElapsed}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if gameElapsed}
|
||||
<div class="metric">
|
||||
<span class="metric-label">Match</span>
|
||||
<span class="metric-value">{gameElapsed}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if players !== 0}<p class="roster-unavailable">{health === 'stale' ? 'Waiting for a fresh report' : 'Player roster unavailable'}</p>{/if}
|
||||
{/if}
|
||||
|
||||
{#if showRoster}
|
||||
{#if topRoster.length > 0}
|
||||
<ul class="roster">
|
||||
{#each topRoster as p (p.guid ?? p.name)}
|
||||
<li>
|
||||
<span class="pname">{p.name || '—'}</span>
|
||||
<span class="pscore">{p.score ?? '—'}</span>
|
||||
<span class="pping">{p.ping === 999 ? 'Connecting' : p.ping === null ? '—' : `${p.ping}ms`}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<div class="lobby-empty">{health === 'stale' ? 'Waiting for a fresh report' : players === 0 ? 'Lobby is empty' : 'Player roster unavailable'}</div>
|
||||
{#if health === 'offline'}
|
||||
<p class="problem-note" title={error ? rconFixHint(error.reason) : undefined}>{error?.detail || 'Server status is unavailable. Open diagnostics for details.'}</p>
|
||||
{#if lastSeenAt}<p class="last-report">Last report {elapsedSince(lastSeenAt, $uiNow)} ago</p>{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if held.length > 0}
|
||||
<div class="held">
|
||||
{#each held as h (h.target)}
|
||||
<Badge variant="info" size="sm" title="held by beta {h.betaId}">β {h.target}</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="foot">
|
||||
{#if health === 'offline' && lastError}
|
||||
<span class="err">{lastError}</span>
|
||||
{:else if lastSeenAt}
|
||||
<span>Last seen {ago(lastSeenAt, $uiNow)}</span>
|
||||
{:else if lastRestartedAt}
|
||||
<span>Restart {ago(lastRestartedAt, $uiNow)}</span>
|
||||
{:else}
|
||||
<span class="dim">No data</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if held.length > 0}<div class="held">{#each held as h (h.target)}<Badge variant="info" title={`Held by beta ${h.betaId}`}>β {h.target}</Badge>{/each}</div>{/if}
|
||||
</Card>
|
||||
|
||||
<style>
|
||||
.stale-note { display: grid; gap: 4px; margin-top: 14px; font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
.stale-note strong { color: var(--text-primary); font-weight: var(--weight-medium); }
|
||||
.roster-heading { margin-top: 14px; font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||
/* Global tweaks scoped through :global() since Card is a child component. */
|
||||
:global(.card.restarting) {
|
||||
outline: 1px solid rgba(255, 176, 32, 0.28);
|
||||
:global(.card.server-card) { gap: var(--space-3); transition: border-color var(--dur-fast) var(--ease-standard); }
|
||||
:global(.card.server-card:hover) { border-color: var(--border-strong); }
|
||||
:global(.card.server-card.offline) { border-color: color-mix(in srgb, var(--state-danger) 35%, var(--border-subtle)); }
|
||||
.head { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-2); }
|
||||
.identity { min-width: 0; color: inherit; text-decoration: none; display: flex; flex-direction: column; gap: var(--space-1); }
|
||||
.identity::after { content: ''; position: absolute; inset: 0; border-radius: var(--radius-lg); }
|
||||
.identity:focus-visible { outline: none; }
|
||||
.identity:focus-visible::after { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
|
||||
h3 { margin: 0; font-size: var(--text-md); font-weight: var(--weight-semibold); line-height: var(--leading-tight); overflow-wrap: anywhere; }
|
||||
.identity-meta { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.game { background: var(--surface-raised); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: 2px 5px; white-space: nowrap; font-weight: var(--weight-semibold); }
|
||||
.head-actions { display: flex; align-items: center; flex-shrink: 0; gap: var(--space-1); }
|
||||
.pin { position: relative; z-index: 1; display: inline-grid; place-items: center; width: 32px; height: 32px; padding: 0; margin: -4px -6px -4px 0; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-secondary); cursor: pointer; font-size: var(--text-lg); }
|
||||
.pin:hover, .pin[aria-pressed='true'] { color: var(--accent-primary-hover); background: var(--surface-raised); }
|
||||
.pin:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||
.metrics { display: grid; grid-template-columns: 1fr 1fr minmax(0, 1.5fr); gap: var(--space-3); margin: 0; }
|
||||
.metrics > div { display: flex; flex-direction: column-reverse; align-items: flex-start; gap: var(--space-1); }
|
||||
dt { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
dd { margin: 0; font-family: var(--font-numeric); font-variant-numeric: tabular-nums; font-size: var(--text-xl); font-weight: var(--weight-semibold); line-height: var(--leading-tight); white-space: nowrap; }
|
||||
dd[title] { position: relative; z-index: 1; cursor: help; }
|
||||
.capacity { font-size: var(--text-sm); font-weight: var(--weight-regular); color: var(--text-secondary); }
|
||||
.roster { border-top: 1px solid var(--border-subtle); padding-top: var(--space-2); }
|
||||
table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: var(--text-sm); font-variant-numeric: tabular-nums; }
|
||||
th { font-size: var(--text-xs); color: var(--text-secondary); font-weight: var(--weight-regular); padding: 2px 0 6px; }
|
||||
td { padding: 3px 0; }
|
||||
th, td { text-align: right; }
|
||||
th:first-child, td:first-child { width: 52%; text-align: left; padding-right: var(--space-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
td:not(:first-child) { color: var(--text-secondary); }
|
||||
.ping-unit { font-size: var(--text-xs); }
|
||||
.map-note, .problem-note, .last-report, .roster-unavailable, .match-note { margin: 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }
|
||||
.problem-note strong { color: var(--text-primary); font-weight: var(--weight-medium); }
|
||||
.last-report, .match-note { font-size: var(--text-xs); }
|
||||
.held { display: flex; flex-wrap: wrap; gap: var(--space-1); }
|
||||
:global(.compact) .head { align-items: center; }
|
||||
:global(.compact) .identity { flex-direction: row; align-items: center; flex-wrap: wrap; gap: var(--space-2) var(--space-3); }
|
||||
:global(.compact) h3 { font-size: var(--text-base); }
|
||||
@media (max-width: 640px) {
|
||||
:global(.card.server-card) { padding: var(--space-3); gap: var(--space-2); }
|
||||
.roster, .roster-unavailable { display: none; }
|
||||
.pin { width: 44px; height: 44px; margin: -8px -6px -8px 0; }
|
||||
h3 { font-size: var(--text-base); }
|
||||
dd { font-size: var(--text-lg); }
|
||||
.head-actions { gap: 0; }
|
||||
.metrics { gap: var(--space-2); }
|
||||
:global(.compact) .identity { flex-direction: column; align-items: flex-start; gap: var(--space-1); }
|
||||
}
|
||||
:global(.card.offline) {
|
||||
border-color: color-mix(in srgb, var(--state-danger) 25%, var(--border-subtle));
|
||||
}
|
||||
|
||||
.restart-bar {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 2px;
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--state-warning),
|
||||
var(--state-online),
|
||||
transparent
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: spark-slide 1.6s var(--ease-linear) infinite;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
.name {
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-primary);
|
||||
letter-spacing: var(--tracking-tight);
|
||||
min-width: 0;
|
||||
}
|
||||
.players { flex-shrink: 0; }
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-1);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.gt { color: var(--text-tertiary); }
|
||||
|
||||
.timing {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-5);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.metric-label {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-label);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.metric-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.roster {
|
||||
list-style: none;
|
||||
padding: var(--space-3) 0 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
.roster li {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
padding: 2px 0;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.pname {
|
||||
color: var(--text-primary);
|
||||
font-weight: var(--weight-regular);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pscore,
|
||||
.pping {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.pping { min-width: 44px; text-align: right; }
|
||||
|
||||
.lobby-empty {
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.held {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
padding-top: var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.err { color: var(--state-danger); }
|
||||
.dim { color: var(--text-quaternary); }
|
||||
</style>
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
<style>
|
||||
.identity { display: inline-flex; align-items: baseline; flex-wrap: wrap; gap: 4px 8px; min-width: 0; }
|
||||
.map { overflow-wrap: anywhere; }
|
||||
.lobby { white-space: nowrap; font-size: .85em; color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
.lobby { white-space: nowrap; font-size: max(var(--text-xs), .85em); color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
small { font-size: var(--text-xs); color: var(--text-secondary); white-space: nowrap; font-weight: var(--weight-regular); }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { beforeNavigate, goto } from '$app/navigation';
|
||||
import { confirmAction } from '$ui/confirm.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import RetentionTools from './RetentionTools.svelte';
|
||||
import {
|
||||
defaultFleetSettings, fleetSettingLabels, settingsGroups, settingHelp, settingMinimum,
|
||||
settingZeroMode, settingUnit, settingDisplayValue, validateFleetSettings, type FleetSettings
|
||||
} from '$lib/fleet-settings.js';
|
||||
|
||||
type Key = keyof FleetSettings;
|
||||
type Draft = { [K in Key]: number | undefined };
|
||||
let { initial }: { initial: FleetSettings } = $props();
|
||||
const id = $props.id();
|
||||
const keys = Object.keys(defaultFleetSettings) as Key[];
|
||||
let baseline = $state<FleetSettings>({ ...untrack(() => initial) });
|
||||
let draft = $state<Draft>({ ...untrack(() => initial) });
|
||||
let remembered = Object.fromEntries(keys.filter(key => baseline[key] > 0).map(key => [key, baseline[key]])) as Partial<FleetSettings>;
|
||||
let query = $state('');
|
||||
let selectedGroup = $state(settingsGroups[0].id);
|
||||
let reviewInvalid = $state(false);
|
||||
let saving = $state(false);
|
||||
let error = $state('');
|
||||
let notice = $state('');
|
||||
let allowNavigation = false;
|
||||
const changed = $derived(keys.filter(key => draft[key] !== baseline[key]));
|
||||
const invalid = $derived(keys.filter(key => !!validationMessage(key, draft[key])));
|
||||
const visibleGroups = $derived.by(() => {
|
||||
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
|
||||
return settingsGroups.map(group => ({ ...group, keys: group.keys.filter(key => {
|
||||
if (reviewInvalid && invalid.length) return invalid.includes(key);
|
||||
if (!terms.length) return group.id === selectedGroup;
|
||||
const text = `${group.title} ${group.description} ${fleetSettingLabels[key]} ${settingHelp[key] ?? ''} ${settingZeroMode(key) ?? ''} ${key}`.toLocaleLowerCase();
|
||||
return terms.every(term => text.includes(term));
|
||||
}) })).filter(group => group.keys.length);
|
||||
});
|
||||
const shownCount = $derived(visibleGroups.reduce((count, group) => count + group.keys.length, 0));
|
||||
|
||||
function validationMessage(key: Key, value: number | undefined): string {
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < settingMinimum(key)) return 'Enter a valid whole number.';
|
||||
if (key.endsWith('Days') && value > 36500) return 'Enter 36,500 days or fewer.';
|
||||
return '';
|
||||
}
|
||||
function toggleMode(key: Key, enabled: boolean) {
|
||||
if (saving) return;
|
||||
const current = draft[key];
|
||||
if (enabled) {
|
||||
if (current !== undefined && Number.isSafeInteger(current) && current > 0) remembered[key] = current;
|
||||
draft[key] = 0;
|
||||
} else {
|
||||
draft[key] = remembered[key] ?? (defaultFleetSettings[key] > 0 ? defaultFleetSettings[key] : 1);
|
||||
}
|
||||
error = ''; notice = '';
|
||||
}
|
||||
function selectGroup(groupId: string) { selectedGroup = groupId; query = ''; reviewInvalid = false; }
|
||||
function discard() {
|
||||
if (saving) return;
|
||||
draft = { ...baseline };
|
||||
remembered = Object.fromEntries(keys.filter(key => baseline[key] > 0).map(key => [key, baseline[key]])) as Partial<FleetSettings>;
|
||||
error = ''; notice = ''; reviewInvalid = false;
|
||||
}
|
||||
async function save() {
|
||||
if (saving || !changed.length || invalid.length) return;
|
||||
saving = true; error = ''; notice = '';
|
||||
try {
|
||||
const values = validateFleetSettings(draft);
|
||||
const response = await fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) });
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.error ?? 'Could not save fleet settings.');
|
||||
baseline = validateFleetSettings(result);
|
||||
draft = { ...baseline };
|
||||
for (const key of keys) if (baseline[key] > 0) remembered[key] = baseline[key];
|
||||
notice = 'Settings saved. Changes apply to the next queue or cleanup check.';
|
||||
} catch (cause) { error = cause instanceof Error ? cause.message : 'Could not save fleet settings.'; }
|
||||
finally { saving = false; }
|
||||
}
|
||||
|
||||
beforeNavigate(({ cancel, to, from, type }) => {
|
||||
if (!changed.length || allowNavigation || type === 'leave' || !to || to.url.pathname === from?.url.pathname) return;
|
||||
cancel();
|
||||
void confirmAction({ title: 'Leave Settings?', message: 'Your unsaved settings will be discarded.', confirmLabel: 'Leave settings', danger: true }).then(async accepted => {
|
||||
if (!accepted) return;
|
||||
allowNavigation = true;
|
||||
try { await goto(to.url); } finally { allowNavigation = false; }
|
||||
});
|
||||
});
|
||||
function beforeUnload(event: BeforeUnloadEvent) { if (changed.length) { event.preventDefault(); event.returnValue = ''; } }
|
||||
</script>
|
||||
|
||||
<svelte:window onbeforeunload={beforeUnload} />
|
||||
<div class="settings-layout">
|
||||
<aside class="section-rail">
|
||||
<label class="search-label" for="settings-search">Find a setting</label>
|
||||
<input id="settings-search" type="search" placeholder="Search settings" bind:value={query} disabled={saving} oninput={() => reviewInvalid = false} />
|
||||
<label class="mobile-section">Section
|
||||
<select aria-label="Settings section" value={selectedGroup} disabled={saving} onchange={event => selectGroup(event.currentTarget.value)}>
|
||||
{#each settingsGroups as group}<option value={group.id}>{group.title}{changed.some(key => group.keys.includes(key)) ? ` · ${changed.filter(key => group.keys.includes(key)).length} changed` : ''}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<nav class="group-links" aria-label="Settings sections">
|
||||
{#each settingsGroups as group}
|
||||
{@const count = changed.filter(key => group.keys.includes(key)).length}
|
||||
<a href={`#settings-group-${group.id}`} aria-current={!query.trim() && !reviewInvalid && selectedGroup === group.id ? 'location' : undefined} onclick={event => { event.preventDefault(); if (!saving) selectGroup(group.id); }}>
|
||||
<span>{group.title}</span>{#if count}<span class="changed-count">{count} changed</span>{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
</aside>
|
||||
<div class="settings-content">
|
||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||
{#if notice}<p class="notice" role="status">{notice}</p>{/if}
|
||||
{#if query.trim()}<p class="search-results">{shownCount} {shownCount === 1 ? 'setting matches' : 'settings match'} “{query.trim()}” across all sections. <button type="button" class="text-button" onclick={() => query = ''}>Clear search</button></p>{/if}
|
||||
{#if reviewInvalid && invalid.length}<p class="search-results">Showing invalid settings across all sections. <button type="button" class="text-button" onclick={() => reviewInvalid = false}>Show selected section</button></p>{/if}
|
||||
<form id={`${id}-form`} onsubmit={event => { event.preventDefault(); void save(); }}>
|
||||
<fieldset disabled={saving}>
|
||||
<legend class="sr-only">Fleet settings</legend>
|
||||
{#each visibleGroups as group (group.id)}
|
||||
<section class="settings-group" id={`settings-group-${group.id}`} aria-labelledby={`settings-heading-${group.id}`}>
|
||||
<header><h2 id={`settings-heading-${group.id}`}>{group.title}</h2><p>{group.description}</p></header>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th scope="col">Setting</th><th scope="col">Value</th><th scope="col">Default</th><th scope="col">What it affects</th></tr></thead>
|
||||
<tbody>{#each group.keys as key (key)}
|
||||
{@const zeroMode = settingZeroMode(key)}
|
||||
{@const unlimited = !!zeroMode && draft[key] === 0}
|
||||
{@const validation = validationMessage(key, draft[key])}
|
||||
<tr data-setting={key} class:changed={changed.includes(key)}>
|
||||
<th scope="row"><label for={`setting-${key}`}>{fleetSettingLabels[key]}</label>{#if changed.includes(key)}<span class="changed-marker">Changed</span>{/if}</th>
|
||||
<td class="value-cell">
|
||||
<div class="numeric-value">
|
||||
{#if unlimited}<input id={`setting-${key}`} type="number" disabled placeholder="—" aria-describedby={`setting-${key}-help setting-${key}-default`} />
|
||||
{:else}<input id={`setting-${key}`} type="number" min={Math.max(1, settingMinimum(key))} max={key.endsWith('Days') ? 36500 : undefined} step="1" required bind:value={draft[key]} aria-invalid={!!validation} aria-describedby={`setting-${key}-help setting-${key}-default${validation ? ` setting-${key}-error` : ''}`} oninput={() => { error = ''; notice = ''; }} />{/if}
|
||||
<span class="unit">{settingUnit(key)}</span>
|
||||
</div>
|
||||
{#if zeroMode}<label class="zero-mode" for={`setting-${key}-zero`}><input id={`setting-${key}-zero`} type="checkbox" checked={unlimited} onchange={event => toggleMode(key, event.currentTarget.checked)} aria-label={`${zeroMode}: ${fleetSettingLabels[key]}`} aria-describedby={`setting-${key}-help`} /><span>{zeroMode}</span></label>{/if}
|
||||
{#if validation}<p class="field-error" id={`setting-${key}-error`}>{validation}</p>{/if}
|
||||
</td>
|
||||
<td class="default-value" id={`setting-${key}-default`}><span class="mobile-label">Default: </span>{settingDisplayValue(key, defaultFleetSettings[key])}</td>
|
||||
<td class="help" id={`setting-${key}-help`}>{settingHelp[key]}</td>
|
||||
</tr>
|
||||
{/each}</tbody>
|
||||
</table></div>
|
||||
{#if group.id === 'concurrency'}<p class="group-note">Lowering a limit lets active operations finish; queued work waits for capacity. Player safety checks still apply.</p>{/if}
|
||||
</section>
|
||||
{:else}<p class="no-results">No settings match this search. Try a setting name, a section, or “Forever”.</p>{/each}
|
||||
</fieldset>
|
||||
</form>
|
||||
<div class="retention-tools">
|
||||
<fieldset disabled={saving || invalid.length > 0}>
|
||||
<legend class="sr-only">Retention preview and protections</legend>
|
||||
{#if invalid.length}<p>Correct invalid values to preview cleanup.</p>{/if}
|
||||
<RetentionTools settings={draft as FleetSettings} />
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="save-bar">
|
||||
<div class="save-summary">
|
||||
<strong class:has-changes={changed.length > 0}>{changed.length ? `${changed.length} unsaved ${changed.length === 1 ? 'change' : 'changes'}` : 'All changes saved'}</strong>
|
||||
<span>Applies to everyone. No restart needed.</span>
|
||||
{#if invalid.length}<button type="button" class="invalid-summary text-button" onclick={() => { query = ''; reviewInvalid = true; }}>{invalid.length} invalid {invalid.length === 1 ? 'setting' : 'settings'} · Review invalid settings</button>{/if}
|
||||
</div>
|
||||
<div class="save-actions"><Button variant="ghost" disabled={saving || !changed.length} onclick={discard}>Discard</Button><button class="save-button" type="submit" form={`${id}-form`} disabled={saving || !changed.length || invalid.length > 0}>{saving ? 'Saving…' : 'Save changes'}</button></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings-layout { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: var(--space-5); align-items: start; }
|
||||
.section-rail { position: sticky; top: 80px; display: grid; gap: var(--space-2); min-width: 0; }
|
||||
.search-label { font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
#settings-search { width: 100%; }
|
||||
.group-links { display: grid; gap: var(--space-1); margin-top: var(--space-3); }
|
||||
.group-links a { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); min-height: 40px; padding: var(--space-2) var(--space-3); border-radius: var(--radius-sm); text-decoration: none; font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
.group-links a:hover, .group-links a[aria-current] { background: var(--surface-raised); color: var(--text-primary); }
|
||||
.group-links a[aria-current] { font-weight: var(--weight-semibold); }
|
||||
.changed-count { font-size: var(--text-xs); color: var(--state-warning); white-space: nowrap; }
|
||||
.settings-content, fieldset { min-width: 0; }
|
||||
fieldset { border: 0; padding: 0; margin: 0; }
|
||||
.settings-group { background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-md); overflow: hidden; margin-bottom: var(--space-4); }
|
||||
.settings-group header { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2) var(--space-4); padding: var(--space-4); border-bottom: 1px solid var(--border-subtle); }
|
||||
h2 { margin: 0; font-size: var(--text-base); font-weight: var(--weight-semibold); }
|
||||
p { color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }
|
||||
header p { margin: 0; font-size: var(--text-xs); }
|
||||
.table-wrap { position: relative; min-width: 0; overflow-x: auto; }
|
||||
table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: var(--text-xs); text-align: left; }
|
||||
th, td { padding: var(--space-3); vertical-align: top; border-bottom: 1px solid var(--border-subtle); }
|
||||
th { font-weight: var(--weight-medium); }
|
||||
thead th { color: var(--text-secondary); font-weight: var(--weight-semibold); }
|
||||
thead th:nth-child(1) { width: 23%; }
|
||||
thead th:nth-child(2) { width: 25%; }
|
||||
thead th:nth-child(3) { width: 15%; }
|
||||
tbody tr:last-child th, tbody tr:last-child td { border-bottom: 0; }
|
||||
.changed { background: color-mix(in srgb, var(--state-warning) 3%, transparent); }
|
||||
.changed-marker { display: block; margin-top: var(--space-1); color: var(--state-warning); font-size: var(--text-xs); }
|
||||
.numeric-value { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-1); }
|
||||
.numeric-value input { width: 88px; min-height: 32px; padding: 5px var(--space-2); font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }
|
||||
input:disabled { color: var(--text-tertiary); opacity: 0.75; }
|
||||
.unit { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.zero-mode { display: inline-flex; align-items: center; gap: var(--space-1); margin-top: var(--space-2); color: var(--text-secondary); font-size: var(--text-xs); cursor: pointer; }
|
||||
.zero-mode input { width: 16px; height: 16px; margin: 0; accent-color: var(--accent-primary); }
|
||||
.default-value, .help { color: var(--text-secondary); line-height: var(--leading-body); }
|
||||
.default-value { font-variant-numeric: tabular-nums; }
|
||||
.field-error { margin: var(--space-1) 0 0; color: var(--state-danger); font-size: var(--text-xs); }
|
||||
.group-note { margin: 0; padding: var(--space-3) var(--space-4); border-top: 1px solid var(--border-subtle); font-size: var(--text-xs); }
|
||||
.search-results { margin-top: 0; }
|
||||
.text-button { padding: 0; background: none; border: 0; color: var(--accent-primary-hover); font: inherit; text-decoration: underline; cursor: pointer; }
|
||||
.retention-tools { margin-top: var(--space-4); }
|
||||
.save-bar { position: sticky; bottom: var(--space-3); z-index: 10; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-3); padding: var(--space-3) var(--space-4); margin-top: var(--space-5); border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: var(--surface-raised); box-shadow: var(--shadow-raised); }
|
||||
.save-summary { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1) var(--space-3); font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.save-summary strong { font-weight: var(--weight-semibold); color: var(--text-primary); }
|
||||
.save-summary strong.has-changes { color: var(--state-warning); }
|
||||
.save-actions { display: flex; align-items: center; gap: var(--space-2); margin-left: auto; }
|
||||
.save-button { min-height: 40px; padding: var(--space-2) var(--space-4); border: 1px solid transparent; border-radius: var(--radius-md); background: var(--accent-primary); color: var(--text-on-accent); font-size: var(--text-sm); font-weight: var(--weight-semibold); cursor: pointer; }
|
||||
.save-button:hover:not(:disabled) { background: var(--accent-primary-hover); }
|
||||
.save-button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.invalid-summary { color: var(--state-danger); }
|
||||
.notice { color: var(--state-online); }
|
||||
.mobile-section, .mobile-label { display: none; }
|
||||
@media (max-width: 1100px) { .settings-layout { grid-template-columns: 180px minmax(0, 1fr); gap: var(--space-4); } thead th:nth-child(1) { width: 25%; } thead th:nth-child(2) { width: 27%; } thead th:nth-child(3) { width: 16%; } th, td { padding: var(--space-2); } }
|
||||
@media (max-width: 720px) {
|
||||
.settings-layout { grid-template-columns: minmax(0, 1fr); gap: var(--space-4); }
|
||||
.section-rail { position: static; }
|
||||
.group-links { display: none; }
|
||||
.mobile-section { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: var(--space-3); font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
.mobile-section select { width: 100%; }
|
||||
.table-wrap { overflow: visible; }
|
||||
table, tbody { display: block; }
|
||||
thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||
tbody tr { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-2); padding: var(--space-3); border-bottom: 1px solid var(--border-subtle); }
|
||||
tbody tr:last-child { border-bottom: 0; }
|
||||
th, td { display: block; padding: 0; border: 0; }
|
||||
tbody th { grid-column: 1 / -1; font-size: var(--text-sm); }
|
||||
.changed-marker { display: inline; margin-left: var(--space-2); }
|
||||
.value-cell { grid-column: 1 / -1; display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2) var(--space-4); }
|
||||
.zero-mode { min-height: 44px; margin-top: 0; }
|
||||
.zero-mode input { width: 20px; height: 20px; }
|
||||
.default-value, .help { grid-column: 1 / -1; }
|
||||
.mobile-label { display: inline; }
|
||||
.save-bar { bottom: max(var(--space-2), env(safe-area-inset-bottom)); padding: var(--space-3); gap: var(--space-2); }
|
||||
.save-summary { gap: var(--space-1); flex-direction: column; align-items: flex-start; }
|
||||
.save-actions { width: 100%; justify-content: flex-end; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,37 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Health } from '$ui/stores/servers.js';
|
||||
interface Props {
|
||||
health: Health;
|
||||
size?: number;
|
||||
label?: string | null;
|
||||
}
|
||||
let { health, size = 10, label = null }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="dot dot-{health}"
|
||||
style="width:{size}px;height:{size}px"
|
||||
aria-label={label ?? `server ${health}`}
|
||||
title={label ?? health}
|
||||
></span>
|
||||
|
||||
<style>
|
||||
.dot {
|
||||
display: inline-block;
|
||||
border-radius: 999px;
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dot-online {
|
||||
background: var(--spark-500);
|
||||
animation: electric-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
.dot-stale, .dot-starting, .dot-provisioning {
|
||||
background: var(--amber-500);
|
||||
box-shadow: var(--amber-glow);
|
||||
}
|
||||
.dot-offline {
|
||||
background: var(--danger-500);
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { TELEMETRY_REQUEST_RETENTION, type TelemetryRequestLogEntry } from '$lib/telemetry-diagnostics.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { serverTitle } from '$ui/server-labels.js';
|
||||
import Card from './primitives/Card.svelte';
|
||||
import Badge from './primitives/Badge.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import Freshness from './Freshness.svelte';
|
||||
import RelativeTime from './RelativeTime.svelte';
|
||||
import EmptyState from './primitives/EmptyState.svelte';
|
||||
import ListFooter from './primitives/ListFooter.svelte';
|
||||
|
||||
@@ -15,15 +16,18 @@
|
||||
let refreshing = $state(false);
|
||||
let loaded = $state(false);
|
||||
let error = $state('');
|
||||
let refreshedAt = $state<string | null>(null);
|
||||
let refreshedAt = $state<number | null>(null);
|
||||
let limit = $state(25);
|
||||
let active = false;
|
||||
let controller: AbortController | null = null;
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
async function refresh(signal?: AbortSignal): Promise<void | false> {
|
||||
if (!active || refreshing) return;
|
||||
const currentServer = server;
|
||||
const requestController = new AbortController();
|
||||
const abort = () => requestController.abort();
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
if (signal?.aborted) requestController.abort();
|
||||
controller = requestController;
|
||||
refreshing = true;
|
||||
try {
|
||||
@@ -35,17 +39,19 @@
|
||||
if (!response.ok || !body?.ok || !Array.isArray(body.requests)) {
|
||||
throw new Error(body?.error || `Could not load telemetry requests (HTTP ${response.status}).`);
|
||||
}
|
||||
if (!active || server !== currentServer) return;
|
||||
if (!active || server !== currentServer || requestController.signal.aborted) return;
|
||||
requests = body.requests;
|
||||
retention = body.retention;
|
||||
loaded = true;
|
||||
error = '';
|
||||
refreshedAt = new Date().toISOString();
|
||||
refreshedAt = Date.now();
|
||||
} catch (cause) {
|
||||
if (active && server === currentServer && !requestController.signal.aborted) {
|
||||
error = cause instanceof Error ? cause.message : 'Could not load telemetry requests.';
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
if (active && server === currentServer) refreshing = false;
|
||||
if (controller === requestController) controller = null;
|
||||
}
|
||||
@@ -61,10 +67,6 @@
|
||||
};
|
||||
});
|
||||
|
||||
function timestamp(value: string): string {
|
||||
return $timeFormat.dateTime(value);
|
||||
}
|
||||
|
||||
function outcome(status: number): string {
|
||||
return status >= 500 ? 'Failed' : status >= 400 ? 'Rejected' : 'Accepted';
|
||||
}
|
||||
@@ -74,15 +76,15 @@
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>Telemetry API requests</h2>
|
||||
<p class="server-name"><code>{server}</code></p>
|
||||
<p class="server-name">{serverTitle(server)}</p>
|
||||
</div>
|
||||
<Button size="sm" onclick={refresh} disabled={refreshing} loading={refreshing}>Refresh</Button>
|
||||
<Freshness updatedAt={refreshedAt} error={error ? 'Telemetry request updates interrupted.' : ''} intervalLabel="Updates every 5 seconds" onRetry={() => void refresh()} retryDisabled={refreshing} />
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="load-error" role="alert">
|
||||
Couldn’t load telemetry requests. {#if loaded} Previously loaded requests are shown below.{/if}
|
||||
</p><Button size="sm" onclick={refresh} disabled={refreshing}>Retry</Button><details class="payload"><summary>Technical details</summary>{error}</details>
|
||||
</p><details class="payload"><summary>Technical details</summary>{error}</details>
|
||||
{/if}
|
||||
|
||||
{#if !loaded && !error}
|
||||
@@ -103,7 +105,7 @@
|
||||
</Badge>
|
||||
<code class="event">{request.event ?? 'Event not provided'}</code>
|
||||
</div>
|
||||
<time class="received" datetime={request.receivedAt}>{timestamp(request.receivedAt)}</time>
|
||||
<span class="received"><RelativeTime value={request.receivedAt} /></span>
|
||||
</div>
|
||||
<dl class="request-meta">
|
||||
<div><dt>Map</dt><dd><code>{request.map ?? 'Not provided'}</code></dd></div>
|
||||
@@ -124,13 +126,11 @@
|
||||
{/if}
|
||||
|
||||
<details class="request-help"><summary>About request logging</summary> <p class="intro">
|
||||
Incoming <code>POST /api/telemetry</code> requests with this exact server name.
|
||||
Incoming <code>POST /api/telemetry</code> requests with the exact server name <code>{server}</code>.
|
||||
Fresh game telemetry supplies player counts when available; RCON provides a fallback. The scoreboard uses telemetry.
|
||||
</p>
|
||||
<p class="retention">
|
||||
Newest first · {retention.maxPerServer ? `up to ${retention.maxPerServer} requests per server` : 'no per-server retention cap'} · {retention.maxAgeDays ? `last ${retention.maxAgeDays} days` : 'no age expiry'}. Fleet-wide limits also apply.
|
||||
Refreshes every 5 seconds while visible.
|
||||
{#if refreshedAt}<span>Last refreshed <time datetime={refreshedAt}>{timestamp(refreshedAt)}</time>.</span>{/if}
|
||||
</p>
|
||||
|
||||
<p class="attribution">
|
||||
@@ -161,7 +161,6 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.retention { margin-top: var(--space-2); }
|
||||
.retention span { display: block; }
|
||||
.attribution { margin-top: var(--space-4); }
|
||||
.request-list { list-style: none; padding: 0; margin: var(--space-5) 0 0; }
|
||||
.request { padding: var(--space-4) 0; border-top: 1px solid var(--border-subtle); }
|
||||
|
||||
@@ -123,16 +123,17 @@
|
||||
<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; justify-content: space-between; align-items: flex-start; gap: var(--space-4); flex-wrap: wrap; }
|
||||
.heading > div:first-child { flex:1 1 240px; min-width:0; }
|
||||
.heading p { margin: var(--space-2) 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
.controls { display:flex; align-items:center; flex-wrap:wrap; gap:12px; }
|
||||
.controls { display:flex; align-items:center; flex-wrap:wrap; gap:12px; min-width:0; max-width:100%; }
|
||||
.scope-note { margin:8px 0 0; font-size:var(--text-xs); color:var(--text-secondary); }
|
||||
.mode { display: flex; gap: 3px; padding: 3px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||||
.mode button { padding: 7px 14px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--text-xs); cursor: pointer; }
|
||||
.mode button[aria-pressed='true'] { color: var(--text-primary); background: var(--surface-raised); }
|
||||
.mode button:focus-visible, .plot:focus-visible, .table-wrap:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
|
||||
.axis-title { margin-top: var(--space-4); color: var(--text-tertiary); font-size: 11px; }
|
||||
.axis-title { margin-top: var(--space-4); color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
.chart-grid { display: grid; grid-template-columns: 42px minmax(0, 1fr); gap: 10px; margin-top: 14px; }
|
||||
.y-axis { position: relative; height: 176px; margin-top: 8px; color: var(--text-tertiary); font-size: 11px; }
|
||||
.y-axis { position: relative; height: 176px; margin-top: 8px; color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
.y-axis span { position: absolute; right: 0; transform: translateY(-50%); }
|
||||
.plot { position: relative; height: 192px; border-radius: 3px; cursor: crosshair; touch-action: pan-y; }
|
||||
svg { display: block; width: 100%; height: 100%; overflow: visible; }
|
||||
@@ -141,7 +142,7 @@
|
||||
.trend { fill: none; stroke: var(--accent-primary); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
|
||||
.point, .selected-point { fill: var(--accent-primary-hover); } .selected-point { stroke: var(--surface); stroke-width: 2; }
|
||||
.cursor { stroke: var(--text-tertiary); stroke-width: 1; stroke-dasharray: 4 5; }
|
||||
.x-axis { grid-column: 2; display: flex; justify-content: space-between; gap: var(--space-4); color: var(--text-tertiary); font-size: 11px; }
|
||||
.x-axis { grid-column: 2; display: flex; justify-content: space-between; gap: var(--space-4); color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
.x-axis span:last-child { text-align: right; }
|
||||
.empty-chart { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 10px; text-align: center; padding: var(--space-4); pointer-events: none; }
|
||||
.empty-chart strong { font-size: var(--text-sm); font-weight: var(--weight-medium); } .empty-chart span { color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
@@ -150,8 +151,8 @@
|
||||
dl { display:flex; flex-wrap:wrap; gap:12px 24px; margin:0; } dl>div { display:flex; gap:6px; } dt { color:var(--text-secondary); } dd { margin:0; font-variant-numeric:tabular-nums; }
|
||||
.note { color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.65; margin: var(--space-4) 0; max-width: 115ch; }
|
||||
summary { cursor: pointer; color: var(--text-secondary); font-size: var(--text-xs); padding: 4px 0; }
|
||||
.table-wrap { overflow: auto; max-height: 400px; margin-top: var(--space-3); } table { width: 100%; border-collapse: collapse; font-size: var(--text-xs); }
|
||||
.table-wrap { position: relative; overflow: auto; max-height: 400px; margin-top: var(--space-3); } table { width: 100%; border-collapse: collapse; font-size: var(--text-xs); }
|
||||
caption { text-align: left; color: var(--text-tertiary); margin-bottom: var(--space-3); } th, td { text-align: right; padding: 12px; white-space: nowrap; border-bottom: 1px solid var(--border-subtle); font-variant-numeric: tabular-nums; }
|
||||
th:first-child { text-align: left; } tbody th { font-weight: var(--weight-regular); } thead th { color: var(--text-secondary); }
|
||||
@media (max-width: 640px) { .panel { padding: var(--space-4); } .chart-grid { grid-template-columns: 32px minmax(0, 1fr); gap: 8px; } .readout { gap: var(--space-4); } dl { gap: var(--space-4); } .x-axis { font-size: 10px; } }
|
||||
@media (max-width: 640px) { .panel { padding: var(--space-4); } .chart-grid { grid-template-columns: 32px minmax(0, 1fr); gap: 8px; } .readout { gap: var(--space-4); } dl { gap: var(--space-4); } .x-axis { font-size: var(--text-xs); } }
|
||||
</style>
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Host</th><th>Additional instances</th><th>Limited by</th><th>CPU available</th><th>Memory available</th><th>Free ports</th><th><span class="sr-only">Details</span></th></tr></thead>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable tables must be reachable by keyboard.) -->
|
||||
<div class="table-wrap" role="region" aria-label="Host capacity comparison" tabindex="0"><table>
|
||||
<thead><tr><th>Host</th><th>Additional servers</th><th>Limited by</th><th>CPU available</th><th>Memory available</th><th>Free ports</th><th><span class="sr-only">Details</span></th></tr></thead>
|
||||
<tbody>{#each hosts as host (host.id)}
|
||||
<tr>
|
||||
<td><a class="name" href={contextHref(`/hosts/${host.id}`, returnTo)}>{host.name}</a></td>
|
||||
@@ -30,12 +31,12 @@
|
||||
<div class="detail-content">
|
||||
{#if host.reasons.length}<ul>{#each host.reasons as reason}<li>{reason}</li>{/each}</ul>{/if}
|
||||
<p>Games ready: {host.games.length ? host.games.map(game => game === 't5' ? 'Black Ops I' : 'Black Ops II').join(', ') : 'None'} · {host.portsAvailable} of {host.totalPorts} ports free.</p>
|
||||
<p>Instance limits: CPU {count(host.limits.cpu)} · Memory {count(host.limits.memory)} · Ports {host.limits.ports}.</p>
|
||||
<dl>{#each [{ label: 'Running instance', value: host.calibration.running }, { label: 'Starting instance', value: host.calibration.startup }] as allowance}<div>
|
||||
<p>Server limits: CPU {count(host.limits.cpu)} · Memory {count(host.limits.memory)} · Ports {host.limits.ports}.</p>
|
||||
<dl>{#each [{ label: 'Running server', value: host.calibration.running }, { label: 'Starting server', value: host.calibration.startup }] as allowance}<div>
|
||||
<dt>{allowance.label}</dt><dd>{count(allowance.value.cpuCores)} cores · {memory(allowance.value.memoryBytes)}</dd>
|
||||
<dd class="secondary">{allowance.value.source === 'bootstrap' ? 'Default allowance' : `${allowance.value.source === 'host' ? 'Host' : 'Fleet'} observations · ${allowance.value.samples} samples`}</dd>
|
||||
</div>{/each}</dl>
|
||||
<p>Starting instances may need more resources. Provisioning checks current capacity before reserving a host and port.</p>
|
||||
<p>Starting servers may need more resources. The controller checks current capacity before reserving a host and port.</p>
|
||||
<p>{host.updatedAt ? `Reported ${$timeFormat.dateTime(host.updatedAt)}` : 'No host report yet'}</p>
|
||||
</div>
|
||||
</td></tr>
|
||||
@@ -43,7 +44,8 @@
|
||||
</table></div>
|
||||
|
||||
<style>
|
||||
.table-wrap { overflow-x:auto; border:1px solid var(--border-subtle); border-radius:var(--radius-md); }
|
||||
.table-wrap { position:relative; overflow-x:auto; border:1px solid var(--border-subtle); border-radius:var(--radius-md); }
|
||||
.table-wrap:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; }
|
||||
table { width:100%; min-width:800px; border-collapse:collapse; text-align:left; font-size:var(--text-sm); }
|
||||
th { padding:12px 16px; color:var(--text-secondary); font-size:var(--text-xs); font-weight:500; background:var(--surface-sunken); }
|
||||
td { padding:14px 16px; border-top:1px solid var(--border-subtle); background:var(--surface); font-variant-numeric:tabular-nums; }
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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 { metricDuration, metricNumber, metricPercent, metricPeriodLabels } 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';
|
||||
@@ -32,7 +32,7 @@
|
||||
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 periodLabels = $derived(metricPeriodLabels(points, days, timeZone));
|
||||
const captured = $derived(selected ? selected.observed + selected.unknown : 0);
|
||||
|
||||
function setMode(value: 'hours' | 'percent') {
|
||||
@@ -65,7 +65,7 @@
|
||||
</script>
|
||||
|
||||
<section class="panel" aria-labelledby="metrics-trend-title">
|
||||
<div class="heading"><div><h2 id="metrics-trend-title">Instance activity 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="heading"><div><h2 id="metrics-trend-title">Server activity 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>
|
||||
@@ -88,10 +88,11 @@
|
||||
</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 class="x-axis" class:with-dates={days === 30} aria-hidden="true">{#each periodLabels as item}<span style:left={`${item.position}%`} title={format.period(points[item.index].startAt)}>{item.label}{#if item.date}<small>{item.date}</small>{/if}</span>{/each}</div>
|
||||
</div>
|
||||
<p class="period-note">{days === 1 ? 'Hours' : 'Weekdays'} show when each period starts in {timeZone.replaceAll('_', ' ')}.{days === 1 ? '' : ' Daily periods are 24-hour windows.'}</p>
|
||||
{#if selected}
|
||||
<div class="selection-summary" aria-label="Selected period summary"><time>{format.period(selected.startAt)}{selected.partial ? ' · Partial period' : ''}</time><span>{captured ? `${metricDuration(selected.active)} with players · ${metricDuration(selected.idle)} idle · ${metricDuration(selected.unknown)} unknown · ${selected.observed ? metricNumber(selected.playerHours) : '—'} player-hours` : 'No captured time · not evidence of idle instances'}</span></div>
|
||||
<div class="selection-summary" aria-label="Selected period summary"><time>{format.period(selected.startAt)}{selected.partial ? ' · Partial period' : ''}</time><span>{captured ? `${metricDuration(selected.active)} with players · ${metricDuration(selected.idle)} idle · ${metricDuration(selected.unknown)} unknown · ${selected.observed ? metricNumber(selected.playerHours) : '—'} player-hours` : 'No captured time · not evidence of idle servers'}</span></div>
|
||||
<details class="inspection"><summary>Inspect a period</summary>
|
||||
<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>
|
||||
@@ -125,6 +126,7 @@
|
||||
.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; }
|
||||
.heading > div:first-child { flex: 1 1 260px; min-width: 0; }
|
||||
.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); }
|
||||
@@ -134,12 +136,12 @@
|
||||
.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); }
|
||||
.idle { background: repeating-linear-gradient(0deg, transparent 0 5px, color-mix(in srgb, var(--text-secondary) 24%, transparent) 5px 6px), 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); }
|
||||
.axis-title { margin-top: var(--space-5); font-size: var(--text-xs); 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 { position: relative; text-align: right; font-size: var(--text-xs); 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; }
|
||||
@@ -149,13 +151,14 @@
|
||||
.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; }
|
||||
.no-time { color: var(--text-tertiary); font-size: var(--text-lg); 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; }
|
||||
.partial-marker { position: absolute; top: -17px; left: 50%; transform: translateX(-50%); font-size: var(--text-base); color: var(--text-secondary); }
|
||||
.x-axis { grid-column: 2; position: relative; min-width: 0; height: 1.6em; color: var(--text-secondary); font-size: var(--text-xs); margin-top: 10px; }
|
||||
.x-axis.with-dates { height: 3em; }
|
||||
.x-axis > span { position: absolute; transform: translateX(-50%); text-align: center; white-space: nowrap; }
|
||||
.x-axis small { margin: 0; }
|
||||
.period-note { color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); margin: var(--space-2) 0 0; }
|
||||
.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; }
|
||||
@@ -165,13 +168,13 @@
|
||||
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; }
|
||||
small { display: block; color: var(--text-tertiary); font-size: var(--text-xs); 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; }
|
||||
.denominator { color: var(--text-tertiary); font-size: var(--text-xs); 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 { position: relative; 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; }
|
||||
@@ -179,5 +182,5 @@
|
||||
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); } }
|
||||
@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; } dl { grid-template-columns: repeat(2, minmax(0, 1fr)); } .readout { padding: var(--space-3); } .step-buttons { flex-wrap: wrap; } }
|
||||
</style>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
type Heatmap = MetricsData['heatmap'];
|
||||
type Cell = Heatmap['cells'][number];
|
||||
let { heatmap, disabled = false, onTimeZoneChange }: { heatmap: Heatmap; disabled?: boolean; onTimeZoneChange: (value: string) => void } = $props();
|
||||
const id = $props.id();
|
||||
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('_', ' ') })));
|
||||
@@ -34,8 +35,8 @@
|
||||
}
|
||||
</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>
|
||||
<section class="heatmap" aria-labelledby={`${id}-heatmap-title`}>
|
||||
<div class="heading"><div><h2 id={`${id}-heatmap-title`}>Activity by weekday and hour</h2><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>
|
||||
@@ -65,24 +66,25 @@
|
||||
</div>
|
||||
{/if}
|
||||
<span class="sr-only" role="status" aria-live="polite">{announcement}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.heatmap { min-width: 0; margin-top: var(--space-4); }
|
||||
.heatmap { min-width: 0; padding: var(--space-5); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); }
|
||||
.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); }
|
||||
.heading > div:first-child { flex: 1 1 260px; min-width: 0; }
|
||||
h2 { margin: 0; font-size: var(--text-lg); }
|
||||
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); }
|
||||
.zone { flex: 0 1 250px; min-width: 0; max-width: 100%; }
|
||||
.legend { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; margin: var(--space-4) 0 var(--space-3); font-size: var(--text-xs); 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 { position: relative; 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 { color: var(--text-tertiary); font-weight: normal; font-size: var(--text-xs); 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; }
|
||||
@@ -91,14 +93,14 @@
|
||||
.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; }
|
||||
.note { color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
.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); }
|
||||
dt { font-size: var(--text-xs); 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); }
|
||||
small { display: block; margin-top: 4px; font-size: var(--text-xs); 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)); } }
|
||||
@media (max-width: 640px) { .heatmap { padding: var(--space-4); } .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>
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
|
||||
<div class="summary">
|
||||
<Card><Stat label="Time with players" value={metricPercent(report.summary.active, report.summary.observed)} size="lg" tween={false} />
|
||||
<p>{occupancyDelta === null ? 'Comparison unavailable' : `${signed(occupancyDelta, ' pp')} vs ${previousLabel}`}</p>
|
||||
<small>{metricDuration(report.summary.active)} of {metricDuration(report.summary.observed)} observed instance time</small>
|
||||
<p>{occupancyDelta === null ? 'Comparison unavailable' : `${signed(occupancyDelta, ' percentage points')} vs ${previousLabel}`}</p>
|
||||
<small>{metricDuration(report.summary.active)} of {metricDuration(report.summary.observed)} observed server time</small>
|
||||
</Card>
|
||||
<Card><Stat label="Player-hours" value={report.summary.observed > 0 ? metricNumber(report.summary.playerHours) : '—'} size="lg" tween={false} />
|
||||
<p>{playerDelta === null ? comparable && report.summary.playerHours > 0 ? 'New · no previous player-hours' : 'Comparison unavailable' : `${signed(playerDelta, '%')} vs ${previousLabel}`}</p>
|
||||
@@ -24,7 +24,7 @@
|
||||
</Card>
|
||||
<Card><Stat label="Time all lobbies were full" value={metricPercent(pressure.fullMapHours, pressure.knownMapHours)} size="lg" tween={false} />
|
||||
<p>{pressure.knownMapHours > 0 ? `${pressure.coveredMaps} of ${pressure.totalMaps} maps have capacity data` : 'Capacity data unavailable'}</p>
|
||||
<small>Pooled map time · not simultaneous fleet saturation</small>
|
||||
<small>Combined map history · each map measured separately</small>
|
||||
</Card>
|
||||
</div>
|
||||
{#if prior.partialHistory}<p class="notice">Partial previous history. Comparisons use available observations without extrapolation.</p>{/if}
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
.heading { padding: 20px; display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
|
||||
h2 { font-size: var(--text-lg); margin: 0; }
|
||||
.search { flex: 0 1 240px; }
|
||||
.table-wrap { width: 100%; overflow-x: auto; }
|
||||
.table-wrap { position: relative; 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: 12px 16px; border-bottom: 1px solid var(--border-subtle); text-align: right; font-variant-numeric: tabular-nums; vertical-align: top; }
|
||||
|
||||
@@ -63,9 +63,9 @@
|
||||
border-color: var(--border-subtle);
|
||||
}
|
||||
.v-online {
|
||||
background: rgba(124, 212, 255, 0.10);
|
||||
background: color-mix(in srgb, var(--state-online) 10%, transparent);
|
||||
color: var(--state-online);
|
||||
border-color: rgba(124, 212, 255, 0.25);
|
||||
border-color: color-mix(in srgb, var(--state-online) 25%, transparent);
|
||||
}
|
||||
.v-warning {
|
||||
background: rgba(255, 176, 32, 0.10);
|
||||
@@ -78,9 +78,9 @@
|
||||
border-color: rgba(255, 86, 112, 0.28);
|
||||
}
|
||||
.v-info {
|
||||
background: rgba(124, 192, 245, 0.10);
|
||||
color: var(--blue-300);
|
||||
border-color: rgba(124, 192, 245, 0.28);
|
||||
background: color-mix(in srgb, var(--state-info) 10%, transparent);
|
||||
color: var(--state-info);
|
||||
border-color: color-mix(in srgb, var(--state-info) 28%, transparent);
|
||||
}
|
||||
.v-accent {
|
||||
background: var(--accent-primary);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user