feat: add auto-refresh with countdown timer, pause/resume, and tab visibility handling

This commit is contained in:
2026-05-05 15:40:17 +10:00
parent fee7f0acaf
commit c98c5e0618
+102 -255
View File
@@ -16,7 +16,6 @@ namespace Disco.Plugins.ServiceTracker.WebHandler
var dataPath = PluginConfigurationHandler.GetPluginDataDirectory( var dataPath = PluginConfigurationHandler.GetPluginDataDirectory(
HostController.HttpContext.Application["Disco.Plugins.ServiceTracker"] as Plugin HostController.HttpContext.Application["Disco.Plugins.ServiceTracker"] as Plugin
?? new ServiceTrackerPlugin()); ?? new ServiceTrackerPlugin());
// Fallback: use AppData path
if (string.IsNullOrEmpty(dataPath)) if (string.IsNullOrEmpty(dataPath))
{ {
dataPath = System.IO.Path.Combine( dataPath = System.IO.Path.Combine(
@@ -51,13 +50,11 @@ namespace Disco.Plugins.ServiceTracker.WebHandler
{ {
var dataStore = GetDataStore(); var dataStore = GetDataStore();
var service = new ServiceTrackerService(Database, dataStore); var service = new ServiceTrackerService(Database, dataStore);
var filterPriority = HostController.Request.QueryString["priority"]; var filterPriority = HostController.Request.QueryString["priority"];
var filterLocation = HostController.Request.QueryString["location"]; var filterLocation = HostController.Request.QueryString["location"];
var filterStatus = HostController.Request.QueryString["status"]; var filterStatus = HostController.Request.QueryString["status"];
var filterTech = HostController.Request.QueryString["tech"]; var filterTech = HostController.Request.QueryString["tech"];
var sortBy = HostController.Request.QueryString["sort"] ?? "due"; var sortBy = HostController.Request.QueryString["sort"] ?? "due";
var model = service.BuildDashboard(filterPriority, filterLocation, filterStatus, filterTech, sortBy); var model = service.BuildDashboard(filterPriority, filterLocation, filterStatus, filterTech, sortBy);
return HtmlResult(BuildDashboardPage(model)); return HtmlResult(BuildDashboardPage(model));
} }
@@ -66,14 +63,11 @@ namespace Disco.Plugins.ServiceTracker.WebHandler
{ {
if (HostController.Request.HttpMethod != "POST") if (HostController.Request.HttpMethod != "POST")
return new HttpStatusCodeResult(405); return new HttpStatusCodeResult(405);
var dataStore = GetDataStore(); var dataStore = GetDataStore();
var service = new ServiceTrackerService(Database, dataStore); var service = new ServiceTrackerService(Database, dataStore);
int jobId; int jobId;
if (!int.TryParse(HostController.Request.Form["jobId"], out jobId)) if (!int.TryParse(HostController.Request.Form["jobId"], out jobId))
return new HttpStatusCodeResult(400); return new HttpStatusCodeResult(400);
var priorityId = HostController.Request.Form["priority"]; var priorityId = HostController.Request.Form["priority"];
var locationId = HostController.Request.Form["location"]; var locationId = HostController.Request.Form["location"];
var techId = HostController.Request.Form["tech"]; var techId = HostController.Request.Form["tech"];
@@ -83,10 +77,8 @@ namespace Disco.Plugins.ServiceTracker.WebHandler
DateTime etaParsed; DateTime etaParsed;
if (DateTime.TryParse(HostController.Request.Form["eta"], out etaParsed)) if (DateTime.TryParse(HostController.Request.Form["eta"], out etaParsed))
eta = etaParsed; eta = etaParsed;
var currentUser = HostController.HttpContext.User?.Identity?.Name ?? "system"; var currentUser = HostController.HttpContext.User?.Identity?.Name ?? "system";
service.UpdateTicket(jobId, priorityId, locationId, techId, eta, status, summary, currentUser); service.UpdateTicket(jobId, priorityId, locationId, techId, eta, status, summary, currentUser);
return new RedirectResult("/Plugin/Disco.Plugins.ServiceTracker/Dashboard"); return new RedirectResult("/Plugin/Disco.Plugins.ServiceTracker/Dashboard");
} }
@@ -94,20 +86,15 @@ namespace Disco.Plugins.ServiceTracker.WebHandler
{ {
if (HostController.Request.HttpMethod != "POST") if (HostController.Request.HttpMethod != "POST")
return new HttpStatusCodeResult(405); return new HttpStatusCodeResult(405);
var dataStore = GetDataStore(); var dataStore = GetDataStore();
var service = new ServiceTrackerService(Database, dataStore); var service = new ServiceTrackerService(Database, dataStore);
int jobId; int jobId;
if (!int.TryParse(HostController.Request.Form["jobId"], out jobId)) if (!int.TryParse(HostController.Request.Form["jobId"], out jobId))
return new HttpStatusCodeResult(400); return new HttpStatusCodeResult(400);
var content = HostController.Request.Form["note"]; var content = HostController.Request.Form["note"];
var noteType = HostController.Request.Form["noteType"] ?? "general"; var noteType = HostController.Request.Form["noteType"] ?? "general";
var currentUser = HostController.HttpContext.User?.Identity?.Name ?? "system"; var currentUser = HostController.HttpContext.User?.Identity?.Name ?? "system";
var userName = currentUser; // Could resolve display name from DB service.AddNote(jobId, currentUser, currentUser, content, noteType);
service.AddNote(jobId, currentUser, userName, content, noteType);
return new RedirectResult("/Plugin/Disco.Plugins.ServiceTracker/Detail?id=" + jobId); return new RedirectResult("/Plugin/Disco.Plugins.ServiceTracker/Detail?id=" + jobId);
} }
@@ -116,22 +103,17 @@ namespace Disco.Plugins.ServiceTracker.WebHandler
int jobId; int jobId;
if (!int.TryParse(HostController.Request.QueryString["id"], out jobId)) if (!int.TryParse(HostController.Request.QueryString["id"], out jobId))
return new HttpStatusCodeResult(400); return new HttpStatusCodeResult(400);
var dataStore = GetDataStore(); var dataStore = GetDataStore();
var service = new ServiceTrackerService(Database, dataStore); var service = new ServiceTrackerService(Database, dataStore);
var config = dataStore.LoadConfig(); var config = dataStore.LoadConfig();
// Get the job
var job = Database.Jobs var job = Database.Jobs
.Include("Device").Include("Device.DeviceModel") .Include("Device").Include("Device.DeviceModel")
.Include("User").Include("OpenedTechUser") .Include("User").Include("OpenedTechUser")
.Include("JobType").Include("JobSubTypes") .Include("JobType").Include("JobSubTypes")
.Include("JobLogs") .Include("JobLogs")
.FirstOrDefault(j => j.Id == jobId); .FirstOrDefault(j => j.Id == jobId);
if (job == null) if (job == null)
return new HttpNotFoundResult(); return new HttpNotFoundResult();
var ticket = service.GetTicketDetail(jobId); var ticket = service.GetTicketDetail(jobId);
return HtmlResult(BuildDetailPage(job, ticket, config)); return HtmlResult(BuildDetailPage(job, ticket, config));
} }
@@ -141,7 +123,6 @@ namespace Disco.Plugins.ServiceTracker.WebHandler
var dataStore = GetDataStore(); var dataStore = GetDataStore();
var service = new ServiceTrackerService(Database, dataStore); var service = new ServiceTrackerService(Database, dataStore);
var model = service.BuildDashboard(); var model = service.BuildDashboard();
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("JobId,Device,User,Priority,Location,Status,AssignedTech,OpenedDate,ETA,SlaDeadline,SlaBreached,AgeDays,Summary,NoteCount"); sb.AppendLine("JobId,Device,User,Priority,Location,Status,AssignedTech,OpenedDate,ETA,SlaDeadline,SlaBreached,AgeDays,Summary,NoteCount");
foreach (var t in model.Tiles) foreach (var t in model.Tiles)
@@ -154,24 +135,15 @@ namespace Disco.Plugins.ServiceTracker.WebHandler
t.SlaDeadline?.ToString("yyyy-MM-dd HH:mm") ?? "", t.SlaDeadline?.ToString("yyyy-MM-dd HH:mm") ?? "",
t.IsSlaBreached, t.AgeDays, Csv(t.Summary), t.NoteCount)); t.IsSlaBreached, t.AgeDays, Csv(t.Summary), t.NoteCount));
} }
var fileName = "ServiceTracker_Export_" + DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".csv"; var fileName = "ServiceTracker_Export_" + DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".csv";
HostController.Response.Headers.Add("Content-Disposition", "attachment; filename=\"" + fileName + "\""); HostController.Response.Headers.Add("Content-Disposition", "attachment; filename=\"" + fileName + "\"");
return new ContentResult return new ContentResult { Content = sb.ToString(), ContentType = "text/csv", ContentEncoding = Encoding.UTF8 };
{
Content = sb.ToString(),
ContentType = "text/csv",
ContentEncoding = Encoding.UTF8
};
} }
// --- HTML Builders ---
private ActionResult HtmlResult(string html) private ActionResult HtmlResult(string html)
{ {
return new ContentResult { Content = html, ContentType = "text/html", ContentEncoding = Encoding.UTF8 }; return new ContentResult { Content = html, ContentType = "text/html", ContentEncoding = Encoding.UTF8 };
} }
private string Csv(string v) { return "\"" + (v ?? "").Replace("\"", "\"\"") + "\""; } private string Csv(string v) { return "\"" + (v ?? "").Replace("\"", "\"\"") + "\""; }
private string H(string v) { return string.IsNullOrEmpty(v) ? "" : HttpUtility.HtmlEncode(v); } private string H(string v) { return string.IsNullOrEmpty(v) ? "" : HttpUtility.HtmlEncode(v); }
@@ -179,97 +151,65 @@ namespace Disco.Plugins.ServiceTracker.WebHandler
{ {
var pluginUrl = "/Plugin/Disco.Plugins.ServiceTracker"; var pluginUrl = "/Plugin/Disco.Plugins.ServiceTracker";
var sb = new StringBuilder(); var sb = new StringBuilder();
// --- Head ---
sb.Append("<!DOCTYPE html><html><head><meta charset='utf-8'/>"); sb.Append("<!DOCTYPE html><html><head><meta charset='utf-8'/>");
sb.Append("<title>Service Tracker Dashboard</title>"); sb.Append("<title>Service Tracker Dashboard</title>");
sb.Append("<style>"); sb.Append("<style>");
sb.Append(GetDashboardCSS()); sb.Append(GetDashboardCSS());
sb.Append("</style></head><body>"); sb.Append("</style></head><body>");
// --- Header --- // Header
sb.Append("<div class='header'>"); sb.Append("<div class='header'><div class='header-left'>");
sb.Append("<div class='header-left'>");
sb.Append("<h1>&#x1F6E0; Service Tracker</h1>"); sb.Append("<h1>&#x1F6E0; Service Tracker</h1>");
sb.Append("<span class='subtitle'>Open Jobs: <strong>" + model.Stats.TotalOpen + "</strong></span>"); sb.Append("<span class='subtitle'>Open Jobs: <strong>" + model.Stats.TotalOpen + "</strong></span>");
sb.Append("</div>"); sb.Append("</div><div class='header-right'>");
sb.Append("<div class='header-right'>");
sb.Append("<a href='" + pluginUrl + "/Export' class='btn btn-default'>&#x2B07; Export CSV</a>"); sb.Append("<a href='" + pluginUrl + "/Export' class='btn btn-default'>&#x2B07; Export CSV</a>");
sb.Append("<a href='" + pluginUrl + "/Dashboard' class='btn btn-primary'>&#x21BB; Refresh</a>"); sb.Append("<a href='" + pluginUrl + "/Dashboard' class='btn btn-primary'>&#x21BB; Refresh</a>");
sb.Append("</div></div>"); sb.Append("</div></div>");
// --- SLA Alert Banner --- // SLA Alerts
if (model.Stats.SlaBreached > 0) if (model.Stats.SlaBreached > 0)
{ sb.Append("<div class='alert alert-danger'>&#x26A0; <strong>" + model.Stats.SlaBreached + " job(s) have BREACHED SLA</strong> — immediate attention required!</div>");
sb.Append("<div class='alert alert-danger'>");
sb.Append("&#x26A0; <strong>" + model.Stats.SlaBreached + " job(s) have BREACHED SLA</strong> — immediate attention required!");
sb.Append("</div>");
}
if (model.Stats.SlaWarning > 0) if (model.Stats.SlaWarning > 0)
{ sb.Append("<div class='alert alert-warning'>&#x23F0; <strong>" + model.Stats.SlaWarning + " job(s) approaching SLA deadline</strong></div>");
sb.Append("<div class='alert alert-warning'>");
sb.Append("&#x23F0; <strong>" + model.Stats.SlaWarning + " job(s) approaching SLA deadline</strong>");
sb.Append("</div>");
}
// --- Stats Bar --- // Stats Bar
sb.Append("<div class='stats-bar'>"); sb.Append("<div class='stats-bar'>");
foreach (var p in model.Config.Priorities) foreach (var p in model.Config.Priorities)
{ {
int count; int count; model.Stats.ByPriority.TryGetValue(p.Id, out count);
model.Stats.ByPriority.TryGetValue(p.Id, out count);
sb.Append("<a href='" + pluginUrl + "/Dashboard?priority=" + p.Id + "&sort=" + model.SortBy + "' class='stat-card' style='border-left:4px solid " + p.Color + ";'>"); sb.Append("<a href='" + pluginUrl + "/Dashboard?priority=" + p.Id + "&sort=" + model.SortBy + "' class='stat-card' style='border-left:4px solid " + p.Color + ";'>");
sb.Append("<div class='stat-num' style='color:" + p.Color + ";'>" + count + "</div>"); sb.Append("<div class='stat-num' style='color:" + p.Color + ";'>" + count + "</div>");
sb.Append("<div class='stat-lbl'>" + H(p.Name) + "</div>"); sb.Append("<div class='stat-lbl'>" + H(p.Name) + "</div></a>");
sb.Append("</a>");
} }
sb.Append("<div class='stat-card stat-sep'>"); sb.Append("<div class='stat-card stat-sep'><div class='stat-num' style='color:#DC3545;'>" + model.Stats.SlaBreached + "</div><div class='stat-lbl'>SLA Breached</div></div>");
sb.Append("<div class='stat-num' style='color:#DC3545;'>" + model.Stats.SlaBreached + "</div>"); sb.Append("<div class='stat-card'><div class='stat-num'>" + model.Stats.AvgAgeDays.ToString("0.0") + "</div><div class='stat-lbl'>Avg Age (days)</div></div>");
sb.Append("<div class='stat-lbl'>SLA Breached</div></div>");
sb.Append("<div class='stat-card'>");
sb.Append("<div class='stat-num'>" + model.Stats.AvgAgeDays.ToString("0.0") + "</div>");
sb.Append("<div class='stat-lbl'>Avg Age (days)</div></div>");
sb.Append("</div>"); sb.Append("</div>");
// --- Location Summary --- // Location Summary
sb.Append("<div class='location-bar'>"); sb.Append("<div class='location-bar'>");
foreach (var loc in model.Config.Locations) foreach (var loc in model.Config.Locations)
{ {
int count; int count; model.Stats.ByLocation.TryGetValue(loc.Id, out count);
model.Stats.ByLocation.TryGetValue(loc.Id, out count);
if (count > 0) if (count > 0)
{ sb.Append("<a href='" + pluginUrl + "/Dashboard?location=" + loc.Id + "' class='loc-chip' style='background:" + loc.Color + ";'>" + loc.Icon + " " + H(loc.Name) + " <strong>" + count + "</strong></a>");
sb.Append("<a href='" + pluginUrl + "/Dashboard?location=" + loc.Id + "' class='loc-chip' style='background:" + loc.Color + ";'>");
sb.Append(loc.Icon + " " + H(loc.Name) + " <strong>" + count + "</strong>");
sb.Append("</a>");
}
} }
sb.Append("</div>"); sb.Append("</div>");
// --- Sort Controls --- // Sort Controls
sb.Append("<div class='controls'>"); sb.Append("<div class='controls'><span class='ctrl-label'>Sort by:</span>");
sb.Append("<span class='ctrl-label'>Sort by:</span>");
string[] sortOptions = { "due|Due Date", "priority|Priority", "age|Age", "sla|SLA Status", "modified|Last Updated" }; string[] sortOptions = { "due|Due Date", "priority|Priority", "age|Age", "sla|SLA Status", "modified|Last Updated" };
foreach (var opt in sortOptions) foreach (var opt in sortOptions)
{ {
var parts = opt.Split('|'); var parts = opt.Split('|');
var active = parts[0] == model.SortBy ? " active" : ""; var active = parts[0] == model.SortBy ? " active" : "";
sb.Append("<a href='" + pluginUrl + "/Dashboard?sort=" + parts[0]); sb.Append("<a href='" + pluginUrl + "/Dashboard?sort=" + parts[0] + "' class='sort-btn" + active + "'>" + parts[1] + "</a>");
if (!string.IsNullOrEmpty(model.CurrentFilter))
{
// preserve filter
}
sb.Append("' class='sort-btn" + active + "'>" + parts[1] + "</a>");
} }
if (!string.IsNullOrEmpty(model.CurrentFilter)) if (!string.IsNullOrEmpty(model.CurrentFilter))
{
sb.Append("<a href='" + pluginUrl + "/Dashboard' class='sort-btn clear-btn'>&#x2716; Clear Filter</a>"); sb.Append("<a href='" + pluginUrl + "/Dashboard' class='sort-btn clear-btn'>&#x2716; Clear Filter</a>");
}
sb.Append("</div>"); sb.Append("</div>");
// --- Priority Legend --- // Priority Legend
sb.Append("<div class='legend'>"); sb.Append("<div class='legend'><span class='ctrl-label'>Priority:</span>");
sb.Append("<span class='ctrl-label'>Priority:</span>");
foreach (var p in model.Config.Priorities) foreach (var p in model.Config.Priorities)
{ {
sb.Append("<span class='legend-item'><span class='legend-dot' style='background:" + p.Color + ";'></span>" + H(p.Name)); sb.Append("<span class='legend-item'><span class='legend-dot' style='background:" + p.Color + ";'></span>" + H(p.Name));
@@ -278,41 +218,51 @@ namespace Disco.Plugins.ServiceTracker.WebHandler
} }
sb.Append("</div>"); sb.Append("</div>");
// --- Tile Grid --- // Tile Grid
sb.Append("<div class='tile-grid'>"); sb.Append("<div class='tile-grid'>");
if (model.Tiles.Count == 0) if (model.Tiles.Count == 0)
{ sb.Append("<div class='empty-state'><div class='empty-icon'>&#x2705;</div><div class='empty-msg'>No open jobs found</div></div>");
sb.Append("<div class='empty-state'>");
sb.Append("<div class='empty-icon'>&#x2705;</div>");
sb.Append("<div class='empty-msg'>No open jobs found</div>");
sb.Append("</div>");
}
else else
{
foreach (var tile in model.Tiles) foreach (var tile in model.Tiles)
{
sb.Append(BuildTileHtml(tile, pluginUrl)); sb.Append(BuildTileHtml(tile, pluginUrl));
}
}
sb.Append("</div>"); sb.Append("</div>");
// --- Tech Workload --- // Tech Workload
if (model.Stats.ByTech.Count > 0) if (model.Stats.ByTech.Count > 0)
{ {
sb.Append("<div class='workload-section'>"); sb.Append("<div class='workload-section'><h3>Tech Workload</h3><div class='workload-bar'>");
sb.Append("<h3>Tech Workload</h3>");
sb.Append("<div class='workload-bar'>");
foreach (var kv in model.Stats.ByTech.OrderByDescending(x => x.Value)) foreach (var kv in model.Stats.ByTech.OrderByDescending(x => x.Value))
{ sb.Append("<div class='workload-item'><span class='wl-name'>" + H(kv.Key) + "</span><span class='wl-count'>" + kv.Value + "</span></div>");
sb.Append("<div class='workload-item'>");
sb.Append("<span class='wl-name'>" + H(kv.Key) + "</span>");
sb.Append("<span class='wl-count'>" + kv.Value + "</span>");
sb.Append("</div>");
}
sb.Append("</div></div>"); sb.Append("</div></div>");
} }
// Auto-refresh bar
var refreshSeconds = model.Config.DashboardRefreshSeconds;
sb.Append("<div class='refresh-bar' id='refreshBar'>");
sb.Append("<span class='refresh-text'>Auto-refresh in <strong id='countdown'>" + refreshSeconds + "</strong>s</span>");
sb.Append("<div class='refresh-progress'><div class='refresh-fill' id='refreshFill'></div></div>");
sb.Append("<button class='refresh-toggle' id='toggleBtn' onclick='toggleAutoRefresh()'>Pause</button>");
sb.Append("</div>");
sb.Append("<div class='footer'>Service Tracker Plugin &mdash; Disco ICT</div>"); sb.Append("<div class='footer'>Service Tracker Plugin &mdash; Disco ICT</div>");
// Auto-refresh JavaScript
sb.Append("<script>");
sb.Append("var totalSec=" + refreshSeconds + ",remaining=" + refreshSeconds + ",paused=false,timer;");
sb.Append("function tick(){if(paused)return;remaining--;");
sb.Append("document.getElementById('countdown').textContent=remaining;");
sb.Append("var pct=((totalSec-remaining)/totalSec)*100;");
sb.Append("document.getElementById('refreshFill').style.width=pct+'%';");
sb.Append("if(remaining<=0){window.location.reload();}}");
sb.Append("function toggleAutoRefresh(){paused=!paused;");
sb.Append("var btn=document.getElementById('toggleBtn');");
sb.Append("var bar=document.getElementById('refreshBar');");
sb.Append("if(paused){btn.textContent='Resume';bar.classList.add('paused');}");
sb.Append("else{btn.textContent='Pause';bar.classList.remove('paused');}}");
sb.Append("timer=setInterval(tick,1000);");
sb.Append("document.addEventListener('visibilitychange',function(){");
sb.Append("if(document.hidden){paused=true;}else{paused=false;remaining=totalSec;}});");
sb.Append("</script>");
sb.Append("</body></html>"); sb.Append("</body></html>");
return sb.ToString(); return sb.ToString();
} }
@@ -321,88 +271,33 @@ namespace Disco.Plugins.ServiceTracker.WebHandler
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
string borderClass = tile.IsSlaBreached ? "tile-breached" : tile.IsSlaWarning ? "tile-warning" : ""; string borderClass = tile.IsSlaBreached ? "tile-breached" : tile.IsSlaWarning ? "tile-warning" : "";
sb.Append("<div class='tile " + borderClass + "' onclick=\"window.location='" + pluginUrl + "/Detail?id=" + tile.JobId + "'\">"); sb.Append("<div class='tile " + borderClass + "' onclick=\"window.location='" + pluginUrl + "/Detail?id=" + tile.JobId + "'\">");
// Priority strip
sb.Append("<div class='tile-priority' style='background:" + tile.PriorityColor + ";'></div>"); sb.Append("<div class='tile-priority' style='background:" + tile.PriorityColor + ";'></div>");
sb.Append("<div class='tile-header'><div class='tile-jobid'>#" + tile.JobId + "</div><div class='tile-age'>" + tile.AgeBadge + "</div></div>");
// Tile header
sb.Append("<div class='tile-header'>");
sb.Append("<div class='tile-jobid'>#" + tile.JobId + "</div>");
sb.Append("<div class='tile-age'>" + tile.AgeBadge + "</div>");
sb.Append("</div>");
// SLA badge
if (tile.IsSlaBreached) if (tile.IsSlaBreached)
{
sb.Append("<div class='sla-badge sla-breached'>&#x26A0; SLA BREACHED</div>"); sb.Append("<div class='sla-badge sla-breached'>&#x26A0; SLA BREACHED</div>");
}
else if (tile.IsSlaWarning) else if (tile.IsSlaWarning)
{
sb.Append("<div class='sla-badge sla-warn'>&#x23F0; SLA Warning</div>"); sb.Append("<div class='sla-badge sla-warn'>&#x23F0; SLA Warning</div>");
} sb.Append("<div class='tile-device'><div class='tile-device-name'>" + H(tile.DeviceComputerName ?? tile.DeviceSerialNumber) + "</div>");
// Device info
sb.Append("<div class='tile-device'>");
sb.Append("<div class='tile-device-name'>" + H(tile.DeviceComputerName ?? tile.DeviceSerialNumber) + "</div>");
if (tile.DeviceModelDescription != null) if (tile.DeviceModelDescription != null)
sb.Append("<div class='tile-device-model'>" + H(tile.DeviceModelDescription) + "</div>"); sb.Append("<div class='tile-device-model'>" + H(tile.DeviceModelDescription) + "</div>");
sb.Append("</div>"); sb.Append("</div>");
sb.Append("<div class='tile-row'><span class='tile-icon'>&#x1F464;</span><span class='tile-value'>" + H(tile.UserDisplayName ?? "—") + "</span></div>");
// User sb.Append("<div class='tile-row'><span class='tile-loc-badge' style='background:" + tile.LocationColor + ";'>" + tile.LocationIcon + " " + H(tile.LocationName) + "</span></div>");
sb.Append("<div class='tile-row'>"); sb.Append("<div class='tile-row'><span class='tile-icon'>&#x1F4CB;</span><span class='tile-value'>" + H(tile.StatusOverride ?? tile.DiscoStatus) + "</span></div>");
sb.Append("<span class='tile-icon'>&#x1F464;</span>");
sb.Append("<span class='tile-value'>" + H(tile.UserDisplayName ?? "—") + "</span>");
sb.Append("</div>");
// Location
sb.Append("<div class='tile-row'>");
sb.Append("<span class='tile-loc-badge' style='background:" + tile.LocationColor + ";'>");
sb.Append(tile.LocationIcon + " " + H(tile.LocationName));
sb.Append("</span></div>");
// Status
sb.Append("<div class='tile-row'>");
sb.Append("<span class='tile-icon'>&#x1F4CB;</span>");
sb.Append("<span class='tile-value'>" + H(tile.StatusOverride ?? tile.DiscoStatus) + "</span>");
sb.Append("</div>");
// Assigned tech
if (!string.IsNullOrEmpty(tile.AssignedTechName)) if (!string.IsNullOrEmpty(tile.AssignedTechName))
{ sb.Append("<div class='tile-row'><span class='tile-icon'>&#x1F527;</span><span class='tile-value'>" + H(tile.AssignedTechName) + "</span></div>");
sb.Append("<div class='tile-row'>"); sb.Append("<div class='tile-row'><span class='tile-icon'>&#x1F4C5;</span><span class='tile-value'>ETA: <strong>" + H(tile.EtaDisplay) + "</strong></span></div>");
sb.Append("<span class='tile-icon'>&#x1F527;</span>");
sb.Append("<span class='tile-value'>" + H(tile.AssignedTechName) + "</span>");
sb.Append("</div>");
}
// ETA
sb.Append("<div class='tile-row'>");
sb.Append("<span class='tile-icon'>&#x1F4C5;</span>");
sb.Append("<span class='tile-value'>ETA: <strong>" + H(tile.EtaDisplay) + "</strong></span>");
sb.Append("</div>");
// Summary / Latest note
if (!string.IsNullOrEmpty(tile.Summary)) if (!string.IsNullOrEmpty(tile.Summary))
{
sb.Append("<div class='tile-summary'>" + H(tile.Summary) + "</div>"); sb.Append("<div class='tile-summary'>" + H(tile.Summary) + "</div>");
}
if (!string.IsNullOrEmpty(tile.LatestNote)) if (!string.IsNullOrEmpty(tile.LatestNote))
{ {
sb.Append("<div class='tile-note'>"); sb.Append("<div class='tile-note'><span class='note-icon'>&#x1F4AC;</span> " + H(tile.LatestNote));
sb.Append("<span class='note-icon'>&#x1F4AC;</span> " + H(tile.LatestNote)); if (tile.NoteCount > 1) sb.Append(" <small>(+" + (tile.NoteCount - 1) + " more)</small>");
if (tile.NoteCount > 1)
sb.Append(" <small>(+" + (tile.NoteCount - 1) + " more)</small>");
sb.Append("</div>"); sb.Append("</div>");
} }
sb.Append("<div class='tile-footer'><span class='priority-label' style='background:" + tile.PriorityColor + ";'>" + H(tile.PriorityName) + "</span>");
// Priority label sb.Append("<span class='tile-type'>" + H(tile.JobTypeDescription) + "</span></div>");
sb.Append("<div class='tile-footer'>");
sb.Append("<span class='priority-label' style='background:" + tile.PriorityColor + ";'>" + H(tile.PriorityName) + "</span>");
sb.Append("<span class='tile-type'>" + H(tile.JobTypeDescription) + "</span>");
sb.Append("</div>");
sb.Append("</div>"); sb.Append("</div>");
return sb.ToString(); return sb.ToString();
} }
@@ -411,26 +306,16 @@ namespace Disco.Plugins.ServiceTracker.WebHandler
{ {
var pluginUrl = "/Plugin/Disco.Plugins.ServiceTracker"; var pluginUrl = "/Plugin/Disco.Plugins.ServiceTracker";
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.Append("<!DOCTYPE html><html><head><meta charset='utf-8'/>"); sb.Append("<!DOCTYPE html><html><head><meta charset='utf-8'/>");
sb.Append("<title>Job #" + job.Id + " - Service Tracker</title>"); sb.Append("<title>Job #" + job.Id + " - Service Tracker</title>");
sb.Append("<style>" + GetDashboardCSS() + GetDetailCSS() + "</style></head><body>"); sb.Append("<style>" + GetDashboardCSS() + GetDetailCSS() + "</style></head><body>");
sb.Append("<div class='header'><div class='header-left'>");
// Header
sb.Append("<div class='header'>");
sb.Append("<div class='header-left'>");
sb.Append("<a href='" + pluginUrl + "/Dashboard' class='back-link'>&larr; Dashboard</a>"); sb.Append("<a href='" + pluginUrl + "/Dashboard' class='back-link'>&larr; Dashboard</a>");
sb.Append("<h1>Job #" + job.Id + "</h1>"); sb.Append("<h1>Job #" + job.Id + "</h1></div></div>");
sb.Append("</div></div>");
// Two-column layout
sb.Append("<div class='detail-grid'>"); sb.Append("<div class='detail-grid'>");
// Left column - Job info + edit form // Left column
sb.Append("<div class='detail-left'>"); sb.Append("<div class='detail-left'><div class='detail-card'><h3>Job Details</h3><table class='detail-table'>");
sb.Append("<div class='detail-card'>");
sb.Append("<h3>Job Details</h3>");
sb.Append("<table class='detail-table'>");
sb.Append("<tr><th>Device</th><td>" + H(job.DeviceSerialNumber) + (job.Device?.DeviceDomainId != null ? " (" + H(job.Device.DeviceDomainId) + ")" : "") + "</td></tr>"); sb.Append("<tr><th>Device</th><td>" + H(job.DeviceSerialNumber) + (job.Device?.DeviceDomainId != null ? " (" + H(job.Device.DeviceDomainId) + ")" : "") + "</td></tr>");
sb.Append("<tr><th>Model</th><td>" + H(job.Device?.DeviceModel?.Description) + "</td></tr>"); sb.Append("<tr><th>Model</th><td>" + H(job.Device?.DeviceModel?.Description) + "</td></tr>");
sb.Append("<tr><th>User</th><td>" + H(job.User?.DisplayName ?? job.UserId) + "</td></tr>"); sb.Append("<tr><th>User</th><td>" + H(job.User?.DisplayName ?? job.UserId) + "</td></tr>");
@@ -443,108 +328,63 @@ namespace Disco.Plugins.ServiceTracker.WebHandler
sb.Append("</table></div>"); sb.Append("</table></div>");
// Edit form // Edit form
sb.Append("<div class='detail-card'>"); sb.Append("<div class='detail-card'><h3>Service Tracker Settings</h3>");
sb.Append("<h3>Service Tracker Settings</h3>");
sb.Append("<form method='POST' action='" + pluginUrl + "/Update'>"); sb.Append("<form method='POST' action='" + pluginUrl + "/Update'>");
sb.Append("<input type='hidden' name='jobId' value='" + job.Id + "'/>"); sb.Append("<input type='hidden' name='jobId' value='" + job.Id + "'/>");
// Priority dropdown
sb.Append("<div class='form-group'><label>Priority</label><select name='priority' class='form-control'>"); sb.Append("<div class='form-group'><label>Priority</label><select name='priority' class='form-control'>");
foreach (var p in config.Priorities) foreach (var p in config.Priorities)
{ {
var selected = (ticket?.PriorityId ?? config.DefaultPriorityId) == p.Id ? " selected" : ""; var sel = (ticket?.PriorityId ?? config.DefaultPriorityId) == p.Id ? " selected" : "";
sb.Append("<option value='" + p.Id + "'" + selected + ">" + H(p.Name) + " (" + p.SlaHours + "h SLA)</option>"); sb.Append("<option value='" + p.Id + "'" + sel + ">" + H(p.Name) + " (" + p.SlaHours + "h SLA)</option>");
} }
sb.Append("</select></div>"); sb.Append("</select></div>");
// Location dropdown
sb.Append("<div class='form-group'><label>Location</label><select name='location' class='form-control'>"); sb.Append("<div class='form-group'><label>Location</label><select name='location' class='form-control'>");
foreach (var l in config.Locations) foreach (var l in config.Locations)
{ {
var selected = (ticket?.LocationId ?? config.DefaultLocationId) == l.Id ? " selected" : ""; var sel = (ticket?.LocationId ?? config.DefaultLocationId) == l.Id ? " selected" : "";
sb.Append("<option value='" + l.Id + "'" + selected + ">" + l.Icon + " " + H(l.Name) + "</option>"); sb.Append("<option value='" + l.Id + "'" + sel + ">" + l.Icon + " " + H(l.Name) + "</option>");
} }
sb.Append("</select></div>"); sb.Append("</select></div>");
sb.Append("<div class='form-group'><label>Status</label><select name='status' class='form-control'><option value=''>— Use Disco Status —</option>");
// Status dropdown
sb.Append("<div class='form-group'><label>Status</label><select name='status' class='form-control'>");
sb.Append("<option value=''>— Use Disco Status —</option>");
foreach (var s in config.StatusOptions) foreach (var s in config.StatusOptions)
{ {
var selected = ticket?.StatusOverride == s ? " selected" : ""; var sel = ticket?.StatusOverride == s ? " selected" : "";
sb.Append("<option value='" + H(s) + "'" + selected + ">" + H(s) + "</option>"); sb.Append("<option value='" + H(s) + "'" + sel + ">" + H(s) + "</option>");
} }
sb.Append("</select></div>"); sb.Append("</select></div>");
sb.Append("<div class='form-group'><label>Assigned Tech (User ID)</label><input type='text' name='tech' class='form-control' value='" + H(ticket?.AssignedTechId) + "' placeholder='e.g. DOMAIN\\username'/></div>");
// Assigned tech sb.Append("<div class='form-group'><label>ETA</label><input type='date' name='eta' class='form-control' value='" + (ticket?.EstimatedCompletion?.ToString("yyyy-MM-dd") ?? "") + "'/></div>");
sb.Append("<div class='form-group'><label>Assigned Tech (User ID)</label>"); sb.Append("<div class='form-group'><label>Summary</label><textarea name='summary' class='form-control' rows='3' placeholder='Brief description of the issue...'>" + H(ticket?.Summary) + "</textarea></div>");
sb.Append("<input type='text' name='tech' class='form-control' value='" + H(ticket?.AssignedTechId) + "' placeholder='e.g. DOMAIN\\username'/></div>");
// ETA
sb.Append("<div class='form-group'><label>ETA</label>");
sb.Append("<input type='date' name='eta' class='form-control' value='" + (ticket?.EstimatedCompletion?.ToString("yyyy-MM-dd") ?? "") + "'/></div>");
// Summary
sb.Append("<div class='form-group'><label>Summary</label>");
sb.Append("<textarea name='summary' class='form-control' rows='3' placeholder='Brief description of the issue...'>" + H(ticket?.Summary) + "</textarea></div>");
sb.Append("<button type='submit' class='btn btn-primary'>&#x2714; Save Changes</button>"); sb.Append("<button type='submit' class='btn btn-primary'>&#x2714; Save Changes</button>");
sb.Append("</form></div>"); sb.Append("</form></div></div>");
sb.Append("</div>"); // end detail-left
// Right column - Notes/timeline // Right column - Notes
sb.Append("<div class='detail-right'>"); sb.Append("<div class='detail-right'><div class='detail-card'><h3>Activity Log</h3>");
sb.Append("<div class='detail-card'>");
sb.Append("<h3>Activity Log</h3>");
// Add note form
sb.Append("<form method='POST' action='" + pluginUrl + "/AddNote' class='note-form'>"); sb.Append("<form method='POST' action='" + pluginUrl + "/AddNote' class='note-form'>");
sb.Append("<input type='hidden' name='jobId' value='" + job.Id + "'/>"); sb.Append("<input type='hidden' name='jobId' value='" + job.Id + "'/>");
sb.Append("<textarea name='note' class='form-control' rows='2' placeholder='Add a note...' required></textarea>"); sb.Append("<textarea name='note' class='form-control' rows='2' placeholder='Add a note...' required></textarea>");
sb.Append("<div class='note-controls'>"); sb.Append("<div class='note-controls'><select name='noteType' class='form-control form-control-sm'>");
sb.Append("<select name='noteType' class='form-control form-control-sm'>"); sb.Append("<option value='general'>General</option><option value='update'>Update</option>");
sb.Append("<option value='general'>General</option>"); sb.Append("<option value='escalation'>Escalation</option><option value='resolution'>Resolution</option>");
sb.Append("<option value='update'>Update</option>"); sb.Append("</select><button type='submit' class='btn btn-primary btn-sm'>Add Note</button></div></form>");
sb.Append("<option value='escalation'>Escalation</option>");
sb.Append("<option value='resolution'>Resolution</option>");
sb.Append("</select>");
sb.Append("<button type='submit' class='btn btn-primary btn-sm'>Add Note</button>");
sb.Append("</div></form>");
// Notes timeline
if (ticket?.Notes != null && ticket.Notes.Count > 0) if (ticket?.Notes != null && ticket.Notes.Count > 0)
{ {
sb.Append("<div class='timeline'>"); sb.Append("<div class='timeline'>");
foreach (var note in ticket.Notes.OrderByDescending(n => n.Timestamp)) foreach (var note in ticket.Notes.OrderByDescending(n => n.Timestamp))
{ {
string typeColor = "#337AB7"; string tc = "#337AB7";
switch (note.NoteType) switch (note.NoteType) { case "escalation": tc = "#DC3545"; break; case "resolution": tc = "#28A745"; break; case "update": tc = "#FFC107"; break; }
{ sb.Append("<div class='timeline-item'><div class='timeline-dot' style='background:" + tc + ";'></div><div class='timeline-content'>");
case "escalation": typeColor = "#DC3545"; break; sb.Append("<div class='timeline-header'><span class='timeline-author'>" + H(note.AuthorName ?? note.AuthorId) + "</span>");
case "resolution": typeColor = "#28A745"; break; sb.Append("<span class='timeline-type' style='color:" + tc + ";'>" + H(note.NoteType) + "</span>");
case "update": typeColor = "#FFC107"; break; sb.Append("<span class='timeline-date'>" + note.Timestamp.ToString("dd MMM HH:mm") + "</span></div>");
} sb.Append("<div class='timeline-body'>" + H(note.Content) + "</div></div></div>");
sb.Append("<div class='timeline-item'>");
sb.Append("<div class='timeline-dot' style='background:" + typeColor + ";'></div>");
sb.Append("<div class='timeline-content'>");
sb.Append("<div class='timeline-header'>");
sb.Append("<span class='timeline-author'>" + H(note.AuthorName ?? note.AuthorId) + "</span>");
sb.Append("<span class='timeline-type' style='color:" + typeColor + ";'>" + H(note.NoteType) + "</span>");
sb.Append("<span class='timeline-date'>" + note.Timestamp.ToString("dd MMM HH:mm") + "</span>");
sb.Append("</div>");
sb.Append("<div class='timeline-body'>" + H(note.Content) + "</div>");
sb.Append("</div></div>");
} }
sb.Append("</div>"); sb.Append("</div>");
} }
else else
{
sb.Append("<p class='muted'>No notes yet.</p>"); sb.Append("<p class='muted'>No notes yet.</p>");
} sb.Append("</div></div></div>");
sb.Append("</div></div>"); // end detail-right
sb.Append("</div>"); // end detail-grid
sb.Append("</body></html>"); sb.Append("</body></html>");
return sb.ToString(); return sb.ToString();
} }
@@ -616,6 +456,14 @@ body{font-family:'Segoe UI',Arial,sans-serif;background:#f0f2f5;color:#333;paddi
.workload-item{background:#fff;border-radius:6px;padding:8px 16px;box-shadow:0 1px 2px rgba(0,0,0,.06);display:flex;align-items:center;gap:8px;} .workload-item{background:#fff;border-radius:6px;padding:8px 16px;box-shadow:0 1px 2px rgba(0,0,0,.06);display:flex;align-items:center;gap:8px;}
.wl-name{font-size:13px;} .wl-count{font-weight:700;font-size:16px;color:#337AB7;} .wl-name{font-size:13px;} .wl-count{font-weight:700;font-size:16px;color:#337AB7;}
.footer{text-align:center;padding:20px;font-size:12px;color:#aaa;} .footer{text-align:center;padding:20px;font-size:12px;color:#aaa;}
.refresh-bar{display:flex;align-items:center;gap:10px;padding:8px 24px;background:#fff;border-top:1px solid #eee;position:sticky;bottom:0;z-index:10;}
.refresh-bar.paused{background:#fff3cd;}
.refresh-text{font-size:12px;color:#888;min-width:140px;}
.refresh-progress{flex:1;height:4px;background:#e9ecef;border-radius:2px;overflow:hidden;}
.refresh-fill{height:100%;background:#337AB7;border-radius:2px;transition:width 1s linear;width:0%;}
.refresh-bar.paused .refresh-fill{background:#FFC107;}
.refresh-toggle{padding:4px 12px;font-size:11px;border:1px solid #ddd;border-radius:12px;background:#fff;cursor:pointer;color:#555;}
.refresh-toggle:hover{background:#f5f5f5;}
.muted{color:#999;font-style:italic;} .muted{color:#999;font-style:italic;}
"; ";
} }
@@ -623,8 +471,7 @@ body{font-family:'Segoe UI',Arial,sans-serif;background:#f0f2f5;color:#333;paddi
private string GetDetailCSS() private string GetDetailCSS()
{ {
return @" return @"
.back-link{font-size:13px;color:#337AB7;text-decoration:none;} .back-link{font-size:13px;color:#337AB7;text-decoration:none;} .back-link:hover{text-decoration:underline;}
.back-link:hover{text-decoration:underline;}
.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px;padding:20px 24px;} .detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px;padding:20px 24px;}
@media(max-width:900px){.detail-grid{grid-template-columns:1fr;}} @media(max-width:900px){.detail-grid{grid-template-columns:1fr;}}
.detail-left,.detail-right{display:flex;flex-direction:column;gap:16px;} .detail-left,.detail-right{display:flex;flex-direction:column;gap:16px;}