Add production Docker Compose file for external PostgreSQL integration

- Introduced `docker-compose.prod.yml` for production deployment.
- Configured service to connect to an external PostgreSQL instance.
- Set environment variables for JWT and database connection strings.
- Defined network and volume for data protection keys.
This commit is contained in:
Matt Batchelder
2026-03-26 19:34:12 -04:00
parent 9a35e40083
commit fc510b9b20
105 changed files with 11291 additions and 2589 deletions

View File

@@ -1,47 +1,15 @@
# OTSSignsOrchestrator — environment variables # OTSSignsOrchestrator — environment variables
# Copy to .env and fill in real values before running. # Copy to .env and fill in real values before running.
#
# Only TWO secrets are required here — everything else is configured
# via the admin UI (Settings page) and stored encrypted in PostgreSQL.
# ── PostgreSQL ─────────────────────────────────────────────────────────────── # ── PostgreSQL ───────────────────────────────────────────────────────────────
# Used directly by the app. When running via docker-compose, POSTGRES_PASSWORD # Password for the postgres service AND the app connection string.
# is also required so the postgres service can initialise the database. # Generate: openssl rand -base64 32
ConnectionStrings__OrchestratorDb=Host=postgres;Port=5432;Database=orchestrator;Username=ots;Password=changeme
POSTGRES_PASSWORD=changeme POSTGRES_PASSWORD=changeme
# ── JWT ────────────────────────────────────────────────────────────────────── # ── JWT ──────────────────────────────────────────────────────────────────────
# Key must be at least 32 characters (256-bit). Generate with: # Key must be at least 32 characters (256-bit).
# openssl rand -base64 32 # Generate: openssl rand -base64 48
Jwt__Key=change-me-to-a-random-256-bit-key JWT_KEY=change-me-to-a-random-256-bit-key
# Jwt__Issuer=OTSSignsOrchestrator # optional — has a default
# Jwt__Audience=OTSSignsOrchestrator # optional — has a default
# ── Bitwarden Secrets Manager ────────────────────────────────────────────────
# Machine account access token from https://vault.bitwarden.com
Bitwarden__AccessToken=
Bitwarden__OrganizationId=
# ProjectId is the default project for orchestrator config secrets
Bitwarden__ProjectId=
# InstanceProjectId is optional; instance-level secrets go here when set
# Bitwarden__InstanceProjectId=
# Bitwarden__IdentityUrl=https://identity.bitwarden.com # optional
# Bitwarden__ApiUrl=https://api.bitwarden.com # optional
# ── Stripe ───────────────────────────────────────────────────────────────────
Stripe__SecretKey=sk_test_...
Stripe__WebhookSecret=whsec_...
# ── Authentik (SAML IdP) ─────────────────────────────────────────────────────
Authentik__BaseUrl=https://auth.example.com
Authentik__ApiToken=
# UUID of the OTS signing certificate-key pair in Authentik
Authentik__OtsSigningKpId=
# Authentik__SourcePreAuthFlowSlug=default-source-pre-authentication # optional
# Authentik__SourceAuthFlowSlug=default-source-authentication # optional
# ── Email (SendGrid) ─────────────────────────────────────────────────────────
Email__SendGridApiKey=SG....
# Email__SenderEmail=noreply@otssigns.com # optional
# Email__SenderName=OTS Signs # optional
# ── Git template repository ───────────────────────────────────────────────────
# These are stored in Bitwarden at runtime; set here only for local dev without BW.
# Git__CacheDir=.template-cache # optional

View File

@@ -35,8 +35,13 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
libssl3 \ libssl3 \
ca-certificates \ ca-certificates \
nfs-common \
default-mysql-client \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Docker CLI — used for local swarm operations when running on the same manager node
COPY --from=docker:27-cli /usr/local/bin/docker /usr/local/bin/docker
COPY --from=dotnet-build /app/publish . COPY --from=dotnet-build /app/publish .
# Data Protection keys must survive restarts — mount a volume here # Data Protection keys must survive restarts — mount a volume here

View File

@@ -103,12 +103,12 @@ public static class HostsApi
return Results.Ok(new { success, message }); return Results.Ok(new { success, message });
}); });
group.MapGet("/{id:guid}/nodes", async (Guid id, OrchestratorDbContext db, SshDockerCliService docker) => group.MapGet("/{id:guid}/nodes", async (Guid id, OrchestratorDbContext db, IDockerServiceFactory dockerFactory) =>
{ {
var host = await db.SshHosts.FindAsync(id); var host = await db.SshHosts.FindAsync(id);
if (host == null) return Results.NotFound(); if (host == null) return Results.NotFound();
docker.SetHost(host); var docker = dockerFactory.GetCliService(host);
var nodes = await docker.ListNodesAsync(); var nodes = await docker.ListNodesAsync();
return Results.Ok(nodes); return Results.Ok(nodes);
}); });

View File

@@ -1,7 +1,6 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using OTSSignsOrchestrator.Services; using OTSSignsOrchestrator.Services;
using OTSSignsOrchestrator.Data; using OTSSignsOrchestrator.Data;
using OTSSignsOrchestrator.Services;
namespace OTSSignsOrchestrator.Api; namespace OTSSignsOrchestrator.Api;
@@ -11,7 +10,7 @@ public static class InstancesApi
{ {
var group = app.MapGroup("/api/instances").RequireAuthorization(); var group = app.MapGroup("/api/instances").RequireAuthorization();
group.MapGet("/live", async (OrchestratorDbContext db, SshDockerCliService docker, ILogger<Program> logger) => group.MapGet("/live", async (OrchestratorDbContext db, IDockerServiceFactory dockerFactory, ILogger<Program> logger) =>
{ {
var hosts = await db.SshHosts.ToListAsync(); var hosts = await db.SshHosts.ToListAsync();
var allInstances = new List<object>(); var allInstances = new List<object>();
@@ -20,7 +19,7 @@ public static class InstancesApi
{ {
try try
{ {
docker.SetHost(host); var docker = dockerFactory.GetCliService(host);
var stacks = await docker.ListStacksAsync(); var stacks = await docker.ListStacksAsync();
foreach (var stack in stacks.Where(s => s.Name.EndsWith("-cms-stack"))) foreach (var stack in stacks.Where(s => s.Name.EndsWith("-cms-stack")))
{ {
@@ -44,22 +43,22 @@ public static class InstancesApi
return Results.Ok(allInstances); return Results.Ok(allInstances);
}); });
group.MapGet("/live/{stackName}/services", async (string stackName, OrchestratorDbContext db, SshDockerCliService docker) => group.MapGet("/live/{stackName}/services", async (string stackName, OrchestratorDbContext db, IDockerServiceFactory dockerFactory) =>
{ {
var host = await FindHostForStack(db, docker, stackName); var host = await FindHostForStack(db, dockerFactory, stackName);
if (host == null) return Results.NotFound("Stack not found on any host"); if (host == null) return Results.NotFound("Stack not found on any host");
docker.SetHost(host); var docker = dockerFactory.GetCliService(host);
var services = await docker.InspectStackServicesAsync(stackName); var services = await docker.InspectStackServicesAsync(stackName);
return Results.Ok(services); return Results.Ok(services);
}); });
group.MapPost("/live/{stackName}/restart", async (string stackName, OrchestratorDbContext db, SshDockerCliService docker) => group.MapPost("/live/{stackName}/restart", async (string stackName, OrchestratorDbContext db, IDockerServiceFactory dockerFactory) =>
{ {
var host = await FindHostForStack(db, docker, stackName); var host = await FindHostForStack(db, dockerFactory, stackName);
if (host == null) return Results.NotFound("Stack not found on any host"); if (host == null) return Results.NotFound("Stack not found on any host");
docker.SetHost(host); var docker = dockerFactory.GetCliService(host);
var services = await docker.InspectStackServicesAsync(stackName); var services = await docker.InspectStackServicesAsync(stackName);
foreach (var svc in services) foreach (var svc in services)
await docker.ForceUpdateServiceAsync(svc.Name); await docker.ForceUpdateServiceAsync(svc.Name);
@@ -68,25 +67,23 @@ public static class InstancesApi
}); });
group.MapPost("/live/{stackName}/services/{serviceName}/restart", group.MapPost("/live/{stackName}/services/{serviceName}/restart",
async (string stackName, string serviceName, OrchestratorDbContext db, SshDockerCliService docker) => async (string stackName, string serviceName, OrchestratorDbContext db, IDockerServiceFactory dockerFactory) =>
{ {
var host = await FindHostForStack(db, docker, stackName); var host = await FindHostForStack(db, dockerFactory, stackName);
if (host == null) return Results.NotFound("Stack not found on any host"); if (host == null) return Results.NotFound("Stack not found on any host");
docker.SetHost(host); var docker = dockerFactory.GetCliService(host);
var success = await docker.ForceUpdateServiceAsync(serviceName); var success = await docker.ForceUpdateServiceAsync(serviceName);
return success ? Results.Ok(new { message = "Service restarted" }) : Results.StatusCode(500); return success ? Results.Ok(new { message = "Service restarted" }) : Results.StatusCode(500);
}); });
group.MapDelete("/live/{stackName}", group.MapDelete("/live/{stackName}",
async (string stackName, OrchestratorDbContext db, SshDockerCliService docker, async (string stackName, OrchestratorDbContext db, IDockerServiceFactory dockerFactory,
SshDockerSecretsService secrets, InstanceService instanceService) => InstanceService instanceService) =>
{ {
var host = await FindHostForStack(db, docker, stackName); var host = await FindHostForStack(db, dockerFactory, stackName);
if (host == null) return Results.NotFound("Stack not found on any host"); if (host == null) return Results.NotFound("Stack not found on any host");
docker.SetHost(host);
secrets.SetHost(host);
var abbrev = stackName.EndsWith("-cms-stack") var abbrev = stackName.EndsWith("-cms-stack")
? stackName[..^"-cms-stack".Length] : stackName.Split('-')[0]; ? stackName[..^"-cms-stack".Length] : stackName.Split('-')[0];
@@ -95,23 +92,22 @@ public static class InstancesApi
}); });
group.MapPost("/live/{stackName}/rotate-mysql", group.MapPost("/live/{stackName}/rotate-mysql",
async (string stackName, OrchestratorDbContext db, SshDockerCliService docker, InstanceService instanceService) => async (string stackName, OrchestratorDbContext db, IDockerServiceFactory dockerFactory, InstanceService instanceService) =>
{ {
var host = await FindHostForStack(db, docker, stackName); var host = await FindHostForStack(db, dockerFactory, stackName);
if (host == null) return Results.NotFound("Stack not found on any host"); if (host == null) return Results.NotFound("Stack not found on any host");
docker.SetHost(host);
var (success, message) = await instanceService.RotateMySqlPasswordAsync(stackName); var (success, message) = await instanceService.RotateMySqlPasswordAsync(stackName);
return Results.Ok(new { success, message }); return Results.Ok(new { success, message });
}); });
group.MapGet("/live/{stackName}/logs", group.MapGet("/live/{stackName}/logs",
async (string stackName, string? service, int? tailLines, OrchestratorDbContext db, SshDockerCliService docker) => async (string stackName, string? service, int? tailLines, OrchestratorDbContext db, IDockerServiceFactory dockerFactory) =>
{ {
var host = await FindHostForStack(db, docker, stackName); var host = await FindHostForStack(db, dockerFactory, stackName);
if (host == null) return Results.NotFound("Stack not found on any host"); if (host == null) return Results.NotFound("Stack not found on any host");
docker.SetHost(host); var docker = dockerFactory.GetCliService(host);
var logs = await docker.GetServiceLogsAsync(stackName, service, tailLines ?? 200); var logs = await docker.GetServiceLogsAsync(stackName, service, tailLines ?? 200);
return Results.Ok(logs); return Results.Ok(logs);
}); });
@@ -158,17 +154,17 @@ public static class InstancesApi
} }
/// <summary> /// <summary>
/// Finds which SSH host a given stack lives on by checking all hosts. /// Finds which host a given stack lives on by querying all registered hosts.
/// </summary> /// </summary>
private static async Task<Data.Entities.SshHost?> FindHostForStack( private static async Task<Data.Entities.SshHost?> FindHostForStack(
OrchestratorDbContext db, SshDockerCliService docker, string stackName) OrchestratorDbContext db, IDockerServiceFactory dockerFactory, string stackName)
{ {
var hosts = await db.SshHosts.ToListAsync(); var hosts = await db.SshHosts.ToListAsync();
foreach (var host in hosts) foreach (var host in hosts)
{ {
try try
{ {
docker.SetHost(host); var docker = dockerFactory.GetCliService(host);
var stacks = await docker.ListStacksAsync(); var stacks = await docker.ListStacksAsync();
if (stacks.Any(s => s.Name == stackName)) if (stacks.Any(s => s.Name == stackName))
return host; return host;

View File

@@ -1,100 +1,4 @@
using System.Security.Claims; // This file is intentionally empty — OperatorsApi has been removed.
using Microsoft.EntityFrameworkCore; // Operator management is now handled via OIDC + admin token.
using OTSSignsOrchestrator.Data; // Delete this file from the project.
using OTSSignsOrchestrator.Data.Entities;
namespace OTSSignsOrchestrator.Api;
public static class OperatorsApi
{
public static void MapOperatorsEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/operators").RequireAuthorization()
.RequireAuthorization(policy => policy.RequireRole("admin"));
group.MapGet("/", async (OrchestratorDbContext db) =>
{
var operators = await db.Operators
.OrderBy(o => o.Email)
.Select(o => new
{
o.Id, o.Email, role = o.Role.ToString(), o.CreatedAt
})
.ToListAsync();
return Results.Ok(operators);
});
group.MapPost("/", async (CreateOperatorRequest req, OrchestratorDbContext db) =>
{
var email = req.Email.Trim().ToLowerInvariant();
if (await db.Operators.AnyAsync(o => o.Email == email))
return Results.Conflict(new { message = "An operator with this email already exists." });
if (!Enum.TryParse<OperatorRole>(req.Role, true, out var role))
return Results.BadRequest(new { message = "Invalid role. Must be Admin or Viewer." });
var op = new Operator
{
Email = email,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password),
Role = role,
CreatedAt = DateTime.UtcNow,
};
db.Operators.Add(op);
await db.SaveChangesAsync();
return Results.Created($"/api/operators/{op.Id}", new
{
op.Id, op.Email, role = op.Role.ToString(), op.CreatedAt
});
});
group.MapPut("/{id:guid}/role", async (Guid id, UpdateRoleRequest req, OrchestratorDbContext db) =>
{
var op = await db.Operators.FindAsync(id);
if (op is null) return Results.NotFound();
if (!Enum.TryParse<OperatorRole>(req.Role, true, out var role))
return Results.BadRequest(new { message = "Invalid role. Must be Admin or Viewer." });
op.Role = role;
await db.SaveChangesAsync();
return Results.Ok(new
{
op.Id, op.Email, role = op.Role.ToString(), op.CreatedAt
});
});
group.MapPost("/{id:guid}/reset-password", async (Guid id, ResetPasswordRequest req, OrchestratorDbContext db) =>
{
var op = await db.Operators.FindAsync(id);
if (op is null) return Results.NotFound();
op.PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.NewPassword);
await db.SaveChangesAsync();
return Results.Ok(new { message = "Password reset successfully." });
});
group.MapDelete("/{id:guid}", async (Guid id, ClaimsPrincipal user, OrchestratorDbContext db) =>
{
var currentId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (id.ToString() == currentId)
return Results.BadRequest(new { message = "Cannot delete your own account." });
var op = await db.Operators.FindAsync(id);
if (op is null) return Results.NotFound();
db.Operators.Remove(op);
await db.SaveChangesAsync();
return Results.NoContent();
});
}
}
public record CreateOperatorRequest(string Email, string Password, string Role);
public record UpdateRoleRequest(string Role);
public record ResetPasswordRequest(string NewPassword);

View File

@@ -73,7 +73,7 @@ public static class ProvisionApi
}); });
group.MapPost("/deploy", async (DeployRequest req, group.MapPost("/deploy", async (DeployRequest req,
OrchestratorDbContext db, SshDockerCliService docker, SshDockerSecretsService secrets, OrchestratorDbContext db, IDockerServiceFactory dockerFactory,
InstanceService instanceService) => InstanceService instanceService) =>
{ {
if (!Guid.TryParse(req.HostId, out var hostId)) if (!Guid.TryParse(req.HostId, out var hostId))
@@ -81,8 +81,9 @@ public static class ProvisionApi
var host = await db.SshHosts.FindAsync(hostId); var host = await db.SshHosts.FindAsync(hostId);
if (host == null) return Results.BadRequest("Host not found"); if (host == null) return Results.BadRequest("Host not found");
docker.SetHost(host); // Resolve and configure both services via the factory
secrets.SetHost(host); _ = dockerFactory.GetCliService(host);
_ = dockerFactory.GetSecretsService(host);
var dto = new CreateInstanceDto var dto = new CreateInstanceDto
{ {

View File

@@ -8,12 +8,12 @@ public static class SecretsApi
public static void MapSecretsEndpoints(this WebApplication app) public static void MapSecretsEndpoints(this WebApplication app)
{ {
app.MapGet("/api/hosts/{hostId:guid}/secrets", app.MapGet("/api/hosts/{hostId:guid}/secrets",
async (Guid hostId, OrchestratorDbContext db, SshDockerSecretsService secrets) => async (Guid hostId, OrchestratorDbContext db, IDockerServiceFactory dockerFactory) =>
{ {
var host = await db.SshHosts.FindAsync(hostId); var host = await db.SshHosts.FindAsync(hostId);
if (host == null) return Results.NotFound(); if (host == null) return Results.NotFound();
secrets.SetHost(host); var secrets = dockerFactory.GetSecretsService(host);
var list = await secrets.ListSecretsAsync(); var list = await secrets.ListSecretsAsync();
return Results.Ok(list); return Results.Ok(list);
}).RequireAuthorization(); }).RequireAuthorization();

View File

@@ -13,27 +13,47 @@ public static class SettingsApi
group.MapGet("/", async (SettingsService settings) => group.MapGet("/", async (SettingsService settings) =>
{ {
var categories = new[] // Canonical keys per category — always shown even on a fresh install
var knownKeys = new Dictionary<string, string[]>
{ {
"Git", "MySql", "Smtp", "Pangolin", "Nfs", "Defaults", ["Git"] = [SettingsService.GitRepoUrl, SettingsService.GitRepoPat],
"Authentik", "Xibo", "Stripe", "Email", "Bitwarden", "Instance" ["MySql"] = [SettingsService.MySqlHost, SettingsService.MySqlPort, SettingsService.MySqlAdminUser, SettingsService.MySqlAdminPassword],
["Smtp"] = [SettingsService.SmtpServer, SettingsService.SmtpPort, SettingsService.SmtpUsername, SettingsService.SmtpPassword, SettingsService.SmtpUseTls, SettingsService.SmtpUseStartTls, SettingsService.SmtpRewriteDomain, SettingsService.SmtpHostname, SettingsService.SmtpFromLineOverride],
["Pangolin"] = [SettingsService.PangolinEndpoint],
["Nfs"] = [SettingsService.NfsServer, SettingsService.NfsExport, SettingsService.NfsExportFolder, SettingsService.NfsOptions],
["Defaults"] = [SettingsService.DefaultCmsImage, SettingsService.DefaultNewtImage, SettingsService.DefaultMemcachedImage, SettingsService.DefaultQuickChartImage, SettingsService.DefaultCmsServerNameTemplate, SettingsService.DefaultThemeHostPath, SettingsService.DefaultMySqlDbTemplate, SettingsService.DefaultMySqlUserTemplate, SettingsService.DefaultPhpPostMaxSize, SettingsService.DefaultPhpUploadMaxFilesize, SettingsService.DefaultPhpMaxExecutionTime],
["Authentik"] = [SettingsService.AuthentikUrl, SettingsService.AuthentikApiKey, SettingsService.AuthentikAuthorizationFlowSlug, SettingsService.AuthentikInvalidationFlowSlug, SettingsService.AuthentikSigningKeypairId, SettingsService.AuthentikOtsSigningKpId, SettingsService.AuthentikSourcePreAuthFlowSlug, SettingsService.AuthentikSourceAuthFlowSlug],
["Xibo"] = [SettingsService.XiboBootstrapClientId, SettingsService.XiboBootstrapClientSecret],
["Stripe"] = [SettingsService.StripeSecretKey, SettingsService.StripeWebhookSecret],
["Email"] = [SettingsService.EmailSendGridApiKey, SettingsService.EmailSenderEmail, SettingsService.EmailSenderName, SettingsService.ReportRecipients],
["Bitwarden"] = [SettingsService.BitwardenAccessToken, SettingsService.BitwardenOrganizationId, SettingsService.BitwardenProjectId, SettingsService.BitwardenInstanceProjectId, SettingsService.BitwardenApiUrl, SettingsService.BitwardenIdentityUrl],
["OIDC"] = [SettingsService.OidcAuthority, SettingsService.OidcClientId, SettingsService.OidcClientSecret, SettingsService.OidcRoleClaim, SettingsService.OidcAdminValue, SettingsService.OidcViewerValue],
}; };
var groups = new List<object>(); var groups = new List<object>();
foreach (var category in categories) foreach (var (category, canonicalKeys) in knownKeys)
{ {
var catSettings = await settings.GetCategoryAsync(category); // Merge stored values with the canonical key list
if (catSettings.Count == 0) continue; var stored = await settings.GetCategoryAsync(category);
var settingItems = catSettings.Select(kvp => new // Start with canonical keys (preserving order), then append any extra stored keys
var allKeys = canonicalKeys
.Concat(stored.Keys.Except(canonicalKeys, StringComparer.OrdinalIgnoreCase))
.ToList();
var settingItems = allKeys.Select(key =>
{ {
key = kvp.Key, var isSensitive = key.Contains("Password") || key.Contains("Secret") || key.Contains("Pat")
value = kvp.Key.Contains("Password") || kvp.Key.Contains("Secret") || kvp.Key.Contains("Pat") || key.Contains("ApiKey") || key.Contains("AccessToken");
|| kvp.Key.Contains("ApiKey") || kvp.Key.Contains("AccessToken") stored.TryGetValue(key, out var storedValue);
? "" : (kvp.Value ?? ""), return new
{
key,
value = isSensitive ? "" : (storedValue ?? ""),
category, category,
isSensitive = kvp.Key.Contains("Password") || kvp.Key.Contains("Secret") || kvp.Key.Contains("Pat") isSensitive,
|| kvp.Key.Contains("ApiKey") || kvp.Key.Contains("AccessToken"), };
}).ToList(); }).ToList();
groups.Add(new { category, settings = settingItems }); groups.Add(new { category, settings = settingItems });
@@ -71,7 +91,7 @@ public static class SettingsApi
}); });
group.MapPost("/test-mysql", async (SettingsService settings, group.MapPost("/test-mysql", async (SettingsService settings,
SshDockerCliService docker, OrchestratorDbContext db) => IDockerServiceFactory dockerFactory, OrchestratorDbContext db) =>
{ {
try try
{ {
@@ -82,12 +102,11 @@ public static class SettingsApi
if (!int.TryParse(mySqlPort, out var port)) port = 3306; if (!int.TryParse(mySqlPort, out var port)) port = 3306;
// Need a host with SSH to tunnel through
var host = await db.SshHosts.FirstOrDefaultAsync(); var host = await db.SshHosts.FirstOrDefaultAsync();
if (host == null) if (host == null)
return Results.Ok(new { success = false, message = "No SSH host configured for MySQL tunnel" }); return Results.Ok(new { success = false, message = "No host configured for MySQL connection" });
docker.SetHost(host); var docker = dockerFactory.GetCliService(host);
var (conn, tunnel) = await docker.OpenMySqlConnectionAsync(mySqlHost, port, adminUser, adminPassword); var (conn, tunnel) = await docker.OpenMySqlConnectionAsync(mySqlHost, port, adminUser, adminPassword);
await using (conn) await using (conn)
using (tunnel) using (tunnel)

View File

@@ -0,0 +1,117 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using OTSSignsOrchestrator.Data;
using OTSSignsOrchestrator.Data.Entities;
namespace OTSSignsOrchestrator.Auth;
/// <summary>
/// Manages the one-time admin bootstrap token. On first run the plaintext is printed
/// to stdout and only the SHA-256 hash is persisted in the <c>AppSettings</c> table.
/// The token survives container restarts. Reset via CLI:
/// <c>docker exec &lt;container&gt; /app/OTSSignsOrchestrator reset-admin-token</c>
/// </summary>
public sealed class AdminTokenService
{
private const string SettingKey = "System.AdminTokenHash";
private const string SettingCategory = "System";
private byte[]? _hash;
/// <summary>
/// Initialises the admin token. If no hash exists in the database a new token is
/// generated, its hash saved, and the plaintext printed to stdout exactly once.
/// </summary>
public async Task InitialiseAsync(IServiceProvider services)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<AdminTokenService>>();
var row = await db.AppSettings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Key == SettingKey);
if (row is not null && !string.IsNullOrWhiteSpace(row.Value))
{
_hash = Convert.FromHexString(row.Value);
logger.LogInformation("Admin token hash loaded from database (token already generated)");
return;
}
// First run — generate and persist
logger.LogInformation("No admin token found — generating new bootstrap token");
await GenerateAndPersistAsync(db, logger);
}
/// <summary>
/// Replaces the current admin token with a freshly generated one.
/// Prints the new plaintext to stdout and persists only the hash.
/// </summary>
public async Task ResetAsync(IServiceProvider services)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<AdminTokenService>>();
await GenerateAndPersistAsync(db, logger);
}
/// <summary>
/// Validates a candidate token against the stored hash using constant-time comparison.
/// </summary>
public bool Validate(string candidateToken)
{
if (_hash is null || string.IsNullOrEmpty(candidateToken))
return false;
var candidateHash = SHA256.HashData(Encoding.UTF8.GetBytes(candidateToken));
return CryptographicOperations.FixedTimeEquals(candidateHash, _hash);
}
private async Task GenerateAndPersistAsync(OrchestratorDbContext db, ILogger logger)
{
var plaintext = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(32));
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(plaintext));
var hashHex = Convert.ToHexStringLower(hash);
var existing = await db.AppSettings.FindAsync(SettingKey);
if (existing is not null)
{
existing.Value = hashHex;
existing.UpdatedAt = DateTime.UtcNow;
}
else
{
db.AppSettings.Add(new AppSetting
{
Key = SettingKey,
Value = hashHex,
Category = SettingCategory,
IsSensitive = false, // it's a hash, not the secret
UpdatedAt = DateTime.UtcNow,
});
}
await db.SaveChangesAsync();
_hash = hash;
// Log via ILogger so the token appears in docker service logs
logger.LogWarning("ADMIN BOOTSTRAP TOKEN: {Token}", plaintext);
logger.LogWarning("This token will NOT be displayed again. Use it at /admintoken to gain SuperAdmin access.");
logger.LogWarning("To reset: docker exec <ctr> /app/OTSSignsOrchestrator reset-admin-token");
// Also print to stdout for direct console access
Console.WriteLine();
Console.WriteLine("╔══════════════════════════════════════════════════════════════════════╗");
Console.WriteLine("║ ADMIN BOOTSTRAP TOKEN ║");
Console.WriteLine("╠══════════════════════════════════════════════════════════════════════╣");
Console.WriteLine($"║ {plaintext} ║");
Console.WriteLine("║ ║");
Console.WriteLine("║ This token will NOT be displayed again. ║");
Console.WriteLine("║ Use it at /admintoken to gain SuperAdmin access. ║");
Console.WriteLine("║ To reset: docker exec <ctr> /app/OTSSignsOrchestrator reset-admin-token ║");
Console.WriteLine("╚══════════════════════════════════════════════════════════════════════╝");
Console.WriteLine();
}
}

View File

@@ -1,76 +1,35 @@
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography;
using System.Text; using System.Text;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using OTSSignsOrchestrator.Data;
using OTSSignsOrchestrator.Data.Entities;
namespace OTSSignsOrchestrator.Auth; namespace OTSSignsOrchestrator.Auth;
public class OperatorAuthService public class OperatorAuthService
{ {
private readonly OrchestratorDbContext _db;
private readonly JwtOptions _jwt; private readonly JwtOptions _jwt;
private readonly ILogger<OperatorAuthService> _logger;
public OperatorAuthService( public OperatorAuthService(IOptions<JwtOptions> jwt)
OrchestratorDbContext db,
IOptions<JwtOptions> jwt,
ILogger<OperatorAuthService> logger)
{ {
_db = db;
_jwt = jwt.Value; _jwt = jwt.Value;
_logger = logger;
} }
public async Task<(string Jwt, string RefreshToken)> LoginAsync(string email, string password) /// <summary>
{ /// Generates a signed JWT for the given identity. Used by admin-token redemption
var op = await _db.Operators.FirstOrDefaultAsync( /// and the OIDC callback to issue the <c>ots_access_token</c> cookie.
o => o.Email == email.Trim().ToLowerInvariant()); /// </summary>
public string GenerateJwt(string email, string role)
if (op is null || !BCrypt.Net.BCrypt.Verify(password, op.PasswordHash))
{
_logger.LogWarning("Login failed for {Email}", email);
throw new UnauthorizedAccessException("Invalid email or password.");
}
_logger.LogInformation("Operator {Email} logged in", op.Email);
var jwt = GenerateJwt(op);
var refresh = await CreateRefreshTokenAsync(op.Id);
return (jwt, refresh);
}
public async Task<string> RefreshAsync(string refreshToken)
{
var token = await _db.RefreshTokens
.Include(r => r.Operator)
.FirstOrDefaultAsync(r => r.Token == refreshToken);
if (token is null || token.RevokedAt is not null || token.ExpiresAt < DateTime.UtcNow)
throw new UnauthorizedAccessException("Invalid or expired refresh token.");
// Revoke the used token (single-use rotation)
token.RevokedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
_logger.LogInformation("Refresh token used for operator {Email}", token.Operator.Email);
return GenerateJwt(token.Operator);
}
private string GenerateJwt(Operator op)
{ {
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwt.Key)); var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwt.Key));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[] var claims = new[]
{ {
new Claim(JwtRegisteredClaimNames.Sub, op.Id.ToString()), new Claim(JwtRegisteredClaimNames.Sub, email),
new Claim(JwtRegisteredClaimNames.Email, op.Email), new Claim(JwtRegisteredClaimNames.Email, email),
new Claim(ClaimTypes.Name, op.Email), new Claim(ClaimTypes.Name, email),
new Claim(ClaimTypes.Role, op.Role.ToString()), new Claim(ClaimTypes.Role, role),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}; };
@@ -83,20 +42,4 @@ public class OperatorAuthService
return new JwtSecurityTokenHandler().WriteToken(token); return new JwtSecurityTokenHandler().WriteToken(token);
} }
private async Task<string> CreateRefreshTokenAsync(Guid operatorId)
{
var tokenValue = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
_db.RefreshTokens.Add(new RefreshToken
{
Id = Guid.NewGuid(),
OperatorId = operatorId,
Token = tokenValue,
ExpiresAt = DateTime.UtcNow.AddDays(7),
});
await _db.SaveChangesAsync();
return tokenValue;
}
} }

View File

@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

View File

@@ -1,11 +1,11 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" class="dark">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OTS Signs Orchestrator</title> <title>OTS Signs Orchestrator</title>
</head> </head>
<body class="bg-gray-950 text-gray-100 antialiased"> <body class="bg-background text-foreground antialiased">
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>

File diff suppressed because it is too large Load Diff

View File

@@ -9,13 +9,23 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.3.0",
"@fontsource-variable/geist": "^5.2.8",
"@microsoft/signalr": "^8.0.7", "@microsoft/signalr": "^8.0.7",
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"lucide-react": "^1.0.1",
"next-themes": "^0.4.6",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.0", "react-router-dom": "^7.1.0",
"react-syntax-highlighter": "^15.6.1", "react-syntax-highlighter": "^15.6.1",
"shadcn": "^4.1.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"zustand": "^5.0.2" "zustand": "^5.0.2"
}, },
"devDependencies": { "devDependencies": {
@@ -23,10 +33,10 @@
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"tailwindcss": "^4.1.0", "tailwindcss": "^4.1.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",
"vite": "^6.1.0", "vite": "^6.1.0"
"@vitejs/plugin-react": "^4.3.4"
} }
} }

View File

@@ -1,19 +1,18 @@
import { Routes, Route, Navigate } from 'react-router-dom'; import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore } from './store/authStore'; import { useAuthStore } from './store/authStore';
import AppShell from './components/layout/AppShell'; import AppShell from './components/layout/AppShell';
import ErrorBoundary from './components/shared/ErrorBoundary';
import LoginPage from './pages/LoginPage'; import LoginPage from './pages/LoginPage';
import AdminTokenPage from './pages/AdminTokenPage';
import FleetPage from './pages/FleetPage'; import FleetPage from './pages/FleetPage';
import CustomerDetailPage from './pages/CustomerDetailPage'; import CustomerDetailPage from './pages/CustomerDetailPage';
import HostsPage from './pages/HostsPage'; import HostsPage from './pages/HostsPage';
import InstancesPage from './pages/InstancesPage'; import InstancesPage from './pages/InstancesPage';
import CreateInstancePage from './pages/CreateInstancePage'; import InstanceDetailPage from './pages/InstanceDetailPage';
import SecretsPage from './pages/SecretsPage';
import SettingsPage from './pages/SettingsPage'; import SettingsPage from './pages/SettingsPage';
import LogsPage from './pages/LogsPage'; import LogsPage from './pages/LogsPage';
import ReportsPage from './pages/ReportsPage'; import ReportsPage from './pages/ReportsPage';
import OperatorsPage from './pages/OperatorsPage';
import HealthPage from './pages/HealthPage'; import HealthPage from './pages/HealthPage';
import AuditPage from './pages/AuditPage';
import CustomersPage from './pages/CustomersPage'; import CustomersPage from './pages/CustomersPage';
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -26,27 +25,31 @@ export default function App() {
return ( return (
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/admintoken" element={<AdminTokenPage />} />
<Route <Route
path="/*" path="/*"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<AppShell> <AppShell>
<ErrorBoundary>
<Routes> <Routes>
<Route path="/" element={<Navigate to="/fleet" replace />} /> <Route path="/" element={<Navigate to="/fleet" replace />} />
<Route path="/fleet" element={<FleetPage />} /> <Route path="/fleet" element={<FleetPage />} />
<Route path="/fleet/:id" element={<CustomerDetailPage />} /> <Route path="/fleet/:id" element={<CustomerDetailPage />} />
<Route path="/hosts" element={<HostsPage />} /> <Route path="/hosts" element={<HostsPage />} />
<Route path="/instances" element={<InstancesPage />} /> <Route path="/instances" element={<InstancesPage />} />
<Route path="/instances/new" element={<CreateInstancePage />} /> <Route path="/instances/:stackName" element={<InstanceDetailPage />} />
<Route path="/secrets" element={<SecretsPage />} /> <Route path="/instances/new" element={<Navigate to="/instances" replace />} />
<Route path="/secrets" element={<Navigate to="/hosts" replace />} />
<Route path="/audit" element={<Navigate to="/logs" replace />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/logs" element={<LogsPage />} /> <Route path="/logs" element={<LogsPage />} />
<Route path="/reports" element={<ReportsPage />} /> <Route path="/reports" element={<ReportsPage />} />
<Route path="/operators" element={<OperatorsPage />} />
<Route path="/health" element={<HealthPage />} /> <Route path="/health" element={<HealthPage />} />
<Route path="/audit" element={<AuditPage />} />
<Route path="/customers" element={<CustomersPage />} /> <Route path="/customers" element={<CustomersPage />} />
<Route path="/customers/:id" element={<CustomerDetailPage />} />
</Routes> </Routes>
</ErrorBoundary>
</AppShell> </AppShell>
</ProtectedRoute> </ProtectedRoute>
} }

View File

@@ -1,7 +1,7 @@
import { apiPost, apiGet } from './client'; import { apiPost, apiGet } from './client';
export function login(email: string, password: string) { export function redeemAdminToken(token: string) {
return apiPost<{ message: string }>('/api/auth/web/login', { email, password }); return apiPost<{ message: string }>('/api/auth/admin-token', { token });
} }
export function logout() { export function logout() {
@@ -9,5 +9,5 @@ export function logout() {
} }
export function getMe() { export function getMe() {
return apiGet<{ id: string; email: string; role: string }>('/api/auth/web/me'); return apiGet<{ id: string; email: string; role: 'SuperAdmin' | 'Admin' | 'Viewer' }>('/api/auth/web/me');
} }

View File

@@ -6,12 +6,6 @@ export class ApiError extends Error {
async function handleResponse<T>(res: Response): Promise<T> { async function handleResponse<T>(res: Response): Promise<T> {
if (res.status === 401) { if (res.status === 401) {
// Try refresh
const refreshRes = await fetch('/api/auth/web/refresh', { method: 'POST', credentials: 'include' });
if (refreshRes.ok) {
// Retry original request — caller should retry
throw new ApiError(401, 'Token refreshed, retry');
}
window.location.href = '/login'; window.location.href = '/login';
throw new ApiError(401, 'Unauthorized'); throw new ApiError(401, 'Unauthorized');
} }
@@ -25,15 +19,7 @@ async function handleResponse<T>(res: Response): Promise<T> {
export async function api<T>(path: string, init?: RequestInit): Promise<T> { export async function api<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, { credentials: 'include', ...init }); const res = await fetch(path, { credentials: 'include', ...init });
try { return handleResponse<T>(res);
return await handleResponse<T>(res);
} catch (e) {
if (e instanceof ApiError && e.status === 401 && e.message.includes('retry')) {
const retry = await fetch(path, { credentials: 'include', ...init });
return handleResponse<T>(retry);
}
throw e;
}
} }
export function apiGet<T>(path: string) { export function apiGet<T>(path: string) {

View File

@@ -1,27 +1,2 @@
import { apiGet, apiPost, apiPut, apiDelete } from './client'; // This file is obsolete — Operator management removed in auth overhaul.
export interface OperatorDto {
id: string;
email: string;
role: string;
createdAt: string;
}
export interface CreateOperatorRequest {
email: string;
password: string;
role: string;
}
export const listOperators = () => apiGet<OperatorDto[]>('/api/operators');
export const createOperator = (req: CreateOperatorRequest) =>
apiPost<OperatorDto>('/api/operators', req);
export const updateOperatorRole = (id: string, role: string) =>
apiPut<OperatorDto>(`/api/operators/${id}/role`, { role });
export const resetOperatorPassword = (id: string, newPassword: string) =>
apiPost<{ message: string }>(`/api/operators/${id}/reset-password`, { newPassword });
export const deleteOperator = (id: string) => apiDelete(`/api/operators/${id}`);

View File

@@ -0,0 +1,299 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import {
previewYaml,
suggestAbbreviation,
manualProvision,
ManualProvisionRequest,
} from '../../api/provision';
import JobProgressPanel from '../jobs/JobProgressPanel';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
interface FormState {
companyName: string;
adminEmail: string;
adminFirstName: string;
adminLastName: string;
abbreviation: string;
plan: string;
screenCount: number;
newtId: string;
newtSecret: string;
nfsServer: string;
nfsExport: string;
nfsExportFolder: string;
nfsExtraOptions: string;
}
const initialForm: FormState = {
companyName: '',
adminEmail: '',
adminFirstName: '',
adminLastName: '',
abbreviation: '',
plan: 'Essentials',
screenCount: 1,
newtId: '',
newtSecret: '',
nfsServer: '',
nfsExport: '',
nfsExportFolder: '',
nfsExtraOptions: '',
};
interface Props {
onClose: () => void;
}
export default function DeployInstancePanel({ onClose }: Props) {
const navigate = useNavigate();
const [form, setForm] = useState<FormState>(initialForm);
const [yaml, setYaml] = useState<string | null>(null);
const [activeJobId, setActiveJobId] = useState<string | null>(null);
const [abbrLoading, setAbbrLoading] = useState(false);
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
const set = <K extends keyof FormState>(field: K, value: FormState[K]) =>
setForm((prev) => ({ ...prev, [field]: value }));
const previewMut = useMutation({
mutationFn: () => previewYaml(form.abbreviation, form.companyName),
onSuccess: (data) => setYaml(data.yaml),
});
const deployMut = useMutation({
mutationFn: () => {
const req: ManualProvisionRequest = {
companyName: form.companyName,
adminEmail: form.adminEmail,
adminFirstName: form.adminFirstName || undefined,
adminLastName: form.adminLastName || undefined,
abbreviation: form.abbreviation || undefined,
plan: form.plan,
screenCount: form.screenCount,
newtId: form.newtId || undefined,
newtSecret: form.newtSecret || undefined,
nfsServer: form.nfsServer || undefined,
nfsExport: form.nfsExport || undefined,
nfsExportFolder: form.nfsExportFolder || undefined,
nfsExtraOptions: form.nfsExtraOptions || undefined,
};
return manualProvision(req);
},
onSuccess: (data) => { setActiveJobId(data.jobId); toast.success('Provisioning started'); },
});
const handleSuggestAbbrev = async () => {
if (!form.companyName.trim()) return;
setAbbrLoading(true);
try {
const result = await suggestAbbreviation(form.companyName);
set('abbreviation', result.abbreviation);
} finally {
setAbbrLoading(false);
}
};
const validate = (): boolean => {
const e: Partial<Record<keyof FormState, string>> = {};
if (!form.companyName.trim()) e.companyName = 'Company name is required';
if (!form.adminEmail.trim()) e.adminEmail = 'Admin email is required';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.adminEmail)) e.adminEmail = 'Invalid email address';
if (form.abbreviation && !/^[a-z]{3}$/.test(form.abbreviation)) e.abbreviation = 'Must be exactly 3 lowercase letters';
setErrors(e);
return Object.keys(e).length === 0;
};
const canDeploy = form.companyName.trim() && form.adminEmail.trim() && !deployMut.isPending;
const canPreview = form.abbreviation.length === 3 && form.companyName.trim();
if (activeJobId) {
return (
<div className="space-y-4">
<h3 className="text-sm font-semibold text-foreground">Provisioning in Progress</h3>
<JobProgressPanel jobId={activeJobId} onClose={() => navigate('/fleet')} />
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => navigate('/fleet')}>Go to Fleet</Button>
<Button
variant="secondary"
size="sm"
onClick={() => {
setActiveJobId(null);
setForm(initialForm);
setYaml(null);
deployMut.reset();
}}
>
Deploy Another
</Button>
<Button variant="ghost" size="sm" onClick={onClose}>Close</Button>
</div>
</div>
);
}
return (
<div className="space-y-5">
{/* Step 1: Account */}
<fieldset className="space-y-3">
<legend className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Account Details</legend>
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<Label htmlFor="deploy-company">Company Name</Label>
<Input
id="deploy-company"
value={form.companyName}
onChange={(e) => set('companyName', e.target.value)}
aria-invalid={!!errors.companyName}
aria-describedby={errors.companyName ? 'deploy-company-err' : undefined}
placeholder="Acme Corporation"
/>
{errors.companyName && <p id="deploy-company-err" className="mt-1 text-xs text-status-danger">{errors.companyName}</p>}
</div>
<div className="col-span-2">
<Label htmlFor="deploy-email">Admin Email</Label>
<Input
id="deploy-email"
type="email"
value={form.adminEmail}
onChange={(e) => set('adminEmail', e.target.value)}
aria-invalid={!!errors.adminEmail}
aria-describedby={errors.adminEmail ? 'deploy-email-err' : undefined}
placeholder="admin@example.com"
/>
{errors.adminEmail && <p id="deploy-email-err" className="mt-1 text-xs text-status-danger">{errors.adminEmail}</p>}
</div>
<div>
<Label htmlFor="deploy-fname">First Name</Label>
<Input id="deploy-fname" value={form.adminFirstName} onChange={(e) => set('adminFirstName', e.target.value)} />
</div>
<div>
<Label htmlFor="deploy-lname">Last Name</Label>
<Input id="deploy-lname" value={form.adminLastName} onChange={(e) => set('adminLastName', e.target.value)} />
</div>
</div>
</fieldset>
{/* Step 2: Plan */}
<fieldset className="space-y-3">
<legend className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Plan</legend>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="deploy-plan">Plan</Label>
<select
id="deploy-plan"
value={form.plan}
onChange={(e) => set('plan', e.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="Essentials">Essentials</option>
<option value="Pro">Pro</option>
</select>
</div>
<div>
<Label htmlFor="deploy-screens">Screens</Label>
<Input
id="deploy-screens"
type="number"
min={1}
value={form.screenCount}
onChange={(e) => set('screenCount', Math.max(1, parseInt(e.target.value) || 1))}
/>
</div>
</div>
</fieldset>
{/* Abbreviation */}
<fieldset className="space-y-3">
<legend className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Abbreviation</legend>
<div className="flex items-end gap-2">
<div>
<Label htmlFor="deploy-abbrev">3 lowercase letters</Label>
<Input
id="deploy-abbrev"
value={form.abbreviation}
onChange={(e) => set('abbreviation', e.target.value.toLowerCase().replace(/[^a-z]/g, '').slice(0, 3))}
maxLength={3}
aria-invalid={!!errors.abbreviation}
aria-describedby={errors.abbreviation ? 'deploy-abbrev-err' : undefined}
className="w-24 font-mono"
placeholder="abc"
/>
</div>
<Button
variant="secondary"
size="sm"
onClick={handleSuggestAbbrev}
disabled={!form.companyName.trim() || abbrLoading}
>
{abbrLoading ? 'Generating...' : 'Auto-generate'}
</Button>
{form.abbreviation.length === 3 && (
<span className="self-center text-xs text-muted-foreground">
Stack: <span className="font-mono text-foreground">{form.abbreviation}-cms-stack</span>
</span>
)}
</div>
{errors.abbreviation && <p id="deploy-abbrev-err" className="mt-1 text-xs text-status-danger">{errors.abbreviation}</p>}
</fieldset>
{/* Advanced Overrides */}
<details className="rounded-xl border border-border">
<summary className="cursor-pointer px-4 py-2.5 text-sm text-muted-foreground hover:text-foreground">
Advanced (Newt, NFS overrides)
</summary>
<div className="grid grid-cols-2 gap-3 border-t border-border p-4">
{(['newtId', 'newtSecret', 'nfsServer', 'nfsExport', 'nfsExportFolder', 'nfsExtraOptions'] as const).map(
(field) => (
<div key={field}>
<Label>{field}</Label>
<Input
value={form[field]}
onChange={(e) => set(field, e.target.value)}
/>
</div>
),
)}
</div>
</details>
{/* Actions */}
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => previewMut.mutate()}
disabled={!canPreview || previewMut.isPending}
>
Preview YAML
</Button>
<Button
size="sm"
onClick={() => { if (validate()) deployMut.mutate(); }}
disabled={!canDeploy}
>
{deployMut.isPending ? 'Provisioning...' : 'Deploy'}
</Button>
</div>
{deployMut.isError && (
<p className="rounded-lg bg-status-danger/10 p-3 text-sm text-status-danger">
{(deployMut.error as Error).message}
</p>
)}
{yaml && (
<div>
<h4 className="mb-1 text-sm font-medium text-muted-foreground">Compose YAML Preview</h4>
<pre className="max-h-64 overflow-auto rounded-xl border border-border bg-card p-3 font-mono text-xs text-muted-foreground">
{yaml}
</pre>
</div>
)}
</div>
);
}

View File

@@ -2,33 +2,35 @@ import { ReactNode } from 'react';
import Sidebar from './Sidebar'; import Sidebar from './Sidebar';
import StatusBanner from '../shared/StatusBanner'; import StatusBanner from '../shared/StatusBanner';
import { useSignalR } from '../../hooks/useSignalR'; import { useSignalR } from '../../hooks/useSignalR';
import { useAuthStore } from '../../store/authStore'; import { Wifi, WifiOff } from 'lucide-react';
import { logout } from '../../api/auth'; import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
export default function AppShell({ children }: { children: ReactNode }) { export default function AppShell({ children }: { children: ReactNode }) {
useSignalR(); const { connectionState } = useSignalR();
const user = useAuthStore((s) => s.user);
const logoutStore = useAuthStore((s) => s.logout);
const handleLogout = async () => {
await logout().catch(() => {});
logoutStore();
};
return ( return (
<div className="flex h-screen"> <div className="flex h-screen bg-background">
<Sidebar /> <Sidebar />
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<header className="flex items-center justify-between border-b border-gray-800 bg-gray-950 px-6 py-3"> <header className="flex h-12 items-center justify-between border-b border-border bg-background px-6">
<StatusBanner /> <StatusBanner />
<div className="flex items-center gap-4"> <div className="flex items-center gap-3">
{user && <span className="text-sm text-gray-400">{user.email}</span>} <Tooltip>
<button <TooltipTrigger className="flex items-center">
onClick={handleLogout} {connectionState === 'Connected' ? (
className="rounded bg-gray-800 px-3 py-1 text-sm text-gray-300 hover:bg-gray-700" <Wifi className="h-4 w-4 text-status-success" />
> ) : (
Logout <WifiOff className={`h-4 w-4 ${connectionState === 'Reconnecting' ? 'animate-pulse text-status-warning' : 'text-status-danger'}`} />
</button> )}
</TooltipTrigger>
<TooltipContent>
{connectionState === 'Connected'
? 'Real-time updates active'
: connectionState === 'Reconnecting'
? 'Reconnecting…'
: 'Disconnected — updates paused'}
</TooltipContent>
</Tooltip>
</div> </div>
</header> </header>
<main className="flex-1 overflow-auto p-6">{children}</main> <main className="flex-1 overflow-auto p-6">{children}</main>

View File

@@ -1,42 +1,127 @@
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import {
LayoutDashboard,
Users,
Layers,
Server,
HeartPulse,
ScrollText,
BarChart2,
Settings,
LogOut,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useAuthStore } from '../../store/authStore';
import { logout } from '../../api/auth';
import { toast } from 'sonner';
const navItems = [ interface NavItem { to: string; label: string; icon: LucideIcon; end?: boolean }
{ to: '/fleet', label: 'Fleet' }, interface NavGroup { heading: string; items: NavItem[] }
{ to: '/customers', label: 'Customers' },
{ to: '/instances', label: 'Instances' }, const navGroups: NavGroup[] = [
{ to: '/instances/new', label: 'Deploy' }, {
{ to: '/hosts', label: 'SSH Hosts' }, heading: 'Overview',
{ to: '/health', label: 'Health' }, items: [
{ to: '/secrets', label: 'Secrets' }, { to: '/fleet', label: 'Fleet', icon: LayoutDashboard },
{ to: '/settings', label: 'Settings' }, { to: '/customers', label: 'Customers', icon: Users },
{ to: '/logs', label: 'Op Logs' }, ],
{ to: '/audit', label: 'Audit Logs' }, },
{ to: '/reports', label: 'Reports' }, {
{ to: '/operators', label: 'Operators' }, heading: 'Infrastructure',
items: [
{ to: '/instances', label: 'Instances', icon: Layers, end: true },
{ to: '/hosts', label: 'Hosts', icon: Server },
],
},
{
heading: 'Monitoring',
items: [
{ to: '/health', label: 'Health', icon: HeartPulse },
{ to: '/logs', label: 'Logs', icon: ScrollText },
{ to: '/reports', label: 'Reports', icon: BarChart2 },
],
},
{
heading: 'System',
items: [{ to: '/settings', label: 'Settings', icon: Settings }],
},
]; ];
export default function Sidebar() { export default function Sidebar() {
const user = useAuthStore((s) => s.user);
const logoutStore = useAuthStore((s) => s.logout);
const handleLogout = async () => {
try {
await logout();
} catch {
toast.error('Logout request failed — session cleared locally');
}
logoutStore();
};
return ( return (
<aside className="w-56 flex-shrink-0 border-r border-gray-800 bg-gray-950 p-4"> <aside className="flex w-64 flex-shrink-0 flex-col border-r border-sidebar-border bg-sidebar">
<h1 className="mb-6 text-lg font-bold text-white">OTS Orchestrator</h1> {/* Logo */}
<nav className="flex flex-col gap-1"> <div className="flex items-center gap-2.5 px-5 py-4">
{navItems.map((item) => ( <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand text-brand-foreground">
<Layers className="h-4 w-4" />
</div>
<div>
<h1 className="text-sm font-bold tracking-tight text-sidebar-foreground">OTS Orchestrator</h1>
</div>
</div>
{/* Navigation */}
<nav aria-label="Main navigation" className="flex flex-1 flex-col gap-5 overflow-y-auto px-3 py-2">
{navGroups.map((group) => (
<div key={group.heading}>
<p className="mb-1.5 px-3 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground/50">
{group.heading}
</p>
<div className="flex flex-col gap-0.5">
{group.items.map((item) => (
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
end={item.to === '/instances'} end={item.end}
className={({ isActive }) => className={({ isActive }) =>
`rounded px-3 py-2 text-sm font-medium transition-colors ${ `flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
isActive isActive
? 'bg-blue-600 text-white' ? 'bg-sidebar-accent text-sidebar-primary'
: 'text-gray-400 hover:bg-gray-800 hover:text-white' : 'text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-foreground'
}` }`
} }
> >
<item.icon className="h-4 w-4 flex-shrink-0" />
{item.label} {item.label}
</NavLink> </NavLink>
))} ))}
</div>
</div>
))}
</nav> </nav>
{/* User footer */}
<div className="border-t border-sidebar-border px-3 py-3">
<div className="flex items-center gap-2 rounded-lg px-3 py-2">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-sidebar-accent text-xs font-medium text-sidebar-foreground">
{user?.email?.charAt(0).toUpperCase() ?? 'O'}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium text-sidebar-foreground">
{user?.email ?? 'Operator'}
</p>
</div>
<button
onClick={handleLogout}
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
title="Sign out"
>
<LogOut className="h-3.5 w-3.5" />
</button>
</div>
</div>
</aside> </aside>
); );
} }

View File

@@ -0,0 +1,38 @@
import { Link } from 'react-router-dom';
import { ChevronRight } from 'lucide-react';
export interface Crumb {
label: string;
to?: string;
}
interface Props {
items: Crumb[];
}
export default function Breadcrumbs({ items }: Props) {
return (
<nav aria-label="Breadcrumb" className="flex items-center gap-1 text-sm">
{items.map((item, i) => {
const isLast = i === items.length - 1;
return (
<span key={i} className="flex items-center gap-1">
{i > 0 && <ChevronRight className="h-3.5 w-3.5 text-muted-foreground/50" />}
{isLast || !item.to ? (
<span className={isLast ? 'font-medium text-foreground' : 'text-muted-foreground'}>
{item.label}
</span>
) : (
<Link
to={item.to}
className="text-muted-foreground transition-colors hover:text-foreground"
>
{item.label}
</Link>
)}
</span>
);
})}
</nav>
);
}

View File

@@ -1,4 +1,16 @@
import { useState, ReactNode } from 'react'; import { useState } from 'react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Input } from '@/components/ui/input';
import type { ReactNode } from 'react';
interface Props { interface Props {
title: string; title: string;
@@ -14,35 +26,37 @@ export default function ConfirmDialog({ title, message, confirmText = 'Confirm',
const canConfirm = !requireTyping || typed === requireTyping; const canConfirm = !requireTyping || typed === requireTyping;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"> <AlertDialog open onOpenChange={(open) => { if (!open) onCancel(); }}>
<div className="w-full max-w-md rounded-lg bg-gray-900 p-6 shadow-xl"> <AlertDialogContent>
<h2 className="mb-2 text-lg font-semibold text-white">{title}</h2> <AlertDialogHeader>
<div className="mb-4 text-sm text-gray-300">{message}</div> <AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription className="text-sm text-muted-foreground">
{message}
</AlertDialogDescription>
</AlertDialogHeader>
{requireTyping && ( {requireTyping && (
<div className="mb-4"> <div className="my-2">
<p className="mb-1 text-xs text-gray-400"> <p className="mb-1.5 text-sm text-muted-foreground">
Type <strong className="text-white">{requireTyping}</strong> to confirm: Type <strong className="text-foreground">{requireTyping}</strong> to confirm:
</p> </p>
<input <Input
value={typed} value={typed}
onChange={(e) => setTyped(e.target.value)} onChange={(e) => setTyped(e.target.value)}
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white" autoFocus
/> />
</div> </div>
)} )}
<div className="flex justify-end gap-2"> <AlertDialogFooter>
<button onClick={onCancel} className="rounded bg-gray-700 px-4 py-2 text-sm text-gray-300 hover:bg-gray-600"> <AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
Cancel <AlertDialogAction
</button>
<button
onClick={onConfirm} onClick={onConfirm}
disabled={!canConfirm} disabled={!canConfirm}
className="rounded bg-red-600 px-4 py-2 text-sm text-white hover:bg-red-500 disabled:opacity-40" className="bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-40"
> >
{confirmText} {confirmText}
</button> </AlertDialogAction>
</div> </AlertDialogFooter>
</div> </AlertDialogContent>
</div> </AlertDialog>
); );
} }

View File

@@ -0,0 +1,30 @@
import { useState } from 'react';
import { Check, Copy } from 'lucide-react';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
interface Props {
value: string;
className?: string;
}
export default function CopyButton({ value, className }: Props) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<Tooltip>
<TooltipTrigger
onClick={handleCopy}
className={`inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${className ?? ''}`}
>
{copied ? <Check className="h-3.5 w-3.5 text-status-success" /> : <Copy className="h-3.5 w-3.5" />}
</TooltipTrigger>
<TooltipContent>{copied ? 'Copied!' : 'Copy'}</TooltipContent>
</Tooltip>
);
}

View File

@@ -0,0 +1,15 @@
interface Props {
title: string;
description?: string;
}
export default function EmptyState({ title, description }: Props) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-12">
<h3 className="text-sm font-medium text-foreground">{title}</h3>
{description && (
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { Component, type ReactNode } from 'react';
import { Button } from '@/components/ui/button';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export default class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error('ErrorBoundary caught:', error, info.componentStack);
}
render() {
if (this.state.hasError) {
return (
<div className="flex h-full items-center justify-center p-12">
<div className="w-full max-w-md rounded-lg border border-destructive/30 bg-card p-8 text-center">
<h2 className="mb-2 text-lg font-semibold text-foreground">Something went wrong</h2>
<p className="mb-6 text-sm text-muted-foreground">
{this.state.error?.message || 'An unexpected error occurred.'}
</p>
<Button
variant="outline"
onClick={() => this.setState({ hasError: false, error: null })}
>
Try again
</Button>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,24 @@
import { Button } from '@/components/ui/button';
interface Props {
error: Error | null;
onRetry?: () => void;
}
export default function PageError({ error, onRetry }: Props) {
return (
<div className="flex items-center justify-center py-16">
<div className="w-full max-w-md rounded-lg border border-destructive/30 bg-card p-8 text-center">
<h2 className="mb-2 text-lg font-semibold text-foreground">Failed to load data</h2>
<p className="mb-6 text-sm text-muted-foreground">
{error?.message || 'An unexpected error occurred.'}
</p>
{onRetry && (
<Button variant="outline" onClick={onRetry}>
Retry
</Button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { Skeleton } from '@/components/ui/skeleton';
export default function PageLoading({ message }: { message?: string }) {
return (
<div className="space-y-4 py-8">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-48" />
</div>
<Skeleton className="h-4 w-72" />
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
{message && (
<p className="text-center text-sm text-muted-foreground">{message}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { cn } from '@/lib/utils';
import type { LucideIcon } from 'lucide-react';
interface Props {
label: string;
value: string | number;
icon?: LucideIcon;
delta?: string;
color?: 'default' | 'success' | 'warning' | 'danger' | 'info';
className?: string;
}
const colorMap = {
default: 'ring-border',
success: 'ring-status-success/30',
warning: 'ring-status-warning/30',
danger: 'ring-status-danger/30',
info: 'ring-status-info/30',
} as const;
const iconColorMap = {
default: 'text-muted-foreground',
success: 'text-status-success',
warning: 'text-status-warning',
danger: 'text-status-danger',
info: 'text-status-info',
} as const;
export default function StatCard({ label, value, icon: Icon, delta, color = 'default', className }: Props) {
return (
<div
className={cn(
'flex items-center gap-3 rounded-xl bg-card px-4 py-3 ring-1',
colorMap[color],
className
)}
>
{Icon && (
<div className={cn('flex h-9 w-9 items-center justify-center rounded-lg bg-muted/50', iconColorMap[color])}>
<Icon className="h-4.5 w-4.5" />
</div>
)}
<div className="min-w-0">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<div className="flex items-baseline gap-2">
<p className="text-2xl font-semibold tabular-nums tracking-tight text-foreground">{value}</p>
{delta && <span className="text-xs text-muted-foreground">{delta}</span>}
</div>
</div>
</div>
);
}

View File

@@ -13,15 +13,20 @@ export default function StatusBanner() {
if (alerts.length === 0) return null; if (alerts.length === 0) return null;
return ( return (
<div className="flex flex-wrap gap-2"> <div role="status" aria-live="polite" className="flex flex-wrap gap-2">
{alerts.slice(0, 3).map((alert) => ( {alerts.slice(0, 3).map((alert) => (
<div <div
key={alert.id} key={alert.id}
role={alert.severity === 'error' ? 'alert' : 'status'}
className={`flex items-center gap-2 rounded px-3 py-1 text-sm ${severityStyles[alert.severity]}`} className={`flex items-center gap-2 rounded px-3 py-1 text-sm ${severityStyles[alert.severity]}`}
> >
<span>{alert.message}</span> <span>{alert.message}</span>
<button onClick={() => dismiss(alert.id)} className="ml-1 opacity-60 hover:opacity-100"> <button
x onClick={() => dismiss(alert.id)}
className="ml-1 opacity-60 hover:opacity-100"
aria-label="Dismiss alert"
>
</button> </button>
</div> </div>
))} ))}

View File

@@ -0,0 +1,187 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: AlertDialogPrimitive.Backdrop.Props) {
return (
<AlertDialogPrimitive.Backdrop
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: AlertDialogPrimitive.Popup.Props & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Popup
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: AlertDialogPrimitive.Close.Props &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<AlertDialogPrimitive.Close
data-slot="alert-dialog-cancel"
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

View File

@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,158 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,23 @@
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,136 @@
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,49 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,80 @@
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@@ -0,0 +1,66 @@
"use client"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -1,11 +1,20 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useState } from 'react';
import { HubConnectionBuilder, HubConnection, LogLevel } from '@microsoft/signalr'; import { HubConnectionBuilder, HubConnection, HubConnectionState, LogLevel } from '@microsoft/signalr';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useAlertStore } from '../store/alertStore'; import { useAlertStore } from '../store/alertStore';
import { useJobProgressStore } from '../store/jobProgressStore'; import { useJobProgressStore } from '../store/jobProgressStore';
export type ConnectionState = 'Connected' | 'Reconnecting' | 'Disconnected';
function mapState(state: HubConnectionState): ConnectionState {
if (state === HubConnectionState.Connected) return 'Connected';
if (state === HubConnectionState.Reconnecting) return 'Reconnecting';
return 'Disconnected';
}
export function useSignalR() { export function useSignalR() {
const connectionRef = useRef<HubConnection | null>(null); const connectionRef = useRef<HubConnection | null>(null);
const [connectionState, setConnectionState] = useState<ConnectionState>('Disconnected');
const addAlert = useAlertStore((s) => s.addAlert); const addAlert = useAlertStore((s) => s.addAlert);
const updateProgress = useJobProgressStore((s) => s.updateProgress); const updateProgress = useJobProgressStore((s) => s.updateProgress);
const clearJob = useJobProgressStore((s) => s.clearJob); const clearJob = useJobProgressStore((s) => s.clearJob);
@@ -14,10 +23,14 @@ export function useSignalR() {
useEffect(() => { useEffect(() => {
const connection = new HubConnectionBuilder() const connection = new HubConnectionBuilder()
.withUrl('/hubs/fleet') .withUrl('/hubs/fleet')
.withAutomaticReconnect() .withAutomaticReconnect([0, 2000, 5000, 10000, 30000])
.configureLogging(LogLevel.Warning) .configureLogging(LogLevel.Warning)
.build(); .build();
connection.onreconnecting(() => setConnectionState('Reconnecting'));
connection.onreconnected(() => setConnectionState('Connected'));
connection.onclose(() => setConnectionState('Disconnected'));
connection.on('SendAlertRaised', (severity: string, message: string) => { connection.on('SendAlertRaised', (severity: string, message: string) => {
addAlert(severity as 'info' | 'warning' | 'error', message); addAlert(severity as 'info' | 'warning' | 'error', message);
}); });
@@ -47,7 +60,9 @@ export function useSignalR() {
qc.invalidateQueries({ queryKey: ['fleet'] }); qc.invalidateQueries({ queryKey: ['fleet'] });
}); });
connection.start().catch((err) => console.error('SignalR connection error:', err)); connection.start()
.then(() => setConnectionState(mapState(connection.state)))
.catch(() => setConnectionState('Disconnected'));
connectionRef.current = connection; connectionRef.current = connection;
return () => { return () => {
@@ -55,5 +70,5 @@ export function useSignalR() {
}; };
}, [addAlert, updateProgress, clearJob, qc]); }, [addAlert, updateProgress, clearJob, qc]);
return connectionRef; return { connectionRef, connectionState };
} }

View File

@@ -1 +1,166 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-sans);
--font-sans: 'Geist Variable', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--color-brand: var(--brand);
--color-brand-foreground: var(--brand-foreground);
--color-surface-1: var(--surface-1);
--color-surface-2: var(--surface-2);
--color-status-success: var(--status-success);
--color-status-success-foreground: var(--status-success-foreground);
--color-status-warning: var(--status-warning);
--color-status-warning-foreground: var(--status-warning-foreground);
--color-status-danger: var(--status-danger);
--color-status-danger-foreground: var(--status-danger-foreground);
--color-status-info: var(--status-info);
--color-status-info-foreground: var(--status-info-foreground);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--brand: oklch(0.488 0.243 264.376);
--brand-foreground: oklch(0.985 0 0);
--surface-1: oklch(0.97 0 0);
--surface-2: oklch(0.93 0 0);
--status-success: oklch(0.723 0.191 149.579);
--status-success-foreground: oklch(0.27 0.07 149);
--status-warning: oklch(0.795 0.184 86.047);
--status-warning-foreground: oklch(0.28 0.07 70);
--status-danger: oklch(0.637 0.237 25.331);
--status-danger-foreground: oklch(0.985 0 0);
--status-info: oklch(0.623 0.214 259.815);
--status-info-foreground: oklch(0.985 0 0);
--chart-1: oklch(0.623 0.214 259.815);
--chart-2: oklch(0.723 0.191 149.579);
--chart-3: oklch(0.795 0.184 86.047);
--chart-4: oklch(0.637 0.237 25.331);
--chart-5: oklch(0.627 0.265 303.9);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.13 0.005 270);
--foreground: oklch(0.985 0 0);
--card: oklch(0.18 0.005 270);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.18 0.005 270);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.24 0.005 270);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.24 0.005 270);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.24 0.005 270);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--brand: oklch(0.588 0.243 264.376);
--brand-foreground: oklch(0.985 0 0);
--surface-1: oklch(0.18 0.005 270);
--surface-2: oklch(0.24 0.005 270);
--status-success: oklch(0.723 0.191 149.579);
--status-success-foreground: oklch(0.985 0 0);
--status-warning: oklch(0.795 0.184 86.047);
--status-warning-foreground: oklch(0.15 0.05 70);
--status-danger: oklch(0.704 0.191 22.216);
--status-danger-foreground: oklch(0.985 0 0);
--status-info: oklch(0.588 0.243 264.376);
--status-info-foreground: oklch(0.985 0 0);
--chart-1: oklch(0.588 0.243 264.376);
--chart-2: oklch(0.723 0.191 149.579);
--chart-3: oklch(0.795 0.184 86.047);
--chart-4: oklch(0.704 0.191 22.216);
--chart-5: oklch(0.627 0.265 303.9);
--sidebar: oklch(0.16 0.005 270);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.588 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.24 0.005 270);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -1,22 +1,33 @@
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider, MutationCache } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import { TooltipProvider } from '@/components/ui/tooltip';
import { Toaster } from '@/components/ui/sonner';
import App from './App'; import App from './App';
import './index.css'; import './index.css';
import { toast } from 'sonner';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { retry: 1, refetchOnWindowFocus: false }, queries: { retry: 1, refetchOnWindowFocus: false },
}, },
mutationCache: new MutationCache({
onError: (error) => {
toast.error(error.message || 'An unexpected error occurred');
},
}),
}); });
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<TooltipProvider>
<BrowserRouter> <BrowserRouter>
<App /> <App />
</BrowserRouter> </BrowserRouter>
<Toaster richColors position="bottom-right" />
</TooltipProvider>
</QueryClientProvider> </QueryClientProvider>
</StrictMode>, </StrictMode>,
); );

View File

@@ -0,0 +1,57 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { redeemAdminToken, getMe } from '../api/auth';
import { useAuthStore } from '../store/authStore';
export default function AdminTokenPage() {
const [token, setToken] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const setUser = useAuthStore((s) => s.setUser);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await redeemAdminToken(token);
const user = await getMe();
setUser(user as Parameters<typeof setUser>[0]);
navigate('/');
} catch {
setError('Invalid or expired admin token');
} finally {
setLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-gray-950">
<form onSubmit={handleSubmit} className="w-full max-w-sm rounded-lg bg-gray-900 p-8 shadow-xl">
<h1 className="mb-6 text-center text-xl font-bold text-white">Admin Token</h1>
{error && <p className="mb-4 rounded bg-red-900/50 p-2 text-center text-sm text-red-300">{error}</p>}
<label className="mb-1 block text-sm text-gray-400">Paste the admin token from the server log</label>
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
required
autoFocus
placeholder="Token"
className="mb-6 w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
/>
<button
type="submit"
disabled={loading}
className="w-full rounded bg-blue-600 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-50"
>
{loading ? 'Verifying...' : 'Authenticate'}
</button>
<p className="mt-4 text-center text-xs text-gray-600">
This token was printed once to the container log on first run.
</p>
</form>
</div>
);
}

View File

@@ -2,6 +2,12 @@ import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { getAuditLogs } from '../api/auditLogs'; import { getAuditLogs } from '../api/auditLogs';
import { format } from 'date-fns'; import { format } from 'date-fns';
import PageLoading from '../components/shared/PageLoading';
import PageError from '../components/shared/PageError';
import EmptyState from '../components/shared/EmptyState';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
const outcomeColors: Record<string, string> = { const outcomeColors: Record<string, string> = {
Success: 'text-green-400', Success: 'text-green-400',
@@ -15,7 +21,7 @@ export default function AuditPage() {
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const pageSize = 50; const pageSize = 50;
const { data, isLoading } = useQuery({ const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: ['audit-logs', actorFilter, actionFilter, page], queryKey: ['audit-logs', actorFilter, actionFilter, page],
queryFn: () => getAuditLogs({ queryFn: () => getAuditLogs({
limit: pageSize, limit: pageSize,
@@ -27,18 +33,23 @@ export default function AuditPage() {
return ( return (
<div> <div>
<h2 className="mb-4 text-xl font-semibold text-white">Audit Logs</h2> <h1 className="mb-4 text-xl font-semibold text-foreground">Audit Logs</h1>
<div className="mb-4 flex gap-3"> <div className="mb-4 flex gap-3">
<input placeholder="Filter by actor..." value={actorFilter} <div>
onChange={(e) => { setActorFilter(e.target.value); setPage(0); }} <Label htmlFor="audit-actor-filter" className="sr-only">Filter by actor</Label>
className="rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white" /> <Input id="audit-actor-filter" placeholder="Filter by actor..." value={actorFilter}
<input placeholder="Filter by action..." value={actionFilter} onChange={(e) => { setActorFilter(e.target.value); setPage(0); }} />
onChange={(e) => { setActionFilter(e.target.value); setPage(0); }} </div>
className="rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white" /> <div>
<Label htmlFor="audit-action-filter" className="sr-only">Filter by action</Label>
<Input id="audit-action-filter" placeholder="Filter by action..." value={actionFilter}
onChange={(e) => { setActionFilter(e.target.value); setPage(0); }} />
</div>
</div> </div>
{isLoading && <p className="text-gray-400">Loading...</p>} {isLoading && <PageLoading />}
{isError && <PageError error={error} onRetry={() => refetch()} />}
<div className="rounded border border-gray-800"> <div className="rounded border border-gray-800">
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm">
@@ -73,12 +84,14 @@ export default function AuditPage() {
</table> </table>
</div> </div>
{!isLoading && !isError && data?.logs.length === 0 && (
<EmptyState title="No audit events" description="No audit log entries match the current filters." />
)}
<div className="mt-3 flex items-center gap-2"> <div className="mt-3 flex items-center gap-2">
<button onClick={() => setPage(Math.max(0, page - 1))} disabled={page === 0} <Button variant="secondary" size="sm" onClick={() => setPage(Math.max(0, page - 1))} disabled={page === 0}>Previous</Button>
className="rounded bg-gray-700 px-3 py-1 text-sm text-gray-300 hover:bg-gray-600 disabled:opacity-40">Previous</button> <span className="px-2 py-1 text-sm text-muted-foreground">Page {page + 1}{data ? ` of ${Math.ceil(data.total / pageSize)}` : ''}</span>
<span className="px-2 py-1 text-sm text-gray-400">Page {page + 1}{data ? ` of ${Math.ceil(data.total / pageSize)}` : ''}</span> <Button variant="secondary" size="sm" onClick={() => setPage(page + 1)} disabled={(data?.logs.length ?? 0) < pageSize}>Next</Button>
<button onClick={() => setPage(page + 1)} disabled={(data?.logs.length ?? 0) < pageSize}
className="rounded bg-gray-700 px-3 py-1 text-sm text-gray-300 hover:bg-gray-600 disabled:opacity-40">Next</button>
</div> </div>
</div> </div>
); );

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { import {
previewYaml, previewYaml,
suggestAbbreviation, suggestAbbreviation,
@@ -8,6 +9,7 @@ import {
ManualProvisionRequest, ManualProvisionRequest,
} from '../api/provision'; } from '../api/provision';
import JobProgressPanel from '../components/jobs/JobProgressPanel'; import JobProgressPanel from '../components/jobs/JobProgressPanel';
import { Button } from '@/components/ui/button';
interface FormState { interface FormState {
companyName: string; companyName: string;
@@ -48,6 +50,8 @@ export default function CreateInstancePage() {
const [activeJobId, setActiveJobId] = useState<string | null>(null); const [activeJobId, setActiveJobId] = useState<string | null>(null);
const [abbrLoading, setAbbrLoading] = useState(false); const [abbrLoading, setAbbrLoading] = useState(false);
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
const set = <K extends keyof FormState>(field: K, value: FormState[K]) => const set = <K extends keyof FormState>(field: K, value: FormState[K]) =>
setForm((prev) => ({ ...prev, [field]: value })); setForm((prev) => ({ ...prev, [field]: value }));
@@ -75,7 +79,7 @@ export default function CreateInstancePage() {
}; };
return manualProvision(req); return manualProvision(req);
}, },
onSuccess: (data) => setActiveJobId(data.jobId), onSuccess: (data) => { setActiveJobId(data.jobId); toast.success('Provisioning started'); },
}); });
const handleSuggestAbbrev = async () => { const handleSuggestAbbrev = async () => {
@@ -89,32 +93,37 @@ export default function CreateInstancePage() {
} }
}; };
const validate = (): boolean => {
const e: Partial<Record<keyof FormState, string>> = {};
if (!form.companyName.trim()) e.companyName = 'Company name is required';
if (!form.adminEmail.trim()) e.adminEmail = 'Admin email is required';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.adminEmail)) e.adminEmail = 'Invalid email address';
if (form.abbreviation && !/^[a-z]{3}$/.test(form.abbreviation)) e.abbreviation = 'Must be exactly 3 lowercase letters';
setErrors(e);
return Object.keys(e).length === 0;
};
const canDeploy = form.companyName.trim() && form.adminEmail.trim() && !deployMut.isPending; const canDeploy = form.companyName.trim() && form.adminEmail.trim() && !deployMut.isPending;
const canPreview = form.abbreviation.length === 3 && form.companyName.trim(); const canPreview = form.abbreviation.length === 3 && form.companyName.trim();
if (activeJobId) { if (activeJobId) {
return ( return (
<div className="max-w-3xl"> <div className="max-w-3xl">
<h2 className="mb-4 text-xl font-semibold text-white">Provisioning Instance</h2> <h1 className="mb-4 text-xl font-semibold text-foreground">Provisioning Instance</h1>
<JobProgressPanel jobId={activeJobId} onClose={() => navigate('/fleet')} /> <JobProgressPanel jobId={activeJobId} onClose={() => navigate('/fleet')} />
<div className="mt-4 flex gap-2"> <div className="mt-4 flex gap-2">
<button <Button variant="secondary" onClick={() => navigate('/fleet')}>Go to Fleet</Button>
onClick={() => navigate('/fleet')} <Button
className="rounded bg-gray-700 px-4 py-2 text-sm text-white hover:bg-gray-600" variant="secondary"
>
Go to Fleet
</button>
<button
onClick={() => { onClick={() => {
setActiveJobId(null); setActiveJobId(null);
setForm(initialForm); setForm(initialForm);
setYaml(null); setYaml(null);
deployMut.reset(); deployMut.reset();
}} }}
className="rounded bg-gray-700 px-4 py-2 text-sm text-white hover:bg-gray-600"
> >
Deploy Another Deploy Another
</button> </Button>
</div> </div>
</div> </div>
); );
@@ -122,7 +131,7 @@ export default function CreateInstancePage() {
return ( return (
<div className="max-w-3xl"> <div className="max-w-3xl">
<h2 className="mb-4 text-xl font-semibold text-white">Deploy New Instance</h2> <h1 className="mb-4 text-xl font-semibold text-foreground">Deploy New Instance</h1>
<div className="space-y-5"> <div className="space-y-5">
{/* Customer Details */} {/* Customer Details */}
@@ -130,35 +139,45 @@ export default function CreateInstancePage() {
<h3 className="mb-2 text-sm font-medium text-gray-400">Customer Details</h3> <h3 className="mb-2 text-sm font-medium text-gray-400">Customer Details</h3>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="col-span-2"> <div className="col-span-2">
<label className="mb-1 block text-xs text-gray-400">Company Name</label> <label htmlFor="cip-company" className="mb-1 block text-xs text-gray-400">Company Name</label>
<input <input
id="cip-company"
value={form.companyName} value={form.companyName}
onChange={(e) => set('companyName', e.target.value)} onChange={(e) => set('companyName', e.target.value)}
aria-invalid={!!errors.companyName}
aria-describedby={errors.companyName ? 'cip-company-err' : undefined}
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white" className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
placeholder="Acme Corporation" placeholder="Acme Corporation"
/> />
{errors.companyName && <p id="cip-company-err" className="mt-1 text-xs text-red-400">{errors.companyName}</p>}
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<label className="mb-1 block text-xs text-gray-400">Admin Email</label> <label htmlFor="cip-email" className="mb-1 block text-xs text-gray-400">Admin Email</label>
<input <input
id="cip-email"
type="email" type="email"
value={form.adminEmail} value={form.adminEmail}
onChange={(e) => set('adminEmail', e.target.value)} onChange={(e) => set('adminEmail', e.target.value)}
aria-invalid={!!errors.adminEmail}
aria-describedby={errors.adminEmail ? 'cip-email-err' : undefined}
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white" className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
placeholder="admin@example.com" placeholder="admin@example.com"
/> />
{errors.adminEmail && <p id="cip-email-err" className="mt-1 text-xs text-red-400">{errors.adminEmail}</p>}
</div> </div>
<div> <div>
<label className="mb-1 block text-xs text-gray-400">Admin First Name</label> <label htmlFor="cip-fname" className="mb-1 block text-xs text-gray-400">Admin First Name</label>
<input <input
id="cip-fname"
value={form.adminFirstName} value={form.adminFirstName}
onChange={(e) => set('adminFirstName', e.target.value)} onChange={(e) => set('adminFirstName', e.target.value)}
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white" className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
/> />
</div> </div>
<div> <div>
<label className="mb-1 block text-xs text-gray-400">Admin Last Name</label> <label htmlFor="cip-lname" className="mb-1 block text-xs text-gray-400">Admin Last Name</label>
<input <input
id="cip-lname"
value={form.adminLastName} value={form.adminLastName}
onChange={(e) => set('adminLastName', e.target.value)} onChange={(e) => set('adminLastName', e.target.value)}
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white" className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
@@ -172,8 +191,9 @@ export default function CreateInstancePage() {
<h3 className="mb-2 text-sm font-medium text-gray-400">Plan &amp; Identity</h3> <h3 className="mb-2 text-sm font-medium text-gray-400">Plan &amp; Identity</h3>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="mb-1 block text-xs text-gray-400">Plan</label> <label htmlFor="cip-plan" className="mb-1 block text-xs text-gray-400">Plan</label>
<select <select
id="cip-plan"
value={form.plan} value={form.plan}
onChange={(e) => set('plan', e.target.value)} onChange={(e) => set('plan', e.target.value)}
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white" className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
@@ -183,8 +203,9 @@ export default function CreateInstancePage() {
</select> </select>
</div> </div>
<div> <div>
<label className="mb-1 block text-xs text-gray-400">Screen Count</label> <label htmlFor="cip-screens" className="mb-1 block text-xs text-gray-400">Screen Count</label>
<input <input
id="cip-screens"
type="number" type="number"
min={1} min={1}
value={form.screenCount} value={form.screenCount}
@@ -193,14 +214,17 @@ export default function CreateInstancePage() {
/> />
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<label className="mb-1 block text-xs text-gray-400"> <label htmlFor="cip-abbrev" className="mb-1 block text-xs text-gray-400">
Abbreviation (3 lowercase letters) Abbreviation (3 lowercase letters)
</label> </label>
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
id="cip-abbrev"
value={form.abbreviation} value={form.abbreviation}
onChange={(e) => set('abbreviation', e.target.value.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 3))} onChange={(e) => set('abbreviation', e.target.value.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 3))}
maxLength={3} maxLength={3}
aria-invalid={!!errors.abbreviation}
aria-describedby={errors.abbreviation ? 'cip-abbrev-err' : undefined}
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white" className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
placeholder="abc" placeholder="abc"
/> />
@@ -217,6 +241,7 @@ export default function CreateInstancePage() {
Stack: <span className="text-gray-300">{form.abbreviation}-cms-stack</span> Stack: <span className="text-gray-300">{form.abbreviation}-cms-stack</span>
</p> </p>
)} )}
{errors.abbreviation && <p id="cip-abbrev-err" className="mt-1 text-xs text-red-400">{errors.abbreviation}</p>}
</div> </div>
</div> </div>
</section> </section>
@@ -244,20 +269,19 @@ export default function CreateInstancePage() {
{/* Actions */} {/* Actions */}
<div className="flex gap-2"> <div className="flex gap-2">
<button <Button
variant="secondary"
onClick={() => previewMut.mutate()} onClick={() => previewMut.mutate()}
disabled={!canPreview || previewMut.isPending} disabled={!canPreview || previewMut.isPending}
className="rounded bg-gray-700 px-4 py-2 text-sm text-white hover:bg-gray-600 disabled:opacity-40"
> >
Preview YAML Preview YAML
</button> </Button>
<button <Button
onClick={() => deployMut.mutate()} onClick={() => { if (validate()) deployMut.mutate(); }}
disabled={!canDeploy} disabled={!canDeploy}
className="rounded bg-green-700 px-4 py-2 text-sm text-white hover:bg-green-600 disabled:opacity-40"
> >
{deployMut.isPending ? 'Provisioning...' : 'Deploy'} {deployMut.isPending ? 'Provisioning...' : 'Deploy'}
</button> </Button>
</div> </div>
{deployMut.isError && ( {deployMut.isError && (

View File

@@ -1,27 +1,54 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate, useLocation } from 'react-router-dom';
import { toast } from 'sonner';
import { formatDistanceToNow } from 'date-fns';
import { getCustomerDetail, FleetCustomerDetail } from '../api/fleet'; import { getCustomerDetail, FleetCustomerDetail } from '../api/fleet';
import { createJob, getJob, JobDetailDto } from '../api/jobs'; import { createJob } from '../api/jobs';
import { useJobProgressStore } from '../store/jobProgressStore';
import ConfirmDialog from '../components/shared/ConfirmDialog'; import ConfirmDialog from '../components/shared/ConfirmDialog';
import JobProgressPanel from '../components/jobs/JobProgressPanel'; import JobProgressPanel from '../components/jobs/JobProgressPanel';
import PageLoading from '../components/shared/PageLoading';
import PageError from '../components/shared/PageError';
import Breadcrumbs from '../components/shared/Breadcrumbs';
import CopyButton from '../components/shared/CopyButton';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
import { ExternalLink, ChevronDown, ChevronRight } from 'lucide-react';
type Tab = 'instances' | 'jobs'; type Tab = 'instances' | 'jobs';
const statusColors: Record<string, string> = { const statusDot: Record<string, string> = {
Active: 'text-green-400', Active: 'bg-status-success',
Provisioning: 'text-blue-400', Provisioning: 'bg-status-info',
PendingPayment: 'text-yellow-400', PendingPayment: 'bg-status-warning',
Suspended: 'text-orange-400', Suspended: 'bg-status-warning',
Decommissioned: 'text-red-400', Decommissioned: 'bg-status-danger',
}; };
const healthColors: Record<string, string> = { const statusText: Record<string, string> = {
Healthy: 'bg-green-500/20 text-green-400', Active: 'text-status-success',
Degraded: 'bg-yellow-500/20 text-yellow-400', Provisioning: 'text-status-info',
Critical: 'bg-red-500/20 text-red-400', PendingPayment: 'text-status-warning',
Unknown: 'bg-gray-500/20 text-gray-400', Suspended: 'text-status-warning',
Decommissioned: 'text-status-danger',
};
const healthDot: Record<string, string> = {
Healthy: 'bg-status-success',
Degraded: 'bg-status-warning',
Critical: 'bg-status-danger',
Unknown: 'bg-muted-foreground',
};
const healthText: Record<string, string> = {
Healthy: 'text-status-success',
Degraded: 'text-status-warning',
Critical: 'text-status-danger',
Unknown: 'text-muted-foreground',
}; };
const jobTypes: { type: string; label: string; guard?: (c: FleetCustomerDetail) => boolean; danger?: boolean; paramInput?: string }[] = [ const jobTypes: { type: string; label: string; guard?: (c: FleetCustomerDetail) => boolean; danger?: boolean; paramInput?: string }[] = [
@@ -33,16 +60,34 @@ const jobTypes: { type: string; label: string; guard?: (c: FleetCustomerDetail)
{ type: 'decommission', label: 'Decommission', danger: true }, { type: 'decommission', label: 'Decommission', danger: true },
]; ];
const jobStatusDot: Record<string, string> = {
Queued: 'bg-muted-foreground',
Running: 'bg-status-info',
Completed: 'bg-status-success',
Failed: 'bg-status-danger',
};
const jobStatusText: Record<string, string> = {
Queued: 'text-muted-foreground',
Running: 'text-status-info',
Completed: 'text-status-success',
Failed: 'text-status-danger',
};
export default function CustomerDetailPage() { export default function CustomerDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const qc = useQueryClient(); const qc = useQueryClient();
const [tab, setTab] = useState<Tab>('instances'); const [tab, setTab] = useState<Tab>('instances');
const [confirm, setConfirm] = useState<{ type: string; label: string; paramInput?: string } | null>(null); const [confirm, setConfirm] = useState<{ type: string; label: string; paramInput?: string } | null>(null);
const [screenInput, setScreenInput] = useState(''); const [screenInput, setScreenInput] = useState('');
const [activeJobId, setActiveJobId] = useState<string | null>(null); const [activeJobId, setActiveJobId] = useState<string | null>(null);
const { data: customer, isLoading } = useQuery({ const cameFromCustomers = location.pathname.startsWith('/customers/');
const backPath = cameFromCustomers ? '/customers' : '/fleet';
const backLabel = cameFromCustomers ? 'Customers' : 'Fleet';
const { data: customer, isLoading, isError, error, refetch } = useQuery({
queryKey: ['fleet-detail', id], queryKey: ['fleet-detail', id],
queryFn: () => getCustomerDetail(id!), queryFn: () => getCustomerDetail(id!),
enabled: !!id, enabled: !!id,
@@ -56,6 +101,7 @@ export default function CustomerDetailPage() {
qc.invalidateQueries({ queryKey: ['fleet-detail', id] }); qc.invalidateQueries({ queryKey: ['fleet-detail', id] });
qc.invalidateQueries({ queryKey: ['fleet'] }); qc.invalidateQueries({ queryKey: ['fleet'] });
setActiveJobId(data.id); setActiveJobId(data.id);
toast.success('Job created');
}, },
}); });
@@ -68,39 +114,58 @@ export default function CustomerDetailPage() {
setScreenInput(''); setScreenInput('');
} }
if (isLoading) return <p className="text-gray-400">Loading customer...</p>; if (isLoading) return <PageLoading message="Loading customer..." />;
if (!customer) return <p className="text-gray-400">Customer not found</p>; if (isError) return <PageError error={error} onRetry={() => refetch()} />;
if (!customer) return <PageError error={new Error('Customer not found')} onRetry={() => refetch()} />;
return ( return (
<div> <div className="space-y-6">
{/* Back button */} {/* Breadcrumbs + title */}
<button onClick={() => navigate('/fleet')} className="mb-4 text-sm text-blue-400 hover:text-blue-300"> <div className="space-y-1">
Back to Fleet <Breadcrumbs items={[{ label: backLabel, to: backPath }, { label: customer.companyName }]} />
</button> <div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight text-foreground">{customer.companyName}</h1>
{/* Header */} <span className="font-mono text-sm text-muted-foreground">{customer.abbreviation}</span>
<div className="mb-6 rounded border border-gray-800 bg-gray-900 p-4"> <span className="inline-flex items-center gap-1.5">
<div className="flex items-start justify-between"> <span className={`inline-block h-2 w-2 rounded-full ${statusDot[customer.status] ?? 'bg-muted-foreground'}`} />
<div> <span className={`text-sm font-medium ${statusText[customer.status] ?? 'text-muted-foreground'}`}>
<h2 className="text-xl font-semibold text-white"> {customer.status}
{customer.companyName} </span>
<span className="ml-2 font-mono text-sm text-gray-400">{customer.abbreviation}</span> </span>
</h2>
<div className="mt-1 flex gap-4 text-sm text-gray-400">
<span>{customer.adminEmail}</span>
<span>{customer.plan} · {customer.screenCount} screens</span>
<span className={statusColors[customer.status] ?? 'text-gray-400'}>{customer.status}</span>
</div> </div>
</div> </div>
{/* Summary card */}
<div className="grid grid-cols-4 gap-4">
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs font-medium text-muted-foreground">Plan</p>
<p className="mt-1 text-lg font-semibold text-foreground">{customer.plan}</p>
<p className="text-xs text-muted-foreground">{customer.screenCount} screens</p>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs font-medium text-muted-foreground">Admin</p>
<p className="mt-1 text-sm font-medium text-foreground">{customer.adminEmail}</p>
<CopyButton value={customer.adminEmail} />
</div>
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs font-medium text-muted-foreground">Instances</p>
<p className="mt-1 text-lg font-semibold text-foreground">{customer.instances.length}</p>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs font-medium text-muted-foreground">Active Jobs</p>
<p className="mt-1 text-lg font-semibold text-foreground">{customer.activeJobs.length}</p>
</div>
</div> </div>
{/* Actions */} {/* Actions */}
<div className="mt-4 flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{jobTypes.map((jt) => { {jobTypes.map((jt) => {
const allowed = !jt.guard || jt.guard(customer); const allowed = !jt.guard || jt.guard(customer);
return ( return (
<button <Button
key={jt.type} key={jt.type}
variant={jt.danger ? 'destructive' : 'secondary'}
size="sm"
disabled={!allowed || jobMut.isPending} disabled={!allowed || jobMut.isPending}
onClick={() => { onClick={() => {
if (jt.danger || jt.paramInput) { if (jt.danger || jt.paramInput) {
@@ -109,39 +174,31 @@ export default function CustomerDetailPage() {
handleAction(jt.type); handleAction(jt.type);
} }
}} }}
className={`rounded px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-30 ${
jt.danger
? 'bg-red-600 text-white hover:bg-red-500'
: 'bg-gray-700 text-gray-200 hover:bg-gray-600'
}`}
> >
{jt.label} {jt.label}
</button> </Button>
); );
})} })}
</div> </div>
</div>
{/* Active job progress */} {/* Active job progress */}
{activeJobId && ( {activeJobId && (
<div className="mb-6">
<JobProgressPanel jobId={activeJobId} onClose={() => setActiveJobId(null)} /> <JobProgressPanel jobId={activeJobId} onClose={() => setActiveJobId(null)} />
</div>
)} )}
{/* Tabs */} {/* Tabs */}
<div className="mb-4 flex gap-1 border-b border-gray-800"> <div className="flex gap-1 border-b border-border">
{(['instances', 'jobs'] as Tab[]).map((t) => ( {(['instances', 'jobs'] as Tab[]).map((t) => (
<button <button
key={t} key={t}
onClick={() => setTab(t)} onClick={() => setTab(t)}
className={`border-b-2 px-4 py-2 text-sm font-medium transition-colors ${ className={`border-b-2 px-4 py-2 text-sm font-medium transition-colors ${
tab === t tab === t
? 'border-blue-500 text-white' ? 'border-brand text-foreground'
: 'border-transparent text-gray-400 hover:text-gray-200' : 'border-transparent text-muted-foreground hover:text-foreground'
}`} }`}
> >
{t === 'instances' ? 'Instances' : 'Jobs'} {t === 'instances' ? `Instances (${customer.instances.length})` : `Jobs (${customer.activeJobs.length})`}
</button> </button>
))} ))}
</div> </div>
@@ -162,24 +219,22 @@ export default function CustomerDetailPage() {
)} )}
{confirm?.paramInput === 'screenCount' && ( {confirm?.paramInput === 'screenCount' && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div className="w-full max-w-sm rounded-lg bg-gray-900 p-6 shadow-xl"> <div className="w-full max-w-sm rounded-xl border border-border bg-card p-6 shadow-xl">
<h3 className="mb-2 text-lg font-semibold text-white">Update Screen Limit</h3> <h3 className="mb-2 text-lg font-semibold text-foreground">Update Screen Limit</h3>
<p className="mb-3 text-sm text-gray-400">Enter the new screen count for {customer.abbreviation}:</p> <p className="mb-3 text-sm text-muted-foreground">Enter the new screen count for {customer.abbreviation}:</p>
<input <Input
type="number" type="number"
min={1} min={1}
value={screenInput} value={screenInput}
onChange={(e) => setScreenInput(e.target.value)} onChange={(e) => setScreenInput(e.target.value)}
className="mb-4 w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white" className="mb-4"
/> />
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button onClick={() => { setConfirm(null); setScreenInput(''); }} <Button variant="secondary" onClick={() => { setConfirm(null); setScreenInput(''); }}>Cancel</Button>
className="rounded bg-gray-700 px-4 py-2 text-sm text-gray-300 hover:bg-gray-600">Cancel</button> <Button
<button
onClick={() => handleAction('update-screen-limit')} onClick={() => handleAction('update-screen-limit')}
disabled={!screenInput || parseInt(screenInput, 10) < 1} disabled={!screenInput || parseInt(screenInput, 10) < 1}
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-500 disabled:opacity-40" >Update</Button>
>Update</button>
</div> </div>
</div> </div>
</div> </div>
@@ -190,45 +245,49 @@ export default function CustomerDetailPage() {
function InstancesTab({ customer }: { customer: FleetCustomerDetail }) { function InstancesTab({ customer }: { customer: FleetCustomerDetail }) {
if (customer.instances.length === 0) if (customer.instances.length === 0)
return <p className="text-sm text-gray-500">No instances deployed yet.</p>; return <p className="text-sm text-muted-foreground">No instances deployed yet.</p>;
return ( return (
<div className="rounded border border-gray-800"> <div className="rounded-xl border border-border bg-card">
<table className="w-full text-left text-sm"> <Table>
<thead> <TableHeader>
<tr className="border-b border-gray-800 text-xs text-gray-400"> <TableRow>
<th className="px-3 py-2">Stack</th> <TableHead>Stack</TableHead>
<th className="px-3 py-2">URL</th> <TableHead>URL</TableHead>
<th className="px-3 py-2">Health</th> <TableHead>Health</TableHead>
<th className="px-3 py-2">Last Check</th> <TableHead>Last Check</TableHead>
</tr> </TableRow>
</thead> </TableHeader>
<tbody> <TableBody>
{customer.instances.map((inst) => { {customer.instances.map((inst) => (
const colorCls = healthColors[inst.healthStatus] ?? healthColors.Unknown; <TableRow key={inst.id}>
return ( <TableCell className="font-mono text-sm">{inst.dockerStackName}</TableCell>
<tr key={inst.id} className="border-b border-gray-800/50"> <TableCell>
<td className="px-3 py-2 font-mono text-white">{inst.dockerStackName}</td>
<td className="px-3 py-2">
{inst.xiboUrl ? ( {inst.xiboUrl ? (
<a href={inst.xiboUrl} target="_blank" rel="noopener noreferrer" className="text-blue-400 hover:underline"> <a href={inst.xiboUrl} target="_blank" rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-brand hover:underline">
{inst.xiboUrl} {inst.xiboUrl}
<ExternalLink className="h-3 w-3" />
</a> </a>
) : <span className="text-gray-500"></span>} ) : <span className="text-muted-foreground"></span>}
</td> </TableCell>
<td className="px-3 py-2"> <TableCell>
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${colorCls}`}> <span className="inline-flex items-center gap-1.5">
<span className={`inline-block h-2 w-2 rounded-full ${healthDot[inst.healthStatus] ?? healthDot.Unknown}`} />
<span className={`text-xs font-medium ${healthText[inst.healthStatus] ?? healthText.Unknown}`}>
{inst.healthStatus} {inst.healthStatus}
</span> </span>
</td> </span>
<td className="whitespace-nowrap px-3 py-2 text-xs text-gray-400"> </TableCell>
{inst.lastHealthCheck ? new Date(inst.lastHealthCheck).toLocaleString() : '—'} <TableCell className="text-xs text-muted-foreground">
</td> {inst.lastHealthCheck
</tr> ? formatDistanceToNow(new Date(inst.lastHealthCheck), { addSuffix: true })
); : '—'}
})} </TableCell>
</tbody> </TableRow>
</table> ))}
</TableBody>
</Table>
</div> </div>
); );
} }
@@ -236,49 +295,40 @@ function InstancesTab({ customer }: { customer: FleetCustomerDetail }) {
function JobsTab({ customerId, activeJobs }: { customerId: string; activeJobs: FleetCustomerDetail['activeJobs'] }) { function JobsTab({ customerId, activeJobs }: { customerId: string; activeJobs: FleetCustomerDetail['activeJobs'] }) {
const [expandedJobId, setExpandedJobId] = useState<string | null>(null); const [expandedJobId, setExpandedJobId] = useState<string | null>(null);
return ( if (activeJobs.length === 0)
<div> return <p className="text-sm text-muted-foreground">No active or recent jobs.</p>;
{activeJobs.length === 0 && (
<p className="text-sm text-gray-500">No active or recent jobs.</p>
)}
return (
<div className="space-y-2"> <div className="space-y-2">
{activeJobs.map((job) => ( {activeJobs.map((job) => (
<div key={job.id} className="rounded border border-gray-800 bg-gray-900"> <div key={job.id} className="rounded-xl border border-border bg-card">
<button <button
onClick={() => setExpandedJobId(expandedJobId === job.id ? null : job.id)} onClick={() => setExpandedJobId(expandedJobId === job.id ? null : job.id)}
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm" className="flex w-full items-center justify-between px-4 py-3 text-left text-sm"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="font-medium text-white">{job.jobType}</span> {expandedJobId === job.id
<JobStatusBadge status={job.status} /> ? <ChevronDown className="h-4 w-4 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 text-muted-foreground" />}
<span className="font-medium text-foreground">{job.jobType}</span>
<span className="inline-flex items-center gap-1.5">
<span className={`inline-block h-2 w-2 rounded-full ${jobStatusDot[job.status] ?? 'bg-muted-foreground'}`} />
<span className={`text-xs font-medium ${jobStatusText[job.status] ?? 'text-muted-foreground'}`}>
{job.status}
</span>
</span>
</div> </div>
<span className="text-xs text-gray-500"> <span className="text-xs text-muted-foreground">
{new Date(job.createdAt).toLocaleString()} {formatDistanceToNow(new Date(job.createdAt), { addSuffix: true })}
</span> </span>
</button> </button>
{expandedJobId === job.id && ( {expandedJobId === job.id && (
<div className="border-t border-gray-800 px-4 py-3"> <div className="border-t border-border px-4 py-3">
<JobProgressPanel jobId={job.id} /> <JobProgressPanel jobId={job.id} />
</div> </div>
)} )}
</div> </div>
))} ))}
</div> </div>
</div>
);
}
function JobStatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
Queued: 'bg-gray-500/20 text-gray-400',
Running: 'bg-blue-500/20 text-blue-400',
Completed: 'bg-green-500/20 text-green-400',
Failed: 'bg-red-500/20 text-red-400',
};
return (
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${colors[status] ?? colors.Queued}`}>
{status}
</span>
); );
} }

View File

@@ -1,258 +1,164 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { listCustomers, getCustomerAdmin, getCustomerStripeEvents, CustomerListItem } from '../api/customers'; import { listCustomers } from '../api/customers';
import { format } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { Search } from 'lucide-react';
import PageLoading from '../components/shared/PageLoading';
import PageError from '../components/shared/PageError';
import EmptyState from '../components/shared/EmptyState';
import Breadcrumbs from '../components/shared/Breadcrumbs';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
const statusColors: Record<string, string> = { const statusDot: Record<string, string> = {
Active: 'bg-green-500/20 text-green-400', Active: 'bg-status-success',
Provisioning: 'bg-blue-500/20 text-blue-400', Provisioning: 'bg-status-info',
PendingPayment: 'bg-yellow-500/20 text-yellow-400', PendingPayment: 'bg-status-warning',
Suspended: 'bg-orange-500/20 text-orange-400', Suspended: 'bg-status-warning',
Decommissioned: 'bg-red-500/20 text-red-400', Decommissioned: 'bg-status-danger',
}; };
const statusText: Record<string, string> = {
Active: 'text-status-success',
Provisioning: 'text-status-info',
PendingPayment: 'text-status-warning',
Suspended: 'text-status-warning',
Decommissioned: 'text-status-danger',
};
const statuses = ['Active', 'Provisioning', 'PendingPayment', 'Suspended', 'Decommissioned'] as const;
export default function CustomersPage() { export default function CustomersPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [statusFilter, setStatusFilter] = useState(''); const [statusFilter, setStatusFilter] = useState('');
const [selected, setSelected] = useState<string | null>(null); const [search, setSearch] = useState('');
const [detailTab, setDetailTab] = useState<'info' | 'byoi' | 'snapshots' | 'stripe'>('info');
const { data: customers, isLoading } = useQuery({ const { data: customers, isLoading, isError, error, refetch } = useQuery({
queryKey: ['customers-admin', statusFilter], queryKey: ['customers-admin', statusFilter],
queryFn: () => listCustomers(statusFilter || undefined), queryFn: () => listCustomers(statusFilter || undefined),
}); });
const { data: detail } = useQuery({
queryKey: ['customer-admin-detail', selected],
queryFn: () => getCustomerAdmin(selected!),
enabled: !!selected,
});
const { data: stripeEvents } = useQuery({
queryKey: ['customer-stripe-events', selected],
queryFn: () => getCustomerStripeEvents(selected!),
enabled: !!selected && detailTab === 'stripe',
});
// Count by status
const counts = customers?.reduce<Record<string, number>>((acc, c) => { const counts = customers?.reduce<Record<string, number>>((acc, c) => {
acc[c.status] = (acc[c.status] ?? 0) + 1; acc[c.status] = (acc[c.status] ?? 0) + 1;
return acc; return acc;
}, {}) ?? {}; }, {}) ?? {};
const filtered = customers?.filter(
(c) =>
c.companyName.toLowerCase().includes(search.toLowerCase()) ||
c.abbreviation.toLowerCase().includes(search.toLowerCase()),
);
return ( return (
<div> <div className="space-y-6">
<h2 className="mb-4 text-xl font-semibold text-white">Customer Management</h2> {/* Header */}
{/* Status summary */}
<div className="mb-6 flex gap-3">
{['Active', 'Provisioning', 'PendingPayment', 'Suspended', 'Decommissioned'].map((s) => (
<button key={s} onClick={() => { setStatusFilter(statusFilter === s ? '' : s); setSelected(null); }}
className={`rounded-lg border px-3 py-2 text-sm ${statusFilter === s ? 'border-blue-500 bg-blue-900/20' : 'border-gray-800'} ${statusColors[s]}`}>
<span className="font-bold">{counts[s] ?? 0}</span> {s}
</button>
))}
</div>
<div className="flex gap-4">
{/* Customer list */}
<div className="w-1/2">
{isLoading && <p className="text-gray-400">Loading...</p>}
<div className="rounded border border-gray-800">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-800 text-xs text-gray-400">
<th className="px-3 py-2">Company</th>
<th className="px-3 py-2">Abbrev</th>
<th className="px-3 py-2">Plan</th>
<th className="px-3 py-2">Screens</th>
<th className="px-3 py-2">Status</th>
</tr>
</thead>
<tbody>
{customers?.map((c) => (
<tr key={c.id}
onClick={() => { setSelected(c.id); setDetailTab('info'); }}
className={`cursor-pointer border-b border-gray-800/50 hover:bg-gray-800/50 ${selected === c.id ? 'bg-gray-800/70' : ''}`}>
<td className="px-3 py-2 text-sm text-white">{c.companyName}</td>
<td className="px-3 py-2 font-mono text-xs text-gray-300">{c.abbreviation}</td>
<td className="px-3 py-2 text-xs text-gray-300">{c.plan}</td>
<td className="px-3 py-2 text-xs text-gray-300">{c.screenCount}</td>
<td className="px-3 py-2">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusColors[c.status] ?? 'text-gray-400'}`}>
{c.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Detail panel */}
<div className="w-1/2">
{selected && detail ? (
<div className="rounded border border-gray-800 bg-gray-900/50 p-4">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-white">{detail.companyName}</h3>
<button onClick={() => navigate(`/fleet/${detail.id}`)}
className="rounded bg-gray-700 px-3 py-1 text-xs text-gray-300 hover:bg-gray-600">
Open in Fleet
</button>
</div>
{/* Detail tabs */}
<div className="mb-4 flex gap-1 rounded bg-gray-900 p-1">
{(['info', 'byoi', 'snapshots', 'stripe'] as const).map((t) => (
<button key={t} onClick={() => setDetailTab(t)}
className={`rounded px-3 py-1 text-xs font-medium ${detailTab === t ? 'bg-gray-700 text-white' : 'text-gray-400 hover:text-white'}`}>
{t === 'info' ? 'Details' : t === 'byoi' ? 'BYOI Certs' : t === 'snapshots' ? 'Screen Trends' : 'Stripe Events'}
</button>
))}
</div>
{detailTab === 'info' && (
<div className="space-y-2 text-sm">
<InfoRow label="Admin" value={`${detail.adminFirstName} ${detail.adminLastName} <${detail.adminEmail}>`} />
<InfoRow label="Plan" value={`${detail.plan} (${detail.screenCount} screens)`} />
<InfoRow label="Status" value={detail.status} />
<InfoRow label="Abbreviation" value={detail.abbreviation} />
<InfoRow label="Created" value={format(new Date(detail.createdAt), 'yyyy-MM-dd HH:mm')} />
{detail.stripeCustomerId && <InfoRow label="Stripe Customer" value={detail.stripeCustomerId} />}
{detail.stripeSubscriptionId && <InfoRow label="Stripe Subscription" value={detail.stripeSubscriptionId} />}
{detail.failedPaymentCount > 0 && (
<div className="rounded border border-red-900/50 bg-red-900/20 p-2 text-xs text-red-400">
{detail.failedPaymentCount} failed payment(s)
{detail.firstPaymentFailedAt && <> since {format(new Date(detail.firstPaymentFailedAt), 'yyyy-MM-dd')}</>}
</div>
)}
<h4 className="mt-3 text-xs font-semibold text-gray-400">Instances ({detail.instances.length})</h4>
{detail.instances.map((inst) => (
<div key={inst.id} className="flex items-center justify-between rounded bg-gray-900 px-2 py-1 text-xs">
<span className="font-mono text-gray-300">{inst.stackName}</span>
{inst.xiboUrl && <a href={inst.xiboUrl} target="_blank" rel="noopener noreferrer"
className="text-blue-400 hover:text-blue-300">{inst.xiboUrl}</a>}
</div>
))}
<h4 className="mt-3 text-xs font-semibold text-gray-400">Recent Jobs ({detail.jobs.length})</h4>
{detail.jobs.map((j) => (
<div key={j.id} className="flex items-center justify-between rounded bg-gray-900 px-2 py-1 text-xs">
<span className="text-gray-300">{j.jobType}</span>
<span className={j.status === 'Completed' ? 'text-green-400' : j.status === 'Failed' ? 'text-red-400' : 'text-yellow-400'}>
{j.status}
</span>
<span className="text-gray-500">{format(new Date(j.createdAt), 'MM/dd HH:mm')}</span>
</div>
))}
</div>
)}
{detailTab === 'byoi' && (
<div>
{detail.byoiConfigs.length === 0 ? (
<p className="text-sm text-gray-500">No BYOI configurations.</p>
) : (
<div className="space-y-2">
{detail.byoiConfigs.map((b) => {
const daysLeft = Math.ceil((new Date(b.certExpiry).getTime() - Date.now()) / 86400000);
const urgency = daysLeft <= 7 ? 'text-red-400' : daysLeft <= 30 ? 'text-yellow-400' : 'text-green-400';
return (
<div key={b.id} className="rounded border border-gray-800 bg-gray-900 p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="font-mono text-sm text-white">{b.slug}</span> <div className="space-y-1">
<span className={`text-xs ${b.enabled ? 'text-green-400' : 'text-gray-500'}`}> <Breadcrumbs items={[{ label: 'Customers' }]} />
{b.enabled ? 'Enabled' : 'Disabled'} <h1 className="text-2xl font-semibold tracking-tight text-foreground">Customer Management</h1>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Label htmlFor="customer-search" className="sr-only">Search customers</Label>
<Input
id="customer-search"
placeholder="Search by name or abbreviation..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-72 pl-8"
/>
</div>
</div>
{/* Status filter tabs */}
<div className="flex items-center gap-1.5 rounded-lg bg-muted/50 p-1">
<button
onClick={() => setStatusFilter('')}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
!statusFilter ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
}`}
>
All <span className="ml-1 tabular-nums text-muted-foreground">({customers?.length ?? 0})</span>
</button>
{statuses.map((s) => (
<button
key={s}
onClick={() => setStatusFilter(statusFilter === s ? '' : s)}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
statusFilter === s ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
}`}
>
{s === 'PendingPayment' ? 'Pending' : s}
<span className="ml-1 tabular-nums text-muted-foreground">({counts[s] ?? 0})</span>
</button>
))}
</div>
{isLoading && <PageLoading />}
{isError && <PageError error={error} onRetry={() => refetch()} />}
{!isLoading && !isError && filtered?.length === 0 && (
<EmptyState title="No customers" description="No customers match the selected filters." />
)}
{!isLoading && !isError && filtered && filtered.length > 0 && (
<div className="rounded-xl border border-border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Company</TableHead>
<TableHead className="w-20">Abbrev</TableHead>
<TableHead>Plan</TableHead>
<TableHead>Screens</TableHead>
<TableHead>Instances</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((c) => (
<TableRow
key={c.id}
onClick={() => navigate(`/customers/${c.id}`)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigate(`/customers/${c.id}`); } }}
tabIndex={0}
className="cursor-pointer"
>
<TableCell>
<div>
<span className="font-medium">{c.companyName}</span>
{c.failedPaymentCount > 0 && (
<Badge variant="destructive" className="ml-2">{c.failedPaymentCount} failed</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{c.adminEmail}</p>
</TableCell>
<TableCell className="font-mono text-xs">{c.abbreviation}</TableCell>
<TableCell className="text-muted-foreground">{c.plan}</TableCell>
<TableCell className="tabular-nums text-muted-foreground">{c.screenCount}</TableCell>
<TableCell className="tabular-nums text-muted-foreground">{c.instanceCount}</TableCell>
<TableCell>
<span className="inline-flex items-center gap-1.5">
<span className={`inline-block h-2 w-2 rounded-full ${statusDot[c.status] ?? 'bg-muted-foreground'}`} />
<span className={`text-xs font-medium ${statusText[c.status] ?? 'text-muted-foreground'}`}>
{c.status === 'PendingPayment' ? 'Pending' : c.status}
</span> </span>
</div> </span>
<div className="mt-1 text-xs text-gray-400">Entity: {b.entityId}</div> </TableCell>
<div className={`mt-1 text-xs font-medium ${urgency}`}> <TableCell className="text-xs text-muted-foreground">
Cert expires: {format(new Date(b.certExpiry), 'yyyy-MM-dd')} ({daysLeft} days) {formatDistanceToNow(new Date(c.createdAt), { addSuffix: true })}
</div> </TableCell>
</div> </TableRow>
);
})}
</div>
)}
</div>
)}
{detailTab === 'snapshots' && (
<div>
{detail.screenSnapshots.length === 0 ? (
<p className="text-sm text-gray-500">No screen snapshots in the last 30 days.</p>
) : (
<div className="rounded border border-gray-800">
<table className="w-full text-left text-xs">
<thead>
<tr className="border-b border-gray-800 text-gray-400">
<th className="px-3 py-2">Date</th>
<th className="px-3 py-2">Screens</th>
</tr>
</thead>
<tbody>
{detail.screenSnapshots.map((s, i) => (
<tr key={i} className="border-b border-gray-800/50">
<td className="px-3 py-1 text-gray-300">{s.snapshotDate}</td>
<td className="px-3 py-1 text-gray-200">{s.screenCount}</td>
</tr>
))} ))}
</tbody> </TableBody>
</table> </Table>
</div> </div>
)} )}
</div> </div>
)}
{detailTab === 'stripe' && (
<div>
{!stripeEvents || stripeEvents.length === 0 ? (
<p className="text-sm text-gray-500">No Stripe events found for this customer.</p>
) : (
<div className="rounded border border-gray-800">
<table className="w-full text-left text-xs">
<thead>
<tr className="border-b border-gray-800 text-gray-400">
<th className="px-3 py-2">Time</th>
<th className="px-3 py-2">Event Type</th>
<th className="px-3 py-2">Event ID</th>
</tr>
</thead>
<tbody>
{stripeEvents.map((e) => (
<tr key={e.stripeEventId} className="border-b border-gray-800/50">
<td className="px-3 py-1 text-gray-400">
{format(new Date(e.processedAt), 'yyyy-MM-dd HH:mm:ss')}
</td>
<td className="px-3 py-1 text-gray-200">{e.eventType}</td>
<td className="px-3 py-1 font-mono text-gray-500">{e.stripeEventId}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
) : (
<div className="flex h-48 items-center justify-center rounded border border-gray-800 bg-gray-900/50">
<p className="text-sm text-gray-500">Select a customer to view details</p>
</div>
)}
</div>
</div>
</div>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between">
<span className="text-gray-400">{label}</span>
<span className="text-gray-200">{value}</span>
</div>
); );
} }

View File

@@ -1,100 +1,185 @@
import { useState } from 'react'; import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { getFleetSummary, FleetSummaryDto } from '../api/fleet'; import { getFleetSummary, FleetSummaryDto } from '../api/fleet';
import { formatDistanceToNow } from 'date-fns';
import { Search, Users, HeartPulse, AlertTriangle, Loader2 } from 'lucide-react';
import PageLoading from '../components/shared/PageLoading';
import PageError from '../components/shared/PageError';
import Breadcrumbs from '../components/shared/Breadcrumbs';
import StatCard from '../components/shared/StatCard';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
const healthColors: Record<string, string> = { type SortKey = 'companyName' | 'plan' | 'healthStatus' | 'screenCount';
Healthy: 'bg-green-500/20 text-green-400', type SortDir = 'asc' | 'desc';
Degraded: 'bg-yellow-500/20 text-yellow-400',
Critical: 'bg-red-500/20 text-red-400', const healthDot: Record<string, string> = {
Unknown: 'bg-gray-500/20 text-gray-400', Healthy: 'bg-status-success',
Degraded: 'bg-status-warning',
Critical: 'bg-status-danger',
Unknown: 'bg-muted-foreground',
};
const healthText: Record<string, string> = {
Healthy: 'text-status-success',
Degraded: 'text-status-warning',
Critical: 'text-status-danger',
Unknown: 'text-muted-foreground',
}; };
export default function FleetPage() { export default function FleetPage() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState<SortKey>('companyName');
const [sortDir, setSortDir] = useState<SortDir>('asc');
const navigate = useNavigate(); const navigate = useNavigate();
const { data: customers, isLoading } = useQuery({ const { data: customers, isLoading, isError, error, refetch, dataUpdatedAt } = useQuery({
queryKey: ['fleet'], queryKey: ['fleet'],
queryFn: getFleetSummary, queryFn: getFleetSummary,
refetchInterval: 15000, refetchInterval: 15000,
}); });
const filtered = customers?.filter( const handleSort = (key: SortKey) => {
if (sortKey === key) setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
else { setSortKey(key); setSortDir('asc'); }
};
const sorted = useMemo(() => {
if (!customers) return [];
const filtered = customers.filter(
(c) => (c) =>
c.companyName.toLowerCase().includes(search.toLowerCase()) || c.companyName.toLowerCase().includes(search.toLowerCase()) ||
c.abbreviation.toLowerCase().includes(search.toLowerCase()), c.abbreviation.toLowerCase().includes(search.toLowerCase()),
); );
return [...filtered].sort((a, b) => {
const av = a[sortKey] ?? '';
const bv = b[sortKey] ?? '';
const cmp = typeof av === 'number' ? av - (bv as number) : String(av).localeCompare(String(bv));
return sortDir === 'asc' ? cmp : -cmp;
});
}, [customers, search, sortKey, sortDir]);
// Stats
const totalCustomers = customers?.length ?? 0;
const healthyCt = customers?.filter((c) => c.healthStatus === 'Healthy').length ?? 0;
const unhealthyCt = customers?.filter((c) => c.healthStatus === 'Degraded' || c.healthStatus === 'Critical').length ?? 0;
const runningJobs = customers?.filter((c) => c.hasRunningJob).length ?? 0;
const SortableHead = ({ label, field }: { label: string; field: SortKey }) => (
<TableHead
className="cursor-pointer select-none hover:text-foreground"
onClick={() => handleSort(field)}
>
<span className="inline-flex items-center gap-1">
{label}
{sortKey === field && <span className="text-brand">{sortDir === 'asc' ? '↑' : '↓'}</span>}
</span>
</TableHead>
);
return ( return (
<div> <div className="space-y-6">
<div className="mb-4 flex items-center justify-between"> {/* Header */}
<h2 className="text-xl font-semibold text-white">Fleet Dashboard</h2> <div className="flex items-center justify-between">
<input <div className="space-y-1">
<Breadcrumbs items={[{ label: 'Fleet' }]} />
<h1 className="text-2xl font-semibold tracking-tight text-foreground">Fleet Dashboard</h1>
</div>
<div className="flex items-center gap-3">
{dataUpdatedAt > 0 && (
<span className="text-xs text-muted-foreground">
Updated {formatDistanceToNow(dataUpdatedAt, { addSuffix: true })}
</span>
)}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Label htmlFor="fleet-search" className="sr-only">Search customers</Label>
<Input
id="fleet-search"
placeholder="Search customers..." placeholder="Search customers..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white placeholder-gray-500" className="w-64 pl-8"
/> />
</div> </div>
{isLoading && <p className="text-gray-400">Loading fleet...</p>}
<div className="rounded border border-gray-800">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-800 text-xs text-gray-400">
<th className="px-3 py-2">Abbrev</th>
<th className="px-3 py-2">Company</th>
<th className="px-3 py-2">Plan</th>
<th className="px-3 py-2">Screens</th>
<th className="px-3 py-2">Health</th>
<th className="px-3 py-2">Last Check</th>
<th className="px-3 py-2">Active Job</th>
</tr>
</thead>
<tbody>
{filtered?.map((c) => (
<FleetRow key={c.customerId} customer={c} onClick={() => navigate(`/fleet/${c.customerId}`)} />
))}
{filtered?.length === 0 && (
<tr>
<td colSpan={7} className="px-3 py-6 text-center text-gray-500">
No customers found
</td>
</tr>
)}
</tbody>
</table>
</div> </div>
</div> </div>
);
}
function FleetRow({ customer: c, onClick }: { customer: FleetSummaryDto; onClick: () => void }) { {/* Stat cards */}
const colorCls = healthColors[c.healthStatus] ?? healthColors.Unknown; <div className="grid grid-cols-4 gap-4">
return ( <StatCard label="Total Customers" value={totalCustomers} icon={Users} />
<tr <StatCard label="Healthy" value={healthyCt} icon={HeartPulse} color="success" />
onClick={onClick} <StatCard label="Degraded / Critical" value={unhealthyCt} icon={AlertTriangle} color={unhealthyCt > 0 ? 'danger' : 'default'} />
className="cursor-pointer border-b border-gray-800/50 transition-colors hover:bg-gray-800/40" <StatCard label="Running Jobs" value={runningJobs} icon={Loader2} color={runningJobs > 0 ? 'info' : 'default'} />
</div>
{isLoading && <PageLoading message="Loading fleet..." />}
{isError && <PageError error={error} onRetry={() => refetch()} />}
{/* Table */}
{!isLoading && !isError && (
<div className="rounded-xl border border-border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">Abbrev</TableHead>
<SortableHead label="Company" field="companyName" />
<SortableHead label="Plan" field="plan" />
<SortableHead label="Screens" field="screenCount" />
<SortableHead label="Health" field="healthStatus" />
<TableHead>Last Check</TableHead>
<TableHead className="w-24">Job</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sorted.map((c) => (
<TableRow
key={c.customerId}
onClick={() => navigate(`/fleet/${c.customerId}`)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigate(`/fleet/${c.customerId}`); } }}
tabIndex={0}
className="cursor-pointer"
> >
<td className="px-3 py-2 font-mono text-white">{c.abbreviation}</td> <TableCell className="font-mono text-xs font-medium">{c.abbreviation}</TableCell>
<td className="px-3 py-2 text-gray-200">{c.companyName}</td> <TableCell className="font-medium">{c.companyName}</TableCell>
<td className="px-3 py-2 text-gray-300">{c.plan}</td> <TableCell className="text-muted-foreground">{c.plan}</TableCell>
<td className="px-3 py-2 text-gray-300">{c.screenCount}</td> <TableCell className="tabular-nums text-muted-foreground">{c.screenCount}</TableCell>
<td className="px-3 py-2"> <TableCell>
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${colorCls}`}> <span className="inline-flex items-center gap-1.5">
<span className={`inline-block h-2 w-2 rounded-full ${healthDot[c.healthStatus] ?? healthDot.Unknown}`} />
<span className={`text-xs font-medium ${healthText[c.healthStatus] ?? healthText.Unknown}`}>
{c.healthStatus} {c.healthStatus}
</span> </span>
</td> </span>
<td className="whitespace-nowrap px-3 py-2 text-xs text-gray-400"> </TableCell>
{c.lastHealthCheck ? new Date(c.lastHealthCheck).toLocaleString() : '—'} <TableCell className="text-xs text-muted-foreground">
</td> {c.lastHealthCheck ? formatDistanceToNow(new Date(c.lastHealthCheck), { addSuffix: true }) : '—'}
<td className="px-3 py-2"> </TableCell>
<TableCell>
{c.hasRunningJob && ( {c.hasRunningJob && (
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-blue-500" title="Job in progress" /> <Badge variant="secondary" className="bg-brand/15 text-brand">
Running
</Badge>
)} )}
</td> </TableCell>
</tr> </TableRow>
))}
{sorted.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="py-8 text-center text-muted-foreground">
No customers found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)}
</div>
); );
} }

View File

@@ -2,19 +2,26 @@ import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { getHealthSummary, getHealthEvents, getCheckNames, HealthEventSummary } from '../api/healthEvents'; import { getHealthSummary, getHealthEvents, getCheckNames, HealthEventSummary } from '../api/healthEvents';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { Activity } from 'lucide-react';
import PageLoading from '../components/shared/PageLoading';
import PageError from '../components/shared/PageError';
import Breadcrumbs from '../components/shared/Breadcrumbs';
import StatCard from '../components/shared/StatCard';
import { Button } from '@/components/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
type Tab = 'summary' | 'history'; type Tab = 'summary' | 'history';
const statusColors: Record<string, string> = { const statusDot: Record<string, string> = {
Healthy: 'bg-green-500/20 text-green-400', Healthy: 'bg-status-success',
Degraded: 'bg-yellow-500/20 text-yellow-400', Degraded: 'bg-status-warning',
Critical: 'bg-red-500/20 text-red-400', Critical: 'bg-status-danger',
}; };
const statusDot: Record<string, string> = { const statusText: Record<string, string> = {
Healthy: 'bg-green-500', Healthy: 'text-status-success',
Degraded: 'bg-yellow-500', Degraded: 'text-status-warning',
Critical: 'bg-red-500', Critical: 'text-status-danger',
}; };
export default function HealthPage() { export default function HealthPage() {
@@ -46,42 +53,36 @@ export default function HealthPage() {
enabled: tab === 'history', enabled: tab === 'history',
}); });
// Group summary by instance
const grouped = summary?.reduce<Record<string, HealthEventSummary[]>>((acc, ev) => { const grouped = summary?.reduce<Record<string, HealthEventSummary[]>>((acc, ev) => {
const key = ev.instanceName || ev.instanceId; const key = ev.instanceName || ev.instanceId;
(acc[key] ??= []).push(ev); (acc[key] ??= []).push(ev);
return acc; return acc;
}, {}) ?? {}; }, {}) ?? {};
// Count statuses
const counts = summary?.reduce<Record<string, number>>((acc, ev) => { const counts = summary?.reduce<Record<string, number>>((acc, ev) => {
acc[ev.status] = (acc[ev.status] ?? 0) + 1; acc[ev.status] = (acc[ev.status] ?? 0) + 1;
return acc; return acc;
}, {}) ?? {}; }, {}) ?? {};
return ( return (
<div> <div className="space-y-6">
<h2 className="mb-4 text-xl font-semibold text-white">Health Dashboard</h2> <Breadcrumbs items={[{ label: 'Home', to: '/' }, { label: 'Health' }]} />
<h1 className="text-2xl font-semibold tracking-tight text-foreground">Health Dashboard</h1>
{/* Status summary cards */} {/* Status summary cards */}
<div className="mb-6 flex gap-4"> <div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{['Healthy', 'Degraded', 'Critical'].map((s) => ( <StatCard label="Healthy" value={counts['Healthy'] ?? 0} icon={Activity} color="success" />
<div key={s} className={`rounded-lg border border-gray-800 px-4 py-3 ${statusColors[s]}`}> <StatCard label="Degraded" value={counts['Degraded'] ?? 0} icon={Activity} color="warning" />
<div className="text-2xl font-bold">{counts[s] ?? 0}</div> <StatCard label="Critical" value={counts['Critical'] ?? 0} icon={Activity} color="danger" />
<div className="text-xs">{s}</div> <StatCard label="Total Checks" value={summary?.length ?? 0} icon={Activity} />
</div>
))}
<div className="rounded-lg border border-gray-800 bg-gray-900 px-4 py-3 text-gray-300">
<div className="text-2xl font-bold">{summary?.length ?? 0}</div>
<div className="text-xs">Total Checks</div>
</div>
</div> </div>
{/* Tab switcher */} {/* Tab switcher */}
<div className="mb-4 flex gap-1 rounded bg-gray-900 p-1"> <div className="flex gap-1 rounded-xl bg-muted/50 p-1">
{(['summary', 'history'] as Tab[]).map((t) => ( {(['summary', 'history'] as Tab[]).map((t) => (
<button key={t} onClick={() => setTab(t)} <button key={t} onClick={() => setTab(t)}
className={`rounded px-4 py-2 text-sm font-medium ${tab === t ? 'bg-gray-700 text-white' : 'text-gray-400 hover:text-white'}`}> className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${tab === t ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}>
{t === 'summary' ? 'Current Status' : 'Event History'} {t === 'summary' ? 'Current Status' : 'Event History'}
</button> </button>
))} ))}
@@ -89,22 +90,22 @@ export default function HealthPage() {
{tab === 'summary' && ( {tab === 'summary' && (
<> <>
{summaryLoading && <p className="text-gray-400">Loading...</p>} {summaryLoading && <PageLoading />}
{Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([instance, checks]) => ( {Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)).map(([instance, checks]) => (
<div key={instance} className="mb-4 rounded border border-gray-800 bg-gray-900/50 p-4"> <div key={instance} className="rounded-xl border border-border bg-card p-4">
<h3 className="mb-3 text-sm font-semibold text-white">{instance}</h3> <h3 className="mb-3 text-sm font-semibold text-foreground">{instance}</h3>
<div className="grid grid-cols-2 gap-2 lg:grid-cols-3 xl:grid-cols-4"> <div className="grid grid-cols-2 gap-2 lg:grid-cols-3 xl:grid-cols-4">
{checks.sort((a, b) => a.checkName.localeCompare(b.checkName)).map((check) => ( {checks.sort((a, b) => a.checkName.localeCompare(b.checkName)).map((check) => (
<div key={check.id} className="flex items-start gap-2 rounded border border-gray-800 bg-gray-900 px-3 py-2"> <div key={check.id} className="flex items-start gap-2 rounded-xl border border-border bg-card px-3 py-2">
<div className={`mt-1 h-2 w-2 flex-shrink-0 rounded-full ${statusDot[check.status] ?? 'bg-gray-500'}`} /> <div className={`mt-1 h-2 w-2 flex-shrink-0 rounded-full ${statusDot[check.status] ?? 'bg-muted-foreground'}`} />
<div className="min-w-0"> <div className="min-w-0">
<div className="text-xs font-medium text-gray-200">{check.checkName}</div> <div className="text-xs font-medium text-foreground">{check.checkName}</div>
{check.message && ( {check.message && (
<div className="mt-0.5 truncate text-xs text-gray-500" title={check.message}>{check.message}</div> <div className="mt-0.5 truncate text-xs text-muted-foreground" title={check.message}>{check.message}</div>
)} )}
<div className="mt-0.5 text-xs text-gray-600"> <div className="mt-0.5 text-xs text-muted-foreground">
{format(new Date(check.occurredAt), 'HH:mm:ss')} {format(new Date(check.occurredAt), 'HH:mm:ss')}
{check.remediated && <span className="ml-1 text-green-600">(remediated)</span>} {check.remediated && <span className="ml-1 text-status-success">(remediated)</span>}
</div> </div>
</div> </div>
</div> </div>
@@ -117,14 +118,14 @@ export default function HealthPage() {
{tab === 'history' && ( {tab === 'history' && (
<> <>
<div className="mb-4 flex gap-3"> <div className="flex gap-3">
<select value={checkFilter} onChange={(e) => { setCheckFilter(e.target.value); setPage(0); }} <select value={checkFilter} onChange={(e) => { setCheckFilter(e.target.value); setPage(0); }}
className="rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"> className="rounded-xl border border-border bg-card px-3 py-2 text-sm text-foreground">
<option value="">All checks</option> <option value="">All checks</option>
{checkNames?.map((n) => <option key={n} value={n}>{n}</option>)} {checkNames?.map((n) => <option key={n} value={n}>{n}</option>)}
</select> </select>
<select value={statusFilter} onChange={(e) => { setStatusFilter(e.target.value); setPage(0); }} <select value={statusFilter} onChange={(e) => { setStatusFilter(e.target.value); setPage(0); }}
className="rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"> className="rounded-xl border border-border bg-card px-3 py-2 text-sm text-foreground">
<option value="">All statuses</option> <option value="">All statuses</option>
<option value="Healthy">Healthy</option> <option value="Healthy">Healthy</option>
<option value="Degraded">Degraded</option> <option value="Degraded">Degraded</option>
@@ -132,51 +133,50 @@ export default function HealthPage() {
</select> </select>
</div> </div>
{historyLoading && <p className="text-gray-400">Loading...</p>} {historyLoading && <PageLoading />}
<div className="rounded border border-gray-800"> <div className="rounded-xl border border-border">
<table className="w-full text-left text-sm"> <Table>
<thead> <TableHeader>
<tr className="border-b border-gray-800 text-xs text-gray-400"> <TableRow>
<th className="px-3 py-2">Time</th> <TableHead>Time</TableHead>
<th className="px-3 py-2">Instance</th> <TableHead>Instance</TableHead>
<th className="px-3 py-2">Check</th> <TableHead>Check</TableHead>
<th className="px-3 py-2">Status</th> <TableHead>Status</TableHead>
<th className="px-3 py-2">Message</th> <TableHead>Message</TableHead>
<th className="px-3 py-2">Remediated</th> <TableHead>Remediated</TableHead>
</tr> </TableRow>
</thead> </TableHeader>
<tbody> <TableBody>
{history?.events.map((ev) => ( {history?.events.map((ev) => (
<tr key={ev.id} className="border-b border-gray-800/50"> <TableRow key={ev.id}>
<td className="whitespace-nowrap px-3 py-2 text-xs text-gray-400"> <TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{format(new Date(ev.occurredAt), 'yyyy-MM-dd HH:mm:ss')} {format(new Date(ev.occurredAt), 'yyyy-MM-dd HH:mm:ss')}
</td> </TableCell>
<td className="px-3 py-2 font-mono text-xs text-gray-300">{ev.instanceName}</td> <TableCell className="font-mono text-xs text-muted-foreground">{ev.instanceName}</TableCell>
<td className="px-3 py-2 text-xs text-gray-200">{ev.checkName}</td> <TableCell className="text-xs text-foreground">{ev.checkName}</TableCell>
<td className="px-3 py-2"> <TableCell>
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusColors[ev.status] ?? 'text-gray-400'}`}> <span className="flex items-center gap-1.5">
{ev.status} <span className={`h-1.5 w-1.5 rounded-full ${statusDot[ev.status] ?? 'bg-muted-foreground'}`} />
<span className={`text-xs font-medium ${statusText[ev.status] ?? 'text-muted-foreground'}`}>{ev.status}</span>
</span> </span>
</td> </TableCell>
<td className="max-w-xs truncate px-3 py-2 text-xs text-gray-400" title={ev.message ?? ''}> <TableCell className="max-w-xs truncate text-xs text-muted-foreground" title={ev.message ?? ''}>
{ev.message || '-'} {ev.message || '-'}
</td> </TableCell>
<td className="px-3 py-2 text-xs"> <TableCell className="text-xs">
{ev.remediated ? <span className="text-green-400">Yes</span> : <span className="text-gray-600">No</span>} {ev.remediated ? <span className="text-status-success">Yes</span> : <span className="text-muted-foreground">No</span>}
</td> </TableCell>
</tr> </TableRow>
))} ))}
</tbody> </TableBody>
</table> </Table>
</div> </div>
<div className="mt-3 flex items-center gap-2"> <div className="flex items-center gap-2">
<button onClick={() => setPage(Math.max(0, page - 1))} disabled={page === 0} <Button variant="secondary" size="sm" onClick={() => setPage(Math.max(0, page - 1))} disabled={page === 0}>Previous</Button>
className="rounded bg-gray-700 px-3 py-1 text-sm text-gray-300 hover:bg-gray-600 disabled:opacity-40">Previous</button> <span className="px-2 py-1 text-sm text-muted-foreground">Page {page + 1}{history ? ` of ${Math.ceil(history.total / pageSize)}` : ''}</span>
<span className="px-2 py-1 text-sm text-gray-400">Page {page + 1}{history ? ` of ${Math.ceil(history.total / pageSize)}` : ''}</span> <Button variant="secondary" size="sm" onClick={() => setPage(page + 1)} disabled={(history?.events.length ?? 0) < pageSize}>Next</Button>
<button onClick={() => setPage(page + 1)} disabled={(history?.events.length ?? 0) < pageSize}
className="rounded bg-gray-700 px-3 py-1 text-sm text-gray-300 hover:bg-gray-600 disabled:opacity-40">Next</button>
</div> </div>
</> </>
)} )}

View File

@@ -1,6 +1,47 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { listHosts, createHost, updateHost, deleteHost, testHost, listNodes, SshHost, CreateSshHostRequest } from '../api/hosts'; import { listHosts, createHost, updateHost, deleteHost, testHost, listNodes, SshHost, CreateSshHostRequest } from '../api/hosts';
import { listSecrets } from '../api/secrets';
import PageLoading from '../components/shared/PageLoading';
import PageError from '../components/shared/PageError';
import EmptyState from '../components/shared/EmptyState';
import Breadcrumbs from '../components/shared/Breadcrumbs';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import ConfirmDialog from '../components/shared/ConfirmDialog';
function SecretsPanel({ hostId }: { hostId: string }) {
const { data: secrets, isLoading } = useQuery({
queryKey: ['secrets', hostId],
queryFn: () => listSecrets(hostId),
});
if (isLoading) return <p className="px-3 py-2 text-xs text-muted-foreground">Loading secrets...</p>;
if (!secrets || secrets.length === 0) return <p className="px-3 py-2 text-xs text-muted-foreground">No secrets on this host.</p>;
return (
<div className="mt-1 rounded-xl border border-border bg-card">
<table className="w-full text-left text-xs">
<thead>
<tr className="border-b border-border text-muted-foreground">
<th className="px-3 py-1.5">Secret Name</th>
<th className="px-3 py-1.5">Created</th>
</tr>
</thead>
<tbody>
{secrets.map((s) => (
<tr key={s.name} className="border-b border-border/50">
<td className="px-3 py-1 font-mono text-foreground">{s.name}</td>
<td className="px-3 py-1 text-muted-foreground">{s.createdAt}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function HostForm({ function HostForm({
initial, initial,
@@ -21,47 +62,47 @@ function HostForm({
}); });
return ( return (
<div className="mb-4 rounded border border-gray-700 bg-gray-900 p-4"> <div className="mb-4 rounded-xl border border-border bg-card p-4">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{(['label', 'host', 'username', 'privateKeyPath'] as const).map((field) => ( {(['label', 'host', 'username', 'privateKeyPath'] as const).map((field) => (
<div key={field}> <div key={field}>
<label className="mb-1 block text-xs text-gray-400">{field}</label> <Label htmlFor={`host-${field}`} className="mb-1">{field}</Label>
<input <Input
id={`host-${field}`}
value={(form[field] as string) ?? ''} value={(form[field] as string) ?? ''}
onChange={(e) => setForm({ ...form, [field]: e.target.value })} onChange={(e) => setForm({ ...form, [field]: e.target.value })}
className="w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-sm text-white"
/> />
</div> </div>
))} ))}
<div> <div>
<label className="mb-1 block text-xs text-gray-400">port</label> <Label htmlFor="host-port" className="mb-1">port</Label>
<input <Input
id="host-port"
type="number" type="number"
value={form.port} value={form.port}
onChange={(e) => setForm({ ...form, port: parseInt(e.target.value) || 22 })} onChange={(e) => setForm({ ...form, port: parseInt(e.target.value) || 22 })}
className="w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-sm text-white"
/> />
</div> </div>
<div> <div>
<label className="mb-1 block text-xs text-gray-400">password</label> <Label htmlFor="host-password" className="mb-1">password</Label>
<input <Input
id="host-password"
type="password" type="password"
onChange={(e) => setForm({ ...form, password: e.target.value || null })} onChange={(e) => setForm({ ...form, password: e.target.value || null })}
placeholder={initial ? '(unchanged)' : ''} placeholder={initial ? '(unchanged)' : ''}
className="w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-sm text-white"
/> />
</div> </div>
<div> <div>
<label className="mb-1 block text-xs text-gray-400">keyPassphrase</label> <Label htmlFor="host-passphrase" className="mb-1">keyPassphrase</Label>
<input <Input
id="host-passphrase"
type="password" type="password"
onChange={(e) => setForm({ ...form, keyPassphrase: e.target.value || null })} onChange={(e) => setForm({ ...form, keyPassphrase: e.target.value || null })}
placeholder={initial ? '(unchanged)' : ''} placeholder={initial ? '(unchanged)' : ''}
className="w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-sm text-white"
/> />
</div> </div>
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
<label className="flex items-center gap-2 text-sm text-gray-300"> <label className="flex items-center gap-2 text-sm text-muted-foreground">
<input <input
type="checkbox" type="checkbox"
checked={form.useKeyAuth} checked={form.useKeyAuth}
@@ -72,12 +113,8 @@ function HostForm({
</div> </div>
</div> </div>
<div className="mt-3 flex gap-2"> <div className="mt-3 flex gap-2">
<button onClick={() => onSubmit(form)} className="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-500"> <Button onClick={() => onSubmit(form)}>Save</Button>
Save <Button variant="secondary" onClick={onCancel}>Cancel</Button>
</button>
<button onClick={onCancel} className="rounded bg-gray-700 px-3 py-1 text-sm text-gray-300 hover:bg-gray-600">
Cancel
</button>
</div> </div>
</div> </div>
); );
@@ -85,29 +122,35 @@ function HostForm({
export default function HostsPage() { export default function HostsPage() {
const qc = useQueryClient(); const qc = useQueryClient();
const { data: hosts, isLoading } = useQuery({ queryKey: ['hosts'], queryFn: listHosts }); const { data: hosts, isLoading, isError, error, refetch } = useQuery({ queryKey: ['hosts'], queryFn: listHosts });
const [editing, setEditing] = useState<string | 'new' | null>(null); const [editing, setEditing] = useState<string | 'new' | null>(null);
const [nodes, setNodes] = useState<{ hostId: string; data: unknown[] } | null>(null); const [nodes, setNodes] = useState<{ hostId: string; data: unknown[] } | null>(null);
const [testResult, setTestResult] = useState<{ hostId: string; success: boolean; message: string } | null>(null); const [testResult, setTestResult] = useState<{ hostId: string; success: boolean; message: string } | null>(null);
const [showSecrets, setShowSecrets] = useState<string | null>(null);
const createMut = useMutation({ mutationFn: createHost, onSuccess: () => { qc.invalidateQueries({ queryKey: ['hosts'] }); setEditing(null); } }); const createMut = useMutation({ mutationFn: createHost, onSuccess: () => { qc.invalidateQueries({ queryKey: ['hosts'] }); setEditing(null); toast.success('Host created'); } });
const updateMut = useMutation({ mutationFn: ({ id, req }: { id: string; req: CreateSshHostRequest }) => updateHost(id, req), onSuccess: () => { qc.invalidateQueries({ queryKey: ['hosts'] }); setEditing(null); } }); const updateMut = useMutation({ mutationFn: ({ id, req }: { id: string; req: CreateSshHostRequest }) => updateHost(id, req), onSuccess: () => { qc.invalidateQueries({ queryKey: ['hosts'] }); setEditing(null); toast.success('Host updated'); } });
const deleteMut = useMutation({ mutationFn: deleteHost, onSuccess: () => qc.invalidateQueries({ queryKey: ['hosts'] }) }); const deleteMut = useMutation({ mutationFn: deleteHost, onSuccess: () => { qc.invalidateQueries({ queryKey: ['hosts'] }); toast.success('Host deleted'); } });
const [confirmDeleteHost, setConfirmDeleteHost] = useState<SshHost | null>(null);
return ( return (
<div> <div className="space-y-6">
<div className="mb-4 flex items-center justify-between"> <Breadcrumbs items={[{ label: 'Home', to: '/' }, { label: 'Infrastructure' }]} />
<h2 className="text-xl font-semibold text-white">SSH Hosts</h2>
<button onClick={() => setEditing('new')} className="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-500"> <div className="flex items-center justify-between">
Add Host <h1 className="text-2xl font-semibold tracking-tight text-foreground">Infrastructure</h1>
</button> <Button onClick={() => setEditing('new')}>Add Host</Button>
</div> </div>
{editing === 'new' && ( {editing === 'new' && (
<HostForm onSubmit={(data) => createMut.mutate(data)} onCancel={() => setEditing(null)} /> <HostForm onSubmit={(data) => createMut.mutate(data)} onCancel={() => setEditing(null)} />
)} )}
{isLoading && <p className="text-gray-400">Loading...</p>} {isLoading && <PageLoading />}
{isError && <PageError error={error} onRetry={() => refetch()} />}
{!isLoading && !isError && hosts?.length === 0 && (
<EmptyState title="No hosts configured" description="Add an SSH host to start managing Docker Swarm instances." />
)}
<div className="space-y-2"> <div className="space-y-2">
{hosts?.map((host) => ( {hosts?.map((host) => (
@@ -119,60 +162,80 @@ export default function HostsPage() {
onCancel={() => setEditing(null)} onCancel={() => setEditing(null)}
/> />
) : ( ) : (
<div className="flex items-center justify-between rounded border border-gray-800 bg-gray-900 px-4 py-3"> <div className="flex items-center justify-between rounded-xl border border-border bg-card px-4 py-3">
<div> <div>
<span className="font-medium text-white">{host.label}</span> <span className="font-medium text-foreground">{host.label}</span>
<span className="ml-3 text-sm text-gray-400"> <span className="ml-3 text-sm text-muted-foreground">
{host.username}@{host.host}:{host.port} {host.username}@{host.host}:{host.port}
</span> </span>
{host.lastTestedAt && ( {host.lastTestedAt && (
<span className={`ml-3 text-xs ${host.lastTestSuccess ? 'text-green-400' : 'text-red-400'}`}> <span className={`ml-3 text-xs ${host.lastTestSuccess ? 'text-status-success' : 'text-status-danger'}`}>
{host.lastTestSuccess ? 'OK' : 'FAIL'} {host.lastTestSuccess ? 'OK' : 'FAIL'}
</span> </span>
)} )}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <Button
variant="secondary"
size="sm"
onClick={async () => { onClick={async () => {
const res = await testHost(host.id); const res = await testHost(host.id);
setTestResult({ hostId: host.id, ...res }); setTestResult({ hostId: host.id, ...res });
qc.invalidateQueries({ queryKey: ['hosts'] }); qc.invalidateQueries({ queryKey: ['hosts'] });
}} }}
className="rounded bg-gray-700 px-2 py-1 text-xs text-gray-300 hover:bg-gray-600"
> >
Test Test
</button> </Button>
<button <Button
variant="secondary"
size="sm"
onClick={async () => { onClick={async () => {
const data = await listNodes(host.id); const data = await listNodes(host.id);
setNodes({ hostId: host.id, data }); setNodes({ hostId: host.id, data });
}} }}
className="rounded bg-gray-700 px-2 py-1 text-xs text-gray-300 hover:bg-gray-600"
> >
Nodes Nodes
</button> </Button>
<button onClick={() => setEditing(host.id)} className="rounded bg-gray-700 px-2 py-1 text-xs text-gray-300 hover:bg-gray-600"> <Button
variant={showSecrets === host.id ? 'default' : 'secondary'}
size="sm"
onClick={() => setShowSecrets(showSecrets === host.id ? null : host.id)}
>
Secrets
</Button>
<Button variant="secondary" size="sm" onClick={() => setEditing(host.id)}>
Edit Edit
</button> </Button>
<button onClick={() => deleteMut.mutate(host.id)} className="rounded bg-red-900 px-2 py-1 text-xs text-red-300 hover:bg-red-800"> <Button variant="destructive" size="sm" onClick={() => setConfirmDeleteHost(host)}>
Delete Delete
</button> </Button>
</div> </div>
</div> </div>
)} )}
{testResult?.hostId === host.id && ( {testResult?.hostId === host.id && (
<div className={`mt-1 rounded px-3 py-2 text-sm ${testResult.success ? 'bg-green-900/30 text-green-300' : 'bg-red-900/30 text-red-300'}`}> <div className={`mt-1 rounded-xl px-3 py-2 text-sm ${testResult.success ? 'bg-status-success/10 text-status-success' : 'bg-status-danger/10 text-status-danger'}`}>
{testResult.message} {testResult.message}
</div> </div>
)} )}
{nodes?.hostId === host.id && ( {nodes?.hostId === host.id && (
<pre className="mt-1 max-h-40 overflow-auto rounded bg-gray-800 p-2 text-xs text-gray-300"> <pre className="mt-1 max-h-40 overflow-auto rounded-xl bg-muted/50 p-2 text-xs text-muted-foreground">
{JSON.stringify(nodes.data, null, 2)} {JSON.stringify(nodes.data, null, 2)}
</pre> </pre>
)} )}
{showSecrets === host.id && <SecretsPanel hostId={host.id} />}
</div> </div>
))} ))}
</div> </div>
{confirmDeleteHost && (
<ConfirmDialog
title="Delete Host"
message={<>Permanently remove host <strong>{confirmDeleteHost.label}</strong> ({confirmDeleteHost.host})?</>}
confirmText="Delete"
onConfirm={() => { deleteMut.mutate(confirmDeleteHost.id); setConfirmDeleteHost(null); }}
onCancel={() => setConfirmDeleteHost(null)}
/>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,283 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams } from 'react-router-dom';
import { toast } from 'sonner';
import {
getStackServices, restartStack, restartService, deleteStack,
rotateMySql, getStackLogs, getCredentials, initializeInstance,
rotateAdminPassword, ServiceInfo,
} from '../api/instances';
import ConfirmDialog from '../components/shared/ConfirmDialog';
import Breadcrumbs from '../components/shared/Breadcrumbs';
import CopyButton from '../components/shared/CopyButton';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
import { RefreshCw, Key, Trash2, RotateCw, Eye, EyeOff } from 'lucide-react';
type Tab = 'services' | 'creds' | 'logs';
export default function InstanceDetailPage() {
const { stackName } = useParams<{ stackName: string }>();
const qc = useQueryClient();
const [tab, setTab] = useState<Tab>('services');
const [confirmRestart, setConfirmRestart] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [confirmRotateMySql, setConfirmRotateMySql] = useState(false);
const abbrev = stackName?.replace(/-cms-stack$/, '') ?? '';
const { data: services, isLoading } = useQuery({
queryKey: ['services', stackName],
queryFn: () => getStackServices(stackName!),
enabled: !!stackName,
});
const restartMut = useMutation({
mutationFn: () => restartStack(stackName!),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['services', stackName] }); toast.success('Stack restarted'); },
});
const deleteMut = useMutation({
mutationFn: () => deleteStack(stackName!),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['instances'] }); toast.success('Stack deleted'); },
});
const rotateMySqlMut = useMutation({
mutationFn: () => rotateMySql(stackName!),
onSuccess: () => toast.success('MySQL password rotated'),
});
if (!stackName) return null;
return (
<div className="space-y-6">
{/* Breadcrumbs + title */}
<div className="space-y-1">
<Breadcrumbs items={[{ label: 'Instances', to: '/instances' }, { label: stackName }]} />
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight text-foreground">{stackName}</h1>
{services && (
<span className="text-sm text-muted-foreground">{services.length} services</span>
)}
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setConfirmRestart(true)}>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" /> Restart All
</Button>
<Button variant="secondary" size="sm" onClick={() => setConfirmRotateMySql(true)}>
<Key className="mr-1.5 h-3.5 w-3.5" /> Rotate MySQL
</Button>
<Button variant="destructive" size="sm" onClick={() => setConfirmDelete(true)}>
<Trash2 className="mr-1.5 h-3.5 w-3.5" /> Delete
</Button>
</div>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-border">
{(['services', 'creds', 'logs'] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`border-b-2 px-4 py-2 text-sm font-medium transition-colors ${
tab === t
? 'border-brand text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
{t === 'services' ? 'Services' : t === 'creds' ? 'Credentials' : 'Logs'}
</button>
))}
</div>
{tab === 'services' && <ServicesPanel stackName={stackName} services={services} isLoading={isLoading} />}
{tab === 'creds' && <CredentialsPanel abbrev={abbrev} />}
{tab === 'logs' && <LogPanel stackName={stackName} />}
{/* Dialogs */}
{confirmRestart && (
<ConfirmDialog
title="Restart All Services"
message={<>Restart all services in <strong>{stackName}</strong>? Connected users may experience a brief outage.</>}
confirmText="Restart"
onConfirm={() => { restartMut.mutate(); setConfirmRestart(false); }}
onCancel={() => setConfirmRestart(false)}
/>
)}
{confirmDelete && (
<ConfirmDialog
title="Delete Instance"
message={<>Permanently remove <strong>{stackName}</strong> and all associated data?</>}
confirmText="Delete"
requireTyping={abbrev}
onConfirm={() => { deleteMut.mutate(); setConfirmDelete(false); }}
onCancel={() => setConfirmDelete(false)}
/>
)}
{confirmRotateMySql && (
<ConfirmDialog
title="Rotate MySQL Password"
message={<>Rotate the MySQL password for <strong>{stackName}</strong>? The instance will be briefly unavailable.</>}
confirmText="Rotate"
onConfirm={() => { rotateMySqlMut.mutate(); setConfirmRotateMySql(false); }}
onCancel={() => setConfirmRotateMySql(false)}
/>
)}
</div>
);
}
function ServicesPanel({ stackName, services, isLoading }: { stackName: string; services?: ServiceInfo[]; isLoading: boolean }) {
const qc = useQueryClient();
const restartMut = useMutation({
mutationFn: (svc: string) => restartService(stackName, svc),
onSuccess: (_, svc) => { qc.invalidateQueries({ queryKey: ['services', stackName] }); toast.success(`Service ${svc} restarted`); },
});
if (isLoading) return <p className="text-sm text-muted-foreground">Loading services...</p>;
if (!services?.length) return <p className="text-sm text-muted-foreground">No services found.</p>;
return (
<div className="rounded-xl border border-border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Service Name</TableHead>
<TableHead>Image</TableHead>
<TableHead>Replicas</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{services.map((svc) => (
<TableRow key={svc.name}>
<TableCell className="font-mono text-sm">{svc.name}</TableCell>
<TableCell className="text-xs text-muted-foreground">{svc.image}</TableCell>
<TableCell className="text-sm">{svc.replicas}</TableCell>
<TableCell className="text-right">
<Button
variant="secondary"
size="sm"
onClick={() => restartMut.mutate(svc.name)}
disabled={restartMut.isPending}
>
<RotateCw className="mr-1.5 h-3 w-3" /> Restart
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
function CredentialsPanel({ abbrev }: { abbrev: string }) {
const { data: creds } = useQuery({
queryKey: ['credentials', abbrev],
queryFn: () => getCredentials(abbrev),
});
const [initForm, setInitForm] = useState({ clientId: '', clientSecret: '' });
const [showPasswords, setShowPasswords] = useState(false);
const initMut = useMutation({ mutationFn: () => initializeInstance(abbrev, initForm.clientId, initForm.clientSecret), onSuccess: () => toast.success('Instance initialized') });
const rotateMut = useMutation({ mutationFn: () => rotateAdminPassword(abbrev), onSuccess: () => toast.success('Admin password rotated') });
if (!creds) return <p className="text-sm text-muted-foreground">Loading credentials...</p>;
return (
<div className="space-y-4">
<div className="rounded-xl border border-border bg-card p-5">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">Credentials</h3>
<button
onClick={() => setShowPasswords(!showPasswords)}
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
>
{showPasswords ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
{showPasswords ? 'Hide' : 'Show'} secrets
</button>
</div>
<div className="grid grid-cols-2 gap-4">
<CredRow label="Admin User" value={creds.adminUsername} />
<CredRow label="Admin Password" value={showPasswords ? (creds.adminPassword || '(not set)') : '••••••••'} copyValue={creds.adminPassword} />
<CredRow label="OAuth Client ID" value={creds.oauthClientId || 'N/A'} />
<CredRow label="Instance URL" value={creds.instanceUrl} />
</div>
<div className="mt-4">
<Button variant="secondary" size="sm" onClick={() => rotateMut.mutate()} disabled={rotateMut.isPending}>
<Key className="mr-1.5 h-3 w-3" /> Rotate Admin Password
</Button>
</div>
</div>
{creds.hasPendingSetup && (
<div className="rounded-xl border border-status-warning/30 bg-status-warning/5 p-5">
<h3 className="mb-2 text-sm font-semibold text-status-warning">Instance Needs Initialization</h3>
<div className="flex gap-2">
<Input placeholder="Client ID" value={initForm.clientId} onChange={(e) => setInitForm({ ...initForm, clientId: e.target.value })} className="flex-1" />
<Input placeholder="Client Secret" value={initForm.clientSecret} onChange={(e) => setInitForm({ ...initForm, clientSecret: e.target.value })} className="flex-1" type="password" />
<Button onClick={() => initMut.mutate()} disabled={initMut.isPending || !initForm.clientId || !initForm.clientSecret}>
Initialize
</Button>
</div>
</div>
)}
</div>
);
}
function CredRow({ label, value, copyValue }: { label: string; value: string; copyValue?: string }) {
return (
<div>
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<div className="mt-0.5 flex items-center gap-1.5">
<span className="font-mono text-sm text-foreground">{value}</span>
{(copyValue || value) && value !== '••••••••' && value !== 'N/A' && value !== '(not set)' && (
<CopyButton value={copyValue ?? value} />
)}
</div>
</div>
);
}
function LogPanel({ stackName }: { stackName: string }) {
const [service, setService] = useState<string | undefined>();
const { data: services } = useQuery({ queryKey: ['services', stackName], queryFn: () => getStackServices(stackName) });
const { data: logs } = useQuery({
queryKey: ['logs', stackName, service],
queryFn: () => getStackLogs(stackName, service, 200),
refetchInterval: 5000,
});
return (
<div className="space-y-3">
<div className="flex gap-1.5 rounded-lg bg-muted/50 p-1">
<button
onClick={() => setService(undefined)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
!service ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
}`}
>
All services
</button>
{services?.map((s) => (
<button
key={s.name}
onClick={() => setService(s.name)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
service === s.name ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
}`}
>
{s.name}
</button>
))}
</div>
<pre className="max-h-[32rem] overflow-auto rounded-xl border border-border bg-card p-4 font-mono text-xs leading-5 text-status-success">
{logs?.join('\n') || 'No logs available'}
</pre>
</div>
);
}

View File

@@ -1,186 +1,100 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { import { useNavigate } from 'react-router-dom';
listLiveInstances, getStackServices, restartStack, restartService, import { listLiveInstances } from '../api/instances';
deleteStack, rotateMySql, getStackLogs, getCredentials, import PageLoading from '../components/shared/PageLoading';
initializeInstance, rotateAdminPassword, LiveInstance, ServiceInfo, InstanceCredentials, import PageError from '../components/shared/PageError';
} from '../api/instances'; import EmptyState from '../components/shared/EmptyState';
import ConfirmDialog from '../components/shared/ConfirmDialog'; import Breadcrumbs from '../components/shared/Breadcrumbs';
import { Button } from '@/components/ui/button';
function ServiceList({ stackName }: { stackName: string }) { import { Input } from '@/components/ui/input';
const { data: services, isLoading } = useQuery({ import { Label } from '@/components/ui/label';
queryKey: ['services', stackName], import DeployInstancePanel from '../components/instances/DeployInstancePanel';
queryFn: () => getStackServices(stackName), import { Search, Server, Plus, RefreshCw, X } from 'lucide-react';
});
const qc = useQueryClient();
const restartMut = useMutation({
mutationFn: (svc: string) => restartService(stackName, svc),
onSuccess: () => qc.invalidateQueries({ queryKey: ['services', stackName] }),
});
if (isLoading) return <p className="text-xs text-gray-500">Loading services...</p>;
return (
<div className="mt-2 space-y-1">
{services?.map((svc: ServiceInfo) => (
<div key={svc.name} className="flex items-center justify-between rounded bg-gray-800 px-3 py-1 text-sm">
<div>
<span className="text-gray-200">{svc.name}</span>
<span className="ml-2 text-xs text-gray-500">{svc.replicas}</span>
<span className="ml-2 text-xs text-gray-600">{svc.image}</span>
</div>
<button
onClick={() => restartMut.mutate(svc.name)}
className="rounded bg-gray-700 px-2 py-0.5 text-xs text-gray-300 hover:bg-gray-600"
>
Restart
</button>
</div>
))}
</div>
);
}
function CredentialsPanel({ abbrev }: { abbrev: string }) {
const { data: creds } = useQuery({
queryKey: ['credentials', abbrev],
queryFn: () => getCredentials(abbrev),
});
const [initForm, setInitForm] = useState({ clientId: '', clientSecret: '' });
const initMut = useMutation({ mutationFn: () => initializeInstance(abbrev, initForm.clientId, initForm.clientSecret) });
const rotateMut = useMutation({ mutationFn: () => rotateAdminPassword(abbrev) });
if (!creds) return null;
return (
<div className="mt-2 rounded border border-gray-700 bg-gray-800 p-3 text-sm">
<div className="grid grid-cols-2 gap-2 text-xs">
<div><span className="text-gray-500">Admin:</span> <span className="text-white">{creds.adminUsername}</span></div>
<div><span className="text-gray-500">Password:</span> <span className="text-white">{creds.adminPassword || '(not set)'}</span></div>
<div><span className="text-gray-500">OAuth Client:</span> <span className="text-white">{creds.oauthClientId || 'N/A'}</span></div>
<div><span className="text-gray-500">URL:</span> <span className="text-white">{creds.instanceUrl}</span></div>
</div>
{creds.hasPendingSetup && (
<div className="mt-2 border-t border-gray-700 pt-2">
<p className="mb-1 text-xs text-yellow-400">Instance needs initialization</p>
<div className="flex gap-2">
<input placeholder="Client ID" value={initForm.clientId} onChange={(e) => setInitForm({ ...initForm, clientId: e.target.value })}
className="rounded border border-gray-600 bg-gray-900 px-2 py-1 text-xs text-white" />
<input placeholder="Client Secret" value={initForm.clientSecret} onChange={(e) => setInitForm({ ...initForm, clientSecret: e.target.value })}
className="rounded border border-gray-600 bg-gray-900 px-2 py-1 text-xs text-white" />
<button onClick={() => initMut.mutate()} className="rounded bg-blue-600 px-2 py-1 text-xs text-white">Init</button>
</div>
</div>
)}
<div className="mt-2 flex gap-2">
<button onClick={() => rotateMut.mutate()} className="rounded bg-yellow-700 px-2 py-1 text-xs text-white hover:bg-yellow-600">
Rotate Admin Password
</button>
</div>
</div>
);
}
function LogViewer({ stackName }: { stackName: string }) {
const [service, setService] = useState<string | undefined>();
const { data: services } = useQuery({ queryKey: ['services', stackName], queryFn: () => getStackServices(stackName) });
const { data: logs } = useQuery({
queryKey: ['logs', stackName, service],
queryFn: () => getStackLogs(stackName, service, 200),
refetchInterval: 5000,
});
return (
<div className="mt-2">
<div className="mb-1 flex gap-2">
<select value={service ?? ''} onChange={(e) => setService(e.target.value || undefined)}
className="rounded border border-gray-700 bg-gray-800 px-2 py-1 text-xs text-white">
<option value="">All services</option>
{services?.map((s: ServiceInfo) => <option key={s.name} value={s.name}>{s.name}</option>)}
</select>
</div>
<pre className="max-h-64 overflow-auto rounded bg-black p-2 text-xs text-green-400">
{logs?.join('\n') || 'No logs'}
</pre>
</div>
);
}
export default function InstancesPage() { export default function InstancesPage() {
const qc = useQueryClient(); const qc = useQueryClient();
const { data: instances, isLoading } = useQuery({ queryKey: ['instances'], queryFn: listLiveInstances }); const navigate = useNavigate();
const [expanded, setExpanded] = useState<string | null>(null); const { data: instances, isLoading, isError, error, refetch } = useQuery({ queryKey: ['instances'], queryFn: listLiveInstances });
const [activeTab, setActiveTab] = useState<'services' | 'creds' | 'logs'>('services'); const [showDeploy, setShowDeploy] = useState(false);
const [confirmDelete, setConfirmDelete] = useState<LiveInstance | null>(null); const [search, setSearch] = useState('');
const restartMut = useMutation({ mutationFn: restartStack, onSuccess: () => qc.invalidateQueries({ queryKey: ['instances'] }) }); const filtered = instances?.filter(
const deleteMut = useMutation({ mutationFn: deleteStack, onSuccess: () => { qc.invalidateQueries({ queryKey: ['instances'] }); setConfirmDelete(null); } }); (i) =>
const rotateMySqlMut = useMutation({ mutationFn: rotateMySql }); i.stackName.toLowerCase().includes(search.toLowerCase()) ||
i.abbreviation.toLowerCase().includes(search.toLowerCase()) ||
i.hostLabel.toLowerCase().includes(search.toLowerCase()),
);
return ( return (
<div> <div className="space-y-6">
<div className="mb-4 flex items-center justify-between"> {/* Header */}
<h2 className="text-xl font-semibold text-white">Live Instances</h2> <div className="flex items-center justify-between">
<button onClick={() => qc.invalidateQueries({ queryKey: ['instances'] })} className="rounded bg-gray-700 px-3 py-1 text-sm text-gray-300 hover:bg-gray-600"> <div className="space-y-1">
Refresh <Breadcrumbs items={[{ label: 'Instances' }]} />
</button> <h1 className="text-2xl font-semibold tracking-tight text-foreground">Live Instances</h1>
</div>
<div className="flex items-center gap-3">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Label htmlFor="instance-search" className="sr-only">Search instances</Label>
<Input
id="instance-search"
placeholder="Search by name, abbrev, or host..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-72 pl-8"
/>
</div>
<Button variant="secondary" size="sm" onClick={() => qc.invalidateQueries({ queryKey: ['instances'] })}>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" /> Refresh
</Button>
<Button size="sm" onClick={() => setShowDeploy(!showDeploy)}>
{showDeploy ? <><X className="mr-1.5 h-3.5 w-3.5" /> Close Deploy</> : <><Plus className="mr-1.5 h-3.5 w-3.5" /> Deploy New</>}
</Button>
</div>
</div> </div>
{isLoading && <p className="text-gray-400">Discovering instances across hosts...</p>} {/* Inline deploy panel */}
{showDeploy && (
<div className="space-y-2"> <div className="rounded-xl border border-border bg-card p-5">
{instances?.map((inst) => ( <DeployInstancePanel onClose={() => setShowDeploy(false)} />
<div key={inst.stackName} className="rounded border border-gray-800 bg-gray-900">
<div
className="flex cursor-pointer items-center justify-between px-4 py-3"
onClick={() => setExpanded(expanded === inst.stackName ? null : inst.stackName)}
>
<div>
<span className="font-medium text-white">{inst.abbreviation}</span>
<span className="ml-2 text-sm text-gray-500">{inst.stackName}</span>
<span className="ml-3 text-xs text-gray-600">{inst.serviceCount} services on {inst.hostLabel}</span>
</div>
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<button onClick={() => restartMut.mutate(inst.stackName)} className="rounded bg-yellow-800 px-2 py-1 text-xs text-yellow-200 hover:bg-yellow-700">
Restart All
</button>
<button onClick={() => rotateMySqlMut.mutate(inst.stackName)} className="rounded bg-gray-700 px-2 py-1 text-xs text-gray-300 hover:bg-gray-600">
Rotate MySQL
</button>
<button onClick={() => setConfirmDelete(inst)} className="rounded bg-red-900 px-2 py-1 text-xs text-red-300 hover:bg-red-800">
Delete
</button>
</div>
</div>
{expanded === inst.stackName && (
<div className="border-t border-gray-800 px-4 py-3">
<div className="mb-2 flex gap-2">
{(['services', 'creds', 'logs'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`rounded px-3 py-1 text-xs ${activeTab === tab ? 'bg-blue-600 text-white' : 'bg-gray-800 text-gray-400'}`}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</button>
))}
</div>
{activeTab === 'services' && <ServiceList stackName={inst.stackName} />}
{activeTab === 'creds' && <CredentialsPanel abbrev={inst.abbreviation} />}
{activeTab === 'logs' && <LogViewer stackName={inst.stackName} />}
</div> </div>
)} )}
{isLoading && <PageLoading message="Discovering instances across hosts..." />}
{isError && <PageError error={error} onRetry={() => refetch()} />}
{!isLoading && !isError && filtered?.length === 0 && (
<EmptyState title="No live instances" description="No running stacks found across configured hosts." />
)}
{/* Card grid */}
{filtered && filtered.length > 0 && (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{filtered.map((inst) => (
<button
key={inst.stackName}
onClick={() => navigate(`/instances/${inst.stackName}`)}
className="group rounded-xl border border-border bg-card p-4 text-left transition-colors hover:border-brand/50 hover:bg-card/80"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2.5">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-brand/10">
<Server className="h-4.5 w-4.5 text-brand" />
</div> </div>
<div>
<p className="font-mono text-sm font-semibold text-foreground">{inst.abbreviation}</p>
<p className="text-xs text-muted-foreground">{inst.stackName}</p>
</div>
</div>
</div>
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
<span>{inst.serviceCount} services</span>
<span>{inst.hostLabel}</span>
</div>
</button>
))} ))}
</div> </div>
{confirmDelete && (
<ConfirmDialog
title="Delete Instance"
message={<>This will permanently remove <strong>{confirmDelete.stackName}</strong> and all associated data.</>}
confirmText="Delete"
requireTyping={confirmDelete.abbreviation}
onConfirm={() => deleteMut.mutate(confirmDelete.stackName)}
onCancel={() => setConfirmDelete(null)}
/>
)} )}
</div> </div>
); );

View File

@@ -1,61 +1,27 @@
import { useState } from 'react'; import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { login, getMe } from '../api/auth';
import { useAuthStore } from '../store/authStore';
export default function LoginPage() { export default function LoginPage() {
const [email, setEmail] = useState(''); // If OIDC is configured, the button redirects to the IdP.
const [password, setPassword] = useState(''); // If not configured, the backend returns 503 and the user must use /admintoken.
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const setUser = useAuthStore((s) => s.setUser);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await login(email, password);
const user = await getMe();
setUser(user);
navigate('/');
} catch {
setError('Invalid email or password');
} finally {
setLoading(false);
}
};
return ( return (
<div className="flex min-h-screen items-center justify-center bg-gray-950"> <div className="flex min-h-screen items-center justify-center bg-gray-950">
<form onSubmit={handleSubmit} className="w-full max-w-sm rounded-lg bg-gray-900 p-8 shadow-xl"> <div className="w-full max-w-sm rounded-lg bg-gray-900 p-8 shadow-xl text-center">
<h1 className="mb-6 text-center text-xl font-bold text-white">OTS Signs Orchestrator</h1> <h1 className="mb-6 text-xl font-bold text-white">OTS Signs Orchestrator</h1>
{error && <p className="mb-4 rounded bg-red-900/50 p-2 text-center text-sm text-red-300">{error}</p>} <a
<label className="mb-1 block text-sm text-gray-400">Email</label> href="/api/auth/oidc/login"
<input className="block w-full rounded bg-blue-600 py-2 text-sm font-medium text-white hover:bg-blue-500"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mb-4 w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
/>
<label className="mb-1 block text-sm text-gray-400">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="mb-6 w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
/>
<button
type="submit"
disabled={loading}
className="w-full rounded bg-blue-600 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-50"
> >
{loading ? 'Signing in...' : 'Sign In'} Sign in with SSO
</button> </a>
</form> <Link
to="/admintoken"
className="mt-4 block text-xs text-gray-500 hover:text-gray-400"
>
Admin token access
</Link>
</div>
</div> </div>
); );
} }

View File

@@ -1,22 +1,86 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { getOperationLogs } from '../api/logs'; import { getOperationLogs } from '../api/logs';
import { getAuditLogs } from '../api/auditLogs';
import { format } from 'date-fns'; import { format } from 'date-fns';
import PageLoading from '../components/shared/PageLoading';
import PageError from '../components/shared/PageError';
import EmptyState from '../components/shared/EmptyState';
import Breadcrumbs from '../components/shared/Breadcrumbs';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
const statusColors: Record<string, string> = { type Tab = 'operations' | 'audit';
Success: 'text-green-400',
Failed: 'text-red-400', const statusDot: Record<string, string> = {
InProgress: 'text-yellow-400', Success: 'bg-status-success',
Pending: 'text-gray-400', Failed: 'bg-status-danger',
InProgress: 'bg-status-warning',
Pending: 'bg-muted-foreground',
};
const statusTextColor: Record<string, string> = {
Success: 'text-status-success',
Failed: 'text-status-danger',
InProgress: 'text-status-warning',
Pending: 'text-muted-foreground',
};
const outcomeDot: Record<string, string> = {
Success: 'bg-status-success',
Failure: 'bg-status-danger',
Warning: 'bg-status-warning',
};
const outcomeTextColor: Record<string, string> = {
Success: 'text-status-success',
Failure: 'text-status-danger',
Warning: 'text-status-warning',
}; };
export default function LogsPage() { export default function LogsPage() {
const [tab, setTab] = useState<Tab>('operations');
return (
<div className="space-y-6">
<Breadcrumbs items={[{ label: 'Home', to: '/' }, { label: 'Logs' }]} />
<h1 className="text-2xl font-semibold tracking-tight text-foreground">Logs</h1>
<div className="flex gap-1 border-b border-border">
{([
{ key: 'operations' as Tab, label: 'Operation Logs' },
{ key: 'audit' as Tab, label: 'Audit Logs' },
]).map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`border-b-2 px-4 py-2 text-sm font-medium transition-colors ${
tab === t.key
? 'border-brand text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
{t.label}
</button>
))}
</div>
{tab === 'operations' && <OperationLogsTab />}
{tab === 'audit' && <AuditLogsTab />}
</div>
);
}
function OperationLogsTab() {
const [stackFilter, setStackFilter] = useState(''); const [stackFilter, setStackFilter] = useState('');
const [operationFilter, setOperationFilter] = useState(''); const [operationFilter, setOperationFilter] = useState('');
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const pageSize = 50; const pageSize = 50;
const { data: logs, isLoading } = useQuery({ const { data: logs, isLoading, isError, error, refetch } = useQuery({
queryKey: ['operation-logs', stackFilter, operationFilter, page], queryKey: ['operation-logs', stackFilter, operationFilter, page],
queryFn: () => queryFn: () =>
getOperationLogs({ getOperationLogs({
@@ -28,66 +92,157 @@ export default function LogsPage() {
}); });
return ( return (
<>
<div className="flex gap-3">
<div> <div>
<h2 className="mb-4 text-xl font-semibold text-white">Operation Logs</h2> <Label htmlFor="log-stack-filter" className="sr-only">Filter by stack name</Label>
<Input id="log-stack-filter" placeholder="Filter by stack name..." value={stackFilter}
<div className="mb-4 flex gap-3"> onChange={(e) => { setStackFilter(e.target.value); setPage(0); }} />
<input placeholder="Filter by stack name..." value={stackFilter} </div>
onChange={(e) => { setStackFilter(e.target.value); setPage(0); }} <div>
className="rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white" /> <Label htmlFor="log-op-filter" className="sr-only">Filter by operation</Label>
<input placeholder="Filter by operation..." value={operationFilter} <Input id="log-op-filter" placeholder="Filter by operation..." value={operationFilter}
onChange={(e) => { setOperationFilter(e.target.value); setPage(0); }} onChange={(e) => { setOperationFilter(e.target.value); setPage(0); }} />
className="rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white" /> </div>
</div> </div>
{isLoading && <p className="text-gray-400">Loading...</p>} {isLoading && <PageLoading />}
{isError && <PageError error={error} onRetry={() => refetch()} />}
<div className="rounded border border-gray-800"> <div className="rounded-xl border border-border">
<table className="w-full text-left text-sm"> <Table>
<thead> <TableHeader>
<tr className="border-b border-gray-800 text-xs text-gray-400"> <TableRow>
<th className="px-3 py-2">Time</th> <TableHead>Time</TableHead>
<th className="px-3 py-2">Operation</th> <TableHead>Operation</TableHead>
<th className="px-3 py-2">Stack</th> <TableHead>Stack</TableHead>
<th className="px-3 py-2">Status</th> <TableHead>Status</TableHead>
<th className="px-3 py-2">Duration</th> <TableHead>Duration</TableHead>
<th className="px-3 py-2">Message</th> <TableHead>Message</TableHead>
</tr> </TableRow>
</thead> </TableHeader>
<tbody> <TableBody>
{logs?.map((log) => ( {logs?.map((log) => (
<tr key={log.id} className="border-b border-gray-800/50"> <TableRow key={log.id}>
<td className="whitespace-nowrap px-3 py-2 text-xs text-gray-400"> <TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{format(new Date(log.timestamp), 'yyyy-MM-dd HH:mm:ss')} {format(new Date(log.timestamp), 'yyyy-MM-dd HH:mm:ss')}
</td> </TableCell>
<td className="px-3 py-2 text-xs text-gray-200">{log.operation}</td> <TableCell className="text-xs text-foreground">{log.operation}</TableCell>
<td className="px-3 py-2 font-mono text-xs text-gray-300">{log.stackName}</td> <TableCell className="font-mono text-xs text-muted-foreground">{log.stackName}</TableCell>
<td className={`px-3 py-2 text-xs font-medium ${statusColors[log.status] ?? 'text-gray-400'}`}> <TableCell>
{log.status} <span className="flex items-center gap-1.5">
</td> <span className={`h-1.5 w-1.5 rounded-full ${statusDot[log.status] ?? 'bg-muted-foreground'}`} />
<td className="px-3 py-2 text-xs text-gray-400"> <span className={`text-xs font-medium ${statusTextColor[log.status] ?? 'text-muted-foreground'}`}>{log.status}</span>
</span>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{log.durationMs != null ? `${(log.durationMs / 1000).toFixed(1)}s` : '-'} {log.durationMs != null ? `${(log.durationMs / 1000).toFixed(1)}s` : '-'}
</td> </TableCell>
<td className="max-w-xs truncate px-3 py-2 text-xs text-gray-400" title={log.message}> <TableCell className="max-w-xs truncate text-xs text-muted-foreground" title={log.message}>
{log.message} {log.message}
</td> </TableCell>
</tr> </TableRow>
))} ))}
</tbody> </TableBody>
</table> </Table>
</div> </div>
<div className="mt-3 flex gap-2"> {!isLoading && !isError && logs?.length === 0 && (
<button onClick={() => setPage(Math.max(0, page - 1))} disabled={page === 0} <EmptyState title="No logs found" description="No operation logs match the current filters." />
className="rounded bg-gray-700 px-3 py-1 text-sm text-gray-300 hover:bg-gray-600 disabled:opacity-40"> )}
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setPage(Math.max(0, page - 1))} disabled={page === 0}>
Previous Previous
</button> </Button>
<span className="px-2 py-1 text-sm text-gray-400">Page {page + 1}</span> <span className="px-2 py-1 text-sm text-muted-foreground">Page {page + 1}</span>
<button onClick={() => setPage(page + 1)} disabled={(logs?.length ?? 0) < pageSize} <Button variant="secondary" size="sm" onClick={() => setPage(page + 1)} disabled={(logs?.length ?? 0) < pageSize}>
className="rounded bg-gray-700 px-3 py-1 text-sm text-gray-300 hover:bg-gray-600 disabled:opacity-40">
Next Next
</button> </Button>
</div>
</div> </div>
</>
);
}
function AuditLogsTab() {
const [actorFilter, setActorFilter] = useState('');
const [actionFilter, setActionFilter] = useState('');
const [page, setPage] = useState(0);
const pageSize = 50;
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: ['audit-logs', actorFilter, actionFilter, page],
queryFn: () => getAuditLogs({
limit: pageSize,
offset: page * pageSize,
actor: actorFilter || undefined,
action: actionFilter || undefined,
}),
});
return (
<>
<div className="flex gap-3">
<div>
<Label htmlFor="audit-actor-filter" className="sr-only">Filter by actor</Label>
<Input id="audit-actor-filter" placeholder="Filter by actor..." value={actorFilter}
onChange={(e) => { setActorFilter(e.target.value); setPage(0); }} />
</div>
<div>
<Label htmlFor="audit-action-filter" className="sr-only">Filter by action</Label>
<Input id="audit-action-filter" placeholder="Filter by action..." value={actionFilter}
onChange={(e) => { setActionFilter(e.target.value); setPage(0); }} />
</div>
</div>
{isLoading && <PageLoading />}
{isError && <PageError error={error} onRetry={() => refetch()} />}
<div className="rounded-xl border border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>Actor</TableHead>
<TableHead>Action</TableHead>
<TableHead>Target</TableHead>
<TableHead>Outcome</TableHead>
<TableHead>Detail</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.logs.map((log) => (
<TableRow key={log.id}>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{format(new Date(log.occurredAt), 'yyyy-MM-dd HH:mm:ss')}
</TableCell>
<TableCell className="text-xs text-foreground">{log.actor}</TableCell>
<TableCell className="text-xs text-muted-foreground">{log.action}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">{log.target}</TableCell>
<TableCell>
<span className="flex items-center gap-1.5">
<span className={`h-1.5 w-1.5 rounded-full ${outcomeDot[log.outcome ?? ''] ?? 'bg-muted-foreground'}`} />
<span className={`text-xs font-medium ${outcomeTextColor[log.outcome ?? ''] ?? 'text-muted-foreground'}`}>{log.outcome || '-'}</span>
</span>
</TableCell>
<TableCell className="max-w-xs truncate text-xs text-muted-foreground" title={log.detail ?? ''}>
{log.detail || '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{!isLoading && !isError && data?.logs.length === 0 && (
<EmptyState title="No audit events" description="No audit log entries match the current filters." />
)}
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => setPage(Math.max(0, page - 1))} disabled={page === 0}>Previous</Button>
<span className="px-2 py-1 text-sm text-muted-foreground">Page {page + 1}{data ? ` of ${Math.ceil(data.total / pageSize)}` : ''}</span>
<Button variant="secondary" size="sm" onClick={() => setPage(page + 1)} disabled={(data?.logs.length ?? 0) < pageSize}>Next</Button>
</div>
</>
); );
} }

View File

@@ -1,166 +1,5 @@
import { useState } from 'react'; // This file is obsolete — Operator management removed in auth overhaul.
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { listOperators, createOperator, updateOperatorRole, resetOperatorPassword, deleteOperator, OperatorDto, CreateOperatorRequest } from '../api/operators';
import { format } from 'date-fns';
import ConfirmDialog from '../components/shared/ConfirmDialog';
export default function OperatorsPage() { export default function OperatorsPage() {
const qc = useQueryClient(); return null;
const [showCreate, setShowCreate] = useState(false);
const [resetTarget, setResetTarget] = useState<OperatorDto | null>(null);
const [newPassword, setNewPassword] = useState('');
const [deleteTarget, setDeleteTarget] = useState<OperatorDto | null>(null);
const { data: operators, isLoading } = useQuery({
queryKey: ['operators'],
queryFn: listOperators,
});
const createMut = useMutation({
mutationFn: (req: CreateOperatorRequest) => createOperator(req),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['operators'] }); setShowCreate(false); },
});
const roleMut = useMutation({
mutationFn: ({ id, role }: { id: string; role: string }) => updateOperatorRole(id, role),
onSuccess: () => qc.invalidateQueries({ queryKey: ['operators'] }),
});
const resetMut = useMutation({
mutationFn: ({ id, password }: { id: string; password: string }) => resetOperatorPassword(id, password),
onSuccess: () => { setResetTarget(null); setNewPassword(''); },
});
const deleteMut = useMutation({
mutationFn: (id: string) => deleteOperator(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['operators'] }); setDeleteTarget(null); },
});
return (
<div>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold text-white">Operators</h2>
<button onClick={() => setShowCreate(true)}
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500">
Add Operator
</button>
</div>
{showCreate && <CreateForm onSubmit={(req) => createMut.mutate(req)} onCancel={() => setShowCreate(false)} error={createMut.error?.message} />}
{isLoading && <p className="text-gray-400">Loading...</p>}
<div className="rounded border border-gray-800">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-800 text-xs text-gray-400">
<th className="px-3 py-2">Email</th>
<th className="px-3 py-2">Role</th>
<th className="px-3 py-2">Created</th>
<th className="px-3 py-2">Actions</th>
</tr>
</thead>
<tbody>
{operators?.map((op) => (
<tr key={op.id} className="border-b border-gray-800/50">
<td className="px-3 py-2 text-sm text-white">{op.email}</td>
<td className="px-3 py-2">
<select value={op.role}
onChange={(e) => roleMut.mutate({ id: op.id, role: e.target.value })}
className="rounded border border-gray-700 bg-gray-800 px-2 py-1 text-xs text-white">
<option value="Admin">Admin</option>
<option value="Viewer">Viewer</option>
</select>
</td>
<td className="px-3 py-2 text-xs text-gray-400">
{format(new Date(op.createdAt), 'yyyy-MM-dd')}
</td>
<td className="flex gap-2 px-3 py-2">
<button onClick={() => setResetTarget(op)}
className="rounded bg-gray-700 px-2 py-1 text-xs text-gray-300 hover:bg-gray-600">
Reset Password
</button>
<button onClick={() => setDeleteTarget(op)}
className="rounded bg-red-900/40 px-2 py-1 text-xs text-red-400 hover:bg-red-900/60">
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Reset password dialog */}
{resetTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div className="w-96 rounded-lg border border-gray-700 bg-gray-900 p-6">
<h3 className="mb-3 text-lg font-medium text-white">Reset Password</h3>
<p className="mb-3 text-sm text-gray-400">Set a new password for <span className="text-white">{resetTarget.email}</span></p>
<input type="password" placeholder="New password" value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="mb-3 w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white" />
<div className="flex justify-end gap-2">
<button onClick={() => { setResetTarget(null); setNewPassword(''); }}
className="rounded bg-gray-700 px-3 py-2 text-sm text-gray-300 hover:bg-gray-600">Cancel</button>
<button onClick={() => resetMut.mutate({ id: resetTarget.id, password: newPassword })}
disabled={!newPassword || resetMut.isPending}
className="rounded bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-500 disabled:opacity-40">
{resetMut.isPending ? 'Resetting...' : 'Reset'}
</button>
</div>
</div>
</div>
)}
{/* Delete confirmation */}
{deleteTarget && (
<ConfirmDialog
title="Delete Operator"
message={`Are you sure you want to delete operator ${deleteTarget.email}? This cannot be undone.`}
confirmText="Delete"
onConfirm={() => deleteMut.mutate(deleteTarget.id)}
onCancel={() => setDeleteTarget(null)}
/>
)}
</div>
);
} }
function CreateForm({ onSubmit, onCancel, error }: { onSubmit: (req: CreateOperatorRequest) => void; onCancel: () => void; error?: string }) {
const [form, setForm] = useState({ email: '', password: '', role: 'Viewer' });
return (
<div className="mb-4 rounded border border-gray-700 bg-gray-900 p-4">
<h3 className="mb-3 text-sm font-medium text-white">New Operator</h3>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="mb-1 block text-xs text-gray-400">Email</label>
<input value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })}
className="w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-sm text-white" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-400">Password</label>
<input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })}
className="w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-sm text-white" />
</div>
<div>
<label className="mb-1 block text-xs text-gray-400">Role</label>
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}
className="w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-sm text-white">
<option value="Admin">Admin</option>
<option value="Viewer">Viewer</option>
</select>
</div>
</div>
{error && <p className="mt-2 text-xs text-red-400">{error}</p>}
<div className="mt-3 flex gap-2">
<button onClick={() => onSubmit(form)}
disabled={!form.email || !form.password}
className="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-500 disabled:opacity-40">Create</button>
<button onClick={onCancel}
className="rounded bg-gray-700 px-3 py-1 text-sm text-gray-300 hover:bg-gray-600">Cancel</button>
</div>
</div>
);
}

View File

@@ -5,14 +5,21 @@ import {
downloadBillingCsv, downloadVersionDriftCsv, downloadBillingCsv, downloadVersionDriftCsv,
downloadFleetHealthPdf, downloadCustomerUsagePdf, exportFleetReport, downloadFleetHealthPdf, downloadCustomerUsagePdf, exportFleetReport,
} from '../api/reports'; } from '../api/reports';
import Breadcrumbs from '../components/shared/Breadcrumbs';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
function todayStr() { return new Date().toISOString().slice(0, 10); } function todayStr() { return new Date().toISOString().slice(0, 10); }
function monthAgoStr() { return new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10); } function monthAgoStr() { return new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10); }
export default function ReportsPage() { export default function ReportsPage() {
return ( return (
<div> <div className="space-y-6">
<h2 className="mb-6 text-xl font-semibold text-white">Reports</h2> <Breadcrumbs items={[{ label: 'Home', to: '/' }, { label: 'Reports' }]} />
<h1 className="text-2xl font-semibold tracking-tight text-foreground">Reports</h1>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<QuickFleetReport /> <QuickFleetReport />
<BillingReport /> <BillingReport />
@@ -26,25 +33,13 @@ export default function ReportsPage() {
function ReportCard({ title, children }: { title: string; children: React.ReactNode }) { function ReportCard({ title, children }: { title: string; children: React.ReactNode }) {
return ( return (
<div className="rounded border border-gray-800 bg-gray-900 p-4"> <div className="rounded-xl border border-border bg-card p-4">
<h3 className="mb-3 text-sm font-semibold text-white">{title}</h3> <h3 className="mb-3 text-sm font-semibold text-foreground">{title}</h3>
{children} {children}
</div> </div>
); );
} }
function DownloadButton({ onClick, loading, label }: { onClick: () => void; loading: boolean; label: string }) {
return (
<button
onClick={onClick}
disabled={loading}
className="rounded bg-blue-600 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-500 disabled:opacity-50"
>
{loading ? 'Downloading...' : label}
</button>
);
}
function QuickFleetReport() { function QuickFleetReport() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -59,9 +54,11 @@ function QuickFleetReport() {
return ( return (
<ReportCard title="Quick Fleet Report (Last 7 Days)"> <ReportCard title="Quick Fleet Report (Last 7 Days)">
<p className="mb-3 text-xs text-gray-400">One-click fleet health PDF covering the last 7 days.</p> <p className="mb-3 text-xs text-muted-foreground">One-click fleet health PDF covering the last 7 days.</p>
<DownloadButton onClick={handleDownload} loading={loading} label="Download PDF" /> <Button onClick={handleDownload} disabled={loading}>
{error && <p className="mt-2 text-xs text-red-400">{error}</p>} {loading ? 'Downloading...' : 'Download PDF'}
</Button>
{error && <p className="mt-2 text-xs text-status-danger">{error}</p>}
</ReportCard> </ReportCard>
); );
} }
@@ -83,8 +80,10 @@ function BillingReport() {
return ( return (
<ReportCard title="Billing Report (CSV)"> <ReportCard title="Billing Report (CSV)">
<DateRangePicker from={from} to={to} onFromChange={setFrom} onToChange={setTo} /> <DateRangePicker from={from} to={to} onFromChange={setFrom} onToChange={setTo} />
<DownloadButton onClick={handleDownload} loading={loading} label="Download CSV" /> <Button onClick={handleDownload} disabled={loading}>
{error && <p className="mt-2 text-xs text-red-400">{error}</p>} {loading ? 'Downloading...' : 'Download CSV'}
</Button>
{error && <p className="mt-2 text-xs text-status-danger">{error}</p>}
</ReportCard> </ReportCard>
); );
} }
@@ -103,9 +102,11 @@ function VersionDriftReport() {
return ( return (
<ReportCard title="Version Drift (CSV)"> <ReportCard title="Version Drift (CSV)">
<p className="mb-3 text-xs text-gray-400">Detect Xibo CMS version mismatches across the fleet.</p> <p className="mb-3 text-xs text-muted-foreground">Detect Xibo CMS version mismatches across the fleet.</p>
<DownloadButton onClick={handleDownload} loading={loading} label="Download CSV" /> <Button onClick={handleDownload} disabled={loading}>
{error && <p className="mt-2 text-xs text-red-400">{error}</p>} {loading ? 'Downloading...' : 'Download CSV'}
</Button>
{error && <p className="mt-2 text-xs text-status-danger">{error}</p>}
</ReportCard> </ReportCard>
); );
} }
@@ -127,8 +128,10 @@ function FleetHealthReport() {
return ( return (
<ReportCard title="Fleet Health Report (PDF)"> <ReportCard title="Fleet Health Report (PDF)">
<DateRangePicker from={from} to={to} onFromChange={setFrom} onToChange={setTo} /> <DateRangePicker from={from} to={to} onFromChange={setFrom} onToChange={setTo} />
<DownloadButton onClick={handleDownload} loading={loading} label="Download PDF" /> <Button onClick={handleDownload} disabled={loading}>
{error && <p className="mt-2 text-xs text-red-400">{error}</p>} {loading ? 'Downloading...' : 'Download PDF'}
</Button>
{error && <p className="mt-2 text-xs text-status-danger">{error}</p>}
</ReportCard> </ReportCard>
); );
} }
@@ -160,7 +163,7 @@ function CustomerUsageReport() {
<select <select
value={customerId} value={customerId}
onChange={(e) => setCustomerId(e.target.value)} onChange={(e) => setCustomerId(e.target.value)}
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white" className="w-full rounded-xl border border-border bg-card px-3 py-2 text-sm text-foreground"
> >
<option value="">Select customer...</option> <option value="">Select customer...</option>
{customers?.map((c) => ( {customers?.map((c) => (
@@ -171,8 +174,10 @@ function CustomerUsageReport() {
</select> </select>
</div> </div>
<DateRangePicker from={from} to={to} onFromChange={setFrom} onToChange={setTo} /> <DateRangePicker from={from} to={to} onFromChange={setFrom} onToChange={setTo} />
<DownloadButton onClick={handleDownload} loading={loading || !customerId} label="Download PDF" /> <Button onClick={handleDownload} disabled={loading || !customerId}>
{error && <p className="mt-2 text-xs text-red-400">{error}</p>} {loading ? 'Downloading...' : 'Download PDF'}
</Button>
{error && <p className="mt-2 text-xs text-status-danger">{error}</p>}
</ReportCard> </ReportCard>
); );
} }
@@ -185,14 +190,12 @@ function DateRangePicker({
return ( return (
<div className="mb-3 flex gap-2"> <div className="mb-3 flex gap-2">
<div className="flex-1"> <div className="flex-1">
<label className="mb-1 block text-xs text-gray-500">From</label> <Label className="mb-1">From</Label>
<input type="date" value={from} onChange={(e) => onFromChange(e.target.value)} <Input type="date" value={from} onChange={(e) => onFromChange(e.target.value)} />
className="w-full rounded border border-gray-700 bg-gray-800 px-2 py-1.5 text-sm text-white" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<label className="mb-1 block text-xs text-gray-500">To</label> <Label className="mb-1">To</Label>
<input type="date" value={to} onChange={(e) => onToChange(e.target.value)} <Input type="date" value={to} onChange={(e) => onToChange(e.target.value)} />
className="w-full rounded border border-gray-700 bg-gray-800 px-2 py-1.5 text-sm text-white" />
</div> </div>
</div> </div>
); );

View File

@@ -2,12 +2,16 @@ import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { listHosts } from '../api/hosts'; import { listHosts } from '../api/hosts';
import { listSecrets } from '../api/secrets'; import { listSecrets } from '../api/secrets';
import PageLoading from '../components/shared/PageLoading';
import PageError from '../components/shared/PageError';
import EmptyState from '../components/shared/EmptyState';
import { Label } from '@/components/ui/label';
export default function SecretsPage() { export default function SecretsPage() {
const { data: hosts } = useQuery({ queryKey: ['hosts'], queryFn: listHosts }); const { data: hosts } = useQuery({ queryKey: ['hosts'], queryFn: listHosts });
const [selectedHostId, setSelectedHostId] = useState<string>(''); const [selectedHostId, setSelectedHostId] = useState<string>('');
const { data: secrets, isLoading } = useQuery({ const { data: secrets, isLoading, isError, error, refetch } = useQuery({
queryKey: ['secrets', selectedHostId], queryKey: ['secrets', selectedHostId],
queryFn: () => listSecrets(selectedHostId), queryFn: () => listSecrets(selectedHostId),
enabled: !!selectedHostId, enabled: !!selectedHostId,
@@ -15,18 +19,24 @@ export default function SecretsPage() {
return ( return (
<div> <div>
<h2 className="mb-4 text-xl font-semibold text-white">Docker Secrets</h2> <h1 className="mb-4 text-xl font-semibold text-foreground">Docker Secrets</h1>
<div className="mb-4"> <div className="mb-4">
<select value={selectedHostId} onChange={(e) => setSelectedHostId(e.target.value)} <Label htmlFor="secrets-host" className="sr-only">Select host</Label>
<select id="secrets-host" value={selectedHostId} onChange={(e) => setSelectedHostId(e.target.value)}
className="rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"> className="rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white">
<option value="">Select host...</option> <option value="">Select host...</option>
{hosts?.map((h) => <option key={h.id} value={h.id}>{h.label}</option>)} {hosts?.map((h) => <option key={h.id} value={h.id}>{h.label}</option>)}
</select> </select>
</div> </div>
{isLoading && <p className="text-gray-400">Loading secrets...</p>} {isLoading && <PageLoading message="Loading secrets..." />}
{isError && <PageError error={error} onRetry={() => refetch()} />}
{secrets && ( {secrets && secrets.length === 0 && (
<EmptyState title="No secrets" description="No Docker secrets found on this host." />
)}
{secrets && secrets.length > 0 && (
<div className="rounded border border-gray-800"> <div className="rounded border border-gray-800">
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm">
<thead> <thead>

View File

@@ -1,8 +1,15 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { getSettings, saveSettings, testBitwarden, testMySql, testAuthentik, SettingGroup, SettingUpdateItem } from '../api/settings'; import { getSettings, saveSettings, testBitwarden, testMySql, testAuthentik, SettingGroup, SettingUpdateItem } from '../api/settings';
import PageLoading from '../components/shared/PageLoading';
import PageError from '../components/shared/PageError';
import Breadcrumbs from '../components/shared/Breadcrumbs';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
const categories = ['Git', 'MySql', 'Smtp', 'Pangolin', 'Nfs', 'Defaults', 'Authentik', 'Xibo', 'Stripe', 'Email', 'Bitwarden', 'Instance']; const categories = ['Git', 'MySql', 'Smtp', 'Pangolin', 'Nfs', 'Defaults', 'Authentik', 'Xibo', 'Stripe', 'Email', 'Bitwarden', 'OIDC'];
function SettingsSection({ function SettingsSection({
group, group,
@@ -17,13 +24,14 @@ function SettingsSection({
<div className="space-y-2"> <div className="space-y-2">
{group.settings.map((s) => ( {group.settings.map((s) => (
<div key={s.key} className="flex items-center gap-3"> <div key={s.key} className="flex items-center gap-3">
<label className="w-60 text-xs text-gray-400 truncate" title={s.key}>{s.key}</label> <Label htmlFor={`setting-${s.key}`} className="w-60 truncate" title={s.key}>{s.key}</Label>
<input <Input
id={`setting-${s.key}`}
type={s.isSensitive ? 'password' : 'text'} type={s.isSensitive ? 'password' : 'text'}
value={edits[s.key] ?? s.value} value={edits[s.key] ?? s.value}
onChange={(e) => onEdit(s.key, e.target.value)} onChange={(e) => onEdit(s.key, e.target.value)}
placeholder={s.isSensitive ? '(unchanged)' : ''} placeholder={s.isSensitive ? '(unchanged)' : ''}
className="flex-1 rounded border border-gray-700 bg-gray-800 px-2 py-1 text-sm text-white" className="flex-1"
/> />
</div> </div>
))} ))}
@@ -33,7 +41,7 @@ function SettingsSection({
export default function SettingsPage() { export default function SettingsPage() {
const qc = useQueryClient(); const qc = useQueryClient();
const { data: groups, isLoading } = useQuery({ queryKey: ['settings'], queryFn: getSettings }); const { data: groups, isLoading, isError, error, refetch } = useQuery({ queryKey: ['settings'], queryFn: getSettings });
const [activeTab, setActiveTab] = useState(categories[0]); const [activeTab, setActiveTab] = useState(categories[0]);
const [edits, setEdits] = useState<Record<string, string>>({}); const [edits, setEdits] = useState<Record<string, string>>({});
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
@@ -42,7 +50,7 @@ export default function SettingsPage() {
const saveMut = useMutation({ const saveMut = useMutation({
mutationFn: (items: SettingUpdateItem[]) => saveSettings(items), mutationFn: (items: SettingUpdateItem[]) => saveSettings(items),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['settings'] }); setEdits({}); }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['settings'] }); setEdits({}); toast.success('Settings saved'); },
}); });
const handleSave = () => { const handleSave = () => {
@@ -69,52 +77,50 @@ export default function SettingsPage() {
const activeGroup = groups?.find((g) => g.category === activeTab); const activeGroup = groups?.find((g) => g.category === activeTab);
return ( return (
<div> <div className="space-y-6">
<h2 className="mb-4 text-xl font-semibold text-white">Settings</h2> <Breadcrumbs items={[{ label: 'Home', to: '/' }, { label: 'Settings' }]} />
<div className="mb-4 flex flex-wrap gap-1"> <h1 className="text-2xl font-semibold tracking-tight text-foreground">Settings</h1>
<div className="flex flex-wrap gap-1 rounded-xl bg-muted/50 p-1">
{categories.map((cat) => ( {categories.map((cat) => (
<button key={cat} onClick={() => { setActiveTab(cat); setTestResult(null); }} <button key={cat} onClick={() => { setActiveTab(cat); setTestResult(null); }}
className={`rounded px-3 py-1 text-sm ${activeTab === cat ? 'bg-blue-600 text-white' : 'bg-gray-800 text-gray-400 hover:bg-gray-700'}`}> className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${activeTab === cat ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}>
{cat} {cat}
</button> </button>
))} ))}
</div> </div>
{isLoading && <p className="text-gray-400">Loading...</p>} {isLoading && <PageLoading />}
{isError && <PageError error={error} onRetry={() => refetch()} />}
{activeGroup && ( {activeGroup && (
<div className="rounded-xl border border-border bg-card p-4">
<SettingsSection <SettingsSection
group={activeGroup} group={activeGroup}
edits={edits} edits={edits}
onEdit={(key, value) => setEdits({ ...edits, [key]: value })} onEdit={(key, value) => setEdits({ ...edits, [key]: value })}
/> />
</div>
)} )}
<div className="mt-4 flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<button onClick={handleSave} disabled={Object.keys(edits).length === 0} <Button onClick={handleSave} disabled={Object.keys(edits).length === 0 || saveMut.isPending}>
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-500 disabled:opacity-40"> {saveMut.isPending ? 'Saving...' : 'Save Changes'}
Save Changes </Button>
</button>
{activeTab === 'MySql' && ( {activeTab === 'MySql' && (
<button onClick={() => runTest(testMySql)} className="rounded bg-gray-700 px-3 py-2 text-sm text-gray-300 hover:bg-gray-600"> <Button variant="secondary" onClick={() => runTest(testMySql)}>Test MySQL</Button>
Test MySQL
</button>
)} )}
{activeTab === 'Authentik' && ( {activeTab === 'Authentik' && (
<button onClick={() => runTest(testAuthentik)} className="rounded bg-gray-700 px-3 py-2 text-sm text-gray-300 hover:bg-gray-600"> <Button variant="secondary" onClick={() => runTest(testAuthentik)}>Test Authentik</Button>
Test Authentik
</button>
)} )}
{activeTab === 'Bitwarden' && ( {activeTab === 'Bitwarden' && (
<button onClick={() => runTest(testBitwarden)} className="rounded bg-gray-700 px-3 py-2 text-sm text-gray-300 hover:bg-gray-600"> <Button variant="secondary" onClick={() => runTest(testBitwarden)}>Test Bitwarden</Button>
Test Bitwarden
</button>
)} )}
</div> </div>
{testResult && ( {testResult && (
<div className={`mt-3 rounded p-2 text-sm ${testResult.success ? 'bg-green-900/30 text-green-300' : 'bg-red-900/30 text-red-300'}`}> <div className={`rounded-xl p-3 text-sm ${testResult.success ? 'bg-status-success/10 text-status-success' : 'bg-status-danger/10 text-status-danger'}`}>
{testResult.message} {testResult.message}
</div> </div>
)} )}

View File

@@ -2,7 +2,7 @@ import { create } from 'zustand';
interface AuthState { interface AuthState {
isAuthenticated: boolean; isAuthenticated: boolean;
user: { id: string; email: string; role: string } | null; user: { id: string; email: string; role: 'SuperAdmin' | 'Admin' | 'Viewer' } | null;
setUser: (user: AuthState['user']) => void; setUser: (user: AuthState['user']) => void;
logout: () => void; logout: () => void;
} }

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/api/auditlogs.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/customers.ts","./src/api/fleet.ts","./src/api/healthevents.ts","./src/api/hosts.ts","./src/api/instances.ts","./src/api/jobs.ts","./src/api/logs.ts","./src/api/operators.ts","./src/api/provision.ts","./src/api/reports.ts","./src/api/secrets.ts","./src/api/settings.ts","./src/components/jobs/jobprogresspanel.tsx","./src/components/layout/appshell.tsx","./src/components/layout/sidebar.tsx","./src/components/shared/confirmdialog.tsx","./src/components/shared/statusbanner.tsx","./src/hooks/usesignalr.ts","./src/pages/auditpage.tsx","./src/pages/createinstancepage.tsx","./src/pages/customerdetailpage.tsx","./src/pages/customerspage.tsx","./src/pages/fleetpage.tsx","./src/pages/healthpage.tsx","./src/pages/hostspage.tsx","./src/pages/instancespage.tsx","./src/pages/loginpage.tsx","./src/pages/logspage.tsx","./src/pages/operatorspage.tsx","./src/pages/reportspage.tsx","./src/pages/secretspage.tsx","./src/pages/settingspage.tsx","./src/store/alertstore.ts","./src/store/authstore.ts","./src/store/jobprogressstore.ts"],"version":"5.9.3"} {"root":["./src/app.tsx","./src/main.tsx","./src/api/auditlogs.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/customers.ts","./src/api/fleet.ts","./src/api/healthevents.ts","./src/api/hosts.ts","./src/api/instances.ts","./src/api/jobs.ts","./src/api/logs.ts","./src/api/operators.ts","./src/api/provision.ts","./src/api/reports.ts","./src/api/secrets.ts","./src/api/settings.ts","./src/components/instances/deployinstancepanel.tsx","./src/components/jobs/jobprogresspanel.tsx","./src/components/layout/appshell.tsx","./src/components/layout/sidebar.tsx","./src/components/shared/confirmdialog.tsx","./src/components/shared/emptystate.tsx","./src/components/shared/errorboundary.tsx","./src/components/shared/pageerror.tsx","./src/components/shared/pageloading.tsx","./src/components/shared/statusbanner.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/tabs.tsx","./src/components/ui/tooltip.tsx","./src/hooks/usesignalr.ts","./src/lib/utils.ts","./src/pages/admintokenpage.tsx","./src/pages/auditpage.tsx","./src/pages/createinstancepage.tsx","./src/pages/customerdetailpage.tsx","./src/pages/customerspage.tsx","./src/pages/fleetpage.tsx","./src/pages/healthpage.tsx","./src/pages/hostspage.tsx","./src/pages/instancespage.tsx","./src/pages/loginpage.tsx","./src/pages/logspage.tsx","./src/pages/operatorspage.tsx","./src/pages/reportspage.tsx","./src/pages/secretspage.tsx","./src/pages/settingspage.tsx","./src/store/alertstore.ts","./src/store/authstore.ts","./src/store/jobprogressstore.ts"],"version":"5.9.3"}

View File

@@ -1,18 +1,3 @@
namespace OTSSignsOrchestrator.Data.Entities; // This file is intentionally empty — Operator entity has been removed.
// Delete this file from the project.
public enum OperatorRole
{
Admin,
Viewer
}
public class Operator
{
public Guid Id { get; set; }
public string Email { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
public OperatorRole Role { get; set; }
public DateTime CreatedAt { get; set; }
public ICollection<RefreshToken> RefreshTokens { get; set; } = [];
}

View File

@@ -1,12 +1,3 @@
namespace OTSSignsOrchestrator.Data.Entities; // This file is intentionally empty — RefreshToken entity has been removed.
// Delete this file from the project.
public class RefreshToken
{
public Guid Id { get; set; }
public Guid OperatorId { get; set; }
public string Token { get; set; } = string.Empty;
public DateTime ExpiresAt { get; set; }
public DateTime? RevokedAt { get; set; }
public Operator Operator { get; set; } = null!;
}

View File

@@ -36,6 +36,14 @@ public class SshHost
public bool UseKeyAuth { get; set; } = true; public bool UseKeyAuth { get; set; } = true;
/// <summary>
/// When true, this host represents the local Docker socket rather than a remote host.
/// Docker commands are executed via Process.Start instead of SSH.
/// NFS operations run directly in the (privileged) container.
/// SSH credentials on this record are only used for MySQL tunnels (if any).
/// </summary>
public bool IsLocal { get; set; } = false;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;

View File

@@ -18,8 +18,6 @@ public class OrchestratorDbContext : DbContext
public DbSet<ScreenSnapshot> ScreenSnapshots => Set<ScreenSnapshot>(); public DbSet<ScreenSnapshot> ScreenSnapshots => Set<ScreenSnapshot>();
public DbSet<OauthAppRegistry> OauthAppRegistries => Set<OauthAppRegistry>(); public DbSet<OauthAppRegistry> OauthAppRegistries => Set<OauthAppRegistry>();
public DbSet<AuthentikMetrics> AuthentikMetrics => Set<AuthentikMetrics>(); public DbSet<AuthentikMetrics> AuthentikMetrics => Set<AuthentikMetrics>();
public DbSet<Operator> Operators => Set<Operator>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<ByoiConfig> ByoiConfigs => Set<ByoiConfig>(); public DbSet<ByoiConfig> ByoiConfigs => Set<ByoiConfig>();
public DbSet<SshHost> SshHosts => Set<SshHost>(); public DbSet<SshHost> SshHosts => Set<SshHost>();
public DbSet<OperationLog> OperationLogs => Set<OperationLog>(); public DbSet<OperationLog> OperationLogs => Set<OperationLog>();
@@ -150,25 +148,6 @@ public class OrchestratorDbContext : DbContext
e.Property(a => a.Status).HasConversion<string>(); e.Property(a => a.Status).HasConversion<string>();
}); });
// ── Operator ────────────────────────────────────────────────────
modelBuilder.Entity<Operator>(e =>
{
e.HasKey(o => o.Id);
e.Property(o => o.Role).HasConversion<string>();
e.HasIndex(o => o.Email).IsUnique();
});
// ── RefreshToken ────────────────────────────────────────────────
modelBuilder.Entity<RefreshToken>(e =>
{
e.HasKey(r => r.Id);
e.HasIndex(r => r.Token).IsUnique();
e.HasIndex(r => r.OperatorId);
e.HasOne(r => r.Operator)
.WithMany(o => o.RefreshTokens)
.HasForeignKey(r => r.OperatorId);
});
// ── ByoiConfig ────────────────────────────────────────────────── // ── ByoiConfig ──────────────────────────────────────────────────
modelBuilder.Entity<ByoiConfig>(e => modelBuilder.Entity<ByoiConfig>(e =>
{ {

View File

@@ -1,5 +1,5 @@
using Renci.SshNet;
using OTSSignsOrchestrator.Data.Entities; using OTSSignsOrchestrator.Data.Entities;
using OTSSignsOrchestrator.Services;
namespace OTSSignsOrchestrator.Health.Checks; namespace OTSSignsOrchestrator.Health.Checks;
@@ -31,76 +31,26 @@ public sealed class MySqlConnectHealthCheck : IHealthCheck
try try
{ {
var settings = _services.GetRequiredService<OTSSignsOrchestrator.Services.SettingsService>(); var settings = _services.GetRequiredService<SettingsService>();
var sshInfo = await GetSwarmSshHostAsync(settings); var mysqlHost = await settings.GetAsync(SettingsService.MySqlHost, "localhost");
var mysqlHost = await settings.GetAsync(OTSSignsOrchestrator.Services.SettingsService.MySqlHost, "localhost"); var mysqlPort = await settings.GetAsync(SettingsService.MySqlPort, "3306");
var mysqlPort = await settings.GetAsync(OTSSignsOrchestrator.Services.SettingsService.MySqlPort, "3306"); var mysqlUser = await settings.GetAsync(SettingsService.MySqlAdminUser, "root");
var mysqlUser = await settings.GetAsync(OTSSignsOrchestrator.Services.SettingsService.MySqlAdminUser, "root"); var mysqlPass = await settings.GetAsync(SettingsService.MySqlAdminPassword, "");
var mysqlPass = await settings.GetAsync(OTSSignsOrchestrator.Services.SettingsService.MySqlAdminPassword, "");
await using var shell = _services.GetRequiredService<SwarmShellService>();
using var sshClient = CreateSshClient(sshInfo);
sshClient.Connect();
try
{
// Simple connectivity test — SELECT 1 against the instance database // Simple connectivity test — SELECT 1 against the instance database
var cmd = $"mysql -h {mysqlHost} -P {mysqlPort} -u {mysqlUser} " + var cmd = $"mysql -h {mysqlHost} -P {mysqlPort} -u {mysqlUser} " +
$"-p'{mysqlPass}' -e 'SELECT 1' {dbName} 2>&1"; $"-p'{mysqlPass}' -e 'SELECT 1' {dbName} 2>&1";
var output = RunSshCommand(sshClient, cmd); await shell.RunCommandAsync(cmd);
return new HealthCheckResult(HealthStatus.Healthy, return new HealthCheckResult(HealthStatus.Healthy,
$"MySQL connection to {dbName} successful"); $"MySQL connection to {dbName} successful");
} }
finally
{
sshClient.Disconnect();
}
}
catch (Exception ex) catch (Exception ex)
{ {
return new HealthCheckResult(HealthStatus.Critical, return new HealthCheckResult(HealthStatus.Critical,
$"MySQL connection failed for {dbName}: {ex.Message}"); $"MySQL connection failed for {dbName}: {ex.Message}");
} }
} }
private static async Task<SshConnectionInfo> GetSwarmSshHostAsync(OTSSignsOrchestrator.Services.SettingsService settings)
{
var host = await settings.GetAsync("Ssh.SwarmHost")
?? throw new InvalidOperationException("SSH Swarm host not configured.");
var portStr = await settings.GetAsync("Ssh.SwarmPort", "22");
var user = await settings.GetAsync("Ssh.SwarmUser", "root");
var keyPath = await settings.GetAsync("Ssh.SwarmKeyPath");
var password = await settings.GetAsync("Ssh.SwarmPassword");
if (!int.TryParse(portStr, out var port)) port = 22;
return new SshConnectionInfo(host, port, user, keyPath, password);
}
private static SshClient CreateSshClient(SshConnectionInfo info)
{
var authMethods = new List<AuthenticationMethod>();
if (!string.IsNullOrEmpty(info.KeyPath))
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(info.KeyPath)));
if (!string.IsNullOrEmpty(info.Password))
authMethods.Add(new PasswordAuthenticationMethod(info.Username, info.Password));
if (authMethods.Count == 0)
{
var defaultKeyPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh", "id_rsa");
if (File.Exists(defaultKeyPath))
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(defaultKeyPath)));
else
throw new InvalidOperationException($"No SSH auth method for {info.Host}:{info.Port}.");
}
var connInfo = new Renci.SshNet.ConnectionInfo(info.Host, info.Port, info.Username, authMethods.ToArray());
return new SshClient(connInfo);
}
private static string RunSshCommand(SshClient client, string command)
{
using var cmd = client.RunCommand(command);
if (cmd.ExitStatus != 0)
throw new InvalidOperationException($"SSH command failed (exit {cmd.ExitStatus}): {cmd.Error}");
return cmd.Result;
}
internal sealed record SshConnectionInfo(string Host, int Port, string Username, string? KeyPath, string? Password);
} }

View File

@@ -1,5 +1,5 @@
using Renci.SshNet;
using OTSSignsOrchestrator.Data.Entities; using OTSSignsOrchestrator.Data.Entities;
using OTSSignsOrchestrator.Services;
namespace OTSSignsOrchestrator.Health.Checks; namespace OTSSignsOrchestrator.Health.Checks;
@@ -30,38 +30,31 @@ public sealed class NfsAccessHealthCheck : IHealthCheck
try try
{ {
var settings = _services.GetRequiredService<OTSSignsOrchestrator.Services.SettingsService>(); var settings = _services.GetRequiredService<SettingsService>();
var sshInfo = await GetSwarmSshHostAsync(settings); var nfsServer = await settings.GetAsync(SettingsService.NfsServer);
var nfsServer = await settings.GetAsync(OTSSignsOrchestrator.Services.SettingsService.NfsServer); var nfsExport = await settings.GetAsync(SettingsService.NfsExport);
var nfsExport = await settings.GetAsync(OTSSignsOrchestrator.Services.SettingsService.NfsExport);
if (string.IsNullOrEmpty(nfsServer) || string.IsNullOrEmpty(nfsExport)) if (string.IsNullOrEmpty(nfsServer) || string.IsNullOrEmpty(nfsExport))
return new HealthCheckResult(HealthStatus.Critical, "NFS server/export not configured"); return new HealthCheckResult(HealthStatus.Critical, "NFS server/export not configured");
using var sshClient = CreateSshClient(sshInfo); await using var shell = _services.GetRequiredService<SwarmShellService>();
sshClient.Connect();
try // Mount temporarily and check the path is listable.
{ // In local (privileged container) mode, sudo is a no-op as root.
// Mount temporarily and check the path is listable
var mountPoint = $"/tmp/healthcheck-nfs-{Guid.NewGuid():N}"; var mountPoint = $"/tmp/healthcheck-nfs-{Guid.NewGuid():N}";
RunSshCommand(sshClient, $"sudo mkdir -p {mountPoint}"); await shell.RunCommandAsync($"sudo mkdir -p {mountPoint}");
try try
{ {
RunSshCommand(sshClient, $"sudo mount -t nfs4 {nfsServer}:{nfsExport} {mountPoint}"); await shell.RunCommandAsync($"sudo mount -t nfs4 {nfsServer}:{nfsExport} {mountPoint}");
var output = RunSshCommand(sshClient, $"ls {mountPoint}/{nfsPath} 2>&1"); await shell.RunCommandAsync($"ls {mountPoint}/{nfsPath} 2>&1");
return new HealthCheckResult(HealthStatus.Healthy, return new HealthCheckResult(HealthStatus.Healthy,
$"NFS path accessible: {nfsPath}"); $"NFS path accessible: {nfsPath}");
} }
finally finally
{ {
RunSshCommandAllowFailure(sshClient, $"sudo umount {mountPoint}"); await shell.RunCommandAllowFailureAsync($"sudo umount {mountPoint}");
RunSshCommandAllowFailure(sshClient, $"sudo rmdir {mountPoint}"); await shell.RunCommandAllowFailureAsync($"sudo rmdir {mountPoint}");
}
}
finally
{
sshClient.Disconnect();
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -70,52 +63,4 @@ public sealed class NfsAccessHealthCheck : IHealthCheck
$"NFS access check failed for {nfsPath}: {ex.Message}"); $"NFS access check failed for {nfsPath}: {ex.Message}");
} }
} }
private static async Task<SshConnectionInfo> GetSwarmSshHostAsync(OTSSignsOrchestrator.Services.SettingsService settings)
{
var host = await settings.GetAsync("Ssh.SwarmHost")
?? throw new InvalidOperationException("SSH Swarm host not configured.");
var portStr = await settings.GetAsync("Ssh.SwarmPort", "22");
var user = await settings.GetAsync("Ssh.SwarmUser", "root");
var keyPath = await settings.GetAsync("Ssh.SwarmKeyPath");
var password = await settings.GetAsync("Ssh.SwarmPassword");
if (!int.TryParse(portStr, out var port)) port = 22;
return new SshConnectionInfo(host, port, user, keyPath, password);
}
private static SshClient CreateSshClient(SshConnectionInfo info)
{
var authMethods = new List<AuthenticationMethod>();
if (!string.IsNullOrEmpty(info.KeyPath))
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(info.KeyPath)));
if (!string.IsNullOrEmpty(info.Password))
authMethods.Add(new PasswordAuthenticationMethod(info.Username, info.Password));
if (authMethods.Count == 0)
{
var defaultKeyPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh", "id_rsa");
if (File.Exists(defaultKeyPath))
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(defaultKeyPath)));
else
throw new InvalidOperationException($"No SSH auth method for {info.Host}:{info.Port}.");
}
var connInfo = new Renci.SshNet.ConnectionInfo(info.Host, info.Port, info.Username, authMethods.ToArray());
return new SshClient(connInfo);
}
private static string RunSshCommand(SshClient client, string command)
{
using var cmd = client.RunCommand(command);
if (cmd.ExitStatus != 0)
throw new InvalidOperationException($"SSH command failed (exit {cmd.ExitStatus}): {cmd.Error}");
return cmd.Result;
}
private static void RunSshCommandAllowFailure(SshClient client, string command)
{
using var cmd = client.RunCommand(command);
// Intentionally ignore exit code — cleanup operations
}
internal sealed record SshConnectionInfo(string Host, int Port, string Username, string? KeyPath, string? Password);
} }

View File

@@ -1,5 +1,5 @@
using Renci.SshNet;
using OTSSignsOrchestrator.Data.Entities; using OTSSignsOrchestrator.Data.Entities;
using OTSSignsOrchestrator.Services;
namespace OTSSignsOrchestrator.Health.Checks; namespace OTSSignsOrchestrator.Health.Checks;
@@ -31,16 +31,10 @@ public sealed class StackHealthCheck : IHealthCheck
try try
{ {
var settings = _services.GetRequiredService<OTSSignsOrchestrator.Services.SettingsService>(); await using var shell = _services.GetRequiredService<SwarmShellService>();
var sshInfo = await GetSwarmSshHostAsync(settings);
using var sshClient = CreateSshClient(sshInfo);
sshClient.Connect();
try
{
// Get task status for all services in the stack // Get task status for all services in the stack
var output = RunSshCommand(sshClient, var output = await shell.RunCommandAsync(
$"docker stack ps {stackName} --format '{{{{.Name}}}}|{{{{.CurrentState}}}}|{{{{.DesiredState}}}}'"); $"docker stack ps {stackName} --format '{{{{.Name}}}}|{{{{.CurrentState}}}}|{{{{.DesiredState}}}}'");
var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries); var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
@@ -71,57 +65,10 @@ public sealed class StackHealthCheck : IHealthCheck
$"{notRunning.Count} service(s) not running in {stackName}", $"{notRunning.Count} service(s) not running in {stackName}",
string.Join("\n", notRunning)); string.Join("\n", notRunning));
} }
finally
{
sshClient.Disconnect();
}
}
catch (Exception ex) catch (Exception ex)
{ {
return new HealthCheckResult(HealthStatus.Critical, return new HealthCheckResult(HealthStatus.Critical,
$"SSH check failed for {stackName}: {ex.Message}"); $"Shell check failed for {stackName}: {ex.Message}");
} }
} }
private static async Task<SshConnectionInfo> GetSwarmSshHostAsync(OTSSignsOrchestrator.Services.SettingsService settings)
{
var host = await settings.GetAsync("Ssh.SwarmHost")
?? throw new InvalidOperationException("SSH Swarm host not configured.");
var portStr = await settings.GetAsync("Ssh.SwarmPort", "22");
var user = await settings.GetAsync("Ssh.SwarmUser", "root");
var keyPath = await settings.GetAsync("Ssh.SwarmKeyPath");
var password = await settings.GetAsync("Ssh.SwarmPassword");
if (!int.TryParse(portStr, out var port)) port = 22;
return new SshConnectionInfo(host, port, user, keyPath, password);
}
private static SshClient CreateSshClient(SshConnectionInfo info)
{
var authMethods = new List<AuthenticationMethod>();
if (!string.IsNullOrEmpty(info.KeyPath))
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(info.KeyPath)));
if (!string.IsNullOrEmpty(info.Password))
authMethods.Add(new PasswordAuthenticationMethod(info.Username, info.Password));
if (authMethods.Count == 0)
{
var defaultKeyPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh", "id_rsa");
if (File.Exists(defaultKeyPath))
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(defaultKeyPath)));
else
throw new InvalidOperationException($"No SSH auth method for {info.Host}:{info.Port}.");
}
var connInfo = new Renci.SshNet.ConnectionInfo(info.Host, info.Port, info.Username, authMethods.ToArray());
return new SshClient(connInfo);
}
private static string RunSshCommand(SshClient client, string command)
{
using var cmd = client.RunCommand(command);
if (cmd.ExitStatus != 0)
throw new InvalidOperationException($"SSH command failed (exit {cmd.ExitStatus}): {cmd.Error}");
return cmd.Result;
}
internal sealed record SshConnectionInfo(string Host, int Port, string Username, string? KeyPath, string? Password);
} }

View File

@@ -9,8 +9,8 @@ namespace OTSSignsOrchestrator.Jobs;
/// <summary> /// <summary>
/// Quartz job with two triggers: /// Quartz job with two triggers:
/// - Weekly (Monday 08:00 UTC): fleet health PDF → operator email list /// - Weekly (Monday 08:00 UTC): fleet health PDF → report recipient list
/// - Monthly (1st of month 08:00 UTC): billing CSV + fleet health PDF → operators; /// - Monthly (1st of month 08:00 UTC): billing CSV + fleet health PDF → recipients;
/// per-customer usage PDF → each active customer's admin email /// per-customer usage PDF → each active customer's admin email
/// </summary> /// </summary>
[DisallowConcurrentExecution] [DisallowConcurrentExecution]
@@ -41,17 +41,17 @@ public sealed class ScheduledReportJob : IJob
var pdfService = scope.ServiceProvider.GetRequiredService<FleetHealthPdfService>(); var pdfService = scope.ServiceProvider.GetRequiredService<FleetHealthPdfService>();
var emailService = scope.ServiceProvider.GetRequiredService<EmailService>(); var emailService = scope.ServiceProvider.GetRequiredService<EmailService>();
var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>(); var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
// Get operator email list (admin operators) // Get report recipients from settings (comma-separated emails)
var operatorEmails = await db.Operators var raw = await settings.GetAsync(SettingsService.ReportRecipients);
.AsNoTracking() var operatorEmails = (raw ?? "")
.Where(o => o.Role == OperatorRole.Admin) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(o => o.Email) .ToList();
.ToListAsync(context.CancellationToken);
if (operatorEmails.Count == 0) if (operatorEmails.Count == 0)
{ {
_logger.LogWarning("No admin operators found — skipping report email dispatch"); _logger.LogWarning("No report recipients configured — set '{Key}' in Settings", SettingsService.ReportRecipients);
return; return;
} }

View File

@@ -0,0 +1,804 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using OTSSignsOrchestrator.Data;
#nullable disable
namespace OTSSignsOrchestrator.Migrations
{
[DbContext(typeof(OrchestratorDbContext))]
[Migration("20260324031132_RemoveOperatorAuth")]
partial class RemoveOperatorAuth
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.AppSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("key");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("category");
b.Property<bool>("IsSensitive")
.HasColumnType("boolean")
.HasColumnName("is_sensitive");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("text")
.HasColumnName("value");
b.HasKey("Key");
b.HasIndex("Category");
b.ToTable("app_settings");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.AuditLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("text")
.HasColumnName("action");
b.Property<string>("Actor")
.IsRequired()
.HasColumnType("text")
.HasColumnName("actor");
b.Property<string>("Detail")
.HasColumnType("text")
.HasColumnName("detail");
b.Property<Guid?>("InstanceId")
.HasColumnType("uuid")
.HasColumnName("instance_id");
b.Property<DateTime>("OccurredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("occurred_at");
b.Property<string>("Outcome")
.HasColumnType("text")
.HasColumnName("outcome");
b.Property<string>("Target")
.IsRequired()
.HasColumnType("text")
.HasColumnName("target");
b.HasKey("Id")
.HasName("pk_audit_logs");
b.HasIndex("InstanceId");
b.HasIndex("OccurredAt");
b.ToTable("audit_logs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.AuthentikMetrics", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime>("CheckedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("checked_at");
b.Property<string>("ErrorMessage")
.HasColumnType("text")
.HasColumnName("error_message");
b.Property<int>("LatencyMs")
.HasColumnType("integer")
.HasColumnName("latency_ms");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.HasKey("Id")
.HasName("pk_authentik_metrics");
b.ToTable("authentik_metrics");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.ByoiConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime>("CertExpiry")
.HasColumnType("timestamp with time zone")
.HasColumnName("cert_expiry");
b.Property<string>("CertPem")
.IsRequired()
.HasColumnType("text")
.HasColumnName("cert_pem");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<bool>("Enabled")
.HasColumnType("boolean")
.HasColumnName("enabled");
b.Property<string>("EntityId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("entity_id");
b.Property<Guid>("InstanceId")
.HasColumnType("uuid")
.HasColumnName("instance_id");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<string>("SsoUrl")
.IsRequired()
.HasColumnType("text")
.HasColumnName("sso_url");
b.HasKey("Id")
.HasName("pk_byoi_configs");
b.HasIndex("InstanceId")
.HasDatabaseName("ix_byoi_configs_instance_id");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("byoi_configs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Customer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Abbreviation")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)")
.HasColumnName("abbreviation");
b.Property<string>("AdminEmail")
.IsRequired()
.HasColumnType("text")
.HasColumnName("admin_email");
b.Property<string>("AdminFirstName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("admin_first_name");
b.Property<string>("AdminLastName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("admin_last_name");
b.Property<string>("CompanyName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("company_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<int>("FailedPaymentCount")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0)
.HasColumnName("failed_payment_count");
b.Property<DateTime?>("FirstPaymentFailedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_payment_failed_at");
b.Property<string>("Plan")
.IsRequired()
.HasColumnType("text")
.HasColumnName("plan");
b.Property<int>("ScreenCount")
.HasColumnType("integer")
.HasColumnName("screen_count");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<string>("StripeCheckoutSessionId")
.HasColumnType("text")
.HasColumnName("stripe_checkout_session_id");
b.Property<string>("StripeCustomerId")
.HasColumnType("text")
.HasColumnName("stripe_customer_id");
b.Property<string>("StripeSubscriptionId")
.HasColumnType("text")
.HasColumnName("stripe_subscription_id");
b.HasKey("Id")
.HasName("pk_customers");
b.HasIndex("Abbreviation")
.IsUnique();
b.HasIndex("StripeCustomerId")
.IsUnique();
b.ToTable("customers");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.HealthEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("CheckName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("check_name");
b.Property<Guid>("InstanceId")
.HasColumnType("uuid")
.HasColumnName("instance_id");
b.Property<string>("Message")
.HasColumnType("text")
.HasColumnName("message");
b.Property<DateTime>("OccurredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("occurred_at");
b.Property<bool>("Remediated")
.HasColumnType("boolean")
.HasColumnName("remediated");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.HasKey("Id")
.HasName("pk_health_events");
b.HasIndex("InstanceId")
.HasDatabaseName("ix_health_events_instance_id");
b.ToTable("health_events");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Instance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("AuthentikProviderId")
.HasColumnType("text")
.HasColumnName("authentik_provider_id");
b.Property<string>("CmsAdminPassRef")
.HasColumnType("text")
.HasColumnName("cms_admin_pass_ref");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid")
.HasColumnName("customer_id");
b.Property<string>("DockerStackName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("docker_stack_name");
b.Property<string>("HealthStatus")
.IsRequired()
.HasColumnType("text")
.HasColumnName("health_status");
b.Property<DateTime?>("LastHealthCheck")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_health_check");
b.Property<string>("MysqlDatabase")
.IsRequired()
.HasColumnType("text")
.HasColumnName("mysql_database");
b.Property<string>("NfsPath")
.IsRequired()
.HasColumnType("text")
.HasColumnName("nfs_path");
b.Property<string>("XiboUrl")
.IsRequired()
.HasColumnType("text")
.HasColumnName("xibo_url");
b.HasKey("Id")
.HasName("pk_instances");
b.HasIndex("CustomerId")
.HasDatabaseName("ix_instances_customer_id");
b.HasIndex("DockerStackName")
.IsUnique();
b.ToTable("instances");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Job", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid")
.HasColumnName("customer_id");
b.Property<string>("ErrorMessage")
.HasColumnType("text")
.HasColumnName("error_message");
b.Property<string>("JobType")
.IsRequired()
.HasColumnType("text")
.HasColumnName("job_type");
b.Property<string>("Parameters")
.HasColumnType("text")
.HasColumnName("parameters");
b.Property<DateTime?>("StartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<string>("TriggeredBy")
.HasColumnType("text")
.HasColumnName("triggered_by");
b.HasKey("Id")
.HasName("pk_jobs");
b.HasIndex("CustomerId")
.HasDatabaseName("ix_jobs_customer_id");
b.ToTable("jobs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.JobStep", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<Guid>("JobId")
.HasColumnType("uuid")
.HasColumnName("job_id");
b.Property<string>("LogOutput")
.HasColumnType("text")
.HasColumnName("log_output");
b.Property<DateTime?>("StartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<string>("StepName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("step_name");
b.HasKey("Id")
.HasName("pk_job_steps");
b.HasIndex("JobId")
.HasDatabaseName("ix_job_steps_job_id");
b.ToTable("job_steps");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.OauthAppRegistry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("InstanceId")
.HasColumnType("uuid")
.HasColumnName("instance_id");
b.HasKey("Id")
.HasName("pk_oauth_app_registries");
b.HasIndex("ClientId")
.IsUnique();
b.HasIndex("InstanceId")
.HasDatabaseName("ix_oauth_app_registries_instance_id");
b.ToTable("oauth_app_registries");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.OperationLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<long?>("DurationMs")
.HasColumnType("bigint")
.HasColumnName("duration_ms");
b.Property<string>("Message")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)")
.HasColumnName("message");
b.Property<string>("Operation")
.IsRequired()
.HasColumnType("text")
.HasColumnName("operation");
b.Property<string>("StackName")
.HasMaxLength(150)
.HasColumnType("character varying(150)")
.HasColumnName("stack_name");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone")
.HasColumnName("timestamp");
b.Property<string>("UserId")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_operation_logs");
b.HasIndex("Operation");
b.HasIndex("StackName");
b.HasIndex("Timestamp");
b.ToTable("operation_logs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.ScreenSnapshot", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("InstanceId")
.HasColumnType("uuid")
.HasColumnName("instance_id");
b.Property<int>("ScreenCount")
.HasColumnType("integer")
.HasColumnName("screen_count");
b.Property<DateOnly>("SnapshotDate")
.HasColumnType("date")
.HasColumnName("snapshot_date");
b.HasKey("Id")
.HasName("pk_screen_snapshots");
b.HasIndex("InstanceId")
.HasDatabaseName("ix_screen_snapshots_instance_id");
b.ToTable("screen_snapshots");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.SshHost", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Host")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("host");
b.Property<string>("KeyPassphrase")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)")
.HasColumnName("key_passphrase");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("label");
b.Property<bool?>("LastTestSuccess")
.HasColumnType("boolean")
.HasColumnName("last_test_success");
b.Property<DateTime?>("LastTestedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_tested_at");
b.Property<string>("Password")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)")
.HasColumnName("password");
b.Property<int>("Port")
.HasColumnType("integer")
.HasColumnName("port");
b.Property<string>("PrivateKeyPath")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)")
.HasColumnName("private_key_path");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<bool>("UseKeyAuth")
.HasColumnType("boolean")
.HasColumnName("use_key_auth");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("username");
b.HasKey("Id")
.HasName("pk_ssh_hosts");
b.HasIndex("Label")
.IsUnique();
b.ToTable("ssh_hosts");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.StripeEvent", b =>
{
b.Property<string>("StripeEventId")
.HasColumnType("text")
.HasColumnName("stripe_event_id");
b.Property<string>("EventType")
.IsRequired()
.HasColumnType("text")
.HasColumnName("event_type");
b.Property<string>("Payload")
.HasColumnType("text")
.HasColumnName("payload");
b.Property<DateTime>("ProcessedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("processed_at");
b.HasKey("StripeEventId")
.HasName("pk_stripe_events");
b.ToTable("stripe_events");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.ByoiConfig", b =>
{
b.HasOne("OTSSignsOrchestrator.Data.Entities.Instance", "Instance")
.WithMany("ByoiConfigs")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_byoi_configs__instances_instance_id");
b.Navigation("Instance");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.HealthEvent", b =>
{
b.HasOne("OTSSignsOrchestrator.Data.Entities.Instance", "Instance")
.WithMany("HealthEvents")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_health_events__instances_instance_id");
b.Navigation("Instance");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Instance", b =>
{
b.HasOne("OTSSignsOrchestrator.Data.Entities.Customer", "Customer")
.WithMany("Instances")
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_instances_customers_customer_id");
b.Navigation("Customer");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Job", b =>
{
b.HasOne("OTSSignsOrchestrator.Data.Entities.Customer", "Customer")
.WithMany("Jobs")
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_jobs_customers_customer_id");
b.Navigation("Customer");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.JobStep", b =>
{
b.HasOne("OTSSignsOrchestrator.Data.Entities.Job", "Job")
.WithMany("Steps")
.HasForeignKey("JobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_job_steps_jobs_job_id");
b.Navigation("Job");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.OauthAppRegistry", b =>
{
b.HasOne("OTSSignsOrchestrator.Data.Entities.Instance", "Instance")
.WithMany("OauthAppRegistries")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_oauth_app_registries_instances_instance_id");
b.Navigation("Instance");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.ScreenSnapshot", b =>
{
b.HasOne("OTSSignsOrchestrator.Data.Entities.Instance", "Instance")
.WithMany("ScreenSnapshots")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_screen_snapshots_instances_instance_id");
b.Navigation("Instance");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Customer", b =>
{
b.Navigation("Instances");
b.Navigation("Jobs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Instance", b =>
{
b.Navigation("ByoiConfigs");
b.Navigation("HealthEvents");
b.Navigation("OauthAppRegistries");
b.Navigation("ScreenSnapshots");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Job", b =>
{
b.Navigation("Steps");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,78 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OTSSignsOrchestrator.Migrations
{
/// <inheritdoc />
public partial class RemoveOperatorAuth : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "refresh_tokens");
migrationBuilder.DropTable(
name: "operators");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "operators",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
email = table.Column<string>(type: "text", nullable: false),
password_hash = table.Column<string>(type: "text", nullable: false),
role = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_operators", x => x.id);
});
migrationBuilder.CreateTable(
name: "refresh_tokens",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
operator_id = table.Column<Guid>(type: "uuid", nullable: false),
expires_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
revoked_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
token = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_refresh_tokens", x => x.id);
table.ForeignKey(
name: "fk_refresh_tokens_operators_operator_id",
column: x => x.operator_id,
principalTable: "operators",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_operators_email",
table: "operators",
column: "email",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_refresh_tokens_operator_id",
table: "refresh_tokens",
column: "operator_id");
migrationBuilder.CreateIndex(
name: "IX_refresh_tokens_token",
table: "refresh_tokens",
column: "token",
unique: true);
}
}
}

View File

@@ -0,0 +1,808 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using OTSSignsOrchestrator.Data;
#nullable disable
namespace OTSSignsOrchestrator.Migrations
{
[DbContext(typeof(OrchestratorDbContext))]
[Migration("20260324120358_AddSshHostIsLocal")]
partial class AddSshHostIsLocal
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.AppSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("key");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("category");
b.Property<bool>("IsSensitive")
.HasColumnType("boolean")
.HasColumnName("is_sensitive");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("text")
.HasColumnName("value");
b.HasKey("Key");
b.HasIndex("Category");
b.ToTable("app_settings");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.AuditLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("text")
.HasColumnName("action");
b.Property<string>("Actor")
.IsRequired()
.HasColumnType("text")
.HasColumnName("actor");
b.Property<string>("Detail")
.HasColumnType("text")
.HasColumnName("detail");
b.Property<Guid?>("InstanceId")
.HasColumnType("uuid")
.HasColumnName("instance_id");
b.Property<DateTime>("OccurredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("occurred_at");
b.Property<string>("Outcome")
.HasColumnType("text")
.HasColumnName("outcome");
b.Property<string>("Target")
.IsRequired()
.HasColumnType("text")
.HasColumnName("target");
b.HasKey("Id")
.HasName("pk_audit_logs");
b.HasIndex("InstanceId");
b.HasIndex("OccurredAt");
b.ToTable("audit_logs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.AuthentikMetrics", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime>("CheckedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("checked_at");
b.Property<string>("ErrorMessage")
.HasColumnType("text")
.HasColumnName("error_message");
b.Property<int>("LatencyMs")
.HasColumnType("integer")
.HasColumnName("latency_ms");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.HasKey("Id")
.HasName("pk_authentik_metrics");
b.ToTable("authentik_metrics");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.ByoiConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime>("CertExpiry")
.HasColumnType("timestamp with time zone")
.HasColumnName("cert_expiry");
b.Property<string>("CertPem")
.IsRequired()
.HasColumnType("text")
.HasColumnName("cert_pem");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<bool>("Enabled")
.HasColumnType("boolean")
.HasColumnName("enabled");
b.Property<string>("EntityId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("entity_id");
b.Property<Guid>("InstanceId")
.HasColumnType("uuid")
.HasColumnName("instance_id");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<string>("SsoUrl")
.IsRequired()
.HasColumnType("text")
.HasColumnName("sso_url");
b.HasKey("Id")
.HasName("pk_byoi_configs");
b.HasIndex("InstanceId")
.HasDatabaseName("ix_byoi_configs_instance_id");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("byoi_configs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Customer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Abbreviation")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)")
.HasColumnName("abbreviation");
b.Property<string>("AdminEmail")
.IsRequired()
.HasColumnType("text")
.HasColumnName("admin_email");
b.Property<string>("AdminFirstName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("admin_first_name");
b.Property<string>("AdminLastName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("admin_last_name");
b.Property<string>("CompanyName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("company_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<int>("FailedPaymentCount")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0)
.HasColumnName("failed_payment_count");
b.Property<DateTime?>("FirstPaymentFailedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_payment_failed_at");
b.Property<string>("Plan")
.IsRequired()
.HasColumnType("text")
.HasColumnName("plan");
b.Property<int>("ScreenCount")
.HasColumnType("integer")
.HasColumnName("screen_count");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<string>("StripeCheckoutSessionId")
.HasColumnType("text")
.HasColumnName("stripe_checkout_session_id");
b.Property<string>("StripeCustomerId")
.HasColumnType("text")
.HasColumnName("stripe_customer_id");
b.Property<string>("StripeSubscriptionId")
.HasColumnType("text")
.HasColumnName("stripe_subscription_id");
b.HasKey("Id")
.HasName("pk_customers");
b.HasIndex("Abbreviation")
.IsUnique();
b.HasIndex("StripeCustomerId")
.IsUnique();
b.ToTable("customers");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.HealthEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("CheckName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("check_name");
b.Property<Guid>("InstanceId")
.HasColumnType("uuid")
.HasColumnName("instance_id");
b.Property<string>("Message")
.HasColumnType("text")
.HasColumnName("message");
b.Property<DateTime>("OccurredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("occurred_at");
b.Property<bool>("Remediated")
.HasColumnType("boolean")
.HasColumnName("remediated");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.HasKey("Id")
.HasName("pk_health_events");
b.HasIndex("InstanceId")
.HasDatabaseName("ix_health_events_instance_id");
b.ToTable("health_events");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Instance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("AuthentikProviderId")
.HasColumnType("text")
.HasColumnName("authentik_provider_id");
b.Property<string>("CmsAdminPassRef")
.HasColumnType("text")
.HasColumnName("cms_admin_pass_ref");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid")
.HasColumnName("customer_id");
b.Property<string>("DockerStackName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("docker_stack_name");
b.Property<string>("HealthStatus")
.IsRequired()
.HasColumnType("text")
.HasColumnName("health_status");
b.Property<DateTime?>("LastHealthCheck")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_health_check");
b.Property<string>("MysqlDatabase")
.IsRequired()
.HasColumnType("text")
.HasColumnName("mysql_database");
b.Property<string>("NfsPath")
.IsRequired()
.HasColumnType("text")
.HasColumnName("nfs_path");
b.Property<string>("XiboUrl")
.IsRequired()
.HasColumnType("text")
.HasColumnName("xibo_url");
b.HasKey("Id")
.HasName("pk_instances");
b.HasIndex("CustomerId")
.HasDatabaseName("ix_instances_customer_id");
b.HasIndex("DockerStackName")
.IsUnique();
b.ToTable("instances");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Job", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid")
.HasColumnName("customer_id");
b.Property<string>("ErrorMessage")
.HasColumnType("text")
.HasColumnName("error_message");
b.Property<string>("JobType")
.IsRequired()
.HasColumnType("text")
.HasColumnName("job_type");
b.Property<string>("Parameters")
.HasColumnType("text")
.HasColumnName("parameters");
b.Property<DateTime?>("StartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<string>("TriggeredBy")
.HasColumnType("text")
.HasColumnName("triggered_by");
b.HasKey("Id")
.HasName("pk_jobs");
b.HasIndex("CustomerId")
.HasDatabaseName("ix_jobs_customer_id");
b.ToTable("jobs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.JobStep", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<Guid>("JobId")
.HasColumnType("uuid")
.HasColumnName("job_id");
b.Property<string>("LogOutput")
.HasColumnType("text")
.HasColumnName("log_output");
b.Property<DateTime?>("StartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<string>("StepName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("step_name");
b.HasKey("Id")
.HasName("pk_job_steps");
b.HasIndex("JobId")
.HasDatabaseName("ix_job_steps_job_id");
b.ToTable("job_steps");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.OauthAppRegistry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("InstanceId")
.HasColumnType("uuid")
.HasColumnName("instance_id");
b.HasKey("Id")
.HasName("pk_oauth_app_registries");
b.HasIndex("ClientId")
.IsUnique();
b.HasIndex("InstanceId")
.HasDatabaseName("ix_oauth_app_registries_instance_id");
b.ToTable("oauth_app_registries");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.OperationLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<long?>("DurationMs")
.HasColumnType("bigint")
.HasColumnName("duration_ms");
b.Property<string>("Message")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)")
.HasColumnName("message");
b.Property<string>("Operation")
.IsRequired()
.HasColumnType("text")
.HasColumnName("operation");
b.Property<string>("StackName")
.HasMaxLength(150)
.HasColumnType("character varying(150)")
.HasColumnName("stack_name");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<DateTime>("Timestamp")
.HasColumnType("timestamp with time zone")
.HasColumnName("timestamp");
b.Property<string>("UserId")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_operation_logs");
b.HasIndex("Operation");
b.HasIndex("StackName");
b.HasIndex("Timestamp");
b.ToTable("operation_logs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.ScreenSnapshot", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("InstanceId")
.HasColumnType("uuid")
.HasColumnName("instance_id");
b.Property<int>("ScreenCount")
.HasColumnType("integer")
.HasColumnName("screen_count");
b.Property<DateOnly>("SnapshotDate")
.HasColumnType("date")
.HasColumnName("snapshot_date");
b.HasKey("Id")
.HasName("pk_screen_snapshots");
b.HasIndex("InstanceId")
.HasDatabaseName("ix_screen_snapshots_instance_id");
b.ToTable("screen_snapshots");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.SshHost", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Host")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("host");
b.Property<bool>("IsLocal")
.HasColumnType("boolean")
.HasColumnName("is_local");
b.Property<string>("KeyPassphrase")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)")
.HasColumnName("key_passphrase");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("label");
b.Property<bool?>("LastTestSuccess")
.HasColumnType("boolean")
.HasColumnName("last_test_success");
b.Property<DateTime?>("LastTestedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_tested_at");
b.Property<string>("Password")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)")
.HasColumnName("password");
b.Property<int>("Port")
.HasColumnType("integer")
.HasColumnName("port");
b.Property<string>("PrivateKeyPath")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)")
.HasColumnName("private_key_path");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<bool>("UseKeyAuth")
.HasColumnType("boolean")
.HasColumnName("use_key_auth");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("username");
b.HasKey("Id")
.HasName("pk_ssh_hosts");
b.HasIndex("Label")
.IsUnique();
b.ToTable("ssh_hosts");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.StripeEvent", b =>
{
b.Property<string>("StripeEventId")
.HasColumnType("text")
.HasColumnName("stripe_event_id");
b.Property<string>("EventType")
.IsRequired()
.HasColumnType("text")
.HasColumnName("event_type");
b.Property<string>("Payload")
.HasColumnType("text")
.HasColumnName("payload");
b.Property<DateTime>("ProcessedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("processed_at");
b.HasKey("StripeEventId")
.HasName("pk_stripe_events");
b.ToTable("stripe_events");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.ByoiConfig", b =>
{
b.HasOne("OTSSignsOrchestrator.Data.Entities.Instance", "Instance")
.WithMany("ByoiConfigs")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_byoi_configs__instances_instance_id");
b.Navigation("Instance");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.HealthEvent", b =>
{
b.HasOne("OTSSignsOrchestrator.Data.Entities.Instance", "Instance")
.WithMany("HealthEvents")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_health_events__instances_instance_id");
b.Navigation("Instance");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Instance", b =>
{
b.HasOne("OTSSignsOrchestrator.Data.Entities.Customer", "Customer")
.WithMany("Instances")
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_instances_customers_customer_id");
b.Navigation("Customer");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Job", b =>
{
b.HasOne("OTSSignsOrchestrator.Data.Entities.Customer", "Customer")
.WithMany("Jobs")
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_jobs_customers_customer_id");
b.Navigation("Customer");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.JobStep", b =>
{
b.HasOne("OTSSignsOrchestrator.Data.Entities.Job", "Job")
.WithMany("Steps")
.HasForeignKey("JobId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_job_steps_jobs_job_id");
b.Navigation("Job");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.OauthAppRegistry", b =>
{
b.HasOne("OTSSignsOrchestrator.Data.Entities.Instance", "Instance")
.WithMany("OauthAppRegistries")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_oauth_app_registries_instances_instance_id");
b.Navigation("Instance");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.ScreenSnapshot", b =>
{
b.HasOne("OTSSignsOrchestrator.Data.Entities.Instance", "Instance")
.WithMany("ScreenSnapshots")
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_screen_snapshots_instances_instance_id");
b.Navigation("Instance");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Customer", b =>
{
b.Navigation("Instances");
b.Navigation("Jobs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Instance", b =>
{
b.Navigation("ByoiConfigs");
b.Navigation("HealthEvents");
b.Navigation("OauthAppRegistries");
b.Navigation("ScreenSnapshots");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Job", b =>
{
b.Navigation("Steps");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OTSSignsOrchestrator.Migrations
{
/// <inheritdoc />
public partial class AddSshHostIsLocal : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "is_local",
table: "ssh_hosts",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "is_local",
table: "ssh_hosts");
}
}
}

View File

@@ -559,77 +559,6 @@ namespace OTSSignsOrchestrator.Migrations
b.ToTable("operation_logs"); b.ToTable("operation_logs");
}); });
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Operator", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text")
.HasColumnName("email");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text")
.HasColumnName("password_hash");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text")
.HasColumnName("role");
b.HasKey("Id")
.HasName("pk_operators");
b.HasIndex("Email")
.IsUnique();
b.ToTable("operators");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<Guid>("OperatorId")
.HasColumnType("uuid")
.HasColumnName("operator_id");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("revoked_at");
b.Property<string>("Token")
.IsRequired()
.HasColumnType("text")
.HasColumnName("token");
b.HasKey("Id")
.HasName("pk_refresh_tokens");
b.HasIndex("OperatorId")
.HasDatabaseName("ix_refresh_tokens_operator_id");
b.HasIndex("Token")
.IsUnique();
b.ToTable("refresh_tokens");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.ScreenSnapshot", b => modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.ScreenSnapshot", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -679,6 +608,10 @@ namespace OTSSignsOrchestrator.Migrations
.HasColumnType("character varying(500)") .HasColumnType("character varying(500)")
.HasColumnName("host"); .HasColumnName("host");
b.Property<bool>("IsLocal")
.HasColumnType("boolean")
.HasColumnName("is_local");
b.Property<string>("KeyPassphrase") b.Property<string>("KeyPassphrase")
.HasMaxLength(2000) .HasMaxLength(2000)
.HasColumnType("character varying(2000)") .HasColumnType("character varying(2000)")
@@ -832,18 +765,6 @@ namespace OTSSignsOrchestrator.Migrations
b.Navigation("Instance"); b.Navigation("Instance");
}); });
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.RefreshToken", b =>
{
b.HasOne("OTSSignsOrchestrator.Data.Entities.Operator", "Operator")
.WithMany("RefreshTokens")
.HasForeignKey("OperatorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_refresh_tokens_operators_operator_id");
b.Navigation("Operator");
});
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.ScreenSnapshot", b => modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.ScreenSnapshot", b =>
{ {
b.HasOne("OTSSignsOrchestrator.Data.Entities.Instance", "Instance") b.HasOne("OTSSignsOrchestrator.Data.Entities.Instance", "Instance")
@@ -878,11 +799,6 @@ namespace OTSSignsOrchestrator.Migrations
{ {
b.Navigation("Steps"); b.Navigation("Steps");
}); });
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Operator", b =>
{
b.Navigation("RefreshTokens");
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }

View File

@@ -18,6 +18,7 @@
<PackageReference Include="CsvHelper" Version="33.1.0" /> <PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="LibGit2Sharp" Version="0.31.0" /> <PackageReference Include="LibGit2Sharp" Version="0.31.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.2" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="9.0.2" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="9.0.2" /> <PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="9.0.2" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" /> <PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.2"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.2">

View File

@@ -2,6 +2,7 @@ using System.Security.Claims;
using System.Text; using System.Text;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
@@ -23,6 +24,19 @@ using OTSSignsOrchestrator.Health.Checks;
using Serilog; using Serilog;
using Stripe; using Stripe;
// ── CLI: reset-admin-token ────────────────────────────────────────────────────
// Usage: docker exec <container> /app/OTSSignsOrchestrator reset-admin-token
if (args.Length > 0 && args[0] == "reset-admin-token")
{
var cliBuilder = WebApplication.CreateBuilder(Array.Empty<string>());
cliBuilder.Services.AddDbContext<OrchestratorDbContext>(options =>
options.UseNpgsql(cliBuilder.Configuration.GetConnectionString("OrchestratorDb")));
var cliApp = cliBuilder.Build();
var adminTokenSvc = new AdminTokenService();
await adminTokenSvc.ResetAsync(cliApp.Services);
return;
}
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// ── Serilog ────────────────────────────────────────────────────────────────── // ── Serilog ──────────────────────────────────────────────────────────────────
@@ -31,7 +45,9 @@ builder.Host.UseSerilog((context, config) =>
// ── EF Core — PostgreSQL ───────────────────────────────────────────────────── // ── EF Core — PostgreSQL ─────────────────────────────────────────────────────
builder.Services.AddDbContext<OrchestratorDbContext>(options => builder.Services.AddDbContext<OrchestratorDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("OrchestratorDb"))); options.UseNpgsql(
builder.Configuration.GetConnectionString("OrchestratorDb"),
npgsql => npgsql.EnableRetryOnFailure(maxRetryCount: 10, maxRetryDelay: TimeSpan.FromSeconds(10), errorCodesToAdd: null)));
// ── JWT Authentication ────────────────────────────────────────────────────── // ── JWT Authentication ──────────────────────────────────────────────────────
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection(JwtOptions.Section)); builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection(JwtOptions.Section));
@@ -71,6 +87,67 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
return Task.CompletedTask; return Task.CompletedTask;
}, },
}; };
})
.AddCookie("oidc-cookie", o =>
{
o.Cookie.SameSite = SameSiteMode.None;
o.Cookie.SecurePolicy = CookieSecurePolicy.Always;
})
.AddOpenIdConnect("oidc", options =>
{
// OIDC settings are loaded from the database at startup.
// Placeholder values here prevent OpenIdConnectOptions.Validate() from throwing on
// every request (the OIDC handler is an IAuthenticationRequestHandler and runs for
// all requests). Real values are injected in the post-build startup block below.
options.Authority = "https://placeholder.invalid";
options.ClientId = "placeholder";
options.SignInScheme = "oidc-cookie";
options.CallbackPath = "/api/auth/oidc/callback";
options.ResponseType = "code";
options.SaveTokens = false;
options.GetClaimsFromUserInfoEndpoint = true;
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
options.Events = new Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectEvents
{
OnTokenValidated = async context =>
{
// Map the OIDC identity to a local JWT cookie
var auth = context.HttpContext.RequestServices.GetRequiredService<OperatorAuthService>();
var settingsSvc = context.HttpContext.RequestServices.GetRequiredService<SettingsService>();
var email = context.Principal?.FindFirstValue(ClaimTypes.Email)
?? context.Principal?.FindFirstValue("email")
?? "unknown";
var roleClaim = await settingsSvc.GetAsync(SettingsService.OidcRoleClaim, "groups");
var adminVal = await settingsSvc.GetAsync(SettingsService.OidcAdminValue, "admin");
var viewerVal = await settingsSvc.GetAsync(SettingsService.OidcViewerValue, "viewer");
var claimValues = context.Principal?.Claims
.Where(c => c.Type == roleClaim)
.Select(c => c.Value)
.ToList() ?? [];
var role = claimValues.Any(v => string.Equals(v, adminVal, StringComparison.OrdinalIgnoreCase))
? "Admin"
: "Viewer";
var jwt = auth.GenerateJwt(email, role);
context.HttpContext.Response.Cookies.Append("ots_access_token", jwt, new CookieOptions
{
HttpOnly = true, Secure = context.HttpContext.Request.IsHttps, SameSite = SameSiteMode.Strict,
Path = "/", MaxAge = TimeSpan.FromMinutes(30),
});
// Skip the default cookie sign-in — we use our own JWT cookie
context.HandleResponse();
context.Response.Redirect(context.Properties?.RedirectUri ?? "/");
},
};
}); });
builder.Services.AddAuthorization(options => builder.Services.AddAuthorization(options =>
@@ -83,6 +160,7 @@ builder.Services.AddAuthorization(options =>
builder.Services.AddDataProtection(); builder.Services.AddDataProtection();
// ── Application services ──────────────────────────────────────────────────── // ── Application services ────────────────────────────────────────────────────
builder.Services.AddSingleton<AdminTokenService>();
builder.Services.AddScoped<OperatorAuthService>(); builder.Services.AddScoped<OperatorAuthService>();
builder.Services.AddScoped<AbbreviationService>(); builder.Services.AddScoped<AbbreviationService>();
builder.Services.AddTransient<EmailService>(); builder.Services.AddTransient<EmailService>();
@@ -120,6 +198,10 @@ builder.Services.AddScoped<SshDockerCliService>();
builder.Services.AddScoped<IDockerCliService>(sp => sp.GetRequiredService<SshDockerCliService>()); builder.Services.AddScoped<IDockerCliService>(sp => sp.GetRequiredService<SshDockerCliService>());
builder.Services.AddScoped<SshDockerSecretsService>(); builder.Services.AddScoped<SshDockerSecretsService>();
builder.Services.AddScoped<IDockerSecretsService>(sp => sp.GetRequiredService<SshDockerSecretsService>()); builder.Services.AddScoped<IDockerSecretsService>(sp => sp.GetRequiredService<SshDockerSecretsService>());
builder.Services.AddScoped<LocalDockerCliService>();
builder.Services.AddScoped<LocalDockerSecretsService>();
builder.Services.AddScoped<IDockerServiceFactory, DockerServiceFactory>();
builder.Services.AddScoped<SwarmShellService>();
builder.Services.AddTransient<InstanceService>(); builder.Services.AddTransient<InstanceService>();
// ── External API clients ──────────────────────────────────────────────────── // ── External API clients ────────────────────────────────────────────────────
@@ -233,18 +315,86 @@ builder.Services.AddRateLimiter(options =>
var app = builder.Build(); var app = builder.Build();
// ── Apply pending migrations + load runtime settings from DB ───────────────── // ── Apply pending migrations + seed env vars + load runtime settings ─────────
using (var initScope = app.Services.CreateScope()) using (var initScope = app.Services.CreateScope())
{ {
var db = initScope.ServiceProvider.GetRequiredService<OrchestratorDbContext>(); var db = initScope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
var logger = initScope.ServiceProvider.GetRequiredService<ILogger<OrchestratorDbContext>>();
for (var attempt = 1; attempt <= 12; attempt++)
{
try
{
await db.Database.MigrateAsync(); await db.Database.MigrateAsync();
break;
}
catch (Exception ex) when (attempt < 12)
{
logger.LogWarning("Database not ready (attempt {Attempt}/12): {Message}. Retrying in 5s...", attempt, ex.Message);
await Task.Delay(TimeSpan.FromSeconds(5));
}
}
var settings = initScope.ServiceProvider.GetRequiredService<SettingsService>(); var settings = initScope.ServiceProvider.GetRequiredService<SettingsService>();
// Seed integration settings from environment variables on first run.
// Once values exist in the database, env vars are ignored — manage via the admin UI.
var envSeedMap = new (string EnvKey, string SettingsKey, string Category, bool Sensitive)[]
{
("Bitwarden:AccessToken", SettingsService.BitwardenAccessToken, SettingsService.CatBitwarden, true),
("Bitwarden:OrganizationId", SettingsService.BitwardenOrganizationId, SettingsService.CatBitwarden, false),
("Bitwarden:ProjectId", SettingsService.BitwardenProjectId, SettingsService.CatBitwarden, false),
("Stripe:SecretKey", SettingsService.StripeSecretKey, SettingsService.CatStripe, true),
("Stripe:WebhookSecret", SettingsService.StripeWebhookSecret, SettingsService.CatStripe, true),
("Authentik:BaseUrl", SettingsService.AuthentikUrl, SettingsService.CatAuthentik, false),
("Authentik:ApiToken", SettingsService.AuthentikApiKey, SettingsService.CatAuthentik, true),
("Authentik:OtsSigningKpId", SettingsService.AuthentikOtsSigningKpId, SettingsService.CatAuthentik, false),
("Email:SendGridApiKey", SettingsService.EmailSendGridApiKey, SettingsService.CatEmail, true),
};
var seeded = 0;
foreach (var (envKey, settingsKey, category, sensitive) in envSeedMap)
{
var envValue = app.Configuration[envKey];
if (string.IsNullOrWhiteSpace(envValue)) continue;
var existing = await settings.GetAsync(settingsKey);
if (!string.IsNullOrWhiteSpace(existing)) continue;
await settings.SetAsync(settingsKey, envValue, category, sensitive);
seeded++;
}
if (seeded > 0)
app.Logger.LogInformation("Seeded {Count} setting(s) from environment variables", seeded);
await settings.PreloadCacheAsync(); await settings.PreloadCacheAsync();
var stripeKey = await settings.GetAsync(SettingsService.StripeSecretKey); var stripeKey = await settings.GetAsync(SettingsService.StripeSecretKey);
if (!string.IsNullOrWhiteSpace(stripeKey)) if (!string.IsNullOrWhiteSpace(stripeKey))
StripeConfiguration.ApiKey = stripeKey; StripeConfiguration.ApiKey = stripeKey;
// Initialise admin bootstrap token (generates on first run, loads hash on subsequent starts)
var adminToken = initScope.ServiceProvider.GetRequiredService<AdminTokenService>();
await adminToken.InitialiseAsync(app.Services);
// Configure OIDC from database settings (if configured)
var oidcAuthority = await settings.GetAsync(SettingsService.OidcAuthority);
var oidcClientId = await settings.GetAsync(SettingsService.OidcClientId);
var oidcClientSecret = await settings.GetAsync(SettingsService.OidcClientSecret);
if (!string.IsNullOrWhiteSpace(oidcAuthority) && !string.IsNullOrWhiteSpace(oidcClientId))
{
var oidcOptions = app.Services.GetRequiredService<Microsoft.Extensions.Options.IOptionsMonitor<
Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectOptions>>().Get("oidc");
oidcOptions.Authority = oidcAuthority;
oidcOptions.ClientId = oidcClientId;
if (!string.IsNullOrWhiteSpace(oidcClientSecret))
oidcOptions.ClientSecret = oidcClientSecret;
app.Logger.LogInformation("OIDC configured with authority {Authority}", oidcAuthority);
}
else
{
app.Logger.LogWarning("OIDC not configured — only admin token auth is available. Set OIDC settings via the Settings page.");
}
} }
// ── Middleware ──────────────────────────────────────────────────────────────── // ── Middleware ────────────────────────────────────────────────────────────────
@@ -255,62 +405,45 @@ app.UseStaticFiles();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
// ── Cookie-based web auth endpoints (no auth required) ──────────────────── // ── Admin token redemption (no auth required) ────────────────────────────────
app.MapPost("/api/auth/web/login", async (LoginRequest req, OperatorAuthService auth, HttpContext http) => app.MapPost("/api/auth/admin-token", (AdminTokenRequest req, AdminTokenService adminToken, OperatorAuthService auth, HttpContext http) =>
{ {
try if (!adminToken.Validate(req.Token))
{
var (jwt, refresh) = await auth.LoginAsync(req.Email, req.Password);
var cookieOpts = new CookieOptions
{
HttpOnly = true, Secure = true, SameSite = SameSiteMode.Strict,
Path = "/", MaxAge = TimeSpan.FromMinutes(30),
};
http.Response.Cookies.Append("ots_access_token", jwt, cookieOpts);
http.Response.Cookies.Append("ots_refresh_token", refresh, new CookieOptions
{
HttpOnly = true, Secure = true, SameSite = SameSiteMode.Strict,
Path = "/api/auth/web", MaxAge = TimeSpan.FromDays(7),
});
return Results.Ok(new { message = "Logged in" });
}
catch (UnauthorizedAccessException)
{
return Results.Unauthorized(); return Results.Unauthorized();
}
});
app.MapPost("/api/auth/web/refresh", async (OperatorAuthService auth, HttpContext http) => var jwt = auth.GenerateJwt("admin-token@system", "SuperAdmin");
{
var refreshToken = http.Request.Cookies["ots_refresh_token"];
if (string.IsNullOrEmpty(refreshToken)) return Results.Unauthorized();
try
{
var jwt = await auth.RefreshAsync(refreshToken);
http.Response.Cookies.Append("ots_access_token", jwt, new CookieOptions http.Response.Cookies.Append("ots_access_token", jwt, new CookieOptions
{ {
HttpOnly = true, Secure = true, SameSite = SameSiteMode.Strict, HttpOnly = true, Secure = http.Request.IsHttps, SameSite = SameSiteMode.Strict,
Path = "/", MaxAge = TimeSpan.FromMinutes(30), Path = "/", MaxAge = TimeSpan.FromMinutes(30),
}); });
return Results.Ok(new { message = "Token refreshed" }); return Results.Ok(new { message = "Authenticated" });
}
catch (UnauthorizedAccessException)
{
return Results.Unauthorized();
}
}); });
// ── OIDC login trigger (no auth required) ────────────────────────────────────
app.MapGet("/api/auth/oidc/login", (SettingsService settings) =>
{
var authority = settings.GetCached(SettingsService.OidcAuthority);
if (string.IsNullOrWhiteSpace(authority))
return Results.Problem("OIDC is not configured. Use the admin token to access Settings.", statusCode: 503);
return Results.Challenge(
new Microsoft.AspNetCore.Authentication.AuthenticationProperties { RedirectUri = "/" },
["oidc"]);
});
// ── OIDC callback — handled by the OpenIdConnect middleware at /api/auth/oidc/callback
// The OnTokenValidated event in the OIDC setup issues the JWT cookie and redirects.
// ── Logout ────────────────────────────────────────────────────────────────────
app.MapPost("/api/auth/web/logout", (HttpContext http) => app.MapPost("/api/auth/web/logout", (HttpContext http) =>
{ {
var expired = new CookieOptions { Expires = DateTimeOffset.UnixEpoch, Path = "/" }; var expired = new CookieOptions { Expires = DateTimeOffset.UnixEpoch, Path = "/" };
http.Response.Cookies.Append("ots_access_token", "", expired); http.Response.Cookies.Append("ots_access_token", "", expired);
http.Response.Cookies.Append("ots_refresh_token", "", new CookieOptions
{
Expires = DateTimeOffset.UnixEpoch, Path = "/api/auth/web",
});
return Results.Ok(new { message = "Logged out" }); return Results.Ok(new { message = "Logged out" });
}); });
// ── Current user info ─────────────────────────────────────────────────────────
app.MapGet("/api/auth/web/me", (ClaimsPrincipal user) => app.MapGet("/api/auth/web/me", (ClaimsPrincipal user) =>
{ {
var id = user.FindFirstValue(ClaimTypes.NameIdentifier); var id = user.FindFirstValue(ClaimTypes.NameIdentifier);
@@ -337,7 +470,6 @@ app.MapProvisionEndpoints();
app.MapSecretsEndpoints(); app.MapSecretsEndpoints();
app.MapSettingsEndpoints(); app.MapSettingsEndpoints();
app.MapLogsEndpoints(); app.MapLogsEndpoints();
app.MapOperatorsEndpoints();
app.MapHealthEndpoints(); app.MapHealthEndpoints();
app.MapAuditEndpoints(); app.MapAuditEndpoints();
app.MapCustomersEndpoints(); app.MapCustomersEndpoints();
@@ -351,4 +483,4 @@ app.MapFallbackToFile("index.html");
app.Run(); app.Run();
// ── Request DTOs for auth endpoints ───────────────────────────────────────── // ── Request DTOs for auth endpoints ─────────────────────────────────────────
public record LoginRequest(string Email, string Password); public record AdminTokenRequest(string Token);

View File

@@ -0,0 +1,46 @@
using OTSSignsOrchestrator.Data.Entities;
namespace OTSSignsOrchestrator.Services;
/// <inheritdoc cref="IDockerServiceFactory"/>
public sealed class DockerServiceFactory : IDockerServiceFactory
{
private readonly IServiceProvider _sp;
public DockerServiceFactory(IServiceProvider sp)
{
_sp = sp;
}
public IDockerCliService GetCliService(SshHost host)
{
if (host.IsLocal)
{
var svc = _sp.GetRequiredService<LocalDockerCliService>();
svc.SetHost(host);
return svc;
}
else
{
var svc = _sp.GetRequiredService<SshDockerCliService>();
svc.SetHost(host);
return svc;
}
}
public IDockerSecretsService GetSecretsService(SshHost host)
{
if (host.IsLocal)
{
var svc = _sp.GetRequiredService<LocalDockerSecretsService>();
svc.SetHost(host);
return svc;
}
else
{
var svc = _sp.GetRequiredService<SshDockerSecretsService>();
svc.SetHost(host);
return svc;
}
}
}

View File

@@ -0,0 +1,24 @@
using OTSSignsOrchestrator.Data.Entities;
namespace OTSSignsOrchestrator.Services;
/// <summary>
/// Factory that resolves the correct <see cref="IDockerCliService"/> and
/// <see cref="IDockerSecretsService"/> implementations for a given <see cref="SshHost"/>.
/// When <c>host.IsLocal</c> is true, returns the local-process implementations.
/// Otherwise returns the SSH-based implementations.
/// </summary>
public interface IDockerServiceFactory
{
/// <summary>
/// Returns an <see cref="IDockerCliService"/> appropriate for the given host,
/// with the host already set (equivalent to calling <c>SetHost(host)</c>).
/// </summary>
IDockerCliService GetCliService(SshHost host);
/// <summary>
/// Returns an <see cref="IDockerSecretsService"/> appropriate for the given host,
/// with the host already set (equivalent to calling <c>SetHost(host)</c>).
/// </summary>
IDockerSecretsService GetSecretsService(SshHost host);
}

View File

@@ -0,0 +1,445 @@
using System.Diagnostics;
using System.Globalization;
using Microsoft.Extensions.Logging;
using MySqlConnector;
using OTSSignsOrchestrator.Data.Entities;
using OTSSignsOrchestrator.Models.DTOs;
namespace OTSSignsOrchestrator.Services;
// Re-export for convenience
using ServiceLogEntry = OTSSignsOrchestrator.Models.DTOs.ServiceLogEntry;
/// <summary>
/// Docker CLI service that executes docker commands via the local Docker socket
/// (<c>/var/run/docker.sock</c>). Requires the container to run with
/// <c>privileged: true</c> and the socket bind-mounted.
/// NFS operations are executed directly as local processes (no SSH).
/// MySQL connections are made directly without an SSH tunnel.
/// </summary>
public class LocalDockerCliService : IDockerCliService
{
private readonly ILogger<LocalDockerCliService> _logger;
// _currentHost is stored only to support OpenMySqlConnectionAsync in scenarios
// where the caller cannot connect directly; not used for Docker or NFS operations.
private SshHost? _currentHost;
public LocalDockerCliService(ILogger<LocalDockerCliService> logger)
{
_logger = logger;
}
public void SetHost(SshHost host) => _currentHost = host;
public SshHost? CurrentHost => _currentHost;
// ── Stack operations ─────────────────────────────────────────────────────
public async Task<DeploymentResultDto> DeployStackAsync(string stackName, string composeYaml, bool resolveImage = false)
{
var sw = Stopwatch.StartNew();
var args = "stack deploy --compose-file -";
if (resolveImage) args += " --resolve-image changed";
args += $" {stackName}";
_logger.LogInformation("Deploying stack locally: {StackName}", stackName);
var (exitCode, stdout, stderr) = await LocalProcessRunner.RunWithStdinAsync("docker", args, composeYaml);
sw.Stop();
var result = new DeploymentResultDto
{
StackName = stackName, Success = exitCode == 0, ExitCode = exitCode,
Output = stdout, ErrorMessage = stderr,
Message = exitCode == 0 ? "Success" : "Failed",
DurationMs = sw.ElapsedMilliseconds
};
if (result.Success)
_logger.LogInformation("Stack deployed: {StackName} | {DurationMs}ms", stackName, result.DurationMs);
else
_logger.LogError("Stack deploy failed: {StackName} | {Error}", stackName, result.ErrorMessage);
return result;
}
public async Task<DeploymentResultDto> RemoveStackAsync(string stackName)
{
var sw = Stopwatch.StartNew();
var (exitCode, stdout, stderr) = await LocalProcessRunner.RunAsync("docker", $"stack rm {stackName}");
sw.Stop();
return new DeploymentResultDto
{
StackName = stackName, Success = exitCode == 0, ExitCode = exitCode,
Output = stdout, ErrorMessage = stderr,
Message = exitCode == 0 ? "Success" : "Failed",
DurationMs = sw.ElapsedMilliseconds
};
}
public async Task<List<StackInfo>> ListStacksAsync()
{
var (exitCode, stdout, _) = await LocalProcessRunner.RunAsync(
"docker", "stack ls --format '{{.Name}}\\t{{.Services}}'");
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout))
return new List<StackInfo>();
return stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(line =>
{
var parts = line.Split('\t', 2);
return new StackInfo
{
Name = parts[0].Trim(),
ServiceCount = parts.Length > 1 && int.TryParse(parts[1].Trim(), out var c) ? c : 0
};
}).ToList();
}
public async Task<List<ServiceInfo>> InspectStackServicesAsync(string stackName)
{
var (exitCode, stdout, _) = await LocalProcessRunner.RunAsync(
"docker", $"stack services {stackName} --format '{{{{.Name}}}}\\t{{{{.Image}}}}\\t{{{{.Replicas}}}}'");
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout))
return new List<ServiceInfo>();
return stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(line =>
{
var parts = line.Split('\t', 3);
return new ServiceInfo
{
Name = parts.Length > 0 ? parts[0].Trim() : "",
Image = parts.Length > 1 ? parts[1].Trim() : "",
Replicas = parts.Length > 2 ? parts[2].Trim() : ""
};
}).ToList();
}
// ── Filesystem operations ─────────────────────────────────────────────────
public async Task<bool> EnsureDirectoryAsync(string path)
{
var (exitCode, _, stderr) = await LocalProcessRunner.RunAsync("mkdir", $"-p {path}");
if (exitCode != 0)
_logger.LogWarning("Failed to create directory {Path}: {Error}", path, stderr);
return exitCode == 0;
}
public async Task<bool> EnsureNfsFoldersAsync(
string nfsServer, string nfsExport, IEnumerable<string> folderNames, string? nfsExportFolder = null)
{
var (success, _) = await EnsureNfsFoldersWithErrorAsync(nfsServer, nfsExport, folderNames, nfsExportFolder);
return success;
}
public async Task<(bool Success, string? Error)> EnsureNfsFoldersWithErrorAsync(
string nfsServer, string nfsExport, IEnumerable<string> folderNames, string? nfsExportFolder = null)
{
var exportPath = (nfsExport ?? string.Empty).Trim('/');
var subFolder = (nfsExportFolder ?? string.Empty).Trim('/');
var subPath = string.IsNullOrEmpty(subFolder) ? string.Empty : $"/{subFolder}";
var folderList = folderNames.Select(f => $"\"$MNT{subPath}/{f}\"").ToList();
var mkdirTargets = string.Join(" ", folderList);
// No sudo needed — privileged container runs as root
var script = $"""
set -e
MNT=$(mktemp -d)
mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
mkdir -p {mkdirTargets}
umount "$MNT"
rmdir "$MNT"
""";
var (exitCode, stdout, stderr) = await LocalProcessRunner.RunBashAsync(script, TimeSpan.FromSeconds(30));
if (exitCode == 0) return (true, null);
var error = (stderr ?? stdout ?? "unknown error").Trim();
_logger.LogWarning("Failed to create NFS folders: {Error}", error);
return (false, error);
}
public async Task<(bool Success, string? Error)> WriteFileToNfsAsync(
string nfsServer, string nfsExport, string relativePath, string content, string? nfsExportFolder = null)
{
var exportPath = (nfsExport ?? string.Empty).Trim('/');
var subFolder = (nfsExportFolder ?? string.Empty).Trim('/');
var subPath = string.IsNullOrEmpty(subFolder) ? string.Empty : $"/{subFolder}";
var targetPath = $"$MNT{subPath}/{relativePath.TrimStart('/')}";
var parentDir = $"$(dirname \"{targetPath}\")";
var base64Content = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(content));
// No sudo needed — privileged container runs as root
var script = $"""
set -e
TMPFILE=$(mktemp)
echo '{base64Content}' | base64 -d > "$TMPFILE"
MNT=$(mktemp -d)
mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
mkdir -p {parentDir}
cp "$TMPFILE" "{targetPath}"
rm -f "$TMPFILE"
umount "$MNT"
rmdir "$MNT"
""";
var (exitCode, stdout, stderr) = await LocalProcessRunner.RunBashAsync(script, TimeSpan.FromSeconds(30));
if (exitCode == 0) return (true, null);
var error = (stderr ?? stdout ?? "unknown error").Trim();
return (false, error);
}
// ── Service operations ───────────────────────────────────────────────────
public async Task<bool> ForceUpdateServiceAsync(string serviceName)
{
var (exitCode, _, stderr) = await LocalProcessRunner.RunAsync(
"docker", $"service update --force {serviceName}");
if (exitCode != 0)
_logger.LogWarning("Force-update failed for {ServiceName}: {Error}", serviceName, stderr);
return exitCode == 0;
}
public async Task<bool> ServiceSwapSecretAsync(
string serviceName, string oldSecretName, string newSecretName, string? targetAlias = null)
{
var target = targetAlias ?? oldSecretName;
var cmd = $"service update --secret-rm {oldSecretName} --secret-add \"source={newSecretName},target={target}\" {serviceName}";
var (exitCode, _, stderr) = await LocalProcessRunner.RunAsync("docker", cmd);
if (exitCode != 0)
_logger.LogError("Secret swap failed for {ServiceName}: {Error}", serviceName, stderr);
return exitCode == 0;
}
// ── Swarm node operations ────────────────────────────────────────────────
public async Task<List<NodeInfo>> ListNodesAsync()
{
var (lsExit, lsOut, lsErr) = await LocalProcessRunner.RunAsync(
"docker", "node ls --format '{{.ID}}'");
if (lsExit != 0)
throw new InvalidOperationException($"Failed to list swarm nodes: {(lsErr ?? lsOut ?? "unknown").Trim()}");
if (string.IsNullOrWhiteSpace(lsOut)) return new List<NodeInfo>();
var nodeIds = lsOut.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(id => id.Trim()).Where(id => !string.IsNullOrEmpty(id)).ToList();
if (nodeIds.Count == 0) return new List<NodeInfo>();
var ids = string.Join(" ", nodeIds);
var format = "'{{.ID}}\t{{.Description.Hostname}}\t{{.Status.State}}\t{{.Spec.Availability}}\t{{.ManagerStatus.Addr}}\t{{.Status.Addr}}\t{{.Description.Engine.EngineVersion}}\t{{.Spec.Role}}'";
var (exitCode, stdout, stderr) = await LocalProcessRunner.RunAsync(
"docker", $"node inspect --format {format} {ids}");
if (exitCode != 0)
throw new InvalidOperationException($"Failed to inspect swarm nodes: {(stderr ?? stdout ?? "unknown").Trim()}");
if (string.IsNullOrWhiteSpace(stdout)) return new List<NodeInfo>();
return stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(line =>
{
var parts = line.Split('\t', 8);
var statusAddr = parts.Length > 5 ? parts[5].Trim() : "";
var managerAddr = parts.Length > 4 ? parts[4].Trim() : "";
var ip = statusAddr;
if (string.IsNullOrEmpty(ip) || ip.StartsWith('<') || ip.StartsWith('{'))
ip = managerAddr.Contains(':') ? managerAddr[..managerAddr.LastIndexOf(':')] : managerAddr;
if (ip.StartsWith('<') || ip.StartsWith('{')) ip = "";
var role = parts.Length > 7 ? parts[7].Trim() : "";
var managerStatus = "";
if (string.Equals(role, "manager", StringComparison.OrdinalIgnoreCase))
managerStatus = !string.IsNullOrEmpty(managerAddr) && !managerAddr.StartsWith('<') ? "Reachable" : "";
return new NodeInfo
{
Id = parts.Length > 0 ? parts[0].Trim() : "",
Hostname = parts.Length > 1 ? parts[1].Trim() : "",
Status = parts.Length > 2 ? parts[2].Trim() : "",
Availability = parts.Length > 3 ? parts[3].Trim() : "",
ManagerStatus = managerStatus, IpAddress = ip,
EngineVersion = parts.Length > 6 ? parts[6].Trim() : ""
};
}).ToList();
}
// ── Logging ──────────────────────────────────────────────────────────────
public async Task<List<ServiceLogEntry>> GetServiceLogsAsync(
string stackName, string? serviceName = null, int tailLines = 200)
{
List<string> serviceNames;
if (!string.IsNullOrEmpty(serviceName))
serviceNames = new List<string> { serviceName };
else
{
var services = await InspectStackServicesAsync(stackName);
serviceNames = services.Select(s => s.Name).ToList();
}
var allEntries = new List<ServiceLogEntry>();
foreach (var svcName in serviceNames)
{
try
{
var (exitCode, stdout, _) = await LocalProcessRunner.RunAsync(
"docker", $"service logs --timestamps --no-trunc --tail {tailLines} {svcName}",
TimeSpan.FromSeconds(15));
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout)) continue;
foreach (var line in stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
var entry = ParseLogLine(line, svcName, stackName);
if (entry != null) allEntries.Add(entry);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch logs for service {Service}", svcName);
}
}
return allEntries.OrderBy(e => e.Timestamp).ToList();
}
// ── Volume operations ────────────────────────────────────────────────────
public async Task<bool> RemoveStackVolumesAsync(string stackName)
{
await LocalProcessRunner.RunAsync("docker", $"stack rm {stackName}");
await Task.Delay(5000);
var script = $"docker volume ls --filter \"name={stackName}_\" -q | xargs -r docker volume rm 2>&1 || true";
await LocalProcessRunner.RunBashAsync(script);
var cleanupSvcName = $"vol-cleanup-{stackName}".Replace("_", "-");
await LocalProcessRunner.RunAsync("docker", $"service rm {cleanupSvcName}");
var createArgs = string.Join(" ",
"service create", "--detach", "--mode global",
"--restart-condition none", $"--name {cleanupSvcName}",
"--mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock",
"docker:cli", "sh", "-c",
$"'docker volume ls -q --filter name={stackName}_ | xargs -r docker volume rm 2>&1; echo done'");
var (svcExit, _, _) = await LocalProcessRunner.RunAsync("docker", createArgs);
if (svcExit == 0) await Task.Delay(10000);
await LocalProcessRunner.RunAsync("docker", $"service rm {cleanupSvcName}");
return true;
}
// ── MySQL (direct — no SSH tunnel in local mode) ─────────────────────────
public async Task<(MySqlConnection Connection, IDisposable Tunnel)> OpenMySqlConnectionAsync(
string mysqlHost, int port, string adminUser, string adminPassword)
{
var csb = new MySqlConnectionStringBuilder
{
Server = mysqlHost,
Port = (uint)port,
UserID = adminUser,
Password = adminPassword,
ConnectionTimeout = 15,
SslMode = MySqlSslMode.Disabled,
};
var connection = new MySqlConnection(csb.ConnectionString);
try
{
await connection.OpenAsync();
// Return a no-op disposable — there is no tunnel to close in local mode
return (connection, NoOpDisposable.Instance);
}
catch
{
await connection.DisposeAsync();
throw;
}
}
public async Task<(bool Success, string Error)> AlterMySqlUserPasswordAsync(
string mysqlHost, int port, string adminUser, string adminPassword,
string targetUser, string newPassword)
{
try
{
var (connection, tunnel) = await OpenMySqlConnectionAsync(mysqlHost, port, adminUser, adminPassword);
await using (connection)
using (tunnel)
{
var escapedUser = targetUser.Replace("'", "''");
await using var cmd = connection.CreateCommand();
cmd.CommandText = $"ALTER USER '{escapedUser}'@'%' IDENTIFIED BY @pwd";
cmd.Parameters.AddWithValue("@pwd", newPassword);
await cmd.ExecuteNonQueryAsync();
}
return (true, string.Empty);
}
catch (MySqlException ex)
{
_logger.LogError(ex, "MySQL ALTER USER failed for user {User}", targetUser);
return (false, ex.Message);
}
}
// ── Helpers ──────────────────────────────────────────────────────────────
private static ServiceLogEntry? ParseLogLine(string line, string serviceName, string stackName)
{
if (string.IsNullOrWhiteSpace(line)) return null;
var firstSpace = line.IndexOf(' ');
if (firstSpace <= 0)
return new ServiceLogEntry
{
Timestamp = DateTimeOffset.UtcNow, Source = serviceName,
ServiceName = StripStackPrefix(serviceName, stackName), Message = line
};
var timestampStr = line[..firstSpace];
var rest = line[(firstSpace + 1)..].TrimStart();
if (!DateTimeOffset.TryParse(timestampStr, null, System.Globalization.DateTimeStyles.RoundtripKind, out var timestamp))
return new ServiceLogEntry
{
Timestamp = DateTimeOffset.UtcNow, Source = serviceName,
ServiceName = StripStackPrefix(serviceName, stackName), Message = line
};
var source = serviceName;
var message = rest;
var pipeIndex = rest.IndexOf('|');
if (pipeIndex >= 0)
{
source = rest[..pipeIndex].Trim();
message = rest[(pipeIndex + 1)..].TrimStart();
}
return new ServiceLogEntry
{
Timestamp = timestamp, Source = source,
ServiceName = StripStackPrefix(serviceName, stackName), Message = message
};
}
private static string StripStackPrefix(string serviceName, string stackName)
{
var prefix = stackName + "_";
return serviceName.StartsWith(prefix) ? serviceName[prefix.Length..] : serviceName;
}
private sealed class NoOpDisposable : IDisposable
{
public static readonly NoOpDisposable Instance = new();
public void Dispose() { }
}
}

View File

@@ -0,0 +1,110 @@
using System.Globalization;
using Microsoft.Extensions.Logging;
using OTSSignsOrchestrator.Data.Entities;
namespace OTSSignsOrchestrator.Services;
/// <summary>
/// Docker Swarm secrets management via the local Docker socket.
/// Used when the orchestrator runs on the same Swarm node as a privileged container.
/// </summary>
public class LocalDockerSecretsService : IDockerSecretsService
{
private readonly ILogger<LocalDockerSecretsService> _logger;
public LocalDockerSecretsService(ILogger<LocalDockerSecretsService> logger)
{
_logger = logger;
}
// SetHost is a no-op in local mode — secrets are global on the local Swarm
public void SetHost(SshHost host) { }
public async Task<(bool Created, string SecretId)> EnsureSecretAsync(
string name, string value, bool rotate = false)
{
var existing = await FindSecretAsync(name);
if (existing != null && !rotate)
return (false, existing.Value.id);
if (existing != null && rotate)
{
var (rmExit, _, rmErr) = await LocalProcessRunner.RunAsync("docker", $"secret rm {name}");
if (rmExit != 0)
{
_logger.LogError("Failed to remove old secret for rotation: {SecretName} | {Error}", name, rmErr);
return (false, string.Empty);
}
}
var (exitCode, stdout, stderr) = await LocalProcessRunner.RunWithStdinAsync(
"docker", $"secret create {name} -", value);
if (exitCode != 0)
{
_logger.LogError("Failed to create secret: {SecretName} | {Error}", name, stderr);
return (false, string.Empty);
}
var secretId = stdout.Trim();
_logger.LogInformation("Secret created: {SecretName} (id={SecretId})", name, secretId);
return (true, secretId);
}
public async Task<List<SecretListItem>> ListSecretsAsync()
{
var (exitCode, stdout, _) = await LocalProcessRunner.RunAsync(
"docker", "secret ls --format '{{.ID}}\\t{{.Name}}\\t{{.CreatedAt}}'");
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout))
return new List<SecretListItem>();
return stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(line =>
{
var parts = line.Split('\t', 3);
return new SecretListItem
{
Id = parts.Length > 0 ? parts[0].Trim() : "",
Name = parts.Length > 1 ? parts[1].Trim() : "",
CreatedAt = parts.Length > 2 && DateTime.TryParse(
parts[2].Trim(), CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt)
? dt : DateTime.MinValue
};
}).ToList();
}
public async Task<bool> DeleteSecretAsync(string name)
{
var existing = await FindSecretAsync(name);
if (existing == null) return true;
var (exitCode, _, stderr) = await LocalProcessRunner.RunAsync("docker", $"secret rm {name}");
if (exitCode != 0)
{
_logger.LogError("Failed to delete secret: {SecretName} | {Error}", name, stderr);
return false;
}
return true;
}
private async Task<(string id, string name)?> FindSecretAsync(string name)
{
var (exitCode, stdout, _) = await LocalProcessRunner.RunAsync(
"docker", $"secret ls --filter 'name={name}' --format '{{{{.ID}}}}\\t{{{{.Name}}}}'");
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout)) return null;
var line = stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault(l =>
{
var parts = l.Split('\t', 2);
return parts.Length > 1 && string.Equals(parts[1].Trim(), name, StringComparison.OrdinalIgnoreCase);
});
if (line == null) return null;
var p = line.Split('\t', 2);
return (p[0].Trim(), p[1].Trim());
}
}

View File

@@ -0,0 +1,90 @@
using System.Diagnostics;
namespace OTSSignsOrchestrator.Services;
/// <summary>
/// Executes shell commands locally via <see cref="Process.Start"/>.
/// Used by local Docker service implementations when the orchestrator runs on
/// the same Swarm node as a privileged container with the Docker socket mounted.
/// </summary>
public static class LocalProcessRunner
{
/// <summary>
/// Runs a program with given arguments, capturing stdout/stderr and exit code.
/// </summary>
public static async Task<(int ExitCode, string Stdout, string Stderr)> RunAsync(
string fileName, string arguments, TimeSpan? timeout = null)
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
}
};
process.Start();
using var cts = timeout.HasValue ? new CancellationTokenSource(timeout.Value) : null;
var ct = cts?.Token ?? CancellationToken.None;
var stdoutTask = process.StandardOutput.ReadToEndAsync(ct);
var stderrTask = process.StandardError.ReadToEndAsync(ct);
await process.WaitForExitAsync(ct);
return (process.ExitCode, (await stdoutTask).Trim(), (await stderrTask).Trim());
}
/// <summary>
/// Runs a program with the given arguments, piping <paramref name="stdinContent"/> to stdin.
/// </summary>
public static async Task<(int ExitCode, string Stdout, string Stderr)> RunWithStdinAsync(
string fileName, string arguments, string stdinContent)
{
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
}
};
process.Start();
await process.StandardInput.WriteAsync(stdinContent);
process.StandardInput.Close();
var stdoutTask = process.StandardOutput.ReadToEndAsync();
var stderrTask = process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
return (process.ExitCode, (await stdoutTask).Trim(), (await stderrTask).Trim());
}
/// <summary>
/// Runs a bash script by piping it to <c>bash -s</c> via stdin.
/// </summary>
public static Task<(int ExitCode, string Stdout, string Stderr)> RunBashAsync(
string script, TimeSpan? timeout = null)
{
if (timeout.HasValue)
{
// Wrap script with a timeout guard
return RunWithStdinAsync("bash", $"-s", $"timeout {(int)timeout.Value.TotalSeconds} bash -s <<'__SCRIPT__'\n{script}\n__SCRIPT__");
}
return RunWithStdinAsync("bash", "-s", script);
}
}

View File

@@ -30,8 +30,20 @@ public class SettingsService
public const string CatStripe = "Stripe"; public const string CatStripe = "Stripe";
public const string CatEmail = "Email"; public const string CatEmail = "Email";
public const string CatBitwarden = "Bitwarden"; public const string CatBitwarden = "Bitwarden";
public const string CatOidc = "OIDC";
// ── Key constants ────────────────────────────────────────────────────── // ── Key constants ──────────────────────────────────────────────────────
// OIDC (operator login via external IdP)
public const string OidcAuthority = "Oidc.Authority";
public const string OidcClientId = "Oidc.ClientId";
public const string OidcClientSecret = "Oidc.ClientSecret";
public const string OidcRoleClaim = "Oidc.RoleClaim"; // e.g. "groups" or "roles"
public const string OidcAdminValue = "Oidc.AdminClaimValue"; // claim value that maps to Admin
public const string OidcViewerValue = "Oidc.ViewerClaimValue";
// Reports
public const string ReportRecipients = "Email.ReportRecipients"; // comma-separated email addresses
// Git // Git
public const string GitRepoUrl = "Git.RepoUrl"; public const string GitRepoUrl = "Git.RepoUrl";
public const string GitRepoPat = "Git.RepoPat"; public const string GitRepoPat = "Git.RepoPat";

View File

@@ -0,0 +1,179 @@
using Renci.SshNet;
using OTSSignsOrchestrator.Services;
namespace OTSSignsOrchestrator.Services;
/// <summary>
/// Abstracts command execution against the Docker Swarm manager host.
/// In local mode (<c>Ssh.SwarmIsLocal = true</c>), commands are run via
/// <see cref="LocalProcessRunner"/> directly in the (privileged) container.
/// In remote mode, a single SSH connection is established on first use and
/// held open for the lifetime of the scope.
/// </summary>
public sealed class SwarmShellService : IAsyncDisposable
{
private readonly SettingsService _settings;
private readonly ILogger<SwarmShellService> _logger;
private bool _initialized;
private bool _isLocal;
private SshClient? _ssh;
public SwarmShellService(SettingsService settings, ILogger<SwarmShellService> logger)
{
_settings = settings;
_logger = logger;
}
// ── Public API ───────────────────────────────────────────────────────────
/// <summary>
/// Runs a shell command. Throws <see cref="InvalidOperationException"/> on non-zero exit.
/// </summary>
public async Task<string> RunCommandAsync(string command, TimeSpan? timeout = null)
{
await EnsureInitializedAsync();
if (_isLocal)
{
var (exitCode, stdout, stderr) = await LocalProcessRunner.RunBashAsync(
command, timeout);
if (exitCode != 0)
throw new InvalidOperationException(
$"Command failed (exit {exitCode}): {(string.IsNullOrWhiteSpace(stderr) ? stdout : stderr)}");
return stdout;
}
else
{
using var cmd = _ssh!.RunCommand(command);
if (cmd.ExitStatus != 0)
throw new InvalidOperationException(
$"SSH command failed (exit {cmd.ExitStatus}): {cmd.Error}");
return cmd.Result.TrimEnd();
}
}
/// <summary>
/// Runs a shell command, ignoring non-zero exit codes (for idempotent cleanup).
/// </summary>
public async Task RunCommandAllowFailureAsync(string command)
{
await EnsureInitializedAsync();
if (_isLocal)
{
await LocalProcessRunner.RunBashAsync(command);
}
else
{
using var cmd = _ssh!.RunCommand(command);
// Intentionally ignore exit code
}
}
/// <summary>
/// Runs a command with <paramref name="stdin"/> piped to its standard input.
/// Throws <see cref="InvalidOperationException"/> on non-zero exit.
/// </summary>
public async Task<string> RunCommandWithStdinAsync(string command, string stdin)
{
await EnsureInitializedAsync();
if (_isLocal)
{
// Pipe stdin: write the content and let bash handle it
var (exitCode, stdout, stderr) = await LocalProcessRunner.RunWithStdinAsync(
"bash", $"-c {ShellQuote(command)}", stdin);
if (exitCode != 0)
throw new InvalidOperationException(
$"Command failed (exit {exitCode}): {(string.IsNullOrWhiteSpace(stderr) ? stdout : stderr)}");
return stdout;
}
else
{
var safeStdin = stdin.Replace("'", "'\\''");
var wrappedCommand = $"printf '%s' '{safeStdin}' | {command}";
using var cmd = _ssh!.RunCommand(wrappedCommand);
if (cmd.ExitStatus != 0)
throw new InvalidOperationException(
$"SSH command failed (exit {cmd.ExitStatus}): {cmd.Error}");
return cmd.Result.TrimEnd();
}
}
// ── Lifecycle ────────────────────────────────────────────────────────────
private async ValueTask EnsureInitializedAsync()
{
if (_initialized) return;
var isLocalStr = await _settings.GetAsync("Ssh.SwarmIsLocal", "false");
_isLocal = string.Equals(isLocalStr, "true", StringComparison.OrdinalIgnoreCase);
if (!_isLocal)
{
var info = await GetSwarmConnectionInfoAsync();
_ssh = BuildSshClient(info);
_ssh.Connect();
_logger.LogDebug("SwarmShellService connected to {Host}:{Port}", info.Host, info.Port);
}
_initialized = true;
}
private async Task<SwarmConnectionInfo> GetSwarmConnectionInfoAsync()
{
var host = await _settings.GetAsync("Ssh.SwarmHost")
?? throw new InvalidOperationException("SSH Swarm host not configured (Ssh.SwarmHost).");
var portStr = await _settings.GetAsync("Ssh.SwarmPort", "22");
var user = await _settings.GetAsync("Ssh.SwarmUser", "root");
var keyPath = await _settings.GetAsync("Ssh.SwarmKeyPath");
var password = await _settings.GetAsync("Ssh.SwarmPassword");
if (!int.TryParse(portStr, out var port)) port = 22;
return new(host, port, user, keyPath, password);
}
private static SshClient BuildSshClient(SwarmConnectionInfo info)
{
var authMethods = new List<AuthenticationMethod>();
if (!string.IsNullOrEmpty(info.KeyPath))
authMethods.Add(new PrivateKeyAuthenticationMethod(
info.Username, new PrivateKeyFile(info.KeyPath)));
if (!string.IsNullOrEmpty(info.Password))
authMethods.Add(new PasswordAuthenticationMethod(info.Username, info.Password));
if (authMethods.Count == 0)
{
var defaultKey = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh", "id_rsa");
if (File.Exists(defaultKey))
authMethods.Add(new PrivateKeyAuthenticationMethod(
info.Username, new PrivateKeyFile(defaultKey)));
else
throw new InvalidOperationException(
$"No SSH authentication method available for {info.Host}:{info.Port}.");
}
var connInfo = new Renci.SshNet.ConnectionInfo(
info.Host, info.Port, info.Username, authMethods.ToArray());
return new SshClient(connInfo);
}
private static string ShellQuote(string s) => $"'{s.Replace("'", "'\\''")}'";
public async ValueTask DisposeAsync()
{
if (_ssh != null)
{
try { _ssh.Disconnect(); } catch { /* ignore */ }
_ssh.Dispose();
_ssh = null;
}
await ValueTask.CompletedTask;
}
private sealed record SwarmConnectionInfo(
string Host, int Port, string Username, string? KeyPath, string? Password);
}

View File

@@ -1,6 +1,5 @@
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Renci.SshNet;
using OTSSignsOrchestrator.Services; using OTSSignsOrchestrator.Services;
using OTSSignsOrchestrator.Clients; using OTSSignsOrchestrator.Clients;
using OTSSignsOrchestrator.Data; using OTSSignsOrchestrator.Data;
@@ -47,6 +46,7 @@ public sealed class DecommissionPipeline : IProvisioningPipeline
var authentikClient = scope.ServiceProvider.GetRequiredService<IAuthentikClient>(); var authentikClient = scope.ServiceProvider.GetRequiredService<IAuthentikClient>();
var xiboFactory = scope.ServiceProvider.GetRequiredService<XiboClientFactory>(); var xiboFactory = scope.ServiceProvider.GetRequiredService<XiboClientFactory>();
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>(); var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
var shell = scope.ServiceProvider.GetRequiredService<SwarmShellService>();
var ctx = await BuildContextAsync(job, db, ct); var ctx = await BuildContextAsync(job, db, ct);
var runner = new StepRunner(db, hub, _logger, job.Id, TotalSteps); var runner = new StepRunner(db, hub, _logger, job.Id, TotalSteps);
@@ -57,13 +57,7 @@ public sealed class DecommissionPipeline : IProvisioningPipeline
// ── Step 1: stack-remove ──────────────────────────────────────────── // ── Step 1: stack-remove ────────────────────────────────────────────
await runner.RunAsync("stack-remove", async () => await runner.RunAsync("stack-remove", async () =>
{ {
var sshHost = await GetSwarmSshHostAsync(settings); var result = await shell.RunCommandAsync($"docker stack rm {stackName}");
using var sshClient = CreateSshClient(sshHost);
sshClient.Connect();
try
{
var result = RunSshCommand(sshClient, $"docker stack rm {stackName}");
db.AuditLogs.Add(new AuditLog db.AuditLogs.Add(new AuditLog
{ {
@@ -79,11 +73,6 @@ public sealed class DecommissionPipeline : IProvisioningPipeline
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return $"Docker stack '{stackName}' removed. Output: {result}"; return $"Docker stack '{stackName}' removed. Output: {result}";
}
finally
{
sshClient.Disconnect();
}
}, ct); }, ct);
// ── Step 2: authentik-cleanup ─────────────────────────────────────── // ── Step 2: authentik-cleanup ───────────────────────────────────────
@@ -205,23 +194,17 @@ public sealed class DecommissionPipeline : IProvisioningPipeline
var dbName = $"xibo_{abbrev}"; var dbName = $"xibo_{abbrev}";
var userName = $"xibo_{abbrev}"; var userName = $"xibo_{abbrev}";
var sshHost = await GetSwarmSshHostAsync(settings);
using var sshClient = CreateSshClient(sshHost);
sshClient.Connect();
try
{
// DROP DATABASE // DROP DATABASE
var dropDbCmd = $"mysql -h {mysqlHost} -P {port} -u {mysqlAdminUser} " + var dropDbCmd = $"mysql -h {mysqlHost} -P {port} -u {mysqlAdminUser} " +
$"-p'{mysqlAdminPassword}' -e " + $"-p'{mysqlAdminPassword}' -e " +
$"\"DROP DATABASE IF EXISTS \\`{dbName}\\`\""; $"\"DROP DATABASE IF EXISTS \\`{dbName}\\`\"";
RunSshCommand(sshClient, dropDbCmd); await shell.RunCommandAsync(dropDbCmd);
// DROP USER // DROP USER
var dropUserCmd = $"mysql -h {mysqlHost} -P {port} -u {mysqlAdminUser} " + var dropUserCmd = $"mysql -h {mysqlHost} -P {port} -u {mysqlAdminUser} " +
$"-p'{mysqlAdminPassword}' -e " + $"-p'{mysqlAdminPassword}' -e " +
$"\"DROP USER IF EXISTS '{userName}'@'%'\""; $"\"DROP USER IF EXISTS '{userName}'@'%'\"";
RunSshCommand(sshClient, dropUserCmd); await shell.RunCommandAsync(dropUserCmd);
db.AuditLogs.Add(new AuditLog db.AuditLogs.Add(new AuditLog
{ {
@@ -237,11 +220,6 @@ public sealed class DecommissionPipeline : IProvisioningPipeline
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return $"Database '{dbName}' and user '{userName}' dropped on {mysqlHost}:{port}."; return $"Database '{dbName}' and user '{userName}' dropped on {mysqlHost}:{port}.";
}
finally
{
sshClient.Disconnect();
}
}, ct); }, ct);
// ── Step 5: nfs-archive ───────────────────────────────────────────── // ── Step 5: nfs-archive ─────────────────────────────────────────────
@@ -262,28 +240,21 @@ public sealed class DecommissionPipeline : IProvisioningPipeline
var sourcePath = $"{basePath}/{abbrev}"; var sourcePath = $"{basePath}/{abbrev}";
var archivePath = $"{basePath}/archived/{abbrev}-{timestamp}"; var archivePath = $"{basePath}/archived/{abbrev}-{timestamp}";
var sshHost = await GetSwarmSshHostAsync(settings); // Temporarily mount NFS, archive directory, unmount.
using var sshClient = CreateSshClient(sshHost); // In local (privileged container) mode, sudo is a no-op as root.
sshClient.Connect();
try
{
// Temporarily mount NFS to move directories
var mountPoint = $"/tmp/nfs-decommission-{abbrev}"; var mountPoint = $"/tmp/nfs-decommission-{abbrev}";
RunSshCommand(sshClient, $"sudo mkdir -p {mountPoint}"); await shell.RunCommandAsync($"sudo mkdir -p {mountPoint}");
RunSshCommand(sshClient, $"sudo mount -t nfs4 {nfsServer}:{basePath} {mountPoint}"); await shell.RunCommandAsync($"sudo mount -t nfs4 {nfsServer}:{basePath} {mountPoint}");
try try
{ {
// Ensure archive directory exists await shell.RunCommandAsync($"sudo mkdir -p {mountPoint}/archived");
RunSshCommand(sshClient, $"sudo mkdir -p {mountPoint}/archived"); await shell.RunCommandAsync($"sudo mv {mountPoint}/{abbrev} {mountPoint}/archived/{abbrev}-{timestamp}");
// Move — DO NOT delete (retain for 30 days minimum)
RunSshCommand(sshClient, $"sudo mv {mountPoint}/{abbrev} {mountPoint}/archived/{abbrev}-{timestamp}");
} }
finally finally
{ {
RunSshCommandAllowFailure(sshClient, $"sudo umount {mountPoint}"); await shell.RunCommandAllowFailureAsync($"sudo umount {mountPoint}");
RunSshCommandAllowFailure(sshClient, $"sudo rmdir {mountPoint}"); await shell.RunCommandAllowFailureAsync($"sudo rmdir {mountPoint}");
} }
db.AuditLogs.Add(new AuditLog db.AuditLogs.Add(new AuditLog
@@ -300,11 +271,6 @@ public sealed class DecommissionPipeline : IProvisioningPipeline
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return $"NFS data moved from {sourcePath} to {archivePath}. Retained for 30+ days."; return $"NFS data moved from {sourcePath} to {archivePath}. Retained for 30+ days.";
}
finally
{
sshClient.Disconnect();
}
}, ct); }, ct);
// ── Step 6: registry-update ───────────────────────────────────────── // ── Step 6: registry-update ─────────────────────────────────────────
@@ -371,60 +337,4 @@ public sealed class DecommissionPipeline : IProvisioningPipeline
}; };
} }
private static async Task<SshConnectionInfo> GetSwarmSshHostAsync(SettingsService settings)
{
var host = await settings.GetAsync("Ssh.SwarmHost")
?? throw new InvalidOperationException("SSH Swarm host not configured (Ssh.SwarmHost).");
var portStr = await settings.GetAsync("Ssh.SwarmPort", "22");
var user = await settings.GetAsync("Ssh.SwarmUser", "root");
var keyPath = await settings.GetAsync("Ssh.SwarmKeyPath");
var password = await settings.GetAsync("Ssh.SwarmPassword");
if (!int.TryParse(portStr, out var port)) port = 22;
return new SshConnectionInfo(host, port, user, keyPath, password);
}
private static SshClient CreateSshClient(SshConnectionInfo info)
{
var authMethods = new List<AuthenticationMethod>();
if (!string.IsNullOrEmpty(info.KeyPath))
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(info.KeyPath)));
if (!string.IsNullOrEmpty(info.Password))
authMethods.Add(new PasswordAuthenticationMethod(info.Username, info.Password));
if (authMethods.Count == 0)
{
var defaultKeyPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh", "id_rsa");
if (File.Exists(defaultKeyPath))
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(defaultKeyPath)));
else
throw new InvalidOperationException(
$"No SSH authentication method available for {info.Host}:{info.Port}.");
}
var connInfo = new Renci.SshNet.ConnectionInfo(info.Host, info.Port, info.Username, authMethods.ToArray());
return new SshClient(connInfo);
}
private static string RunSshCommand(SshClient client, string command)
{
using var cmd = client.RunCommand(command);
if (cmd.ExitStatus != 0)
throw new InvalidOperationException(
$"SSH command failed (exit {cmd.ExitStatus}): {cmd.Error}");
return cmd.Result;
}
private static void RunSshCommandAllowFailure(SshClient client, string command)
{
using var cmd = client.RunCommand(command);
// Intentionally ignore exit code — used for idempotent cleanup operations
}
internal sealed record SshConnectionInfo(
string Host, int Port, string Username, string? KeyPath, string? Password);
} }

View File

@@ -2,7 +2,6 @@ using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Renci.SshNet;
using OTSSignsOrchestrator.Configuration; using OTSSignsOrchestrator.Configuration;
using OTSSignsOrchestrator.Services; using OTSSignsOrchestrator.Services;
using OTSSignsOrchestrator.Clients; using OTSSignsOrchestrator.Clients;
@@ -51,6 +50,7 @@ public sealed class Phase1Pipeline : IProvisioningPipeline
var gitService = scope.ServiceProvider.GetRequiredService<GitTemplateService>(); var gitService = scope.ServiceProvider.GetRequiredService<GitTemplateService>();
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>(); var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>(); var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
var shell = scope.ServiceProvider.GetRequiredService<SwarmShellService>();
var ctx = await BuildContextAsync(job, db, ct); var ctx = await BuildContextAsync(job, db, ct);
var runner = new StepRunner(db, hub, _logger, job.Id, TotalSteps); var runner = new StepRunner(db, hub, _logger, job.Id, TotalSteps);
@@ -74,50 +74,29 @@ public sealed class Phase1Pipeline : IProvisioningPipeline
if (!int.TryParse(mysqlPort, out var port)) port = 3306; if (!int.TryParse(mysqlPort, out var port)) port = 3306;
// SSH to MySQL host, create database and user
// Reuse pattern from InstanceService.CreateMySqlDatabaseAsync — runs SQL
// via SSH tunnel to the MySQL server.
var sshHost = await GetSwarmSshHostAsync(settings);
using var sshClient = CreateSshClient(sshHost);
sshClient.Connect();
try
{
// Create database // Create database
var createDbCmd = $"mysql -h {mysqlHost} -P {port} -u {mysqlAdminUser} " + var createDbCmd = $"mysql -h {mysqlHost} -P {port} -u {mysqlAdminUser} " +
$"-p'{mysqlAdminPassword}' -e " + $"-p'{mysqlAdminPassword}' -e " +
$"\"CREATE DATABASE IF NOT EXISTS \\`{mysqlDbName}\\`\""; $"\"CREATE DATABASE IF NOT EXISTS \\`{mysqlDbName}\\`\"";
RunSshCommand(sshClient, createDbCmd); await shell.RunCommandAsync(createDbCmd);
// Create user // Create user
var createUserCmd = $"mysql -h {mysqlHost} -P {port} -u {mysqlAdminUser} " + var createUserCmd = $"mysql -h {mysqlHost} -P {port} -u {mysqlAdminUser} " +
$"-p'{mysqlAdminPassword}' -e " + $"-p'{mysqlAdminPassword}' -e " +
$"\"CREATE USER IF NOT EXISTS '{mysqlUserName}'@'%' IDENTIFIED BY '{mysqlPassword}'\""; $"\"CREATE USER IF NOT EXISTS '{mysqlUserName}'@'%' IDENTIFIED BY '{mysqlPassword}'\"";
RunSshCommand(sshClient, createUserCmd); await shell.RunCommandAsync(createUserCmd);
// Grant privileges // Grant privileges
var grantCmd = $"mysql -h {mysqlHost} -P {port} -u {mysqlAdminUser} " + var grantCmd = $"mysql -h {mysqlHost} -P {port} -u {mysqlAdminUser} " +
$"-p'{mysqlAdminPassword}' -e " + $"-p'{mysqlAdminPassword}' -e " +
$"\"GRANT ALL PRIVILEGES ON \\`{mysqlDbName}\\`.* TO '{mysqlUserName}'@'%'; FLUSH PRIVILEGES;\""; $"\"GRANT ALL PRIVILEGES ON \\`{mysqlDbName}\\`.* TO '{mysqlUserName}'@'%'; FLUSH PRIVILEGES;\"";
RunSshCommand(sshClient, grantCmd); await shell.RunCommandAsync(grantCmd);
return $"Database '{mysqlDbName}' and user '{mysqlUserName}' created on {mysqlHost}:{port}."; return $"Database '{mysqlDbName}' and user '{mysqlUserName}' created on {mysqlHost}:{port}.";
}
finally
{
sshClient.Disconnect();
}
}, ct); }, ct);
// ── Step 2: docker-secrets ────────────────────────────────────────── // ── Step 2: docker-secrets ──────────────────────────────────────────
await runner.RunAsync("docker-secrets", async () => await runner.RunAsync("docker-secrets", async () =>
{
// Reuse IDockerSecretsService pattern — create secrets via SSH docker CLI
var sshHost = await GetSwarmSshHostAsync(settings);
using var sshClient = CreateSshClient(sshHost);
sshClient.Connect();
try
{ {
var secrets = new Dictionary<string, string> var secrets = new Dictionary<string, string>
{ {
@@ -131,20 +110,12 @@ public sealed class Phase1Pipeline : IProvisioningPipeline
foreach (var (name, value) in secrets) foreach (var (name, value) in secrets)
{ {
// Remove existing secret if present (idempotent rotate) // Remove existing secret if present (idempotent rotate)
RunSshCommandAllowFailure(sshClient, $"docker secret rm {name}"); await shell.RunCommandAllowFailureAsync($"docker secret rm {name}");
await shell.RunCommandWithStdinAsync($"docker secret create {name} -", value);
var safeValue = value.Replace("'", "'\\''");
var cmd = $"printf '%s' '{safeValue}' | docker secret create {name} -";
RunSshCommand(sshClient, cmd);
created.Add(name); created.Add(name);
} }
return $"Docker secrets created: {string.Join(", ", created)}."; return $"Docker secrets created: {string.Join(", ", created)}.";
}
finally
{
sshClient.Disconnect();
}
}, ct); }, ct);
// ── Step 3: nfs-dirs ──────────────────────────────────────────────── // ── Step 3: nfs-dirs ────────────────────────────────────────────────
@@ -157,12 +128,6 @@ public sealed class Phase1Pipeline : IProvisioningPipeline
if (string.IsNullOrWhiteSpace(nfsServer)) if (string.IsNullOrWhiteSpace(nfsServer))
return "NFS server not configured — skipping directory creation."; return "NFS server not configured — skipping directory creation.";
var sshHost = await GetSwarmSshHostAsync(settings);
using var sshClient = CreateSshClient(sshHost);
sshClient.Connect();
try
{
// Build the base path for NFS dirs // Build the base path for NFS dirs
var export = (nfsExport ?? string.Empty).TrimEnd('/'); var export = (nfsExport ?? string.Empty).TrimEnd('/');
var folder = (nfsExportFolder ?? string.Empty).Trim('/'); var folder = (nfsExportFolder ?? string.Empty).Trim('/');
@@ -177,33 +142,26 @@ public sealed class Phase1Pipeline : IProvisioningPipeline
$"{abbrev}/cms-ca-certs", $"{abbrev}/cms-ca-certs",
}; };
// Create directories via sudo on the NFS host // Temporarily mount the NFS export to create directories.
// The swarm node mounts NFS, so we can create directories by // In local (privileged container) mode, sudo is a no-op as root.
// temporarily mounting, creating, then unmounting.
var mountPoint = $"/tmp/nfs-provision-{abbrev}"; var mountPoint = $"/tmp/nfs-provision-{abbrev}";
RunSshCommand(sshClient, $"sudo mkdir -p {mountPoint}"); await shell.RunCommandAsync($"sudo mkdir -p {mountPoint}");
RunSshCommand(sshClient, await shell.RunCommandAsync($"sudo mount -t nfs4 {nfsServer}:{basePath} {mountPoint}");
$"sudo mount -t nfs4 {nfsServer}:{basePath} {mountPoint}");
try try
{ {
foreach (var subdir in subdirs) foreach (var subdir in subdirs)
{ {
RunSshCommand(sshClient, $"sudo mkdir -p {mountPoint}/{subdir}"); await shell.RunCommandAsync($"sudo mkdir -p {mountPoint}/{subdir}");
} }
} }
finally finally
{ {
RunSshCommandAllowFailure(sshClient, $"sudo umount {mountPoint}"); await shell.RunCommandAllowFailureAsync($"sudo umount {mountPoint}");
RunSshCommandAllowFailure(sshClient, $"sudo rmdir {mountPoint}"); await shell.RunCommandAllowFailureAsync($"sudo rmdir {mountPoint}");
} }
return $"NFS directories created under {basePath}: {string.Join(", ", subdirs)}."; return $"NFS directories created under {basePath}: {string.Join(", ", subdirs)}.";
}
finally
{
sshClient.Disconnect();
}
}, ct); }, ct);
// ── Step 4: authentik-provision ───────────────────────────────────── // ── Step 4: authentik-provision ─────────────────────────────────────
@@ -323,22 +281,10 @@ public sealed class Phase1Pipeline : IProvisioningPipeline
var composeYaml = composeRenderer.Render(templateConfig.Yaml, renderCtx); var composeYaml = composeRenderer.Render(templateConfig.Yaml, renderCtx);
// Deploy via SSH: pipe compose YAML to docker stack deploy // Pipe compose YAML to docker stack deploy
var sshHost = await GetSwarmSshHostAsync(settings); var deployResult = await shell.RunCommandWithStdinAsync(
using var sshClient = CreateSshClient(sshHost); $"docker stack deploy -c - {stackName}", composeYaml);
sshClient.Connect(); return $"Stack '{stackName}' deployed. Output: {deployResult}";
try
{
var safeYaml = composeYaml.Replace("'", "'\\''");
var deployCmd = $"printf '%s' '{safeYaml}' | docker stack deploy -c - {stackName}";
var result = RunSshCommand(sshClient, deployCmd);
return $"Stack '{stackName}' deployed. Output: {result}";
}
finally
{
sshClient.Disconnect();
}
}, ct); }, ct);
// ── Step 6: credential-store ──────────────────────────────────────── // ── Step 6: credential-store ────────────────────────────────────────
@@ -402,75 +348,6 @@ public sealed class Phase1Pipeline : IProvisioningPipeline
}; };
} }
/// <summary>
/// Reads SSH connection details for the Docker Swarm host from settings.
/// Expects settings keys: "Ssh.SwarmHost", "Ssh.SwarmPort", "Ssh.SwarmUser", "Ssh.SwarmKeyPath".
/// </summary>
private static async Task<SshConnectionInfo> GetSwarmSshHostAsync(SettingsService settings)
{
var host = await settings.GetAsync("Ssh.SwarmHost")
?? throw new InvalidOperationException("SSH Swarm host not configured (Ssh.SwarmHost).");
var portStr = await settings.GetAsync("Ssh.SwarmPort", "22");
var user = await settings.GetAsync("Ssh.SwarmUser", "root");
var keyPath = await settings.GetAsync("Ssh.SwarmKeyPath");
var password = await settings.GetAsync("Ssh.SwarmPassword");
if (!int.TryParse(portStr, out var port)) port = 22;
return new SshConnectionInfo(host, port, user, keyPath, password);
}
private static SshClient CreateSshClient(SshConnectionInfo info)
{
var authMethods = new List<AuthenticationMethod>();
if (!string.IsNullOrEmpty(info.KeyPath))
{
authMethods.Add(new PrivateKeyAuthenticationMethod(
info.Username, new PrivateKeyFile(info.KeyPath)));
}
if (!string.IsNullOrEmpty(info.Password))
{
authMethods.Add(new PasswordAuthenticationMethod(info.Username, info.Password));
}
if (authMethods.Count == 0)
{
var defaultKeyPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh", "id_rsa");
if (File.Exists(defaultKeyPath))
{
authMethods.Add(new PrivateKeyAuthenticationMethod(
info.Username, new PrivateKeyFile(defaultKeyPath)));
}
else
{
throw new InvalidOperationException(
$"No SSH authentication method available for {info.Host}:{info.Port}. " +
"Configure Ssh.SwarmKeyPath or Ssh.SwarmPassword in settings.");
}
}
var connInfo = new Renci.SshNet.ConnectionInfo(info.Host, info.Port, info.Username, authMethods.ToArray());
return new SshClient(connInfo);
}
private static string RunSshCommand(SshClient client, string command)
{
using var cmd = client.RunCommand(command);
if (cmd.ExitStatus != 0)
throw new InvalidOperationException(
$"SSH command failed (exit {cmd.ExitStatus}): {cmd.Error}");
return cmd.Result;
}
private static void RunSshCommandAllowFailure(SshClient client, string command)
{
using var cmd = client.RunCommand(command);
// Intentionally ignore exit code — used for idempotent cleanup operations
}
private static string GenerateRandomPassword(int length) private static string GenerateRandomPassword(int length)
{ {
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"; const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
@@ -484,11 +361,4 @@ public sealed class Phase1Pipeline : IProvisioningPipeline
$"API call failed with status {response.StatusCode}: {response.Error?.Content}"); $"API call failed with status {response.StatusCode}: {response.Error?.Content}");
} }
/// <summary>SSH connection details read from Bitwarden settings.</summary>
internal sealed record SshConnectionInfo(
string Host,
int Port,
string Username,
string? KeyPath,
string? Password);
} }

View File

@@ -8,7 +8,6 @@ using OTSSignsOrchestrator.Clients;
using OTSSignsOrchestrator.Data; using OTSSignsOrchestrator.Data;
using OTSSignsOrchestrator.Data.Entities; using OTSSignsOrchestrator.Data.Entities;
using OTSSignsOrchestrator.Hubs; using OTSSignsOrchestrator.Hubs;
using OTSSignsOrchestrator.Services;
namespace OTSSignsOrchestrator.Workers; namespace OTSSignsOrchestrator.Workers;

View File

@@ -1,6 +1,5 @@
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Renci.SshNet;
using OTSSignsOrchestrator.Services; using OTSSignsOrchestrator.Services;
using OTSSignsOrchestrator.Data; using OTSSignsOrchestrator.Data;
using OTSSignsOrchestrator.Data.Entities; using OTSSignsOrchestrator.Data.Entities;
@@ -42,6 +41,7 @@ public sealed class ReactivatePipeline : IProvisioningPipeline
var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>(); var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
var hub = scope.ServiceProvider.GetRequiredService<IHubContext<FleetHub, IFleetClient>>(); var hub = scope.ServiceProvider.GetRequiredService<IHubContext<FleetHub, IFleetClient>>();
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>(); var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
var shell = scope.ServiceProvider.GetRequiredService<SwarmShellService>();
var ctx = await BuildContextAsync(job, db, ct); var ctx = await BuildContextAsync(job, db, ct);
var runner = new StepRunner(db, hub, _logger, job.Id, TotalSteps); var runner = new StepRunner(db, hub, _logger, job.Id, TotalSteps);
@@ -51,20 +51,9 @@ public sealed class ReactivatePipeline : IProvisioningPipeline
// ── Step 1: scale-up ──────────────────────────────────────────────── // ── Step 1: scale-up ────────────────────────────────────────────────
await runner.RunAsync("scale-up", async () => await runner.RunAsync("scale-up", async () =>
{ {
var sshHost = await GetSwarmSshHostAsync(settings); var result = await shell.RunCommandAsync(
using var sshClient = CreateSshClient(sshHost); $"docker service scale xibo-{abbrev}_web=1 xibo-{abbrev}_xmr=1");
sshClient.Connect();
try
{
var cmd = $"docker service scale xibo-{abbrev}_web=1 xibo-{abbrev}_xmr=1";
var result = RunSshCommand(sshClient, cmd);
return $"Scaled up services for xibo-{abbrev}. Output: {result}"; return $"Scaled up services for xibo-{abbrev}. Output: {result}";
}
finally
{
sshClient.Disconnect();
}
}, ct); }, ct);
// ── Step 2: health-verify ─────────────────────────────────────────── // ── Step 2: health-verify ───────────────────────────────────────────
@@ -176,54 +165,4 @@ public sealed class ReactivatePipeline : IProvisioningPipeline
}; };
} }
private static async Task<SshConnectionInfo> GetSwarmSshHostAsync(SettingsService settings)
{
var host = await settings.GetAsync("Ssh.SwarmHost")
?? throw new InvalidOperationException("SSH Swarm host not configured (Ssh.SwarmHost).");
var portStr = await settings.GetAsync("Ssh.SwarmPort", "22");
var user = await settings.GetAsync("Ssh.SwarmUser", "root");
var keyPath = await settings.GetAsync("Ssh.SwarmKeyPath");
var password = await settings.GetAsync("Ssh.SwarmPassword");
if (!int.TryParse(portStr, out var port)) port = 22;
return new SshConnectionInfo(host, port, user, keyPath, password);
}
private static SshClient CreateSshClient(SshConnectionInfo info)
{
var authMethods = new List<AuthenticationMethod>();
if (!string.IsNullOrEmpty(info.KeyPath))
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(info.KeyPath)));
if (!string.IsNullOrEmpty(info.Password))
authMethods.Add(new PasswordAuthenticationMethod(info.Username, info.Password));
if (authMethods.Count == 0)
{
var defaultKeyPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh", "id_rsa");
if (File.Exists(defaultKeyPath))
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(defaultKeyPath)));
else
throw new InvalidOperationException(
$"No SSH authentication method available for {info.Host}:{info.Port}.");
}
var connInfo = new Renci.SshNet.ConnectionInfo(info.Host, info.Port, info.Username, authMethods.ToArray());
return new SshClient(connInfo);
}
private static string RunSshCommand(SshClient client, string command)
{
using var cmd = client.RunCommand(command);
if (cmd.ExitStatus != 0)
throw new InvalidOperationException(
$"SSH command failed (exit {cmd.ExitStatus}): {cmd.Error}");
return cmd.Result;
}
internal sealed record SshConnectionInfo(
string Host, int Port, string Username, string? KeyPath, string? Password);
} }

View File

@@ -1,6 +1,5 @@
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Renci.SshNet;
using OTSSignsOrchestrator.Services; using OTSSignsOrchestrator.Services;
using OTSSignsOrchestrator.Data; using OTSSignsOrchestrator.Data;
using OTSSignsOrchestrator.Data.Entities; using OTSSignsOrchestrator.Data.Entities;
@@ -41,6 +40,7 @@ public sealed class SuspendPipeline : IProvisioningPipeline
var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>(); var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
var hub = scope.ServiceProvider.GetRequiredService<IHubContext<FleetHub, IFleetClient>>(); var hub = scope.ServiceProvider.GetRequiredService<IHubContext<FleetHub, IFleetClient>>();
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>(); var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
var shell = scope.ServiceProvider.GetRequiredService<SwarmShellService>();
var ctx = await BuildContextAsync(job, db, ct); var ctx = await BuildContextAsync(job, db, ct);
var runner = new StepRunner(db, hub, _logger, job.Id, TotalSteps); var runner = new StepRunner(db, hub, _logger, job.Id, TotalSteps);
@@ -50,20 +50,9 @@ public sealed class SuspendPipeline : IProvisioningPipeline
// ── Step 1: scale-down ────────────────────────────────────────────── // ── Step 1: scale-down ──────────────────────────────────────────────
await runner.RunAsync("scale-down", async () => await runner.RunAsync("scale-down", async () =>
{ {
var sshHost = await GetSwarmSshHostAsync(settings); var result = await shell.RunCommandAsync(
using var sshClient = CreateSshClient(sshHost); $"docker service scale xibo-{abbrev}_web=0 xibo-{abbrev}_xmr=0");
sshClient.Connect();
try
{
var cmd = $"docker service scale xibo-{abbrev}_web=0 xibo-{abbrev}_xmr=0";
var result = RunSshCommand(sshClient, cmd);
return $"Scaled down services for xibo-{abbrev}. Output: {result}"; return $"Scaled down services for xibo-{abbrev}. Output: {result}";
}
finally
{
sshClient.Disconnect();
}
}, ct); }, ct);
// ── Step 2: update-status ─────────────────────────────────────────── // ── Step 2: update-status ───────────────────────────────────────────
@@ -142,54 +131,4 @@ public sealed class SuspendPipeline : IProvisioningPipeline
}; };
} }
private static async Task<SshConnectionInfo> GetSwarmSshHostAsync(SettingsService settings)
{
var host = await settings.GetAsync("Ssh.SwarmHost")
?? throw new InvalidOperationException("SSH Swarm host not configured (Ssh.SwarmHost).");
var portStr = await settings.GetAsync("Ssh.SwarmPort", "22");
var user = await settings.GetAsync("Ssh.SwarmUser", "root");
var keyPath = await settings.GetAsync("Ssh.SwarmKeyPath");
var password = await settings.GetAsync("Ssh.SwarmPassword");
if (!int.TryParse(portStr, out var port)) port = 22;
return new SshConnectionInfo(host, port, user, keyPath, password);
}
private static SshClient CreateSshClient(SshConnectionInfo info)
{
var authMethods = new List<AuthenticationMethod>();
if (!string.IsNullOrEmpty(info.KeyPath))
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(info.KeyPath)));
if (!string.IsNullOrEmpty(info.Password))
authMethods.Add(new PasswordAuthenticationMethod(info.Username, info.Password));
if (authMethods.Count == 0)
{
var defaultKeyPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh", "id_rsa");
if (File.Exists(defaultKeyPath))
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(defaultKeyPath)));
else
throw new InvalidOperationException(
$"No SSH authentication method available for {info.Host}:{info.Port}.");
}
var connInfo = new Renci.SshNet.ConnectionInfo(info.Host, info.Port, info.Username, authMethods.ToArray());
return new SshClient(connInfo);
}
private static string RunSshCommand(SshClient client, string command)
{
using var cmd = client.RunCommand(command);
if (cmd.ExitStatus != 0)
throw new InvalidOperationException(
$"SSH command failed (exit {cmd.ExitStatus}): {cmd.Error}");
return cmd.Result;
}
internal sealed record SshConnectionInfo(
string Host, int Port, string Username, string? KeyPath, string? Password);
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More