diff --git a/README.md b/README.md index d4cd0d0..235b420 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,14 @@ removal, and restart-recovery state appears alongside affected servers. Fleet membership and operation progress refresh every ten seconds, with live server status between refreshes. Failed refreshes keep the last snapshot visible. +**Metrics** explores recorded server use by time range, game, and map. It includes +occupied/idle/unknown timelines, sortable rankings, previous-period comparisons, +map capacity pressure, and an activity heatmap in the selected timezone. Hosting +experiments suggest changes from explicit rules, including tentative advice from +sparse observations; they never change the fleet automatically. Background refresh +keeps the last report visible if it fails. See [Metrics](docs/metrics.md) for the +definitions, evidence limits, and experimental rules. + ## Stack - SvelteKit (adapter-node) + TypeScript diff --git a/docs/metrics-improvement-plan.md b/docs/metrics-improvement-plan.md index 22fcbdd..ac78d4c 100644 --- a/docs/metrics-improvement-plan.md +++ b/docs/metrics-improvement-plan.md @@ -105,3 +105,5 @@ The team accepted two primary metrics, visible data-quality context, an absolute Defer a default percentage-only chart, fleet-wide “all full” KPI, period-over-period deltas, heatmaps, automatic hosting recommendations, and global game/map filters. A percentage-only chart can hide tiny denominators; map pressure intervals overlap and cannot be summed into a meaningful fleet total. Comparisons and heatmaps need fair coverage treatment. Global filters should follow later as a complete query scope applied consistently to summary, timeline, and both tables, with URL state. Delivery can be split into three reviewable changes: (1) report metadata, measured query improvements, and calculation tests; (2) refresh handling plus summary/timeline and report states; (3) rankings, mobile/accessibility refinement, and documentation. The data contract comes first; frontend layout work can proceed against that agreed contract in parallel. The result should let an operator read utilization correctly, see its evidence limits, inspect a period, and find relevant maps or servers without treating missing data as zero. + +After approval, the base was implemented and committed as `3922e28`. The user also authorized all previously deferred features as experiments. The panel reconvened after that commit; [the review and revised decisions](metrics-review.md) supersede the deferrals above while preserving explicit evidence and metric definitions. diff --git a/docs/metrics-review.md b/docs/metrics-review.md new file mode 100644 index 0000000..f67eee1 --- /dev/null +++ b/docs/metrics-review.md @@ -0,0 +1,36 @@ +The metrics panel reconvened after commit `3922e28` to review the implemented base and revisit the features deferred in the original plan. The user authorized proceeding with all of them, including experiments based on limited evidence. + +The base passed 711 unit tests, Svelte checking with no errors or warnings, and a production build. Firefox checks exercised combined search/sort state, preservation across range changes, pointer and keyboard chart selection, failed background refresh with retained data, successful retry, and 390px layout without page overflow. These checks caught and fixed a shallow-URL reactivity issue before the commit. Calculation parity against the previous implementation held within `1e-7` on a 118,806-row synthetic fixture. Seven-day query time fell from approximately 1,369ms to 367–408ms in the uncontended comparison. + +The post-commit engineering and UX reviews found no blocking issue. Two small follow-ups were accepted: neutral copy for samples with zero elapsed time, and a polite announcement when a user selects a chart period. Background refresh should not repeatedly announce the chart. + +The team revisited the original objections as design constraints: + +| Feature | Original concern | Agreed experiment | +| --- | --- | --- | +| Percentage chart | Normalization hides sample volume. | Default to share of captured server time, including unknown, with an Hours switch and exact durations in the selected-period readout. | +| Fleet pressure score | Map intervals overlap and cannot be added into elapsed fleet time. | Pool full map-hours divided by known-capacity map-hours; name the denominator explicitly and show how many map groups contribute. | +| Previous-period changes | Retained and observed history may differ substantially. | Show comparisons from available samples, including both denominators and a partial-history label. Utilization changes are percentage points. | +| Activity heatmap | Sparse observations can look like stable recurring demand. | Show duration-weighted occupancy by local weekday/hour, with known/unknown time, contributing dates, and no-data cells distinct from observed zero. | +| Hosting recommendations | Occupancy and saturation do not prove unmet demand. | Generate advisory, reversible hosting experiments from visible rules. Include evidence and tentative labels; never automatically create or remove servers. | +| Global filters | Inconsistent scopes would make the page misleading. | Apply exact game/map scope to every aggregate, comparison, heatmap, recommendation, and ranking; preserve collection boundaries before filtering. | +| Historical server links | A name may have been reused for a new instance. | Label the action “Open current server” and show it only when a current registration exists. Historical totals remain grouped by name. | + +The agreed hierarchy keeps the two headline cards and core timeline. Compact scope controls sit near the time-range control. Pressure, comparisons, advisory experiments, and the secondary heatmap follow the timeline so the added features do not crowd out the primary task on mobile. Use neutral comparison colors, visible units, and concise evidence next to each recommendation. + +The original plan remains as the record of the initial conservative scope. These decisions supersede its deferrals; limited data does not disable a feature when a defined calculation or explicitly tentative experiment is possible. An unavailable denominator still remains unavailable rather than becoming an invented number. + +All seven additions were implemented. The expanded full suite passed 731 tests; +subsequent refresh-response hardening passed its focused tests, including a new +malformed-response case. Svelte checking reported no errors or warnings and the +production build passed. Firefox exercised the percentage/Hours switch with +preserved table state, current-registration links, whole-report game/map filters, +empty scopes, heatmap keyboard selection and timezone changes, saved preference +adoption, and explicit timezone precedence. The final mobile report fits at +390px without page overflow. + +The combined 30-day report took 443–446ms on the seven-day, 118,806-row fixture. +A separate full-retention fixture with six servers and 535,680 rows took +1.96–3.30 seconds for the combined 30-day report. The queries still run +synchronously; that measured full-retention cost remains an improvement target. +These local synthetic timings are not production guarantees. diff --git a/docs/metrics.md b/docs/metrics.md index 89988d8..6daf54d 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -14,6 +14,14 @@ Background refresh keeps the last successful report visible on failure and offer Retry. Returning to a visible tab refreshes immediately. The selected range is stored in the URL, and refreshes preserve table controls and chart selection. +Game and map filters apply to the whole report, including its comparisons, +recommendations, and heatmap. Map choices preserve the exact recorded game/map +pair. Range and scope are shareable through the URL. Table searches remain local. + +The timeline defaults to **Share of captured time**: each stack includes occupied, +idle, and unknown server time, normalized to 100%. This denominator differs from the +headline utilization percentage, which excludes unknown time. Switch to **Hours** +to compare absolute server time. Periods without captured time remain gaps. The timeline has labelled axes and marks partial edge periods. Select a period, use Previous/Next, or select a bar to inspect its exact interval and values. The time table supplies the same figures for comparison and assistive technology. @@ -23,7 +31,46 @@ seconds or minutes so a real observation is not rounded to zero. Map and server tables support numeric sorting and local search. Their searches apply only to the named table. Known-capacity duration stays next to all-full time. Server identities retain the technical name; rows describe history grouped by -name, which may include different instances if a name was reused. +name, which may include different instances if a name was reused. **Open current +server** appears only when that name is currently registered; its destination +represents the current registration rather than every instance in the history. + +**Explore this period** adds experimental decision aids below the main timeline: + +- **Map capacity pressure** pools all-full map-hours divided by known-capacity + map-hours across the selected map groups. Overlapping map intervals are counted + separately. The score is not elapsed fleet time or a fleet health score; its + denominator and contributing map count remain visible. +- **Previous-period comparisons** use the preceding equally long window with + identical filters. Utilization changes are percentage points; player-hours + changes are relative percentages. A previous observed zero can show **New**. + Missing evidence remains unavailable. Partial retained history is labelled; + a 30-day comparison usually has little previous history because retention is + only 31 days. Both periods' known time is shown. Bounds cannot establish that + collection was uninterrupted, and changes are not adjusted for fleet size. +- **Activity heatmap** groups captured time by weekday and hour in its displayed + timezone. Color represents occupied / known server time. Exact cell values + include unknown time and contributing dates. No-data cells differ from observed + zero utilization; repeated DST hours contribute their actual elapsed time. +- **Hosting experiments** are generated automatically from the selected map + observations. They are advisory and never create, restart, or destroy a server. + +Hosting Rules v1 deliberately offer experiments even with small samples: + +| Recorded condition | Suggested experiment | +| --- | --- | +| At least 20% of known capacity time had every lobby full | Trial one additional lobby during busy hours. | +| Capacity unavailable and players present for at least 80% of known server time | Tentatively trial an extra lobby using occupancy as a proxy. | +| Players present for at most 10% of known time, with under 1% measured pressure or unavailable capacity | Review one idle lobby or a shorter hosting window while preserving access to the map. | +| Occupancy unavailable | Check telemetry/RCON reporting and watch for known player counts. | +| Other observations | Check missing capacity or keep the lineup and watch peaks. | + +Each suggestion includes evidence and an outcome to watch. Unknown capacity, +less than one hour of relevant known evidence, or substantial unknown captured +status makes the suggestion tentative. These thresholds are product hypotheses; +occupancy does not establish failed joins or a need for additional capacity. +Suggestions use historical observations. Check the current lineup before trying +a change; a retained map may no longer have a hosted lobby. - Utilized means a successful status response reported at least one player. - Idle means a successful response reported zero players. @@ -36,3 +83,10 @@ Observations are recorded by the existing status worker. Each remains valid unti Capacity comes from `sv_maxclients` / `sv_maxClients` in the status response. When unavailable, the UI displays unknown rather than assuming a four-player limit. Capacity coverage is the map time for which every tracked lobby reported both occupancy and capacity. Collection begins after this feature is installed; previous occupancy cannot be reconstructed. Samples are retained for 31 days and pruned during collection. They survive removal of a server and contain no player identities. The additive database table is created automatically on startup. + +`GET /api/metrics` supplies the same report as the page and disables response +caching. Query parameters are `days` (1, 7, or 30), `game`, `map` (requires game), +and `timeZone` (an IANA zone). Invalid ranges and zones fall back to the defaults; +invalid scope syntax is normalized. Fleet collection timestamps remain separate +from selected-scope evidence timestamps. Collection boundaries are determined +before scope filtering, so samples from other groups still end a batch interval. diff --git a/src/lib/metrics-insights.ts b/src/lib/metrics-insights.ts new file mode 100644 index 0000000..7d7adac --- /dev/null +++ b/src/lib/metrics-insights.ts @@ -0,0 +1,73 @@ +import type { MapMetrics } from './metrics.js'; +import { metricDuration, metricPercent } from './ui/metrics.js'; + +/** Product hypotheses for advisory experiments; these are not demand forecasts. */ +export const HOSTING_RULES = { + pressureExpansion: 0.2, + occupancyExpansion: 0.8, + idleUtilization: 0.1, + quietPressure: 0.01, + minimumEvidenceHours: 1 +} as const; + +export interface MetricRecommendation { + game: string; + map: string; + kind: 'expand' | 'consolidate' | 'capacity' | 'watch'; + title: string; + reason: string; + evidence: string; + outcome: string; + tentative: boolean; +} + +/** Apply the same visible rules to all selected maps, including sparse observations. */ +export function metricRecommendations(maps: MapMetrics[]): MetricRecommendation[] { + return maps.map(map => { + const utilization = map.observed > 0 ? map.active / map.observed : null; + const pressure = map.capacityObserved > 0 ? map.full / map.capacityObserved : null; + const knownShare = map.observed + map.unknown > 0 ? map.observed / (map.observed + map.unknown) : 0; + const base = { + game: map.game, + map: map.map, + evidence: `${metricDuration(map.active)} with players / ${metricDuration(map.observed)} known server time (${metricPercent(map.active, map.observed)}). ` + + (pressure === null ? 'Capacity unavailable.' : `All lobbies full ${metricDuration(map.full)} / ${metricDuration(map.capacityObserved)} known map time (${metricPercent(map.full, map.capacityObserved)}).`), + tentative: map.observed < HOSTING_RULES.minimumEvidenceHours || pressure === null || map.capacityObserved < HOSTING_RULES.minimumEvidenceHours || knownShare < 0.8 + }; + if (utilization === null) return { + ...base, kind: 'capacity' as const, title: map.unknown > 0 ? 'Check occupancy reporting' : 'Let observations accumulate', + reason: map.unknown > 0 + ? 'Captured status is unknown. Check a server’s telemetry or RCON diagnostics before choosing a hosting change.' + : 'This map has no elapsed observations in the selected window. Wait for another status check or try a wider range.', + outcome: 'Watch for known server time in the next report.', tentative: true + }; + if (pressure !== null && pressure >= HOSTING_RULES.pressureExpansion) return { + ...base, kind: 'expand' as const, title: 'Trial one additional lobby', + reason: 'Recorded lobbies were all full for at least 20% of known capacity time. Try one extra lobby during busy hours.', + outcome: 'Compare all-full time and use of the additional lobby over the next comparable period.' + }; + if (pressure === null && utilization >= HOSTING_RULES.occupancyExpansion) return { + ...base, kind: 'expand' as const, title: 'Trial an extra lobby during busy hours', + reason: 'Players were present for at least 80% of known server time. Capacity is unknown, so this experiment uses occupancy as a proxy, not evidence of saturation.', + outcome: 'Check whether players use the extra lobby, and measure capacity if the server reports it.', tentative: true + }; + if (utilization <= HOSTING_RULES.idleUtilization && (pressure === null || pressure < HOSTING_RULES.quietPressure)) return { + ...base, kind: 'consolidate' as const, title: 'Trial less idle hosting', + reason: 'Players were present for at most 10% of known time, with less than 1% measured pressure or unavailable capacity. Review one idle lobby or a shorter hosting window; preserve access to this map.', + outcome: 'Watch for increased all-full time or loss of player-hours, and restore the lineup if the trial hurts availability.' + }; + if (pressure === null) return { + ...base, kind: 'capacity' as const, title: 'Check capacity during busy hours', + reason: 'Occupancy is recorded, but player limits are unavailable. Check capacity reporting while keeping the current lineup.', + outcome: 'Look for known-capacity time and all-full periods before the next hosting trial.' + }; + return { + ...base, kind: 'watch' as const, title: 'Keep the lineup and watch peaks', + reason: 'These observations do not trigger the expansion or idle-hosting rules. Keep the current lineup for another comparison period.', + outcome: 'Watch whether occupied time and measured capacity pressure change.' + }; + }).sort((a, b) => { + const priority = { expand: 0, consolidate: 1, capacity: 2, watch: 3 }; + return priority[a.kind] - priority[b.kind] || a.map.localeCompare(b.map) || a.game.localeCompare(b.game); + }); +} diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index fc1732d..c325ad4 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -1,3 +1,5 @@ +import { DEFAULT_TIME_ZONE, validTimeZone } from './time-zone.js'; + export type MetricsDays = 1 | 7 | 30; export function parseMetricsDays(value: string | null | undefined): MetricsDays { @@ -5,6 +7,28 @@ export function parseMetricsDays(value: string | null | undefined): MetricsDays return days === 1 || days === 7 || days === 30 ? days : 7; } +export interface MetricsScope { + game: string | null; + map: string | null; + timeZone: string; +} + +export interface MetricsQuery extends MetricsScope { + days: MetricsDays; +} + +/** A map is an exact recorded label within a game, never a cross-game match. */ +export function parseMetricsQuery(params: URLSearchParams): MetricsQuery { + const requestedGame = params.get('game')?.trim() ?? ''; + const game = /^[a-z0-9_-]{1,32}$/.test(requestedGame) ? requestedGame : null; + const requestedMap = params.get('map'); + const map = game && requestedMap && requestedMap.trim() && requestedMap.length <= 200 && !/[\u0000-\u001f\u007f]/.test(requestedMap) + ? requestedMap : null; + const requestedZone = params.get('timeZone'); + return { days: parseMetricsDays(params.get('days')), game, map, + timeZone: validTimeZone(requestedZone) ? requestedZone : DEFAULT_TIME_ZONE }; +} + /** All durations are hours. Observed is known server time, not collection coverage. */ export interface Utilization { observed: number; @@ -16,6 +40,8 @@ export interface Utilization { export interface ServerMetrics extends Utilization { server: string; + /** A current registration with this name exists; historical identity may differ. */ + currentRegistered: boolean; } export interface MapMetrics extends Utilization { @@ -34,6 +60,33 @@ export interface MetricPoint extends Utilization { partial: boolean; } +export interface PreviousMetrics { + since: number; + until: number; + summary: Utilization; + firstSampleAt: number | null; + lastSampleAt: number | null; + /** Retained scope evidence starts late or is absent, not a completeness guarantee. */ + partialHistory: boolean; +} + +export interface HeatmapCell extends Utilization { + /** Monday = 0, Sunday = 6. */ + weekday: number; + hour: number; + /** Distinct local dates with any captured time, including unknown status. */ + distinctDates: number; +} + +export interface PooledPressure { + /** Share of known map-hours with every tracked lobby full, from 0 to 100. */ + value: number | null; + fullMapHours: number; + knownMapHours: number; + coveredMaps: number; + totalMaps: number; +} + export interface MetricsData { days: MetricsDays; since: number; @@ -42,8 +95,15 @@ export interface MetricsData { firstRetainedSampleAt: number | null; latestSampleAt: number | null; registeredServerCount: number; + scope: MetricsScope; + scopeOptions: { games: string[]; maps: { game: string; map: string }[] }; + scopeFirstSampleAt: number | null; + scopeLatestSampleAt: number | null; summary: Utilization; servers: ServerMetrics[]; maps: MapMetrics[]; points: MetricPoint[]; + previous: PreviousMetrics; + heatmap: { timeZone: string; cells: HeatmapCell[] }; + pooledPressure: PooledPressure; } diff --git a/src/lib/server/fleet/metrics.ts b/src/lib/server/fleet/metrics.ts index 9890345..a6312d4 100644 --- a/src/lib/server/fleet/metrics.ts +++ b/src/lib/server/fleet/metrics.ts @@ -1,6 +1,7 @@ import { db } from '../db/index.js'; import { ONLINE_MAX_MS } from '../../server-freshness.js'; -import type { MapMetrics, MetricPoint, MetricsData, MetricsDays, ServerMetrics, Utilization } from '../../metrics.js'; +import { parseMetricsQuery, type HeatmapCell, type MapMetrics, type MetricPoint, type MetricsData, + type MetricsDays, type MetricsQuery, type PreviousMetrics, type ServerMetrics, type Utilization } from '../../metrics.js'; export type { MapMetrics, Utilization } from '../../metrics.js'; @@ -38,16 +39,34 @@ function addObservation(total: Utilization, players: number | null, hours: numbe /** Each collection is a complete fleet snapshot. Hold it until the next collection, * capped at the freshness limit; longer gaps contribute no observed or unknown time. * Read the indexed history once and share the intervals across every aggregation. */ -export function readMetrics(days: MetricsDays, now = Date.now()): MetricsData { +export function readMetrics(input: MetricsDays | MetricsQuery, now = Date.now()): MetricsData { + const query = parseMetricsQuery(new URLSearchParams(typeof input === 'number' ? { days: String(input) } + : { days: String(input.days), game: input.game ?? '', map: input.map ?? '', timeZone: input.timeZone })); + const { days, ...scope } = query; const since = now - days * 86400_000; const bucketMs = days === 1 ? 3600_000 : 86400_000; const handle = db(); - const metadata = handle.prepare(`SELECT - (SELECT MIN(at) FROM metric_samples WHERE at <= @now) AS firstRetainedSampleAt, - (SELECT MAX(at) FROM metric_samples WHERE at <= @now) AS latestSampleAt, - (SELECT COUNT(*) FROM servers WHERE destroyed_at IS NULL) AS registeredServerCount`).get({ now }) as - Pick; + // These small grouped results keep options and retained evidence independent + // of the chosen scope/window without transferring a second raw history scan. + const groups = handle.prepare(`SELECT game, map, MIN(at) AS first, MAX(at) AS latest + FROM metric_samples WHERE at <= ? GROUP BY game, map ORDER BY game, map`).all(now) as + { game: string; map: string; first: number; latest: number }[]; + const matchesScope = (sample: { game: string; map: string }) => + (scope.game === null || sample.game === scope.game) && (scope.map === null || sample.map === scope.map); + const evidenceBounds = (values: typeof groups) => values.reduce<{ first: number | null; latest: number | null }>((bounds, group) => ({ + first: bounds.first === null ? group.first : Math.min(bounds.first, group.first), + latest: bounds.latest === null ? group.latest : Math.max(bounds.latest, group.latest) + }), { first: null, latest: null }); + const globalBounds = evidenceBounds(groups); + const scopedBounds = evidenceBounds(groups.filter(matchesScope)); + const registered = new Set((handle.prepare('SELECT name FROM servers WHERE destroyed_at IS NULL').all() as { name: string }[]) + .map(server => server.name)); + const metadata = { firstRetainedSampleAt: globalBounds.first, latestSampleAt: globalBounds.latest, + registeredServerCount: registered.size, scopeFirstSampleAt: scopedBounds.first, scopeLatestSampleAt: scopedBounds.latest }; + const scopeOptions = { games: [...new Set(groups.map(group => group.game))], maps: groups.map(({ game, map }) => ({ game, map })) }; const summary = emptyUtilization(); + const previous: PreviousMetrics = { since: since - days * 86400_000, until: since, summary: emptyUtilization(), + firstSampleAt: null, lastSampleAt: null, partialHistory: false }; const serverTotals = new Map(); const mapTotals = new Map>(); const points: MetricPoint[] = []; @@ -57,14 +76,61 @@ export function readMetrics(days: MetricsDays, now = Date.now()): MetricsData { points.push({ at, startAt: start, endAt: end, partial: start !== at || end !== at + bucketMs, ...emptyUtilization() }); } + const cells: HeatmapCell[] = Array.from({ length: 7 * 24 }, (_, index) => ({ + weekday: Math.floor(index / 24), hour: index % 24, distinctDates: 0, ...emptyUtilization() + })); + const cellDates = cells.map(() => new Set()); + const calendar = new Intl.DateTimeFormat('en-US', { timeZone: scope.timeZone, + year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short', hour: 'numeric', hourCycle: 'h23' }); + const weekdays: Record = { Mon: 0, Tue: 1, Wed: 2, Thu: 3, Fri: 4, Sat: 5, Sun: 6 }; + let cachedMinute = NaN; + let localCell = { index: 0, date: '' }; + interface BatchCounts { known: number; active: number; idle: number; unknown: number; players: number } + function addHeatmap(counts: BatchCounts, start: number, end: number): void { + // Samples are at most 45 seconds. Splitting at UTC minute boundaries + // handles local hour/day boundaries, DST, and fractional-hour zones. + // Cache each minute once across the complete fleet collection batches. + for (let cursor = start; cursor < end;) { + const minute = Math.floor(cursor / 60_000); + if (minute !== cachedMinute) { + const parts = Object.fromEntries(calendar.formatToParts(cursor).map(part => [part.type, part.value])); + localCell = { index: weekdays[parts.weekday] * 24 + Number(parts.hour), date: `${parts.year}-${parts.month}-${parts.day}` }; + cachedMinute = minute; + } + const stop = Math.min(end, (minute + 1) * 60_000); + const hours = (stop - cursor) / 3600_000; + const cell = cells[localCell.index]; + cell.observed += counts.known * hours; + cell.active += counts.active * hours; + cell.idle += counts.idle * hours; + cell.unknown += counts.unknown * hours; + cell.playerHours += counts.players * hours; + cellDates[localCell.index].add(localCell.date); + cursor = stop; + } + } + type RecordedSample = MetricSample & { at: number }; let batch: RecordedSample[] = []; function aggregateBatch(nextAt: number): void { if (!batch.length) return; - const start = Math.max(batch[0].at, since); - const end = Math.min(nextAt, batch[0].at + ONLINE_MAX_MS, now); + const at = batch[0].at; + const samples = scope.game === null ? batch : batch.filter(matchesScope); + if (!samples.length) return; + const end = Math.min(nextAt, at + ONLINE_MAX_MS, now); + const priorHours = Math.max(0, Math.min(end, previous.until) - Math.max(at, previous.since)) / 3600_000; + if (priorHours > 0) { + previous.firstSampleAt ??= at; + previous.lastSampleAt = at; + for (const sample of samples) addObservation(previous.summary, sample.players, priorHours); + } + // Preserve current-window zero-duration rows without bringing rows from + // the separate comparison window into the visible server/map tables. + if (at < since - ONLINE_MAX_MS) return; + const start = Math.max(at, since); const hours = Math.max(0, end - start) / 3600_000; const pressure = new Map(); + const counts: BatchCounts = { known: 0, active: 0, idle: 0, unknown: 0, players: 0 }; // Split once for the entire batch; observations crossing UTC hour/day // boundaries contribute to both buckets without shifting their duration. const slices: { point: MetricPoint; hours: number }[] = []; @@ -75,10 +141,10 @@ export function readMetrics(days: MetricsDays, now = Date.now()): MetricsData { slices.push({ point, hours: (stop - cursor) / 3600_000 }); cursor = stop; } - for (const sample of batch) { + for (const sample of samples) { let server = serverTotals.get(sample.server); if (!server) { - server = { server: sample.server, ...emptyUtilization() }; + server = { server: sample.server, currentRegistered: registered.has(sample.server), ...emptyUtilization() }; serverTotals.set(sample.server, server); } let game = mapTotals.get(sample.game); @@ -92,6 +158,13 @@ export function readMetrics(days: MetricsDays, now = Date.now()): MetricsData { addObservation(server, sample.players, hours); addObservation(map, sample.players, hours); for (const slice of slices) addObservation(slice.point, sample.players, slice.hours); + if (sample.players === null) counts.unknown++; + else { + counts.known++; + counts.players += sample.players; + if (sample.players > 0) counts.active++; + else if (sample.players === 0) counts.idle++; + } const known = sample.players !== null && sample.capacity !== null && sample.capacity > 0; const full = known && sample.players! >= sample.capacity!; const previous = pressure.get(map); @@ -103,11 +176,12 @@ export function readMetrics(days: MetricsDays, now = Date.now()): MetricsData { if (state.full) map.full += hours; } } + addHeatmap(counts, start, end); } type SampleRow = [at: number, server: string, game: string, map: string, players: number | null, capacity: number | null]; const samples = handle.prepare(`SELECT at, server, game, map, players, capacity FROM metric_samples - WHERE at >= ? AND at <= ? ORDER BY at`).raw().iterate(since - ONLINE_MAX_MS, now) as IterableIterator; + WHERE at >= ? AND at <= ? ORDER BY at`).raw().iterate(previous.since - ONLINE_MAX_MS, now) as IterableIterator; for (const [at, server, game, map, players, capacity] of samples) { if (batch.length && at !== batch[0].at) { aggregateBatch(at); @@ -121,5 +195,12 @@ export function readMetrics(days: MetricsDays, now = Date.now()): MetricsData { const servers = [...serverTotals.values()].sort((a, b) => b.active - a.active || compareText(a.server, b.server)); const maps = [...mapTotals.values()].flatMap(game => [...game.values()]) .sort((a, b) => b.playerHours - a.playerHours || compareText(a.map, b.map) || compareText(a.game, b.game)); - return { days, since, now, ...metadata, summary, servers, maps, points }; + previous.partialHistory = scopedBounds.first === null || scopedBounds.first > previous.since || previous.firstSampleAt === null; + for (let index = 0; index < cells.length; index++) cells[index].distinctDates = cellDates[index].size; + const fullMapHours = maps.reduce((total, map) => total + map.full, 0); + const knownMapHours = maps.reduce((total, map) => total + map.capacityObserved, 0); + const pooledPressure = { value: knownMapHours > 0 ? 100 * fullMapHours / knownMapHours : null, + fullMapHours, knownMapHours, coveredMaps: maps.filter(map => map.capacityObserved > 0).length, totalMaps: maps.length }; + return { days, since, now, ...metadata, scope, scopeOptions, summary, servers, maps, points, previous, + heatmap: { timeZone: scope.timeZone, cells }, pooledPressure }; } diff --git a/src/lib/ui/components/metrics/MetricsChart.svelte b/src/lib/ui/components/metrics/MetricsChart.svelte index 06a664c..1f584cf 100644 --- a/src/lib/ui/components/metrics/MetricsChart.svelte +++ b/src/lib/ui/components/metrics/MetricsChart.svelte @@ -1,13 +1,19 @@
-

