//! Application state and the egui frame loop.
use std::path::PathBuf;
use std::sync::mpsc::Receiver;
use std::time::{Duration, Instant};
use twice_core::{AppSummary, Host, JobOutcome};
use crate::backend::{Backend, BackendEvent, UiCommand};
use crate::client::Lifecycle;
use crate::profiles::{self, ProfileStore, ServerProfile, default_profile_path};
use crate::views::{self, RowAction};
/// How often the application list refreshes on its own.
const AUTO_REFRESH: Duration = Duration::from_secs(5);
/// Tri-state control for the `--auto-update` flag.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum AutoUpdateChoice {
/// Do not send the flag at all.
#[default]
Inherit,
/// Send `--auto-update=true`.
Enable,
/// Send `--auto-update=false`.
Disable,
}
/// State of the image update modal.
#[derive(Clone, Debug)]
pub(crate) struct UpdateDialog {
/// Application being updated.
pub(crate) host: Host,
/// Image reference being typed.
pub(crate) image: String,
/// Whether to pin `--auto-update`.
pub(crate) auto_update: AutoUpdateChoice,
/// Validation failure to show under the field.
pub(crate) error: Option<String>,
/// Set when the operator closed the window.
pub(crate) closed: bool,
}
/// Editable infrastructure-as-code document and its validation state.
#[derive(Clone, Debug)]
pub(crate) struct InfrastructureEditor {
/// TOML sent to the selected server after local validation.
pub(crate) document: String,
/// Parse or validation failure shown next to the document.
pub(crate) error: Option<String>,
}
impl Default for InfrastructureEditor {
fn default() -> Self {
Self {
document: concat!(
"host = \"app.example.com\"\n",
"source = \"ghcr.io/owner/app\"\n",
"version = \"1.0.0\"\n",
)
.to_owned(),
error: None,
}
}
}
/// Live view of the most recent job.
#[derive(Clone, Debug)]
pub(crate) struct JobPane {
/// Heading, e.g. `update image · app.example.com`.
pub(crate) title: String,
/// Output lines received so far.
pub(crate) lines: Vec<String>,
/// Terminal outcome once known.
pub(crate) outcome: Option<JobOutcome>,
}
#[derive(Clone, Debug, Default)]
enum Status {
#[default]
Idle,
Info(String),
Error(String),
}
/// The whole client.
pub(crate) struct TwiceApp {
pub(crate) backend: Backend,
pub(crate) store: ProfileStore,
pub(crate) draft: ServerProfile,
pub(crate) editing_servers: bool,
pub(crate) editing_infrastructure: bool,
pub(crate) pending_selection: Option<usize>,
events: Receiver<BackendEvent>,
profile_path: PathBuf,
apps: Vec<AppSummary>,
status: Status,
job: Option<JobPane>,
dialog: Option<UpdateDialog>,
infrastructure: InfrastructureEditor,
last_refresh: Instant,
}
impl TwiceApp {
/// Builds the client, loading saved profiles and connecting to the last one.
pub(crate) fn new(ctx: &egui::Context) -> Self {
let (backend, events) = Backend::spawn(ctx.clone());
let profile_path =
default_profile_path().unwrap_or_else(|_ignored| PathBuf::from("twice-ui.toml"));
let store = ProfileStore::load(&profile_path).unwrap_or_default();
let draft = store.current().cloned().unwrap_or_default();
let status = store.current().map_or_else(
|| Status::Info("add a server profile to begin".to_owned()),
|profile| {
backend.send(UiCommand::Connect(Box::new(profile.clone())));
Status::Info(format!("connecting to {}", profile.base_url))
},
);
Self {
backend,
store,
draft,
editing_servers: false,
editing_infrastructure: false,
pending_selection: None,
events,
profile_path,
apps: Vec::new(),
status,
job: None,
dialog: None,
infrastructure: InfrastructureEditor::default(),
last_refresh: Instant::now(),
}
}
/// Colour and text for the status area of the toolbar.
pub(crate) fn status_line(&self) -> (egui::Color32, String) {
match &self.status {
Status::Idle => (egui::Color32::GRAY, String::new()),
Status::Info(text) => (egui::Color32::GRAY, text.clone()),
Status::Error(text) => (egui::Color32::from_rgb(220, 90, 90), text.clone()),
}
}
/// Saves the draft as a new profile, or updates the selected one in place.
pub(crate) fn save_draft(&mut self) {
let cleaned = match profiles::normalize(&self.draft) {
Ok(cleaned) => cleaned,
Err(rejection) => {
self.status = Status::Error(rejection.0);
return;
}
};
self.draft = cleaned.clone();
if let Some(existing) = self
.store
.profiles
.iter_mut()
.find(|p| p.name == cleaned.name)
{
*existing = cleaned;
} else {
self.store.profiles.push(cleaned);
self.store.selected = self.store.profiles.len().saturating_sub(1);
}
self.persist();
self.connect_selected();
}
/// Removes the selected profile from local storage. Servers are untouched.
pub(crate) fn forget_selected(&mut self) {
if self.store.selected >= self.store.profiles.len() {
return;
}
let removed = self.store.profiles.remove(self.store.selected);
self.store.selected = 0;
self.apps.clear();
self.persist();
self.status = Status::Info(format!("forgot profile {}", removed.name));
self.connect_selected();
}
fn persist(&mut self) {
if let Err(err) = self.store.save(&self.profile_path) {
self.status = Status::Error(format!("{err:#}"));
}
}
fn connect_selected(&mut self) {
if let Some(profile) = self.store.current() {
self.draft = profile.clone();
self.backend
.send(UiCommand::Connect(Box::new(profile.clone())));
}
}
fn drain_events(&mut self) {
while let Ok(event) = self.events.try_recv() {
match event {
BackendEvent::Apps(apps) => {
self.status = Status::Info(format!("{} application(s)", apps.len()));
self.apps = apps;
}
BackendEvent::JobStarted(view) => {
self.job = Some(JobPane {
title: format!("{} · {}", view.kind.label(), view.kind.host()),
lines: view.log.iter().map(|line| line.text.clone()).collect(),
outcome: None,
});
}
BackendEvent::JobLine(line) => {
if let Some(job) = self.job.as_mut() {
job.lines.push(line.text);
}
}
BackendEvent::JobFinished(outcome) => {
if let Some(job) = self.job.as_mut() {
job.outcome = Some(outcome);
}
}
BackendEvent::Failed(message) => self.status = Status::Error(message),
}
}
}
fn apply(&mut self, action: RowAction) {
match action {
RowAction::Start(host) => {
self.backend.send(UiCommand::Lifecycle {
host,
op: Lifecycle::Start,
});
}
RowAction::Stop(host) => {
self.backend.send(UiCommand::Lifecycle {
host,
op: Lifecycle::Stop,
});
}
RowAction::OpenUpdate(host, image) => {
self.dialog = Some(UpdateDialog {
host,
image,
auto_update: AutoUpdateChoice::default(),
error: None,
closed: false,
});
}
}
}
}
impl eframe::App for TwiceApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
self.drain_events();
if let Some(index) = self.pending_selection.take() {
self.store.selected = index;
self.persist();
self.connect_selected();
}
if self.store.current().is_some() && self.last_refresh.elapsed() >= AUTO_REFRESH {
self.last_refresh = Instant::now();
self.backend.send(UiCommand::RefreshApps);
}
egui::Panel::top("toolbar").show(ui, |ui| views::toolbar(self, ui));
if self.editing_servers {
egui::Panel::top("servers").show(ui, |ui| views::server_editor(self, ui));
}
if self.editing_infrastructure {
let command = egui::Panel::top("infrastructure")
.show(ui, |ui| {
views::infrastructure_editor(&mut self.infrastructure, ui)
})
.inner;
if let Some(command) = command {
self.backend.send(command);
}
}
if let Some(job) = self.job.clone() {
egui::Panel::bottom("job")
.resizable(true)
.show(ui, |ui| views::job_pane(&job, ui));
}
let action = egui::CentralPanel::default()
.show(ui, |ui| {
egui::ScrollArea::both()
.show(ui, |ui| views::apps_table(&self.apps, ui))
.inner
})
.inner;
if let Some(action) = action {
self.apply(action);
}
if let Some(dialog) = self.dialog.as_mut() {
let command = views::update_dialog(dialog, &ctx);
let closed = dialog.closed;
if let Some(command) = command {
self.backend.send(command);
self.dialog = None;
} else if closed {
self.dialog = None;
}
}
ctx.request_repaint_after(Duration::from_millis(500));
}
}