cc_switch/daemon/
status.rs1use crate::config::ConfigStorage;
2use crate::daemon::state::{DaemonState, ProxyEntry};
3use std::collections::BTreeMap;
4
5pub type AliasesByUpstream = BTreeMap<String, Vec<String>>;
6
7pub struct ProxyStatus {
8 pub entry: ProxyEntry,
9 pub reachable: bool,
10 pub request_count: Option<u64>,
11 pub store_degraded: bool,
12}
13
14pub fn build_aliases_by_upstream(storage: &ConfigStorage) -> AliasesByUpstream {
15 let mut map: AliasesByUpstream = BTreeMap::new();
16 for config in storage.configurations.values() {
17 if !config.url.is_empty() {
18 map.entry(config.url.clone())
19 .or_default()
20 .push(config.alias_name.clone());
21 }
22 }
23 map
24}
25
26struct HealthProbe {
27 reachable: bool,
28 request_count: Option<u64>,
29 store_degraded: bool,
30}
31
32pub fn collect_status(state: &DaemonState) -> Vec<ProxyStatus> {
33 state
34 .proxies
35 .iter()
36 .map(|entry| {
37 let probe = probe_health(entry.api_port);
38 ProxyStatus {
39 entry: entry.clone(),
40 reachable: probe.reachable,
41 request_count: probe.request_count,
42 store_degraded: probe.store_degraded,
43 }
44 })
45 .collect()
46}
47
48fn probe_health(api_port: Option<u16>) -> HealthProbe {
49 let Some(port) = api_port else {
50 return HealthProbe {
51 reachable: false,
52 request_count: None,
53 store_degraded: false,
54 };
55 };
56 let url = format!("http://127.0.0.1:{port}/api/health");
57 let client = reqwest::blocking::Client::builder()
58 .timeout(std::time::Duration::from_millis(500))
59 .build();
60 let client = match client {
61 Ok(c) => c,
62 Err(_) => {
63 return HealthProbe {
64 reachable: false,
65 request_count: None,
66 store_degraded: false,
67 };
68 }
69 };
70 match client.get(&url).send() {
71 Ok(resp) if resp.status().is_success() => {
72 let json: serde_json::Value = resp.json().unwrap_or_default();
73 let request_count = json.get("request_count").and_then(|v| v.as_u64());
74 let store_degraded = json
75 .get("store_degraded")
76 .and_then(|v| v.as_bool())
77 .unwrap_or(false);
78 HealthProbe {
79 reachable: true,
80 request_count,
81 store_degraded,
82 }
83 }
84 _ => HealthProbe {
85 reachable: false,
86 request_count: None,
87 store_degraded: false,
88 },
89 }
90}
91
92pub fn format_status_text(
93 state: &DaemonState,
94 statuses: &[ProxyStatus],
95 aliases_per_upstream: &AliasesByUpstream,
96) -> String {
97 let mut out = String::new();
98
99 let uptime = compute_uptime(&state.started_at);
100 out.push_str(&format!(
101 "ccs-daemon: RUNNING (pid {}, uptime {})\n",
102 state.pid, uptime
103 ));
104 out.push_str(&format!(
105 " state: {}\n",
106 state.data_root.parent().map_or_else(
107 || state.data_root.display().to_string(),
108 |p| p.join("daemon-state.json").display().to_string(),
109 )
110 ));
111 out.push_str(&format!(
112 " pidfile: {}\n",
113 state.data_root.parent().map_or_else(
114 || "~/.cc-switch/daemon.pid".to_string(),
115 |p| p.join("daemon.pid").display().to_string(),
116 )
117 ));
118 if let Some(agg_port) = state.agg_port {
119 out.push_str(&format!(" dashboard: http://127.0.0.1:{agg_port}\n"));
120 }
121 out.push('\n');
122
123 if statuses.is_empty() {
124 out.push_str("No proxies running.\n");
125 return out;
126 }
127
128 out.push_str(&format!("Proxies ({}):\n", statuses.len()));
129
130 out.push_str(" upstream proxy_port dashboard requests\n");
132 out.push_str(" ----------------------------------- ---------- --------- --------\n");
133
134 for status in statuses {
135 let upstream = &status.entry.upstream;
136 let upstream_display = if upstream.len() > 35 {
137 format!("{}...", &upstream[..32])
138 } else {
139 upstream.clone()
140 };
141 let req_str = match status.request_count {
142 Some(n) => format!("{n:>8}"),
143 None if !status.reachable => "(unreachable)".to_string(),
144 None => " ?".to_string(),
145 };
146 let dashboard_str = match status.entry.api_port {
147 Some(port) => format!(":{port}"),
148 None => "—".to_string(),
149 };
150 out.push_str(&format!(
151 " {:<35} {:>10} {:>9} {}\n",
152 upstream_display, status.entry.proxy_port, dashboard_str, req_str,
153 ));
154 }
155
156 if !aliases_per_upstream.is_empty() {
158 out.push_str("\nAliases routed through daemon:\n");
159 for status in statuses {
160 if let Some(aliases) = aliases_per_upstream.get(&status.entry.upstream) {
161 let alias_list = aliases.join(", ");
162 let upstream_short = if status.entry.upstream.len() > 40 {
163 format!("{}...", &status.entry.upstream[..37])
164 } else {
165 status.entry.upstream.clone()
166 };
167 out.push_str(&format!(" {:<20} → {}\n", alias_list, upstream_short));
168 }
169 }
170 }
171
172 out
173}
174
175pub fn format_status_json(state: &DaemonState, statuses: &[ProxyStatus]) -> serde_json::Value {
176 let proxies: Vec<serde_json::Value> = statuses
177 .iter()
178 .map(|s| {
179 serde_json::json!({
180 "provider": s.entry.provider,
181 "upstream": s.entry.upstream,
182 "proxy_port": s.entry.proxy_port,
183 "api_port": s.entry.api_port,
184 "data_dir": s.entry.data_dir.display().to_string(),
185 "reachable": s.reachable,
186 "request_count": s.request_count,
187 "store_degraded": s.store_degraded,
188 "restart_count": s.entry.restart_count,
189 })
190 })
191 .collect();
192
193 serde_json::json!({
194 "status": "running",
195 "pid": state.pid,
196 "started_at": state.started_at,
197 "data_root": state.data_root.display().to_string(),
198 "agg_port": state.agg_port,
199 "proxies": proxies,
200 })
201}
202
203fn compute_uptime(started_at: &str) -> String {
204 let started = match chrono::DateTime::parse_from_rfc3339(started_at) {
205 Ok(dt) => dt,
206 Err(_) => return "?".to_string(),
207 };
208 let now = chrono::Utc::now();
209 let duration = now.signed_duration_since(started);
210 let secs = duration.num_seconds();
211 if secs < 60 {
212 format!("{secs}s")
213 } else if secs < 3600 {
214 format!("{}m {}s", secs / 60, secs % 60)
215 } else {
216 let hours = secs / 3600;
217 let mins = (secs % 3600) / 60;
218 format!("{hours}h {mins:02}m")
219 }
220}