Skip to content

Commit 9447fb6

Browse files
authored
🐛 fix(web): publish status and usage as one pair (#2002)
The dashboard and the admin status page each loaded the status snapshot and the usage counters into their own resource, refreshed on its own five-second interval. Whichever half settled first was written straight to the page, so a card could show listings counted seconds apart from the accepted-request total printed above it. Neither combination was ever a state the server held. Both pages now drive one resource that reads the snapshot and the counters together, so every published pair comes from a single refresh. A refresh tick that lands while the previous load is still outstanding is skipped rather than stacking a second request on a slow endpoint, where the responses could arrive out of order. A tick can also arrive after its route has been left, since the interval outlives the page; reading the disposed resource would panic, and there is nothing left to refresh. The usage half of the pair carries its own outcome instead of failing the whole load. `/+stats` is operator-scoped and answers `401` to every other reader, so pairing that failure with the snapshot would leave anyone without the scope looking at a blank dashboard, and a first load whose counters fail has no earlier pair to fall back on. A pair whose usage half failed reports that failure where the counters would go, never counters measured in another refresh.
1 parent 0bddb8a commit 9447fb6

8 files changed

Lines changed: 255 additions & 37 deletions

File tree

crates/peryx-web/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ web-sys = { workspace = true, optional = true, features = [
6666
"XmlHttpRequestUpload",
6767
] }
6868
base64 = { workspace = true, optional = true }
69+
futures-util.workspace = true
6970
peryx-core.workspace = true
7071
serde.workspace = true
7172
serde_json.workspace = true
@@ -78,7 +79,6 @@ minicov = { workspace = true, optional = true }
7879
[dev-dependencies]
7980
any_spawner = "0.3"
8081
async-trait.workspace = true
81-
futures-util.workspace = true
8282
peryx-policy.workspace = true
8383
peryx-events.workspace = true
8484
peryx-identity.workspace = true

crates/peryx-web/src/data/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ pub use search::load_search;
2727
#[cfg(all(not(feature = "ssr"), feature = "hydrate"))]
2828
pub use stats::load_analytics;
2929
pub use stats::load_stats;
30-
pub use status::{load_admin_snapshot, load_snapshot};
30+
pub use status::{UiOverview, load_admin_overview, load_overview};
3131
pub use topology::load_topology;
3232
#[cfg(all(not(feature = "ssr"), feature = "hydrate"))]
3333
pub use topology::{TopologyStream, subscribe_topology};

crates/peryx-web/src/data/status.rs

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::model::UiSnapshot;
1+
use crate::model::{UiSnapshot, UiStats};
22

33
#[cfg(all(not(feature = "ssr"), feature = "hydrate"))]
44
use super::RequiredOption;
@@ -168,12 +168,47 @@ fn required<T>(value: Option<T>) -> Result<T, super::LoaderError> {
168168
value.ok_or(super::LoaderError::Invalid(super::LoaderEndpoint::Status))
169169
}
170170

171+
/// A status snapshot and the usage counters read in the same refresh.
172+
///
173+
/// The usage half carries its own outcome because `/+stats` is operator-scoped: a reader without
174+
/// that scope is answered `401` on every poll, and failing the pair on it would leave the whole
175+
/// dashboard blank rather than show the indexes it is allowed to see.
176+
pub type UiOverview = (UiSnapshot, Result<UiStats, super::LoaderError>);
177+
178+
/// The dashboard snapshot and the usage counters measured beside it.
179+
///
180+
/// Status and usage are separate endpoints that both keep counting while a page is open, so they
181+
/// are read as one pair: an index card must never report listings counted seconds apart from the
182+
/// accepted-request total printed above it, a combination the server never held. A pair whose
183+
/// usage half failed reports that failure instead of the counters, never counters from an earlier
184+
/// refresh.
185+
///
186+
/// # Errors
187+
///
188+
/// Returns a typed error when the status endpoint cannot provide a valid document. Nothing is
189+
/// published then, so the caller keeps the last pair it did publish.
190+
pub async fn load_overview() -> Result<UiOverview, super::LoaderError> {
191+
let (snapshot, usage) = futures_util::future::join(load_snapshot(), super::load_stats(None, None)).await;
192+
Ok((snapshot?, usage))
193+
}
194+
195+
/// The admin snapshot and the usage counters measured beside it, paired as [`load_overview`] pairs
196+
/// them.
197+
///
198+
/// # Errors
199+
///
200+
/// Returns a typed error when the status endpoint cannot provide a valid document.
201+
pub async fn load_admin_overview() -> Result<UiOverview, super::LoaderError> {
202+
let (snapshot, usage) = futures_util::future::join(load_admin_snapshot(), super::load_stats(None, None)).await;
203+
Ok((snapshot?, usage))
204+
}
205+
171206
/// The dashboard snapshot.
172207
///
173208
/// # Errors
174209
///
175210
/// Returns a typed error when the status endpoint cannot provide a valid document.
176-
pub async fn load_snapshot() -> Result<UiSnapshot, super::LoaderError> {
211+
async fn load_snapshot() -> Result<UiSnapshot, super::LoaderError> {
177212
#[cfg(feature = "ssr")]
178213
{
179214
Ok(crate::ssr::snapshot().await)
@@ -198,7 +233,7 @@ pub async fn load_snapshot() -> Result<UiSnapshot, super::LoaderError> {
198233
/// # Errors
199234
///
200235
/// Returns a typed error when the status endpoint cannot provide a valid document.
201-
pub async fn load_admin_snapshot() -> Result<UiSnapshot, super::LoaderError> {
236+
async fn load_admin_snapshot() -> Result<UiSnapshot, super::LoaderError> {
202237
#[cfg(feature = "ssr")]
203238
{
204239
Ok(crate::ssr::admin_snapshot().await)

crates/peryx-web/src/pages/admin.rs

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,33 @@
11
use leptos::prelude::*;
22

3-
use super::{ErrorMessage, LoadState, ecosystem_stats, human_size, optional_counters_for, retain, start_refresh};
4-
use crate::data::{load_admin_snapshot, load_stats};
3+
use super::{
4+
ErrorMessage, LoadState, ecosystem_stats, human_size, optional_counters_for, retain, start_refresh, usage_or_error,
5+
};
6+
use crate::data::load_admin_overview;
57
use crate::model::{UiCounters, UiIndex, UiRecentWrite, UiSnapshot, UiStats, UiSummaryStatus};
68
use crate::url::{browse_index_url, stats_index_url};
79

810
#[component]
911
pub fn AdminStatus() -> impl IntoView {
10-
let snapshot = Resource::new(|| (), |()| load_admin_snapshot());
11-
let stats = Resource::new(|| (), |()| load_stats(None, None));
12-
let loaded_snapshot = RwSignal::new(LoadState::default());
13-
let loaded_stats = RwSignal::new(LoadState::default());
14-
start_refresh(snapshot);
15-
start_refresh(stats);
12+
let overview = Resource::new(|| (), |()| load_admin_overview());
13+
let loaded = RwSignal::new(LoadState::default());
14+
start_refresh(overview);
1615
view! {
1716
<section class="page ops-page">
1817
<Suspense fallback=|| view! { <p class="dim">"loading"</p> }>
1918
{move || Suspend::new(async move {
20-
let snapshot = retain(loaded_snapshot, snapshot.await);
21-
let stats = retain(loaded_stats, stats.await);
19+
let loaded = retain(loaded, overview.await);
2220
view! {
23-
{snapshot.error.map(|message| view! { <ErrorMessage message /> })}
24-
{stats.error.map(|message| view! { <ErrorMessage message /> })}
25-
{snapshot.value.map(|data| view! { <AdminStatusBody data usage=stats.value /> })}
21+
{loaded.error.map(|message| view! { <ErrorMessage message /> })}
22+
{loaded
23+
.value
24+
.map(|(data, usage)| {
25+
let (usage, error) = usage_or_error(usage);
26+
view! {
27+
{error.map(|message| view! { <ErrorMessage message /> })}
28+
<AdminStatusBody data usage />
29+
}
30+
})}
2631
}
2732
})}
2833
</Suspense>

crates/peryx-web/src/pages/dashboard.rs

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,35 @@
11
use leptos::prelude::*;
22

3-
use super::{ErrorMessage, LoadState, ecosystem_stats, human_size, optional_counters_for, retain, start_refresh};
4-
use crate::data::{load_snapshot, load_stats};
3+
use super::{
4+
ErrorMessage, LoadState, ecosystem_stats, human_size, optional_counters_for, retain, start_refresh, usage_or_error,
5+
};
6+
use crate::data::{UiOverview, load_overview};
57
use crate::model::{UiCounters, UiIndex, UiSnapshot, UiStats};
68
use crate::url::{browse_index_url, stats_index_url};
79

810
/// The landing dashboard: identity, live counters, and the configured indexes with their usage.
911
#[component]
1012
pub fn Dashboard() -> impl IntoView {
11-
let snapshot = Resource::new(|| (), |()| load_snapshot());
12-
let stats = Resource::new(|| (), |()| load_stats(None, None));
13-
let loaded_snapshot = RwSignal::new(LoadState::default());
14-
let loaded_stats = RwSignal::new(LoadState::default());
15-
start_refresh(snapshot);
16-
start_refresh(stats);
13+
let overview = Resource::new(|| (), |()| load_overview());
14+
let loaded = RwSignal::new(LoadState::default());
15+
start_refresh(overview);
1716
view! {
1817
<section class="page">
19-
<StoopHero snapshot=loaded_snapshot />
18+
<StoopHero overview=loaded />
2019
<Suspense fallback=|| view! { <StoopLoader /> }>
2120
{move || Suspend::new(async move {
22-
let snapshot = retain(loaded_snapshot, snapshot.await);
23-
let stats = retain(loaded_stats, stats.await);
21+
let loaded = retain(loaded, overview.await);
2422
view! {
25-
{snapshot.error.map(|message| view! { <ErrorMessage message /> })}
26-
{stats.error.map(|message| view! { <ErrorMessage message /> })}
27-
{snapshot.value.map(|data| view! { <DashboardBody data usage=stats.value /> })}
23+
{loaded.error.map(|message| view! { <ErrorMessage message /> })}
24+
{loaded
25+
.value
26+
.map(|(data, usage)| {
27+
let (usage, error) = usage_or_error(usage);
28+
view! {
29+
{error.map(|message| view! { <ErrorMessage message /> })}
30+
<DashboardBody data usage />
31+
}
32+
})}
2833
}
2934
})}
3035
</Suspense>
@@ -39,7 +44,7 @@ pub fn Dashboard() -> impl IntoView {
3944
/// refetch every few seconds would otherwise rebuild this `<svg>`, and a fresh node restarts the
4045
/// once-on-load dive, so the falcon would re-dive on every poll.
4146
#[component]
42-
fn StoopHero(snapshot: RwSignal<LoadState<UiSnapshot>>) -> impl IntoView {
47+
fn StoopHero(overview: RwSignal<LoadState<UiOverview>>) -> impl IntoView {
4348
view! {
4449
<div class="hero-brand">
4550
<span class="stoop-stage">
@@ -50,7 +55,7 @@ fn StoopHero(snapshot: RwSignal<LoadState<UiSnapshot>>) -> impl IntoView {
5055
<span class="wordmark">"peryx"</span>
5156
<span class="tagline">
5257
"the artifact vault · v"
53-
{move || snapshot.get().value.map(|snapshot| snapshot.version)}
58+
{move || overview.get().value.map(|(snapshot, _)| snapshot.version)}
5459
</span>
5560
</span>
5661
</div>

crates/peryx-web/src/pages/mod.rs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,32 @@ pub use topology::AvailabilityTopology;
4141
pub use trash::Trash;
4242

4343
/// Refresh browser data every five seconds after hydration.
44+
///
45+
/// A tick that lands while the previous load is still outstanding is skipped: refetching then
46+
/// would stack a second request on a slow endpoint and let a later generation overtake an earlier
47+
/// one.
48+
///
49+
/// The interval outlives the page that started it, so a tick can arrive after the route has been
50+
/// left and the resource disposed. Reading a disposed resource panics, and there is nothing left
51+
/// to refresh, so a disposed resource skips the tick as well.
4452
#[cfg(all(not(feature = "ssr"), feature = "hydrate"))]
4553
fn start_refresh<T>(resource: Resource<Result<T, LoaderError>>)
4654
where
4755
T: serde::Serialize + serde::de::DeserializeOwned + Send + Sync + 'static,
4856
{
4957
use std::time::Duration;
58+
59+
use futures_util::FutureExt as _;
60+
5061
Effect::new(move |_| {
51-
set_interval(move || resource.refetch(), Duration::from_secs(5));
62+
set_interval(
63+
move || {
64+
if !resource.is_disposed() && resource.ready().now_or_never().is_some() {
65+
resource.refetch();
66+
}
67+
},
68+
Duration::from_secs(5),
69+
);
5270
});
5371
}
5472

@@ -88,6 +106,16 @@ fn retain<T: Clone + Send + Sync + 'static>(
88106
state.get_untracked()
89107
}
90108

109+
/// The counters to render from the usage half of a published pair, and the message to report when
110+
/// that half did not answer. The counters are dropped rather than carried over, so a page never
111+
/// prints usage measured in a refresh other than the snapshot beside it.
112+
fn usage_or_error(usage: Result<UiStats, LoaderError>) -> (Option<UiStats>, Option<String>) {
113+
match usage {
114+
Ok(usage) => (Some(usage), None),
115+
Err(error) => (None, Some(error.to_string())),
116+
}
117+
}
118+
91119
/// The per-ecosystem metric groups: one labelled block per ecosystem, so the reader can tell a
92120
/// ecosystem-scoped counter from the global request count.
93121
fn ecosystem_stats(data: &UiSnapshot) -> impl IntoView + use<> {

crates/peryx-web/tests/frontend/tests/ui.spec.mjs

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1824,7 +1824,7 @@ test("dashboard preserves and recovers its last status snapshot", async ({
18241824
});
18251825
});
18261826
await page.route("**/+stats**", (route) =>
1827-
route.fulfill({ json: statsRoutes(3) }),
1827+
route.fulfill({ json: statsRoutes(phase === "initial" ? 3 : 9) }),
18281828
);
18291829
await goto(page, "/login");
18301830
await openClientPath(page, "/");
@@ -1842,6 +1842,127 @@ test("dashboard preserves and recovers its last status snapshot", async ({
18421842
phase = "recovery";
18431843
await advanceToRequest(page, "/+status");
18441844
await expect(page.locator(".metrics-group").first()).toContainText("42");
1845+
await expect(page.locator(".card-usage")).toContainText("9 reads");
1846+
await expect(page.getByRole("alert")).toHaveCount(0);
1847+
});
1848+
1849+
test("dashboard reports a failed usage half without stale counters", async ({
1850+
page,
1851+
}) => {
1852+
await page.clock.install();
1853+
let phase = "initial";
1854+
await page.route("**/+status", (route) =>
1855+
route.fulfill({ json: statusDocument(phase === "initial" ? 41 : 42) }),
1856+
);
1857+
await page.route("**/+stats**", (route) => {
1858+
if (phase === "failure") return route.fulfill({ status: 500 });
1859+
return route.fulfill({
1860+
json: statsRoutes(phase === "initial" ? 3 : 9),
1861+
});
1862+
});
1863+
await goto(page, "/login");
1864+
await openClientPath(page, "/");
1865+
await expect(page.locator(".tagline")).toContainText("vbrowser-41");
1866+
await expect(page.locator(".card-usage")).toContainText("3 reads");
1867+
1868+
phase = "failure";
1869+
await advanceToRequest(page, "/+stats");
1870+
await expect(page.getByRole("alert")).toHaveText(
1871+
"/+stats returned HTTP 500.",
1872+
);
1873+
await expect(page.locator(".tagline")).toContainText("vbrowser-42");
1874+
await expect(page.locator(".metrics-group").first()).toContainText("42");
1875+
await expect(page.locator(".card-usage")).toHaveCount(0);
1876+
1877+
phase = "recovery";
1878+
await advanceToRequest(page, "/+stats");
1879+
await expect(page.locator(".tagline")).toContainText("vbrowser-42");
1880+
await expect(page.locator(".metrics-group").first()).toContainText("42");
1881+
await expect(page.locator(".card-usage")).toContainText("9 reads");
1882+
await expect(page.getByRole("alert")).toHaveCount(0);
1883+
});
1884+
1885+
test("dashboard renders its first snapshot when usage is out of reach", async ({
1886+
page,
1887+
}) => {
1888+
await page.route("**/+status", (route) =>
1889+
route.fulfill({ json: statusDocument(41) }),
1890+
);
1891+
await page.route("**/+stats**", (route) => route.fulfill({ status: 401 }));
1892+
await goto(page, "/login");
1893+
await openClientPath(page, "/");
1894+
await expect(page.locator(".card .card-title")).toHaveText("cache");
1895+
await expect(page.locator(".metrics-group").first()).toContainText("41");
1896+
await expect(page.getByRole("alert")).toHaveText(
1897+
"/+stats returned HTTP 401.",
1898+
);
1899+
await expect(page.locator(".card-usage")).toHaveCount(0);
1900+
});
1901+
1902+
test("dashboard skips a refresh while the previous one is outstanding", async ({
1903+
page,
1904+
}) => {
1905+
await page.clock.install();
1906+
let statusRequests = 0;
1907+
let usage = null;
1908+
await page.route("**/+status", (route) => {
1909+
statusRequests += 1;
1910+
return route.fulfill({ json: statusDocument(40 + statusRequests) });
1911+
});
1912+
await page.route("**/+stats**", async (route) => {
1913+
if (usage) await usage.promise;
1914+
await route.fulfill({ json: statsRoutes(3) });
1915+
});
1916+
await goto(page, "/login");
1917+
await openClientPath(page, "/");
1918+
await expect(page.locator(".tagline")).toContainText("vbrowser-41");
1919+
1920+
usage = Promise.withResolvers();
1921+
await advanceToRequest(page, "/+status");
1922+
await page.clock.fastForward(5_000);
1923+
await page.clock.fastForward(5_000);
1924+
usage.resolve();
1925+
await expect(page.locator(".tagline")).toContainText("vbrowser-42");
1926+
expect(statusRequests).toBe(2);
1927+
1928+
await advanceToRequest(page, "/+status");
1929+
await expect(page.locator(".tagline")).toContainText("vbrowser-43");
1930+
expect(statusRequests).toBe(3);
1931+
});
1932+
1933+
test("admin status reports a failed usage half without stale counters", async ({
1934+
page,
1935+
}) => {
1936+
await page.clock.install();
1937+
let phase = "initial";
1938+
await page.route("**/+status", (route) =>
1939+
route.fulfill({ json: statusDocument(phase === "initial" ? 41 : 42) }),
1940+
);
1941+
await page.route("**/+stats**", (route) => {
1942+
if (phase === "failure") return route.fulfill({ status: 500 });
1943+
return route.fulfill({
1944+
json: statsRoutes(phase === "initial" ? 3 : 9),
1945+
});
1946+
});
1947+
await goto(page, "/login");
1948+
await openClientPath(page, "/admin/status");
1949+
const usage = page.locator(".ops-table").filter({ hasText: "Refreshes" });
1950+
const reads = usage.locator("tbody td").nth(2);
1951+
await expect(page.locator(".metrics-group").first()).toContainText("41");
1952+
await expect(reads).toHaveText("3");
1953+
1954+
phase = "failure";
1955+
await advanceToRequest(page, "/+stats");
1956+
await expect(page.getByRole("alert")).toHaveText(
1957+
"/+stats returned HTTP 500.",
1958+
);
1959+
await expect(page.locator(".metrics-group").first()).toContainText("42");
1960+
await expect(usage).toHaveCount(0);
1961+
1962+
phase = "recovery";
1963+
await advanceToRequest(page, "/+stats");
1964+
await expect(page.locator(".metrics-group").first()).toContainText("42");
1965+
await expect(reads).toHaveText("9");
18451966
await expect(page.getByRole("alert")).toHaveCount(0);
18461967
});
18471968

0 commit comments

Comments
 (0)