systemprompt_api/routes/admin/services/
mod.rs1mod refresh;
19mod status;
20
21use std::sync::Arc;
22
23use axum::routing::{get, post};
24use axum::{Extension, Router};
25use chrono::{DateTime, Utc};
26use serde::{Deserialize, Serialize};
27use systemprompt_loader::services_root::{ActiveServicesRoot, ServicesProvenance};
28use systemprompt_models::services::bundle::ServicesBundleState;
29use systemprompt_runtime::AppContext;
30
31pub use status::build_status;
32
33pub(super) fn router() -> Router<AppContext> {
34 Router::new()
35 .route("/status", get(status::status))
36 .route("/refresh", post(refresh::refresh))
37 .layer(Extension(RefreshLock::default()))
38}
39
40#[derive(Debug, Clone, Default)]
46pub struct RefreshLock(Arc<tokio::sync::Mutex<()>>);
47
48impl RefreshLock {
49 pub fn try_acquire(&self) -> Option<tokio::sync::OwnedMutexGuard<()>> {
50 Arc::clone(&self.0).try_lock_owned().ok()
51 }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct SourceView {
56 pub name: String,
57 pub digest: String,
58 pub version: String,
59 pub content_hash: String,
60 pub fetched_at: DateTime<Utc>,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ProvenanceView {
65 pub kind: String,
66
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub composed_hash: Option<String>,
69
70 #[serde(skip_serializing_if = "Option::is_none")]
71 pub error: Option<String>,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct ServicesStatusResponse {
76 pub active_root: String,
77 pub provenance: ProvenanceView,
78 pub sources: Vec<SourceView>,
79
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub last_reconciled_hash: Option<String>,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct ServicesRefreshResponse {
86 pub changed: bool,
87
88 #[serde(skip_serializing_if = "Option::is_none")]
89 pub composed_hash: Option<String>,
90
91 pub sources: Vec<SourceView>,
92 pub restarting: bool,
93}
94
95pub fn source_views(state: &ServicesBundleState) -> Vec<SourceView> {
96 state
97 .sources
98 .iter()
99 .map(|(name, s)| SourceView {
100 name: name.clone(),
101 digest: s.digest.clone(),
102 version: s.version.clone(),
103 content_hash: s.content_hash.clone(),
104 fetched_at: s.fetched_at,
105 })
106 .collect()
107}
108
109pub fn provenance_view(provenance: &ServicesProvenance) -> ProvenanceView {
110 match provenance {
111 ServicesProvenance::Bundled => ProvenanceView {
112 kind: "bundled".to_owned(),
113 composed_hash: None,
114 error: None,
115 },
116 ServicesProvenance::Fetched { composed_hash, .. } => ProvenanceView {
117 kind: "fetched".to_owned(),
118 composed_hash: Some(composed_hash.clone()),
119 error: None,
120 },
121 ServicesProvenance::LastGood {
122 composed_hash,
123 error,
124 } => ProvenanceView {
125 kind: "last_good".to_owned(),
126 composed_hash: Some(composed_hash.clone()),
127 error: Some(error.clone()),
128 },
129 ServicesProvenance::BundledFallback { error } => ProvenanceView {
130 kind: "bundled".to_owned(),
131 composed_hash: None,
132 error: Some(error.clone()),
133 },
134 }
135}
136
137pub const fn composed_hash_of(active: &ActiveServicesRoot) -> Option<&str> {
138 match &active.provenance {
139 ServicesProvenance::Fetched { composed_hash, .. }
140 | ServicesProvenance::LastGood { composed_hash, .. } => Some(composed_hash.as_str()),
141 ServicesProvenance::Bundled | ServicesProvenance::BundledFallback { .. } => None,
142 }
143}