2 Commits

6 changed files with 1452 additions and 2 deletions

View File

@@ -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; }
}

View File

@@ -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) &&

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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();
}