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.Mvc;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using TaskTracker.Api.Hubs;
|
||||||
using TaskTracker.Core.DTOs;
|
using TaskTracker.Core.DTOs;
|
||||||
using TaskTracker.Core.Entities;
|
using TaskTracker.Core.Entities;
|
||||||
using TaskTracker.Core.Interfaces;
|
using TaskTracker.Core.Interfaces;
|
||||||
@@ -11,6 +13,7 @@ public class ContextController(
|
|||||||
IContextEventRepository contextRepo,
|
IContextEventRepository contextRepo,
|
||||||
IAppMappingRepository mappingRepo,
|
IAppMappingRepository mappingRepo,
|
||||||
ITaskRepository taskRepo,
|
ITaskRepository taskRepo,
|
||||||
|
IHubContext<ActivityHub> hubContext,
|
||||||
ILogger<ContextController> logger) : ControllerBase
|
ILogger<ContextController> logger) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
@@ -37,6 +40,17 @@ public class ContextController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var created = await contextRepo.CreateAsync(contextEvent);
|
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));
|
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,13 +87,11 @@
|
|||||||
<section>
|
<section>
|
||||||
<h2 class="section-title">Recent Activity</h2>
|
<h2 class="section-title">Recent Activity</h2>
|
||||||
<div class="surface">
|
<div class="surface">
|
||||||
|
<div id="activity-feed" class="activity-feed">
|
||||||
@if (Model.ActivityItems.Count == 0)
|
@if (Model.ActivityItems.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="search-empty">No activity recorded in this time range.</div>
|
<div class="search-empty" id="activity-empty">No activity recorded in this time range.</div>
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
<div id="activity-feed" class="activity-feed">
|
|
||||||
@foreach (var item in Model.ActivityItems)
|
@foreach (var item in Model.ActivityItems)
|
||||||
{
|
{
|
||||||
<div class="activity-item">
|
<div class="activity-item">
|
||||||
@@ -116,7 +114,6 @@
|
|||||||
Load more (@(Model.TotalActivityCount - Model.ActivityItems.Count) remaining)
|
Load more (@(Model.TotalActivityCount - Model.ActivityItems.Count) remaining)
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -78,6 +78,7 @@
|
|||||||
<script src="~/lib/htmx.min.js"></script>
|
<script src="~/lib/htmx.min.js"></script>
|
||||||
<script src="~/lib/Sortable.min.js"></script>
|
<script src="~/lib/Sortable.min.js"></script>
|
||||||
<script src="~/lib/chart.umd.min.js"></script>
|
<script src="~/lib/chart.umd.min.js"></script>
|
||||||
|
<script src="~/lib/signalr.min.js"></script>
|
||||||
<script src="~/js/app.js"></script>
|
<script src="~/js/app.js"></script>
|
||||||
@await RenderSectionAsync("Scripts", required: false)
|
@await RenderSectionAsync("Scripts", required: false)
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TaskTracker.Api.Hubs;
|
||||||
using TaskTracker.Core.Interfaces;
|
using TaskTracker.Core.Interfaces;
|
||||||
using TaskTracker.Infrastructure.Data;
|
using TaskTracker.Infrastructure.Data;
|
||||||
using TaskTracker.Infrastructure.Repositories;
|
using TaskTracker.Infrastructure.Repositories;
|
||||||
@@ -31,12 +32,16 @@ builder.Services.AddCors(options =>
|
|||||||
{
|
{
|
||||||
options.AddDefaultPolicy(policy =>
|
options.AddDefaultPolicy(policy =>
|
||||||
{
|
{
|
||||||
policy.AllowAnyOrigin()
|
policy.SetIsOriginAllowed(_ => true)
|
||||||
.AllowAnyHeader()
|
.AllowAnyHeader()
|
||||||
.AllowAnyMethod();
|
.AllowAnyMethod()
|
||||||
|
.AllowCredentials();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// SignalR
|
||||||
|
builder.Services.AddSignalR();
|
||||||
|
|
||||||
// Razor Pages
|
// Razor Pages
|
||||||
builder.Services.AddRazorPages();
|
builder.Services.AddRazorPages();
|
||||||
|
|
||||||
@@ -57,5 +62,6 @@ app.UseCors();
|
|||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
app.MapRazorPages();
|
app.MapRazorPages();
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
app.MapHub<ActivityHub>("/hubs/activity");
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -1281,6 +1281,21 @@ body::before {
|
|||||||
flex-shrink: 0;
|
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
|
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