Skip to main content

pitchfork_cli/web/
helpers.rs

1//! Shared helper functions for web routes.
2
3use crate::daemon::Daemon;
4use crate::daemon_id::DaemonId;
5use crate::procs::PROCS;
6
7/// HTML-escape a string to prevent XSS attacks.
8pub fn html_escape(s: &str) -> String {
9    s.replace('&', "&")
10        .replace('<', "&lt;")
11        .replace('>', "&gt;")
12        .replace('"', "&quot;")
13        .replace('\'', "&#39;")
14}
15
16/// URL-encode a string for use in URLs.
17pub fn url_encode(s: &str) -> String {
18    urlencoding::encode(s).into_owned()
19}
20
21/// Convert daemon ID to a CSS-selector-safe string.
22///
23/// Encodes special characters that are not valid in CSS selectors.
24/// Uses a simple escape scheme: special characters are replaced with `-XX-`
25/// where XX is the hex code of the character.
26pub fn css_safe_id(s: &str) -> String {
27    let mut result = String::with_capacity(s.len() * 2);
28    for c in s.chars() {
29        match c {
30            '/' => result.push_str("-2f-"),
31            '.' => result.push_str("-2e-"),
32            ':' => result.push_str("-3a-"),
33            '@' => result.push_str("-40-"),
34            '#' => result.push_str("-23-"),
35            '[' => result.push_str("-5b-"),
36            ']' => result.push_str("-5d-"),
37            '(' => result.push_str("-28-"),
38            ')' => result.push_str("-29-"),
39            '!' => result.push_str("-21-"),
40            '$' => result.push_str("-24-"),
41            '%' => result.push_str("-25-"),
42            '^' => result.push_str("-5e-"),
43            '&' => result.push_str("-26-"),
44            '*' => result.push_str("-2a-"),
45            '+' => result.push_str("-2b-"),
46            '=' => result.push_str("-3d-"),
47            '|' => result.push_str("-7c-"),
48            '\\' => result.push_str("-5c-"),
49            '~' => result.push_str("-7e-"),
50            '`' => result.push_str("-60-"),
51            '<' => result.push_str("-3c-"),
52            '>' => result.push_str("-3e-"),
53            ',' => result.push_str("-2c-"),
54            ' ' => result.push_str("-20-"),
55            '"' => result.push_str("-22-"),
56            '\'' => result.push_str("-27-"),
57            _ => result.push(c),
58        }
59    }
60    result
61}
62
63/// Format daemon ID with dim namespace for HTML display.
64///
65/// Returns HTML with the namespace wrapped in a span with class "daemon-ns" for CSS styling.
66pub fn format_daemon_id_html(id: &DaemonId) -> String {
67    format!(
68        r#"<span class="daemon-ns">{}</span>/{}"#,
69        html_escape(id.namespace()),
70        html_escape(id.name())
71    )
72}
73
74/// Generate an HTML table row for a daemon.
75///
76/// This is used by both the index page and the daemons list page.
77pub fn daemon_row(id: &DaemonId, d: &Daemon, is_disabled: bool) -> String {
78    let id_str = id.to_string();
79    let safe_id = css_safe_id(&id_str);
80    let confirm_id = html_escape(&id_str); // For display in confirm dialogs
81    let url_id = url_encode(&id_str);
82    let display_html = format_daemon_id_html(id);
83    let status_class = match &d.status {
84        crate::daemon_status::DaemonStatus::Running => "running",
85        crate::daemon_status::DaemonStatus::Stopped => "stopped",
86        crate::daemon_status::DaemonStatus::Waiting => "waiting",
87        crate::daemon_status::DaemonStatus::Stopping => "stopping",
88        crate::daemon_status::DaemonStatus::Failed(_) => "failed",
89        crate::daemon_status::DaemonStatus::Errored(_) => "errored",
90    };
91
92    let pid_display = d
93        .pid
94        .map(|p| p.to_string())
95        .unwrap_or_else(|| "-".to_string());
96
97    // Get process stats (CPU, memory, uptime)
98    let stats = d.pid.and_then(|pid| PROCS.get_stats(pid));
99    let cpu_display = stats
100        .map(|s| s.cpu_display())
101        .unwrap_or_else(|| "-".to_string());
102    let mem_display = stats
103        .map(|s| s.memory_display())
104        .unwrap_or_else(|| "-".to_string());
105    let uptime_display = stats
106        .map(|s| s.uptime_display())
107        .unwrap_or_else(|| "-".to_string());
108
109    let error_msg = html_escape(&d.status.error_message().unwrap_or_default());
110    let disabled_badge = if is_disabled {
111        r#"<span class="badge disabled">disabled</span>"#
112    } else {
113        ""
114    };
115
116    let actions = if d.status.is_running() {
117        format!(
118            r##"
119            <button hx-post="/daemons/{url_id}/stop" hx-target="#daemon-{safe_id}" hx-swap="outerHTML" hx-confirm="Stop daemon '{confirm_id}'?" class="btn btn-sm"><i data-lucide="square" class="icon"></i> Stop</button>
120            <button hx-post="/daemons/{url_id}/restart" hx-target="#daemon-{safe_id}" hx-swap="outerHTML" hx-confirm="Restart daemon '{confirm_id}'?" class="btn btn-sm"><i data-lucide="refresh-cw" class="icon"></i> Restart</button>
121        "##
122        )
123    } else {
124        format!(
125            r##"
126            <button hx-post="/daemons/{url_id}/start" hx-target="#daemon-{safe_id}" hx-swap="outerHTML" class="btn btn-sm btn-primary"><i data-lucide="play" class="icon"></i> Start</button>
127        "##
128        )
129    };
130
131    let toggle_btn = if is_disabled {
132        format!(
133            r##"<button hx-post="/daemons/{url_id}/enable" hx-target="#daemon-{safe_id}" hx-swap="outerHTML" class="btn btn-sm"><i data-lucide="check" class="icon"></i> Enable</button>"##
134        )
135    } else {
136        format!(
137            r##"<button hx-post="/daemons/{url_id}/disable" hx-target="#daemon-{safe_id}" hx-swap="outerHTML" hx-confirm="Disable daemon '{confirm_id}'?" class="btn btn-sm"><i data-lucide="x" class="icon"></i> Disable</button>"##
138        )
139    };
140
141    format!(
142        r#"<tr id="daemon-{safe_id}" class="clickable-row" onclick="window.location.href='/daemons/{url_id}'">
143        <td><a href="/daemons/{url_id}" class="daemon-name" onclick="event.stopPropagation()">{display_html}</a> {disabled_badge}</td>
144        <td>{pid_display}</td>
145        <td><span class="status {status_class}">{}</span></td>
146        <td>{cpu_display}</td>
147        <td>{mem_display}</td>
148        <td>{uptime_display}</td>
149        <td class="error-msg">{error_msg}</td>
150        <td class="actions" onclick="event.stopPropagation()">{actions} {toggle_btn} <a href="/logs/{url_id}" class="btn btn-sm"><i data-lucide="file-text" class="icon"></i> Logs</a></td>
151    </tr>"#,
152        d.status
153    )
154}