Files
hotline-planner/dev/backend/web app webhook claude/HotlinePlannerWebhook/Views/Sync/Index.cshtml
2026-02-23 12:27:26 +01:00

405 lines
20 KiB
Plaintext

@model HotlinePlannerWebhook.Models.DashboardViewModel
@{
ViewData["Title"] = "Dashboard";
Layout = "~/Views/Shared/_Layout.cshtml";
}
@section HeadStyles {
<style>
/* ── Layout ── */
.dash { padding: 1rem 1.25rem; }
.dash-grid { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 0.75rem; margin-bottom: 1rem; }
.dash-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-bottom: 1rem; }
@@media (max-width: 900px) { .dash-grid { grid-template-columns: 1fr 1fr; } .dash-row { grid-template-columns: 1fr; } }
/* ── Cards ── */
.card-dark { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; }
.stat-card { text-align: center; }
.stat-val { font-size: 2rem; font-weight: 700; line-height: 1; }
.stat-lbl { font-size: 0.72rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.08em; margin-top: 0.3rem; }
/* ── Activity Feed ── */
.feed-wrap { height: 340px; overflow-y: auto; font-family: 'Courier New', monospace; font-size: 0.76rem; }
.feed-wrap::-webkit-scrollbar { width: 4px; }
.feed-wrap::-webkit-scrollbar-thumb { background: var(--border); }
.feed-entry { display: flex; gap: 0.5rem; padding: 0.2rem 0.1rem; border-bottom: 1px solid #1c2128; align-items: flex-start; }
.feed-time { color: var(--text-muted); min-width: 68px; font-size: 0.7rem; padding-top: 1px; }
.feed-cat { min-width: 68px; font-size: 0.7rem; font-weight: 600; padding-top: 1px; }
.feed-msg { flex: 1; word-break: break-word; color: var(--text-primary); }
.feed-email { color: var(--text-muted); font-size: 0.68rem; }
/* category colors */
.cat-Auth { color: #58a6ff; }
.cat-Webhook { color: #d2a8ff; }
.cat-Sync { color: #3fb950; }
.cat-Subscription { color: #d29922; }
.cat-System { color: #8b949e; }
/* level colors */
.lvl-success .feed-msg { color: #3fb950; }
.lvl-warning .feed-msg { color: #d29922; }
.lvl-error .feed-msg { color: #f85149; }
.lvl-info .feed-msg { color: var(--text-primary); }
/* ── Tables ── */
.section-title { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 0.5rem; border-bottom: 1px solid var(--border); padding-bottom: 0.35rem; }
.tbl { width: 100%; font-size: 0.76rem; border-collapse: collapse; }
.tbl th { color: var(--text-muted); font-weight: 500; text-transform: uppercase; font-size: 0.68rem; letter-spacing: 0.06em; padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; }
.tbl td { padding: 0.35rem 0.5rem; border-bottom: 1px solid #1c2128; vertical-align: middle; }
.tbl tr:hover td { background: var(--bg-card2); }
.badge-ok { background: #1a3022; color: #3fb950; border-radius: 4px; padding: 1px 6px; font-size: 0.68rem; }
.badge-warn { background: #2d2210; color: #d29922; border-radius: 4px; padding: 1px 6px; font-size: 0.68rem; }
.badge-err { background: #2a1217; color: #f85149; border-radius: 4px; padding: 1px 6px; font-size: 0.68rem; }
.badge-muted { background: #1c2128; color: #8b949e; border-radius: 4px; padding: 1px 6px; font-size: 0.68rem; }
.mono { font-family: monospace; font-size: 0.7rem; color: var(--text-muted); }
.dot-live { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #3fb950; margin-right: 5px; animation: pulse 2s infinite; }
@@keyframes pulse { 0%,100%{opacity:1;} 50%{opacity:0.3;} }
.btn-sm-dark { background: var(--bg-card2); border: 1px solid var(--border); color: var(--text-primary); padding: 0.25rem 0.6rem; border-radius: 4px; font-size: 0.75rem; cursor: pointer; }
.btn-sm-dark:hover { border-color: var(--accent-blue); }
.tbl-wrap { max-height: 260px; overflow-y: auto; }
.tbl-wrap::-webkit-scrollbar { width: 4px; }
.tbl-wrap::-webkit-scrollbar-thumb { background: var(--border); }
.conn-badge { font-size: 0.75rem; color: var(--text-muted); }
</style>
}
<div class="dash">
<!-- ── Header row ── -->
<div class="d-flex align-items-center justify-content-between mb-3">
<div>
<span style="font-size:1.1rem; font-weight:600; color:var(--text-primary);">
<span class="dot-live"></span> Sync Dashboard
</span>
</div>
<div class="d-flex align-items-center gap-3">
<span class="conn-badge" id="clientCount">— viewers</span>
<button class="btn-sm-dark" id="btnClear">Clear feed</button>
<button class="btn-sm-dark" id="btnManualSync">Manual sync</button>
</div>
</div>
<!-- ── Stats row ── -->
<div class="dash-grid">
<div class="card-dark stat-card">
<div class="stat-val" style="color:var(--accent-blue);" id="statUsers">@Model.Stats.TotalUsers</div>
<div class="stat-lbl">Connected Users</div>
</div>
<div class="card-dark stat-card">
<div class="stat-val" style="color:var(--accent-green);" id="statSubs">@Model.Stats.ActiveSubscriptions</div>
<div class="stat-lbl">Active Subscriptions</div>
</div>
<div class="card-dark stat-card">
<div class="stat-val" style="color:var(--accent-purple);" id="statWebhooks">@Model.Stats.WebhooksToday</div>
<div class="stat-lbl">Webhooks Today</div>
</div>
<div class="card-dark stat-card">
<div class="stat-val" style="color:var(--accent-yellow);" id="statExpiring">@Model.Stats.ExpiringSubscriptions</div>
<div class="stat-lbl">Expiring &lt;24h</div>
</div>
</div>
<!-- ── Activity feed + Users table ── -->
<div class="dash-row">
<!-- Live Activity Feed -->
<div class="card-dark">
<div class="section-title">Live Activity Feed</div>
<div class="feed-wrap" id="activityFeed">
<div class="feed-entry lvl-info" style="color:var(--text-muted); font-size:0.72rem; padding: 0.5rem 0;">
Waiting for activity... Connect to Microsoft Calendar to start.
</div>
</div>
</div>
<!-- Users & Subscriptions -->
<div class="card-dark">
<div class="section-title">Users &amp; Subscriptions</div>
<div class="tbl-wrap">
<table class="tbl" id="tblUsers">
<thead>
<tr>
<th>Email</th>
<th>Status</th>
<th>Expires</th>
<th>Last Sync</th>
<th>Events</th>
<th>Δ</th>
</tr>
</thead>
<tbody>
@foreach (var u in Model.Users)
{
var statusClass = u.SubscriptionStatus switch {
"Active" => "badge-ok",
"Expiring" => "badge-warn",
"Expired" => "badge-err",
_ => "badge-muted"
};
<tr>
<td>
<div style="color:var(--text-primary);">@u.DisplayName</div>
<div class="mono">@u.Email</div>
</td>
<td><span class="@statusClass">@u.SubscriptionStatus</span></td>
<td class="mono">@(u.SubscriptionExpiration?.ToString("MM/dd HH:mm") ?? "—")</td>
<td class="mono">@(u.LastSyncAt?.ToString("MM/dd HH:mm") ?? "—")</td>
<td>@u.TotalEventsSynced</td>
<td><span class="@(string.IsNullOrEmpty(u.LastDeltaLink) ? "badge-warn" : "badge-ok")">@(string.IsNullOrEmpty(u.LastDeltaLink) ? "none" : "stored")</span></td>
</tr>
}
@if (!Model.Users.Any())
{
<tr><td colspan="6" style="color:var(--text-muted); text-align:center; padding:1rem;">No users yet — connect a calendar to start.</td></tr>
}
</tbody>
</table>
</div>
</div>
</div>
<!-- ── Webhook Logs ── -->
<div class="card-dark">
<div class="section-title d-flex justify-content-between">
<span>Webhook Logs <span style="color:var(--text-muted); font-weight:400;">(last 50)</span></span>
<span style="color:var(--text-muted);">Total: @Model.Stats.WebhooksTotal &nbsp;|&nbsp; Handshakes: @Model.Stats.HandshakesTotal</span>
</div>
<div class="tbl-wrap">
<table class="tbl" id="tblLogs">
<thead>
<tr>
<th>Time</th>
<th>Endpoint</th>
<th>Type</th>
<th>Change</th>
<th>Status</th>
<th>Events</th>
<th>Dupes</th>
<th>Sub ID</th>
<th>Error</th>
<th>Raw</th>
</tr>
</thead>
<tbody id="tblLogsBody">
@foreach (var log in Model.RecentLogs)
{
var statusClass = log.ProcessingStatus switch {
"Completed" => "badge-ok",
"Failed" => "badge-err",
"Skipped" => "badge-muted",
_ => "badge-warn"
};
<tr>
<td class="mono">@log.ReceivedAt.ToString("HH:mm:ss")</td>
<td><span class="@(log.EndpointType == "Listen" ? "badge-ok" : "badge-warn")">@log.EndpointType</span></td>
<td>@(log.IsHandshake ? "<span class='badge-muted'>Handshake</span>" : log.LifecycleEvent is not null ? $"<span class='badge-warn'>{log.LifecycleEvent}</span>" : "<span class='badge-muted'>Notification</span>")</td>
<td class="mono">@(log.ChangeType ?? "—")</td>
<td><span class="@statusClass">@log.ProcessingStatus</span></td>
<td>@log.EventsProcessed</td>
<td>@log.EventsDeduplicated</td>
<td class="mono" style="max-width:100px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">@(log.SubscriptionId?[..Math.Min(8, log.SubscriptionId?.Length ?? 0)] + "…" ?? "—")</td>
<td style="color:var(--accent-red); font-size:0.7rem;">@log.ErrorMessage</td>
<td>
<button class="btn-sm-dark" onclick="viewRaw(@log.Id)">JSON</button>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
<!-- Raw payload modal -->
<div id="rawModal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:1000; align-items:center; justify-content:center;">
<div style="background:var(--bg-card); border:1px solid var(--border); border-radius:8px; max-width:700px; width:90%; max-height:80vh; display:flex; flex-direction:column;">
<div style="padding:0.75rem 1rem; border-bottom:1px solid var(--border); display:flex; justify-content:space-between; align-items:center;">
<span style="font-weight:600;">Raw Payload</span>
<button class="btn-sm-dark" onclick="document.getElementById('rawModal').style.display='none'">✕ Close</button>
</div>
<pre id="rawContent" style="padding:1rem; overflow:auto; flex:1; font-size:0.72rem; color:var(--text-primary); margin:0;"></pre>
</div>
</div>
@section Scripts {
<script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/8.0.7/signalr.min.js"></script>
<script>
// ── SignalR connection ──
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/dashboard")
// Custom retry delays (ms): immediate, 2 s, 5 s, 10 s, 30 s
.withAutomaticReconnect([0, 2000, 5000, 10000, 30000])
.configureLogging(signalR.LogLevel.Warning)
.build();
connection.onclose(err => {
console.error("[Dashboard] SignalR connection closed.", err ?? "");
document.getElementById("clientCount").textContent = "— viewers (disconnected)";
});
connection.onreconnecting(err => {
console.warn("[Dashboard] SignalR reconnecting…", err ?? "");
document.getElementById("clientCount").textContent = "… reconnecting";
});
connection.onreconnected(() => {
console.log("[Dashboard] SignalR reconnected.");
refreshData();
});
// ── Activity Feed ──
const feed = document.getElementById("activityFeed");
const MAX_FEED = 200;
function addFeedEntry(entry) {
const isFirst = feed.children.length === 1
&& feed.children[0].textContent.includes("Waiting for activity");
if (isFirst) feed.innerHTML = "";
const time = new Date(entry.timestamp).toLocaleTimeString('en-GB', {hour12:false});
const div = document.createElement("div");
div.className = `feed-entry lvl-${entry.level}`;
div.innerHTML =
`<span class="feed-time">${time}</span>` +
`<span class="feed-cat cat-${entry.category}">[${entry.category}]</span>` +
`<span class="feed-msg">${escHtml(entry.message)}` +
(entry.email ? ` <span class="feed-email">(${escHtml(entry.email)})</span>` : '') +
`</span>`;
feed.prepend(div);
// Keep cap
while (feed.children.length > MAX_FEED) feed.removeChild(feed.lastChild);
}
function escHtml(s) {
return String(s)
.replace(/&/g,"&amp;").replace(/</g,"&lt;")
.replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
// ── Refresh stats + tables via AJAX ──
async function refreshData() {
try {
const r = await fetch("/sync/data");
if (!r.ok) return;
const d = await r.json();
// Stats
document.getElementById("statUsers").textContent = d.users.length;
document.getElementById("statSubs").textContent = d.users.filter(u => u.subscriptionId && new Date(u.subscriptionExpiration) > new Date()).length;
document.getElementById("statExpiring").textContent = d.users.filter(u => {
if (!u.subscriptionExpiration) return false;
const exp = new Date(u.subscriptionExpiration);
const now = new Date();
return exp > now && exp < new Date(now.getTime() + 86400000);
}).length;
document.getElementById("statWebhooks").textContent = d.logs.filter(l => {
return new Date(l.receivedAt).toDateString() === new Date().toDateString();
}).length;
// Users table
const uTbody = document.querySelector("#tblUsers tbody");
if (d.users.length === 0) {
uTbody.innerHTML = `<tr><td colspan="6" style="color:var(--text-muted); text-align:center; padding:1rem;">No users yet.</td></tr>`;
} else {
uTbody.innerHTML = d.users.map(u => {
const sCls = {Active:"badge-ok",Expiring:"badge-warn",Expired:"badge-err"}[u.subscriptionStatus] || "badge-muted";
const exp = u.subscriptionExpiration ? new Date(u.subscriptionExpiration).toLocaleString('en-GB',{hour12:false,month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}) : "—";
const sync = u.lastSyncAt ? new Date(u.lastSyncAt).toLocaleString('en-GB',{hour12:false,month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}) : "—";
return `<tr>
<td><div style="color:var(--text-primary);">${escHtml(u.displayName||u.email)}</div>
<div class="mono">${escHtml(u.email)}</div></td>
<td><span class="${sCls}">${escHtml(u.subscriptionStatus)}</span></td>
<td class="mono">${exp}</td>
<td class="mono">${sync}</td>
<td>${u.totalEventsSynced}</td>
<td><span class="${u.deltaLinkStored ? 'badge-ok':'badge-warn'}">${u.deltaLinkStored?'stored':'none'}</span></td>
</tr>`;
}).join("");
}
// Logs table
const lTbody = document.getElementById("tblLogsBody");
lTbody.innerHTML = d.logs.map(l => {
const sCls = {Completed:"badge-ok",Failed:"badge-err",Skipped:"badge-muted"}[l.processingStatus] || "badge-warn";
const eCls = l.endpointType === "Listen" ? "badge-ok" : "badge-warn";
const type = l.isHandshake ? "<span class='badge-muted'>Handshake</span>"
: l.lifecycleEvent ? `<span class='badge-warn'>${escHtml(l.lifecycleEvent)}</span>`
: "<span class='badge-muted'>Notification</span>";
const time = new Date(l.receivedAt).toLocaleTimeString('en-GB',{hour12:false});
const subId = l.subscriptionId ? l.subscriptionId.substring(0,8)+"…" : "—";
return `<tr>
<td class="mono">${time}</td>
<td><span class="${eCls}">${l.endpointType}</span></td>
<td>${type}</td>
<td class="mono">${l.changeType||"—"}</td>
<td><span class="${sCls}">${l.processingStatus}</span></td>
<td>${l.eventsProcessed}</td>
<td>${l.eventsDeduplicated}</td>
<td class="mono">${subId}</td>
<td style="color:var(--accent-red); font-size:0.7rem;">${escHtml(l.errorMessage||"")}</td>
<td><button class="btn-sm-dark" onclick="viewRaw(${l.id})">JSON</button></td>
</tr>`;
}).join("") || `<tr><td colspan="10" style="color:var(--text-muted); text-align:center; padding:1rem;">No logs yet.</td></tr>`;
} catch (e) { console.warn("refreshData failed", e); }
}
// ── SignalR event handlers ──
connection.on("ActivityLog", entry => addFeedEntry(entry));
connection.on("StatsChanged", () => refreshData());
connection.on("ClientCountChanged", count => {
document.getElementById("clientCount").textContent = `${count} viewer${count === 1 ? "" : "s"}`;
});
// ── Manual sync ──
document.getElementById("btnManualSync").addEventListener("click", async () => {
const btn = document.getElementById("btnManualSync");
btn.textContent = "Syncing…";
btn.disabled = true;
try {
const r = await fetch("/sync/manual", { method: "POST", headers: { "RequestVerificationToken": getCsrfToken() } });
const d = await r.json();
addFeedEntry({ timestamp: new Date(), level: d.success ? "success" : "error",
category: "Sync", message: d.success
? `Manual sync: ${d.newOrUpdated} new/updated, ${d.deduplicated} dupes filtered`
: `Manual sync failed: ${d.error}` });
} finally { btn.textContent = "Manual sync"; btn.disabled = false; }
});
// ── Clear feed ──
document.getElementById("btnClear").addEventListener("click", () => {
feed.innerHTML = `<div class="feed-entry lvl-info" style="color:var(--text-muted); font-size:0.72rem; padding:0.5rem 0;">Feed cleared.</div>`;
});
// ── Raw payload viewer ──
async function viewRaw(id) {
const r = await fetch(`/sync/log/${id}`);
const d = await r.json();
document.getElementById("rawContent").textContent =
JSON.stringify(JSON.parse(d.rawPayload), null, 2);
document.getElementById("rawModal").style.display = "flex";
}
document.getElementById("rawModal").addEventListener("click", e => {
if (e.target === document.getElementById("rawModal"))
document.getElementById("rawModal").style.display = "none";
});
function getCsrfToken() {
return document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? "";
}
// ── Start SignalR + initial data refresh ──
connection.start()
.then(() => console.log("[Dashboard] SignalR connected"))
.catch(e => console.error("[Dashboard] SignalR error:", e));
// Refresh table data every 30s as fallback
setInterval(refreshData, 30000);
</script>
}