refactor: Rename logging level switch variables for clarity and add fallback handler to WebApiServer to allow for serving debug app

This commit is contained in:
Neil Dorin 2026-04-08 11:51:10 -06:00
parent c98b48ff87
commit bfb9838743
4 changed files with 267 additions and 9 deletions

View file

@ -51,7 +51,7 @@ public static class Debug
private static readonly LoggingLevelSwitch errorLogLevelSwitch;
private static readonly LoggingLevelSwitch fileLevelSwitch;
private static readonly LoggingLevelSwitch fileLoggingLevelSwitch;
/// <summary>
/// The minimum log level for messages to be sent to the console sink
@ -168,7 +168,7 @@ public static class Debug
errorLogLevelSwitch = new LoggingLevelSwitch(initialMinimumLevel: defaultErrorLogLevel);
fileLevelSwitch = new LoggingLevelSwitch(initialMinimumLevel: defaultFileLogLevel);
fileLoggingLevelSwitch = new LoggingLevelSwitch(initialMinimumLevel: defaultFileLogLevel);
websocketSink = new DebugWebsocketSink(new JsonFormatter(renderMessage: true));
@ -193,7 +193,7 @@ public static class Debug
rollingInterval: RollingInterval.Day,
restrictedToMinimumLevel: LogEventLevel.Debug,
retainedFileCountLimit: CrestronEnvironment.DevicePlatform == eDevicePlatform.Appliance ? 30 : 60,
levelSwitch: fileLevelSwitch
levelSwitch: fileLoggingLevelSwitch
);
// Instantiate the root logger
@ -413,7 +413,8 @@ public static class Debug
return;
}
if (Enum.TryParse<LogEventLevel>(levelString, out var levelEnum))
// make this parse attempt case-insensitive to allow for more flexible command usage
if (Enum.TryParse<LogEventLevel>(levelString, true, out var levelEnum))
{
SetDebugLevel(levelEnum);
return;
@ -483,7 +484,7 @@ public static class Debug
var err = CrestronDataStoreStatic.SetLocalUintValue(WebSocketLevelStoreKey, (uint)level);
if (err != CrestronDataStore.CDS_ERROR.CDS_SUCCESS)
LogMessage(LogEventLevel.Information, "Error saving websocket debug level setting: {erro}", err);
LogMessage(LogEventLevel.Information, "Error saving websocket debug level setting: {error}", err);
LogMessage(LogEventLevel.Information, "Websocket debug level set to {0}", websocketLoggingLevelSwitch.MinimumLevel);
}
@ -502,7 +503,7 @@ public static class Debug
if (err != CrestronDataStore.CDS_ERROR.CDS_SUCCESS)
LogMessage(LogEventLevel.Information, "Error saving Error Log debug level setting: {error}", err);
LogMessage(LogEventLevel.Information, "Error log debug level set to {0}", websocketLoggingLevelSwitch.MinimumLevel);
LogMessage(LogEventLevel.Information, "Error log debug level set to {0}", errorLogLevelSwitch.MinimumLevel);
}
/// <summary>
@ -510,14 +511,14 @@ public static class Debug
/// </summary>
public static void SetFileMinimumDebugLevel(LogEventLevel level)
{
errorLogLevelSwitch.MinimumLevel = level;
fileLoggingLevelSwitch.MinimumLevel = level;
var err = CrestronDataStoreStatic.SetLocalUintValue(ErrorLogLevelStoreKey, (uint)level);
var err = CrestronDataStoreStatic.SetLocalUintValue(FileLevelStoreKey, (uint)level);
if (err != CrestronDataStore.CDS_ERROR.CDS_SUCCESS)
LogMessage(LogEventLevel.Information, "Error saving File debug level setting: {error}", err);
LogMessage(LogEventLevel.Information, "File debug level set to {0}", websocketLoggingLevelSwitch.MinimumLevel);
LogMessage(LogEventLevel.Information, "File debug level set to {0}", fileLoggingLevelSwitch.MinimumLevel);
}
/// <summary>

View file

@ -160,6 +160,22 @@ public class WebApiServer : IKeyName
_server.Routes.Remove(route);
}
/// <summary>
/// Sets the fallback request handler that is invoked when no registered route
/// matches an incoming request. Must be called before <see cref="Start"/>.
/// </summary>
/// <param name="handler">The handler to use as the server-level fallback.</param>
public void SetFallbackHandler(IHttpCwsHandler handler)
{
if (handler == null)
{
this.LogWarning("SetFallbackHandler: handler parameter is null, ignoring");
return;
}
_server.HttpRequestHandler = handler;
}
/// <summary>
/// GetRouteCollection method
/// </summary>

View file

