Skip to main content

pitchfork_cli/
daemon_list.rs

1use crate::Result;
2use crate::daemon::Daemon;
3use crate::daemon_id::DaemonId;
4use crate::daemon_status::DaemonStatus;
5use crate::ipc::client::IpcClient;
6use crate::pitchfork_toml::PitchforkToml;
7use std::collections::HashSet;
8
9/// Represents a daemon entry that can be either tracked (from state file) or available (from config only)
10#[derive(Debug, Clone)]
11pub struct DaemonListEntry {
12    pub id: DaemonId,
13    pub daemon: Daemon,
14    pub is_disabled: bool,
15    pub is_available: bool, // true if daemon is only in config, not in state
16}
17
18/// Get a unified list of all daemons from IPC client and config
19///
20/// This function merges daemons from the state file (including failed daemons) with daemons
21/// defined in config files. Daemons that are only in config (not in state file) are marked
22/// as "available".
23///
24/// This logic is shared across:
25/// - `pitchfork list` command
26/// - TUI daemon list
27///
28/// # Arguments
29/// * `client` - IPC client to communicate with supervisor (used only for disabled list)
30///
31/// # Returns
32/// A vector of daemon entries with their current status
33pub async fn get_all_daemons(client: &IpcClient) -> Result<Vec<DaemonListEntry>> {
34    let config = PitchforkToml::all_merged()?;
35
36    // Read state file to get all daemons (including failed ones)
37    let state_file = crate::state_file::StateFile::read(&*crate::env::PITCHFORK_STATE_FILE)?;
38    let state_daemons: Vec<Daemon> = state_file.daemons.values().cloned().collect();
39
40    let disabled_daemons = client.get_disabled_daemons().await?;
41    let disabled_set: HashSet<DaemonId> = disabled_daemons.into_iter().collect();
42
43    build_daemon_list(state_daemons, disabled_set, config)
44}
45
46/// Get a unified list of all daemons from supervisor directly (for Web UI)
47///
48/// This function is used by the Web UI which runs inside the supervisor process
49/// and can access the supervisor directly without IPC.
50///
51/// # Arguments
52/// * `supervisor` - Reference to the supervisor instance
53///
54/// # Returns
55/// A vector of daemon entries with their current status
56pub async fn get_all_daemons_direct(
57    supervisor: &crate::supervisor::Supervisor,
58) -> Result<Vec<DaemonListEntry>> {
59    let config = PitchforkToml::all_merged()?;
60
61    // Read all daemons from state file (including failed/stopped ones)
62    let state_file = supervisor.state_file.lock().await;
63    let state_daemons: Vec<Daemon> = state_file.daemons.values().cloned().collect();
64    let disabled_set: HashSet<DaemonId> = state_file.disabled.clone().into_iter().collect();
65    drop(state_file); // Release lock early
66
67    build_daemon_list(state_daemons, disabled_set, config)
68}
69
70/// Look up a single daemon by ID from state + config (for Web UI show handler).
71///
72/// Checks the state file first, then falls back to config files (including namespaces).
73/// Returns `None` if the daemon is not found anywhere.
74pub async fn get_daemon_direct(
75    supervisor: &crate::supervisor::Supervisor,
76    id: &DaemonId,
77) -> Result<Option<DaemonListEntry>> {
78    let pitchfork_id = DaemonId::pitchfork();
79    if *id == pitchfork_id {
80        return Ok(None);
81    }
82
83    // Check state file first
84    let state_file = supervisor.state_file.lock().await;
85    if let Some(daemon) = state_file.daemons.get(id).cloned() {
86        let is_disabled = state_file.disabled.contains(id);
87        drop(state_file);
88        return Ok(Some(DaemonListEntry {
89            id: id.clone(),
90            daemon,
91            is_disabled,
92            is_available: false,
93        }));
94    }
95    let is_disabled = state_file.disabled.contains(id);
96    drop(state_file);
97
98    // Not in state — look in local config
99    let config = PitchforkToml::all_merged()?;
100    if let Some(daemon_config) = config.daemons.get(id) {
101        return Ok(Some(DaemonListEntry {
102            id: id.clone(),
103            daemon: build_placeholder_daemon(id, daemon_config),
104            is_disabled,
105            is_available: true,
106        }));
107    }
108
109    // Check registered namespaces
110    let namespaces = PitchforkToml::read_global_namespaces();
111    for (_, entry) in namespaces {
112        match PitchforkToml::all_merged_from(&entry.dir) {
113            Ok(ns_config) => {
114                if let Some(daemon_config) = ns_config.daemons.get(id) {
115                    return Ok(Some(DaemonListEntry {
116                        id: id.clone(),
117                        daemon: build_placeholder_daemon(id, daemon_config),
118                        is_disabled,
119                        is_available: true,
120                    }));
121                }
122            }
123            Err(e) => {
124                log::warn!("Failed to load namespace from {}: {e}", entry.dir.display());
125            }
126        }
127    }
128
129    Ok(None)
130}
131
132/// Build a placeholder Daemon from config for daemons that exist in config but not state.
133fn build_placeholder_daemon(
134    id: &DaemonId,
135    daemon_config: &crate::pitchfork_toml::PitchforkTomlDaemon,
136) -> Daemon {
137    Daemon {
138        id: id.clone(),
139        status: DaemonStatus::Stopped,
140        port: daemon_config.port.clone(),
141        depends: vec![],
142        env: None,
143        watch: vec![],
144        watch_mode: daemon_config.watch_mode,
145        watch_base_dir: None,
146        mise: daemon_config.mise,
147        user: daemon_config.user.clone(),
148        active_port: None,
149        slug: None,
150        proxy: None,
151        memory_limit: daemon_config.memory_limit,
152        cpu_limit: daemon_config.cpu_limit,
153        ..Daemon::default()
154    }
155}
156
157/// Internal helper to build the daemon list from state daemons and config
158fn build_daemon_list(
159    state_daemons: Vec<Daemon>,
160    disabled_set: HashSet<DaemonId>,
161    config: PitchforkToml,
162) -> Result<Vec<DaemonListEntry>> {
163    let mut entries = Vec::new();
164    let mut seen_ids = HashSet::new();
165
166    // Skip the supervisor itself
167    let pitchfork_id = DaemonId::pitchfork();
168
169    // First, add all daemons from state file
170    for daemon in state_daemons {
171        if daemon.id == pitchfork_id {
172            continue; // Skip supervisor itself
173        }
174
175        // proxy and mise are stored as Option<bool> in the Daemon struct.
176        // None means "inherit from global settings", which is resolved at display/routing time.
177        // No override needed here — daemon_list consumers call .unwrap_or(settings()...) themselves.
178
179        seen_ids.insert(daemon.id.clone());
180        entries.push(DaemonListEntry {
181            id: daemon.id.clone(),
182            is_disabled: disabled_set.contains(&daemon.id),
183            is_available: false,
184            daemon,
185        });
186    }
187
188    // Then, add daemons from config that aren't in state file (available daemons)
189    for (daemon_id, daemon_config) in &config.daemons {
190        if *daemon_id == pitchfork_id || seen_ids.contains(daemon_id) {
191            continue;
192        }
193
194        let placeholder = build_placeholder_daemon(daemon_id, daemon_config);
195
196        entries.push(DaemonListEntry {
197            id: daemon_id.clone(),
198            daemon: placeholder,
199            is_disabled: disabled_set.contains(daemon_id),
200            is_available: true,
201        });
202        seen_ids.insert(daemon_id.clone());
203    }
204
205    // Add daemons from registered namespaces
206    let namespaces = PitchforkToml::read_global_namespaces();
207    for (ns_name, entry) in namespaces {
208        match PitchforkToml::all_merged_from(&entry.dir) {
209            Ok(ns_config) => {
210                for (daemon_id, daemon_config) in &ns_config.daemons {
211                    if *daemon_id == pitchfork_id || seen_ids.contains(daemon_id) {
212                        continue;
213                    }
214                    let placeholder = build_placeholder_daemon(daemon_id, daemon_config);
215                    entries.push(DaemonListEntry {
216                        id: daemon_id.clone(),
217                        daemon: placeholder,
218                        is_disabled: disabled_set.contains(daemon_id),
219                        is_available: true,
220                    });
221                    seen_ids.insert(daemon_id.clone());
222                }
223            }
224            Err(e) => {
225                log::warn!(
226                    "Failed to load namespace '{ns_name}' from {}: {e}",
227                    entry.dir.display()
228                );
229            }
230        }
231    }
232
233    Ok(entries)
234}