Compare commits
2 Commits
main
...
Features/W
| Author | SHA1 | Date | |
|---|---|---|---|
|
f6bf5e2079
|
|||
|
32c601d046
|
@@ -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<bool>("OpenJibo:WebPanel:Enabled"),
|
||||
refreshIntervalSeconds = configuration.GetValue<int>("OpenJibo:WebPanel:RefreshIntervalSeconds"),
|
||||
allowRemoteAccess = configuration.GetValue<bool>("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<object>(),
|
||||
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<object> _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<bool>("OpenJibo:MultiPortMode:Enabled");
|
||||
|
||||
if (multiPortEnabled)
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
mode = "multi-port",
|
||||
enabled = true,
|
||||
ports = new
|
||||
{
|
||||
api = configuration.GetValue<int>("OpenJibo:MultiPortMode:Ports:Api"),
|
||||
apiSocket = configuration.GetValue<int>("OpenJibo:MultiPortMode:Ports:ApiSocket"),
|
||||
neoHubListen = configuration.GetValue<int>("OpenJibo:MultiPortMode:Ports:NeoHubListen"),
|
||||
neoHubProactive = configuration.GetValue<int>("OpenJibo:MultiPortMode:Ports:NeoHubProactive"),
|
||||
webPanel = configuration.GetValue<int>("OpenJibo:MultiPortMode:Ports:WebPanel")
|
||||
},
|
||||
robotConfig = new
|
||||
{
|
||||
webCoreServerPort = configuration.GetValue<int>("OpenJibo:MultiPortMode:Ports:Api"),
|
||||
jetstreamServiceServerPort = configuration.GetValue<int>("OpenJibo:MultiPortMode:Ports:Api"),
|
||||
jetstreamServiceRegistryPort = configuration.GetValue<int>("OpenJibo:MultiPortMode:Ports:ApiSocket"),
|
||||
hubClientHubPort = configuration.GetValue<int>("OpenJibo:MultiPortMode:Ports:NeoHubListen"),
|
||||
hubClientProactivePort = configuration.GetValue<int>("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; }
|
||||
}
|
||||
@@ -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<string>();
|
||||
|
||||
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<ProtocolEnvelope> 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<bool>("OpenJibo:MultiPortMode:Enabled");
|
||||
|
||||
if (multiPortEnabled && port.HasValue)
|
||||
{
|
||||
var apiSocketPort = configuration.GetValue<int>("OpenJibo:MultiPortMode:Ports:ApiSocket");
|
||||
var neoHubListenPort = configuration.GetValue<int>("OpenJibo:MultiPortMode:Ports:NeoHubListen");
|
||||
var neoHubProactivePort = configuration.GetValue<int>("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) &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OpenJibo Cloud Panel</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/panel.css">
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"@material/web/": "https://esm.run/@material/web/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module">
|
||||
import '@material/web/all.js';
|
||||
import {styles as typescaleStyles} from '@material/web/typography/md-typescale-styles.js';
|
||||
|
||||
document.adoptedStyleSheets.push(typescaleStyles.styleSheet);
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-header">OpenJibo Panel</div>
|
||||
<div class="nav-item active" data-tab="dashboard" onclick="switchTab('dashboard')">
|
||||
<span class="material-icons nav-icon">dashboard</span>
|
||||
<span class="nav-text">Dashboard</span>
|
||||
</div>
|
||||
<div class="nav-item" data-tab="robots" onclick="switchTab('robots')">
|
||||
<span class="material-icons nav-icon">smart_toy</span>
|
||||
<span class="nav-text">Robots</span>
|
||||
</div>
|
||||
<div class="nav-item" data-tab="sessions" onclick="switchTab('sessions')">
|
||||
<span class="material-icons nav-icon">people</span>
|
||||
<span class="nav-text">Sessions</span>
|
||||
</div>
|
||||
<div class="nav-item" data-tab="health" onclick="switchTab('health')">
|
||||
<span class="material-icons nav-icon">favorite</span>
|
||||
<span class="nav-text">Health</span>
|
||||
</div>
|
||||
<div class="nav-item" data-tab="config" onclick="switchTab('config')">
|
||||
<span class="material-icons nav-icon">settings</span>
|
||||
<span class="nav-text">Config</span>
|
||||
</div>
|
||||
<div class="nav-item" data-tab="terminal" onclick="switchTab('terminal')">
|
||||
<span class="material-icons nav-icon">terminal</span>
|
||||
<span class="nav-text">Terminal</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="main-wrapper">
|
||||
<header class="header">
|
||||
<h1 class="title">OpenJibo Cloud Panel Test Thingy</h1>
|
||||
<div class="status-indicator">
|
||||
<span class="status-dot" id="connectionStatus"></span>
|
||||
<span class="status-text" id="connectionText">Connecting...</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Dashboard Tab -->
|
||||
<div id="tab-dashboard" class="main-content active">
|
||||
<md-elevated-card>
|
||||
<div class="card-content">
|
||||
<h2 class="md-typescale-headline-small">Server Status</h2>
|
||||
<div class="status-grid">
|
||||
<div class="status-item">
|
||||
<span class="status-label">Version</span>
|
||||
<span class="status-value" id="serverVersion">-</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Uptime</span>
|
||||
<span class="status-value" id="serverUptime">-</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Started</span>
|
||||
<span class="status-value" id="serverStartTime">-</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Last Saved</span>
|
||||
<span class="status-value" id="lastSaved">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</md-elevated-card>
|
||||
|
||||
<md-elevated-card>
|
||||
<div class="card-content">
|
||||
<h2 class="md-typescale-headline-small">Server Quick Controls</h2>
|
||||
<div class="controls-grid">
|
||||
<md-filled-button onclick="saveState()">Save State</md-filled-button>
|
||||
<md-filled-button class="warning" onclick="reloadState()">Reload State</md-filled-button>
|
||||
</div>
|
||||
</div>
|
||||
</md-elevated-card>
|
||||
</div>
|
||||
|
||||
<!-- Robots Tab -->
|
||||
<div id="tab-robots" class="main-content">
|
||||
<md-elevated-card>
|
||||
<div class="card-content">
|
||||
<h2 class="md-typescale-headline-small">Will Have Connected Robots</h2>
|
||||
<div id="robotsList">
|
||||
<div class="robot-item">
|
||||
<div class="robot-info">
|
||||
<span class="robot-name" id="robotName">-</span>
|
||||
<span class="robot-id" id="robotId">-</span>
|
||||
</div>
|
||||
<div class="robot-details">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Device ID:</span>
|
||||
<span class="detail-value" id="deviceId">-</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Firmware:</span>
|
||||
<span class="detail-value" id="firmwareVersion">-</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">App Version:</span>
|
||||
<span class="detail-value" id="appVersion">-</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Platform:</span>
|
||||
<span class="detail-value" id="platform">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</md-elevated-card>
|
||||
</div>
|
||||
|
||||
<!-- Sessions Tab -->
|
||||
<div id="tab-sessions" class="main-content">
|
||||
<md-elevated-card>
|
||||
<div class="card-content">
|
||||
<h2 class="md-typescale-headline-small">Active Sessions</h2>
|
||||
<div class="session-count">
|
||||
<span class="count-label">Active Sessions:</span>
|
||||
<span class="count-value" id="sessionCount">0</span>
|
||||
</div>
|
||||
<div id="sessionsList" class="sessions-list">
|
||||
<p class="empty-state">No active sessions</p>
|
||||
</div>
|
||||
</div>
|
||||
</md-elevated-card>
|
||||
</div>
|
||||
|
||||
<!-- Health Tab -->
|
||||
<div id="tab-health" class="main-content">
|
||||
<md-elevated-card>
|
||||
<div class="card-content">
|
||||
<h2 class="md-typescale-headline-small">Health Check</h2>
|
||||
<div class="health-status">
|
||||
<span class="health-label">Overall Status:</span>
|
||||
<span class="health-value" id="healthStatus">-</span>
|
||||
</div>
|
||||
<div class="health-checks">
|
||||
<div class="health-check">
|
||||
<span class="check-label">Persistence:</span>
|
||||
<span class="check-value" id="persistenceStatus">-</span>
|
||||
</div>
|
||||
<div class="health-check">
|
||||
<span class="check-label">State Store:</span>
|
||||
<span class="check-value" id="stateStoreStatus">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</md-elevated-card>
|
||||
</div>
|
||||
|
||||
<!-- Config Tab -->
|
||||
<div id="tab-config" class="main-content">
|
||||
<md-elevated-card>
|
||||
<div class="card-content">
|
||||
<h2 class="md-typescale-headline-small">Will be Configurator</h2>
|
||||
<div class="config-grid">
|
||||
<div class="config-item">
|
||||
<span class="config-label">Web Panel Enabled</span>
|
||||
<span class="config-value" id="webPanelEnabled">-</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="config-label">Refresh Interval</span>
|
||||
<span class="config-value" id="refreshInterval">-</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="config-label">Remote Access</span>
|
||||
<span class="config-value" id="remoteAccess">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</md-elevated-card>
|
||||
</div>
|
||||
|
||||
<!-- Terminal Tab -->
|
||||
<div id="tab-terminal" class="main-content">
|
||||
<div class="terminal-container">
|
||||
<div class="terminal-header">
|
||||
<span class="terminal-title">Server Logs</span>
|
||||
<div class="terminal-controls">
|
||||
<md-outlined-button onclick="clearTerminal()">Clear</md-outlined-button>
|
||||
<md-outlined-button onclick="toggleAutoScroll()">Auto Scroll</md-outlined-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="terminal-output" id="terminalOutput">
|
||||
<div class="log-entry">Waiting for server logs...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/panel.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 = '<p class="empty-state">No active sessions</p>';
|
||||
} else {
|
||||
sessionsList.innerHTML = data.sessions.map(session => `
|
||||
<div class="session-item">
|
||||
<div class="session-info">
|
||||
<span class="session-kind">${session.kind || 'Unknown'}</span>
|
||||
<span class="session-token">${session.token || 'No token'}</span>
|
||||
</div>
|
||||
<div class="session-time">
|
||||
Last seen: ${formatDateTime(session.lastSeenUtc)}
|
||||
</div>
|
||||
</div>
|
||||
`).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 = '<div class="log-entry">Connecting to server logs...</div>';
|
||||
|
||||
// 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 = '<div class="log-entry">Terminal cleared</div>';
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user