using Disco.Plugins.ServiceTracker.Models;
using Disco.Plugins.ServiceTracker.Services;
using Disco.Services.Plugins;
using System;
using System.Linq;
using System.Text;
using System.Web;
using System.Web.Mvc;
namespace Disco.Plugins.ServiceTracker.WebHandler
{
public class ServiceTrackerWebHandler : PluginWebHandler
{
private ServiceTrackerDataStore GetDataStore()
{
var dataPath = System.IO.Path.Combine(
AppDomain.CurrentDomain.BaseDirectory, "App_Data", "Plugins", "Disco.Plugins.ServiceTracker");
return new ServiceTrackerDataStore(dataPath);
}
public override ActionResult ExecuteAction(string ActionName)
{
var action = ActionName != null ? ActionName.ToLower() : "";
switch (action)
{
case "":
case "index":
case "dashboard":
return Dashboard();
case "update":
return UpdateTicket();
case "addnote":
return AddNote();
case "detail":
return TicketDetail();
case "export":
return ExportCsv();
default:
return new HttpNotFoundResult();
}
}
private ActionResult Dashboard()
{
var dataStore = GetDataStore();
var service = new ServiceTrackerService(Database, dataStore);
var filterPriority = HostController.Request.QueryString["priority"];
var filterLocation = HostController.Request.QueryString["location"];
var filterStatus = HostController.Request.QueryString["status"];
var filterTech = HostController.Request.QueryString["tech"];
var sortBy = HostController.Request.QueryString["sort"] ?? "due";
var model = service.BuildDashboard(filterPriority, filterLocation, filterStatus, filterTech, sortBy);
return HtmlResult(BuildDashboardPage(model));
}
private ActionResult UpdateTicket()
{
if (HostController.Request.HttpMethod != "POST")
return new HttpStatusCodeResult(405);
var dataStore = GetDataStore();
var service = new ServiceTrackerService(Database, dataStore);
int jobId;
if (!int.TryParse(HostController.Request.Form["jobId"], out jobId))
return new HttpStatusCodeResult(400);
var priorityId = HostController.Request.Form["priority"];
var locationId = HostController.Request.Form["location"];
var techId = HostController.Request.Form["tech"];
var status = HostController.Request.Form["status"];
var summary = HostController.Request.Form["summary"];
DateTime? eta = null;
DateTime etaParsed;
if (DateTime.TryParse(HostController.Request.Form["eta"], out etaParsed))
eta = etaParsed;
var currentUser = GetCurrentUser();
service.UpdateTicket(jobId, priorityId, locationId, techId, eta, status, summary, currentUser);
return new RedirectResult("/Plugin/Disco.Plugins.ServiceTracker/Dashboard");
}
private ActionResult AddNote()
{
if (HostController.Request.HttpMethod != "POST")
return new HttpStatusCodeResult(405);
var dataStore = GetDataStore();
var service = new ServiceTrackerService(Database, dataStore);
int jobId;
if (!int.TryParse(HostController.Request.Form["jobId"], out jobId))
return new HttpStatusCodeResult(400);
var content = HostController.Request.Form["note"];
var noteType = HostController.Request.Form["noteType"] ?? "general";
var currentUser = GetCurrentUser();
service.AddNote(jobId, currentUser, currentUser, content, noteType);
return new RedirectResult("/Plugin/Disco.Plugins.ServiceTracker/Detail?id=" + jobId);
}
private ActionResult TicketDetail()
{
int jobId;
if (!int.TryParse(HostController.Request.QueryString["id"], out jobId))
return new HttpStatusCodeResult(400);
var dataStore = GetDataStore();
var service = new ServiceTrackerService(Database, dataStore);
var config = dataStore.LoadConfig();
var job = Database.Jobs
.Include("Device").Include("Device.DeviceModel")
.Include("User").Include("OpenedTechUser")
.Include("JobType").Include("JobSubTypes")
.Include("JobLogs")
.FirstOrDefault(j => j.Id == jobId);
if (job == null)
return new HttpNotFoundResult();
var ticket = service.GetTicketDetail(jobId);
return HtmlResult(BuildDetailPage(job, ticket, config));
}
private ActionResult ExportCsv()
{
var dataStore = GetDataStore();
var service = new ServiceTrackerService(Database, dataStore);
var model = service.BuildDashboard();
var sb = new StringBuilder();
sb.AppendLine("JobId,Device,User,Priority,Location,Status,AssignedTech,OpenedDate,ETA,SlaDeadline,SlaBreached,AgeDays,Summary,NoteCount");
foreach (var t in model.Tiles)
{
var etaStr = t.EstimatedCompletion.HasValue ? t.EstimatedCompletion.Value.ToString("yyyy-MM-dd") : "";
var slaStr = t.SlaDeadline.HasValue ? t.SlaDeadline.Value.ToString("yyyy-MM-dd HH:mm") : "";
sb.AppendLine(string.Join(",",
t.JobId, Csv(t.DeviceSerialNumber), Csv(t.UserDisplayName),
Csv(t.PriorityName), Csv(t.LocationName), Csv(t.StatusOverride),
Csv(t.AssignedTechName), t.OpenedDate.ToString("yyyy-MM-dd"),
etaStr, slaStr, t.IsSlaBreached, t.AgeDays, Csv(t.Summary), t.NoteCount));
}
var fileName = "ServiceTracker_Export_" + DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".csv";
HostController.Response.Headers.Add("Content-Disposition", "attachment; filename=\"" + fileName + "\"");
return new ContentResult { Content = sb.ToString(), ContentType = "text/csv", ContentEncoding = Encoding.UTF8 };
}
// --- Helpers ---
private string GetCurrentUser()
{
if (HostController.HttpContext.User != null && HostController.HttpContext.User.Identity != null)
return HostController.HttpContext.User.Identity.Name ?? "system";
return "system";
}
private ActionResult HtmlResult(string html)
{
return new ContentResult { Content = html, ContentType = "text/html", ContentEncoding = Encoding.UTF8 };
}
private string Csv(string v) { return "\"" + (v ?? "").Replace("\"", "\"\"") + "\""; }
private string H(string v) { return string.IsNullOrEmpty(v) ? "" : HttpUtility.HtmlEncode(v); }
// --- Safe accessors for job navigation properties (C#5 compatible) ---
private string SafeDeviceDomainId(Disco.Models.Repository.Job job)
{
return job.Device != null ? job.Device.DeviceDomainId : null;
}
private string SafeDeviceModelDesc(Disco.Models.Repository.Job job)
{
return (job.Device != null && job.Device.DeviceModel != null) ? job.Device.DeviceModel.Description : null;
}
private string SafeUserDisplay(Disco.Models.Repository.Job job)
{
return job.User != null ? job.User.DisplayName : job.UserId;
}
private string SafeJobTypeDesc(Disco.Models.Repository.Job job)
{
return job.JobType != null ? job.JobType.Description : job.JobTypeId;
}
private string SafeTechDisplay(Disco.Models.Repository.Job job)
{
return job.OpenedTechUser != null ? job.OpenedTechUser.DisplayName : job.OpenedTechUserId;
}
private string SafeTicketStr(ServiceTicket ticket, string field)
{
if (ticket == null) return null;
switch (field)
{
case "PriorityId": return ticket.PriorityId;
case "LocationId": return ticket.LocationId;
case "AssignedTechId": return ticket.AssignedTechId;
case "StatusOverride": return ticket.StatusOverride;
case "Summary": return ticket.Summary;
default: return null;
}
}
// --- HTML Builders ---
private string BuildDashboardPage(DashboardViewModel model)
{
var pluginUrl = "/Plugin/Disco.Plugins.ServiceTracker";
var sb = new StringBuilder();
sb.Append("
");
sb.Append("Service Tracker Dashboard");
sb.Append("");
// Header
sb.Append("");
// SLA Alerts
if (model.Stats.SlaBreached > 0)
sb.Append("⚠ " + model.Stats.SlaBreached + " job(s) have BREACHED SLA — immediate attention required!
");
if (model.Stats.SlaWarning > 0)
sb.Append("⏰ " + model.Stats.SlaWarning + " job(s) approaching SLA deadline
");
// Stats Bar
sb.Append("");
foreach (var p in model.Config.Priorities)
{
int count; model.Stats.ByPriority.TryGetValue(p.Id, out count);
sb.Append("
");
sb.Append("" + count + "
");
sb.Append("" + H(p.Name) + "
");
}
sb.Append("
" + model.Stats.SlaBreached + "
SLA Breached
");
sb.Append("
" + model.Stats.AvgAgeDays.ToString("0.0") + "
Avg Age (days)
");
sb.Append("
");
// Location Summary
sb.Append("");
// Sort Controls
sb.Append("Sort by:");
string[] sortOptions = { "due|Due Date", "priority|Priority", "age|Age", "sla|SLA Status", "modified|Last Updated" };
foreach (var opt in sortOptions)
{
var parts = opt.Split('|');
var active = parts[0] == model.SortBy ? " active" : "";
sb.Append("
" + parts[1] + "");
}
if (!string.IsNullOrEmpty(model.CurrentFilter))
sb.Append("
✖ Clear Filter");
sb.Append("
");
// Priority Legend
sb.Append("Priority:");
foreach (var p in model.Config.Priorities)
{
sb.Append("" + H(p.Name));
if (p.SlaHours > 0) sb.Append(" (" + p.SlaHours + "h SLA)");
sb.Append("");
}
sb.Append("
");
// Tile Grid
sb.Append("");
if (model.Tiles.Count == 0)
{
sb.Append("
");
}
else
{
foreach (var tile in model.Tiles)
sb.Append(BuildTileHtml(tile, pluginUrl));
}
sb.Append("
");
// Tech Workload
if (model.Stats.ByTech.Count > 0)
{
sb.Append("Tech Workload
");
foreach (var kv in model.Stats.ByTech.OrderByDescending(x => x.Value))
sb.Append("
" + H(kv.Key) + "" + kv.Value + "
");
sb.Append("
");
}
// Auto-refresh bar
var refreshSeconds = model.Config.DashboardRefreshSeconds;
sb.Append("");
sb.Append("
Auto-refresh in " + refreshSeconds + "s");
sb.Append("
");
sb.Append("
");
sb.Append("
");
sb.Append("");
// Auto-refresh JavaScript
sb.Append("");
sb.Append("");
return sb.ToString();
}
private string BuildTileHtml(DashboardTile tile, string pluginUrl)
{
var sb = new StringBuilder();
string borderClass = tile.IsSlaBreached ? "tile-breached" : tile.IsSlaWarning ? "tile-warning" : "";
sb.Append("");
sb.Append("
");
sb.Append("");
if (tile.IsSlaBreached)
sb.Append("
⚠ SLA BREACHED
");
else if (tile.IsSlaWarning)
sb.Append("
⏰ SLA Warning
");
sb.Append("
" + H(tile.DeviceComputerName ?? tile.DeviceSerialNumber) + "
");
if (tile.DeviceModelDescription != null)
sb.Append("
" + H(tile.DeviceModelDescription) + "
");
sb.Append("
");
sb.Append("
👤" + H(tile.UserDisplayName ?? "—") + "
");
sb.Append("
" + tile.LocationIcon + " " + H(tile.LocationName) + "
");
sb.Append("
📋" + H(tile.StatusOverride ?? tile.DiscoStatus) + "
");
if (!string.IsNullOrEmpty(tile.AssignedTechName))
sb.Append("
🔧" + H(tile.AssignedTechName) + "
");
sb.Append("
📅ETA: " + H(tile.EtaDisplay) + "
");
if (!string.IsNullOrEmpty(tile.Summary))
sb.Append("
" + H(tile.Summary) + "
");
if (!string.IsNullOrEmpty(tile.LatestNote))
{
sb.Append("
💬 " + H(tile.LatestNote));
if (tile.NoteCount > 1) sb.Append(" (+" + (tile.NoteCount - 1) + " more)");
sb.Append("
");
}
sb.Append("");
sb.Append("
");
return sb.ToString();
}
private string BuildDetailPage(Disco.Models.Repository.Job job, ServiceTicket ticket, ServiceTrackerConfig config)
{
var pluginUrl = "/Plugin/Disco.Plugins.ServiceTracker";
var sb = new StringBuilder();
sb.Append("");
sb.Append("Job #" + job.Id + " - Service Tracker");
sb.Append("");
sb.Append("");
sb.Append("");
// Left column - Job info
sb.Append("
Job Details
");
var domainId = SafeDeviceDomainId(job);
sb.Append("| Device | " + H(job.DeviceSerialNumber) + (domainId != null ? " (" + H(domainId) + ")" : "") + " |
");
sb.Append("| Model | " + H(SafeDeviceModelDesc(job)) + " |
");
sb.Append("| User | " + H(SafeUserDisplay(job)) + " |
");
sb.Append("| Type | " + H(SafeJobTypeDesc(job)) + " |
");
sb.Append("| Opened | " + job.OpenedDate.ToString("dd MMM yyyy HH:mm") + " by " + H(SafeTechDisplay(job)) + " |
");
if (job.ExpectedClosedDate.HasValue)
sb.Append("| Expected Close | " + job.ExpectedClosedDate.Value.ToString("dd MMM yyyy") + " |
");
if (job.DeviceHeld.HasValue)
sb.Append("| Device Held | " + job.DeviceHeld.Value.ToString("dd MMM yyyy") + (job.DeviceHeldLocation != null ? " — " + H(job.DeviceHeldLocation) : "") + " |
");
sb.Append("
");
// Edit form
var ticketPriority = SafeTicketStr(ticket, "PriorityId") ?? config.DefaultPriorityId;
var ticketLocation = SafeTicketStr(ticket, "LocationId") ?? config.DefaultLocationId;
var ticketStatus = SafeTicketStr(ticket, "StatusOverride");
var ticketTech = SafeTicketStr(ticket, "AssignedTechId") ?? "";
var ticketSummary = SafeTicketStr(ticket, "Summary") ?? "";
var ticketEta = (ticket != null && ticket.EstimatedCompletion.HasValue) ? ticket.EstimatedCompletion.Value.ToString("yyyy-MM-dd") : "";
sb.Append("
Service Tracker Settings
");
sb.Append("
");
// Right column - Notes
sb.Append("
Activity Log
");
sb.Append("
");
if (ticket != null && ticket.Notes != null && ticket.Notes.Count > 0)
{
sb.Append("
");
foreach (var note in ticket.Notes.OrderByDescending(n => n.Timestamp))
{
string tc = "#337AB7";
switch (note.NoteType)
{
case "escalation": tc = "#DC3545"; break;
case "resolution": tc = "#28A745"; break;
case "update": tc = "#FFC107"; break;
}
var authorDisplay = note.AuthorName != null ? note.AuthorName : note.AuthorId;
sb.Append("
");
sb.Append("");
sb.Append("
" + H(note.Content) + "
");
}
sb.Append("
");
}
else
{
sb.Append("
No notes yet.
");
}
sb.Append("
");
sb.Append("");
return sb.ToString();
}
private string GetDashboardCSS()
{
return @"
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:'Segoe UI',Arial,sans-serif;background:#f0f2f5;color:#333;padding:0;}
.header{display:flex;justify-content:space-between;align-items:center;padding:16px 24px;background:#fff;border-bottom:2px solid #337AB7;box-shadow:0 1px 3px rgba(0,0,0,.08);}
.header-left{display:flex;align-items:center;gap:16px;}
.header h1{font-size:22px;margin:0;color:#333;}
.subtitle{color:#666;font-size:14px;}
.header-right{display:flex;gap:8px;}
.btn{display:inline-block;padding:8px 16px;font-size:13px;border:none;border-radius:4px;cursor:pointer;text-decoration:none;color:#fff;}
.btn-primary{background:#337AB7;} .btn-primary:hover{background:#286090;}
.btn-default{background:#777;} .btn-default:hover{background:#555;}
.btn-sm{padding:5px 10px;font-size:12px;}
.alert{margin:0 24px;padding:10px 16px;border-radius:4px;font-size:13px;margin-top:12px;}
.alert-danger{background:#f8d7da;color:#721c24;border:1px solid #f5c6cb;}
.alert-warning{background:#fff3cd;color:#856404;border:1px solid #ffeeba;}
.stats-bar{display:flex;gap:12px;padding:16px 24px;flex-wrap:wrap;}
.stat-card{text-align:center;padding:12px 18px;background:#fff;border-radius:6px;min-width:80px;box-shadow:0 1px 2px rgba(0,0,0,.06);text-decoration:none;color:inherit;transition:transform .15s;}
.stat-card:hover{transform:translateY(-2px);box-shadow:0 3px 8px rgba(0,0,0,.12);}
.stat-sep{border-left:2px solid #eee;margin-left:8px;padding-left:20px;}
.stat-num{font-size:26px;font-weight:700;} .stat-lbl{font-size:11px;color:#888;margin-top:2px;}
.location-bar{display:flex;gap:8px;padding:0 24px;flex-wrap:wrap;margin-bottom:8px;}
.loc-chip{display:inline-flex;align-items:center;gap:4px;padding:5px 12px;border-radius:16px;font-size:12px;color:#fff;text-decoration:none;transition:opacity .15s;}
.loc-chip:hover{opacity:.85;}
.controls{display:flex;align-items:center;gap:6px;padding:8px 24px;}
.ctrl-label{font-size:12px;color:#888;margin-right:4px;}
.sort-btn{padding:5px 12px;border-radius:14px;font-size:12px;background:#e9ecef;color:#555;text-decoration:none;transition:background .15s;}
.sort-btn:hover{background:#d0d5db;} .sort-btn.active{background:#337AB7;color:#fff;}
.clear-btn{background:#DC3545;color:#fff;} .clear-btn:hover{background:#b52a3a;}
.legend{display:flex;align-items:center;gap:12px;padding:4px 24px 12px;flex-wrap:wrap;}
.legend-item{display:flex;align-items:center;gap:4px;font-size:12px;color:#666;}
.legend-dot{width:10px;height:10px;border-radius:50%;display:inline-block;}
.legend-item small{color:#aaa;}
.tile-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;padding:0 24px 24px;}
.tile{background:#fff;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,.08);cursor:pointer;transition:box-shadow .15s,transform .15s;overflow:hidden;position:relative;}
.tile:hover{box-shadow:0 4px 12px rgba(0,0,0,.15);transform:translateY(-2px);}
.tile-breached{border:2px solid #DC3545;animation:pulse 2s infinite;}
.tile-warning{border:2px solid #FFC107;}
@keyframes pulse{0%,100%{border-color:#DC3545;}50%{border-color:#f8d7da;}}
.tile-priority{height:5px;width:100%;}
.tile-header{display:flex;justify-content:space-between;padding:10px 14px 4px;}
.tile-jobid{font-weight:700;font-size:15px;color:#337AB7;}
.tile-age{font-size:11px;color:#888;padding:2px 8px;background:#f5f5f5;border-radius:10px;}
.sla-badge{margin:0 14px 6px;padding:4px 8px;border-radius:4px;font-size:11px;font-weight:700;text-align:center;}
.sla-breached{background:#f8d7da;color:#721c24;}
.sla-warn{background:#fff3cd;color:#856404;}
.tile-device{padding:0 14px 6px;}
.tile-device-name{font-weight:600;font-size:14px;} .tile-device-model{font-size:11px;color:#888;}
.tile-row{display:flex;align-items:center;gap:6px;padding:3px 14px;font-size:13px;}
.tile-icon{width:18px;text-align:center;font-size:12px;}
.tile-value{flex:1;} .tile-value strong{font-weight:600;}
.tile-loc-badge{display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:12px;font-size:11px;color:#fff;}
.tile-summary{padding:6px 14px;font-size:12px;color:#555;border-top:1px solid #f0f0f0;font-style:italic;}
.tile-note{padding:6px 14px;font-size:11px;color:#777;background:#fafbfc;}
.note-icon{font-size:10px;}
.tile-footer{display:flex;justify-content:space-between;align-items:center;padding:8px 14px;border-top:1px solid #f0f0f0;background:#fafbfc;}
.priority-label{display:inline-block;padding:2px 10px;border-radius:10px;font-size:11px;color:#fff;font-weight:600;}
.tile-type{font-size:11px;color:#aaa;}
.empty-state{grid-column:1/-1;text-align:center;padding:60px 20px;}
.empty-icon{font-size:48px;} .empty-msg{font-size:16px;color:#888;margin-top:12px;}
.workload-section{padding:0 24px 24px;}
.workload-section h3{font-size:14px;color:#888;margin-bottom:8px;}
.workload-bar{display:flex;gap:12px;flex-wrap:wrap;}
.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;}
.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;}
";
}
private string GetDetailCSS()
{
return @"
.back-link{font-size:13px;color:#337AB7;text-decoration:none;} .back-link:hover{text-decoration:underline;}
.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px;padding:20px 24px;}
@media(max-width:900px){.detail-grid{grid-template-columns:1fr;}}
.detail-left,.detail-right{display:flex;flex-direction:column;gap:16px;}
.detail-card{background:#fff;border-radius:8px;padding:20px;box-shadow:0 1px 3px rgba(0,0,0,.08);}
.detail-card h3{font-size:15px;color:#333;margin-bottom:12px;border-bottom:1px solid #eee;padding-bottom:8px;}
.detail-table{width:100%;border-collapse:collapse;}
.detail-table th{text-align:left;padding:6px 8px;font-size:12px;color:#888;width:130px;vertical-align:top;}
.detail-table td{padding:6px 8px;font-size:13px;}
.form-group{margin-bottom:12px;}
.form-group label{display:block;font-size:12px;font-weight:600;color:#555;margin-bottom:4px;}
.form-control{width:100%;padding:7px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;font-family:inherit;}
.form-control:focus{border-color:#337AB7;outline:none;box-shadow:0 0 0 2px rgba(51,122,183,.15);}
.form-control-sm{width:auto;padding:4px 8px;font-size:12px;}
.note-form{margin-bottom:16px;}
.note-controls{display:flex;gap:8px;margin-top:6px;align-items:center;}
.timeline{border-left:2px solid #e0e0e0;margin-left:8px;padding-left:0;}
.timeline-item{display:flex;gap:12px;padding:8px 0;position:relative;}
.timeline-dot{width:10px;height:10px;border-radius:50%;margin-top:4px;flex-shrink:0;margin-left:-6px;}
.timeline-content{flex:1;}
.timeline-header{display:flex;gap:8px;align-items:center;font-size:12px;margin-bottom:3px;}
.timeline-author{font-weight:600;color:#333;}
.timeline-type{font-size:11px;text-transform:capitalize;}
.timeline-date{color:#aaa;margin-left:auto;}
.timeline-body{font-size:13px;color:#555;line-height:1.4;}
";
}
}
}