Skip to content

Commit 1e8f0e4

Browse files
Nigel TatschnerNigel Tatschner
authored andcommitted
feat(sync): priority lanes for urgent event types + updates polish
Adds a fast-lane / bulk-lane split to the local→remote event sync so location changes, deaths, vehicle destruction, quantum target, and session-end events ship every ~5s while the rest drains on the usual 60s cadence. User-tunable via a four-card preset picker (Fast / Balanced / Resource-saver / Custom) plus raw interval inputs. Storage: `events.sent_at` replaces the global `sync_cursor` model so either lane can pick its next batch via `WHERE sent_at IS NULL AND type [NOT] IN (...)`. Partial index keeps the scan O(unsent). Legacy DBs are migrated by back-stamping rows below the old cursor. Sync: `start()` now returns `SyncHandles { bulk, priority }` and spawns up to two workers sharing the same `kick` Notify. Bulk owns the idle heartbeat — the priority lane wakes too often to justify hammering `/v1/auth/me`. Config + Tauri command: `priority_interval_secs` (default 5) and `priority_event_types` (default urgent set) added with `#[serde(default)]` so existing config.toml survives. `set_sync_preset` command applies the named pair and respawns the workers. UI: SettingsPane gets a "Sync speed" radio-card section above the existing intervals, plus a dedicated "Priority interval" input next to the existing bulk interval and batch size. Updates surface polish: - Release-channel dropdown now matches the INPUT_BASE contract used by TextInput (`var(--bg)`, `var(--r-sm)`, `var(--font-mono)`, `7px 9px`) so it reads as part of the same family. (Native popup chrome is still browser-controlled.) - `ReleaseChannel::default()` returns Live unconditionally instead of deriving from `CARGO_PKG_VERSION`. Pre-release binaries no longer implicitly opt users into pre-release update channels; the Settings dropdown remains the explicit opt-in path.
1 parent bdc215d commit 1e8f0e4

9 files changed

Lines changed: 1067 additions & 94 deletions

File tree

apps/tray-ui/src/api.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,82 @@ export interface RemoteSyncConfig {
8383
api_url: string | null;
8484
claimed_handle: string | null;
8585
access_token: string | null;
86+
/** How often the BULK lane drains (seconds). Defaults to 60. */
8687
interval_secs: number;
8788
batch_size: number;
89+
/** How often the PRIORITY lane drains (seconds). Defaults to 5. */
90+
priority_interval_secs: number;
91+
/**
92+
* Event-type strings that ride the fast lane. Empty list disables
93+
* the priority lane entirely. Defaults to the canonical urgent set
94+
* (location, deaths, vehicle destruction, quantum target, session
95+
* end) — see Rust-side `DEFAULT_URGENT_TYPES`.
96+
*/
97+
priority_event_types: string[];
98+
}
99+
100+
/**
101+
* Named sync-speed presets. Pairs of (priority_interval, bulk_interval)
102+
* are kept ~10x apart so the fast/bulk ratio stays sensible across
103+
* presets. `"custom"` is a UI-only marker: the Rust side returns the
104+
* current config unchanged when this is selected — actual numbers
105+
* change via `saveConfig` once the user edits a field.
106+
*/
107+
export type SyncPreset = 'fast' | 'balanced' | 'resource_saver' | 'custom';
108+
109+
export const SYNC_PRESETS: {
110+
id: SyncPreset;
111+
label: string;
112+
description: string;
113+
priorityInterval: number;
114+
bulkInterval: number;
115+
}[] = [
116+
{
117+
id: 'fast',
118+
label: 'Fast',
119+
description: 'Priority every 3s, bulk every 30s — snappier "where am I" updates.',
120+
priorityInterval: 3,
121+
bulkInterval: 30,
122+
},
123+
{
124+
id: 'balanced',
125+
label: 'Balanced',
126+
description: 'Default. Priority every 5s, bulk every 60s.',
127+
priorityInterval: 5,
128+
bulkInterval: 60,
129+
},
130+
{
131+
id: 'resource_saver',
132+
label: 'Resource saver',
133+
description: 'Priority every 30s, bulk every 5min — lowest CPU/network.',
134+
priorityInterval: 30,
135+
bulkInterval: 300,
136+
},
137+
{
138+
id: 'custom',
139+
label: 'Custom',
140+
description: 'Pick your own intervals below.',
141+
priorityInterval: 0,
142+
bulkInterval: 0,
143+
},
144+
];
145+
146+
/**
147+
* Match a current `RemoteSyncConfig` to its named preset, if any.
148+
* Returns `"custom"` when the intervals don't line up with one of
149+
* the canned pairs — the UI uses this to highlight the active radio.
150+
*/
151+
export function detectSyncPreset(cfg: RemoteSyncConfig): SyncPreset {
152+
for (const p of SYNC_PRESETS) {
153+
if (p.id === 'custom') continue;
154+
if (
155+
cfg.priority_interval_secs === p.priorityInterval &&
156+
cfg.interval_secs === p.bulkInterval
157+
) {
158+
return p.id;
159+
}
160+
}
161+
return 'custom';
88162
}
89163

