feat: Enhance notification system with WebSocket support and auto-hide alerts

This commit is contained in:
bolade
2025-08-13 14:49:23 +01:00
parent 411f47e039
commit bd4a795a5a
5 changed files with 269 additions and 12 deletions
+1
View File
@@ -108,6 +108,7 @@ def process_alerts(db: Session, cfg: dict) -> List[int]:
# Only if last message is incoming or last_in is later than last_out
if last_out_dt and last_out_dt > last_in_dt:
print("Here in lies the problem")
# There's a reply from us after the last incoming; skip
continue
+62 -5
View File
@@ -6,7 +6,7 @@ from contextlib import suppress
from typing import List
from dotenv import load_dotenv
from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Request
from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Request, WebSocket
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
@@ -345,6 +345,17 @@ def process(db: Session = Depends(get_db)):
return RedirectResponse(url=f"/?alerts_processed={len(alerted)}", status_code=303)
def send_to_all(message: str):
logging.info(
"Broadcasting to %d clients: %s", len(manager.active_connections), message
)
loop = getattr(app.state, "loop", None)
if not loop or not loop.is_running():
logging.warning("ASGI event loop not ready; skipping broadcast")
return
asyncio.run_coroutine_threadsafe(manager.broadcast(message), loop)
def _sync_emails_once(cfg: dict) -> int:
"""Fetch INBOX and Sent from Zoho and ingest into DB. Returns threads requiring reply count."""
from datetime import datetime, timezone
@@ -364,14 +375,16 @@ def _sync_emails_once(cfg: dict) -> int:
days_back = max(1, delta_days)
except Exception:
pass
max_results = 100
max_results = 10
client = ZohoClient(
email=cfg.get("zoho_email") or account_email,
app_password=cfg.get("zoho_app_password"),
)
db = SessionLocal()
try:
send_to_all("Fetching emails from inbox...")
inbox = client.fetch_folder_emails(
folder="INBOX",
max_results=max_results,
@@ -379,6 +392,8 @@ def _sync_emails_once(cfg: dict) -> int:
db_session=db,
account_email=account_email,
)
send_to_all("Fetching emails from sent...")
sent = client.fetch_folder_emails(
folder="Sent",
max_results=max_results,
@@ -389,6 +404,7 @@ def _sync_emails_once(cfg: dict) -> int:
finally:
client.close()
send_to_all("Analysing Threads with AI")
try:
ingest_emails(
db, account_email=account_email, emails=inbox, default_folder="INBOX"
@@ -402,7 +418,9 @@ def _sync_emails_once(cfg: dict) -> int:
.filter(Thread.account_email == account_email.lower())
.count()
)
send_to_all("Email synced Successfully")
return count
finally:
db.close()
@@ -445,19 +463,57 @@ def _sync_emails_background_task():
@app.post("/sync_emails")
async def sync_emails(background_tasks: BackgroundTasks):
cfg = load_config()
if cfg.get("sync_in_progress"):
return RedirectResponse(url="/?sync=busy", status_code=303)
return {"status": "already_running"}
# Mark sync as starting
cfg["sync_in_progress"] = True
cfg["last_sync_status"] = "running"
cfg["last_sync_error"] = None
save_config(cfg)
# Add the background task
background_tasks.add_task(_sync_emails_background_task)
return RedirectResponse(url="/?sync=started", status_code=303)
return {"status": "syncing"}
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
with suppress(ValueError):
self.active_connections.remove(websocket)
async def broadcast(self, message: str):
for connection in list(self.active_connections):
try:
await connection.send_json({"message": message})
except Exception:
with suppress(Exception):
await connection.close()
with suppress(ValueError):
if connection in self.active_connections:
self.active_connections.remove(connection)
manager = ConnectionManager()
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await manager.connect(websocket)
try:
while True:
data = await websocket.receive_text()
await manager.broadcast(f"{data}")
except Exception:
manager.disconnect(websocket)
# ---------------------
@@ -529,6 +585,7 @@ async def _auto_runner():
@app.on_event("startup")
async def on_startup():
app.state.loop = asyncio.get_running_loop()
# Reset any stale sync status from previous session
try:
cfg = load_config()
+85 -1
View File
@@ -60,7 +60,7 @@ th, td { border: 1px solid var(--border); padding: 12px 15px; vertical-align: to
th { background: #0f152a; color: var(--muted); text-align: left; position: sticky; top: 0; font-weight: 600; }
tbody tr:hover { background: #0e1426; }
pre, code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
/* pre, code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } */
pre {
background: #0b1121;
padding: 0.75rem;
@@ -149,6 +149,8 @@ button:hover { filter: brightness(1.05); }
border-radius: 8px;
border: 1px solid;
font-size: 0.9rem;
/* Smooth hide animation support */
transition: opacity 0.35s ease, transform 0.35s ease;
}
.alert.success {
background: rgba(34, 197, 94, 0.1);
@@ -165,3 +167,85 @@ button:hover { filter: brightness(1.05); }
border-color: var(--danger);
color: #f87171;
}
/* Generic fade-out helper when dismissing alerts */
.alert.fade-out {
opacity: 0;
transform: translateY(-4px);
pointer-events: none;
}
/* Notification banner */
.notify-banner {
position: relative; /* lives under the header */
max-width: 1400px;
margin: 0.5rem auto 0; /* small gap under header */
padding: 0 1rem; /* match container gutter */
}
.notify {
pointer-events: auto;
width: 100%;
display: flex;
align-items: center;
gap: 10px;
--accent: #39518a;
background:
linear-gradient(180deg, var(--panel) 0%, var(--panel-soft) 100%),
linear-gradient(0deg, rgba(79, 140, 255, 0.06), rgba(79, 140, 255, 0.06));
border: 1px solid var(--border);
border-left: 4px solid var(--accent);
color: var(--text);
border-radius: 10px;
box-shadow: 0 8px 22px rgba(0,0,0,0.28);
padding: 0.75rem 0.75rem 0.75rem 0.9rem;
transition: transform 0.2s ease, opacity 0.2s ease, background 0.2s ease;
}
.notify.info {
--accent: #4f8cff;
background:
linear-gradient(180deg, var(--panel) 0%, var(--panel-soft) 100%),
linear-gradient(0deg, rgba(79, 140, 255, 0.08), rgba(79, 140, 255, 0.08));
}
.notify.success {
--accent: var(--success);
background:
linear-gradient(180deg, var(--panel) 0%, var(--panel-soft) 100%),
linear-gradient(0deg, rgba(34, 197, 94, 0.10), rgba(34, 197, 94, 0.10));
}
.notify.warn {
--accent: var(--warn);
background:
linear-gradient(180deg, var(--panel) 0%, var(--panel-soft) 100%),
linear-gradient(0deg, rgba(245, 158, 11, 0.10), rgba(245, 158, 11, 0.10));
}
.notify.danger {
--accent: var(--danger);
background:
linear-gradient(180deg, var(--panel) 0%, var(--panel-soft) 100%),
linear-gradient(0deg, rgba(239, 68, 68, 0.10), rgba(239, 68, 68, 0.10));
}
.notify-content { font-size: 0.95rem; line-height: 1.35rem; flex: 1; }
.notify-close {
margin-left: 8px;
background: transparent;
color: var(--muted);
border: 1px solid transparent;
border-radius: 6px;
padding: 2px 6px;
font-size: 18px;
line-height: 1;
}
.notify-close:hover {
background: #27345a;
border-color: var(--border);
color: #c9d1ff;
}
@media (max-width: 600px) {
.notify-banner { padding: 0 10px; }
.notify { max-width: 100%; min-width: 0; }
}
+103 -5
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Email Alerts</title>
<link rel="stylesheet" href="/static/styles.css" />
<link rel="stylesheet" href="{{ url_for('static', path='/styles.css') }}" />
</head>
<body>
<header>
@@ -12,18 +12,116 @@
<h1>Email Alerts</h1>
<nav>
<a href="/">Threads</a>
<a href="/config">Config</a>
<form action="/sync_emails" method="post" style="display:inline-block; margin-left:12px;">
<button type="submit">Process Emails</button>
</form>
<a href="/config">Config</a>
<button id="processEmailsBtn" style="margin-left:12px;">Process Emails</button>
<form action="/process" method="post" style="display:inline-block; margin-left:12px;">
<button type="submit">Process Alerts</button>
</form>
</nav>
</div>
</header>
<!-- Notification banner -->
<div id="notify-root" class="notify-banner" aria-live="polite" aria-atomic="false"></div>
<main class="container">
{% block content %}{% endblock %}
</main>
</body>
<script>
const wsScheme = location.protocol === 'https:' ? 'wss' : 'ws';
const socket = new WebSocket(`${wsScheme}://${location.host}/ws`);
// One-time reload guard after successful sync
let reloadedAfterSync = false;
function maybeRefreshFromMessage(message) {
try {
const msg = String(message || '').toLowerCase();
if (!reloadedAfterSync && msg.includes('email synced successfully')) {
reloadedAfterSync = true;
// Give users a moment to read the success banner, then refresh
setTimeout(() => {
window.location.reload();
}, 800);
}
} catch (_) {
// no-op
}
}
// Simple single-banner notification system
function pushNotification(message, level = 'info') {
try {
const root = document.getElementById('notify-root');
if (!root) return;
const levels = new Set(['info', 'success', 'warn', 'danger']);
const cls = levels.has(String(level)) ? String(level) : 'info';
// Ensure only a single banner is visible at a time
root.innerHTML = '';
const item = document.createElement('div');
item.className = `notify ${cls}`;
item.setAttribute('role', 'status');
const content = document.createElement('div');
content.className = 'notify-content';
content.textContent = String(message ?? '');
const closeBtn = document.createElement('button');
closeBtn.className = 'notify-close';
closeBtn.setAttribute('aria-label', 'Close notification');
closeBtn.innerHTML = '&times;';
closeBtn.addEventListener('click', () => {
// Clear the banner on close
root.innerHTML = '';
});
item.appendChild(content);
item.appendChild(closeBtn);
root.appendChild(item);
} catch (e) {
console.error('Failed to render notification:', e);
}
}
socket.onmessage = function(event) {
try {
let data = null;
try { data = JSON.parse(event.data); } catch { /* may be plain text */ }
if (data && typeof data === 'object') {
const msg = data.message ?? data.msg ?? data.text ?? JSON.stringify(data);
const level = (data.level || data.type || data.status || 'info').toString().toLowerCase();
pushNotification(msg, level);
console.log('Received sync status update:', msg);
maybeRefreshFromMessage(msg);
} else {
const text = String(event.data);
pushNotification(text);
console.log('Received sync status update:', text);
maybeRefreshFromMessage(text);
}
} catch (err) {
console.error('Error handling websocket message:', err);
}
};
document.getElementById("processEmailsBtn").addEventListener("click", function () {
fetch("/sync_emails", { method: "POST" })
.then(response => response.json())
.then(data => {
const status = data?.status ?? 'ok';
pushNotification(`Email sync triggered (${status})`, status === 'ok' ? 'success' : 'info');
console.log("Sync triggered:", status);
})
.catch(err => {
pushNotification(`Error starting sync: ${err?.message || err}`, 'danger');
console.error("Error starting sync:", err)
});
});
</script>
</html>
+18 -1
View File
@@ -8,6 +8,23 @@
<div class="alert success" style="margin-bottom:12px;">
✓ Alerts processed! {{ alerts_processed }} thread(s) were checked for alerts.
</div>
<script>
// Auto-hide the success alert after a short delay with a smooth fade-out
(function() {
const alertEl = document.currentScript?.previousElementSibling;
if (!alertEl || !alertEl.classList || !alertEl.classList.contains('alert')) return;
const hideMs = 3000; // visible duration
const fadeMs = 350; // should match CSS transition
setTimeout(() => {
alertEl.classList.add('fade-out');
setTimeout(() => {
if (alertEl && alertEl.parentNode) {
alertEl.parentNode.removeChild(alertEl);
}
}, fadeMs + 25);
}, hideMs);
})();
</script>
{% endif %}
{% if status %}
<div class="muted" style="margin-top:6px;">
@@ -25,7 +42,7 @@
<span style="margin-left:8px;">Last Sync: {{ status.last_sync_at or 'never' }}</span>
<span style="margin-left:8px;">Items: {{ status.last_sync_count }}</span>
{% if status.last_sync_error %}
<div class="muted">Error: {{ status.last_sync_error }}</div>
<div class="muted" style="margin-top:2px;">Error: {{ status.last_sync_error }}</div>
{% endif %}
{% if status.auto_process %}
<div class="muted">Auto process enabled (every {{ status.interval }}m)</div>