Skip to main content

cc_switch/daemon/
status.rs

1use 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    // Table header
131    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    // Aliases section
157    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}