···9292 ///
9393 /// Warning: this exploits the internal implementation detail of jetstream cursors
9494 /// being ~microsecond timestamps.
9595- pub fn at(t: SystemTime) -> Self {
9595+ pub fn at(t: impl Into<SystemTime>) -> Self {
9696 let unix_dt = t
9797+ .into()
9798 .duration_since(UNIX_EPOCH)
9899 .expect("cannot set jetstream cursor earlier than unix epoch");
99100 Self(unix_dt.as_micros() as u64)
+2-1
ufos/Cargo.toml
···99base64 = "0.22.1"
1010bincode = { version = "2.0.1", features = ["serde"] }
1111cardinality-estimator-safe = { version = "4.0.1", features = ["with_serde", "with_digest"] }
1212+chrono = { version = "0.4.41", features = ["serde"] }
1213clap = { version = "4.5.31", features = ["derive"] }
1314dropshot = "0.16.0"
1415env_logger = "0.11.7"
···1819jetstream = { path = "../jetstream" }
1920log = "0.4.26"
2021lsm-tree = "2.6.6"
2121-schemars = { version = "0.8.22", features = ["raw_value"] }
2222+schemars = { version = "0.8.22", features = ["raw_value", "chrono"] }
2223semver = "1.0.26"
2324serde = "1.0.219"
2425serde_json = "1.0.140"
+22-2
ufos/src/server.rs
···11use crate::index_html::INDEX_HTML;
22use crate::storage::StoreReader;
33+use crate::store_types::HourTruncatedCursor;
34use crate::{ConsumerInfo, Nsid, NsidCount, QueryPeriod, TopCollections, UFOsRecord};
45use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
66+use chrono::{DateTime, Utc};
57use dropshot::endpoint;
68use dropshot::ApiDescription;
79use dropshot::Body;
···2325struct Context {
2426 pub spec: Arc<serde_json::Value>,
2527 storage: Box<dyn StoreReader>,
2828+}
2929+3030+fn dt_to_cursor(dt: DateTime<Utc>) -> Result<HourTruncatedCursor, HttpError> {
3131+ let t = dt.timestamp_micros();
3232+ if t < 0 {
3333+ Err(HttpError::for_bad_request(None, "timestamp too old".into()))
3434+ } else {
3535+ Ok(HourTruncatedCursor::truncate_raw_u64(t as u64))
3636+ }
2637}
27382839/// Serve index page as html
···230241 limit: usize,
231242 /// Always omit the cursor for the first request. If more collections than the limit are available, the response will contain a non-null `cursor` to include with the next request.
232243 cursor: Option<String>,
244244+ /// Limit collections and statistics to those seen after this UTC datetime
245245+ since: Option<DateTime<Utc>>,
246246+ /// Limit collections and statistics to those seen before this UTC datetime
247247+ until: Option<DateTime<Utc>>,
233248}
234249fn all_collections_default_limit() -> usize {
235250 100
···240255}]
241256/// Get all collections
242257///
243243-/// There have been a lot of collections seen in the ATmosphere, well over 400 at time of writing, so you *will* need to make a series of paginaged requests using the `cursor` response property and request parameter to get them all.
258258+/// There have been a lot of collections seen in the ATmosphere, well over 400 at time of writing, so you *will* need to make a series of paginaged requests with `cursor`s to get them all.
244259///
245260/// The set of collections across multiple requests is not guaranteed to be a perfectly consistent snapshot:
246261///
···251266/// - no duplicate NSIDs will occur in the combined results
252267///
253268/// In practice this is close enough for most use-cases to not worry about.
269269+///
270270+/// Statistics are bucketed hourly, so the most granular effecitve time boundary for `since` and `until` is one hour.
254271async fn get_all_collections(
255272 ctx: RequestContext<Context>,
256273 query: Query<AllCollectionsQuery>,
···270287 .transpose()
271288 .map_err(|e| HttpError::for_bad_request(None, format!("invalid cursor: {e:?}")))?;
272289290290+ let since = q.since.map(dt_to_cursor).transpose()?;
291291+ let until = q.until.map(dt_to_cursor).transpose()?;
292292+273293 let (collections, next_cursor) = storage
274274- .get_all_collections(QueryPeriod::all_time(), q.limit, cursor)
294294+ .get_all_collections(q.limit, cursor, since, until)
275295 .await
276296 .map_err(|e| HttpError::for_internal_error(format!("oh shoot: {e:?}")))?;
277297