Server time over time

Server-hours per {days === 1 ? 'hour' : '24-hour period'}. Partial periods contain less elapsed time.

+

Server time over time

{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.

+
+
With playersIdleUnknown* Partial period
-
Server-hours
+
{mode === 'percent' ? 'Share of captured server time' : 'Server-hours'}
@@ -52,40 +77,42 @@ {#each points as point, index (point.at)} {/each}
- +
{#if selected}
-
selectPeriod(Number((event.currentTarget as HTMLSelectElement).value))} />
-

{$timeFormat.period(selected.startAt)} – {$timeFormat.period(selected.endAt)} {#if selected.partial}Partial period{/if}

+

{format.period(selected.startAt)} – {format.period(selected.endAt)} {#if selected.partial}Partial period{/if}

{#if captured === 0}

No captured time in this period. Missing observations do not mean the fleet was idle.

{:else}
-
With players
{metricDuration(selected.active)}{metricPercent(selected.active, selected.observed)} of known time
-
Idle
{metricDuration(selected.idle)}
-
Unknown
{metricDuration(selected.unknown)}
+
With players
{metricDuration(selected.active)}{metricPercent(selected.active, captured)} captured · {metricPercent(selected.active, selected.observed)} known
+
Idle
{metricDuration(selected.idle)}{metricPercent(selected.idle, captured)} of captured time
+
Unknown
{metricDuration(selected.unknown)}{metricPercent(selected.unknown, captured)} of captured time
Player-hours
{selected.observed ? metricNumber(selected.playerHours) : '—'}
-
{/if} +

{metricDuration(captured)} captured server time · {metricDuration(selected.observed)} known. Percentage bars include unknown time; the occupancy KPI excludes it.

{/if}
{/if} + {announcement}
View exact time data
- + - {#each points as point (point.at)}{/each} + {#each points as point (point.at)}{/each}
Periods in {$timeZone.replaceAll('_', ' ')}. Durations are estimated from captured observations.Periods in {timeZone.replaceAll('_', ' ')}. Durations are estimated from captured observations.
PeriodWith playersIdleUnknownPlayer-hours
{$timeFormat.period(point.startAt)}to {$timeFormat.period(point.endAt)}{point.partial ? ' · Partial' : ''}{metricDuration(point.active)}{metricDuration(point.idle)}{metricDuration(point.unknown)}{point.observed ? metricNumber(point.playerHours) : '—'}
{format.period(point.startAt)}to {format.period(point.endAt)}{point.partial ? ' · Partial' : ''}{metricDuration(point.active)}{metricDuration(point.idle)}{metricDuration(point.unknown)}{point.observed ? metricNumber(point.playerHours) : '—'}
@@ -93,6 +120,11 @@ diff --git a/src/lib/ui/components/metrics/MetricsHeatmap.svelte b/src/lib/ui/components/metrics/MetricsHeatmap.svelte new file mode 100644 index 0000000..b932bae --- /dev/null +++ b/src/lib/ui/components/metrics/MetricsHeatmap.svelte @@ -0,0 +1,104 @@ + + +
+

Activity by weekday and hour

Time with players as a share of known server time, grouped in {heatmap.timeZone.replaceAll('_', ' ')}.

+
select(Number((event.currentTarget as HTMLSelectElement).value), selected.hour)} /> changeScope({ game: (event.currentTarget as HTMLSelectElement).value, map: '' })} /> +