feat(web): add real-time activity feed via SignalR
- Add ActivityHub and wire up SignalR in Program.cs - Broadcast new context events from ContextController - Connect SignalR client on Analytics page for live feed updates - Restructure activity feed HTML to support live prepending - Add slide-in animation for new activity items - Update CORS to allow credentials for SignalR Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using TaskTracker.Api.Hubs;
|
||||
using TaskTracker.Core.DTOs;
|
||||
using TaskTracker.Core.Entities;
|
||||
using TaskTracker.Core.Interfaces;
|
||||
@@ -11,6 +13,7 @@ public class ContextController(
|
||||
IContextEventRepository contextRepo,
|
||||
IAppMappingRepository mappingRepo,
|
||||
ITaskRepository taskRepo,
|
||||
IHubContext<ActivityHub> hubContext,
|
||||
ILogger<ContextController> logger) : ControllerBase
|
||||
{
|
||||
[HttpPost]
|
||||
@@ -37,6 +40,17 @@ public class ContextController(
|
||||
}
|
||||
|
||||
var created = await contextRepo.CreateAsync(contextEvent);
|
||||
|
||||
await hubContext.Clients.All.SendAsync("NewActivity", new
|
||||
{
|
||||
id = created.Id,
|
||||
appName = created.AppName,
|
||||
windowTitle = created.WindowTitle,
|
||||
url = created.Url,
|
||||
timestamp = created.Timestamp,
|
||||
workTaskId = created.WorkTaskId
|
||||
});
|
||||
|
||||
return Ok(ApiResponse<ContextEvent>.Ok(created));
|
||||
}
|
||||
|
||||
|
||||
5
TaskTracker.Api/Hubs/ActivityHub.cs
Normal file
5
TaskTracker.Api/Hubs/ActivityHub.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace TaskTracker.Api.Hubs;
|
||||
|
||||
public class ActivityHub : Hub { }
|
||||
@@ -87,35 +87,32 @@
|
||||
<section>
|
||||
<h2 class="section-title">Recent Activity</h2>
|
||||
<div class="surface">
|
||||
@if (Model.ActivityItems.Count == 0)
|
||||
{
|
||||
<div class="search-empty">No activity recorded in this time range.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div id="activity-feed" class="activity-feed">
|
||||
@foreach (var item in Model.ActivityItems)
|
||||
{
|
||||
<div class="activity-item">
|
||||
<span class="activity-dot" style="background: var(--color-accent)"></span>
|
||||
<div class="activity-line"></div>
|
||||
<div class="activity-info">
|
||||
<span class="activity-app">@item.AppName</span>
|
||||
<span class="activity-title">@(string.IsNullOrEmpty(item.Url) ? item.WindowTitle : item.Url)</span>
|
||||
<span class="activity-time">@AnalyticsModel.FormatRelativeTime(item.Timestamp)</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (Model.TotalActivityCount > Model.ActivityItems.Count)
|
||||
<div id="activity-feed" class="activity-feed">
|
||||
@if (Model.ActivityItems.Count == 0)
|
||||
{
|
||||
<button class="btn btn--ghost btn--full"
|
||||
hx-get="/analytics?handler=ActivityFeed&minutes=@Model.Minutes&taskId=@Model.TaskId&offset=@Model.ActivityItems.Count"
|
||||
hx-target="#activity-feed"
|
||||
hx-swap="beforeend">
|
||||
Load more (@(Model.TotalActivityCount - Model.ActivityItems.Count) remaining)
|
||||
</button>
|
||||
<div class="search-empty" id="activity-empty">No activity recorded in this time range.</div>
|
||||
}
|
||||
@foreach (var item in Model.ActivityItems)
|
||||
{
|
||||
<div class="activity-item">
|
||||
<span class="activity-dot" style="background: var(--color-accent)"></span>
|
||||
<div class="activity-line"></div>
|
||||
<div class="activity-info">
|
||||
<span class="activity-app">@item.AppName</span>
|
||||
<span class="activity-title">@(string.IsNullOrEmpty(item.Url) ? item.WindowTitle : item.Url)</span>
|
||||
<span class="activity-time">@AnalyticsModel.FormatRelativeTime(item.Timestamp)</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (Model.TotalActivityCount > Model.ActivityItems.Count)
|
||||
{
|
||||
<button class="btn btn--ghost btn--full"
|
||||
hx-get="/analytics?handler=ActivityFeed&minutes=@Model.Minutes&taskId=@Model.TaskId&offset=@Model.ActivityItems.Count"
|
||||
hx-target="#activity-feed"
|
||||
hx-swap="beforeend">
|
||||
Load more (@(Model.TotalActivityCount - Model.ActivityItems.Count) remaining)
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
<script src="~/lib/htmx.min.js"></script>
|
||||
<script src="~/lib/Sortable.min.js"></script>
|
||||
<script src="~/lib/chart.umd.min.js"></script>
|
||||
<script src="~/lib/signalr.min.js"></script>
|
||||
<script src="~/js/app.js"></script>
|
||||
@await RenderSectionAsync("Scripts", required: false)
|
||||
</body>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskTracker.Api.Hubs;
|
||||
using TaskTracker.Core.Interfaces;
|
||||
using TaskTracker.Infrastructure.Data;
|
||||
using TaskTracker.Infrastructure.Repositories;
|
||||
@@ -31,12 +32,16 @@ builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
{
|
||||
policy.AllowAnyOrigin()
|
||||
policy.SetIsOriginAllowed(_ => true)
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod();
|
||||
.AllowAnyMethod()
|
||||
.AllowCredentials();
|
||||
});
|
||||
});
|
||||
|
||||
// SignalR
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
// Razor Pages
|
||||
builder.Services.AddRazorPages();
|
||||
|
||||
@@ -57,5 +62,6 @@ app.UseCors();
|
||||
app.UseStaticFiles();
|
||||
app.MapRazorPages();
|
||||
app.MapControllers();
|
||||
app.MapHub<ActivityHub>("/hubs/activity");
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -1281,6 +1281,21 @@ body::before {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes activity-slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.activity-item--new {
|
||||
animation: activity-slide-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
CHART CONTAINER
|
||||
|
||||
@@ -272,4 +272,60 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// SignalR — Real-time Activity Feed
|
||||
// ========================================
|
||||
|
||||
function initActivityHub() {
|
||||
var feed = document.getElementById('activity-feed');
|
||||
if (!feed) return; // Not on the Analytics page
|
||||
|
||||
var connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl('/hubs/activity')
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
|
||||
connection.on('NewActivity', function(data) {
|
||||
// Remove the empty-state placeholder if present
|
||||
var empty = document.getElementById('activity-empty');
|
||||
if (empty) empty.remove();
|
||||
|
||||
var item = document.createElement('div');
|
||||
item.className = 'activity-item activity-item--new';
|
||||
|
||||
var displayText = data.url || data.windowTitle || '';
|
||||
// Escape HTML to prevent XSS
|
||||
var div = document.createElement('div');
|
||||
div.textContent = data.appName || '';
|
||||
var safeApp = div.innerHTML;
|
||||
div.textContent = displayText;
|
||||
var safeTitle = div.innerHTML;
|
||||
|
||||
item.innerHTML =
|
||||
'<span class="activity-dot" style="background: var(--color-accent)"></span>' +
|
||||
'<div class="activity-line"></div>' +
|
||||
'<div class="activity-info">' +
|
||||
'<span class="activity-app">' + safeApp + '</span>' +
|
||||
'<span class="activity-title">' + safeTitle + '</span>' +
|
||||
'<span class="activity-time">just now</span>' +
|
||||
'</div>';
|
||||
|
||||
feed.insertBefore(item, feed.firstChild);
|
||||
|
||||
// Cap visible items at 100 to prevent memory bloat
|
||||
var items = feed.querySelectorAll('.activity-item');
|
||||
if (items.length > 100) {
|
||||
items[items.length - 1].remove();
|
||||
}
|
||||
});
|
||||
|
||||
connection.start().catch(function(err) {
|
||||
console.error('SignalR connection error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initActivityHub();
|
||||
});
|
||||
|
||||
})();
|
||||
|
||||
2
TaskTracker.Api/wwwroot/lib/signalr.min.js
vendored
Normal file
2
TaskTracker.Api/wwwroot/lib/signalr.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user