@ -18,6 +18,7 @@ namespace PepperDash.Essentials.Core.Web;
public class EssentialsWebApi : EssentialsDevice
{
private readonly WebApiServer _server;
private readonly WebApiServer _debugServer;
///<example>
/// http(s)://{ipaddress}/cws/{basePath}
@ -68,6 +69,9 @@ public class EssentialsWebApi : EssentialsDevice
_server = new WebApiServer(Key, Name, BasePath);
_debugServer = new WebApiServer($"{key}-debug-app", $"{name} Debug App", "/debug");
_debugServer.SetFallbackHandler(new ServeDebugAppRequestHandler());
SetupRoutes();
}
@ -226,6 +230,7 @@ public class EssentialsWebApi : EssentialsDevice
Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on {0} Appliance", is4Series ? "4-series" : "3-series");
_server.Start();
_debugServer.Start();
PrintPaths();
@ -236,6 +241,7 @@ public class EssentialsWebApi : EssentialsDevice
Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on Virtual Control Server");
_server.Start();
_debugServer.Start();
PrintPaths();
}
@ -277,6 +283,12 @@ public class EssentialsWebApi : EssentialsDevice
{
Debug.LogMessage(LogEventLevel.Information, this, "{routeName:l}: {routePath:l}/{routeUrl:l}", route.Name, path, route.Url);
}
var debugPath = CrestronEnvironment.DevicePlatform == eDevicePlatform.Server
? $"https://{hostname}/VirtualControl/Rooms/{InitialParametersClass.RoomId}/cws/debug"
: $"https://{currentIp}/cws/debug";
Debug.LogMessage(LogEventLevel.Information, this, "Debug App: {debugPath:l}", debugPath);
Debug.LogMessage(LogEventLevel.Information, this, new string('-', 50));
}
}

View file

