good stuff

This commit is contained in:
Andras Schmelczer 2026-03-15 21:10:54 +00:00
parent ea8389ef40
commit f4de0eeb9f
39 changed files with 5165 additions and 348 deletions

View file

@ -1,6 +1,12 @@
use std::sync::Arc;
use std::time::Duration;
use metrics::gauge;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use tracing::info;
use tracing::{info, warn};
use crate::state::AppState;
#[derive(Deserialize)]
struct AuthResponse {
@ -344,6 +350,116 @@ async fn ensure_saved_searches_rules(
ensure_user_owned_rules(client, base_url, token, "saved_searches").await
}
/// Ensure the `saved_searches` collection has a `screenshot` file field.
/// This field was added after the initial collection schema — existing deployments
/// need it patched in so the frontend can attach screenshot JPEGs to saved searches.
async fn ensure_screenshot_field(
client: &Client,
base_url: &str,
token: &str,
) -> anyhow::Result<()> {
let url = format!("{base_url}/api/collections/saved_searches");
let resp = client
.get(&url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to fetch saved_searches collection ({status}): {text}");
}
let body: serde_json::Value = resp.json().await?;
let fields = body["fields"]
.as_array()
.ok_or_else(|| anyhow::anyhow!("saved_searches collection has no fields array"))?;
if fields.iter().any(|f| f["name"] == "screenshot") {
return Ok(());
}
let mut new_fields = fields.clone();
new_fields.push(serde_json::json!({
"name": "screenshot",
"type": "file",
"required": false,
"maxSelect": 1,
"maxSize": 10485760,
"mimeTypes": ["image/png", "image/jpeg", "image/webp"],
}));
let patch_resp = client
.patch(&url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({ "fields": new_fields }))
.send()
.await?;
if !patch_resp.status().is_success() {
let status = patch_resp.status();
let text = patch_resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to add screenshot field to saved_searches ({status}): {text}");
}
info!("Added screenshot file field to PocketBase collection 'saved_searches'");
Ok(())
}
/// Ensure a collection has a `notes` text field for user annotations.
async fn ensure_notes_field(
client: &Client,
base_url: &str,
token: &str,
collection_name: &str,
) -> anyhow::Result<()> {
let url = format!("{base_url}/api/collections/{collection_name}");
let resp = client
.get(&url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to fetch {collection_name} collection ({status}): {text}");
}
let body: serde_json::Value = resp.json().await?;
let fields = body["fields"]
.as_array()
.ok_or_else(|| anyhow::anyhow!("{collection_name} collection has no fields array"))?;
if fields.iter().any(|f| f["name"] == "notes") {
return Ok(());
}
let mut new_fields = fields.clone();
new_fields.push(serde_json::json!({
"name": "notes",
"type": "text",
"required": false,
}));
let patch_resp = client
.patch(&url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({ "fields": new_fields }))
.send()
.await?;
if !patch_resp.status().is_success() {
let status = patch_resp.status();
let text = patch_resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to add notes field to {collection_name} ({status}): {text}");
}
info!("Added notes text field to PocketBase collection '{collection_name}'");
Ok(())
}
/// Ensure a collection has `created` and `updated` autodate fields.
/// PocketBase 0.23+ no longer adds these automatically — they must be explicit.
async fn ensure_autodate_fields(
@ -445,6 +561,7 @@ pub async fn ensure_collections(
Field::text("name", true),
Field::text("params", true),
Field::file("screenshot", vec!["image/png", "image/jpeg", "image/webp"]),
Field::text("notes", false),
Field::autodate("created", true, false),
Field::autodate("updated", true, true),
],
@ -459,6 +576,8 @@ pub async fn ensure_collections(
} else {
ensure_saved_searches_rules(client, base_url, &token).await?;
ensure_autodate_fields(client, base_url, &token, "saved_searches").await?;
ensure_screenshot_field(client, base_url, &token).await?;
ensure_notes_field(client, base_url, &token, "saved_searches").await?;
}
if !existing.iter().any(|n| n == "saved_properties") {
@ -476,6 +595,7 @@ pub async fn ensure_collections(
Field::text("address", true),
Field::text("postcode", true),
Field::text("data", false),
Field::text("notes", false),
Field::autodate("created", true, false),
Field::autodate("updated", true, true),
],
@ -490,6 +610,7 @@ pub async fn ensure_collections(
} else {
ensure_user_owned_rules(client, base_url, &token, "saved_properties").await?;
ensure_autodate_fields(client, base_url, &token, "saved_properties").await?;
ensure_notes_field(client, base_url, &token, "saved_properties").await?;
}
if !existing.iter().any(|n| n == "invites") {
@ -639,3 +760,106 @@ pub async fn ensure_oauth_providers(
info!("PocketBase OAuth configured on users collection");
Ok(())
}
/// Spawn a background task that polls PocketBase every 60 seconds for collection counts
/// and exposes them as Prometheus gauges.
pub fn start_metrics_poller(state: Arc<AppState>) {
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(60));
loop {
interval.tick().await;
poll_pocketbase_counts(&state).await;
}
});
}
async fn poll_pocketbase_counts(state: &AppState) {
let pb_url = state.pocketbase_url.trim_end_matches('/');
let token = match auth_superuser(
&state.http_client,
pb_url,
&state.pocketbase_admin_email,
&state.pocketbase_admin_password,
)
.await
{
Ok(tk) => tk,
Err(err) => {
warn!("PocketBase metrics poll auth failed: {err}");
return;
}
};
// Simple collection counts
for (collection, metric_name) in [
("users", "pocketbase_users_total"),
("saved_searches", "pocketbase_saved_searches_total"),
("saved_properties", "pocketbase_saved_properties_total"),
] {
if let Some(total) = pb_count(&state.http_client, pb_url, &token, collection, None).await {
gauge!(metric_name).set(total as f64);
}
}
// Invite metrics: by type and redeemed status
for (filter, metric, labels) in [
(None, "invites_total", ("type", "all")),
(
Some(r#"invite_type="admin""#),
"invites_total",
("type", "admin"),
),
(
Some(r#"invite_type="referral""#),
"invites_total",
("type", "referral"),
),
(
Some(r#"used_by_id!=""#),
"invites_total",
("type", "redeemed"),
),
] {
if let Some(total) = pb_count(&state.http_client, pb_url, &token, "invites", filter).await
{
gauge!(metric, labels.0 => labels.1.to_string()).set(total as f64);
}
}
}
async fn pb_count(
client: &reqwest::Client,
pb_url: &str,
token: &str,
collection: &str,
filter: Option<&str>,
) -> Option<u64> {
let mut url = format!("{pb_url}/api/collections/{collection}/records?perPage=1");
if let Some(f) = filter {
url.push_str(&format!("&filter={}", urlencoding::encode(f)));
}
match client
.get(&url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
if let Ok(body) = resp.json::<serde_json::Value>().await {
return body.get("totalItems").and_then(|v| v.as_u64());
}
None
}
Ok(resp) => {
warn!(
"PocketBase {collection} count query failed: {}",
resp.status()
);
None
}
Err(err) => {
warn!("PocketBase {collection} count query error: {err}");
None
}
}
}