ferrodock 1.5.0

A fast, native container manager for Docker, written in Rust.
//! Compose page: containers grouped by Docker Compose project, with
//! project-wide start / stop / restart / remove.

use eframe::egui::{self, Color32, RichText};

use crate::app::components::icons::Icon;
use crate::app::components::{details, table, widgets};
use crate::app::page::{
    Command, CommandRun, Confirmation, Intent, Page, PageCtx, toggle_selection,
};
use crate::app::store::Store;
use crate::docker::{Action, ComposeProject, Resource, Task, plural};
use crate::theme::Palette;

#[derive(Default)]
pub struct ComposePage {
    /// Project name.
    selected: Option<String>,
}

impl ComposePage {
    fn selected<'a>(&self, store: &'a Store) -> Option<&'a ComposeProject> {
        let name = self.selected.as_deref()?;
        store.projects.iter().find(|p| p.name == name)
    }
}

impl Page for ComposePage {
    fn title(&self) -> &'static str {
        "Compose"
    }

    fn icon(&self) -> Icon {
        Icon::Stack
    }

    fn resource(&self) -> Resource {
        Resource::Containers
    }

    fn badge(&self, store: &Store) -> String {
        store.projects.len().to_string()
    }

    fn summary(&self, store: &Store) -> String {
        let running = store.projects.iter().filter(|p| p.running() > 0).count();
        format!(
            "{} · {running} running",
            plural(store.projects.len(), "project")
        )
    }

    fn search_hint(&self) -> &'static str {
        "Search project or service"
    }

    fn show(&mut self, ui: &mut egui::Ui, ctx: &mut PageCtx) {
        let p = ctx.palette;
        let store = ctx.store;
        let visible: Vec<&ComposeProject> = store
            .projects
            .iter()
            .filter(|project| ctx.matches_search(&[&project.name, &project.services().join(" ")]))
            .collect();
        if visible.is_empty() {
            super::nothing_to_show(
                ui,
                ctx,
                !store.projects.is_empty(),
                (
                    Icon::Stack,
                    "No Compose projects",
                    "Projects show up here when you run `docker compose up`.",
                ),
                None,
            );
            return;
        }

        let columns = vec![
            table::Col::fill("PROJECT"),
            table::Col::fixed("STATUS", 150.0),
            table::Col::fixed("", 68.0),
        ];
        let selected = self.selected.clone();
        let clicked = table::show(
            ui,
            p,
            "compose",
            columns,
            visible.len(),
            |i| selected.as_deref() == Some(visible[i].name.as_str()),
            |i, row| {
                let project = visible[i];
                let services = plural(project.services().len(), "service");
                row.col(|ui| {
                    let color = if project.running() > 0 {
                        p.success
                    } else {
                        p.muted
                    };
                    table::with_icon(ui, p, Icon::Stack, color, &project.name, &services)
                });
                row.col(|ui| state_pill(ui, p, project));
                row.col(|ui| {
                    ui.spacing_mut().item_spacing.x = 4.0;
                    for task in project_buttons(ui, store, project, false) {
                        ctx.request(Intent::Run(task));
                    }
                });
            },
        );
        if let Some(i) = clicked {
            toggle_selection(&mut self.selected, visible[i].name.clone());
        }
    }

    fn has_selection(&self, store: &Store) -> bool {
        self.selected(store).is_some()
    }

    fn show_details(&mut self, ui: &mut egui::Ui, ctx: &mut PageCtx) {
        let p = ctx.palette;
        let store = ctx.store;
        let Some(project) = self.selected(store) else {
            return;
        };

        if details::header(ui, p, &project.name) {
            self.selected = None;
        }
        state_pill(ui, p, project);
        ui.add_space(10.0);
        ui.horizontal(|ui| {
            for task in project_buttons(ui, store, project, true) {
                ctx.request(Intent::Run(task));
            }
            if !is_busy(store, project) {
                remove_project_button(ui, ctx, project);
            }
        });
        ui.add_space(16.0);

        egui::ScrollArea::vertical()
            .auto_shrink(false)
            .show(ui, |ui| {
                details::section(ui, p, "Services", |ui| {
                    for c in &project.containers {
                        let service = c.compose.as_ref().map_or("", |l| l.service.as_str());
                        ui.label(RichText::new(service).color(p.muted));
                        ui.horizontal(|ui| {
                            widgets::dot(ui, if c.is_active() { p.success } else { p.muted });
                            ui.label(RichText::new(&c.name).color(p.text));
                            super::containers::ports::links(ui, p, &c.ports);
                        });
                        ui.end_row();
                    }
                });
                details::section(ui, p, "Files", |ui| {
                    details::field(ui, p, "Directory", &project.working_dir);
                    details::field(
                        ui,
                        p,
                        "Config",
                        &widgets::join_or_dash(&project.config_files),
                    );
                });
            });
    }

    fn clear_selection(&mut self) {
        self.selected = None;
    }

    fn select(&mut self, id: &str) {
        self.selected = Some(id.to_owned());
    }

    fn commands(&self, store: &Store) -> Vec<Command> {
        let mut list = Vec::new();
        for project in &store.projects {
            let status = format!(
                "Compose project · {}/{} running",
                project.running(),
                project.containers.len()
            );
            list.push(Command::select(&project.name, status, &project.name));
            let toggle = if project.running() > 0 {
                (Action::Stop, "Stop")
            } else {
                (Action::Start, "Start")
            };
            for (action, verb) in [toggle, (Action::Restart, "Restart")] {
                list.push(Command {
                    label: format!("{verb} project {}", project.name),
                    detail: "Compose project".into(),
                    run: CommandRun::Intents(
                        tasks_for(project, action)
                            .into_iter()
                            .map(Intent::Run)
                            .collect(),
                    ),
                });
            }
        }
        list
    }
}

