diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-12 14:21:12 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-12 16:36:54 +0200 |
| commit | 2114a54eb6335f97b0c276c4f1f224d45f46fd1a (patch) | |
| tree | dbb2aa53f1a82d652f7aeb03e29dee80b6e8e0a5 /packages/meshbay-hub/src/meshbay_hub | |
| parent | ef4842644a207c3d1b6d6f06c1ad1055270ae283 (diff) | |
| download | meshbay-2114a54eb6335f97b0c276c4f1f224d45f46fd1a.tar.gz | |
feat(hub): the mail state is a panel section, and a ceiling falling is an event
The figure was a line beside the settings form, which is where it is changed
and not where it is watched. It sits with the other live figures under
Statistics now — four cards and, above them, a banner saying which of the two
ceilings has fallen. The two states are not the same to whoever is reading:
one means newcomers are turned away, the other means somebody locked out of
their account cannot get back in. The settings block keeps a line pointing at
it.
And an operator no longer has to be looking. When a global ceiling is reached
the administrators are notified — in `mail.py`, in its own session, never
raising, because this runs while a request is being refused and an alert that
fails must not turn a refusal into a 500. Once per hour, keyed on a row
rather than a flag in memory: a flood is what spends the budget, so one alert
per refusal would bury the message under its own cause, and a hub that is
refusing mail is a hub somebody is about to restart.
`/v1/admin/mail` gains `general_exhausted` and `all_exhausted` rather than
leaving the panel to compare two numbers.
Labels in all ten catalogues; `.warn-msg` for the middle state, on the
`--warn` token both themes already define.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
13 files changed, 195 insertions, 14 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/mail.py b/packages/meshbay-hub/src/meshbay_hub/mail.py index 661a1f3..aa6b74e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/mail.py +++ b/packages/meshbay-hub/src/meshbay_hub/mail.py @@ -105,6 +105,67 @@ async def _take(db, key: str, window: timedelta, ceiling: int, row.last_sent = now +# The two global ceilings, and what each one means to somebody waiting. +EXHAUSTED_GENERAL = "general" # sign-ups and address changes refused +EXHAUSTED_ALL = "all" # even a passphrase reset is refused + + +async def _announce_exhaustion(scope: str) -> None: + """Tell the administrators, once per hour, that the hub stopped sending. + + In its own session, and never raising: this runs while a request is being + refused, and an alert that fails must not turn a refusal into a 500. Same + reason `_mark_hosted` opens its own session next door. + + Once per window, keyed on a row rather than a flag in memory, because the + thing being reported is exactly the kind of event a restart would erase — + and a hub that is refusing mail is a hub somebody is probably restarting. + """ + from sqlalchemy import select + + from meshbay_hub.api.deps import user_is_admin + from meshbay_hub.api.notifications import create_notification + from meshbay_hub.db.engine import get_session_factory + from meshbay_hub.db.models import MailQuota, User + + try: + async with get_session_factory()() as db: + key = f"alert:{scope}" + now = datetime.now(timezone.utc) + row = await db.get(MailQuota, key) + if row is not None and row.last_sent is not None: + last = row.last_sent + if last.tzinfo is None: + last = last.replace(tzinfo=timezone.utc) + if now - last < timedelta(hours=1): + return + if row is None: + row = MailQuota(key=key, window_start=now, count=0) + db.add(row) + row.last_sent = now + row.count += 1 + + admins = [u for u in (await db.execute(select(User).where( + User.status == "active"))).scalars().all() if user_is_admin(u)] + for admin in admins: + await create_notification( + db, admin.id, f"mail_budget_{scope}", + "This hub has stopped sending mail for this hour" + if scope == EXHAUSTED_ALL + else "This hub has stopped sending sign-up mail for this hour", + detail=("Passphrase resets and invitations are refused too." + if scope == EXHAUSTED_ALL + else "Passphrase resets and invitations still go out."), + link="#/admin", + aggregate=False, + ) + await db.commit() + log.warning("Mail budget exhausted (%s) — %d administrator(s) told", + scope, len(admins)) + except Exception as e: + log.warning("Could not announce the mail budget: %s", e) + + async def reserve(db, purpose: str, address: str) -> None: """Charge one send, or raise MailRefused. The caller owns the commit.""" from meshbay_hub import hub_settings @@ -120,7 +181,15 @@ async def reserve(db, purpose: str, address: str) -> None: budget = limits["hourly_budget"] if purpose not in RECOVERY_PURPOSES: budget = max(0, budget - limits["hourly_reserved_for_recovery"]) - await _take(db, "hour", timedelta(hours=1), budget, None) + try: + await _take(db, "hour", timedelta(hours=1), budget, None) + except MailRefused: + # An operator finds out here or not at all: a refusal is otherwise a + # line in the journal, and a hub that has stopped sending sign-up + # codes looks exactly like one nobody is signing up to. + await _announce_exhaustion( + EXHAUSTED_ALL if purpose in RECOVERY_PURPOSES else EXHAUSTED_GENERAL) + raise # Then the recipient: across every purpose, account and endpoint. This is # what a person being mail-bombed actually experiences, and the only bound @@ -153,7 +222,14 @@ async def status(db) -> dict: .where(MailQuota.key.like("dest:%"))) or 0 general = max(0, limits["hourly_budget"] - limits["hourly_reserved_for_recovery"]) + # Named rather than left for the reader to compute from two numbers: the + # panel draws a warning off these, and "is it still sending" is the + # question an operator opens this page to answer. + general_exhausted = used >= general + all_exhausted = used >= limits["hourly_budget"] return { + "general_exhausted": general_exhausted, + "all_exhausted": all_exhausted, "hourly_budget": limits["hourly_budget"], "hourly_used": used, "hour_started_at": window_start, diff --git a/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js b/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js index 3cc42db..8800615 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js @@ -105,7 +105,11 @@ export function AdminPage({ token, role }) { useEffect(() => { setError(''); if (tab === 'general') loadSettings(); - else if (tab === 'stats') loadStats(); + else if (tab === 'stats') { + loadStats(); + hubFetch('/v1/admin/mail', { token }) + .then(setMailStatus).catch(() => { /* the cards still render */ }); + } else if (tab === 'users') loadUsers(userSearch); else if (tab === 'groups') loadGroups(); else if (tab === 'nodes') { @@ -225,18 +229,7 @@ const TABS = ['general', 'stats', 'users', 'groups', 'nodes', 'logs', 'blocklist <h3 class="settings-heading">${t('admin.mail_heading')}</h3> <p class="settings-hint">${t('admin.mail_hint')}</p> - ${mailStatus && html` - <div class="settings-row"> - <span class="settings-label">${t('admin.mail_this_hour')}</span> - <span>${mailStatus.hourly_used} / ${mailStatus.hourly_budget}</span> - </div> - <p class="settings-hint"> - ${t('admin.mail_remaining', { - general: mailStatus.general_remaining, - recovery: mailStatus.recovery_remaining, - })} - </p> - `} + <p class="settings-hint">${t('admin.mail_state_is_in_stats')}</p> ${mailDraft && MAIL_FIELDS.map(key => html` <div class="settings-row" key=${key}> @@ -279,6 +272,37 @@ const TABS = ['general', 'stats', 'users', 'groups', 'nodes', 'logs', 'blocklist </div> `)} </div> + + ${mailStatus && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('admin.mail_heading')}</h3> + + ${mailStatus.all_exhausted && html` + <div class="error-msg">${t('admin.mail_alert_all')}</div>`} + ${!mailStatus.all_exhausted && mailStatus.general_exhausted && html` + <div class="warn-msg">${t('admin.mail_alert_general')}</div>`} + + <div class="admin-stats"> + <div class="stat-card"> + <div class="stat-value">${mailStatus.hourly_used} / ${mailStatus.hourly_budget}</div> + <div class="stat-label">${t('admin.mail_this_hour')}</div> + </div> + <div class="stat-card"> + <div class="stat-value">${mailStatus.general_remaining}</div> + <div class="stat-label">${t('admin.mail_left_signups')}</div> + </div> + <div class="stat-card"> + <div class="stat-value">${mailStatus.recovery_remaining}</div> + <div class="stat-label">${t('admin.mail_left_recovery')}</div> + </div> + <div class="stat-card"> + <div class="stat-value">${mailStatus.recipients_tracked}</div> + <div class="stat-label">${t('admin.mail_recipients')}</div> + </div> + </div> + <p class="settings-hint">${t('admin.mail_state_hint')}</p> + </div> + `} `} ${tab === 'users' && html` diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index e5695f3..f460255 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -499,6 +499,13 @@ export default { 'admin.mail_email_change_cooldown': "Sekunden, bevor ein Konto eine andere Adresse vorschlagen darf", 'admin.mail_save': "Mail-Grenzen speichern", 'admin.mail_reset_defaults': "Standardwerte wiederherstellen", + 'admin.mail_state_is_in_stats': "Der Verbrauch der aktuellen Stunde steht unter Statistik.", + 'admin.mail_state_hint': "Rücksetzungen und Einladungen dürfen das ganze Budget nutzen; Registrierungen und Adressänderungen nicht den dafür reservierten Anteil. Administratoren werden einmal pro Stunde benachrichtigt, wenn eine der beiden Grenzen erreicht ist.", + 'admin.mail_left_signups': "Rest für Registrierungen", + 'admin.mail_left_recovery': "Rest für Rücksetzungen und Einladungen", + 'admin.mail_recipients': "Heute gezählte Empfänger", + 'admin.mail_alert_all': "Dieser Hub versendet in dieser Stunde keine E-Mail mehr. Auch Rücksetzungen und Einladungen werden abgelehnt.", + 'admin.mail_alert_general': "Der Anteil für Registrierungen und Adressänderungen ist für diese Stunde aufgebraucht. Rücksetzungen und Einladungen gehen weiterhin raus.", 'admin.settings_readonly': "Only an admin can change these settings.", // Admin stats diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 06b3626..e7ffa64 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -489,6 +489,13 @@ export default { 'admin.mail_email_change_cooldown': "Seconds before an account may propose another address", 'admin.mail_save': "Save mail limits", 'admin.mail_reset_defaults': "Restore defaults", + 'admin.mail_state_is_in_stats': "The current hour's usage is shown under Statistics.", + 'admin.mail_state_hint': "Resets and invitations may spend the whole budget; sign-ups and address changes may not spend the share reserved for them. Administrators are notified once per hour when either ceiling is reached.", + 'admin.mail_left_signups': "Left for sign-ups", + 'admin.mail_left_recovery': "Left for resets and invitations", + 'admin.mail_recipients': "Recipients counted today", + 'admin.mail_alert_all': "This hub has stopped sending mail for this hour. Passphrase resets and invitations are refused too.", + 'admin.mail_alert_general': "The share for sign-ups and address changes is spent for this hour. Passphrase resets and invitations still go out.", 'admin.settings_readonly': "Only an admin can change these settings.", // Admin stats diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 0a2ed2b..2641708 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -495,6 +495,13 @@ export default { 'admin.mail_email_change_cooldown': "Segundos antes de que una cuenta pueda proponer otra dirección", 'admin.mail_save': "Guardar límites de correo", 'admin.mail_reset_defaults': "Restaurar valores por defecto", + 'admin.mail_state_is_in_stats': "El uso de la hora actual se muestra en Estadísticas.", + 'admin.mail_state_hint': "Los restablecimientos y las invitaciones pueden gastar todo el presupuesto; los registros y cambios de dirección no pueden tocar la parte reservada. Se avisa a los administradores una vez por hora cuando se alcanza cualquiera de los dos límites.", + 'admin.mail_left_signups': "Restante para registros", + 'admin.mail_left_recovery': "Restante para restablecimientos e invitaciones", + 'admin.mail_recipients': "Destinatarios contados hoy", + 'admin.mail_alert_all': "Este hub ha dejado de enviar correo durante esta hora. También se rechazan los restablecimientos e invitaciones.", + 'admin.mail_alert_general': "La parte para registros y cambios de dirección está agotada esta hora. Los restablecimientos e invitaciones siguen saliendo.", 'admin.settings_readonly': "Only an admin can change these settings.", // Admin stats diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 0179c06..0c639b7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -498,6 +498,13 @@ export default { 'admin.mail_email_change_cooldown': "Secondes avant qu'un compte puisse proposer une autre adresse", 'admin.mail_save': "Enregistrer les limites", 'admin.mail_reset_defaults': "Rétablir les valeurs par défaut", + 'admin.mail_state_is_in_stats': "La consommation de l'heure en cours est affichée dans Statistiques.", + 'admin.mail_state_hint': "Les réinitialisations et invitations peuvent dépenser tout le budget ; les inscriptions et changements d'adresse ne peuvent pas entamer la part qui leur est réservée. Les administrateurs sont prévenus une fois par heure lorsqu'un des deux plafonds est atteint.", + 'admin.mail_left_signups': "Restant pour les inscriptions", + 'admin.mail_left_recovery': "Restant pour réinitialisations et invitations", + 'admin.mail_recipients': "Destinataires comptés aujourd'hui", + 'admin.mail_alert_all': "Ce hub n'envoie plus de courrier pour cette heure. Les réinitialisations de passphrase et les invitations sont refusées aussi.", + 'admin.mail_alert_general': "La part réservée aux inscriptions et changements d'adresse est épuisée pour cette heure. Les réinitialisations et invitations partent toujours.", 'admin.settings_readonly': "Seul un administrateur peut modifier ces paramètres.", // Admin stats diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index cd64ffd..efda136 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -498,6 +498,13 @@ export default { 'admin.mail_email_change_cooldown': "Secondi prima che un account possa proporre un altro indirizzo", 'admin.mail_save': "Salva i limiti di posta", 'admin.mail_reset_defaults': "Ripristina i valori predefiniti", + 'admin.mail_state_is_in_stats': "Il consumo dell'ora corrente è mostrato in Statistiche.", + 'admin.mail_state_hint': "Reimpostazioni e inviti possono spendere l'intero budget; registrazioni e cambi di indirizzo non possono intaccare la quota riservata. Gli amministratori vengono avvisati una volta all'ora quando uno dei due limiti viene raggiunto.", + 'admin.mail_left_signups': "Rimanente per le registrazioni", + 'admin.mail_left_recovery': "Rimanente per reimpostazioni e inviti", + 'admin.mail_recipients': "Destinatari contati oggi", + 'admin.mail_alert_all': "Questo hub ha smesso di inviare posta per quest'ora. Anche reimpostazioni e inviti sono rifiutati.", + 'admin.mail_alert_general': "La quota per registrazioni e cambi di indirizzo è esaurita per quest'ora. Reimpostazioni e inviti partono ancora.", 'admin.settings_readonly': "Only an admin can change these settings.", // Admin stats diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index c7f2c76..22f5d6b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -491,6 +491,13 @@ export default { 'admin.mail_email_change_cooldown': "別のアドレスを申請できるようになるまでの秒数", 'admin.mail_save': "メール制限を保存", 'admin.mail_reset_defaults': "既定値に戻す", + 'admin.mail_state_is_in_stats': "現在の 1 時間の使用状況は「統計」に表示されます。", + 'admin.mail_state_hint': "再設定と招待は上限全体を使えます。登録とアドレス変更は、確保された分には手を付けられません。いずれかの上限に達すると、管理者に 1 時間に 1 回通知されます。", + 'admin.mail_left_signups': "登録に残っている数", + 'admin.mail_left_recovery': "再設定・招待に残っている数", + 'admin.mail_recipients': "本日カウントした受信者", + 'admin.mail_alert_all': "このハブはこの 1 時間、メール送信を停止しました。パスフレーズ再設定と招待も拒否されます。", + 'admin.mail_alert_general': "登録とアドレス変更のための分はこの 1 時間で使い切りました。再設定と招待は引き続き送信されます。", 'admin.settings_readonly': "Only an admin can change these settings.", // Admin stats diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 519acdc..88aebfa 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -499,6 +499,13 @@ export default { 'admin.mail_email_change_cooldown': "Seconden voordat een account een ander adres mag voorstellen", 'admin.mail_save': "E-maillimieten opslaan", 'admin.mail_reset_defaults': "Standaardwaarden herstellen", + 'admin.mail_state_is_in_stats': "Het verbruik van dit uur staat onder Statistieken.", + 'admin.mail_state_hint': "Herstel en uitnodigingen mogen het hele budget gebruiken; registraties en adreswijzigingen niet het gereserveerde deel. Beheerders krijgen één keer per uur bericht wanneer een van beide grenzen is bereikt.", + 'admin.mail_left_signups': "Resterend voor registraties", + 'admin.mail_left_recovery': "Resterend voor herstel en uitnodigingen", + 'admin.mail_recipients': "Vandaag getelde ontvangers", + 'admin.mail_alert_all': "Deze hub verstuurt dit uur geen e-mail meer. Ook herstel en uitnodigingen worden geweigerd.", + 'admin.mail_alert_general': "Het deel voor registraties en adreswijzigingen is dit uur op. Herstel en uitnodigingen gaan nog wel uit.", 'admin.settings_readonly': "Only an admin can change these settings.", // Admin stats diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index d2ae19d..bed39d2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -511,6 +511,13 @@ export default { 'admin.mail_email_change_cooldown': "Sekundy, zanim konto może zaproponować inny adres", 'admin.mail_save': "Zapisz limity poczty", 'admin.mail_reset_defaults': "Przywróć domyślne", + 'admin.mail_state_is_in_stats': "Zużycie w bieżącej godzinie pokazano w Statystykach.", + 'admin.mail_state_hint': "Resety i zaproszenia mogą wykorzystać cały budżet; rejestracje i zmiany adresu nie mogą naruszyć zarezerwowanej części. Administratorzy są powiadamiani raz na godzinę, gdy któryś z limitów zostanie osiągnięty.", + 'admin.mail_left_signups': "Pozostało na rejestracje", + 'admin.mail_left_recovery': "Pozostało na resety i zaproszenia", + 'admin.mail_recipients': "Odbiorcy policzeni dzisiaj", + 'admin.mail_alert_all': "Ten hub przestał wysyłać pocztę w tej godzinie. Resety i zaproszenia również są odrzucane.", + 'admin.mail_alert_general': "Część na rejestracje i zmiany adresu wyczerpała się w tej godzinie. Resety i zaproszenia nadal są wysyłane.", 'admin.settings_readonly': "Only an admin can change these settings.", // Admin stats diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 6518d9a..cd5da7d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -497,6 +497,13 @@ export default { 'admin.mail_email_change_cooldown': "Segundos até uma conta poder propor outro endereço", 'admin.mail_save': "Salvar limites de correio", 'admin.mail_reset_defaults': "Restaurar padrões", + 'admin.mail_state_is_in_stats': "O consumo da hora atual aparece em Estatísticas.", + 'admin.mail_state_hint': "Redefinições e convites podem gastar todo o orçamento; cadastros e trocas de endereço não podem usar a parte reservada. Os administradores são avisados uma vez por hora quando qualquer um dos limites é atingido.", + 'admin.mail_left_signups': "Restante para cadastros", + 'admin.mail_left_recovery': "Restante para redefinições e convites", + 'admin.mail_recipients': "Destinatários contados hoje", + 'admin.mail_alert_all': "Este hub parou de enviar e-mail nesta hora. Redefinições e convites também são recusados.", + 'admin.mail_alert_general': "A parte para cadastros e trocas de endereço acabou nesta hora. Redefinições e convites continuam saindo.", 'admin.settings_readonly': "Only an admin can change these settings.", // Admin stats diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 1f6881b..06aa011 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -483,6 +483,13 @@ export default { 'admin.mail_email_change_cooldown': "账号可再次申请其他地址前的秒数", 'admin.mail_save': "保存邮件限制", 'admin.mail_reset_defaults': "恢复默认值", + 'admin.mail_state_is_in_stats': "本小时的用量显示在「统计」中。", + 'admin.mail_state_hint': "重置与邀请可动用全部额度;注册与更换地址不得占用为前者保留的份额。任一上限达到时,每小时通知管理员一次。", + 'admin.mail_left_signups': "注册剩余", + 'admin.mail_left_recovery': "重置与邀请剩余", + 'admin.mail_recipients': "今日计入的收件人", + 'admin.mail_alert_all': "本 Hub 在本小时已停止发送邮件,密码重置与邀请同样被拒绝。", + 'admin.mail_alert_general': "本小时用于注册与更换地址的份额已用尽,密码重置与邀请仍会发送。", 'admin.settings_readonly': "Only an admin can change these settings.", // Admin stats diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index c375884..871ca0a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -846,6 +846,17 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } font-size: 0.85em; } +/* The middle state: the hub is still sending what somebody is waiting on, + but not what a newcomer needs. `--warn` already exists in both themes. */ +.warn-msg { + background: color-mix(in srgb, var(--warn) 12%, transparent); + color: var(--warn); + border: 1px solid var(--warn); + border-radius: 6px; + padding: 8px 12px; + font-size: 0.85em; +} + .success-msg { background: #16a34a20; color: var(--success); |