90164
/**
@@ -468,6 +542,14 @@ export const api = {
468542
getStatus: () => invoke<StatusResponse>('get_status'),
469543
getConfig: () => invoke<Config>('get_config'),
470544
saveConfig: (cfg: Config) => invoke<void>('save_config', { cfg }),
545+
/**
546+
* Apply a named sync-speed preset. Returns the resulting
547+
* `RemoteSyncConfig` so callers can refresh local state without a
548+
* follow-up `getConfig` round-trip. `"custom"` is a no-op on the
549+
* Rust side — handle it UI-side by revealing the raw number inputs.
550+
*/
551+
setSyncPreset: (preset: SyncPreset) =>
552+
invoke<RemoteSyncConfig>('set_sync_preset', { preset }),
471553
getDiscoveredLogs: () => invoke<DiscoveredLog[]>('get_discovered_logs'),
472554
pairDevice: (apiUrl: string, code: string) =>
473555
invoke<PairOutcome>('pair_device', { apiUrl, code }),

apps/tray-ui/src/components/SettingsPane.tsx

Lines changed: 172 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,16 @@ import type {
33
Config,
44
ReleaseChannel,
55
RsiCookieStatus,
6+
SyncPreset,
67
Theme,
78
} from '../api';
8-
import { api, RELEASE_CHANNEL_LABELS, THEMES } from '../api';
9+
import {
10+
api,
11+
detectSyncPreset,
12+
RELEASE_CHANNEL_LABELS,
13+
SYNC_PRESETS,
14+
THEMES,
15+
} from '../api';
916
import {
1017
applyUpdate,
1118
checkForUpdate,
@@ -464,13 +471,26 @@ export function SettingsPane({ config, onSave }: Props) {
464471
updateState.kind === 'installing'
465472
}
466473
style={{
467-
background: 'var(--bg-2)',
474+
// Match the INPUT_BASE pattern from primitives.tsx so
475+
// this dropdown sits visually next to the TextInputs
476+
// elsewhere on the same card — same surface, radius,
477+
// font, and padding contract. The native chrome of
478+
// the OPTIONS popup is still browser-controlled
479+
// (can't be themed reliably), but the closed-state
480+
// control now reads as part of the design system.
481+
background: 'var(--bg)',
468482
color: 'var(--fg)',
469483
border: '1px solid var(--border)',
470-
borderRadius: 4,
471-
padding: '4px 6px',
484+
borderRadius: 'var(--r-sm)',
485+
padding: '7px 9px',
486+
fontFamily: 'var(--font-mono)',
472487
fontSize: 12,
473-
fontFamily: 'inherit',
488+
outline: 'none',
489+
cursor:
490+
updateState.kind === 'checking' ||
491+
updateState.kind === 'installing'
492+
? 'not-allowed'
493+
: 'pointer',
474494
}}
475495
>
476496
{(Object.keys(RELEASE_CHANNEL_LABELS) as ReleaseChannel[]).map(
@@ -753,14 +773,43 @@ export function SettingsPane({ config, onSave }: Props) {
753773
)}
754774
</Field>
755775

776+
<SyncSpeedSection
777+
draft={draft}
778+
updateRemote={updateRemote}
779+
/>
780+
756781
<div
757782
style={{
758783
display: 'grid',
759-
gridTemplateColumns: '1fr 1fr',
784+
gridTemplateColumns: '1fr 1fr 1fr',
760785
gap: 10,
761786
}}
762787
>
763-
<Field label="Sync interval">
788+
<Field label="Priority interval">
789+
<div
790+
style={{ display: 'flex', alignItems: 'center', gap: 6 }}
791+
>
792+
<TextInput
793+
type="number"
794+
min={1}
795+
max={60}
796+
value={draft.remote_sync.priority_interval_secs}
797+
onChange={(e) =>
798+
updateRemote({
799+
priority_interval_secs: Math.max(
800+
1,
801+
Number(e.target.value) || 5,
802+
),
803+
})
804+
}
805+
style={{ flex: 1 }}
806+
/>
807+
<span style={{ fontSize: 11, color: 'var(--fg-dim)' }}>
808+
sec
809+
</span>
810+
</div>
811+
</Field>
812+
<Field label="Bulk interval">
764813
<div
765814
style={{ display: 'flex', alignItems: 'center', gap: 6 }}
766815
>
@@ -1041,3 +1090,119 @@ function UpdateStatusLine({
10411090
);
10421091
}
10431092
}
1093+
1094+
interface SyncSpeedSectionProps {
1095+
draft: Config;
1096+
updateRemote: (patch: Partial<Config['remote_sync']>) => void;
1097+
}
1098+
1099+
/**
1100+
* Radio-card preset picker for sync cadence. The Rust side has a
1101+
* `set_sync_preset` command but we drive everything through the
1102+
* existing `updateRemote` flow so the draft stays the single source
1103+
* of truth — Save commits intervals + the rest in one round-trip.
1104+
*
1105+
* The active preset is derived from the current interval pair via
1106+
* `detectSyncPreset`; a config that doesn't match any named pair
1107+
* resolves to 'custom' which reveals the raw number inputs (which
1108+
* are always rendered below anyway — selecting Custom is more of a
1109+
* visual cue than a mode switch).
1110+
*/
1111+
function SyncSpeedSection({ draft, updateRemote }: SyncSpeedSectionProps) {
1112+
const active: SyncPreset = detectSyncPreset(draft.remote_sync);
1113+
1114+
const onPick = (preset: SyncPreset) => {
1115+
if (preset === 'custom') {
1116+
// Custom is a UI marker — keep current intervals as the user
1117+
// commits to whatever numbers they type below.
1118+
return;
1119+
}
1120+
const choice = SYNC_PRESETS.find((p) => p.id === preset);
1121+
if (!choice) return;
1122+
updateRemote({
1123+
priority_interval_secs: choice.priorityInterval,
1124+
interval_secs: choice.bulkInterval,
1125+
});
1126+
};
1127+
1128+
return (
1129+
<fieldset
1130+
style={{
1131+
border: '1px solid var(--border-dim)',
1132+
borderRadius: 6,
1133+
padding: '8px 10px',
1134+
margin: '0 0 10px',
1135+
}}
1136+
>
1137+
<legend
1138+
style={{
1139+
padding: '0 6px',
1140+
fontSize: 11,
1141+
fontWeight: 600,
1142+
letterSpacing: 0.4,
1143+
textTransform: 'uppercase',
1144+
color: 'var(--fg-dim)',
1145+
}}
1146+
>
1147+
Sync speed
1148+
</legend>
1149+
<div
1150+
role="radiogroup"
1151+
aria-label="Sync speed preset"
1152+
style={{
1153+
display: 'grid',
1154+
gridTemplateColumns: 'repeat(4, 1fr)',
1155+
gap: 6,
1156+
}}
1157+
>
1158+
{SYNC_PRESETS.map((p) => {
1159+
const selected = p.id === active;
1160+
return (
1161+
<button
1162+
key={p.id}
1163+
type="button"
1164+
role="radio"
1165+
aria-checked={selected}
1166+
onClick={() => onPick(p.id)}
1167+
style={{
1168+
background: selected ? 'var(--accent-dim)' : 'transparent',
1169+
border: `1px solid ${
1170+
selected ? 'var(--accent)' : 'var(--border-dim)'
1171+
}`,
1172+
borderRadius: 6,
1173+
padding: '8px 6px',
1174+
cursor: 'pointer',
1175+
textAlign: 'left',
1176+
color: 'var(--fg)',
1177+
display: 'flex',
1178+
flexDirection: 'column',
1179+
gap: 2,
1180+
}}
1181+
>
1182+
<span style={{ fontSize: 12, fontWeight: 600 }}>{p.label}</span>
1183+
<span style={{ fontSize: 10, color: 'var(--fg-dim)' }}>
1184+
{p.id === 'custom'
1185+
? 'Pick your own intervals'
1186+
: `${p.priorityInterval}s / ${p.bulkInterval}s`}
1187+
</span>
1188+
</button>
1189+
);
1190+
})}
1191+
</div>
1192+
<p
1193+
style={{
1194+
margin: '8px 0 0',
1195+
fontSize: 11,
1196+
color: 'var(--fg-dim)',
1197+
lineHeight: 1.4,
1198+
}}
1199+
>
1200+
Priority lane drains{' '}
1201+
<strong style={{ color: 'var(--fg)' }}>
1202+
location, deaths, vehicle destruction, quantum target, session end
1203+
</strong>{' '}
1204+
on the fast schedule. Everything else waits for the bulk schedule.
1205+
</p>
1206+
</fieldset>
1207+
);
1208+
}

crates/starstats-client/sql/schema.sql

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,28 @@ CREATE TABLE IF NOT EXISTS events (
1616
-- only ever read/written wholesale (no per-field SQL queries). NULL
1717
-- means no enrichment has happened for this row yet; that's the
1818
-- vast-majority case so the storage cost is per-enriched-row only.
19-
metadata TEXT
19+
metadata TEXT,
20+
-- Timestamp at which this row was successfully POSTed to the
21+
-- StarStats API (and the server returned a 2xx). NULL = still in
22+
-- the local outbox. Replaces the global `sync_cursor` model: with
23+
-- priority lanes the urgent worker drains specific event_types out
24+
-- of monotonic id order, so a single high-water-mark cursor can't
25+
-- represent the drain state correctly. Per-row flag lets either
26+
-- lane pick its next batch with a simple `WHERE sent_at IS NULL
27+
-- [AND type IN (...)]` filter. Existing rows are backfilled at
28+
-- migration time from the legacy cursor — see
29+
-- `Storage::migrate_events_sent_at`.
30+
sent_at TEXT
2031
);
2132
CREATE INDEX IF NOT EXISTS idx_events_type_ts ON events(type, timestamp);
2233
CREATE INDEX IF NOT EXISTS idx_events_inserted ON events(inserted_at);
34+
-- The partial index on `events(id) WHERE sent_at IS NULL` is created
35+
-- by `Storage::migrate_events_sent_at` instead of here. Reason: this
36+
-- file runs BEFORE the column-add migrations, and SQLite rejects an
37+
-- index whose WHERE clause references a column that doesn't exist
38+
-- yet. Keeping the index colocated with the migration also means a
39+
-- legacy DB that's missing both column and index reaches a
40+
-- consistent state in one place.
2341

2442
CREATE TABLE IF NOT EXISTS tail_cursor (
2543
path TEXT PRIMARY KEY,

0 commit comments

Comments
 (0)