mod logs;
mod up;
pub use up::{pick_compose_file, up_op};
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, ComposeAction, ComposeOp, ComposeProject, ComposeTarget, Resource, Task, next_job,
plural,
};
use crate::theme::Palette;
#[derive(Default, Clone, Copy, PartialEq, Eq)]
enum Tab {
#[default]
Overview,
Logs,
}
#[derive(Default)]
pub struct ComposePage {
selected: Option<String>,
tab: Tab,
up_path: String,
closed_output: Option<u64>,
only: Option<String>,
}
fn target(project: &ComposeProject) -> Option<ComposeTarget> {
let files: Vec<std::path::PathBuf> = project
.config_files
.iter()
.map(std::path::PathBuf::from)
.collect();
(!files.is_empty() && files.iter().all(|f| f.is_file())).then(|| ComposeTarget {
name: Some(project.name.clone()),
files,
dir: project.working_dir.clone().into(),
})
}
fn run(ctx: &mut PageCtx, target: ComposeTarget, action: ComposeAction) {
ctx.request(Intent::Compose(ComposeOp::Run {
job: next_job(),
target,
action,
}));
}
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;
up::bar(ui, ctx, &mut self.up_path);
let job = store.compose.run.as_ref().map(|j| j.id);
if job.is_some() && job != self.closed_output && !up::output(ui, ctx) {
self.closed_output = job;
}
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",
"Start one from its compose.yaml above, or with `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);
let target = target(project);
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.horizontal(|ui| {
let missing = "Its Compose files aren't on this computer";
let rebuild = ui
.add_enabled(target.is_some(), egui::Button::new("Rebuild all").small())
.on_hover_text("docker compose up -d --build")
.on_disabled_hover_text(missing);
let pull = ui
.add_enabled(
target.is_some(),
egui::Button::new("Pull & recreate").small(),
)
.on_hover_text("docker compose pull, then up -d --force-recreate")
.on_disabled_hover_text(missing);
if let Some(target) = &target {
if rebuild.clicked() {
run(ctx, target.clone(), ComposeAction::Rebuild(None));
}
if pull.clicked() {
run(ctx, target.clone(), ComposeAction::PullRecreate);
}
}
});
ui.add_space(12.0);
widgets::segmented(ui, p, |ui| {
for (tab, label) in [(Tab::Overview, "Overview"), (Tab::Logs, "Logs")] {
if widgets::tab(ui, p, label, None, self.tab == tab).clicked() {
self.tab = tab;
}
}
});
ui.add_space(10.0);
if self.tab == Tab::Logs {
logs::show(ui, ctx, project, &mut self.only);
return;
}
if let Some(job) = store.compose.logs.as_ref().filter(|j| j.ended.is_none()) {
ctx.request(Intent::Compose(ComposeOp::Cancel { job: job.id }));
}
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);
if let Some(target) = &target {
let rebuild = ui.small_button("Rebuild").on_hover_text(format!(
"docker compose up -d --build --no-deps {service}"
));
if rebuild.clicked() {
run(
ctx,
target.clone(),
ComposeAction::Rebuild(Some(service.to_owned())),
);
}
}
});
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![Command {
label: "Open a Compose file…".into(),
detail: "docker compose up -d".into(),
run: CommandRun::Intents(vec![Intent::OpenComposeFile]),
}];
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
}
}
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))
}
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()
}
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,
..Default::default()
}));
}
}