From 32c601d0460d2a32705b473f775bdafb6e90dd7b Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 22 May 2026 00:47:47 +0300 Subject: [PATCH] Created a simple web panel with a quick API and added a mode for the server to switch to "multi-port" mode --- .../Controllers/WebPanelController.cs | 317 ++++++++++++++ .../dotnet/src/Jibo.Cloud.Api/Program.cs | 84 +++- .../src/Jibo.Cloud.Api/appsettings.json | 34 ++ .../src/Jibo.Cloud.Api/wwwroot/css/panel.css | 399 +++++++++++++++++ .../src/Jibo.Cloud.Api/wwwroot/index.html | 217 ++++++++++ .../src/Jibo.Cloud.Api/wwwroot/js/panel.js | 403 ++++++++++++++++++ 6 files changed, 1452 insertions(+), 2 deletions(-) create mode 100644 OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/Controllers/WebPanelController.cs create mode 100644 OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/wwwroot/css/panel.css create mode 100644 OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/wwwroot/index.html create mode 100644 OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/wwwroot/js/panel.js diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/Controllers/WebPanelController.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/Controllers/WebPanelController.cs new file mode 100644 index 0000000..d89231e --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/Controllers/WebPanelController.cs @@ -0,0 +1,317 @@ +using Jibo.Cloud.Application.Abstractions; +using Jibo.Cloud.Application.Services; +using Jibo.Cloud.Domain.Models; +using Microsoft.AspNetCore.Mvc; + +namespace Jibo.Cloud.Api.Controllers; + +[ApiController] +[Route("api/panel")] +public class WebPanelController( + ICloudStateStore stateStore, + IConfiguration configuration) : ControllerBase +{ + private static readonly DateTimeOffset _startTime = DateTimeOffset.UtcNow; + + [HttpGet("status")] + public ActionResult GetStatus() + { + var persistenceInfo = stateStore.GetPersistenceStateInfo(); + var account = stateStore.GetAccount(); + var robot = stateStore.GetRobot(); + + return Ok(new + { + version = OpenJiboCloudBuildInfo.Version, + uptime = (DateTimeOffset.UtcNow - _startTime).ToString(@"hh\:mm\:ss"), + startTime = _startTime.ToString("o"), + persistence = new + { + schemaVersion = persistenceInfo.SchemaVersion, + revision = persistenceInfo.Revision, + lastLoaded = persistenceInfo.LastLoadedUtc?.ToString("o"), + lastSaved = persistenceInfo.LastSavedUtc?.ToString("o") + }, + account = new + { + accountId = account.AccountId, + firstName = account.FirstName, + lastName = account.LastName + }, + robot = new + { + deviceId = robot.DeviceId, + robotId = robot.RobotId, + friendlyName = robot.FriendlyName, + firmwareVersion = robot.FirmwareVersion, + applicationVersion = robot.ApplicationVersion + }, + configuration = new + { + webPanelEnabled = configuration.GetValue("OpenJibo:WebPanel:Enabled"), + refreshIntervalSeconds = configuration.GetValue("OpenJibo:WebPanel:RefreshIntervalSeconds"), + allowRemoteAccess = configuration.GetValue("OpenJibo:WebPanel:AllowRemoteAccess") + } + }); + } + + [HttpGet("sessions")] + public ActionResult GetSessions() + { + // Since ICloudStateStore doesnt have a GetAllSessions method for now ill just return a empty list - TO BE UPGRADED!! + return Ok(new + { + sessions = Array.Empty(), + count = 0 + }); + } + + [HttpGet("robots")] + public ActionResult GetRobots() + { + var robot = stateStore.GetRobot(); + var robotProfile = stateStore.GetRobotProfile(); + + return Ok(new + { + robots = new[] + { + new + { + deviceId = robot.DeviceId, + robotId = robot.RobotId, + friendlyName = robot.FriendlyName, + firmwareVersion = robot.FirmwareVersion, + applicationVersion = robot.ApplicationVersion, + profile = new + { + robotId = robotProfile.RobotId, + connectedAt = robotProfile.UpdatedUtc.ToString("o"), + platform = robotProfile.Payload?.TryGetValue("platform", out var platformValue) == true ? platformValue?.ToString() : null, + serialNumber = robotProfile.Payload?.TryGetValue("serialNumber", out var serialValue) == true ? serialValue?.ToString() : null + } + } + }, + count = 1 + }); + } + + [HttpGet("health")] + public ActionResult GetHealth() + { + var persistenceInfo = stateStore.GetPersistenceStateInfo(); + + return Ok(new + { + status = "healthy", + timestamp = DateTimeOffset.UtcNow.ToString("o"), + checks = new + { + persistence = new + { + status = persistenceInfo.LastSavedUtc.HasValue ? "ok" : "warning", + lastSaved = persistenceInfo.LastSavedUtc?.ToString("o"), + revision = persistenceInfo.Revision + }, + stateStore = new + { + status = "ok", + type = "InMemoryCloudStateStore" + } + } + }); + } + + [HttpPost("state/save")] + public ActionResult SaveState() + { + try + { + stateStore.SavePersistedState(); + return Ok(new { success = true, message = "State saved successfully" }); + } + catch (Exception ex) + { + return BadRequest(new { success = false, message = ex.Message }); + } + } + + [HttpPost("state/reload")] + public ActionResult ReloadState() + { + try + { + stateStore.LoadPersistedState(); + return Ok(new { success = true, message = "State reloaded successfully" }); + } + catch (Exception ex) + { + return BadRequest(new { success = false, message = ex.Message }); + } + } + + [HttpGet("info")] + public ActionResult GetInfo() + { + var robot = stateStore.GetRobot(); + var persistenceInfo = stateStore.GetPersistenceStateInfo(); + + return Ok(new + { + serverId = Environment.MachineName, + serverName = robot.FriendlyName ?? "OpenJibo Server", + endpoint = Request.Host.Value, + version = OpenJiboCloudBuildInfo.Version, + startTime = _startTime.ToString("o"), + uptime = (DateTimeOffset.UtcNow - _startTime).TotalSeconds, + robotId = robot.RobotId, + deviceId = robot.DeviceId, + stateRevision = persistenceInfo.Revision, + lastStateSave = persistenceInfo.LastSavedUtc?.ToString("o") + }); + } + + [HttpGet("metrics")] + public ActionResult GetMetrics() + { + var persistenceInfo = stateStore.GetPersistenceStateInfo(); + var robot = stateStore.GetRobot(); + var loops = stateStore.GetLoops(); + var people = stateStore.GetPeople(); + var media = stateStore.ListMedia(); + var updates = stateStore.ListUpdates(); + var backups = stateStore.GetBackups(); + + return Ok(new + { + timestamp = DateTimeOffset.UtcNow.ToString("o"), + server = new + { + version = OpenJiboCloudBuildInfo.Version, + uptime = (DateTimeOffset.UtcNow - _startTime).TotalSeconds, + startTime = _startTime.ToString("o") + }, + state = new + { + revision = persistenceInfo.Revision, + lastLoaded = persistenceInfo.LastLoadedUtc?.ToString("o"), + lastSaved = persistenceInfo.LastSavedUtc?.ToString("o"), + schemaVersion = persistenceInfo.SchemaVersion + }, + robot = new + { + robotId = robot.RobotId, + deviceId = robot.DeviceId, + firmwareVersion = robot.FirmwareVersion, + applicationVersion = robot.ApplicationVersion + }, + counts = new + { + loops = loops.Count, + people = people.Count, + media = media.Count, + updates = updates.Count, + backups = backups.Count + } + }); + } + + private static List _serverLogs = new(); + private static readonly object _logsLock = new(); + + [HttpGet("logs")] + public ActionResult GetLogs(long since = 0) + { + lock (_logsLock) + { + // Add some test logs if empty + if (_serverLogs.Count == 0) + { + _serverLogs.Add(new { timestamp = DateTimeOffset.UtcNow.AddSeconds(-10).ToUnixTimeMilliseconds(), level = "info", message = "Server running normally" }); + _serverLogs.Add(new { timestamp = DateTimeOffset.UtcNow.AddSeconds(-5).ToUnixTimeMilliseconds(), level = "info", message = "Health check passed" }); + _serverLogs.Add(new { timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), level = "info", message = "Web panel accessed" }); + } + + // Filter logs + var filteredLogs = _serverLogs + .Where(log => (long)((dynamic)log).timestamp > since) + .ToList(); + + return Ok(new + { + logs = filteredLogs, + count = filteredLogs.Count + }); + } + } + + [HttpGet("endpoints")] + public ActionResult GetEndpoints() + { + var multiPortEnabled = configuration.GetValue("OpenJibo:MultiPortMode:Enabled"); + + if (multiPortEnabled) + { + return Ok(new + { + mode = "multi-port", + enabled = true, + ports = new + { + api = configuration.GetValue("OpenJibo:MultiPortMode:Ports:Api"), + apiSocket = configuration.GetValue("OpenJibo:MultiPortMode:Ports:ApiSocket"), + neoHubListen = configuration.GetValue("OpenJibo:MultiPortMode:Ports:NeoHubListen"), + neoHubProactive = configuration.GetValue("OpenJibo:MultiPortMode:Ports:NeoHubProactive"), + webPanel = configuration.GetValue("OpenJibo:MultiPortMode:Ports:WebPanel") + }, + robotConfig = new + { + webCoreServerPort = configuration.GetValue("OpenJibo:MultiPortMode:Ports:Api"), + jetstreamServiceServerPort = configuration.GetValue("OpenJibo:MultiPortMode:Ports:Api"), + jetstreamServiceRegistryPort = configuration.GetValue("OpenJibo:MultiPortMode:Ports:ApiSocket"), + hubClientHubPort = configuration.GetValue("OpenJibo:MultiPortMode:Ports:NeoHubListen"), + hubClientProactivePort = configuration.GetValue("OpenJibo:MultiPortMode:Ports:NeoHubProactive") + } + }); + } + else + { + return Ok(new + { + mode = "dns-based", + enabled = false, + description = "Server uses DNS-based routing. Configure robot hostnames to point to this server.", + hosts = new + { + api = "api.jibo.com", + apiSocket = "api-socket.jibo.com", + neoHub = "neo-hub.jibo.com" + } + }); + } + } + + [HttpPost("endpoints/multi-port/enable")] + public ActionResult EnableMultiPortMode([FromBody] MultiPortConfigRequest request) + { + try + { + // This is a placeholder for future web panel integration + // For now, users need to manually edit appsettings.json + return Ok(new { success = false, message = "Please manually edit appsettings.json to enable multi-port mode. Set OpenJibo:MultiPortMode:Enabled to true and configure the ports." }); + } + catch (Exception ex) + { + return BadRequest(new { success = false, message = ex.Message }); + } + } +} + +public class MultiPortConfigRequest +{ + public int? Api { get; set; } + public int? ApiSocket { get; set; } + public int? NeoHubListen { get; set; } + public int? NeoHubProactive { get; set; } + public int? WebPanel { get; set; } +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/Program.cs b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/Program.cs index 337d58f..718d169 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/Program.cs +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/Program.cs @@ -9,12 +9,53 @@ using Jibo.Cloud.Infrastructure.DependencyInjection; var builder = WebApplication.CreateBuilder(args); builder.Services.AddOpenJiboCloud(builder.Configuration); +builder.Services.AddControllers(); + +// Add CORS for multi-server controller support (for future api support so we can hook up azure / aws / firebase / pocketbase) <===================================================================== +builder.Services.AddCors(options => +{ + options.AddPolicy("WebPanelPolicy", policy => + { + var allowedOrigins = builder.Configuration["OpenJibo:WebPanel:AllowedOrigins"]?.Split(',', StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty(); + + if (allowedOrigins.Length > 0) + { + policy.WithOrigins(allowedOrigins) + .AllowAnyMethod() + .AllowAnyHeader(); + } + else + { + // Default: allow localhost for development + policy.WithOrigins("http://localhost:3380", "http://localhost:3000", "http://localhost:8080") + .AllowAnyMethod() + .AllowAnyHeader(); + } + }); +}); var app = builder.Build(); app.Logger.LogInformation("Starting Open Jibo Cloud Api version {Version}", OpenJiboCloudBuildInfo.Version); app.UseWebSockets(); +app.UseCors("WebPanelPolicy"); +app.UseDefaultFiles(); +app.UseStaticFiles(); +app.MapControllers(); + +// Serve web panel index.html for root requests on port 3380 <===================================================================== +app.Use(async (context, next) => +{ + if (context.Request.Path == "/" && (context.Request.Host.Port == 3380 || + (context.Request.Host.Value != null && context.Request.Host.Value.Contains("3380")))) + { + context.Response.ContentType = "text/html"; + await context.Response.SendFileAsync(Path.Combine(app.Environment.WebRootPath, "index.html")); + return; + } + await next(); +}); app.Use(async (context, next) => { @@ -24,7 +65,7 @@ app.Use(async (context, next) => return; } - var kind = ResolveSocketKind(context.Request.Host.Host, context.Request.Path); + var kind = ResolveSocketKind(context.Request.Host.Host, context.Request.Path, context.Request.Host.Port, builder.Configuration); var token = ResolveToken(context.Request); switch (kind) { @@ -127,6 +168,32 @@ app.MapGet("/health", () => Results.Json(new app.MapMethods("/{**path}", ["GET", "POST", "PUT"], async (HttpContext context, JiboCloudProtocolService service, IProtocolTelemetrySink telemetrySink, CancellationToken cancellationToken) => { + // For web panel port, **try** to serve static files <===================================================================== + if (context.Request.Host.Port == 3380 || + (context.Request.Host.Value != null && context.Request.Host.Value.Contains("3380"))) + { + var path = context.Request.Path.Value ?? ""; + var filePath = Path.Combine(app.Environment.WebRootPath, path.TrimStart('/')); + + if (File.Exists(filePath)) + { + var contentType = Path.GetExtension(filePath) switch + { + ".css" => "text/css", + ".js" => "application/javascript", + ".html" => "text/html", + _ => "application/octet-stream" + }; + + context.Response.ContentType = contentType; + await context.Response.SendFileAsync(filePath); + return; + } + + context.Response.StatusCode = 404; + return; + } + var envelope = await BuildEnvelopeAsync(context, cancellationToken); var result = await service.DispatchAsync(envelope, cancellationToken); await telemetrySink.RecordAsync(envelope, result, cancellationToken); @@ -187,8 +254,21 @@ static async Task BuildEnvelopeAsync(HttpContext context, Canc }; } -static string ResolveSocketKind(string host, PathString path) +static string ResolveSocketKind(string host, PathString path, int? port, IConfiguration configuration) { + var multiPortEnabled = configuration.GetValue("OpenJibo:MultiPortMode:Enabled"); + + if (multiPortEnabled && port.HasValue) + { + var apiSocketPort = configuration.GetValue("OpenJibo:MultiPortMode:Ports:ApiSocket"); + var neoHubListenPort = configuration.GetValue("OpenJibo:MultiPortMode:Ports:NeoHubListen"); + var neoHubProactivePort = configuration.GetValue("OpenJibo:MultiPortMode:Ports:NeoHubProactive"); + + if (port == apiSocketPort) return "api-socket"; + if (port == neoHubProactivePort) return "neo-hub-proactive"; + if (port == neoHubListenPort) return "neo-hub-listen"; + } + if (host.Equals("api-socket.jibo.com", StringComparison.OrdinalIgnoreCase)) return "api-socket"; if (host.Equals("neo-hub.jibo.com", StringComparison.OrdinalIgnoreCase) && diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/appsettings.json b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/appsettings.json index 786bfdd..68bbb9a 100644 --- a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/appsettings.json +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/appsettings.json @@ -1,5 +1,39 @@ { + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://localhost:5000" + }, + "ApiSocket": { + "Url": "http://localhost:5001" + }, + "NeoHubListen": { + "Url": "http://localhost:5002" + }, + "NeoHubProactive": { + "Url": "http://localhost:5003" + }, + "WebPanel": { + "Url": "http://localhost:3380" + } + } + }, "OpenJibo": { + "MultiPortMode": { + "Enabled": true, + "Ports": { + "Api": 5000, + "ApiSocket": 5001, + "NeoHubListen": 5002, + "NeoHubProactive": 5003, + "WebPanel": 3380 + } + }, + "WebPanel": { + "Enabled": true, + "RefreshIntervalSeconds": 5, + "AllowRemoteAccess": false + }, "Telemetry": { "Enabled": true, "ExportFixtures": true, diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/wwwroot/css/panel.css b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/wwwroot/css/panel.css new file mode 100644 index 0000000..64a50e1 --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/wwwroot/css/panel.css @@ -0,0 +1,399 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: #363636; + color: #ffffff; + min-height: 100vh; + margin: 0; +} + +.app-container { + display: flex; + flex-direction: row; + height: 100vh; + overflow: hidden; +} + +/* Custom Sidebar with Material Design styling */ +.sidebar { + width: 280px; + height: 100%; + background: #212121; + flex-shrink: 0; + display: flex; + flex-direction: column; +} + +.sidebar-header { + padding: 16px; + font-size: 20px; + font-weight: 500; + color: #fbfbfb; +} + +.nav-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + cursor: pointer; + transition: background-color 0.2s ease; + color: #dfdfdf; + font-size: 20px; + margin: 4px 8px; + +} + +.nav-item:hover { + background-color: rgba(98, 0, 238, 0.08); +} + +.nav-item.active { + background-color: rgba(98, 0, 238, 0.12); + color: #df62ff; +} + +.nav-icon { + font-size: 40px; +} + +.nav-text { + flex: 1; +} + +.header { + background: #6200ee; + color: #ffffff; + padding: 16px 24px; + display: flex; + justify-content: space-between; + align-items: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.title { + font-size: 20px; + font-weight: 500; + color: #ffffff; +} + +/* Main Content Area */ +.main-wrapper { + display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; +} + +.main-content { + flex: 1; + padding: 16px; + overflow-y: auto; + display: none; +} + +.main-content.active { + display: block; +} + +/* Material Web Card */ +md-elevated-card { + margin-bottom: 16px; + max-width: 100%; +} + +.card-content { + padding: 16px; +} + +.status-grid, +.config-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; +} + +.status-item, +.config-item { + display: flex; + flex-direction: column; + gap: 4px; +} + +.status-label, +.config-label, +.detail-label, +.check-label, +.health-label, +.count-label { + font-size: 12px; + color: #666666; + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 500; +} + +.status-value, +.config-value, +.detail-value, +.check-value, +.health-value, +.count-value { + font-size: 14px; + color: #111111; + font-weight: 500; +} + +.robot-item { + background: #1e1e1e; + border-radius: 4px; + padding: 16px; +} + +.robot-info { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid #e0e0e0; +} + +.robot-name { + font-size: 16px; + font-weight: 500; + color: #ffffff; +} + +.robot-id { + font-size: 12px; + color: #666666; + font-family: monospace; +} + +.robot-details { + display: grid; + gap: 8px; +} + +.detail-item { + display: flex; + justify-content: space-between; + align-items: center; +} + +.session-count { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + padding: 12px; + background: #f5f5f5; + border-radius: 4px; +} + +.count-value { + font-size: 24px; + font-weight: 700; + color: #6200ee; +} + +.sessions-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.empty-state { + text-align: center; + color: #666666; + font-style: italic; + padding: 20px; +} + +.health-status { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + padding: 12px; + background: #111111; + border-radius: 4px; +} + +.health-value { + font-size: 18px; + font-weight: 500; + color: #4caf50; +} + +.health-value.warning { + color: #ff9800; +} + +.health-value.error { + color: #f44336; +} + +.health-checks { + display: flex; + flex-direction: column; + gap: 12px; +} + +.health-check { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: #f5f5f5; + border-radius: 4px; +} + +.check-value { + color: #4caf50; +} + +.check-value.warning { + color: #ff9800; +} + +.check-value.error { + color: #f44336; +} + +/* Status indicator */ +.status-indicator { + display: flex; + align-items: center; + gap: 8px; +} + +.status-dot { + width: 12px; + height: 12px; + border-radius: 50%; + background: #9e9e9e; + animation: pulse 2s infinite; +} + +.status-dot.connected { + background: #4caf50; +} + +.status-dot.disconnected { + background: #f44336; + animation: none; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.status-text { + font-size: 14px; + color: #ffffff; + font-weight: 500; +} + +/* Material Web Button warning variant */ +md-filled-button.warning { + --md-filled-button-container-color: #f44336; + --md-filled-button-label-text-color: #ffffff; +} + +.controls-grid { + display: flex; + gap: 8px; +} + +/* Terminal Styles */ +.terminal-container { + background: #1e1e1e; + border-radius: 4px; + padding: 16px; + height: calc(100vh - 120px); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.terminal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid #333; +} + +.terminal-title { + font-size: 16px; + font-weight: 500; + color: #d4d4d4; +} + +.terminal-controls { + display: flex; + gap: 8px; +} + +.terminal-output { + flex: 1; + overflow-y: auto; + font-family: 'Courier New', monospace; + font-size: 13px; + line-height: 1.4; + color: #d4d4d4; + padding: 8px; + background: #0d0d0d; + border-radius: 4px; +} + +.log-entry { + margin-bottom: 4px; + white-space: pre-wrap; + word-break: break-all; +} + +.log-entry.info { + color: #5bc0de; +} + +.log-entry.warning { + color: #f0ad4e; +} + +.log-entry.error { + color: #d9534f; +} + +.log-entry.debug { + color: #777; +} + +@media (max-width: 768px) { + .app-container { + flex-direction: column; + } + + md-navigation-drawer { + width: 100%; + height: auto; + } + + .main-content { + grid-template-columns: 1fr; + } + + .status-grid, + .config-grid { + grid-template-columns: 1fr; + } +} diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/wwwroot/index.html b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/wwwroot/index.html new file mode 100644 index 0000000..ebb1df4 --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/wwwroot/index.html @@ -0,0 +1,217 @@ + + + + + + OpenJibo Cloud Panel + + + + + + + +
+ + +
+
+

OpenJibo Cloud Panel Test Thingy

+
+ + Connecting... +
+
+ + +
+ +
+

Server Status

+
+
+ Version + - +
+
+ Uptime + - +
+
+ Started + - +
+
+ Last Saved + - +
+
+
+
+ + +
+

Server Quick Controls

+
+ Save State + Reload State +
+
+
+
+ + +
+ +
+

Will Have Connected Robots

+
+
+
+ - + - +
+
+
+ Device ID: + - +
+
+ Firmware: + - +
+
+ App Version: + - +
+
+ Platform: + - +
+
+
+
+
+
+
+ + +
+ +
+

Active Sessions

+
+ Active Sessions: + 0 +
+
+

No active sessions

+
+
+
+
+ + +
+ +
+

Health Check

+
+ Overall Status: + - +
+
+
+ Persistence: + - +
+
+ State Store: + - +
+
+
+
+
+ + +
+ +
+

Will be Configurator

+
+
+ Web Panel Enabled + - +
+
+ Refresh Interval + - +
+
+ Remote Access + - +
+
+
+
+
+ + +
+
+
+ Server Logs +
+ Clear + Auto Scroll +
+
+
+
Waiting for server logs...
+
+
+
+
+
+ + + + diff --git a/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/wwwroot/js/panel.js b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/wwwroot/js/panel.js new file mode 100644 index 0000000..17a743b --- /dev/null +++ b/OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/wwwroot/js/panel.js @@ -0,0 +1,403 @@ +const API_BASE = '/api/panel'; +let refreshInterval = 5000; // Default 5 seconds +let refreshTimer = null; +let isConnected = false; +let autoScrollEnabled = true; +let currentTab = 'dashboard'; + +// Initialize the panel +async function init() { + try { + // Fetch configuration first to get refresh interval + const status = await fetchStatus(); + if (status && status.configuration) { + refreshInterval = (status.configuration.refreshIntervalSeconds || 5) * 1000; + } + + // Initial data load + await refreshAll(); + + // Set up auto-refresh + startAutoRefresh(); + + // Update connection status + setConnectionStatus(true); + + // Start terminal if on terminal tab + if (currentTab === 'terminal') { + startTerminal(); + } + } catch (error) { + console.error('Failed to initialize panel:', error); + setConnectionStatus(false); + // Retry after 5 seconds + setTimeout(init, 5000); + } +} + +// Tab switching +function switchTab(tabName) { + currentTab = tabName; + + // Update navigation items + document.querySelectorAll('.nav-item').forEach(item => { + item.classList.remove('active'); + if (item.dataset.tab === tabName) { + item.classList.add('active'); + } + }); + + // Update tab content + document.querySelectorAll('.main-content').forEach(content => { + content.classList.remove('active'); + }); + + const targetTab = document.getElementById(`tab-${tabName}`); + if (targetTab) { + targetTab.classList.add('active'); + } + + // Start terminal if switching to terminal tab + if (tabName === 'terminal') { + startTerminal(); + } else { + stopTerminal(); + } +} + +// Fetch server status +async function fetchStatus() { + try { + const response = await fetch(`${API_BASE}/status`); + if (!response.ok) throw new Error('Failed to fetch status'); + return await response.json(); + } catch (error) { + console.error('Error fetching status:', error); + return null; + } +} + +// Fetch sessions +async function fetchSessions() { + try { + const response = await fetch(`${API_BASE}/sessions`); + if (!response.ok) throw new Error('Failed to fetch sessions'); + return await response.json(); + } catch (error) { + console.error('Error fetching sessions:', error); + return null; + } +} + +// Fetch robots +async function fetchRobots() { + try { + const response = await fetch(`${API_BASE}/robots`); + if (!response.ok) throw new Error('Failed to fetch robots'); + return await response.json(); + } catch (error) { + console.error('Error fetching robots:', error); + return null; + } +} + +// Fetch health +async function fetchHealth() { + try { + const response = await fetch(`${API_BASE}/health`); + if (!response.ok) throw new Error('Failed to fetch health'); + return await response.json(); + } catch (error) { + console.error('Error fetching health:', error); + return null; + } +} + +// Refresh all data +async function refreshAll() { + const [status, sessions, robots, health] = await Promise.all([ + fetchStatus(), + fetchSessions(), + fetchRobots(), + fetchHealth() + ]); + + if (status) updateStatus(status); + if (sessions) updateSessions(sessions); + if (robots) updateRobots(robots); + if (health) updateHealth(health); + + updateLastRefresh(); +} + +// Update server status UI +function updateStatus(data) { + document.getElementById('serverVersion').textContent = data.version || '-'; + document.getElementById('serverUptime').textContent = data.uptime || '-'; + document.getElementById('serverStartTime').textContent = formatDateTime(data.startTime) || '-'; + document.getElementById('lastSaved').textContent = formatDateTime(data.persistence?.lastSaved) || '-'; + + if (data.configuration) { + document.getElementById('webPanelEnabled').textContent = + data.configuration.webPanelEnabled ? 'Yes' : 'No'; + document.getElementById('refreshInterval').textContent = + `${data.configuration.refreshIntervalSeconds}s`; + document.getElementById('remoteAccess').textContent = + data.configuration.allowRemoteAccess ? 'Yes' : 'No'; + } +} + +// Update sessions UI +function updateSessions(data) { + const count = data.count || 0; + document.getElementById('sessionCount').textContent = count; + + const sessionsList = document.getElementById('sessionsList'); + if (count === 0 || !data.sessions || data.sessions.length === 0) { + sessionsList.innerHTML = '

No active sessions

'; + } else { + sessionsList.innerHTML = data.sessions.map(session => ` +
+
+ ${session.kind || 'Unknown'} + ${session.token || 'No token'} +
+
+ Last seen: ${formatDateTime(session.lastSeenUtc)} +
+
+ `).join(''); + } +} + +// Update robots UI +function updateRobots(data) { + if (data.robots && data.robots.length > 0) { + const robot = data.robots[0]; + document.getElementById('robotName').textContent = robot.friendlyName || 'Unknown Robot'; + document.getElementById('robotId').textContent = robot.robotId || '-'; + document.getElementById('deviceId').textContent = robot.deviceId || '-'; + document.getElementById('firmwareVersion').textContent = robot.firmwareVersion || '-'; + document.getElementById('appVersion').textContent = robot.applicationVersion || '-'; + document.getElementById('platform').textContent = robot.profile?.platform || '-'; + } +} + +// Update health UI +function updateHealth(data) { + const healthStatus = document.getElementById('healthStatus'); + healthStatus.textContent = data.status || '-'; + healthStatus.className = 'health-value'; + + if (data.status === 'healthy') { + healthStatus.classList.add('success'); + } else if (data.status === 'warning') { + healthStatus.classList.add('warning'); + } else { + healthStatus.classList.add('error'); + } + + if (data.checks) { + const persistenceStatus = document.getElementById('persistenceStatus'); + persistenceStatus.textContent = data.checks.persistence?.status || '-'; + persistenceStatus.className = 'check-value'; + if (data.checks.persistence?.status === 'ok') { + persistenceStatus.classList.add('success'); + } else if (data.checks.persistence?.status === 'warning') { + persistenceStatus.classList.add('warning'); + } else { + persistenceStatus.classList.add('error'); + } + + const stateStoreStatus = document.getElementById('stateStoreStatus'); + stateStoreStatus.textContent = data.checks.stateStore?.status || '-'; + stateStoreStatus.className = 'check-value'; + if (data.checks.stateStore?.status === 'ok') { + stateStoreStatus.classList.add('success'); + } else { + stateStoreStatus.classList.add('error'); + } + } +} + +// Update connection status indicator +function setConnectionStatus(connected) { + isConnected = connected; + const dot = document.getElementById('connectionStatus'); + const text = document.getElementById('connectionText'); + + dot.className = 'status-dot ' + (connected ? 'connected' : 'disconnected'); + text.textContent = connected ? 'Connected' : 'Disconnected'; +} + +// Update last refresh time +function updateLastRefresh() { + document.getElementById('lastUpdate').textContent = formatDateTime(new Date().toISOString()); + updateNextRefresh(); +} + +// Update next refresh countdown +function updateNextRefresh() { + const nextRefresh = document.getElementById('nextRefresh'); + const seconds = Math.ceil(refreshInterval / 1000); + nextRefresh.textContent = `${seconds}s`; +} + +// Start auto-refresh +function startAutoRefresh() { + if (refreshTimer) clearInterval(refreshTimer); + + refreshTimer = setInterval(() => { + refreshAll(); + }, refreshInterval); +} + +// Format date/time for display +function formatDateTime(isoString) { + if (!isoString) return '-'; + try { + const date = new Date(isoString); + return date.toLocaleString(); + } catch (error) { + return '-'; + } +} + +// Save state +async function saveState() { + if (!confirm('Are you sure you want to save the current state?')) { + return; + } + + try { + const response = await fetch(`${API_BASE}/state/save`, { + method: 'POST' + }); + const result = await response.json(); + + if (result.success) { + alert('State saved successfully!'); + await refreshAll(); + } else { + alert(`Failed to save state: ${result.message}`); + } + } catch (error) { + console.error('Error saving state:', error); + alert('Failed to save state. Check console for details.'); + } +} + +// Reload state +async function reloadState() { + if (!confirm('Are you sure you want to reload the state? This will discard any unsaved changes.')) { + return; + } + + try { + const response = await fetch(`${API_BASE}/state/reload`, { + method: 'POST' + }); + const result = await response.json(); + + if (result.success) { + alert('State reloaded successfully!'); + await refreshAll(); + } else { + alert(`Failed to reload state: ${result.message}`); + } + } catch (error) { + console.error('Error reloading state:', error); + alert('Failed to reload state. Check console for details.'); + } +} + +// Terminal functionality +let terminalInterval = null; +let lastLogTimestamp = 0; + +async function startTerminal() { + if (terminalInterval) return; + + const terminalOutput = document.getElementById('terminalOutput'); + terminalOutput.innerHTML = '
Connecting to server logs...
'; + + // Fetch logs periodically + await fetchLogs(); + terminalInterval = setInterval(fetchLogs, 3000); +} + +function stopTerminal() { + if (terminalInterval) { + clearInterval(terminalInterval); + terminalInterval = null; + } +} + +async function fetchLogs() { + try { + const response = await fetch(`${API_BASE}/logs?since=${lastLogTimestamp}`); + if (!response.ok) throw new Error('Failed to fetch logs'); + const data = await response.json(); + + if (data.logs && data.logs.length > 0) { + const terminalOutput = document.getElementById('terminalOutput'); + if (!terminalOutput) return; + + // Clear the "connecting" message if it exists + if (terminalOutput.querySelector('.log-entry')?.textContent === 'Connecting to server logs...') { + terminalOutput.innerHTML = ''; + } + + // Add new log entries + data.logs.forEach(log => { + addLogEntry(log.level || 'info', `[${new Date(log.timestamp).toISOString()}] ${log.message}`); + // Update last timestamp + if (log.timestamp > lastLogTimestamp) { + lastLogTimestamp = log.timestamp; + } + }); + } + } catch (error) { + console.error('Error fetching logs:', error); + addLogEntry('error', 'Failed to fetch logs'); + } +} + +function addLogEntry(level, message) { + const terminalOutput = document.getElementById('terminalOutput'); + if (!terminalOutput) return; + + const logEntry = document.createElement('div'); + logEntry.className = `log-entry ${level}`; + logEntry.textContent = message; + terminalOutput.appendChild(logEntry); + + // Keep only last 100 entries to prevent memory issues + while (terminalOutput.children.length > 100) { + terminalOutput.removeChild(terminalOutput.firstChild); + } + + if (autoScrollEnabled) { + terminalOutput.scrollTop = terminalOutput.scrollHeight; + } +} + +function clearTerminal() { + const terminalOutput = document.getElementById('terminalOutput'); + if (terminalOutput) { + terminalOutput.innerHTML = '
Terminal cleared
'; + } +} + +function toggleAutoScroll() { + autoScrollEnabled = !autoScrollEnabled; + const button = event.target; + button.textContent = autoScrollEnabled ? 'Auto Scroll' : 'Scroll Off'; +} + +// Start the panel when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +}