generated from eblume/project-template
feat(heph-tui,hephd): surface sync health (last-sync age, conflicts, auth failure)
All checks were successful
Build / validate (pull_request) Successful in 6m11s
All checks were successful
Build / validate (pull_request) Successful in 6m11s
A spoke could be silently failing to sync (expired token → 401, or hub unreachable) with the only signal buried in the daemon log. Now: - hephd tracks SyncHealth (last attempt/success time, last error, auth-failure flag) from the background sync loop and sync.now, classifying a 401 as an auth failure. sync.status returns it plus the pending merge-conflict count. - heph-tui shows a live status-line indicator (spoke only): '⟳ <age>' since the last good sync, red '⚠ auth' when re-login is needed, '⚠ offline' when the hub is unreachable, and '⚠ N conflicts' when conflicts are pending. The event loop polls on a 2s tick so the age advances and failures appear while idle. - docs: recommended Authentik access/refresh token validity to stop frequent re-logins (with the iOS PWA localStorage-eviction caveat). Closes the 'Add hub connection status to heph-tui' and 'Spoke sync health: surface unhealthy state instead of silent 401 spam' backlog items. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4bf255b211
commit
11aa25c9f4
11 changed files with 364 additions and 16 deletions
|
|
@ -9,7 +9,7 @@ use anyhow::Result;
|
|||
use chrono::NaiveDate;
|
||||
use heph_core::{Attention, ProjectOverview, RankedTask, SchedulePatch, TaskState, BUILTIN_VIEWS};
|
||||
|
||||
use crate::backend::{Backend, Project, SearchHit};
|
||||
use crate::backend::{Backend, Project, SearchHit, SyncStatus};
|
||||
use crate::fmt::{days_overdue, today_local};
|
||||
|
||||
/// How the task list is ordered (toggled in the UI, §8.1).
|
||||
|
|
@ -433,6 +433,8 @@ pub struct App<B: Backend> {
|
|||
undo_stack: Vec<UndoEntry>,
|
||||
redo_stack: Vec<UndoEntry>,
|
||||
pub status: String,
|
||||
/// Latest sync health for the status-line indicator (refreshed on a tick).
|
||||
pub sync: SyncStatus,
|
||||
pub should_quit: bool,
|
||||
}
|
||||
|
||||
|
|
@ -472,12 +474,23 @@ impl<B: Backend> App<B> {
|
|||
undo_stack: Vec::new(),
|
||||
redo_stack: Vec::new(),
|
||||
status: String::new(),
|
||||
sync: SyncStatus::default(),
|
||||
should_quit: false,
|
||||
};
|
||||
app.reload();
|
||||
app.refresh_sync();
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
/// Refresh the sync-health snapshot for the status line. Best-effort: a
|
||||
/// failed read leaves the previous snapshot in place (a stale indicator
|
||||
/// beats a flicker), so this never disrupts navigation.
|
||||
pub fn refresh_sync(&mut self) {
|
||||
if let Ok(status) = self.backend.sync_status() {
|
||||
self.sync = status;
|
||||
}
|
||||
}
|
||||
|
||||
/// The title shown above the task list (the selected source).
|
||||
pub fn task_pane_title(&self) -> String {
|
||||
match self.sidebar.get(self.sidebar_cursor) {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,35 @@ pub struct SearchHit {
|
|||
pub kind: String,
|
||||
}
|
||||
|
||||
/// Sync health for the status line (the `sync.status` RPC). On a standalone
|
||||
/// instance `hub_url` is `None` and `health` is absent; the conflict count is
|
||||
/// always present.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize)]
|
||||
pub struct SyncStatus {
|
||||
/// The hub this device syncs with, or `None` if standalone (no indicator).
|
||||
pub hub_url: Option<String>,
|
||||
/// Pending merge conflicts awaiting resolution.
|
||||
#[serde(default)]
|
||||
pub conflicts: usize,
|
||||
/// Observed health of the background sync loop (spoke only).
|
||||
#[serde(default)]
|
||||
pub health: Option<SyncHealth>,
|
||||
}
|
||||
|
||||
/// The spoke's observed sync health (mirrors `hephd`'s `SyncHealth`).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize)]
|
||||
pub struct SyncHealth {
|
||||
/// Epoch ms of the last successful exchange ("last synced"), if any.
|
||||
pub last_success_ms: Option<i64>,
|
||||
/// Epoch ms of the last attempt (success or failure), if any.
|
||||
pub last_attempt_ms: Option<i64>,
|
||||
/// The last error message, cleared on the next success.
|
||||
pub last_error: Option<String>,
|
||||
/// Whether the most recent attempt failed authentication (needs re-login).
|
||||
#[serde(default)]
|
||||
pub auth_failure: bool,
|
||||
}
|
||||
|
||||
/// Everything the agenda surface asks of the daemon.
|
||||
pub trait Backend {
|
||||
/// All project nodes (for the sidebar), title-sorted.
|
||||
|
|
@ -56,6 +85,11 @@ pub trait Backend {
|
|||
/// A task's canonical-context doc id (where its description/checklist live),
|
||||
/// for opening a task search-hit at the useful node. `None` if it has none.
|
||||
fn context_of(&mut self, task_id: &str) -> Result<Option<String>>;
|
||||
/// Sync health for the status line. The default is a standalone instance
|
||||
/// (no hub, no conflicts); the real backend forwards `sync.status`.
|
||||
fn sync_status(&mut self) -> Result<SyncStatus> {
|
||||
Ok(SyncStatus::default())
|
||||
}
|
||||
|
||||
// --- triage mutations (T2) ---
|
||||
|
||||
|
|
@ -170,6 +204,11 @@ impl Backend for ClientBackend {
|
|||
.map(|l| l.dst_id))
|
||||
}
|
||||
|
||||
fn sync_status(&mut self) -> Result<SyncStatus> {
|
||||
let v = self.call("sync.status", json!({}))?;
|
||||
Ok(serde_json::from_value(v)?)
|
||||
}
|
||||
|
||||
fn set_state(&mut self, task_id: &str, state: &str) -> Result<()> {
|
||||
self.call("task.set_state", json!({ "id": task_id, "state": state }))?;
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -25,6 +25,27 @@ pub fn today_local() -> NaiveDate {
|
|||
Local::now().date_naive()
|
||||
}
|
||||
|
||||
/// Now, in epoch milliseconds (the reference for [`fmt_age`]).
|
||||
pub fn now_ms() -> i64 {
|
||||
Local::now().timestamp_millis()
|
||||
}
|
||||
|
||||
/// A compact "how long ago" for the sync indicator: `just now` under a minute,
|
||||
/// then `Nm` / `Nh` / `Nd`. Clamped at zero so a little clock skew never shows a
|
||||
/// negative age.
|
||||
pub fn fmt_age(now_ms: i64, then_ms: i64) -> String {
|
||||
let secs = (now_ms - then_ms).max(0) / 1000;
|
||||
if secs < 60 {
|
||||
"just now".into()
|
||||
} else if secs < 3_600 {
|
||||
format!("{}m", secs / 60)
|
||||
} else if secs < 86_400 {
|
||||
format!("{}h", secs / 3_600)
|
||||
} else {
|
||||
format!("{}d", secs / 86_400)
|
||||
}
|
||||
}
|
||||
|
||||
/// How many days past its do-date a task is (0 if not overdue, no do-date, or
|
||||
/// future-dated). The "how overdue" signal the agenda sort ranks on (§8.1).
|
||||
pub fn days_overdue(do_date: Option<i64>, today: NaiveDate) -> i64 {
|
||||
|
|
@ -102,6 +123,18 @@ mod tests {
|
|||
assert_eq!(fmt_date(ms(day(2027, 1, 1)), today), "2027-01-01");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn age_is_compact_and_clamped() {
|
||||
let now = 1_000_000_000_000;
|
||||
assert_eq!(fmt_age(now, now), "just now");
|
||||
assert_eq!(fmt_age(now, now - 30_000), "just now");
|
||||
assert_eq!(fmt_age(now, now - 5 * 60_000), "5m");
|
||||
assert_eq!(fmt_age(now, now - 3 * 3_600_000), "3h");
|
||||
assert_eq!(fmt_age(now, now - 2 * 86_400_000), "2d");
|
||||
// Clock skew (then in the future) never shows a negative age.
|
||||
assert_eq!(fmt_age(now, now + 10_000), "just now");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_color_is_stable_distinct_and_neutral_when_absent() {
|
||||
assert_eq!(project_color(None), Color::DarkGray);
|
||||
|
|
|
|||
|
|
@ -61,8 +61,12 @@ fn run<B: heph_tui::Backend>(
|
|||
mut app: App<B>,
|
||||
socket: &std::path::Path,
|
||||
) -> Result<()> {
|
||||
// Poll with a timeout so the sync indicator's age advances and a sync
|
||||
// failure surfaces within a couple of seconds even while the user is idle.
|
||||
let tick = std::time::Duration::from_secs(2);
|
||||
loop {
|
||||
terminal.draw(|f| ui::render(f, &app))?;
|
||||
if event::poll(tick)? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if key.kind == KeyEventKind::Press {
|
||||
if let Some(action) = handle_key(&mut app, key) {
|
||||
|
|
@ -70,6 +74,10 @@ fn run<B: heph_tui::Backend>(
|
|||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Idle tick: refresh the sync-health snapshot for the status line.
|
||||
app.refresh_sync();
|
||||
}
|
||||
if app.should_quit {
|
||||
return Ok(());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
use heph_core::Attention;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Margin, Rect},
|
||||
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{
|
||||
|
|
@ -14,8 +14,8 @@ use ratatui::{
|
|||
};
|
||||
|
||||
use crate::app::{App, Focus, InputState, Mode, MoveOption, MoveState, SidebarEntry, SortMode};
|
||||
use crate::backend::Backend;
|
||||
use crate::fmt::{fmt_date, project_color, today_local};
|
||||
use crate::backend::{Backend, SyncStatus};
|
||||
use crate::fmt::{fmt_age, fmt_date, now_ms, project_color, today_local};
|
||||
|
||||
// Task-pane gestures (the focused pane shows its own hints, §8.1).
|
||||
const HINTS: &str =
|
||||
|
|
@ -538,5 +538,133 @@ fn render_status<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
|||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
frame.render_widget(Paragraph::new(Line::from(Span::styled(text, style))), area);
|
||||
let left = Paragraph::new(Line::from(Span::styled(text, style)));
|
||||
|
||||
// A right-aligned sync indicator (spoke only); the hints take the rest.
|
||||
let indicator = sync_indicator(&app.sync, now_ms());
|
||||
if indicator.is_empty() {
|
||||
frame.render_widget(left, area);
|
||||
return;
|
||||
}
|
||||
let ind_w: usize = indicator.iter().map(|s| s.content.chars().count()).sum();
|
||||
let cols =
|
||||
Layout::horizontal([Constraint::Min(1), Constraint::Length(ind_w as u16 + 1)]).split(area);
|
||||
frame.render_widget(left, cols[0]);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(indicator)).alignment(Alignment::Right),
|
||||
cols[1],
|
||||
);
|
||||
}
|
||||
|
||||
/// The status-line sync indicator (empty on a standalone instance): a sync-state
|
||||
/// chip — `⚠ auth` when re-login is needed, `⟳ <age>` since the last successful
|
||||
/// sync, `⚠ offline` when erroring, `⟳ …` before the first sync — plus a
|
||||
/// conflict chip when any merge conflicts are pending.
|
||||
fn sync_indicator(sync: &SyncStatus, now: i64) -> Vec<Span<'static>> {
|
||||
if sync.hub_url.is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let red = Style::default().fg(Color::Red).add_modifier(Modifier::BOLD);
|
||||
let yellow = Style::default().fg(Color::Yellow);
|
||||
|
||||
let health = sync.health.clone().unwrap_or_default();
|
||||
let mut spans = vec![if health.auth_failure {
|
||||
Span::styled("⚠ auth", red)
|
||||
} else if let Some(ts) = health.last_success_ms {
|
||||
Span::styled(format!("⟳ {}", fmt_age(now, ts)), dim)
|
||||
} else if health.last_error.is_some() {
|
||||
Span::styled("⚠ offline", yellow)
|
||||
} else {
|
||||
Span::styled("⟳ …", dim)
|
||||
}];
|
||||
|
||||
if sync.conflicts > 0 {
|
||||
let label = if sync.conflicts == 1 {
|
||||
"1 conflict".to_string()
|
||||
} else {
|
||||
format!("{} conflicts", sync.conflicts)
|
||||
};
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(format!("⚠ {label}"), red));
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::backend::SyncHealth;
|
||||
|
||||
fn render(sync: &SyncStatus, now: i64) -> String {
|
||||
sync_indicator(sync, now)
|
||||
.iter()
|
||||
.map(|s| s.content.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
const NOW: i64 = 1_000_000_000_000;
|
||||
|
||||
fn spoke(health: SyncHealth, conflicts: usize) -> SyncStatus {
|
||||
SyncStatus {
|
||||
hub_url: Some("http://hub:8787".into()),
|
||||
conflicts,
|
||||
health: Some(health),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_shows_no_indicator() {
|
||||
assert!(sync_indicator(&SyncStatus::default(), NOW).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indicator_reflects_each_sync_state() {
|
||||
// Recently synced → a dim age chip.
|
||||
let ok = spoke(
|
||||
SyncHealth {
|
||||
last_success_ms: Some(NOW - 5 * 60_000),
|
||||
..Default::default()
|
||||
},
|
||||
0,
|
||||
);
|
||||
assert_eq!(render(&ok, NOW), "⟳ 5m");
|
||||
|
||||
// Auth failure wins over age (it's the actionable state).
|
||||
let auth = spoke(
|
||||
SyncHealth {
|
||||
last_success_ms: Some(NOW - 60_000),
|
||||
auth_failure: true,
|
||||
..Default::default()
|
||||
},
|
||||
0,
|
||||
);
|
||||
assert_eq!(render(&auth, NOW), "⚠ auth");
|
||||
|
||||
// Errored with no prior success → offline.
|
||||
let offline = spoke(
|
||||
SyncHealth {
|
||||
last_error: Some("error sending request".into()),
|
||||
..Default::default()
|
||||
},
|
||||
0,
|
||||
);
|
||||
assert_eq!(render(&offline, NOW), "⚠ offline");
|
||||
|
||||
// Before the first sync.
|
||||
assert_eq!(render(&spoke(SyncHealth::default(), 0), NOW), "⟳ …");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflicts_chip_appends_and_pluralizes() {
|
||||
let h = SyncHealth {
|
||||
last_success_ms: Some(NOW),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
render(&spoke(h.clone(), 1), NOW),
|
||||
"⟳ just now ⚠ 1 conflict"
|
||||
);
|
||||
assert_eq!(render(&spoke(h, 3), NOW), "⟳ just now ⚠ 3 conflicts");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,12 @@ fn agenda_renders_views_projects_and_tasks() {
|
|||
// The red/orange tasks carry a flag glyph in the leading column (§8.1).
|
||||
assert!(s.contains('⚑'), "attention flag glyph missing:\n{s}");
|
||||
assert!(s.contains("Preview"), "preview pane missing:\n{s}");
|
||||
// A standalone daemon (no hub) shows no sync indicator — the `sync.status`
|
||||
// RPC round-trips and reports `hub_url: null`.
|
||||
assert!(
|
||||
!s.contains('⟳'),
|
||||
"sync indicator should be hidden without a hub:\n{s}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -10,9 +10,10 @@
|
|||
//! ops with the configured hub (tech-spec §6.1, §12).
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
|
|
@ -32,6 +33,23 @@ struct SpokeAuth {
|
|||
client_id: String,
|
||||
}
|
||||
|
||||
/// A spoke's observed sync health, updated after every exchange (background loop
|
||||
/// or manual `sync.now`). Surfaced by `sync.status` so clients can show whether
|
||||
/// sync is actually working instead of trusting silence (tech-spec §3.1 / the
|
||||
/// `Spoke sync health` task). All times are epoch ms; `None` means "not yet".
|
||||
#[derive(Clone, Default, Serialize)]
|
||||
struct SyncHealth {
|
||||
/// When we last attempted an exchange.
|
||||
last_attempt_ms: Option<i64>,
|
||||
/// When we last completed one without error (the "last synced" time).
|
||||
last_success_ms: Option<i64>,
|
||||
/// The last error message, cleared on the next success.
|
||||
last_error: Option<String>,
|
||||
/// Whether the most recent attempt failed authentication (a 401) — the
|
||||
/// "re-auth needed" signal, distinct from a transient network blip.
|
||||
auth_failure: bool,
|
||||
}
|
||||
|
||||
/// The shared, cheaply-cloneable context each connection serves from.
|
||||
#[derive(Clone)]
|
||||
struct Ctx {
|
||||
|
|
@ -43,6 +61,41 @@ struct Ctx {
|
|||
auth: Option<SpokeAuth>,
|
||||
/// Opt-in self-update config (`Some` ⇒ enabled, tech-spec self-update card).
|
||||
self_update: Option<SelfUpdateConfig>,
|
||||
/// Live sync health, shared between the background loop and `sync.status`.
|
||||
sync_health: Arc<Mutex<SyncHealth>>,
|
||||
}
|
||||
|
||||
/// Epoch-ms wall clock (the daemon may read it; only `heph-core` is clock-pure).
|
||||
fn now_ms() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// True if `e` carries an HTTP 401 — i.e. the hub rejected our bearer token.
|
||||
fn is_auth_error(e: &anyhow::Error) -> bool {
|
||||
e.downcast_ref::<reqwest::Error>()
|
||||
.and_then(|re| re.status())
|
||||
.is_some_and(|s| s == reqwest::StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
|
||||
/// Fold one exchange outcome into the shared [`SyncHealth`].
|
||||
fn record_sync_outcome(health: &Arc<Mutex<SyncHealth>>, result: &Result<sync::SyncReport>) {
|
||||
let now = now_ms();
|
||||
let mut h = health.lock().expect("sync_health mutex poisoned");
|
||||
h.last_attempt_ms = Some(now);
|
||||
match result {
|
||||
Ok(_) => {
|
||||
h.last_success_ms = Some(now);
|
||||
h.last_error = None;
|
||||
h.auth_failure = false;
|
||||
}
|
||||
Err(e) => {
|
||||
h.auth_failure = is_auth_error(e);
|
||||
h.last_error = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Ctx {
|
||||
|
|
@ -87,6 +140,7 @@ impl Daemon {
|
|||
.expect("building the daemon HTTP client"),
|
||||
auth: None,
|
||||
self_update: None,
|
||||
sync_health: Arc::new(Mutex::new(SyncHealth::default())),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -170,7 +224,10 @@ impl Daemon {
|
|||
loop {
|
||||
tick.tick().await;
|
||||
let bearer = ctx.bearer().await;
|
||||
match sync::sync_once(ctx.store.clone(), &hub, &ctx.http, bearer.as_deref()).await {
|
||||
let result =
|
||||
sync::sync_once(ctx.store.clone(), &hub, &ctx.http, bearer.as_deref()).await;
|
||||
record_sync_outcome(&ctx.sync_health, &result);
|
||||
match result {
|
||||
Ok(report) => tracing::debug!(?report, "background sync"),
|
||||
Err(e) => tracing::warn!("background sync failed: {e}"),
|
||||
}
|
||||
|
|
@ -265,7 +322,9 @@ async fn sync_now(ctx: &Ctx) -> Result<Value, RpcError> {
|
|||
});
|
||||
};
|
||||
let bearer = ctx.bearer().await;
|
||||
match sync::sync_once(ctx.store.clone(), &hub_url, &ctx.http, bearer.as_deref()).await {
|
||||
let result = sync::sync_once(ctx.store.clone(), &hub_url, &ctx.http, bearer.as_deref()).await;
|
||||
record_sync_outcome(&ctx.sync_health, &result);
|
||||
match result {
|
||||
Ok(report) => Ok(json!(report)),
|
||||
Err(e) => Err(RpcError {
|
||||
code: INTERNAL_ERROR,
|
||||
|
|
@ -274,11 +333,28 @@ async fn sync_now(ctx: &Ctx) -> Result<Value, RpcError> {
|
|||
}
|
||||
}
|
||||
|
||||
/// `sync.status` — the hub url and the current per-hub cursors.
|
||||
/// `sync.status` — the hub url, the current per-hub cursors, the observed sync
|
||||
/// health (last-success time / last error / auth-failure flag), and the pending
|
||||
/// merge-conflict count. A spoke that is silently failing is visible here (and,
|
||||
/// via it, in the TUI status line).
|
||||
async fn sync_status(ctx: &Ctx) -> Result<Value, RpcError> {
|
||||
// Conflict count is meaningful even on a hub / standalone instance.
|
||||
let store = ctx.store.clone();
|
||||
let conflicts = tokio::task::spawn_blocking(move || {
|
||||
let guard = store.lock().expect("store mutex poisoned");
|
||||
guard.conflicts_list().map(|c| c.len())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| RpcError {
|
||||
code: INTERNAL_ERROR,
|
||||
message: format!("sync.status task failed: {e}"),
|
||||
})?
|
||||
.map_err(RpcError::from)?;
|
||||
|
||||
let Some(hub_url) = ctx.hub_url.clone() else {
|
||||
return Ok(json!({ "hub_url": Value::Null }));
|
||||
return Ok(json!({ "hub_url": Value::Null, "conflicts": conflicts }));
|
||||
};
|
||||
|
||||
let store = ctx.store.clone();
|
||||
let hub = hub_url.clone();
|
||||
let cursors = tokio::task::spawn_blocking(move || {
|
||||
|
|
@ -291,5 +367,17 @@ async fn sync_status(ctx: &Ctx) -> Result<Value, RpcError> {
|
|||
message: format!("sync.status task failed: {e}"),
|
||||
})?
|
||||
.map_err(RpcError::from)?;
|
||||
Ok(json!({ "hub_url": hub_url, "cursors": cursors }))
|
||||
|
||||
let health = ctx
|
||||
.sync_health
|
||||
.lock()
|
||||
.expect("sync_health mutex poisoned")
|
||||
.clone();
|
||||
|
||||
Ok(json!({
|
||||
"hub_url": hub_url,
|
||||
"cursors": cursors,
|
||||
"conflicts": conflicts,
|
||||
"health": health,
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
1
docs/changelog.d/tui-sync-health.doc.md
Normal file
1
docs/changelog.d/tui-sync-health.doc.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
[[set-up-sync-hub]] now documents recommended Authentik token-validity settings (access + refresh token lifetime) to avoid frequent re-logins, with an iOS PWA storage-eviction caveat; [[host-heph-pwa]] points the PWA's login note at it.
|
||||
1
docs/changelog.d/tui-sync-health.feature.md
Normal file
1
docs/changelog.d/tui-sync-health.feature.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
heph-tui's status line now shows a live sync indicator for spokes: how long since the last successful sync (`⟳ 5m`), a red `⚠ auth` when the hub is rejecting the token (re-login needed), `⚠ offline` when the hub is unreachable, and a `⚠ N conflicts` chip when merge conflicts are pending. The daemon tracks this health and exposes it via `sync.status` (also visible in `heph sync --status`), so a silently-broken spoke is obvious at a glance instead of buried in the log.
|
||||
|
|
@ -102,6 +102,11 @@ app defaults its hub URL to its own origin.
|
|||
(A manual **Bearer token** field remains as a fallback for hubs without
|
||||
OIDC, or for pasting a one-off token.)
|
||||
|
||||
> Re-prompted for login too often? The fix is the Authentik provider's
|
||||
> **refresh token validity**, not the app — see the token-lifetime note in
|
||||
> [[set-up-sync-hub]]. (On iOS, Safari may also purge an un-installed PWA's
|
||||
> storage after ~7 idle days; Add to Home Screen mitigates it.)
|
||||
|
||||
**Prerequisite — register the PWA redirect URI.** Browser PKCE needs the app's
|
||||
origin registered on the Authentik `heph` provider's **Redirect URIs** (Authentik
|
||||
also keys token-endpoint CORS off those origins). Add the PWA origin(s) with a
|
||||
|
|
|
|||
|
|
@ -51,6 +51,26 @@ need:
|
|||
- **Issuer** — e.g. `https://authentik.ops.eblu.me/application/o/heph/`
|
||||
- **Client id** — the device-code client id (this is also the token *audience*).
|
||||
|
||||
### Token lifetime (avoid frequent re-logins)
|
||||
|
||||
Token lifetimes are set on the Authentik **provider**, not in heph — heph honors
|
||||
whatever `expires_in` Authentik returns and silently refreshes using the
|
||||
`offline_access` refresh token (both the CLI/daemon and the PWA do this). To
|
||||
avoid re-authenticating often, set generous validities on the heph provider:
|
||||
|
||||
- **Access token validity** — e.g. `hours=24`. The hub validates `exp` and keeps
|
||||
no revocation list, so this is the window in which a leaked token stays usable;
|
||||
on a Tailscale-only hub, 24–48h is a reasonable trade.
|
||||
- **Refresh token validity** — e.g. `days=30`+. This is the setting that stops
|
||||
the re-logins: while the refresh token is valid, the spoke **and** the PWA
|
||||
renew silently with no browser round-trip. A short refresh window is the usual
|
||||
cause of "I have to log in constantly".
|
||||
|
||||
> **iOS PWA caveat:** Safari can purge an *un-installed* PWA's `localStorage`
|
||||
> (where its tokens live) after ~7 idle days regardless of these settings.
|
||||
> Installing the app to the home screen mitigates it, but expect the occasional
|
||||
> re-login on iOS.
|
||||
|
||||
## 2. Bring up the hub on `indri`
|
||||
|
||||
**Seed it from `gilbert` (Path A).** Quiesce `gilbert` (`heph daemon stop`),
|
||||
|
|
@ -98,10 +118,16 @@ and background-syncs on its interval.
|
|||
## 4. Verify
|
||||
|
||||
```bash
|
||||
heph sync --status # last push/pull cursors, hub url
|
||||
heph sync --status # hub url, last push/pull cursors, sync health
|
||||
heph sync # force a cycle now
|
||||
```
|
||||
|
||||
`heph sync --status` also reports **sync health** — the time of the last
|
||||
successful exchange, any last error, and whether the spoke is currently failing
|
||||
to authenticate. The same signal is surfaced live in `heph-tui`'s status line
|
||||
(last-sync age · pending conflicts · an auth-failure flag), so a silently-broken
|
||||
spoke is visible at a glance rather than buried in the daemon log.
|
||||
|
||||
Make a change on `gilbert`, force a sync, and confirm it appears via the hub.
|
||||
|
||||
## Current gaps (finalized by the blumeops deployment)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue