//! egui panels. Split from state so `app.rs` stays a state machine.
use twice_core::{AppSummary, ContainerState, Host, JobOutcome};
#[cfg(test)]
mod widget_tests;
use crate::app::{AutoUpdateChoice, InfrastructureEditor, JobPane, TwiceApp, UpdateDialog};
use crate::backend::UiCommand;
use crate::client::{infrastructure_spec, update_request};
/// A button press in the application table, applied after the row loop ends.
#[derive(Clone, Debug)]
pub(crate) enum RowAction {
/// Start the application.
Start(Host),
/// Stop the application.
Stop(Host),
/// Open the image update dialog, pre-filled with the running image.
OpenUpdate(Host, String),
}
/// Server picker and refresh controls.
pub(crate) fn toolbar(app: &mut TwiceApp, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.heading("twice");
ui.separator();
let selected = app
.store
.current()
.map_or("no server", |p| p.name.as_str())
.to_owned();
egui::ComboBox::from_id_salt("server-picker")
.selected_text(selected)
.show_ui(ui, |ui| {
for (index, profile) in app.store.profiles.iter().enumerate() {
let label = format!("{} — {}", profile.name, profile.base_url);
if ui
.selectable_label(app.store.selected == index, label)
.clicked()
{
app.pending_selection = Some(index);
}
}
});
if ui.button("Refresh").clicked() {
app.backend.send(UiCommand::RefreshApps);
}
ui.toggle_value(&mut app.editing_servers, "Servers…");
ui.toggle_value(&mut app.editing_infrastructure, "Infrastructure…");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let (colour, text) = app.status_line();
ui.colored_label(colour, text);
});
});
}
/// Editor for the saved server profiles, including their API keys.
pub(crate) fn server_editor(app: &mut TwiceApp, ui: &mut egui::Ui) {
ui.label("Server profiles are stored with owner-only permissions.");
egui::Grid::new("server-editor")
.num_columns(2)
.show(ui, |ui| {
ui.label("Name");
ui.text_edit_singleline(&mut app.draft.name);
ui.end_row();
ui.label("Base URL");
ui.text_edit_singleline(&mut app.draft.base_url);
ui.end_row();
ui.label("API key");
ui.add(egui::TextEdit::singleline(&mut app.draft.api_key).password(true));
ui.end_row();
});
ui.horizontal(|ui| {
if ui.button("Save profile").clicked() {
app.save_draft();
}
if ui.button("Forget selected").clicked() {
app.forget_selected();
}
});
}
/// TOML infrastructure editor. Returns an apply command after local validation.
pub(crate) fn infrastructure_editor(
editor: &mut InfrastructureEditor,
ui: &mut egui::Ui,
) -> Option<UiCommand> {
ui.strong("Infrastructure as code");
ui.label("Declare one application by hostname, image source, and version.");
ui.add(
egui::TextEdit::multiline(&mut editor.document)
.code_editor()
.desired_rows(4)
.desired_width(f32::INFINITY),
);
if let Some(err) = &editor.error {
ui.colored_label(egui::Color32::from_rgb(220, 90, 90), err);
}
if ui.button("Apply infrastructure").clicked() {
match infrastructure_spec(&editor.document) {
Ok(spec) => {
editor.error = None;
return Some(UiCommand::ApplyInfrastructure(Box::new(spec)));
}
Err(err) => editor.error = Some(format!("{err:#}")),
}
}
None
}
/// The application table. Returns the button the operator pressed, if any.
pub(crate) fn apps_table(apps: &[AppSummary], ui: &mut egui::Ui) -> Option<RowAction> {
if apps.is_empty() {
ui.label("No once-managed applications reported by this server.");
return None;
}
let mut action = None;
egui::Grid::new("apps")
.num_columns(6)
.striped(true)
.show(ui, |ui| {
for header in ["Host", "State", "Image", "Status", "Auto-update", ""] {
ui.strong(header);
}
ui.end_row();
for app in apps {
ui.label(app.host.as_str());
ui.colored_label(state_colour(app.state), app.state.label());
ui.label(app.image.as_str());
ui.label(&app.status);
ui.label(match app.auto_update {
Some(true) => "on",
Some(false) => "off",
None => "—",
});
ui.horizontal(|ui| {
if ui.button("Start").clicked() {
action = Some(RowAction::Start(app.host.clone()));
}
if ui.button("Stop").clicked() {
action = Some(RowAction::Stop(app.host.clone()));
}
if ui.button("Update image…").clicked() {
action = Some(RowAction::OpenUpdate(
app.host.clone(),
app.image.as_str().to_owned(),
));
}
});
ui.end_row();
}
});
action
}
/// Modal for rolling one application onto a new image.
pub(crate) fn update_dialog(dialog: &mut UpdateDialog, ctx: &egui::Context) -> Option<UiCommand> {
let mut command = None;
let mut open = true;
egui::Window::new(format!("Update {}", dialog.host))
.open(&mut open)
.collapsible(false)
.resizable(false)
.show(ctx, |ui| {
ui.label("New image reference");
ui.add(egui::TextEdit::singleline(&mut dialog.image).desired_width(420.0));
ui.horizontal(|ui| {
ui.label("Auto-update:");
ui.selectable_value(&mut dialog.auto_update, AutoUpdateChoice::Inherit, "leave");
ui.selectable_value(&mut dialog.auto_update, AutoUpdateChoice::Enable, "on");
ui.selectable_value(&mut dialog.auto_update, AutoUpdateChoice::Disable, "off");
});
if let Some(err) = &dialog.error {
ui.colored_label(egui::Color32::from_rgb(220, 90, 90), err);
}
ui.separator();
if ui.button("Roll onto this image").clicked() {
match update_request(&dialog.image, dialog.auto_update.as_flag()) {
Ok(request) => {
command = Some(UiCommand::UpdateImage {
host: dialog.host.clone(),
request: Box::new(request),
});
}
Err(err) => dialog.error = Some(format!("{err:#}")),
}
}
});
dialog.closed = !open;
command
}
/// Live output of the most recent job.
pub(crate) fn job_pane(job: &JobPane, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.strong(&job.title);
match &job.outcome {
None | Some(JobOutcome::Running) => ui.spinner(),
Some(JobOutcome::Succeeded) => {
ui.colored_label(egui::Color32::from_rgb(90, 190, 110), "succeeded")
}
Some(JobOutcome::Failed { exit_code, message }) => ui.colored_label(
egui::Color32::from_rgb(220, 90, 90),
format!("failed ({}): {message}", exit_code.unwrap_or(-1)),
),
};
});
egui::ScrollArea::vertical()
.stick_to_bottom(true)
.max_height(220.0)
.show(ui, |ui| {
for line in &job.lines {
ui.monospace(line);
}
});
}
const fn state_colour(state: ContainerState) -> egui::Color32 {
match state {
ContainerState::Running => egui::Color32::from_rgb(90, 190, 110),
ContainerState::Paused | ContainerState::Restarting | ContainerState::Created => {
egui::Color32::from_rgb(220, 180, 80)
}
ContainerState::Exited | ContainerState::Removing => egui::Color32::from_rgb(170, 170, 170),
ContainerState::Dead => egui::Color32::from_rgb(220, 90, 90),
ContainerState::Unknown => egui::Color32::GRAY,
}
}
/// Convenience used by the dialog to convert the tri-state control.
impl AutoUpdateChoice {
pub(crate) const fn as_flag(self) -> Option<bool> {
match self {
Self::Inherit => None,
Self::Enable => Some(true),
Self::Disable => Some(false),
}
}
}