Skip to content

Commit 9c4cdd4

Browse files
datlechinclaude
andcommitted
feat(linux): connection health pill in header bar
EntryInner gains a ConnectionHealth field (Healthy or Reconnecting{attempt}). The monitor task writes Reconnecting at the start of the backoff loop and on every retry, then back to Healthy after a successful reconnect. DatabaseService.active_health() exposes the active connection's state. App polls every 1s via glib::timeout_add_seconds_local and updates a label in the header bar — green "Connected" pill when Healthy, yellow "Reconnecting (attempt N)" pill while in the retry loop, hidden when no active connection. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 088ac5a commit 9c4cdd4

3 files changed

Lines changed: 74 additions & 1 deletion

File tree

linux/crates/app/src/services/connection_monitor.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use tablepro_core::Connection;
77
use tablepro_ssh::SshTunnel;
88

99
use super::connection_service;
10-
use super::database_service::{EntryInner, ReconnectParams};
10+
use super::database_service::{ConnectionHealth, EntryInner, ReconnectParams};
1111

1212
const PING_INTERVAL: Duration = Duration::from_secs(30);
1313
const BACKOFF_INITIAL: Duration = Duration::from_secs(5);
@@ -45,6 +45,7 @@ async fn reconnect_loop(
4545
) -> Result<(), ()> {
4646
let mut delay = BACKOFF_INITIAL;
4747
let mut attempt: u32 = 1;
48+
set_health(inner, ConnectionHealth::Reconnecting { attempt });
4849
loop {
4950
tokio::select! {
5051
_ = cancel.cancelled() => return Err(()),
@@ -54,12 +55,14 @@ async fn reconnect_loop(
5455
match try_reconnect(params).await {
5556
Ok((conn, tunnel)) => {
5657
swap_connection(inner, conn, tunnel);
58+
set_health(inner, ConnectionHealth::Healthy);
5759
tracing::info!(attempt, "reconnect succeeded");
5860
return Ok(());
5961
}
6062
Err(e) => {
6163
tracing::warn!(error = %e, attempt, delay_secs = delay.as_secs(), "reconnect failed; backing off");
6264
attempt += 1;
65+
set_health(inner, ConnectionHealth::Reconnecting { attempt });
6366
delay = next_delay(delay);
6467
}
6568
}
@@ -88,6 +91,12 @@ fn swap_connection(inner: &Arc<Mutex<EntryInner>>, conn: Box<dyn Connection>, tu
8891
}
8992
}
9093

94+
fn set_health(inner: &Arc<Mutex<EntryInner>>, health: ConnectionHealth) {
95+
if let Ok(mut g) = inner.lock() {
96+
g.health = health;
97+
}
98+
}
99+
91100
#[cfg(test)]
92101
mod tests {
93102
use super::*;

linux/crates/app/src/services/database_service.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ pub fn instance() -> &'static DatabaseService {
1818
pub(super) struct EntryInner {
1919
pub(super) connection: Arc<dyn Connection>,
2020
pub(super) tunnel: Option<SshTunnel>,
21+
pub(super) health: ConnectionHealth,
22+
}
23+
24+
#[derive(Debug, Clone, PartialEq, Eq)]
25+
pub enum ConnectionHealth {
26+
Healthy,
27+
Reconnecting { attempt: u32 },
2128
}
2229

2330
pub struct ReconnectParams {
@@ -59,6 +66,7 @@ impl DatabaseService {
5966
let inner = Arc::new(Mutex::new(EntryInner {
6067
connection: arc,
6168
tunnel,
69+
health: ConnectionHealth::Healthy,
6270
}));
6371
let cancel = CancellationToken::new();
6472
let monitor = tokio::spawn(connection_monitor::run(inner.clone(), params, cancel.clone()));
@@ -91,6 +99,14 @@ impl DatabaseService {
9199
*self.active.lock().expect("database_service lock")
92100
}
93101

102+
pub fn active_health(&self) -> Option<ConnectionHealth> {
103+
let id = self.active_id()?;
104+
let entries = self.connections.lock().expect("database_service lock");
105+
let entry = entries.get(&id)?;
106+
let inner = entry.inner.lock().expect("entry inner lock");
107+
Some(inner.health.clone())
108+
}
109+
94110
pub fn is_active_read_only(&self) -> bool {
95111
let id = match self.active_id() {
96112
Some(id) => id,

linux/crates/app/src/ui/app.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use super::edit_dialog::{EditDialog, EditDialogInit, EditDialogOutput};
1313
use super::editor::SqlEditor;
1414
use super::grid::build_column_view;
1515
use super::insert_dialog::{InsertDialog, InsertDialogInit, InsertDialogOutput};
16+
use crate::services::database_service::ConnectionHealth;
1617
use crate::services::{connection_service, database_service};
1718
use crate::sql_dialect::{placeholder_for, quote_ident};
1819

@@ -27,6 +28,8 @@ pub struct App {
2728
connections_popover: gtk::Popover,
2829
edit_button: gtk::Button,
2930
disconnect_button: gtk::Button,
31+
health_pill: gtk::Label,
32+
health_state: Option<ConnectionHealth>,
3033
table_search: gtk::SearchEntry,
3134
paginator_label: gtk::Label,
3235
prev_button: gtk::Button,
@@ -81,6 +84,7 @@ pub enum AppMsg {
8184
DeleteConnection(Uuid),
8285
OpenEditor,
8386
Disconnect,
87+
PollHealth,
8488
}
8589

8690
#[relm4::component(pub)]
@@ -115,6 +119,12 @@ impl SimpleComponent for App {
115119
set_popover = &gtk::Popover {},
116120
},
117121

122+
#[name = "health_pill"]
123+
pack_end = &gtk::Label {
124+
set_visible: false,
125+
set_margin_end: 6,
126+
},
127+
118128
#[name = "edit_button"]
119129
pack_end = &gtk::Button {
120130
set_icon_name: "edit-symbolic",
@@ -311,6 +321,8 @@ impl SimpleComponent for App {
311321
connections_popover: widgets.connections_popover.clone(),
312322
edit_button: widgets.edit_button.clone(),
313323
disconnect_button: widgets.disconnect_button.clone(),
324+
health_pill: widgets.health_pill.clone(),
325+
health_state: None,
314326
table_search: widgets.table_search.clone(),
315327
paginator_label,
316328
prev_button,
@@ -333,6 +345,13 @@ impl SimpleComponent for App {
333345
connected: false,
334346
};
335347
sender.input(AppMsg::ReloadConnections);
348+
349+
let poll_sender = sender.clone();
350+
glib::timeout_add_seconds_local(1, move || {
351+
poll_sender.input(AppMsg::PollHealth);
352+
glib::ControlFlow::Continue
353+
});
354+
336355
ComponentParts { model, widgets }
337356
}
338357

@@ -672,6 +691,14 @@ impl SimpleComponent for App {
672691
self.editor = Some(editor);
673692
}
674693

694+
AppMsg::PollHealth => {
695+
let current = database_service::instance().active_health();
696+
if current != self.health_state {
697+
self.refresh_health_pill(current.clone());
698+
self.health_state = current;
699+
}
700+
}
701+
675702
AppMsg::DeleteConnection(id) => {
676703
let sender_clone = sender.clone();
677704
sender.command(move |_, shutdown| {
@@ -754,6 +781,27 @@ impl App {
754781
});
755782
}
756783

784+
fn refresh_health_pill(&self, health: Option<ConnectionHealth>) {
785+
let pill = &self.health_pill;
786+
pill.remove_css_class("success");
787+
pill.remove_css_class("warning");
788+
match health {
789+
None => {
790+
pill.set_visible(false);
791+
}
792+
Some(ConnectionHealth::Healthy) => {
793+
pill.set_visible(true);
794+
pill.set_label("Connected");
795+
pill.add_css_class("success");
796+
}
797+
Some(ConnectionHealth::Reconnecting { attempt }) => {
798+
pill.set_visible(true);
799+
pill.set_label(&format!("Reconnecting (attempt {attempt})"));
800+
pill.add_css_class("warning");
801+
}
802+
}
803+
}
804+
757805
fn refresh_crud_buttons(&self) {
758806
let read_only = database_service::instance().is_active_read_only();
759807
self.insert_button.set_visible(!read_only);

0 commit comments

Comments
 (0)