feat: connectivity view: quota for all transports (#7630)

- **show quota of all relays**
- **remove `DC_STR_STORAGE_ON_DOMAIN` stock string**
- renames the quota section to "Relay Capacity" until we come up with a
better name in
https://github.com/chatmail/core/issues/7580#issuecomment-3633803432

closes #7591

<img width="300" alt="image"
src="https://github.com/user-attachments/assets/1909dccd-e6b3-42e6-963f-004b2b464db7"
/> <img width="300" alt="image"
src="https://github.com/user-attachments/assets/1e97e67b-e0ed-492b-95a0-6ef12595abe4"
/>
This commit is contained in:
Simon Laux
2025-12-24 13:19:58 +01:00
committed by GitHub
parent 294e23d82d
commit 9ac64ea6b9
8 changed files with 82 additions and 63 deletions

View File

@@ -7314,12 +7314,6 @@ void dc_event_unref(dc_event_t* event);
/// Used as a headline in the connectivity view. /// Used as a headline in the connectivity view.
#define DC_STR_OUTGOING_MESSAGES 104 #define DC_STR_OUTGOING_MESSAGES 104
/// "Storage on %1$s"
///
/// Used as a headline in the connectivity view.
///
/// `%1$s` will be replaced by the domain of the configured e-mail address.
#define DC_STR_STORAGE_ON_DOMAIN 105
/// @deprecated Deprecated 2022-04-16, this string is no longer needed. /// @deprecated Deprecated 2022-04-16, this string is no longer needed.
#define DC_STR_ONE_MOMENT 106 #define DC_STR_ONE_MOMENT 106

View File

@@ -944,7 +944,7 @@ impl Context {
/// This should only be used by test code and during configure. /// This should only be used by test code and during configure.
#[cfg(test)] // AEAP is disabled, but there are still tests for it #[cfg(test)] // AEAP is disabled, but there are still tests for it
pub(crate) async fn set_primary_self_addr(&self, primary_new: &str) -> Result<()> { pub(crate) async fn set_primary_self_addr(&self, primary_new: &str) -> Result<()> {
self.quota.write().await.take(); self.quota.write().await.clear();
self.sql self.sql
.set_raw_config(Config::ConfiguredAddr.as_ref(), Some(primary_new)) .set_raw_config(Config::ConfiguredAddr.as_ref(), Some(primary_new))

View File

@@ -210,7 +210,8 @@ impl Context {
/// (i.e. [EnteredLoginParam::addr]). /// (i.e. [EnteredLoginParam::addr]).
pub async fn delete_transport(&self, addr: &str) -> Result<()> { pub async fn delete_transport(&self, addr: &str) -> Result<()> {
let now = time(); let now = time();
self.sql let removed_transport_id = self
.sql
.transaction(|transaction| { .transaction(|transaction| {
let primary_addr = transaction.query_row( let primary_addr = transaction.query_row(
"SELECT value FROM config WHERE keyname='configured_addr'", "SELECT value FROM config WHERE keyname='configured_addr'",
@@ -251,10 +252,11 @@ impl Context {
(addr, remove_timestamp), (addr, remove_timestamp),
)?; )?;
Ok(()) Ok(transport_id)
}) })
.await?; .await?;
send_sync_transports(self).await?; send_sync_transports(self).await?;
self.quota.write().await.remove(&removed_transport_id);
Ok(()) Ok(())
} }

View File

@@ -243,9 +243,9 @@ pub struct InnerContext {
pub(crate) scheduler: SchedulerState, pub(crate) scheduler: SchedulerState,
pub(crate) ratelimit: RwLock<Ratelimit>, pub(crate) ratelimit: RwLock<Ratelimit>,
/// Recently loaded quota information, if any. /// Recently loaded quota information for each trasnport, if any.
/// Set to `None` if quota was never tried to load. /// If quota was never tried to load, then the transport doesn't have an entry in the BTreeMap.
pub(crate) quota: RwLock<Option<QuotaInfo>>, pub(crate) quota: RwLock<BTreeMap<u32, QuotaInfo>>,
/// Notify about new messages. /// Notify about new messages.
/// ///
@@ -479,7 +479,7 @@ impl Context {
events, events,
scheduler: SchedulerState::new(), scheduler: SchedulerState::new(),
ratelimit: RwLock::new(Ratelimit::new(Duration::new(3, 0), 3.0)), // Allow at least 1 message every second + a burst of 3. ratelimit: RwLock::new(Ratelimit::new(Duration::new(3, 0), 3.0)), // Allow at least 1 message every second + a burst of 3.
quota: RwLock::new(None), quota: RwLock::new(BTreeMap::new()),
new_msgs_notify, new_msgs_notify,
server_id: RwLock::new(None), server_id: RwLock::new(None),
metadata: RwLock::new(None), metadata: RwLock::new(None),
@@ -614,8 +614,13 @@ impl Context {
} }
// Update quota (to send warning if full) - but only check it once in a while. // Update quota (to send warning if full) - but only check it once in a while.
// note: For now this only checks quota of primary transport,
// because background check only checks primary transport at the moment
if self if self
.quota_needs_update(DC_BACKGROUND_FETCH_QUOTA_CHECK_RATELIMIT) .quota_needs_update(
session.transport_id(),
DC_BACKGROUND_FETCH_QUOTA_CHECK_RATELIMIT,
)
.await .await
&& let Err(err) = self.update_recent_quota(&mut session).await && let Err(err) = self.update_recent_quota(&mut session).await
{ {

View File

@@ -107,10 +107,10 @@ pub fn needs_quota_warning(curr_percentage: u64, warned_at_percentage: u64) -> b
impl Context { impl Context {
/// Returns whether the quota value needs an update. If so, `update_recent_quota()` should be /// Returns whether the quota value needs an update. If so, `update_recent_quota()` should be
/// called. /// called.
pub(crate) async fn quota_needs_update(&self, ratelimit_secs: u64) -> bool { pub(crate) async fn quota_needs_update(&self, transport_id: u32, ratelimit_secs: u64) -> bool {
let quota = self.quota.read().await; let quota = self.quota.read().await;
quota quota
.as_ref() .get(&transport_id)
.filter(|quota| time_elapsed(&quota.modified) < Duration::from_secs(ratelimit_secs)) .filter(|quota| time_elapsed(&quota.modified) < Duration::from_secs(ratelimit_secs))
.is_none() .is_none()
} }
@@ -155,10 +155,13 @@ impl Context {
} }
} }
*self.quota.write().await = Some(QuotaInfo { self.quota.write().await.insert(
recent: quota, session.transport_id(),
modified: tools::Time::now(), QuotaInfo {
}); recent: quota,
modified: tools::Time::now(),
},
);
self.emit_event(EventType::ConnectivityChanged); self.emit_event(EventType::ConnectivityChanged);
Ok(()) Ok(())
@@ -203,27 +206,42 @@ mod tests {
let mut tcm = TestContextManager::new(); let mut tcm = TestContextManager::new();
let t = &tcm.unconfigured().await; let t = &tcm.unconfigured().await;
const TIMEOUT: u64 = 60; const TIMEOUT: u64 = 60;
assert!(t.quota_needs_update(TIMEOUT).await); assert!(t.quota_needs_update(0, TIMEOUT).await);
*t.quota.write().await = Some(QuotaInfo { *t.quota.write().await = {
recent: Ok(Default::default()), let mut map = BTreeMap::new();
modified: tools::Time::now() - Duration::from_secs(TIMEOUT + 1), map.insert(
}); 0,
assert!(t.quota_needs_update(TIMEOUT).await); QuotaInfo {
recent: Ok(Default::default()),
modified: tools::Time::now() - Duration::from_secs(TIMEOUT + 1),
},
);
map
};
assert!(t.quota_needs_update(0, TIMEOUT).await);
*t.quota.write().await = Some(QuotaInfo { *t.quota.write().await = {
recent: Ok(Default::default()), let mut map = BTreeMap::new();
modified: tools::Time::now(), map.insert(
}); 0,
assert!(!t.quota_needs_update(TIMEOUT).await); QuotaInfo {
recent: Ok(Default::default()),
modified: tools::Time::now(),
},
);
map
};
assert!(!t.quota_needs_update(0, TIMEOUT).await);
t.evtracker.clear_events(); t.evtracker.clear_events();
t.set_primary_self_addr("new@addr").await?; t.set_primary_self_addr("new@addr").await?;
assert!(t.quota.read().await.is_none()); assert!(t.quota.read().await.is_empty());
t.evtracker t.evtracker
.get_matching(|evt| matches!(evt, EventType::ConnectivityChanged)) .get_matching(|evt| matches!(evt, EventType::ConnectivityChanged))
.await; .await;
assert!(t.quota_needs_update(TIMEOUT).await); assert!(t.quota_needs_update(0, TIMEOUT).await);
Ok(()) Ok(())
} }
} }

View File

@@ -481,7 +481,7 @@ async fn inbox_fetch_idle(ctx: &Context, imap: &mut Imap, mut session: Session)
} }
// Update quota no more than once a minute. // Update quota no more than once a minute.
if ctx.quota_needs_update(60).await if ctx.quota_needs_update(session.transport_id(), 60).await
&& let Err(err) = ctx.update_recent_quota(&mut session).await && let Err(err) = ctx.update_recent_quota(&mut session).await
{ {
warn!(ctx, "Failed to update quota: {:#}.", err); warn!(ctx, "Failed to update quota: {:#}.", err);

View File

@@ -462,21 +462,41 @@ impl Context {
// [======67%===== ] // [======67%===== ]
// ============================================================================================= // =============================================================================================
let domain = ret += "<h3>Message Buffers</h3>";
&deltachat_contact_tools::EmailAddress::new(&self.get_primary_self_addr().await?)? let transports = self
.domain; .sql
let storage_on_domain = .query_map_vec("SELECT id, addr FROM transports", (), |row| {
escaper::encode_minimal(&stock_str::storage_on_domain(self, domain).await); let transport_id: u32 = row.get(0)?;
ret += &format!("<h3>{storage_on_domain}</h3><ul>"); let addr: String = row.get(1)?;
Ok((transport_id, addr))
})
.await?;
let quota = self.quota.read().await; let quota = self.quota.read().await;
if let Some(quota) = &*quota { ret += "<ul>";
for (transport_id, transport_addr) in transports {
let domain = &deltachat_contact_tools::EmailAddress::new(&transport_addr)
.map_or(transport_addr, |email| email.domain);
let domain_escaped = escaper::encode_minimal(domain);
let Some(quota) = quota.get(&transport_id) else {
let not_connected = stock_str::not_connected(self).await;
ret += &format!("<li>{domain_escaped} &middot; {not_connected}</li>");
continue;
};
match &quota.recent { match &quota.recent {
Err(e) => {
let error_escaped = escaper::encode_minimal(&e.to_string());
ret += &format!("<li>{domain_escaped} &middot; {error_escaped}</li>");
}
Ok(quota) => { Ok(quota) => {
if !quota.is_empty() { if quota.is_empty() {
ret += &format!(
"<li>{domain_escaped} &middot; Warning: {domain_escaped} claims to support quota but gives no information</li>"
);
} else {
for (root_name, resources) in quota { for (root_name, resources) in quota {
use async_imap::types::QuotaResourceName::*; use async_imap::types::QuotaResourceName::*;
for resource in resources { for resource in resources {
ret += "<li>"; ret += &format!("<li>{domain_escaped} &middot; ");
// root name is empty eg. for gmail and redundant eg. for riseup. // root name is empty eg. for gmail and redundant eg. for riseup.
// therefore, use it only if there are really several roots. // therefore, use it only if there are really several roots.
@@ -539,21 +559,9 @@ impl Context {
ret += "</li>"; ret += "</li>";
} }
} }
} else {
let domain_escaped = escaper::encode_minimal(domain);
ret += &format!(
"<li>Warning: {domain_escaped} claims to support quota but gives no information</li>"
);
} }
} }
Err(e) => {
let error_escaped = escaper::encode_minimal(&e.to_string());
ret += &format!("<li>{error_escaped}</li>");
}
} }
} else {
let not_connected = stock_str::not_connected(self).await;
ret += &format!("<li>{not_connected}</li>");
} }
ret += "</ul>"; ret += "</ul>";

View File

@@ -1144,14 +1144,6 @@ pub(crate) async fn outgoing_messages(context: &Context) -> String {
translated(context, StockMessage::OutgoingMessages).await translated(context, StockMessage::OutgoingMessages).await
} }
/// Stock string: `Storage on %1$s`.
/// `%1$s` will be replaced by the domain of the configured email-address.
pub(crate) async fn storage_on_domain(context: &Context, domain: &str) -> String {
translated(context, StockMessage::StorageOnDomain)
.await
.replace1(domain)
}
/// Stock string: `Not connected`. /// Stock string: `Not connected`.
pub(crate) async fn not_connected(context: &Context) -> String { pub(crate) async fn not_connected(context: &Context) -> String {
translated(context, StockMessage::NotConnected).await translated(context, StockMessage::NotConnected).await