feat: Enhance notification system with WebSocket support and auto-hide alerts
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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 = '×';
|
||||
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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user