/// "Running 2/3": green when everything runs, amber when some do.
fn state_pill(ui: &mut egui::Ui, p: &Palette, project: &ComposeProject) {
    let total = project.containers.len();
    let running = project.running();
    let (label, color): (String, Color32) = match running {
        0 => ("Stopped".into(), p.muted),
        n if n == total => ("Running".into(), p.success),
        n => (format!("Running {n}/{total}"), p.warning),
    };
    widgets::pill(ui, &label, color);
}

fn is_busy(store: &Store, project: &ComposeProject) -> bool {
    project
        .containers
        .iter()
        .any(|c| store.pending.contains_key(&c.id))
}

/// Start-all / Stop-all and Restart-all. Returns the tasks to run when one
/// is clicked (empty otherwise).
fn project_buttons(
    ui: &mut egui::Ui,
    store: &Store,
    project: &ComposeProject,
    with_labels: bool,
) -> Vec<Task> {
    if is_busy(store, project) {
        ui.spinner();
        return Vec::new();
    }
    let any_running = project.running() > 0;
    let toggle = if any_running {
        (Action::Stop, Icon::Stop, "Stop all")
    } else {
        (Action::Start, Icon::Play, "Start all")
    };
    let mut clicked = None;
    for (action, icon, label) in [toggle, (Action::Restart, Icon::Restart, "Restart all")] {
        let response = if with_labels {
            widgets::button(ui, icon, label)
        } else {
            widgets::icon_button(ui, icon, label)
        };
        if response.clicked() {
            clicked = Some(action);
        }
    }
    clicked
        .map(|action| tasks_for(project, action))
        .unwrap_or_default()
}

/// One task per container the action applies to: stop only what runs,
/// start only what's stopped, restart everything.
fn tasks_for(project: &ComposeProject, action: Action) -> Vec<Task> {
    project
        .containers
        .iter()
        .filter(|c| match action {
            Action::Start => !c.is_active(),
            Action::Stop => c.is_active(),
            _ => true,
        })
        .map(|c| Task {
            action,
            id: c.id.clone(),
            name: c.name.clone(),
        })
        .collect()
}

fn remove_project_button(ui: &mut egui::Ui, ctx: &mut PageCtx, project: &ComposeProject) {
    if project.running() > 0 {
        ui.add_enabled(false, egui::Button::new("Remove"))
            .on_disabled_hover_text("Stop the project first");
    } else if widgets::danger_button(ui, ctx.palette, "Remove").clicked() {
        let tasks = tasks_for(project, Action::Remove(Resource::Containers));
        ctx.request(Intent::Confirm(Confirmation {
            title: format!("Remove project “{}”?", project.name),
            message: format!(
                "This removes its {}. Volumes, networks and images are kept, and \
                 `docker compose up` recreates everything.",
                plural(tasks.len(), "container")
            ),
            action_label: "Remove".into(),
            tasks,
        }));
    }
}