good stuff
This commit is contained in:
parent
ea8389ef40
commit
f4de0eeb9f
39 changed files with 5165 additions and 348 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue