feat: add Disco job link, GUI config editor, change history, version display
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
using Disco.Plugins.ServiceTracker.Models;
|
using Disco.Plugins.ServiceTracker.Models;
|
||||||
using Disco.Plugins.ServiceTracker.Services;
|
using Disco.Plugins.ServiceTracker.Services;
|
||||||
using Disco.Services.Plugins;
|
using Disco.Services.Plugins;
|
||||||
|
using Newtonsoft.Json;
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Web;
|
using System.Web;
|
||||||
@@ -23,20 +25,14 @@ namespace Disco.Plugins.ServiceTracker.WebHandler
|
|||||||
var action = ActionName != null ? ActionName.ToLower() : "";
|
var action = ActionName != null ? ActionName.ToLower() : "";
|
||||||
switch (action)
|
switch (action)
|
||||||
{
|
{
|
||||||
case "":
|
case "": case "index": case "dashboard": return Dashboard();
|
||||||
case "index":
|
case "update": return UpdateTicket();
|
||||||
case "dashboard":
|
case "addnote": return AddNote();
|
||||||
return Dashboard();
|
case "detail": return TicketDetail();
|
||||||
case "update":
|
case "export": return ExportCsv();
|
||||||
return UpdateTicket();
|
case "config": return ConfigEditor();
|
||||||
case "addnote":
|
case "saveconfig": return SaveConfig();
|
||||||
return AddNote();
|
default: return new HttpNotFoundResult();
|
||||||
case "detail":
|
|
||||||
return TicketDetail();
|
|
||||||
case "export":
|
|
||||||
return ExportCsv();
|
|
||||||
default:
|
|
||||||
return new HttpNotFoundResult();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,59 +51,48 @@ namespace Disco.Plugins.ServiceTracker.WebHandler
|
|||||||
|
|
||||||
private ActionResult UpdateTicket()
|
private ActionResult UpdateTicket()
|
||||||
{
|
{
|
||||||
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 currentUser = GetCurrentUser();
|
||||||
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? eta = null;
|
||||||
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;
|
service.UpdateTicket(jobId,
|
||||||
var currentUser = GetCurrentUser();
|
HostController.Request.Form["priority"], HostController.Request.Form["location"],
|
||||||
service.UpdateTicket(jobId, priorityId, locationId, techId, eta, status, summary, currentUser);
|
HostController.Request.Form["tech"], eta,
|
||||||
return new RedirectResult("/Plugin/Disco.Plugins.ServiceTracker/Dashboard");
|
HostController.Request.Form["status"], HostController.Request.Form["summary"], currentUser);
|
||||||
|
return new RedirectResult("/Plugin/Disco.Plugins.ServiceTracker/Detail?id=" + jobId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ActionResult AddNote()
|
private ActionResult AddNote()
|
||||||
{
|
{
|
||||||
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 noteType = HostController.Request.Form["noteType"] ?? "general";
|
|
||||||
var currentUser = GetCurrentUser();
|
var currentUser = GetCurrentUser();
|
||||||
service.AddNote(jobId, currentUser, currentUser, content, noteType);
|
service.AddNote(jobId, currentUser, currentUser,
|
||||||
|
HostController.Request.Form["note"], HostController.Request.Form["noteType"] ?? "general");
|
||||||
return new RedirectResult("/Plugin/Disco.Plugins.ServiceTracker/Detail?id=" + jobId);
|
return new RedirectResult("/Plugin/Disco.Plugins.ServiceTracker/Detail?id=" + jobId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ActionResult TicketDetail()
|
private ActionResult TicketDetail()
|
||||||
{
|
{
|
||||||
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();
|
||||||
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));
|
||||||
}
|
}
|
||||||
@@ -123,17 +108,74 @@ namespace Disco.Plugins.ServiceTracker.WebHandler
|
|||||||
{
|
{
|
||||||
var etaStr = t.EstimatedCompletion.HasValue ? t.EstimatedCompletion.Value.ToString("yyyy-MM-dd") : "";
|
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") : "";
|
var slaStr = t.SlaDeadline.HasValue ? t.SlaDeadline.Value.ToString("yyyy-MM-dd HH:mm") : "";
|
||||||
sb.AppendLine(string.Join(",",
|
sb.AppendLine(string.Join(",", t.JobId, Csv(t.DeviceSerialNumber), Csv(t.UserDisplayName),
|
||||||
t.JobId, Csv(t.DeviceSerialNumber), Csv(t.UserDisplayName),
|
Csv(t.PriorityName), Csv(t.LocationName), Csv(t.StatusOverride), Csv(t.AssignedTechName),
|
||||||
Csv(t.PriorityName), Csv(t.LocationName), Csv(t.StatusOverride),
|
t.OpenedDate.ToString("yyyy-MM-dd"), etaStr, slaStr, t.IsSlaBreached, t.AgeDays, Csv(t.Summary), t.NoteCount));
|
||||||
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=\"ServiceTracker_" + 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 };
|
return new ContentResult { Content = sb.ToString(), ContentType = "text/csv", ContentEncoding = Encoding.UTF8 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ActionResult ConfigEditor()
|
||||||
|
{
|
||||||
|
var dataStore = GetDataStore();
|
||||||
|
var config = dataStore.LoadConfig();
|
||||||
|
var saved = HostController.Request.QueryString["saved"] == "1";
|
||||||
|
return HtmlResult(BuildConfigPage(config, saved));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ActionResult SaveConfig()
|
||||||
|
{
|
||||||
|
if (HostController.Request.HttpMethod != "POST") return new HttpStatusCodeResult(405);
|
||||||
|
var dataStore = GetDataStore();
|
||||||
|
var config = dataStore.LoadConfig();
|
||||||
|
|
||||||
|
// General settings
|
||||||
|
int refresh;
|
||||||
|
if (int.TryParse(HostController.Request.Form["refreshSeconds"], out refresh) && refresh >= 10)
|
||||||
|
config.DashboardRefreshSeconds = refresh;
|
||||||
|
config.AutoCreateTicketsForNewJobs = HostController.Request.Form["autoCreate"] == "on";
|
||||||
|
var dp = HostController.Request.Form["defaultPriority"];
|
||||||
|
if (!string.IsNullOrEmpty(dp)) config.DefaultPriorityId = dp;
|
||||||
|
var dl = HostController.Request.Form["defaultLocation"];
|
||||||
|
if (!string.IsNullOrEmpty(dl)) config.DefaultLocationId = dl;
|
||||||
|
|
||||||
|
// Status options
|
||||||
|
var statusRaw = HostController.Request.Form["statusOptions"];
|
||||||
|
if (statusRaw != null)
|
||||||
|
{
|
||||||
|
config.StatusOptions = statusRaw.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(s => s.Trim()).Where(s => s.Length > 0).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priorities JSON
|
||||||
|
var priJson = HostController.Request.Form["prioritiesJson"];
|
||||||
|
if (!string.IsNullOrEmpty(priJson))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var list = JsonConvert.DeserializeObject<List<PriorityLevel>>(priJson);
|
||||||
|
if (list != null && list.Count > 0) config.Priorities = list;
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locations JSON
|
||||||
|
var locJson = HostController.Request.Form["locationsJson"];
|
||||||
|
if (!string.IsNullOrEmpty(locJson))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var list = JsonConvert.DeserializeObject<List<DeviceLocation>>(locJson);
|
||||||
|
if (list != null && list.Count > 0) config.Locations = list;
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
dataStore.SaveConfig(config);
|
||||||
|
return new RedirectResult("/Plugin/Disco.Plugins.ServiceTracker/Config?saved=1");
|
||||||
|
}
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
private string GetCurrentUser()
|
private string GetCurrentUser()
|
||||||
@@ -142,66 +184,32 @@ namespace Disco.Plugins.ServiceTracker.WebHandler
|
|||||||
return HostController.HttpContext.User.Identity.Name ?? "system";
|
return HostController.HttpContext.User.Identity.Name ?? "system";
|
||||||
return "system";
|
return "system";
|
||||||
}
|
}
|
||||||
|
private ActionResult HtmlResult(string html) { return new ContentResult { Content = html, ContentType = "text/html", ContentEncoding = Encoding.UTF8 }; }
|
||||||
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 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); }
|
||||||
|
private string SafeDeviceDomainId(Disco.Models.Repository.Job j) { return j.Device != null ? j.Device.DeviceDomainId : null; }
|
||||||
|
private string SafeDeviceModelDesc(Disco.Models.Repository.Job j) { return (j.Device != null && j.Device.DeviceModel != null) ? j.Device.DeviceModel.Description : null; }
|
||||||
|
private string SafeUserDisplay(Disco.Models.Repository.Job j) { return j.User != null ? j.User.DisplayName : j.UserId; }
|
||||||
|
private string SafeJobTypeDesc(Disco.Models.Repository.Job j) { return j.JobType != null ? j.JobType.Description : j.JobTypeId; }
|
||||||
|
private string SafeTechDisplay(Disco.Models.Repository.Job j) { return j.OpenedTechUser != null ? j.OpenedTechUser.DisplayName : j.OpenedTechUserId; }
|
||||||
|
|
||||||
// --- Safe accessors for job navigation properties (C#5 compatible) ---
|
// ===================== DASHBOARD PAGE =====================
|
||||||
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)
|
private string BuildDashboardPage(DashboardViewModel model)
|
||||||
{
|
{
|
||||||
var pluginUrl = "/Plugin/Disco.Plugins.ServiceTracker";
|
var u = "/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'/><title>Service Tracker</title><style>");
|
||||||
sb.Append("<title>Service Tracker Dashboard</title>");
|
sb.Append(GetDashboardCSS()); sb.Append("</style></head><body>");
|
||||||
sb.Append("<style>"); sb.Append(GetDashboardCSS()); sb.Append("</style></head><body>");
|
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
sb.Append("<div class='header'><div class='header-left'>");
|
sb.Append("<div class='header'><div class='header-left'>");
|
||||||
sb.Append("<h1>🛠 Service Tracker</h1>");
|
sb.Append("<h1>🛠 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><div class='header-right'>");
|
sb.Append("</div><div class='header-right'>");
|
||||||
sb.Append("<a href='" + pluginUrl + "/Export' class='btn btn-default'>⬇ Export CSV</a>");
|
sb.Append("<a href='" + u + "/Config' class='btn btn-default'>⚙ Config</a>");
|
||||||
sb.Append("<a href='" + pluginUrl + "/Dashboard' class='btn btn-primary'>↻ Refresh</a>");
|
sb.Append("<a href='" + u + "/Export' class='btn btn-default'>⬇ Export</a>");
|
||||||
|
sb.Append("<a href='" + u + "/Dashboard' class='btn btn-primary'>↻ Refresh</a>");
|
||||||
sb.Append("</div></div>");
|
sb.Append("</div></div>");
|
||||||
|
|
||||||
// SLA Alerts
|
// SLA Alerts
|
||||||
@@ -210,66 +218,55 @@ namespace Disco.Plugins.ServiceTracker.WebHandler
|
|||||||
if (model.Stats.SlaWarning > 0)
|
if (model.Stats.SlaWarning > 0)
|
||||||
sb.Append("<div class='alert alert-warning'>⏰ <strong>" + model.Stats.SlaWarning + " job(s) approaching SLA deadline</strong></div>");
|
sb.Append("<div class='alert alert-warning'>⏰ <strong>" + model.Stats.SlaWarning + " job(s) approaching SLA deadline</strong></div>");
|
||||||
|
|
||||||
// Stats Bar
|
// Stats
|
||||||
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; model.Stats.ByPriority.TryGetValue(p.Id, out count);
|
int c; model.Stats.ByPriority.TryGetValue(p.Id, out c);
|
||||||
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='" + u + "/Dashboard?priority=" + p.Id + "&sort=" + model.SortBy + "' class='stat-card' style='border-left:4px solid " + p.Color + ";'><div class='stat-num' style='color:" + p.Color + ";'>" + c + "</div><div class='stat-lbl'>" + H(p.Name) + "</div></a>");
|
||||||
sb.Append("<div class='stat-num' style='color:" + p.Color + ";'>" + count + "</div>");
|
|
||||||
sb.Append("<div class='stat-lbl'>" + H(p.Name) + "</div></a>");
|
|
||||||
}
|
}
|
||||||
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-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-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-card'><div class='stat-num'>" + model.Stats.AvgAgeDays.ToString("0.0") + "</div><div class='stat-lbl'>Avg Age (days)</div></div></div>");
|
||||||
sb.Append("</div>");
|
|
||||||
|
|
||||||
// Location Summary
|
// Location chips
|
||||||
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; model.Stats.ByLocation.TryGetValue(loc.Id, out count);
|
int c; model.Stats.ByLocation.TryGetValue(loc.Id, out c);
|
||||||
if (count > 0)
|
if (c > 0) sb.Append("<a href='" + u + "/Dashboard?location=" + loc.Id + "' class='loc-chip' style='background:" + loc.Color + ";'>" + loc.Icon + " " + H(loc.Name) + " <strong>" + c + "</strong></a>");
|
||||||
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("</div>");
|
sb.Append("</div>");
|
||||||
|
|
||||||
// Sort Controls
|
// Sort controls
|
||||||
sb.Append("<div class='controls'><span class='ctrl-label'>Sort by:</span>");
|
sb.Append("<div class='controls'><span class='ctrl-label'>Sort by:</span>");
|
||||||
string[] sortOptions = { "due|Due Date", "priority|Priority", "age|Age", "sla|SLA Status", "modified|Last Updated" };
|
foreach (var opt in new[] { "due|Due Date", "priority|Priority", "age|Age", "sla|SLA Status", "modified|Last Updated" })
|
||||||
foreach (var opt in sortOptions)
|
|
||||||
{
|
{
|
||||||
var parts = opt.Split('|');
|
var p = opt.Split('|');
|
||||||
var active = parts[0] == model.SortBy ? " active" : "";
|
sb.Append("<a href='" + u + "/Dashboard?sort=" + p[0] + "' class='sort-btn" + (p[0] == model.SortBy ? " active" : "") + "'>" + p[1] + "</a>");
|
||||||
sb.Append("<a href='" + pluginUrl + "/Dashboard?sort=" + parts[0] + "' 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'>✖ Clear Filter</a>");
|
sb.Append("<a href='" + u + "/Dashboard' class='sort-btn clear-btn'>✖ Clear Filter</a>");
|
||||||
sb.Append("</div>");
|
sb.Append("</div>");
|
||||||
|
|
||||||
// Priority Legend
|
// Legend
|
||||||
sb.Append("<div class='legend'><span class='ctrl-label'>Priority:</span>");
|
sb.Append("<div class='legend'><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));
|
||||||
if (p.SlaHours > 0) sb.Append(" <small>(" + p.SlaHours + "h SLA)</small>");
|
if (p.SlaHours > 0) sb.Append(" <small>(" + p.SlaHours + "h)</small>");
|
||||||
sb.Append("</span>");
|
sb.Append("</span>");
|
||||||
}
|
}
|
||||||
sb.Append("</div>");
|
sb.Append("</div>");
|
||||||
|
|
||||||
// Tile Grid
|
// Tiles
|
||||||
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'>✅</div><div class='empty-msg'>No open jobs</div></div>");
|
||||||
sb.Append("<div class='empty-state'><div class='empty-icon'>✅</div><div class='empty-msg'>No open jobs found</div></div>");
|
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
foreach (var tile in model.Tiles) sb.Append(BuildTileHtml(tile, u));
|
||||||
foreach (var tile in model.Tiles)
|
|
||||||
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'><h3>Tech Workload</h3><div class='workload-bar'>");
|
sb.Append("<div class='workload-section'><h3>Tech Workload</h3><div class='workload-bar'>");
|
||||||
@@ -278,180 +275,248 @@ namespace Disco.Plugins.ServiceTracker.WebHandler
|
|||||||
sb.Append("</div></div>");
|
sb.Append("</div></div>");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-refresh bar
|
// Refresh bar
|
||||||
var refreshSeconds = model.Config.DashboardRefreshSeconds;
|
var rs = model.Config.DashboardRefreshSeconds;
|
||||||
sb.Append("<div class='refresh-bar' id='refreshBar'>");
|
sb.Append("<div class='refresh-bar' id='refreshBar'><span class='refresh-text'>Auto-refresh in <strong id='countdown'>" + rs + "</strong>s</span>");
|
||||||
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("<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("<button class='refresh-toggle' id='toggleBtn' onclick='toggleAutoRefresh()'>Pause</button></div>");
|
||||||
sb.Append("</div>");
|
sb.Append("<div class='footer'>Service Tracker v" + ServiceTrackerService.PluginVersion + " — Disco ICT</div>");
|
||||||
|
sb.Append("<script>var totalSec=" + rs + ",remaining=" + rs + ",paused=false;");
|
||||||
sb.Append("<div class='footer'>Service Tracker Plugin — Disco ICT</div>");
|
sb.Append("function tick(){if(paused)return;remaining--;document.getElementById('countdown').textContent=remaining;");
|
||||||
|
sb.Append("document.getElementById('refreshFill').style.width=((totalSec-remaining)/totalSec*100)+'%';");
|
||||||
// Auto-refresh JavaScript
|
sb.Append("if(remaining<=0)window.location.reload();}");
|
||||||
sb.Append("<script>");
|
sb.Append("function toggleAutoRefresh(){paused=!paused;var b=document.getElementById('toggleBtn'),r=document.getElementById('refreshBar');");
|
||||||
sb.Append("var totalSec=" + refreshSeconds + ",remaining=" + refreshSeconds + ",paused=false,timer;");
|
sb.Append("if(paused){b.textContent='Resume';r.classList.add('paused');}else{b.textContent='Pause';r.classList.remove('paused');}}");
|
||||||
sb.Append("function tick(){if(paused)return;remaining--;");
|
sb.Append("setInterval(tick,1000);document.addEventListener('visibilitychange',function(){if(document.hidden)paused=true;else{paused=false;remaining=totalSec;}});</script>");
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
private string BuildTileHtml(DashboardTile tile, string pluginUrl)
|
private string BuildTileHtml(DashboardTile t, string u)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
string borderClass = tile.IsSlaBreached ? "tile-breached" : tile.IsSlaWarning ? "tile-warning" : "";
|
var bc = t.IsSlaBreached ? "tile-breached" : t.IsSlaWarning ? "tile-warning" : "";
|
||||||
sb.Append("<div class='tile " + borderClass + "' onclick=\"window.location='" + pluginUrl + "/Detail?id=" + tile.JobId + "'\">");
|
sb.Append("<div class='tile " + bc + "' onclick=\"window.location='" + u + "/Detail?id=" + t.JobId + "'\">");
|
||||||
sb.Append("<div class='tile-priority' style='background:" + tile.PriorityColor + ";'></div>");
|
sb.Append("<div class='tile-priority' style='background:" + t.PriorityColor + ";'></div>");
|
||||||
sb.Append("<div class='tile-header'><div class='tile-jobid'>#" + tile.JobId + "</div><div class='tile-age'>" + tile.AgeBadge + "</div></div>");
|
sb.Append("<div class='tile-header'><div class='tile-jobid'>#" + t.JobId + "</div><div class='tile-age'>" + t.AgeBadge + "</div></div>");
|
||||||
if (tile.IsSlaBreached)
|
if (t.IsSlaBreached) sb.Append("<div class='sla-badge sla-breached'>⚠ SLA BREACHED</div>");
|
||||||
sb.Append("<div class='sla-badge sla-breached'>⚠ SLA BREACHED</div>");
|
else if (t.IsSlaWarning) sb.Append("<div class='sla-badge sla-warn'>⏰ SLA Warning</div>");
|
||||||
else if (tile.IsSlaWarning)
|
sb.Append("<div class='tile-device'><div class='tile-device-name'>" + H(t.DeviceComputerName ?? t.DeviceSerialNumber) + "</div>");
|
||||||
sb.Append("<div class='sla-badge sla-warn'>⏰ SLA Warning</div>");
|
if (t.DeviceModelDescription != null) sb.Append("<div class='tile-device-model'>" + H(t.DeviceModelDescription) + "</div>");
|
||||||
sb.Append("<div class='tile-device'><div class='tile-device-name'>" + H(tile.DeviceComputerName ?? tile.DeviceSerialNumber) + "</div>");
|
|
||||||
if (tile.DeviceModelDescription != null)
|
|
||||||
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'>👤</span><span class='tile-value'>" + H(tile.UserDisplayName ?? "—") + "</span></div>");
|
sb.Append("<div class='tile-row'><span class='tile-icon'>👤</span><span class='tile-value'>" + H(t.UserDisplayName ?? "\u2014") + "</span></div>");
|
||||||
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'><span class='tile-loc-badge' style='background:" + t.LocationColor + ";'>" + t.LocationIcon + " " + H(t.LocationName) + "</span></div>");
|
||||||
sb.Append("<div class='tile-row'><span class='tile-icon'>📋</span><span class='tile-value'>" + H(tile.StatusOverride ?? tile.DiscoStatus) + "</span></div>");
|
sb.Append("<div class='tile-row'><span class='tile-icon'>📋</span><span class='tile-value'>" + H(t.StatusOverride ?? t.DiscoStatus) + "</span></div>");
|
||||||
if (!string.IsNullOrEmpty(tile.AssignedTechName))
|
if (!string.IsNullOrEmpty(t.AssignedTechName))
|
||||||
sb.Append("<div class='tile-row'><span class='tile-icon'>🔧</span><span class='tile-value'>" + H(tile.AssignedTechName) + "</span></div>");
|
sb.Append("<div class='tile-row'><span class='tile-icon'>🔧</span><span class='tile-value'>" + H(t.AssignedTechName) + "</span></div>");
|
||||||
sb.Append("<div class='tile-row'><span class='tile-icon'>📅</span><span class='tile-value'>ETA: <strong>" + H(tile.EtaDisplay) + "</strong></span></div>");
|
sb.Append("<div class='tile-row'><span class='tile-icon'>📅</span><span class='tile-value'>ETA: <strong>" + H(t.EtaDisplay) + "</strong></span></div>");
|
||||||
if (!string.IsNullOrEmpty(tile.Summary))
|
if (!string.IsNullOrEmpty(t.Summary)) sb.Append("<div class='tile-summary'>" + H(t.Summary) + "</div>");
|
||||||
sb.Append("<div class='tile-summary'>" + H(tile.Summary) + "</div>");
|
if (!string.IsNullOrEmpty(t.LatestNote))
|
||||||
if (!string.IsNullOrEmpty(tile.LatestNote))
|
|
||||||
{
|
{
|
||||||
sb.Append("<div class='tile-note'><span class='note-icon'>💬</span> " + H(tile.LatestNote));
|
sb.Append("<div class='tile-note'><span class='note-icon'>💬</span> " + H(t.LatestNote));
|
||||||
if (tile.NoteCount > 1) sb.Append(" <small>(+" + (tile.NoteCount - 1) + " more)</small>");
|
if (t.NoteCount > 1) sb.Append(" <small>(+" + (t.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>");
|
sb.Append("<div class='tile-footer'><span class='priority-label' style='background:" + t.PriorityColor + ";'>" + H(t.PriorityName) + "</span><span class='tile-type'>" + H(t.JobTypeDescription) + "</span></div></div>");
|
||||||
sb.Append("<span class='tile-type'>" + H(tile.JobTypeDescription) + "</span></div>");
|
|
||||||
sb.Append("</div>");
|
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================== DETAIL PAGE =====================
|
||||||
|
|
||||||
private string BuildDetailPage(Disco.Models.Repository.Job job, ServiceTicket ticket, ServiceTrackerConfig config)
|
private string BuildDetailPage(Disco.Models.Repository.Job job, ServiceTicket ticket, ServiceTrackerConfig config)
|
||||||
{
|
{
|
||||||
var pluginUrl = "/Plugin/Disco.Plugins.ServiceTracker";
|
var u = "/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'/><title>Job #" + job.Id + "</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>");
|
||||||
|
|
||||||
|
// Header with Disco link
|
||||||
sb.Append("<div class='header'><div class='header-left'>");
|
sb.Append("<div class='header'><div class='header-left'>");
|
||||||
sb.Append("<a href='" + pluginUrl + "/Dashboard' class='back-link'>← Dashboard</a>");
|
sb.Append("<a href='" + u + "/Dashboard' class='back-link'>← Dashboard</a>");
|
||||||
sb.Append("<h1>Job #" + job.Id + "</h1></div></div>");
|
sb.Append("<h1>Job #" + job.Id + "</h1>");
|
||||||
|
sb.Append("</div><div class='header-right'>");
|
||||||
|
sb.Append("<a href='/Job/" + job.Id + "' class='btn btn-disco' target='_blank'>📂 Open in Disco</a>");
|
||||||
|
sb.Append("</div></div>");
|
||||||
|
|
||||||
sb.Append("<div class='detail-grid'>");
|
sb.Append("<div class='detail-grid'>");
|
||||||
|
|
||||||
// Left column - Job info
|
// Left column
|
||||||
sb.Append("<div class='detail-left'><div class='detail-card'><h3>Job Details</h3><table class='detail-table'>");
|
sb.Append("<div class='detail-left'>");
|
||||||
|
|
||||||
|
// Job info card
|
||||||
|
sb.Append("<div class='detail-card'><h3>Job Details</h3><table class='detail-table'>");
|
||||||
var domainId = SafeDeviceDomainId(job);
|
var domainId = SafeDeviceDomainId(job);
|
||||||
sb.Append("<tr><th>Device</th><td>" + H(job.DeviceSerialNumber) + (domainId != null ? " (" + H(domainId) + ")" : "") + "</td></tr>");
|
sb.Append("<tr><th>Device</th><td>" + H(job.DeviceSerialNumber) + (domainId != null ? " (" + H(domainId) + ")" : "") + "</td></tr>");
|
||||||
sb.Append("<tr><th>Model</th><td>" + H(SafeDeviceModelDesc(job)) + "</td></tr>");
|
sb.Append("<tr><th>Model</th><td>" + H(SafeDeviceModelDesc(job)) + "</td></tr>");
|
||||||
sb.Append("<tr><th>User</th><td>" + H(SafeUserDisplay(job)) + "</td></tr>");
|
sb.Append("<tr><th>User</th><td>" + H(SafeUserDisplay(job)) + "</td></tr>");
|
||||||
sb.Append("<tr><th>Type</th><td>" + H(SafeJobTypeDesc(job)) + "</td></tr>");
|
sb.Append("<tr><th>Type</th><td>" + H(SafeJobTypeDesc(job)) + "</td></tr>");
|
||||||
sb.Append("<tr><th>Opened</th><td>" + job.OpenedDate.ToString("dd MMM yyyy HH:mm") + " by " + H(SafeTechDisplay(job)) + "</td></tr>");
|
sb.Append("<tr><th>Opened</th><td>" + job.OpenedDate.ToString("dd MMM yyyy HH:mm") + " by " + H(SafeTechDisplay(job)) + "</td></tr>");
|
||||||
if (job.ExpectedClosedDate.HasValue)
|
if (job.ExpectedClosedDate.HasValue) sb.Append("<tr><th>Expected Close</th><td>" + job.ExpectedClosedDate.Value.ToString("dd MMM yyyy") + "</td></tr>");
|
||||||
sb.Append("<tr><th>Expected Close</th><td>" + job.ExpectedClosedDate.Value.ToString("dd MMM yyyy") + "</td></tr>");
|
if (job.DeviceHeld.HasValue) sb.Append("<tr><th>Device Held</th><td>" + job.DeviceHeld.Value.ToString("dd MMM yyyy") + (job.DeviceHeldLocation != null ? " \u2014 " + H(job.DeviceHeldLocation) : "") + "</td></tr>");
|
||||||
if (job.DeviceHeld.HasValue)
|
|
||||||
sb.Append("<tr><th>Device Held</th><td>" + job.DeviceHeld.Value.ToString("dd MMM yyyy") + (job.DeviceHeldLocation != null ? " — " + H(job.DeviceHeldLocation) : "") + "</td></tr>");
|
|
||||||
sb.Append("</table></div>");
|
sb.Append("</table></div>");
|
||||||
|
|
||||||
// Edit form
|
// Edit form
|
||||||
var ticketPriority = SafeTicketStr(ticket, "PriorityId") ?? config.DefaultPriorityId;
|
var tPri = (ticket != null ? ticket.PriorityId : null) ?? config.DefaultPriorityId;
|
||||||
var ticketLocation = SafeTicketStr(ticket, "LocationId") ?? config.DefaultLocationId;
|
var tLoc = (ticket != null ? ticket.LocationId : null) ?? config.DefaultLocationId;
|
||||||
var ticketStatus = SafeTicketStr(ticket, "StatusOverride");
|
var tSts = ticket != null ? ticket.StatusOverride : null;
|
||||||
var ticketTech = SafeTicketStr(ticket, "AssignedTechId") ?? "";
|
var tTech = ticket != null ? ticket.AssignedTechId : "";
|
||||||
var ticketSummary = SafeTicketStr(ticket, "Summary") ?? "";
|
var tSum = ticket != null ? ticket.Summary : "";
|
||||||
var ticketEta = (ticket != null && ticket.EstimatedCompletion.HasValue) ? ticket.EstimatedCompletion.Value.ToString("yyyy-MM-dd") : "";
|
var tEta = (ticket != null && ticket.EstimatedCompletion.HasValue) ? ticket.EstimatedCompletion.Value.ToString("yyyy-MM-dd") : "";
|
||||||
|
|
||||||
sb.Append("<div class='detail-card'><h3>Service Tracker Settings</h3>");
|
sb.Append("<div class='detail-card'><h3>Service Tracker Settings</h3><form method='POST' action='" + u + "/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 + "'/>");
|
||||||
|
|
||||||
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) sb.Append("<option value='" + p.Id + "'" + (tPri == p.Id ? " selected" : "") + ">" + H(p.Name) + " (" + p.SlaHours + "h SLA)</option>");
|
||||||
{
|
|
||||||
var sel = ticketPriority == p.Id ? " selected" : "";
|
|
||||||
sb.Append("<option value='" + p.Id + "'" + sel + ">" + H(p.Name) + " (" + p.SlaHours + "h SLA)</option>");
|
|
||||||
}
|
|
||||||
sb.Append("</select></div>");
|
sb.Append("</select></div>");
|
||||||
|
|
||||||
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) sb.Append("<option value='" + l.Id + "'" + (tLoc == l.Id ? " selected" : "") + ">" + l.Icon + " " + H(l.Name) + "</option>");
|
||||||
{
|
|
||||||
var sel = ticketLocation == l.Id ? " selected" : "";
|
|
||||||
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=''>\u2014 Use Disco Status \u2014</option>");
|
||||||
sb.Append("<div class='form-group'><label>Status</label><select name='status' class='form-control'><option value=''>— Use Disco Status —</option>");
|
foreach (var s in config.StatusOptions) sb.Append("<option value='" + H(s) + "'" + (tSts == s ? " selected" : "") + ">" + H(s) + "</option>");
|
||||||
foreach (var s in config.StatusOptions)
|
|
||||||
{
|
|
||||||
var sel = ticketStatus == s ? " selected" : "";
|
|
||||||
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</label><input type='text' name='tech' class='form-control' value='" + H(tTech ?? "") + "' placeholder='DOMAIN\\username'/></div>");
|
||||||
sb.Append("<div class='form-group'><label>Assigned Tech (User ID)</label><input type='text' name='tech' class='form-control' value='" + H(ticketTech) + "' placeholder='e.g. DOMAIN\\username'/></div>");
|
sb.Append("<div class='form-group'><label>ETA</label><input type='date' name='eta' class='form-control' value='" + tEta + "'/></div>");
|
||||||
sb.Append("<div class='form-group'><label>ETA</label><input type='date' name='eta' class='form-control' value='" + ticketEta + "'/></div>");
|
sb.Append("<div class='form-group'><label>Summary</label><textarea name='summary' class='form-control' rows='3' placeholder='Brief description...'>" + H(tSum ?? "") + "</textarea></div>");
|
||||||
sb.Append("<div class='form-group'><label>Summary</label><textarea name='summary' class='form-control' rows='3' placeholder='Brief description of the issue...'>" + H(ticketSummary) + "</textarea></div>");
|
|
||||||
sb.Append("<button type='submit' class='btn btn-primary'>✔ Save Changes</button>");
|
sb.Append("<button type='submit' class='btn btn-primary'>✔ Save Changes</button>");
|
||||||
sb.Append("</form></div></div>");
|
sb.Append("</form></div></div>");
|
||||||
|
|
||||||
// Right column - Notes
|
// Right column - Activity + Change Log
|
||||||
sb.Append("<div class='detail-right'><div class='detail-card'><h3>Activity Log</h3>");
|
sb.Append("<div class='detail-right'>");
|
||||||
sb.Append("<form method='POST' action='" + pluginUrl + "/AddNote' class='note-form'>");
|
|
||||||
|
// Notes
|
||||||
|
sb.Append("<div class='detail-card'><h3>Activity Log</h3>");
|
||||||
|
sb.Append("<form method='POST' action='" + u + "/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'><select name='noteType' class='form-control form-control-sm'>");
|
sb.Append("<div class='note-controls'><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><option value='update'>Update</option>");
|
||||||
sb.Append("<option value='escalation'>Escalation</option><option value='resolution'>Resolution</option>");
|
sb.Append("<option value='escalation'>Escalation</option><option value='resolution'>Resolution</option>");
|
||||||
sb.Append("</select><button type='submit' class='btn btn-primary btn-sm'>Add Note</button></div></form>");
|
sb.Append("</select><button type='submit' class='btn btn-primary btn-sm'>Add Note</button></div></form>");
|
||||||
|
|
||||||
if (ticket != null && ticket.Notes != null && ticket.Notes.Count > 0)
|
if (ticket != null && 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 tc = "#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; }
|
||||||
{
|
var author = note.AuthorName != null ? note.AuthorName : note.AuthorId;
|
||||||
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("<div class='timeline-item'><div class='timeline-dot' style='background:" + tc + ";'></div><div class='timeline-content'>");
|
sb.Append("<div class='timeline-item'><div class='timeline-dot' style='background:" + tc + ";'></div><div class='timeline-content'>");
|
||||||
sb.Append("<div class='timeline-header'><span class='timeline-author'>" + H(authorDisplay) + "</span>");
|
sb.Append("<div class='timeline-header'><span class='timeline-author'>" + H(author) + "</span>");
|
||||||
sb.Append("<span class='timeline-type' style='color:" + tc + ";'>" + H(note.NoteType) + "</span>");
|
sb.Append("<span class='timeline-type' style='color:" + tc + ";'>" + H(note.NoteType) + "</span>");
|
||||||
sb.Append("<span class='timeline-date'>" + note.Timestamp.ToString("dd MMM HH:mm") + "</span></div>");
|
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-body'>" + H(note.Content) + "</div></div></div>");
|
||||||
}
|
}
|
||||||
sb.Append("</div>");
|
sb.Append("</div>");
|
||||||
}
|
}
|
||||||
else
|
else sb.Append("<p class='muted'>No notes yet.</p>");
|
||||||
|
sb.Append("</div>");
|
||||||
|
|
||||||
|
// Change Log
|
||||||
|
if (ticket != null && ticket.ChangeLog != null && ticket.ChangeLog.Count > 0)
|
||||||
{
|
{
|
||||||
sb.Append("<p class='muted'>No notes yet.</p>");
|
sb.Append("<div class='detail-card'><h3>📝 Change History</h3>");
|
||||||
|
sb.Append("<table class='changelog-table'><tr><th>When</th><th>Who</th><th>Field</th><th>From</th><th>To</th></tr>");
|
||||||
|
foreach (var ch in ticket.ChangeLog.OrderByDescending(c => c.Timestamp))
|
||||||
|
{
|
||||||
|
sb.Append("<tr><td>" + ch.Timestamp.ToString("dd MMM HH:mm") + "</td>");
|
||||||
|
sb.Append("<td>" + H(ch.UserId) + "</td>");
|
||||||
|
sb.Append("<td><strong>" + H(ch.Field) + "</strong></td>");
|
||||||
|
sb.Append("<td class='old-val'>" + H(ch.OldValue ?? "\u2014") + "</td>");
|
||||||
|
sb.Append("<td class='new-val'>" + H(ch.NewValue ?? "\u2014") + "</td></tr>");
|
||||||
|
}
|
||||||
|
sb.Append("</table></div>");
|
||||||
}
|
}
|
||||||
sb.Append("</div></div></div>");
|
|
||||||
|
sb.Append("</div></div>"); // detail-right, detail-grid
|
||||||
|
sb.Append("<div class='footer'>Service Tracker v" + ServiceTrackerService.PluginVersion + "</div>");
|
||||||
sb.Append("</body></html>");
|
sb.Append("</body></html>");
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================== CONFIG EDITOR PAGE =====================
|
||||||
|
|
||||||
|
private string BuildConfigPage(ServiceTrackerConfig config, bool saved)
|
||||||
|
{
|
||||||
|
var u = "/Plugin/Disco.Plugins.ServiceTracker";
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append("<!DOCTYPE html><html><head><meta charset='utf-8'/><title>Service Tracker Config</title>");
|
||||||
|
sb.Append("<style>" + GetDashboardCSS() + GetDetailCSS() + GetConfigCSS() + "</style></head><body>");
|
||||||
|
|
||||||
|
sb.Append("<div class='header'><div class='header-left'>");
|
||||||
|
sb.Append("<a href='" + u + "/Dashboard' class='back-link'>← Dashboard</a>");
|
||||||
|
sb.Append("<h1>⚙ Configuration</h1>");
|
||||||
|
sb.Append("<span class='subtitle'>v" + ServiceTrackerService.PluginVersion + "</span>");
|
||||||
|
sb.Append("</div></div>");
|
||||||
|
|
||||||
|
if (saved) sb.Append("<div class='alert alert-success'>✅ Configuration saved successfully!</div>");
|
||||||
|
|
||||||
|
sb.Append("<form method='POST' action='" + u + "/SaveConfig'>");
|
||||||
|
sb.Append("<div class='config-grid'>");
|
||||||
|
|
||||||
|
// General Settings
|
||||||
|
sb.Append("<div class='config-card'><h3>General Settings</h3>");
|
||||||
|
sb.Append("<div class='form-group'><label>Dashboard Refresh Interval (seconds)</label>");
|
||||||
|
sb.Append("<input type='number' name='refreshSeconds' class='form-control' value='" + config.DashboardRefreshSeconds + "' min='10'/></div>");
|
||||||
|
sb.Append("<div class='form-group'><label><input type='checkbox' name='autoCreate'" + (config.AutoCreateTicketsForNewJobs ? " checked" : "") + "/> Auto-create tickets for new Disco jobs</label></div>");
|
||||||
|
sb.Append("<div class='form-group'><label>Default Priority</label><select name='defaultPriority' class='form-control'>");
|
||||||
|
foreach (var p in config.Priorities) sb.Append("<option value='" + p.Id + "'" + (config.DefaultPriorityId == p.Id ? " selected" : "") + ">" + H(p.Name) + "</option>");
|
||||||
|
sb.Append("</select></div>");
|
||||||
|
sb.Append("<div class='form-group'><label>Default Location</label><select name='defaultLocation' class='form-control'>");
|
||||||
|
foreach (var l in config.Locations) sb.Append("<option value='" + l.Id + "'" + (config.DefaultLocationId == l.Id ? " selected" : "") + ">" + l.Icon + " " + H(l.Name) + "</option>");
|
||||||
|
sb.Append("</select></div></div>");
|
||||||
|
|
||||||
|
// Priority Levels
|
||||||
|
sb.Append("<div class='config-card'><h3>Priority Levels</h3>");
|
||||||
|
sb.Append("<p class='config-help'>Edit the JSON below to customise priority levels. Each entry needs: id, Name, Color (hex), SortOrder, SlaHours, Description.</p>");
|
||||||
|
sb.Append("<textarea name='prioritiesJson' class='form-control json-editor' rows='14' id='priEditor'>");
|
||||||
|
sb.Append(H(JsonConvert.SerializeObject(config.Priorities, Formatting.Indented)));
|
||||||
|
sb.Append("</textarea>");
|
||||||
|
sb.Append("<div class='preview-section'><h4>Current Priorities</h4><div class='preview-chips'>");
|
||||||
|
foreach (var p in config.Priorities.OrderBy(x => x.SortOrder))
|
||||||
|
sb.Append("<span class='preview-chip' style='background:" + p.Color + ";'>" + H(p.Name) + " (" + p.SlaHours + "h)</span>");
|
||||||
|
sb.Append("</div></div></div>");
|
||||||
|
|
||||||
|
// Locations
|
||||||
|
sb.Append("<div class='config-card'><h3>Device Locations</h3>");
|
||||||
|
sb.Append("<p class='config-help'>Edit the JSON below to customise locations. Each entry needs: Id, Name, Icon (emoji/HTML entity), Color (hex).</p>");
|
||||||
|
sb.Append("<textarea name='locationsJson' class='form-control json-editor' rows='14' id='locEditor'>");
|
||||||
|
sb.Append(H(JsonConvert.SerializeObject(config.Locations, Formatting.Indented)));
|
||||||
|
sb.Append("</textarea>");
|
||||||
|
sb.Append("<div class='preview-section'><h4>Current Locations</h4><div class='preview-chips'>");
|
||||||
|
foreach (var l in config.Locations)
|
||||||
|
sb.Append("<span class='preview-chip' style='background:" + l.Color + ";'>" + l.Icon + " " + H(l.Name) + "</span>");
|
||||||
|
sb.Append("</div></div></div>");
|
||||||
|
|
||||||
|
// Status Options
|
||||||
|
sb.Append("<div class='config-card'><h3>Status Options</h3>");
|
||||||
|
sb.Append("<p class='config-help'>One status per line. These appear in the status dropdown on the ticket detail page.</p>");
|
||||||
|
sb.Append("<textarea name='statusOptions' class='form-control' rows='10'>");
|
||||||
|
foreach (var s in config.StatusOptions) sb.Append(H(s) + "\n");
|
||||||
|
sb.Append("</textarea></div>");
|
||||||
|
|
||||||
|
sb.Append("</div>"); // config-grid
|
||||||
|
|
||||||
|
sb.Append("<div class='config-actions'>");
|
||||||
|
sb.Append("<button type='submit' class='btn btn-primary btn-lg'>✔ Save Configuration</button>");
|
||||||
|
sb.Append("<a href='" + u + "/Dashboard' class='btn btn-default btn-lg'>Cancel</a>");
|
||||||
|
sb.Append("</div></form>");
|
||||||
|
|
||||||
|
sb.Append("<div class='footer'>Service Tracker v" + ServiceTrackerService.PluginVersion + "</div>");
|
||||||
|
|
||||||
|
// JSON validation script
|
||||||
|
sb.Append("<script>");
|
||||||
|
sb.Append("document.querySelector('form').addEventListener('submit',function(e){");
|
||||||
|
sb.Append("var editors=['priEditor','locEditor'];for(var i=0;i<editors.length;i++){");
|
||||||
|
sb.Append("var el=document.getElementById(editors[i]);try{JSON.parse(el.value);}");
|
||||||
|
sb.Append("catch(ex){e.preventDefault();el.style.borderColor='#DC3545';");
|
||||||
|
sb.Append("alert('Invalid JSON in '+editors[i]+': '+ex.message);return;}}});");
|
||||||
|
sb.Append("</script>");
|
||||||
|
sb.Append("</body></html>");
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== CSS =====================
|
||||||
|
|
||||||
private string GetDashboardCSS()
|
private string GetDashboardCSS()
|
||||||
{
|
{
|
||||||
return @"
|
return @"
|
||||||
@@ -465,29 +530,29 @@ body{font-family:'Segoe UI',Arial,sans-serif;background:#f0f2f5;color:#333;paddi
|
|||||||
.btn{display:inline-block;padding:8px 16px;font-size:13px;border:none;border-radius:4px;cursor:pointer;text-decoration:none;color:#fff;}
|
.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-primary{background:#337AB7;} .btn-primary:hover{background:#286090;}
|
||||||
.btn-default{background:#777;} .btn-default:hover{background:#555;}
|
.btn-default{background:#777;} .btn-default:hover{background:#555;}
|
||||||
|
.btn-disco{background:#5B2D8E;} .btn-disco:hover{background:#4A2475;}
|
||||||
.btn-sm{padding:5px 10px;font-size:12px;}
|
.btn-sm{padding:5px 10px;font-size:12px;}
|
||||||
|
.btn-lg{padding:10px 24px;font-size:14px;}
|
||||||
.alert{margin:0 24px;padding:10px 16px;border-radius:4px;font-size:13px;margin-top: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-danger{background:#f8d7da;color:#721c24;border:1px solid #f5c6cb;}
|
||||||
.alert-warning{background:#fff3cd;color:#856404;border:1px solid #ffeeba;}
|
.alert-warning{background:#fff3cd;color:#856404;border:1px solid #ffeeba;}
|
||||||
|
.alert-success{background:#d4edda;color:#155724;border:1px solid #c3e6cb;}
|
||||||
.stats-bar{display:flex;gap:12px;padding:16px 24px;flex-wrap:wrap;}
|
.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{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-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-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;}
|
.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;}
|
.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{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;}
|
||||||
.loc-chip:hover{opacity:.85;}
|
|
||||||
.controls{display:flex;align-items:center;gap:6px;padding:8px 24px;}
|
.controls{display:flex;align-items:center;gap:6px;padding:8px 24px;}
|
||||||
.ctrl-label{font-size:12px;color:#888;margin-right:4px;}
|
.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{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;}
|
||||||
.sort-btn:hover{background:#d0d5db;} .sort-btn.active{background:#337AB7;color:#fff;}
|
|
||||||
.clear-btn{background:#DC3545;color:#fff;} .clear-btn:hover{background:#b52a3a;}
|
.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{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-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-dot{width:10px;height:10px;border-radius:50%;display:inline-block;} .legend-item small{color:#aaa;}
|
||||||
.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-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{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;}
|
||||||
.tile:hover{box-shadow:0 4px 12px rgba(0,0,0,.15);transform:translateY(-2px);}
|
.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-breached{border:2px solid #DC3545;animation:pulse 2s infinite;}
|
||||||
.tile-warning{border:2px solid #FFC107;}
|
.tile-warning{border:2px solid #FFC107;}
|
||||||
@@ -497,8 +562,7 @@ body{font-family:'Segoe UI',Arial,sans-serif;background:#f0f2f5;color:#333;paddi
|
|||||||
.tile-jobid{font-weight:700;font-size:15px;color:#337AB7;}
|
.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;}
|
.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-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-breached{background:#f8d7da;color:#721c24;} .sla-warn{background:#fff3cd;color:#856404;}
|
||||||
.sla-warn{background:#fff3cd;color:#856404;}
|
|
||||||
.tile-device{padding:0 14px 6px;}
|
.tile-device{padding:0 14px 6px;}
|
||||||
.tile-device-name{font-weight:600;font-size:14px;} .tile-device-model{font-size:11px;color:#888;}
|
.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-row{display:flex;align-items:center;gap:6px;padding:3px 14px;font-size:13px;}
|
||||||
@@ -506,27 +570,20 @@ body{font-family:'Segoe UI',Arial,sans-serif;background:#f0f2f5;color:#333;paddi
|
|||||||
.tile-value{flex:1;} .tile-value strong{font-weight:600;}
|
.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-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-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;}
|
.tile-note{padding:6px 14px;font-size:11px;color:#777;background:#fafbfc;} .note-icon{font-size:10px;}
|
||||||
.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;}
|
.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;}
|
.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;}
|
||||||
.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;}
|
||||||
.empty-state{grid-column:1/-1;text-align:center;padding:60px 20px;}
|
.workload-section{padding:0 24px 24px;} .workload-section h3{font-size:14px;color:#888;margin-bottom:8px;}
|
||||||
.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-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;}
|
.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{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-bar.paused{background:#fff3cd;}
|
|
||||||
.refresh-text{font-size:12px;color:#888;min-width:140px;}
|
.refresh-text{font-size:12px;color:#888;min-width:140px;}
|
||||||
.refresh-progress{flex:1;height:4px;background:#e9ecef;border-radius:2px;overflow:hidden;}
|
.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-fill{height:100%;background:#337AB7;border-radius:2px;transition:width 1s linear;width:0%;} .refresh-bar.paused .refresh-fill{background:#FFC107;}
|
||||||
.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;}
|
||||||
.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;}
|
||||||
";
|
";
|
||||||
}
|
}
|
||||||
@@ -543,22 +600,41 @@ body{font-family:'Segoe UI',Arial,sans-serif;background:#f0f2f5;color:#333;paddi
|
|||||||
.detail-table{width:100%;border-collapse:collapse;}
|
.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 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;}
|
.detail-table td{padding:6px 8px;font-size:13px;}
|
||||||
.form-group{margin-bottom:12px;}
|
.form-group{margin-bottom:12px;} .form-group label{display:block;font-size:12px;font-weight:600;color:#555;margin-bottom:4px;}
|
||||||
.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{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: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;}
|
.form-control-sm{width:auto;padding:4px 8px;font-size:12px;}
|
||||||
.note-form{margin-bottom:16px;}
|
.note-form{margin-bottom:16px;} .note-controls{display:flex;gap:8px;margin-top:6px;align-items:center;}
|
||||||
.note-controls{display:flex;gap:8px;margin-top:6px;align-items:center;}
|
.timeline{border-left:2px solid #e0e0e0;margin-left:8px;}
|
||||||
.timeline{border-left:2px solid #e0e0e0;margin-left:8px;padding-left:0;}
|
.timeline-item{display:flex;gap:12px;padding:8px 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-dot{width:10px;height:10px;border-radius:50%;margin-top:4px;flex-shrink:0;margin-left:-6px;}
|
||||||
.timeline-content{flex:1;}
|
.timeline-content{flex:1;}
|
||||||
.timeline-header{display:flex;gap:8px;align-items:center;font-size:12px;margin-bottom:3px;}
|
.timeline-header{display:flex;gap:8px;align-items:center;font-size:12px;margin-bottom:3px;}
|
||||||
.timeline-author{font-weight:600;color:#333;}
|
.timeline-author{font-weight:600;color:#333;} .timeline-type{font-size:11px;text-transform:capitalize;}
|
||||||
.timeline-type{font-size:11px;text-transform:capitalize;}
|
|
||||||
.timeline-date{color:#aaa;margin-left:auto;}
|
.timeline-date{color:#aaa;margin-left:auto;}
|
||||||
.timeline-body{font-size:13px;color:#555;line-height:1.4;}
|
.timeline-body{font-size:13px;color:#555;line-height:1.4;}
|
||||||
|
.changelog-table{width:100%;border-collapse:collapse;font-size:12px;}
|
||||||
|
.changelog-table th{text-align:left;padding:6px 8px;background:#f8f9fa;color:#888;font-weight:600;border-bottom:1px solid #eee;}
|
||||||
|
.changelog-table td{padding:5px 8px;border-bottom:1px solid #f5f5f5;}
|
||||||
|
.old-val{color:#DC3545;text-decoration:line-through;} .new-val{color:#28A745;font-weight:500;}
|
||||||
|
";
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetConfigCSS()
|
||||||
|
{
|
||||||
|
return @"
|
||||||
|
.config-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px;padding:20px 24px;}
|
||||||
|
@media(max-width:900px){.config-grid{grid-template-columns:1fr;}}
|
||||||
|
.config-card{background:#fff;border-radius:8px;padding:20px;box-shadow:0 1px 3px rgba(0,0,0,.08);}
|
||||||
|
.config-card h3{font-size:15px;color:#333;margin-bottom:12px;border-bottom:1px solid #eee;padding-bottom:8px;}
|
||||||
|
.config-help{font-size:12px;color:#888;margin-bottom:10px;line-height:1.4;}
|
||||||
|
.json-editor{font-family:'Consolas','Courier New',monospace;font-size:12px;line-height:1.5;background:#f8f9fa;border:1px solid #ddd;}
|
||||||
|
.json-editor:focus{border-color:#337AB7;background:#fff;}
|
||||||
|
.preview-section{margin-top:12px;padding-top:10px;border-top:1px solid #eee;}
|
||||||
|
.preview-section h4{font-size:12px;color:#888;margin-bottom:6px;}
|
||||||
|
.preview-chips{display:flex;gap:6px;flex-wrap:wrap;}
|
||||||
|
.preview-chip{display:inline-block;padding:3px 10px;border-radius:10px;font-size:11px;color:#fff;font-weight:500;}
|
||||||
|
.config-actions{padding:20px 24px;display:flex;gap:10px;}
|
||||||
";
|
";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user