@ -0,0 +1,229 @@
using System;
using System.Collections.Generic;
using System.Text;
using Crestron.SimplSharp;
using Crestron.SimplSharp.CrestronIO;
using Crestron.SimplSharp.WebScripting;
using PepperDash.Core;
using PepperDash.Core.Web.RequestHandlers;
using Serilog.Events;
namespace PepperDash.Essentials.Core.Web.RequestHandlers;
/// <summary>
/// Serves the React debug app from the processor's /HTML/debug folder.
/// The root route (debug) and all sub-paths (debug/{*filePath}) are handled here.
/// Text assets are sent as UTF-8 strings; binary assets are written to the response
/// OutputStream. Any sub-path that does not match a real file falls back to
/// index.html so that client-side (React Router) routing continues to work.
/// </summary>
public class ServeDebugAppRequestHandler : WebApiBaseRequestHandler
{
private static readonly Dictionary<string, string> MimeTypes = new(StringComparer.OrdinalIgnoreCase)
{
{ ".html", "text/html; charset=utf-8" },
{ ".htm", "text/html; charset=utf-8" },
{ ".js", "application/javascript" },
{ ".mjs", "application/javascript" },
{ ".jsx", "application/javascript" },
{ ".css", "text/css" },
{ ".json", "application/json" },
{ ".map", "application/json" },
{ ".svg", "image/svg+xml" },
{ ".ico", "image/x-icon" },
{ ".png", "image/png" },
{ ".jpg", "image/jpeg" },
{ ".jpeg", "image/jpeg" },
{ ".gif", "image/gif" },
{ ".woff", "font/woff" },
{ ".woff2","font/woff2" },
{ ".ttf", "font/ttf" },
{ ".eot", "application/vnd.ms-fontobject" },
};
private static readonly HashSet<string> TextExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".html", ".htm", ".js", ".mjs", ".jsx", ".css", ".json", ".map", ".svg", ".txt", ".xml"
};
/// <summary>
/// Constructor. CORS is enabled so browser dev-tools requests succeed.
/// </summary>
public ServeDebugAppRequestHandler() : base(true) { }
/// <summary>
/// Handles GET requests for the debug app and its static assets.
/// </summary>
protected override void HandleGet(HttpCwsContext context)
{
// When acting as the server-level fallback handler, only handle
// requests that are actually for the /debug path; defer everything
// else to the base class (which returns 501 Not Implemented).
var rawUrl = context.Request.RawUrl ?? string.Empty;
if (rawUrl.IndexOf("/debug", StringComparison.OrdinalIgnoreCase) < 0)
{
base.HandleGet(context);
return;
}
try
{
var htmlDebugPath = GetHtmlDebugPath();
if (htmlDebugPath == null)
{
SendResponse(context, 500, "Internal Server Error");
return;
}
var requestedPath = GetRequestedFilePath(context);
// Paths with no file extension are SPA client-side routes — serve index.html
string candidate;
if (string.IsNullOrEmpty(requestedPath) || !System.IO.Path.HasExtension(requestedPath))
{
candidate = System.IO.Path.Combine(htmlDebugPath, "index.html");
}
else
{
var relativePart = requestedPath.Replace('/', System.IO.Path.DirectorySeparatorChar);
candidate = System.IO.Path.Combine(htmlDebugPath, relativePart);
}
// Resolve to an absolute path and guard against path-traversal attacks
var resolvedCandidate = System.IO.Path.GetFullPath(candidate);
var resolvedBase = System.IO.Path.GetFullPath(htmlDebugPath)
+ System.IO.Path.DirectorySeparatorChar;
if (!resolvedCandidate.StartsWith(resolvedBase, StringComparison.OrdinalIgnoreCase))
{
SendResponse(context, 403, "Forbidden");
return;
}
// Missing static asset → fall back to index.html (SPA deep-link support)
if (!File.Exists(resolvedCandidate) && System.IO.Path.HasExtension(requestedPath ?? string.Empty))
{
resolvedCandidate = System.IO.Path.Combine(htmlDebugPath, "index.html");
Debug.LogMessage(LogEventLevel.Debug,
"ServeDebugAppRequestHandler: '{requestedPath:l}' not found, falling back to index.html",
requestedPath);
}
if (!File.Exists(resolvedCandidate))
{
SendResponse(context, 404, "Not Found");
return;
}
var ext = System.IO.Path.GetExtension(resolvedCandidate);
var contentType = MimeTypes.TryGetValue(ext, out var mime) ? mime : "application/octet-stream";
context.Response.StatusCode = 200;
context.Response.StatusDescription = "OK";
context.Response.ContentType = contentType;
if (TextExtensions.Contains(ext))
{
string content;
using (var reader = new StreamReader(resolvedCandidate))
content = reader.ReadToEnd();
context.Response.ContentEncoding = Encoding.UTF8;
context.Response.Write(content, false);
}
else
{
var bytes = System.IO.File.ReadAllBytes(resolvedCandidate);
context.Response.OutputStream.Write(bytes, 0, bytes.Length);
}
context.Response.End();
}
catch (Exception ex)
{
Debug.LogMessage(LogEventLevel.Error,
"ServeDebugAppRequestHandler: Unhandled error serving '{rawUrl:l}': {ex}",
context.Request.RawUrl, ex.Message);
try { SendResponse(context, 500, "Internal Server Error"); } catch { /* best-effort */ }
}
}
/// <summary>
/// Resolves the absolute path of the /HTML/debug folder on the processor.
/// </summary>
/// <remarks>
/// On a 4-series appliance, <c>Global.FilePathPrefix</c> is
/// <c>{root}/user/programX/</c>, so walking up two parents gives the
/// processor root that contains the <c>html</c> folder.
/// On Virtual Control, <c>Global.FilePathPrefix</c> is <c>{root}/User/</c>,
/// so only one parent hop is needed.
/// </remarks>
private static string GetHtmlDebugPath()
{
try
{
var separators = new[] { System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar };
var programDir = new DirectoryInfo(Global.FilePathPrefix.TrimEnd(separators));
DirectoryInfo rootDir;
if (CrestronEnvironment.DevicePlatform == eDevicePlatform.Server)
{
// Virtual Control: {root}/User/ → one parent up = {root}
rootDir = programDir.Parent;
}
else
{
// 4-series appliance: {root}/user/programX/ → two parents up = {root}
rootDir = programDir.Parent?.Parent;
}
if (rootDir == null)
{
Debug.LogMessage(LogEventLevel.Error,
"ServeDebugAppRequestHandler: Cannot resolve HTML root from FilePathPrefix '{prefix:l}'",
Global.FilePathPrefix);
return null;
}
return System.IO.Path.Combine(rootDir.FullName, "html", "debug");
}
catch (Exception ex)
{
Debug.LogMessage(LogEventLevel.Error,
"ServeDebugAppRequestHandler: Error resolving HTML debug path: {ex}", ex.Message);
return null;
}
}
/// <summary>
/// Extracts the file sub-path from the request by parsing <c>RawUrl</c>.
/// Returns an empty string when the URL ends at <c>/debug</c> (root hit).
/// </summary>
private static string GetRequestedFilePath(HttpCwsContext context)
{
var rawUrl = context.Request.RawUrl ?? string.Empty;
// Locate the /debug segment in the URL
const string debugToken = "/debug";
var idx = rawUrl.IndexOf(debugToken, StringComparison.OrdinalIgnoreCase);
if (idx < 0)
return string.Empty;
var afterDebug = rawUrl.Substring(idx + debugToken.Length);
// Strip query string
var qIdx = afterDebug.IndexOf('?');
if (qIdx >= 0)
afterDebug = afterDebug.Substring(0, qIdx);
// Strip leading slash to get a relative file path
return afterDebug.TrimStart('/');
}
private static void SendResponse(HttpCwsContext context, int statusCode, string statusDescription)
{
context.Response.StatusCode = statusCode;
context.Response.StatusDescription = statusDescription;
context.Response.End();
}
}