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 # 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: 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 # There's a reply from us after the last incoming; skip
continue continue
+62 -5
View File
@@ -6,7 +6,7 @@ from contextlib import suppress
from typing import List from typing import List
from dotenv import load_dotenv 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.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates 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) 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: def _sync_emails_once(cfg: dict) -> int:
"""Fetch INBOX and Sent from Zoho and ingest into DB. Returns threads requiring reply count.""" """Fetch INBOX and Sent from Zoho and ingest into DB. Returns threads requiring reply count."""
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -364,14 +375,16 @@ def _sync_emails_once(cfg: dict) -> int:
days_back = max(1, delta_days) days_back = max(1, delta_days)
except Exception: except Exception:
pass pass
max_results = 100 max_results = 10
client = ZohoClient( client = ZohoClient(
email=cfg.get("zoho_email") or account_email, email=cfg.get("zoho_email") or account_email,
app_password=cfg.get("zoho_app_password"), app_password=cfg.get("zoho_app_password"),
) )
db = SessionLocal() db = SessionLocal()
try: try:
send_to_all("Fetching emails from inbox...")
inbox = client.fetch_folder_emails( inbox = client.fetch_folder_emails(
folder="INBOX", folder="INBOX",
max_results=max_results, max_results=max_results,
@@ -379,6 +392,8 @@ def _sync_emails_once(cfg: dict) -> int:
db_session=db, db_session=db,
account_email=account_email, account_email=account_email,
) )
send_to_all("Fetching emails from sent...")
sent = client.fetch_folder_emails( sent = client.fetch_folder_emails(
folder="Sent", folder="Sent",
max_results=max_results, max_results=max_results,
@@ -389,6 +404,7 @@ def _sync_emails_once(cfg: dict) -> int:
finally: finally:
client.close() client.close()
send_to_all("Analysing Threads with AI")
try: try:
ingest_emails( ingest_emails(
db, account_email=account_email, emails=inbox, default_folder="INBOX" 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()) .filter(Thread.account_email == account_email.lower())
.count() .count()
) )
send_to_all("Email synced Successfully")
return count return count
finally: finally:
db.close() db.close()
@@ -445,19 +463,57 @@ def _sync_emails_background_task():
@app.post("/sync_emails") @app.post("/sync_emails")
async def sync_emails(background_tasks: BackgroundTasks): async def sync_emails(background_tasks: BackgroundTasks):
cfg = load_config() cfg = load_config()
if cfg.get("sync_in_progress"): if cfg.get("sync_in_progress"):
return RedirectResponse(url="/?sync=busy", status_code=303) return {"status": "already_running"}
# Mark sync as starting # Mark sync as starting
cfg["sync_in_progress"] = True cfg["sync_in_progress"] = True
cfg["last_sync_status"] = "running" cfg["last_sync_status"] = "running"
cfg["last_sync_error"] = None cfg["last_sync_error"] = None
save_config(cfg) save_config(cfg)
# Add the background task # Add the background task
background_tasks.add_task(_sync_emails_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") @app.on_event("startup")
async def on_startup(): async def on_startup():
app.state.loop = asyncio.get_running_loop()
# Reset any stale sync status from previous session # Reset any stale sync status from previous session
try: try:
cfg = load_config() 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; } th { background: #0f152a; color: var(--muted); text-align: left; position: sticky; top: 0; font-weight: 600; }
tbody tr:hover { background: #0e1426; } 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 { pre {
background: #0b1121; background: #0b1121;
padding: 0.75rem; padding: 0.75rem;
@@ -149,6 +149,8 @@ button:hover { filter: brightness(1.05); }
border-radius: 8px; border-radius: 8px;
border: 1px solid; border: 1px solid;
font-size: 0.9rem; font-size: 0.9rem;
/* Smooth hide animation support */
transition: opacity 0.35s ease, transform 0.35s ease;
} }
.alert.success { .alert.success {
background: rgba(34, 197, 94, 0.1); background: rgba(34, 197, 94, 0.1);
@@ -165,3 +167,85 @@ button:hover { filter: brightness(1.05); }
border-color: var(--danger); border-color: var(--danger);
color: #f87171; 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 charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Email Alerts</title> <title>Email Alerts</title>
<link rel="stylesheet" href="/static/styles.css" /> <link rel="stylesheet" href="{{ url_for('static', path='/styles.css') }}" />
</head> </head>
<body> <body>
<header> <header>
@@ -12,18 +12,116 @@
<h1>Email Alerts</h1> <h1>Email Alerts</h1>
<nav> <nav>
<a href="/">Threads</a> <a href="/">Threads</a>
<a href="/config">Config</a> <a href="/config">Config</a>
<form action="/sync_emails" method="post" style="display:inline-block; margin-left:12px;"> <button id="processEmailsBtn" style="margin-left:12px;">Process Emails</button>
<button type="submit">Process Emails</button>
</form>
<form action="/process" method="post" style="display:inline-block; margin-left:12px;"> <form action="/process" method="post" style="display:inline-block; margin-left:12px;">
<button type="submit">Process Alerts</button> <button type="submit">Process Alerts</button>
</form> </form>
</nav> </nav>
</div> </div>
</header> </header>
<!-- Notification banner -->
<div id="notify-root" class="notify-banner" aria-live="polite" aria-atomic="false"></div>
<main class="container"> <main class="container">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
</body> </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> </html>
+18 -1
View File
@@ -8,6 +8,23 @@
<div class="alert success" style="margin-bottom:12px;"> <div class="alert success" style="margin-bottom:12px;">
✓ Alerts processed! {{ alerts_processed }} thread(s) were checked for alerts. ✓ Alerts processed! {{ alerts_processed }} thread(s) were checked for alerts.
</div> </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 %} {% endif %}
{% if status %} {% if status %}
<div class="muted" style="margin-top:6px;"> <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;">Last Sync: {{ status.last_sync_at or 'never' }}</span>
<span style="margin-left:8px;">Items: {{ status.last_sync_count }}</span> <span style="margin-left:8px;">Items: {{ status.last_sync_count }}</span>
{% if status.last_sync_error %} {% 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 %} {% endif %}
{% if status.auto_process %} {% if status.auto_process %}
<div class="muted">Auto process enabled (every {{ status.interval }}m)</div> <div class="muted">Auto process enabled (every {{ status.interval }}m)</div>