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:
48
.env.example
48
.env.example
@@ -1,47 +1,15 @@
|
||||
# OTSSignsOrchestrator — environment variables
|
||||
# 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 ───────────────────────────────────────────────────────────────
|
||||
# Used directly by the app. When running via docker-compose, POSTGRES_PASSWORD
|
||||
# is also required so the postgres service can initialise the database.
|
||||
ConnectionStrings__OrchestratorDb=Host=postgres;Port=5432;Database=orchestrator;Username=ots;Password=changeme
|
||||
# Password for the postgres service AND the app connection string.
|
||||
# Generate: openssl rand -base64 32
|
||||
POSTGRES_PASSWORD=changeme
|
||||
|
||||
# ── JWT ──────────────────────────────────────────────────────────────────────
|
||||
# Key must be at least 32 characters (256-bit). Generate with:
|
||||
# openssl rand -base64 32
|
||||
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
|
||||
# Key must be at least 32 characters (256-bit).
|
||||
# Generate: openssl rand -base64 48
|
||||
JWT_KEY=change-me-to-a-random-256-bit-key
|
||||
|
||||
@@ -35,8 +35,13 @@ WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libssl3 \
|
||||
ca-certificates \
|
||||
nfs-common \
|
||||
default-mysql-client \
|
||||
&& 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 .
|
||||
|
||||
# Data Protection keys must survive restarts — mount a volume here
|
||||
|
||||
@@ -103,12 +103,12 @@ public static class HostsApi
|
||||
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);
|
||||
if (host == null) return Results.NotFound();
|
||||
|
||||
docker.SetHost(host);
|
||||
var docker = dockerFactory.GetCliService(host);
|
||||
var nodes = await docker.ListNodesAsync();
|
||||
return Results.Ok(nodes);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OTSSignsOrchestrator.Services;
|
||||
using OTSSignsOrchestrator.Data;
|
||||
using OTSSignsOrchestrator.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.Api;
|
||||
|
||||
@@ -11,7 +10,7 @@ public static class InstancesApi
|
||||
{
|
||||
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 allInstances = new List<object>();
|
||||
@@ -20,7 +19,7 @@ public static class InstancesApi
|
||||
{
|
||||
try
|
||||
{
|
||||
docker.SetHost(host);
|
||||
var docker = dockerFactory.GetCliService(host);
|
||||
var stacks = await docker.ListStacksAsync();
|
||||
foreach (var stack in stacks.Where(s => s.Name.EndsWith("-cms-stack")))
|
||||
{
|
||||
@@ -44,22 +43,22 @@ public static class InstancesApi
|
||||
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");
|
||||
|
||||
docker.SetHost(host);
|
||||
var docker = dockerFactory.GetCliService(host);
|
||||
var services = await docker.InspectStackServicesAsync(stackName);
|
||||
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");
|
||||
|
||||
docker.SetHost(host);
|
||||
var docker = dockerFactory.GetCliService(host);
|
||||
var services = await docker.InspectStackServicesAsync(stackName);
|
||||
foreach (var svc in services)
|
||||
await docker.ForceUpdateServiceAsync(svc.Name);
|
||||
@@ -68,25 +67,23 @@ public static class InstancesApi
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
docker.SetHost(host);
|
||||
var docker = dockerFactory.GetCliService(host);
|
||||
var success = await docker.ForceUpdateServiceAsync(serviceName);
|
||||
return success ? Results.Ok(new { message = "Service restarted" }) : Results.StatusCode(500);
|
||||
});
|
||||
|
||||
group.MapDelete("/live/{stackName}",
|
||||
async (string stackName, OrchestratorDbContext db, SshDockerCliService docker,
|
||||
SshDockerSecretsService secrets, 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");
|
||||
|
||||
docker.SetHost(host);
|
||||
secrets.SetHost(host);
|
||||
var abbrev = stackName.EndsWith("-cms-stack")
|
||||
? stackName[..^"-cms-stack".Length] : stackName.Split('-')[0];
|
||||
|
||||
@@ -95,23 +92,22 @@ public static class InstancesApi
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
docker.SetHost(host);
|
||||
var (success, message) = await instanceService.RotateMySqlPasswordAsync(stackName);
|
||||
return Results.Ok(new { success, message });
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
docker.SetHost(host);
|
||||
var docker = dockerFactory.GetCliService(host);
|
||||
var logs = await docker.GetServiceLogsAsync(stackName, service, tailLines ?? 200);
|
||||
return Results.Ok(logs);
|
||||
});
|
||||
@@ -158,17 +154,17 @@ public static class InstancesApi
|
||||
}
|
||||
|
||||
/// <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>
|
||||
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();
|
||||
foreach (var host in hosts)
|
||||
{
|
||||
try
|
||||
{
|
||||
docker.SetHost(host);
|
||||
var docker = dockerFactory.GetCliService(host);
|
||||
var stacks = await docker.ListStacksAsync();
|
||||
if (stacks.Any(s => s.Name == stackName))
|
||||
return host;
|
||||
|
||||
@@ -1,100 +1,4 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OTSSignsOrchestrator.Data;
|
||||
using OTSSignsOrchestrator.Data.Entities;
|
||||
// This file is intentionally empty — OperatorsApi has been removed.
|
||||
// Operator management is now handled via OIDC + admin token.
|
||||
// Delete this file from the project.
|
||||
|
||||
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);
|
||||
|
||||
@@ -73,7 +73,7 @@ public static class ProvisionApi
|
||||
});
|
||||
|
||||
group.MapPost("/deploy", async (DeployRequest req,
|
||||
OrchestratorDbContext db, SshDockerCliService docker, SshDockerSecretsService secrets,
|
||||
OrchestratorDbContext db, IDockerServiceFactory dockerFactory,
|
||||
InstanceService instanceService) =>
|
||||
{
|
||||
if (!Guid.TryParse(req.HostId, out var hostId))
|
||||
@@ -81,8 +81,9 @@ public static class ProvisionApi
|
||||
var host = await db.SshHosts.FindAsync(hostId);
|
||||
if (host == null) return Results.BadRequest("Host not found");
|
||||
|
||||
docker.SetHost(host);
|
||||
secrets.SetHost(host);
|
||||
// Resolve and configure both services via the factory
|
||||
_ = dockerFactory.GetCliService(host);
|
||||
_ = dockerFactory.GetSecretsService(host);
|
||||
|
||||
var dto = new CreateInstanceDto
|
||||
{
|
||||
|
||||
@@ -8,12 +8,12 @@ public static class SecretsApi
|
||||
public static void MapSecretsEndpoints(this WebApplication app)
|
||||
{
|
||||
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);
|
||||
if (host == null) return Results.NotFound();
|
||||
|
||||
secrets.SetHost(host);
|
||||
var secrets = dockerFactory.GetSecretsService(host);
|
||||
var list = await secrets.ListSecretsAsync();
|
||||
return Results.Ok(list);
|
||||
}).RequireAuthorization();
|
||||
|
||||
@@ -13,27 +13,47 @@ public static class SettingsApi
|
||||
|
||||
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",
|
||||
"Authentik", "Xibo", "Stripe", "Email", "Bitwarden", "Instance"
|
||||
["Git"] = [SettingsService.GitRepoUrl, SettingsService.GitRepoPat],
|
||||
["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>();
|
||||
|
||||
foreach (var category in categories)
|
||||
foreach (var (category, canonicalKeys) in knownKeys)
|
||||
{
|
||||
var catSettings = await settings.GetCategoryAsync(category);
|
||||
if (catSettings.Count == 0) continue;
|
||||
// Merge stored values with the canonical key list
|
||||
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,
|
||||
value = kvp.Key.Contains("Password") || kvp.Key.Contains("Secret") || kvp.Key.Contains("Pat")
|
||||
|| kvp.Key.Contains("ApiKey") || kvp.Key.Contains("AccessToken")
|
||||
? "" : (kvp.Value ?? ""),
|
||||
category,
|
||||
isSensitive = kvp.Key.Contains("Password") || kvp.Key.Contains("Secret") || kvp.Key.Contains("Pat")
|
||||
|| kvp.Key.Contains("ApiKey") || kvp.Key.Contains("AccessToken"),
|
||||
var isSensitive = key.Contains("Password") || key.Contains("Secret") || key.Contains("Pat")
|
||||
|| key.Contains("ApiKey") || key.Contains("AccessToken");
|
||||
stored.TryGetValue(key, out var storedValue);
|
||||
return new
|
||||
{
|
||||
key,
|
||||
value = isSensitive ? "" : (storedValue ?? ""),
|
||||
category,
|
||||
isSensitive,
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
groups.Add(new { category, settings = settingItems });
|
||||
@@ -71,7 +91,7 @@ public static class SettingsApi
|
||||
});
|
||||
|
||||
group.MapPost("/test-mysql", async (SettingsService settings,
|
||||
SshDockerCliService docker, OrchestratorDbContext db) =>
|
||||
IDockerServiceFactory dockerFactory, OrchestratorDbContext db) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -82,12 +102,11 @@ public static class SettingsApi
|
||||
|
||||
if (!int.TryParse(mySqlPort, out var port)) port = 3306;
|
||||
|
||||
// Need a host with SSH to tunnel through
|
||||
var host = await db.SshHosts.FirstOrDefaultAsync();
|
||||
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);
|
||||
await using (conn)
|
||||
using (tunnel)
|
||||
|
||||
117
OTSSignsOrchestrator/Auth/AdminTokenService.cs
Normal file
117
OTSSignsOrchestrator/Auth/AdminTokenService.cs
Normal 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 <container> /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();
|
||||
}
|
||||
}
|
||||
@@ -1,76 +1,35 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using OTSSignsOrchestrator.Data;
|
||||
using OTSSignsOrchestrator.Data.Entities;
|
||||
|
||||
namespace OTSSignsOrchestrator.Auth;
|
||||
|
||||
public class OperatorAuthService
|
||||
{
|
||||
private readonly OrchestratorDbContext _db;
|
||||
private readonly JwtOptions _jwt;
|
||||
private readonly ILogger<OperatorAuthService> _logger;
|
||||
|
||||
public OperatorAuthService(
|
||||
OrchestratorDbContext db,
|
||||
IOptions<JwtOptions> jwt,
|
||||
ILogger<OperatorAuthService> logger)
|
||||
public OperatorAuthService(IOptions<JwtOptions> jwt)
|
||||
{
|
||||
_db = db;
|
||||
_jwt = jwt.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<(string Jwt, string RefreshToken)> LoginAsync(string email, string password)
|
||||
{
|
||||
var op = await _db.Operators.FirstOrDefaultAsync(
|
||||
o => o.Email == email.Trim().ToLowerInvariant());
|
||||
|
||||
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)
|
||||
/// <summary>
|
||||
/// Generates a signed JWT for the given identity. Used by admin-token redemption
|
||||
/// and the OIDC callback to issue the <c>ots_access_token</c> cookie.
|
||||
/// </summary>
|
||||
public string GenerateJwt(string email, string role)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwt.Key));
|
||||
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, op.Id.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Email, op.Email),
|
||||
new Claim(ClaimTypes.Name, op.Email),
|
||||
new Claim(ClaimTypes.Role, op.Role.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Sub, email),
|
||||
new Claim(JwtRegisteredClaimNames.Email, email),
|
||||
new Claim(ClaimTypes.Name, email),
|
||||
new Claim(ClaimTypes.Role, role),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
@@ -83,20 +42,4 @@ public class OperatorAuthService
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
25
OTSSignsOrchestrator/ClientApp/components.json
Normal file
25
OTSSignsOrchestrator/ClientApp/components.json
Normal 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": {}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OTS Signs Orchestrator</title>
|
||||
</head>
|
||||
<body class="bg-gray-950 text-gray-100 antialiased">
|
||||
<body class="bg-background text-foreground antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
3905
OTSSignsOrchestrator/ClientApp/package-lock.json
generated
3905
OTSSignsOrchestrator/ClientApp/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,13 +9,23 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@microsoft/signalr": "^8.0.7",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^1.0.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.0",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -23,10 +33,10 @@
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.1.0",
|
||||
"@vitejs/plugin-react": "^4.3.4"
|
||||
"vite": "^6.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import AppShell from './components/layout/AppShell';
|
||||
import ErrorBoundary from './components/shared/ErrorBoundary';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import AdminTokenPage from './pages/AdminTokenPage';
|
||||
import FleetPage from './pages/FleetPage';
|
||||
import CustomerDetailPage from './pages/CustomerDetailPage';
|
||||
import HostsPage from './pages/HostsPage';
|
||||
import InstancesPage from './pages/InstancesPage';
|
||||
import CreateInstancePage from './pages/CreateInstancePage';
|
||||
import SecretsPage from './pages/SecretsPage';
|
||||
import InstanceDetailPage from './pages/InstanceDetailPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import LogsPage from './pages/LogsPage';
|
||||
import ReportsPage from './pages/ReportsPage';
|
||||
import OperatorsPage from './pages/OperatorsPage';
|
||||
import HealthPage from './pages/HealthPage';
|
||||
import AuditPage from './pages/AuditPage';
|
||||
import CustomersPage from './pages/CustomersPage';
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
@@ -26,27 +25,31 @@ export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/admintoken" element={<AdminTokenPage />} />
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/fleet" replace />} />
|
||||
<Route path="/fleet" element={<FleetPage />} />
|
||||
<Route path="/fleet/:id" element={<CustomerDetailPage />} />
|
||||
<Route path="/hosts" element={<HostsPage />} />
|
||||
<Route path="/instances" element={<InstancesPage />} />
|
||||
<Route path="/instances/new" element={<CreateInstancePage />} />
|
||||
<Route path="/secrets" element={<SecretsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/logs" element={<LogsPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/operators" element={<OperatorsPage />} />
|
||||
<Route path="/health" element={<HealthPage />} />
|
||||
<Route path="/audit" element={<AuditPage />} />
|
||||
<Route path="/customers" element={<CustomersPage />} />
|
||||
</Routes>
|
||||
<ErrorBoundary>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/fleet" replace />} />
|
||||
<Route path="/fleet" element={<FleetPage />} />
|
||||
<Route path="/fleet/:id" element={<CustomerDetailPage />} />
|
||||
<Route path="/hosts" element={<HostsPage />} />
|
||||
<Route path="/instances" element={<InstancesPage />} />
|
||||
<Route path="/instances/:stackName" element={<InstanceDetailPage />} />
|
||||
<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="/logs" element={<LogsPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/health" element={<HealthPage />} />
|
||||
<Route path="/customers" element={<CustomersPage />} />
|
||||
<Route path="/customers/:id" element={<CustomerDetailPage />} />
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { apiPost, apiGet } from './client';
|
||||
|
||||
export function login(email: string, password: string) {
|
||||
return apiPost<{ message: string }>('/api/auth/web/login', { email, password });
|
||||
export function redeemAdminToken(token: string) {
|
||||
return apiPost<{ message: string }>('/api/auth/admin-token', { token });
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
@@ -9,5 +9,5 @@ export function logout() {
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -6,12 +6,6 @@ export class ApiError extends Error {
|
||||
|
||||
async function handleResponse<T>(res: Response): Promise<T> {
|
||||
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';
|
||||
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> {
|
||||
const res = await fetch(path, { credentials: 'include', ...init });
|
||||
try {
|
||||
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;
|
||||
}
|
||||
return handleResponse<T>(res);
|
||||
}
|
||||
|
||||
export function apiGet<T>(path: string) {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,33 +2,35 @@ import { ReactNode } from 'react';
|
||||
import Sidebar from './Sidebar';
|
||||
import StatusBanner from '../shared/StatusBanner';
|
||||
import { useSignalR } from '../../hooks/useSignalR';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { logout } from '../../api/auth';
|
||||
import { Wifi, WifiOff } from 'lucide-react';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
|
||||
export default function AppShell({ children }: { children: ReactNode }) {
|
||||
useSignalR();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const logoutStore = useAuthStore((s) => s.logout);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout().catch(() => {});
|
||||
logoutStore();
|
||||
};
|
||||
const { connectionState } = useSignalR();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<div className="flex h-screen bg-background">
|
||||
<Sidebar />
|
||||
<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 />
|
||||
<div className="flex items-center gap-4">
|
||||
{user && <span className="text-sm text-gray-400">{user.email}</span>}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="rounded bg-gray-800 px-3 py-1 text-sm text-gray-300 hover:bg-gray-700"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="flex items-center">
|
||||
{connectionState === 'Connected' ? (
|
||||
<Wifi className="h-4 w-4 text-status-success" />
|
||||
) : (
|
||||
<WifiOff className={`h-4 w-4 ${connectionState === 'Reconnecting' ? 'animate-pulse text-status-warning' : 'text-status-danger'}`} />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{connectionState === 'Connected'
|
||||
? 'Real-time updates active'
|
||||
: connectionState === 'Reconnecting'
|
||||
? 'Reconnecting…'
|
||||
: 'Disconnected — updates paused'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto p-6">{children}</main>
|
||||
|
||||
@@ -1,42 +1,127 @@
|
||||
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 = [
|
||||
{ to: '/fleet', label: 'Fleet' },
|
||||
{ to: '/customers', label: 'Customers' },
|
||||
{ to: '/instances', label: 'Instances' },
|
||||
{ to: '/instances/new', label: 'Deploy' },
|
||||
{ to: '/hosts', label: 'SSH Hosts' },
|
||||
{ to: '/health', label: 'Health' },
|
||||
{ to: '/secrets', label: 'Secrets' },
|
||||
{ to: '/settings', label: 'Settings' },
|
||||
{ to: '/logs', label: 'Op Logs' },
|
||||
{ to: '/audit', label: 'Audit Logs' },
|
||||
{ to: '/reports', label: 'Reports' },
|
||||
{ to: '/operators', label: 'Operators' },
|
||||
interface NavItem { to: string; label: string; icon: LucideIcon; end?: boolean }
|
||||
interface NavGroup { heading: string; items: NavItem[] }
|
||||
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
heading: 'Overview',
|
||||
items: [
|
||||
{ to: '/fleet', label: 'Fleet', icon: LayoutDashboard },
|
||||
{ to: '/customers', label: 'Customers', icon: Users },
|
||||
],
|
||||
},
|
||||
{
|
||||
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() {
|
||||
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 (
|
||||
<aside className="w-56 flex-shrink-0 border-r border-gray-800 bg-gray-950 p-4">
|
||||
<h1 className="mb-6 text-lg font-bold text-white">OTS Orchestrator</h1>
|
||||
<nav className="flex flex-col gap-1">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/instances'}
|
||||
className={({ isActive }) =>
|
||||
`rounded px-3 py-2 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
<aside className="flex w-64 flex-shrink-0 flex-col border-r border-sidebar-border bg-sidebar">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2.5 px-5 py-4">
|
||||
<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
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-sidebar-accent text-sidebar-primary'
|
||||
: 'text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-foreground'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<item.icon className="h-4 w-4 flex-shrink-0" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
title: string;
|
||||
@@ -14,35 +26,37 @@ export default function ConfirmDialog({ title, message, confirmText = 'Confirm',
|
||||
const canConfirm = !requireTyping || typed === requireTyping;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
||||
<div className="w-full max-w-md rounded-lg bg-gray-900 p-6 shadow-xl">
|
||||
<h2 className="mb-2 text-lg font-semibold text-white">{title}</h2>
|
||||
<div className="mb-4 text-sm text-gray-300">{message}</div>
|
||||
<AlertDialog open onOpenChange={(open) => { if (!open) onCancel(); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-sm text-muted-foreground">
|
||||
{message}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{requireTyping && (
|
||||
<div className="mb-4">
|
||||
<p className="mb-1 text-xs text-gray-400">
|
||||
Type <strong className="text-white">{requireTyping}</strong> to confirm:
|
||||
<div className="my-2">
|
||||
<p className="mb-1.5 text-sm text-muted-foreground">
|
||||
Type <strong className="text-foreground">{requireTyping}</strong> to confirm:
|
||||
</p>
|
||||
<input
|
||||
<Input
|
||||
value={typed}
|
||||
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 className="flex justify-end gap-2">
|
||||
<button onClick={onCancel} className="rounded bg-gray-700 px-4 py-2 text-sm text-gray-300 hover:bg-gray-600">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onConfirm}
|
||||
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}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -13,15 +13,20 @@ export default function StatusBanner() {
|
||||
if (alerts.length === 0) return null;
|
||||
|
||||
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) => (
|
||||
<div
|
||||
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]}`}
|
||||
>
|
||||
<span>{alert.message}</span>
|
||||
<button onClick={() => dismiss(alert.id)} className="ml-1 opacity-60 hover:opacity-100">
|
||||
x
|
||||
<button
|
||||
onClick={() => dismiss(alert.id)}
|
||||
className="ml-1 opacity-60 hover:opacity-100"
|
||||
aria-label="Dismiss alert"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
52
OTSSignsOrchestrator/ClientApp/src/components/ui/badge.tsx
Normal file
52
OTSSignsOrchestrator/ClientApp/src/components/ui/badge.tsx
Normal 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 }
|
||||
58
OTSSignsOrchestrator/ClientApp/src/components/ui/button.tsx
Normal file
58
OTSSignsOrchestrator/ClientApp/src/components/ui/button.tsx
Normal 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 }
|
||||
103
OTSSignsOrchestrator/ClientApp/src/components/ui/card.tsx
Normal file
103
OTSSignsOrchestrator/ClientApp/src/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
158
OTSSignsOrchestrator/ClientApp/src/components/ui/dialog.tsx
Normal file
158
OTSSignsOrchestrator/ClientApp/src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
20
OTSSignsOrchestrator/ClientApp/src/components/ui/input.tsx
Normal file
20
OTSSignsOrchestrator/ClientApp/src/components/ui/input.tsx
Normal 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 }
|
||||
18
OTSSignsOrchestrator/ClientApp/src/components/ui/label.tsx
Normal file
18
OTSSignsOrchestrator/ClientApp/src/components/ui/label.tsx
Normal 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 }
|
||||
201
OTSSignsOrchestrator/ClientApp/src/components/ui/select.tsx
Normal file
201
OTSSignsOrchestrator/ClientApp/src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
@@ -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 }
|
||||
136
OTSSignsOrchestrator/ClientApp/src/components/ui/sheet.tsx
Normal file
136
OTSSignsOrchestrator/ClientApp/src/components/ui/sheet.tsx
Normal 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,
|
||||
}
|
||||
@@ -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 }
|
||||
49
OTSSignsOrchestrator/ClientApp/src/components/ui/sonner.tsx
Normal file
49
OTSSignsOrchestrator/ClientApp/src/components/ui/sonner.tsx
Normal 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 }
|
||||
114
OTSSignsOrchestrator/ClientApp/src/components/ui/table.tsx
Normal file
114
OTSSignsOrchestrator/ClientApp/src/components/ui/table.tsx
Normal 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,
|
||||
}
|
||||
80
OTSSignsOrchestrator/ClientApp/src/components/ui/tabs.tsx
Normal file
80
OTSSignsOrchestrator/ClientApp/src/components/ui/tabs.tsx
Normal 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 }
|
||||
66
OTSSignsOrchestrator/ClientApp/src/components/ui/tooltip.tsx
Normal file
66
OTSSignsOrchestrator/ClientApp/src/components/ui/tooltip.tsx
Normal 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 }
|
||||
@@ -1,11 +1,20 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { HubConnectionBuilder, HubConnection, LogLevel } from '@microsoft/signalr';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { HubConnectionBuilder, HubConnection, HubConnectionState, LogLevel } from '@microsoft/signalr';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useAlertStore } from '../store/alertStore';
|
||||
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() {
|
||||
const connectionRef = useRef<HubConnection | null>(null);
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>('Disconnected');
|
||||
const addAlert = useAlertStore((s) => s.addAlert);
|
||||
const updateProgress = useJobProgressStore((s) => s.updateProgress);
|
||||
const clearJob = useJobProgressStore((s) => s.clearJob);
|
||||
@@ -14,10 +23,14 @@ export function useSignalR() {
|
||||
useEffect(() => {
|
||||
const connection = new HubConnectionBuilder()
|
||||
.withUrl('/hubs/fleet')
|
||||
.withAutomaticReconnect()
|
||||
.withAutomaticReconnect([0, 2000, 5000, 10000, 30000])
|
||||
.configureLogging(LogLevel.Warning)
|
||||
.build();
|
||||
|
||||
connection.onreconnecting(() => setConnectionState('Reconnecting'));
|
||||
connection.onreconnected(() => setConnectionState('Connected'));
|
||||
connection.onclose(() => setConnectionState('Disconnected'));
|
||||
|
||||
connection.on('SendAlertRaised', (severity: string, message: string) => {
|
||||
addAlert(severity as 'info' | 'warning' | 'error', message);
|
||||
});
|
||||
@@ -47,7 +60,9 @@ export function useSignalR() {
|
||||
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;
|
||||
|
||||
return () => {
|
||||
@@ -55,5 +70,5 @@ export function useSignalR() {
|
||||
};
|
||||
}, [addAlert, updateProgress, clearJob, qc]);
|
||||
|
||||
return connectionRef;
|
||||
return { connectionRef, connectionState };
|
||||
}
|
||||
|
||||
@@ -1 +1,166 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
6
OTSSignsOrchestrator/ClientApp/src/lib/utils.ts
Normal file
6
OTSSignsOrchestrator/ClientApp/src/lib/utils.ts
Normal 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))
|
||||
}
|
||||
@@ -1,22 +1,33 @@
|
||||
import { StrictMode } from 'react';
|
||||
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 { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: 1, refetchOnWindowFocus: false },
|
||||
},
|
||||
mutationCache: new MutationCache({
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'An unexpected error occurred');
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
<TooltipProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
<Toaster richColors position="bottom-right" />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
57
OTSSignsOrchestrator/ClientApp/src/pages/AdminTokenPage.tsx
Normal file
57
OTSSignsOrchestrator/ClientApp/src/pages/AdminTokenPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,12 @@ import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getAuditLogs } from '../api/auditLogs';
|
||||
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> = {
|
||||
Success: 'text-green-400',
|
||||
@@ -15,7 +21,7 @@ export default function AuditPage() {
|
||||
const [page, setPage] = useState(0);
|
||||
const pageSize = 50;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: ['audit-logs', actorFilter, actionFilter, page],
|
||||
queryFn: () => getAuditLogs({
|
||||
limit: pageSize,
|
||||
@@ -27,18 +33,23 @@ export default function AuditPage() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<input placeholder="Filter by actor..." value={actorFilter}
|
||||
onChange={(e) => { setActorFilter(e.target.value); setPage(0); }}
|
||||
className="rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white" />
|
||||
<input placeholder="Filter by action..." value={actionFilter}
|
||||
onChange={(e) => { setActionFilter(e.target.value); setPage(0); }}
|
||||
className="rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white" />
|
||||
<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 && <p className="text-gray-400">Loading...</p>}
|
||||
{isLoading && <PageLoading />}
|
||||
{isError && <PageError error={error} onRetry={() => refetch()} />}
|
||||
|
||||
<div className="rounded border border-gray-800">
|
||||
<table className="w-full text-left text-sm">
|
||||
@@ -73,12 +84,14 @@ export default function AuditPage() {
|
||||
</table>
|
||||
</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">
|
||||
<button onClick={() => setPage(Math.max(0, page - 1))} disabled={page === 0}
|
||||
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-gray-400">Page {page + 1}{data ? ` of ${Math.ceil(data.total / pageSize)}` : ''}</span>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
previewYaml,
|
||||
suggestAbbreviation,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
ManualProvisionRequest,
|
||||
} from '../api/provision';
|
||||
import JobProgressPanel from '../components/jobs/JobProgressPanel';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface FormState {
|
||||
companyName: string;
|
||||
@@ -48,6 +50,8 @@ export default function CreateInstancePage() {
|
||||
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 }));
|
||||
|
||||
@@ -75,7 +79,7 @@ export default function CreateInstancePage() {
|
||||
};
|
||||
return manualProvision(req);
|
||||
},
|
||||
onSuccess: (data) => setActiveJobId(data.jobId),
|
||||
onSuccess: (data) => { setActiveJobId(data.jobId); toast.success('Provisioning started'); },
|
||||
});
|
||||
|
||||
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 canPreview = form.abbreviation.length === 3 && form.companyName.trim();
|
||||
|
||||
if (activeJobId) {
|
||||
return (
|
||||
<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')} />
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
onClick={() => navigate('/fleet')}
|
||||
className="rounded bg-gray-700 px-4 py-2 text-sm text-white hover:bg-gray-600"
|
||||
>
|
||||
Go to Fleet
|
||||
</button>
|
||||
<button
|
||||
<Button variant="secondary" onClick={() => navigate('/fleet')}>Go to Fleet</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setActiveJobId(null);
|
||||
setForm(initialForm);
|
||||
setYaml(null);
|
||||
deployMut.reset();
|
||||
}}
|
||||
className="rounded bg-gray-700 px-4 py-2 text-sm text-white hover:bg-gray-600"
|
||||
>
|
||||
Deploy Another
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -122,7 +131,7 @@ export default function CreateInstancePage() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
{/* Customer Details */}
|
||||
@@ -130,35 +139,45 @@ export default function CreateInstancePage() {
|
||||
<h3 className="mb-2 text-sm font-medium text-gray-400">Customer Details</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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
|
||||
id="cip-company"
|
||||
value={form.companyName}
|
||||
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"
|
||||
placeholder="Acme Corporation"
|
||||
/>
|
||||
{errors.companyName && <p id="cip-company-err" className="mt-1 text-xs text-red-400">{errors.companyName}</p>}
|
||||
</div>
|
||||
<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
|
||||
id="cip-email"
|
||||
type="email"
|
||||
value={form.adminEmail}
|
||||
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"
|
||||
placeholder="admin@example.com"
|
||||
/>
|
||||
{errors.adminEmail && <p id="cip-email-err" className="mt-1 text-xs text-red-400">{errors.adminEmail}</p>}
|
||||
</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
|
||||
id="cip-fname"
|
||||
value={form.adminFirstName}
|
||||
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"
|
||||
/>
|
||||
</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
|
||||
id="cip-lname"
|
||||
value={form.adminLastName}
|
||||
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"
|
||||
@@ -172,8 +191,9 @@ export default function CreateInstancePage() {
|
||||
<h3 className="mb-2 text-sm font-medium text-gray-400">Plan & Identity</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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
|
||||
id="cip-plan"
|
||||
value={form.plan}
|
||||
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"
|
||||
@@ -183,8 +203,9 @@ export default function CreateInstancePage() {
|
||||
</select>
|
||||
</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
|
||||
id="cip-screens"
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.screenCount}
|
||||
@@ -193,14 +214,17 @@ export default function CreateInstancePage() {
|
||||
/>
|
||||
</div>
|
||||
<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)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
id="cip-abbrev"
|
||||
value={form.abbreviation}
|
||||
onChange={(e) => set('abbreviation', e.target.value.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 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"
|
||||
placeholder="abc"
|
||||
/>
|
||||
@@ -217,6 +241,7 @@ export default function CreateInstancePage() {
|
||||
Stack: <span className="text-gray-300">{form.abbreviation}-cms-stack</span>
|
||||
</p>
|
||||
)}
|
||||
{errors.abbreviation && <p id="cip-abbrev-err" className="mt-1 text-xs text-red-400">{errors.abbreviation}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -244,20 +269,19 @@ export default function CreateInstancePage() {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => previewMut.mutate()}
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deployMut.mutate()}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => { if (validate()) deployMut.mutate(); }}
|
||||
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'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{deployMut.isError && (
|
||||
|
||||
@@ -1,27 +1,54 @@
|
||||
import { useState } from 'react';
|
||||
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 { createJob, getJob, JobDetailDto } from '../api/jobs';
|
||||
import { useJobProgressStore } from '../store/jobProgressStore';
|
||||
import { createJob } from '../api/jobs';
|
||||
import ConfirmDialog from '../components/shared/ConfirmDialog';
|
||||
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';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
Active: 'text-green-400',
|
||||
Provisioning: 'text-blue-400',
|
||||
PendingPayment: 'text-yellow-400',
|
||||
Suspended: 'text-orange-400',
|
||||
Decommissioned: 'text-red-400',
|
||||
const statusDot: Record<string, string> = {
|
||||
Active: 'bg-status-success',
|
||||
Provisioning: 'bg-status-info',
|
||||
PendingPayment: 'bg-status-warning',
|
||||
Suspended: 'bg-status-warning',
|
||||
Decommissioned: 'bg-status-danger',
|
||||
};
|
||||
|
||||
const healthColors: Record<string, string> = {
|
||||
Healthy: 'bg-green-500/20 text-green-400',
|
||||
Degraded: 'bg-yellow-500/20 text-yellow-400',
|
||||
Critical: 'bg-red-500/20 text-red-400',
|
||||
Unknown: 'bg-gray-500/20 text-gray-400',
|
||||
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 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 }[] = [
|
||||
@@ -33,16 +60,34 @@ const jobTypes: { type: string; label: string; guard?: (c: FleetCustomerDetail)
|
||||
{ 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() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const qc = useQueryClient();
|
||||
const [tab, setTab] = useState<Tab>('instances');
|
||||
const [confirm, setConfirm] = useState<{ type: string; label: string; paramInput?: string } | null>(null);
|
||||
const [screenInput, setScreenInput] = useState('');
|
||||
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],
|
||||
queryFn: () => getCustomerDetail(id!),
|
||||
enabled: !!id,
|
||||
@@ -56,6 +101,7 @@ export default function CustomerDetailPage() {
|
||||
qc.invalidateQueries({ queryKey: ['fleet-detail', id] });
|
||||
qc.invalidateQueries({ queryKey: ['fleet'] });
|
||||
setActiveJobId(data.id);
|
||||
toast.success('Job created');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -68,80 +114,91 @@ export default function CustomerDetailPage() {
|
||||
setScreenInput('');
|
||||
}
|
||||
|
||||
if (isLoading) return <p className="text-gray-400">Loading customer...</p>;
|
||||
if (!customer) return <p className="text-gray-400">Customer not found</p>;
|
||||
if (isLoading) return <PageLoading message="Loading customer..." />;
|
||||
if (isError) return <PageError error={error} onRetry={() => refetch()} />;
|
||||
if (!customer) return <PageError error={new Error('Customer not found')} onRetry={() => refetch()} />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Back button */}
|
||||
<button onClick={() => navigate('/fleet')} className="mb-4 text-sm text-blue-400 hover:text-blue-300">
|
||||
← Back to Fleet
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-6 rounded border border-gray-800 bg-gray-900 p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
{customer.companyName}
|
||||
<span className="ml-2 font-mono text-sm text-gray-400">{customer.abbreviation}</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 className="space-y-6">
|
||||
{/* Breadcrumbs + title */}
|
||||
<div className="space-y-1">
|
||||
<Breadcrumbs items={[{ label: backLabel, to: backPath }, { label: customer.companyName }]} />
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">{customer.companyName}</h1>
|
||||
<span className="font-mono text-sm text-muted-foreground">{customer.abbreviation}</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${statusDot[customer.status] ?? 'bg-muted-foreground'}`} />
|
||||
<span className={`text-sm font-medium ${statusText[customer.status] ?? 'text-muted-foreground'}`}>
|
||||
{customer.status}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{jobTypes.map((jt) => {
|
||||
const allowed = !jt.guard || jt.guard(customer);
|
||||
return (
|
||||
<button
|
||||
key={jt.type}
|
||||
disabled={!allowed || jobMut.isPending}
|
||||
onClick={() => {
|
||||
if (jt.danger || jt.paramInput) {
|
||||
setConfirm(jt);
|
||||
} else {
|
||||
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}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{/* 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>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{jobTypes.map((jt) => {
|
||||
const allowed = !jt.guard || jt.guard(customer);
|
||||
return (
|
||||
<Button
|
||||
key={jt.type}
|
||||
variant={jt.danger ? 'destructive' : 'secondary'}
|
||||
size="sm"
|
||||
disabled={!allowed || jobMut.isPending}
|
||||
onClick={() => {
|
||||
if (jt.danger || jt.paramInput) {
|
||||
setConfirm(jt);
|
||||
} else {
|
||||
handleAction(jt.type);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{jt.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Active job progress */}
|
||||
{activeJobId && (
|
||||
<div className="mb-6">
|
||||
<JobProgressPanel jobId={activeJobId} onClose={() => setActiveJobId(null)} />
|
||||
</div>
|
||||
<JobProgressPanel jobId={activeJobId} onClose={() => setActiveJobId(null)} />
|
||||
)}
|
||||
|
||||
{/* 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) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`border-b-2 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
tab === t
|
||||
? 'border-blue-500 text-white'
|
||||
: 'border-transparent text-gray-400 hover:text-gray-200'
|
||||
? 'border-brand text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t === 'instances' ? 'Instances' : 'Jobs'}
|
||||
{t === 'instances' ? `Instances (${customer.instances.length})` : `Jobs (${customer.activeJobs.length})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -162,24 +219,22 @@ export default function CustomerDetailPage() {
|
||||
)}
|
||||
{confirm?.paramInput === 'screenCount' && (
|
||||
<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">
|
||||
<h3 className="mb-2 text-lg font-semibold text-white">Update Screen Limit</h3>
|
||||
<p className="mb-3 text-sm text-gray-400">Enter the new screen count for {customer.abbreviation}:</p>
|
||||
<input
|
||||
<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-foreground">Update Screen Limit</h3>
|
||||
<p className="mb-3 text-sm text-muted-foreground">Enter the new screen count for {customer.abbreviation}:</p>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={screenInput}
|
||||
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">
|
||||
<button onClick={() => { setConfirm(null); setScreenInput(''); }}
|
||||
className="rounded bg-gray-700 px-4 py-2 text-sm text-gray-300 hover:bg-gray-600">Cancel</button>
|
||||
<button
|
||||
<Button variant="secondary" onClick={() => { setConfirm(null); setScreenInput(''); }}>Cancel</Button>
|
||||
<Button
|
||||
onClick={() => handleAction('update-screen-limit')}
|
||||
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>
|
||||
@@ -190,45 +245,49 @@ export default function CustomerDetailPage() {
|
||||
|
||||
function InstancesTab({ customer }: { customer: FleetCustomerDetail }) {
|
||||
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 (
|
||||
<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">Stack</th>
|
||||
<th className="px-3 py-2">URL</th>
|
||||
<th className="px-3 py-2">Health</th>
|
||||
<th className="px-3 py-2">Last Check</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{customer.instances.map((inst) => {
|
||||
const colorCls = healthColors[inst.healthStatus] ?? healthColors.Unknown;
|
||||
return (
|
||||
<tr key={inst.id} className="border-b border-gray-800/50">
|
||||
<td className="px-3 py-2 font-mono text-white">{inst.dockerStackName}</td>
|
||||
<td className="px-3 py-2">
|
||||
{inst.xiboUrl ? (
|
||||
<a href={inst.xiboUrl} target="_blank" rel="noopener noreferrer" className="text-blue-400 hover:underline">
|
||||
{inst.xiboUrl}
|
||||
</a>
|
||||
) : <span className="text-gray-500">—</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${colorCls}`}>
|
||||
<div className="rounded-xl border border-border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Stack</TableHead>
|
||||
<TableHead>URL</TableHead>
|
||||
<TableHead>Health</TableHead>
|
||||
<TableHead>Last Check</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{customer.instances.map((inst) => (
|
||||
<TableRow key={inst.id}>
|
||||
<TableCell className="font-mono text-sm">{inst.dockerStackName}</TableCell>
|
||||
<TableCell>
|
||||
{inst.xiboUrl ? (
|
||||
<a href={inst.xiboUrl} target="_blank" rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-brand hover:underline">
|
||||
{inst.xiboUrl}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
) : <span className="text-muted-foreground">—</span>}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<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}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2 text-xs text-gray-400">
|
||||
{inst.lastHealthCheck ? new Date(inst.lastHealthCheck).toLocaleString() : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{inst.lastHealthCheck
|
||||
? formatDistanceToNow(new Date(inst.lastHealthCheck), { addSuffix: true })
|
||||
: '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -236,49 +295,40 @@ function InstancesTab({ customer }: { customer: FleetCustomerDetail }) {
|
||||
function JobsTab({ customerId, activeJobs }: { customerId: string; activeJobs: FleetCustomerDetail['activeJobs'] }) {
|
||||
const [expandedJobId, setExpandedJobId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{activeJobs.length === 0 && (
|
||||
<p className="text-sm text-gray-500">No active or recent jobs.</p>
|
||||
)}
|
||||
if (activeJobs.length === 0)
|
||||
return <p className="text-sm text-muted-foreground">No active or recent jobs.</p>;
|
||||
|
||||
<div className="space-y-2">
|
||||
{activeJobs.map((job) => (
|
||||
<div key={job.id} className="rounded border border-gray-800 bg-gray-900">
|
||||
<button
|
||||
onClick={() => setExpandedJobId(expandedJobId === job.id ? null : job.id)}
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium text-white">{job.jobType}</span>
|
||||
<JobStatusBadge status={job.status} />
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(job.createdAt).toLocaleString()}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{activeJobs.map((job) => (
|
||||
<div key={job.id} className="rounded-xl border border-border bg-card">
|
||||
<button
|
||||
onClick={() => setExpandedJobId(expandedJobId === job.id ? null : job.id)}
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{expandedJobId === job.id
|
||||
? <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>
|
||||
</button>
|
||||
{expandedJobId === job.id && (
|
||||
<div className="border-t border-gray-800 px-4 py-3">
|
||||
<JobProgressPanel jobId={job.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(job.createdAt), { addSuffix: true })}
|
||||
</span>
|
||||
</button>
|
||||
{expandedJobId === job.id && (
|
||||
<div className="border-t border-border px-4 py-3">
|
||||
<JobProgressPanel jobId={job.id} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,258 +1,164 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { listCustomers, getCustomerAdmin, getCustomerStripeEvents, CustomerListItem } from '../api/customers';
|
||||
import { format } from 'date-fns';
|
||||
import { listCustomers } from '../api/customers';
|
||||
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> = {
|
||||
Active: 'bg-green-500/20 text-green-400',
|
||||
Provisioning: 'bg-blue-500/20 text-blue-400',
|
||||
PendingPayment: 'bg-yellow-500/20 text-yellow-400',
|
||||
Suspended: 'bg-orange-500/20 text-orange-400',
|
||||
Decommissioned: 'bg-red-500/20 text-red-400',
|
||||
const statusDot: Record<string, string> = {
|
||||
Active: 'bg-status-success',
|
||||
Provisioning: 'bg-status-info',
|
||||
PendingPayment: 'bg-status-warning',
|
||||
Suspended: 'bg-status-warning',
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [detailTab, setDetailTab] = useState<'info' | 'byoi' | 'snapshots' | 'stripe'>('info');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const { data: customers, isLoading } = useQuery({
|
||||
const { data: customers, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: ['customers-admin', statusFilter],
|
||||
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) => {
|
||||
acc[c.status] = (acc[c.status] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {}) ?? {};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-4 text-xl font-semibold text-white">Customer Management</h2>
|
||||
const filtered = customers?.filter(
|
||||
(c) =>
|
||||
c.companyName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.abbreviation.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
{/* 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}
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Breadcrumbs items={[{ label: 'Customers' }]} />
|
||||
<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>
|
||||
|
||||
<div className="flex gap-4">
|
||||
{/* Customer list */}
|
||||
<div className="w-1/2">
|
||||
{isLoading && <p className="text-gray-400">Loading...</p>}
|
||||
{isLoading && <PageLoading />}
|
||||
{isError && <PageError error={error} onRetry={() => refetch()} />}
|
||||
|
||||
<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}
|
||||
{!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>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(c.createdAt), { addSuffix: true })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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">
|
||||
<span className="font-mono text-sm text-white">{b.slug}</span>
|
||||
<span className={`text-xs ${b.enabled ? 'text-green-400' : 'text-gray-500'}`}>
|
||||
{b.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-400">Entity: {b.entityId}</div>
|
||||
<div className={`mt-1 text-xs font-medium ${urgency}`}>
|
||||
Cert expires: {format(new Date(b.certExpiry), 'yyyy-MM-dd')} ({daysLeft} days)
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
</table>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,100 +1,185 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
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> = {
|
||||
Healthy: 'bg-green-500/20 text-green-400',
|
||||
Degraded: 'bg-yellow-500/20 text-yellow-400',
|
||||
Critical: 'bg-red-500/20 text-red-400',
|
||||
Unknown: 'bg-gray-500/20 text-gray-400',
|
||||
type SortKey = 'companyName' | 'plan' | 'healthStatus' | 'screenCount';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
export default function FleetPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortKey, setSortKey] = useState<SortKey>('companyName');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: customers, isLoading } = useQuery({
|
||||
const { data: customers, isLoading, isError, error, refetch, dataUpdatedAt } = useQuery({
|
||||
queryKey: ['fleet'],
|
||||
queryFn: getFleetSummary,
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
|
||||
const filtered = customers?.filter(
|
||||
(c) =>
|
||||
c.companyName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.abbreviation.toLowerCase().includes(search.toLowerCase()),
|
||||
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.companyName.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 (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-white">Fleet Dashboard</h2>
|
||||
<input
|
||||
placeholder="Search customers..."
|
||||
value={search}
|
||||
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"
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<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..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-64 pl-8"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<StatCard label="Total Customers" value={totalCustomers} icon={Users} />
|
||||
<StatCard label="Healthy" value={healthyCt} icon={HeartPulse} color="success" />
|
||||
<StatCard label="Degraded / Critical" value={unhealthyCt} icon={AlertTriangle} color={unhealthyCt > 0 ? 'danger' : 'default'} />
|
||||
<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"
|
||||
>
|
||||
<TableCell className="font-mono text-xs font-medium">{c.abbreviation}</TableCell>
|
||||
<TableCell className="font-medium">{c.companyName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{c.plan}</TableCell>
|
||||
<TableCell className="tabular-nums text-muted-foreground">{c.screenCount}</TableCell>
|
||||
<TableCell>
|
||||
<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}
|
||||
</span>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{c.lastHealthCheck ? formatDistanceToNow(new Date(c.lastHealthCheck), { addSuffix: true }) : '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{c.hasRunningJob && (
|
||||
<Badge variant="secondary" className="bg-brand/15 text-brand">
|
||||
Running
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
function FleetRow({ customer: c, onClick }: { customer: FleetSummaryDto; onClick: () => void }) {
|
||||
const colorCls = healthColors[c.healthStatus] ?? healthColors.Unknown;
|
||||
return (
|
||||
<tr
|
||||
onClick={onClick}
|
||||
className="cursor-pointer border-b border-gray-800/50 transition-colors hover:bg-gray-800/40"
|
||||
>
|
||||
<td className="px-3 py-2 font-mono text-white">{c.abbreviation}</td>
|
||||
<td className="px-3 py-2 text-gray-200">{c.companyName}</td>
|
||||
<td className="px-3 py-2 text-gray-300">{c.plan}</td>
|
||||
<td className="px-3 py-2 text-gray-300">{c.screenCount}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${colorCls}`}>
|
||||
{c.healthStatus}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2 text-xs text-gray-400">
|
||||
{c.lastHealthCheck ? new Date(c.lastHealthCheck).toLocaleString() : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{c.hasRunningJob && (
|
||||
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-blue-500" title="Job in progress" />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,19 +2,26 @@ import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getHealthSummary, getHealthEvents, getCheckNames, HealthEventSummary } from '../api/healthEvents';
|
||||
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';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
Healthy: 'bg-green-500/20 text-green-400',
|
||||
Degraded: 'bg-yellow-500/20 text-yellow-400',
|
||||
Critical: 'bg-red-500/20 text-red-400',
|
||||
const statusDot: Record<string, string> = {
|
||||
Healthy: 'bg-status-success',
|
||||
Degraded: 'bg-status-warning',
|
||||
Critical: 'bg-status-danger',
|
||||
};
|
||||
|
||||
const statusDot: Record<string, string> = {
|
||||
Healthy: 'bg-green-500',
|
||||
Degraded: 'bg-yellow-500',
|
||||
Critical: 'bg-red-500',
|
||||
const statusText: Record<string, string> = {
|
||||
Healthy: 'text-status-success',
|
||||
Degraded: 'text-status-warning',
|
||||
Critical: 'text-status-danger',
|
||||
};
|
||||
|
||||
export default function HealthPage() {
|
||||
@@ -46,42 +53,36 @@ export default function HealthPage() {
|
||||
enabled: tab === 'history',
|
||||
});
|
||||
|
||||
// Group summary by instance
|
||||
const grouped = summary?.reduce<Record<string, HealthEventSummary[]>>((acc, ev) => {
|
||||
const key = ev.instanceName || ev.instanceId;
|
||||
(acc[key] ??= []).push(ev);
|
||||
return acc;
|
||||
}, {}) ?? {};
|
||||
|
||||
// Count statuses
|
||||
const counts = summary?.reduce<Record<string, number>>((acc, ev) => {
|
||||
acc[ev.status] = (acc[ev.status] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {}) ?? {};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-4 text-xl font-semibold text-white">Health Dashboard</h2>
|
||||
<div className="space-y-6">
|
||||
<Breadcrumbs items={[{ label: 'Home', to: '/' }, { label: 'Health' }]} />
|
||||
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">Health Dashboard</h1>
|
||||
|
||||
{/* Status summary cards */}
|
||||
<div className="mb-6 flex gap-4">
|
||||
{['Healthy', 'Degraded', 'Critical'].map((s) => (
|
||||
<div key={s} className={`rounded-lg border border-gray-800 px-4 py-3 ${statusColors[s]}`}>
|
||||
<div className="text-2xl font-bold">{counts[s] ?? 0}</div>
|
||||
<div className="text-xs">{s}</div>
|
||||
</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 className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<StatCard label="Healthy" value={counts['Healthy'] ?? 0} icon={Activity} color="success" />
|
||||
<StatCard label="Degraded" value={counts['Degraded'] ?? 0} icon={Activity} color="warning" />
|
||||
<StatCard label="Critical" value={counts['Critical'] ?? 0} icon={Activity} color="danger" />
|
||||
<StatCard label="Total Checks" value={summary?.length ?? 0} icon={Activity} />
|
||||
</div>
|
||||
|
||||
{/* 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) => (
|
||||
<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'}
|
||||
</button>
|
||||
))}
|
||||
@@ -89,22 +90,22 @@ export default function HealthPage() {
|
||||
|
||||
{tab === 'summary' && (
|
||||
<>
|
||||
{summaryLoading && <p className="text-gray-400">Loading...</p>}
|
||||
{summaryLoading && <PageLoading />}
|
||||
{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">
|
||||
<h3 className="mb-3 text-sm font-semibold text-white">{instance}</h3>
|
||||
<div key={instance} className="rounded-xl border border-border bg-card p-4">
|
||||
<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">
|
||||
{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 className={`mt-1 h-2 w-2 flex-shrink-0 rounded-full ${statusDot[check.status] ?? 'bg-gray-500'}`} />
|
||||
<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-muted-foreground'}`} />
|
||||
<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 && (
|
||||
<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')}
|
||||
{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>
|
||||
@@ -117,14 +118,14 @@ export default function HealthPage() {
|
||||
|
||||
{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); }}
|
||||
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>
|
||||
{checkNames?.map((n) => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
<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="Healthy">Healthy</option>
|
||||
<option value="Degraded">Degraded</option>
|
||||
@@ -132,51 +133,50 @@ export default function HealthPage() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{historyLoading && <p className="text-gray-400">Loading...</p>}
|
||||
{historyLoading && <PageLoading />}
|
||||
|
||||
<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">Time</th>
|
||||
<th className="px-3 py-2">Instance</th>
|
||||
<th className="px-3 py-2">Check</th>
|
||||
<th className="px-3 py-2">Status</th>
|
||||
<th className="px-3 py-2">Message</th>
|
||||
<th className="px-3 py-2">Remediated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<div className="rounded-xl border border-border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>Instance</TableHead>
|
||||
<TableHead>Check</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Message</TableHead>
|
||||
<TableHead>Remediated</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{history?.events.map((ev) => (
|
||||
<tr key={ev.id} className="border-b border-gray-800/50">
|
||||
<td className="whitespace-nowrap px-3 py-2 text-xs text-gray-400">
|
||||
<TableRow key={ev.id}>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{format(new Date(ev.occurredAt), 'yyyy-MM-dd HH:mm:ss')}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-300">{ev.instanceName}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-200">{ev.checkName}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusColors[ev.status] ?? 'text-gray-400'}`}>
|
||||
{ev.status}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">{ev.instanceName}</TableCell>
|
||||
<TableCell className="text-xs text-foreground">{ev.checkName}</TableCell>
|
||||
<TableCell>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<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>
|
||||
</td>
|
||||
<td className="max-w-xs truncate px-3 py-2 text-xs text-gray-400" title={ev.message ?? ''}>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate text-xs text-muted-foreground" title={ev.message ?? ''}>
|
||||
{ev.message || '-'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
{ev.remediated ? <span className="text-green-400">Yes</span> : <span className="text-gray-600">No</span>}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{ev.remediated ? <span className="text-status-success">Yes</span> : <span className="text-muted-foreground">No</span>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button onClick={() => setPage(Math.max(0, page - 1))} disabled={page === 0}
|
||||
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-gray-400">Page {page + 1}{history ? ` of ${Math.ceil(history.total / pageSize)}` : ''}</span>
|
||||
<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 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}{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>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,47 @@
|
||||
import { useState } from 'react';
|
||||
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 { 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({
|
||||
initial,
|
||||
@@ -21,47 +62,47 @@ function HostForm({
|
||||
});
|
||||
|
||||
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">
|
||||
{(['label', 'host', 'username', 'privateKeyPath'] as const).map((field) => (
|
||||
<div key={field}>
|
||||
<label className="mb-1 block text-xs text-gray-400">{field}</label>
|
||||
<input
|
||||
<Label htmlFor={`host-${field}`} className="mb-1">{field}</Label>
|
||||
<Input
|
||||
id={`host-${field}`}
|
||||
value={(form[field] as string) ?? ''}
|
||||
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>
|
||||
<label className="mb-1 block text-xs text-gray-400">port</label>
|
||||
<input
|
||||
<Label htmlFor="host-port" className="mb-1">port</Label>
|
||||
<Input
|
||||
id="host-port"
|
||||
type="number"
|
||||
value={form.port}
|
||||
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>
|
||||
<label className="mb-1 block text-xs text-gray-400">password</label>
|
||||
<input
|
||||
<Label htmlFor="host-password" className="mb-1">password</Label>
|
||||
<Input
|
||||
id="host-password"
|
||||
type="password"
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value || null })}
|
||||
placeholder={initial ? '(unchanged)' : ''}
|
||||
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">keyPassphrase</label>
|
||||
<input
|
||||
<Label htmlFor="host-passphrase" className="mb-1">keyPassphrase</Label>
|
||||
<Input
|
||||
id="host-passphrase"
|
||||
type="password"
|
||||
onChange={(e) => setForm({ ...form, keyPassphrase: e.target.value || null })}
|
||||
placeholder={initial ? '(unchanged)' : ''}
|
||||
className="w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-sm text-white"
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
type="checkbox"
|
||||
checked={form.useKeyAuth}
|
||||
@@ -72,12 +113,8 @@ function HostForm({
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
Save
|
||||
</button>
|
||||
<button onClick={onCancel} className="rounded bg-gray-700 px-3 py-1 text-sm text-gray-300 hover:bg-gray-600">
|
||||
Cancel
|
||||
</button>
|
||||
<Button onClick={() => onSubmit(form)}>Save</Button>
|
||||
<Button variant="secondary" onClick={onCancel}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -85,29 +122,35 @@ function HostForm({
|
||||
|
||||
export default function HostsPage() {
|
||||
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 [nodes, setNodes] = useState<{ hostId: string; data: unknown[] } | 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 updateMut = useMutation({ mutationFn: ({ id, req }: { id: string; req: CreateSshHostRequest }) => updateHost(id, req), onSuccess: () => { qc.invalidateQueries({ queryKey: ['hosts'] }); setEditing(null); } });
|
||||
const deleteMut = useMutation({ mutationFn: deleteHost, onSuccess: () => qc.invalidateQueries({ queryKey: ['hosts'] }) });
|
||||
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); toast.success('Host updated'); } });
|
||||
const deleteMut = useMutation({ mutationFn: deleteHost, onSuccess: () => { qc.invalidateQueries({ queryKey: ['hosts'] }); toast.success('Host deleted'); } });
|
||||
const [confirmDeleteHost, setConfirmDeleteHost] = useState<SshHost | null>(null);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<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">
|
||||
Add Host
|
||||
</button>
|
||||
<div className="space-y-6">
|
||||
<Breadcrumbs items={[{ label: 'Home', to: '/' }, { label: 'Infrastructure' }]} />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">Infrastructure</h1>
|
||||
<Button onClick={() => setEditing('new')}>Add Host</Button>
|
||||
</div>
|
||||
|
||||
{editing === 'new' && (
|
||||
<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">
|
||||
{hosts?.map((host) => (
|
||||
@@ -119,60 +162,80 @@ export default function HostsPage() {
|
||||
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>
|
||||
<span className="font-medium text-white">{host.label}</span>
|
||||
<span className="ml-3 text-sm text-gray-400">
|
||||
<span className="font-medium text-foreground">{host.label}</span>
|
||||
<span className="ml-3 text-sm text-muted-foreground">
|
||||
{host.username}@{host.host}:{host.port}
|
||||
</span>
|
||||
{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'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const res = await testHost(host.id);
|
||||
setTestResult({ hostId: host.id, ...res });
|
||||
qc.invalidateQueries({ queryKey: ['hosts'] });
|
||||
}}
|
||||
className="rounded bg-gray-700 px-2 py-1 text-xs text-gray-300 hover:bg-gray-600"
|
||||
>
|
||||
Test
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const data = await listNodes(host.id);
|
||||
setNodes({ hostId: host.id, data });
|
||||
}}
|
||||
className="rounded bg-gray-700 px-2 py-1 text-xs text-gray-300 hover:bg-gray-600"
|
||||
>
|
||||
Nodes
|
||||
</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>
|
||||
<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
|
||||
</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>
|
||||
<Button variant="destructive" size="sm" onClick={() => setConfirmDeleteHost(host)}>
|
||||
Delete
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
{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)}
|
||||
</pre>
|
||||
)}
|
||||
{showSecrets === host.id && <SecretsPanel hostId={host.id} />}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
283
OTSSignsOrchestrator/ClientApp/src/pages/InstanceDetailPage.tsx
Normal file
283
OTSSignsOrchestrator/ClientApp/src/pages/InstanceDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,186 +1,100 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
listLiveInstances, getStackServices, restartStack, restartService,
|
||||
deleteStack, rotateMySql, getStackLogs, getCredentials,
|
||||
initializeInstance, rotateAdminPassword, LiveInstance, ServiceInfo, InstanceCredentials,
|
||||
} from '../api/instances';
|
||||
import ConfirmDialog from '../components/shared/ConfirmDialog';
|
||||
|
||||
function ServiceList({ stackName }: { stackName: string }) {
|
||||
const { data: services, isLoading } = useQuery({
|
||||
queryKey: ['services', stackName],
|
||||
queryFn: () => getStackServices(stackName),
|
||||
});
|
||||
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>
|
||||
);
|
||||
}
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { listLiveInstances } from '../api/instances';
|
||||
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 DeployInstancePanel from '../components/instances/DeployInstancePanel';
|
||||
import { Search, Server, Plus, RefreshCw, X } from 'lucide-react';
|
||||
|
||||
export default function InstancesPage() {
|
||||
const qc = useQueryClient();
|
||||
const { data: instances, isLoading } = useQuery({ queryKey: ['instances'], queryFn: listLiveInstances });
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'services' | 'creds' | 'logs'>('services');
|
||||
const [confirmDelete, setConfirmDelete] = useState<LiveInstance | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const { data: instances, isLoading, isError, error, refetch } = useQuery({ queryKey: ['instances'], queryFn: listLiveInstances });
|
||||
const [showDeploy, setShowDeploy] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const restartMut = useMutation({ mutationFn: restartStack, onSuccess: () => qc.invalidateQueries({ queryKey: ['instances'] }) });
|
||||
const deleteMut = useMutation({ mutationFn: deleteStack, onSuccess: () => { qc.invalidateQueries({ queryKey: ['instances'] }); setConfirmDelete(null); } });
|
||||
const rotateMySqlMut = useMutation({ mutationFn: rotateMySql });
|
||||
const filtered = instances?.filter(
|
||||
(i) =>
|
||||
i.stackName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
i.abbreviation.toLowerCase().includes(search.toLowerCase()) ||
|
||||
i.hostLabel.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-white">Live Instances</h2>
|
||||
<button onClick={() => qc.invalidateQueries({ queryKey: ['instances'] })} className="rounded bg-gray-700 px-3 py-1 text-sm text-gray-300 hover:bg-gray-600">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading && <p className="text-gray-400">Discovering instances across hosts...</p>}
|
||||
|
||||
<div className="space-y-2">
|
||||
{instances?.map((inst) => (
|
||||
<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 className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Breadcrumbs items={[{ label: 'Instances' }]} />
|
||||
<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>
|
||||
|
||||
{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)}
|
||||
/>
|
||||
{/* Inline deploy panel */}
|
||||
{showDeploy && (
|
||||
<div className="rounded-xl border border-border bg-card p-5">
|
||||
<DeployInstancePanel onClose={() => setShowDeploy(false)} />
|
||||
</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>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -1,61 +1,27 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { login, getMe } from '../api/auth';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = 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 login(email, password);
|
||||
const user = await getMe();
|
||||
setUser(user);
|
||||
navigate('/');
|
||||
} catch {
|
||||
setError('Invalid email or password');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
// If OIDC is configured, the button redirects to the IdP.
|
||||
// If not configured, the backend returns 503 and the user must use /admintoken.
|
||||
|
||||
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">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>}
|
||||
<label className="mb-1 block text-sm text-gray-400">Email</label>
|
||||
<input
|
||||
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"
|
||||
<div className="w-full max-w-sm rounded-lg bg-gray-900 p-8 shadow-xl text-center">
|
||||
<h1 className="mb-6 text-xl font-bold text-white">OTS Signs Orchestrator</h1>
|
||||
<a
|
||||
href="/api/auth/oidc/login"
|
||||
className="block w-full rounded bg-blue-600 py-2 text-sm font-medium text-white hover:bg-blue-500"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
Sign in with SSO
|
||||
</a>
|
||||
<Link
|
||||
to="/admintoken"
|
||||
className="mt-4 block text-xs text-gray-500 hover:text-gray-400"
|
||||
>
|
||||
Admin token access
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,86 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getOperationLogs } from '../api/logs';
|
||||
import { getAuditLogs } from '../api/auditLogs';
|
||||
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> = {
|
||||
Success: 'text-green-400',
|
||||
Failed: 'text-red-400',
|
||||
InProgress: 'text-yellow-400',
|
||||
Pending: 'text-gray-400',
|
||||
type Tab = 'operations' | 'audit';
|
||||
|
||||
const statusDot: Record<string, string> = {
|
||||
Success: 'bg-status-success',
|
||||
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() {
|
||||
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 [operationFilter, setOperationFilter] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const pageSize = 50;
|
||||
|
||||
const { data: logs, isLoading } = useQuery({
|
||||
const { data: logs, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: ['operation-logs', stackFilter, operationFilter, page],
|
||||
queryFn: () =>
|
||||
getOperationLogs({
|
||||
@@ -28,66 +92,157 @@ export default function LogsPage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-4 text-xl font-semibold text-white">Operation Logs</h2>
|
||||
|
||||
<div className="mb-4 flex gap-3">
|
||||
<input placeholder="Filter by stack name..." value={stackFilter}
|
||||
onChange={(e) => { setStackFilter(e.target.value); setPage(0); }}
|
||||
className="rounded border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white" />
|
||||
<input placeholder="Filter by operation..." value={operationFilter}
|
||||
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 className="flex gap-3">
|
||||
<div>
|
||||
<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}
|
||||
onChange={(e) => { setStackFilter(e.target.value); setPage(0); }} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="log-op-filter" className="sr-only">Filter by operation</Label>
|
||||
<Input id="log-op-filter" placeholder="Filter by operation..." value={operationFilter}
|
||||
onChange={(e) => { setOperationFilter(e.target.value); setPage(0); }} />
|
||||
</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">
|
||||
<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">Time</th>
|
||||
<th className="px-3 py-2">Operation</th>
|
||||
<th className="px-3 py-2">Stack</th>
|
||||
<th className="px-3 py-2">Status</th>
|
||||
<th className="px-3 py-2">Duration</th>
|
||||
<th className="px-3 py-2">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<div className="rounded-xl border border-border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>Operation</TableHead>
|
||||
<TableHead>Stack</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead>Message</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs?.map((log) => (
|
||||
<tr key={log.id} className="border-b border-gray-800/50">
|
||||
<td className="whitespace-nowrap px-3 py-2 text-xs text-gray-400">
|
||||
<TableRow key={log.id}>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{format(new Date(log.timestamp), 'yyyy-MM-dd HH:mm:ss')}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-200">{log.operation}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-300">{log.stackName}</td>
|
||||
<td className={`px-3 py-2 text-xs font-medium ${statusColors[log.status] ?? 'text-gray-400'}`}>
|
||||
{log.status}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-400">
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-foreground">{log.operation}</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">{log.stackName}</TableCell>
|
||||
<TableCell>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${statusDot[log.status] ?? 'bg-muted-foreground'}`} />
|
||||
<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` : '-'}
|
||||
</td>
|
||||
<td className="max-w-xs truncate px-3 py-2 text-xs text-gray-400" title={log.message}>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate text-xs text-muted-foreground" title={log.message}>
|
||||
{log.message}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button onClick={() => setPage(Math.max(0, page - 1))} disabled={page === 0}
|
||||
className="rounded bg-gray-700 px-3 py-1 text-sm text-gray-300 hover:bg-gray-600 disabled:opacity-40">
|
||||
{!isLoading && !isError && logs?.length === 0 && (
|
||||
<EmptyState title="No logs found" description="No operation logs match the current filters." />
|
||||
)}
|
||||
|
||||
<div className="flex 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-gray-400">Page {page + 1}</span>
|
||||
<button 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">
|
||||
</Button>
|
||||
<span className="px-2 py-1 text-sm text-muted-foreground">Page {page + 1}</span>
|
||||
<Button variant="secondary" size="sm" onClick={() => setPage(page + 1)} disabled={(logs?.length ?? 0) < pageSize}>
|
||||
Next
|
||||
</button>
|
||||
</Button>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,166 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
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';
|
||||
|
||||
// This file is obsolete — Operator management removed in auth overhaul.
|
||||
export default function OperatorsPage() {
|
||||
const qc = useQueryClient();
|
||||
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>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,14 +5,21 @@ import {
|
||||
downloadBillingCsv, downloadVersionDriftCsv,
|
||||
downloadFleetHealthPdf, downloadCustomerUsagePdf, exportFleetReport,
|
||||
} 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 monthAgoStr() { return new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10); }
|
||||
|
||||
export default function ReportsPage() {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-6 text-xl font-semibold text-white">Reports</h2>
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
<QuickFleetReport />
|
||||
<BillingReport />
|
||||
@@ -26,25 +33,13 @@ export default function ReportsPage() {
|
||||
|
||||
function ReportCard({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded border border-gray-800 bg-gray-900 p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold text-white">{title}</h3>
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold text-foreground">{title}</h3>
|
||||
{children}
|
||||
</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() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@@ -59,9 +54,11 @@ function QuickFleetReport() {
|
||||
|
||||
return (
|
||||
<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>
|
||||
<DownloadButton onClick={handleDownload} loading={loading} label="Download PDF" />
|
||||
{error && <p className="mt-2 text-xs text-red-400">{error}</p>}
|
||||
<p className="mb-3 text-xs text-muted-foreground">One-click fleet health PDF covering the last 7 days.</p>
|
||||
<Button onClick={handleDownload} disabled={loading}>
|
||||
{loading ? 'Downloading...' : 'Download PDF'}
|
||||
</Button>
|
||||
{error && <p className="mt-2 text-xs text-status-danger">{error}</p>}
|
||||
</ReportCard>
|
||||
);
|
||||
}
|
||||
@@ -83,8 +80,10 @@ function BillingReport() {
|
||||
return (
|
||||
<ReportCard title="Billing Report (CSV)">
|
||||
<DateRangePicker from={from} to={to} onFromChange={setFrom} onToChange={setTo} />
|
||||
<DownloadButton onClick={handleDownload} loading={loading} label="Download CSV" />
|
||||
{error && <p className="mt-2 text-xs text-red-400">{error}</p>}
|
||||
<Button onClick={handleDownload} disabled={loading}>
|
||||
{loading ? 'Downloading...' : 'Download CSV'}
|
||||
</Button>
|
||||
{error && <p className="mt-2 text-xs text-status-danger">{error}</p>}
|
||||
</ReportCard>
|
||||
);
|
||||
}
|
||||
@@ -103,9 +102,11 @@ function VersionDriftReport() {
|
||||
|
||||
return (
|
||||
<ReportCard title="Version Drift (CSV)">
|
||||
<p className="mb-3 text-xs text-gray-400">Detect Xibo CMS version mismatches across the fleet.</p>
|
||||
<DownloadButton onClick={handleDownload} loading={loading} label="Download CSV" />
|
||||
{error && <p className="mt-2 text-xs text-red-400">{error}</p>}
|
||||
<p className="mb-3 text-xs text-muted-foreground">Detect Xibo CMS version mismatches across the fleet.</p>
|
||||
<Button onClick={handleDownload} disabled={loading}>
|
||||
{loading ? 'Downloading...' : 'Download CSV'}
|
||||
</Button>
|
||||
{error && <p className="mt-2 text-xs text-status-danger">{error}</p>}
|
||||
</ReportCard>
|
||||
);
|
||||
}
|
||||
@@ -127,8 +128,10 @@ function FleetHealthReport() {
|
||||
return (
|
||||
<ReportCard title="Fleet Health Report (PDF)">
|
||||
<DateRangePicker from={from} to={to} onFromChange={setFrom} onToChange={setTo} />
|
||||
<DownloadButton onClick={handleDownload} loading={loading} label="Download PDF" />
|
||||
{error && <p className="mt-2 text-xs text-red-400">{error}</p>}
|
||||
<Button onClick={handleDownload} disabled={loading}>
|
||||
{loading ? 'Downloading...' : 'Download PDF'}
|
||||
</Button>
|
||||
{error && <p className="mt-2 text-xs text-status-danger">{error}</p>}
|
||||
</ReportCard>
|
||||
);
|
||||
}
|
||||
@@ -160,7 +163,7 @@ function CustomerUsageReport() {
|
||||
<select
|
||||
value={customerId}
|
||||
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>
|
||||
{customers?.map((c) => (
|
||||
@@ -171,8 +174,10 @@ function CustomerUsageReport() {
|
||||
</select>
|
||||
</div>
|
||||
<DateRangePicker from={from} to={to} onFromChange={setFrom} onToChange={setTo} />
|
||||
<DownloadButton onClick={handleDownload} loading={loading || !customerId} label="Download PDF" />
|
||||
{error && <p className="mt-2 text-xs text-red-400">{error}</p>}
|
||||
<Button onClick={handleDownload} disabled={loading || !customerId}>
|
||||
{loading ? 'Downloading...' : 'Download PDF'}
|
||||
</Button>
|
||||
{error && <p className="mt-2 text-xs text-status-danger">{error}</p>}
|
||||
</ReportCard>
|
||||
);
|
||||
}
|
||||
@@ -185,14 +190,12 @@ function DateRangePicker({
|
||||
return (
|
||||
<div className="mb-3 flex gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs text-gray-500">From</label>
|
||||
<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" />
|
||||
<Label className="mb-1">From</Label>
|
||||
<Input type="date" value={from} onChange={(e) => onFromChange(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs text-gray-500">To</label>
|
||||
<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" />
|
||||
<Label className="mb-1">To</Label>
|
||||
<Input type="date" value={to} onChange={(e) => onToChange(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,12 +2,16 @@ import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { listHosts } 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 { Label } from '@/components/ui/label';
|
||||
|
||||
export default function SecretsPage() {
|
||||
const { data: hosts } = useQuery({ queryKey: ['hosts'], queryFn: listHosts });
|
||||
const [selectedHostId, setSelectedHostId] = useState<string>('');
|
||||
|
||||
const { data: secrets, isLoading } = useQuery({
|
||||
const { data: secrets, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: ['secrets', selectedHostId],
|
||||
queryFn: () => listSecrets(selectedHostId),
|
||||
enabled: !!selectedHostId,
|
||||
@@ -15,18 +19,24 @@ export default function SecretsPage() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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">
|
||||
<option value="">Select host...</option>
|
||||
{hosts?.map((h) => <option key={h.id} value={h.id}>{h.label}</option>)}
|
||||
</select>
|
||||
</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">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
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({
|
||||
group,
|
||||
@@ -17,13 +24,14 @@ function SettingsSection({
|
||||
<div className="space-y-2">
|
||||
{group.settings.map((s) => (
|
||||
<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>
|
||||
<input
|
||||
<Label htmlFor={`setting-${s.key}`} className="w-60 truncate" title={s.key}>{s.key}</Label>
|
||||
<Input
|
||||
id={`setting-${s.key}`}
|
||||
type={s.isSensitive ? 'password' : 'text'}
|
||||
value={edits[s.key] ?? s.value}
|
||||
onChange={(e) => onEdit(s.key, e.target.value)}
|
||||
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>
|
||||
))}
|
||||
@@ -33,7 +41,7 @@ function SettingsSection({
|
||||
|
||||
export default function SettingsPage() {
|
||||
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 [edits, setEdits] = useState<Record<string, string>>({});
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
@@ -42,7 +50,7 @@ export default function SettingsPage() {
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (items: SettingUpdateItem[]) => saveSettings(items),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['settings'] }); setEdits({}); },
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['settings'] }); setEdits({}); toast.success('Settings saved'); },
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
@@ -69,52 +77,50 @@ export default function SettingsPage() {
|
||||
const activeGroup = groups?.find((g) => g.category === activeTab);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-4 text-xl font-semibold text-white">Settings</h2>
|
||||
<div className="space-y-6">
|
||||
<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) => (
|
||||
<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}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading && <p className="text-gray-400">Loading...</p>}
|
||||
{isLoading && <PageLoading />}
|
||||
{isError && <PageError error={error} onRetry={() => refetch()} />}
|
||||
|
||||
{activeGroup && (
|
||||
<SettingsSection
|
||||
group={activeGroup}
|
||||
edits={edits}
|
||||
onEdit={(key, value) => setEdits({ ...edits, [key]: value })}
|
||||
/>
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<SettingsSection
|
||||
group={activeGroup}
|
||||
edits={edits}
|
||||
onEdit={(key, value) => setEdits({ ...edits, [key]: value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button onClick={handleSave} disabled={Object.keys(edits).length === 0}
|
||||
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-500 disabled:opacity-40">
|
||||
Save Changes
|
||||
</button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={handleSave} disabled={Object.keys(edits).length === 0 || saveMut.isPending}>
|
||||
{saveMut.isPending ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
{activeTab === 'MySql' && (
|
||||
<button onClick={() => runTest(testMySql)} className="rounded bg-gray-700 px-3 py-2 text-sm text-gray-300 hover:bg-gray-600">
|
||||
Test MySQL
|
||||
</button>
|
||||
<Button variant="secondary" onClick={() => runTest(testMySql)}>Test MySQL</Button>
|
||||
)}
|
||||
{activeTab === 'Authentik' && (
|
||||
<button onClick={() => runTest(testAuthentik)} className="rounded bg-gray-700 px-3 py-2 text-sm text-gray-300 hover:bg-gray-600">
|
||||
Test Authentik
|
||||
</button>
|
||||
<Button variant="secondary" onClick={() => runTest(testAuthentik)}>Test Authentik</Button>
|
||||
)}
|
||||
{activeTab === 'Bitwarden' && (
|
||||
<button onClick={() => runTest(testBitwarden)} className="rounded bg-gray-700 px-3 py-2 text-sm text-gray-300 hover:bg-gray-600">
|
||||
Test Bitwarden
|
||||
</button>
|
||||
<Button variant="secondary" onClick={() => runTest(testBitwarden)}>Test Bitwarden</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { create } from 'zustand';
|
||||
|
||||
interface AuthState {
|
||||
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;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,14 @@ public class SshHost
|
||||
|
||||
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 UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
|
||||
@@ -18,8 +18,6 @@ public class OrchestratorDbContext : DbContext
|
||||
public DbSet<ScreenSnapshot> ScreenSnapshots => Set<ScreenSnapshot>();
|
||||
public DbSet<OauthAppRegistry> OauthAppRegistries => Set<OauthAppRegistry>();
|
||||
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<SshHost> SshHosts => Set<SshHost>();
|
||||
public DbSet<OperationLog> OperationLogs => Set<OperationLog>();
|
||||
@@ -150,25 +148,6 @@ public class OrchestratorDbContext : DbContext
|
||||
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 ──────────────────────────────────────────────────
|
||||
modelBuilder.Entity<ByoiConfig>(e =>
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using Renci.SshNet;
|
||||
using OTSSignsOrchestrator.Data.Entities;
|
||||
using OTSSignsOrchestrator.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.Health.Checks;
|
||||
|
||||
@@ -31,29 +31,21 @@ public sealed class MySqlConnectHealthCheck : IHealthCheck
|
||||
|
||||
try
|
||||
{
|
||||
var settings = _services.GetRequiredService<OTSSignsOrchestrator.Services.SettingsService>();
|
||||
var sshInfo = await GetSwarmSshHostAsync(settings);
|
||||
var mysqlHost = await settings.GetAsync(OTSSignsOrchestrator.Services.SettingsService.MySqlHost, "localhost");
|
||||
var mysqlPort = await settings.GetAsync(OTSSignsOrchestrator.Services.SettingsService.MySqlPort, "3306");
|
||||
var mysqlUser = await settings.GetAsync(OTSSignsOrchestrator.Services.SettingsService.MySqlAdminUser, "root");
|
||||
var mysqlPass = await settings.GetAsync(OTSSignsOrchestrator.Services.SettingsService.MySqlAdminPassword, "");
|
||||
var settings = _services.GetRequiredService<SettingsService>();
|
||||
var mysqlHost = await settings.GetAsync(SettingsService.MySqlHost, "localhost");
|
||||
var mysqlPort = await settings.GetAsync(SettingsService.MySqlPort, "3306");
|
||||
var mysqlUser = await settings.GetAsync(SettingsService.MySqlAdminUser, "root");
|
||||
var mysqlPass = await settings.GetAsync(SettingsService.MySqlAdminPassword, "");
|
||||
|
||||
using var sshClient = CreateSshClient(sshInfo);
|
||||
sshClient.Connect();
|
||||
try
|
||||
{
|
||||
// Simple connectivity test — SELECT 1 against the instance database
|
||||
var cmd = $"mysql -h {mysqlHost} -P {mysqlPort} -u {mysqlUser} " +
|
||||
$"-p'{mysqlPass}' -e 'SELECT 1' {dbName} 2>&1";
|
||||
var output = RunSshCommand(sshClient, cmd);
|
||||
await using var shell = _services.GetRequiredService<SwarmShellService>();
|
||||
|
||||
return new HealthCheckResult(HealthStatus.Healthy,
|
||||
$"MySQL connection to {dbName} successful");
|
||||
}
|
||||
finally
|
||||
{
|
||||
sshClient.Disconnect();
|
||||
}
|
||||
// Simple connectivity test — SELECT 1 against the instance database
|
||||
var cmd = $"mysql -h {mysqlHost} -P {mysqlPort} -u {mysqlUser} " +
|
||||
$"-p'{mysqlPass}' -e 'SELECT 1' {dbName} 2>&1";
|
||||
await shell.RunCommandAsync(cmd);
|
||||
|
||||
return new HealthCheckResult(HealthStatus.Healthy,
|
||||
$"MySQL connection to {dbName} successful");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -61,46 +53,4 @@ public sealed class MySqlConnectHealthCheck : IHealthCheck
|
||||
$"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);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using Renci.SshNet;
|
||||
using OTSSignsOrchestrator.Data.Entities;
|
||||
using OTSSignsOrchestrator.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.Health.Checks;
|
||||
|
||||
@@ -30,38 +30,31 @@ public sealed class NfsAccessHealthCheck : IHealthCheck
|
||||
|
||||
try
|
||||
{
|
||||
var settings = _services.GetRequiredService<OTSSignsOrchestrator.Services.SettingsService>();
|
||||
var sshInfo = await GetSwarmSshHostAsync(settings);
|
||||
var nfsServer = await settings.GetAsync(OTSSignsOrchestrator.Services.SettingsService.NfsServer);
|
||||
var nfsExport = await settings.GetAsync(OTSSignsOrchestrator.Services.SettingsService.NfsExport);
|
||||
var settings = _services.GetRequiredService<SettingsService>();
|
||||
var nfsServer = await settings.GetAsync(SettingsService.NfsServer);
|
||||
var nfsExport = await settings.GetAsync(SettingsService.NfsExport);
|
||||
|
||||
if (string.IsNullOrEmpty(nfsServer) || string.IsNullOrEmpty(nfsExport))
|
||||
return new HealthCheckResult(HealthStatus.Critical, "NFS server/export not configured");
|
||||
|
||||
using var sshClient = CreateSshClient(sshInfo);
|
||||
sshClient.Connect();
|
||||
await using var shell = _services.GetRequiredService<SwarmShellService>();
|
||||
|
||||
// Mount temporarily and check the path is listable.
|
||||
// In local (privileged container) mode, sudo is a no-op as root.
|
||||
var mountPoint = $"/tmp/healthcheck-nfs-{Guid.NewGuid():N}";
|
||||
await shell.RunCommandAsync($"sudo mkdir -p {mountPoint}");
|
||||
try
|
||||
{
|
||||
// Mount temporarily and check the path is listable
|
||||
var mountPoint = $"/tmp/healthcheck-nfs-{Guid.NewGuid():N}";
|
||||
RunSshCommand(sshClient, $"sudo mkdir -p {mountPoint}");
|
||||
try
|
||||
{
|
||||
RunSshCommand(sshClient, $"sudo mount -t nfs4 {nfsServer}:{nfsExport} {mountPoint}");
|
||||
var output = RunSshCommand(sshClient, $"ls {mountPoint}/{nfsPath} 2>&1");
|
||||
await shell.RunCommandAsync($"sudo mount -t nfs4 {nfsServer}:{nfsExport} {mountPoint}");
|
||||
await shell.RunCommandAsync($"ls {mountPoint}/{nfsPath} 2>&1");
|
||||
|
||||
return new HealthCheckResult(HealthStatus.Healthy,
|
||||
$"NFS path accessible: {nfsPath}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
RunSshCommandAllowFailure(sshClient, $"sudo umount {mountPoint}");
|
||||
RunSshCommandAllowFailure(sshClient, $"sudo rmdir {mountPoint}");
|
||||
}
|
||||
return new HealthCheckResult(HealthStatus.Healthy,
|
||||
$"NFS path accessible: {nfsPath}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
sshClient.Disconnect();
|
||||
await shell.RunCommandAllowFailureAsync($"sudo umount {mountPoint}");
|
||||
await shell.RunCommandAllowFailureAsync($"sudo rmdir {mountPoint}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -70,52 +63,4 @@ public sealed class NfsAccessHealthCheck : IHealthCheck
|
||||
$"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);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using Renci.SshNet;
|
||||
using OTSSignsOrchestrator.Data.Entities;
|
||||
using OTSSignsOrchestrator.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.Health.Checks;
|
||||
|
||||
@@ -31,97 +31,44 @@ public sealed class StackHealthCheck : IHealthCheck
|
||||
|
||||
try
|
||||
{
|
||||
var settings = _services.GetRequiredService<OTSSignsOrchestrator.Services.SettingsService>();
|
||||
var sshInfo = await GetSwarmSshHostAsync(settings);
|
||||
await using var shell = _services.GetRequiredService<SwarmShellService>();
|
||||
|
||||
using var sshClient = CreateSshClient(sshInfo);
|
||||
sshClient.Connect();
|
||||
// Get task status for all services in the stack
|
||||
var output = await shell.RunCommandAsync(
|
||||
$"docker stack ps {stackName} --format '{{{{.Name}}}}|{{{{.CurrentState}}}}|{{{{.DesiredState}}}}'");
|
||||
|
||||
try
|
||||
var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
var notRunning = new List<string>();
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
// Get task status for all services in the stack
|
||||
var output = RunSshCommand(sshClient,
|
||||
$"docker stack ps {stackName} --format '{{{{.Name}}}}|{{{{.CurrentState}}}}|{{{{.DesiredState}}}}'");
|
||||
var parts = line.Split('|');
|
||||
if (parts.Length < 3) continue;
|
||||
|
||||
var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
var notRunning = new List<string>();
|
||||
var name = parts[0].Trim();
|
||||
var currentState = parts[1].Trim();
|
||||
var desiredState = parts[2].Trim();
|
||||
|
||||
foreach (var line in lines)
|
||||
// Only check tasks whose desired state is Running
|
||||
if (desiredState.Equals("Running", StringComparison.OrdinalIgnoreCase) &&
|
||||
!currentState.StartsWith("Running", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var parts = line.Split('|');
|
||||
if (parts.Length < 3) continue;
|
||||
|
||||
var name = parts[0].Trim();
|
||||
var currentState = parts[1].Trim();
|
||||
var desiredState = parts[2].Trim();
|
||||
|
||||
// Only check tasks whose desired state is Running
|
||||
if (desiredState.Equals("Running", StringComparison.OrdinalIgnoreCase) &&
|
||||
!currentState.StartsWith("Running", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
notRunning.Add($"{name}: {currentState}");
|
||||
}
|
||||
notRunning.Add($"{name}: {currentState}");
|
||||
}
|
||||
|
||||
if (notRunning.Count == 0)
|
||||
return new HealthCheckResult(HealthStatus.Healthy,
|
||||
$"All services in {stackName} are Running");
|
||||
|
||||
return new HealthCheckResult(HealthStatus.Critical,
|
||||
$"{notRunning.Count} service(s) not running in {stackName}",
|
||||
string.Join("\n", notRunning));
|
||||
}
|
||||
finally
|
||||
{
|
||||
sshClient.Disconnect();
|
||||
}
|
||||
|
||||
if (notRunning.Count == 0)
|
||||
return new HealthCheckResult(HealthStatus.Healthy,
|
||||
$"All services in {stackName} are Running");
|
||||
|
||||
return new HealthCheckResult(HealthStatus.Critical,
|
||||
$"{notRunning.Count} service(s) not running in {stackName}",
|
||||
string.Join("\n", notRunning));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ namespace OTSSignsOrchestrator.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Quartz job with two triggers:
|
||||
/// - Weekly (Monday 08:00 UTC): fleet health PDF → operator email list
|
||||
/// - Monthly (1st of month 08:00 UTC): billing CSV + fleet health PDF → operators;
|
||||
/// - Weekly (Monday 08:00 UTC): fleet health PDF → report recipient list
|
||||
/// - Monthly (1st of month 08:00 UTC): billing CSV + fleet health PDF → recipients;
|
||||
/// per-customer usage PDF → each active customer's admin email
|
||||
/// </summary>
|
||||
[DisallowConcurrentExecution]
|
||||
@@ -41,17 +41,17 @@ public sealed class ScheduledReportJob : IJob
|
||||
var pdfService = scope.ServiceProvider.GetRequiredService<FleetHealthPdfService>();
|
||||
var emailService = scope.ServiceProvider.GetRequiredService<EmailService>();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
|
||||
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
|
||||
// Get operator email list (admin operators)
|
||||
var operatorEmails = await db.Operators
|
||||
.AsNoTracking()
|
||||
.Where(o => o.Role == OperatorRole.Admin)
|
||||
.Select(o => o.Email)
|
||||
.ToListAsync(context.CancellationToken);
|
||||
// Get report recipients from settings (comma-separated emails)
|
||||
var raw = await settings.GetAsync(SettingsService.ReportRecipients);
|
||||
var operatorEmails = (raw ?? "")
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToList();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
804
OTSSignsOrchestrator/Migrations/20260324031132_RemoveOperatorAuth.Designer.cs
generated
Normal file
804
OTSSignsOrchestrator/Migrations/20260324031132_RemoveOperatorAuth.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
808
OTSSignsOrchestrator/Migrations/20260324120358_AddSshHostIsLocal.Designer.cs
generated
Normal file
808
OTSSignsOrchestrator/Migrations/20260324120358_AddSshHostIsLocal.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -559,77 +559,6 @@ namespace OTSSignsOrchestrator.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -679,6 +608,10 @@ namespace OTSSignsOrchestrator.Migrations
|
||||
.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)")
|
||||
@@ -832,18 +765,6 @@ namespace OTSSignsOrchestrator.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("OTSSignsOrchestrator.Data.Entities.Instance", "Instance")
|
||||
@@ -878,11 +799,6 @@ namespace OTSSignsOrchestrator.Migrations
|
||||
{
|
||||
b.Navigation("Steps");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Data.Entities.Operator", b =>
|
||||
{
|
||||
b.Navigation("RefreshTokens");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
||||
<PackageReference Include="LibGit2Sharp" Version="0.31.0" />
|
||||
<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.SignalR" Version="1.1.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.2">
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Threading.RateLimiting;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
@@ -23,6 +24,19 @@ using OTSSignsOrchestrator.Health.Checks;
|
||||
using Serilog;
|
||||
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);
|
||||
|
||||
// ── Serilog ──────────────────────────────────────────────────────────────────
|
||||
@@ -31,7 +45,9 @@ builder.Host.UseSerilog((context, config) =>
|
||||
|
||||
// ── EF Core — PostgreSQL ─────────────────────────────────────────────────────
|
||||
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 ──────────────────────────────────────────────────────
|
||||
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection(JwtOptions.Section));
|
||||
@@ -71,6 +87,67 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
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 =>
|
||||
@@ -83,6 +160,7 @@ builder.Services.AddAuthorization(options =>
|
||||
builder.Services.AddDataProtection();
|
||||
|
||||
// ── Application services ────────────────────────────────────────────────────
|
||||
builder.Services.AddSingleton<AdminTokenService>();
|
||||
builder.Services.AddScoped<OperatorAuthService>();
|
||||
builder.Services.AddScoped<AbbreviationService>();
|
||||
builder.Services.AddTransient<EmailService>();
|
||||
@@ -120,6 +198,10 @@ builder.Services.AddScoped<SshDockerCliService>();
|
||||
builder.Services.AddScoped<IDockerCliService>(sp => sp.GetRequiredService<SshDockerCliService>());
|
||||
builder.Services.AddScoped<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>();
|
||||
|
||||
// ── External API clients ────────────────────────────────────────────────────
|
||||
@@ -233,18 +315,86 @@ builder.Services.AddRateLimiter(options =>
|
||||
|
||||
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())
|
||||
{
|
||||
var db = initScope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
|
||||
await db.Database.MigrateAsync();
|
||||
var logger = initScope.ServiceProvider.GetRequiredService<ILogger<OrchestratorDbContext>>();
|
||||
for (var attempt = 1; attempt <= 12; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
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>();
|
||||
|
||||
// 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();
|
||||
|
||||
var stripeKey = await settings.GetAsync(SettingsService.StripeSecretKey);
|
||||
if (!string.IsNullOrWhiteSpace(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 ────────────────────────────────────────────────────────────────
|
||||
@@ -255,62 +405,45 @@ app.UseStaticFiles();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
// ── Cookie-based web auth endpoints (no auth required) ────────────────────
|
||||
app.MapPost("/api/auth/web/login", async (LoginRequest req, OperatorAuthService auth, HttpContext http) =>
|
||||
// ── Admin token redemption (no auth required) ────────────────────────────────
|
||||
app.MapPost("/api/auth/admin-token", (AdminTokenRequest req, AdminTokenService adminToken, OperatorAuthService auth, HttpContext http) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
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)
|
||||
{
|
||||
if (!adminToken.Validate(req.Token))
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var jwt = auth.GenerateJwt("admin-token@system", "SuperAdmin");
|
||||
http.Response.Cookies.Append("ots_access_token", jwt, new CookieOptions
|
||||
{
|
||||
HttpOnly = true, Secure = http.Request.IsHttps, SameSite = SameSiteMode.Strict,
|
||||
Path = "/", MaxAge = TimeSpan.FromMinutes(30),
|
||||
});
|
||||
return Results.Ok(new { message = "Authenticated" });
|
||||
});
|
||||
|
||||
app.MapPost("/api/auth/web/refresh", async (OperatorAuthService auth, HttpContext http) =>
|
||||
// ── OIDC login trigger (no auth required) ────────────────────────────────────
|
||||
app.MapGet("/api/auth/oidc/login", (SettingsService settings) =>
|
||||
{
|
||||
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
|
||||
{
|
||||
HttpOnly = true, Secure = true, SameSite = SameSiteMode.Strict,
|
||||
Path = "/", MaxAge = TimeSpan.FromMinutes(30),
|
||||
});
|
||||
return Results.Ok(new { message = "Token refreshed" });
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
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) =>
|
||||
{
|
||||
var expired = new CookieOptions { Expires = DateTimeOffset.UnixEpoch, Path = "/" };
|
||||
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" });
|
||||
});
|
||||
|
||||
// ── Current user info ─────────────────────────────────────────────────────────
|
||||
app.MapGet("/api/auth/web/me", (ClaimsPrincipal user) =>
|
||||
{
|
||||
var id = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
@@ -337,7 +470,6 @@ app.MapProvisionEndpoints();
|
||||
app.MapSecretsEndpoints();
|
||||
app.MapSettingsEndpoints();
|
||||
app.MapLogsEndpoints();
|
||||
app.MapOperatorsEndpoints();
|
||||
app.MapHealthEndpoints();
|
||||
app.MapAuditEndpoints();
|
||||
app.MapCustomersEndpoints();
|
||||
@@ -351,4 +483,4 @@ app.MapFallbackToFile("index.html");
|
||||
app.Run();
|
||||
|
||||
// ── Request DTOs for auth endpoints ─────────────────────────────────────────
|
||||
public record LoginRequest(string Email, string Password);
|
||||
public record AdminTokenRequest(string Token);
|
||||
|
||||
46
OTSSignsOrchestrator/Services/DockerServiceFactory.cs
Normal file
46
OTSSignsOrchestrator/Services/DockerServiceFactory.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
24
OTSSignsOrchestrator/Services/IDockerServiceFactory.cs
Normal file
24
OTSSignsOrchestrator/Services/IDockerServiceFactory.cs
Normal 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);
|
||||
}
|
||||
445
OTSSignsOrchestrator/Services/LocalDockerCliService.cs
Normal file
445
OTSSignsOrchestrator/Services/LocalDockerCliService.cs
Normal 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() { }
|
||||
}
|
||||
}
|
||||
110
OTSSignsOrchestrator/Services/LocalDockerSecretsService.cs
Normal file
110
OTSSignsOrchestrator/Services/LocalDockerSecretsService.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
90
OTSSignsOrchestrator/Services/LocalProcessRunner.cs
Normal file
90
OTSSignsOrchestrator/Services/LocalProcessRunner.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,20 @@ public class SettingsService
|
||||
public const string CatStripe = "Stripe";
|
||||
public const string CatEmail = "Email";
|
||||
public const string CatBitwarden = "Bitwarden";
|
||||
public const string CatOidc = "OIDC";
|
||||
|
||||
// ── 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
|
||||
public const string GitRepoUrl = "Git.RepoUrl";
|
||||
public const string GitRepoPat = "Git.RepoPat";
|
||||
|
||||
179
OTSSignsOrchestrator/Services/SwarmShellService.cs
Normal file
179
OTSSignsOrchestrator/Services/SwarmShellService.cs
Normal 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);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Renci.SshNet;
|
||||
using OTSSignsOrchestrator.Services;
|
||||
using OTSSignsOrchestrator.Clients;
|
||||
using OTSSignsOrchestrator.Data;
|
||||
@@ -47,6 +46,7 @@ public sealed class DecommissionPipeline : IProvisioningPipeline
|
||||
var authentikClient = scope.ServiceProvider.GetRequiredService<IAuthentikClient>();
|
||||
var xiboFactory = scope.ServiceProvider.GetRequiredService<XiboClientFactory>();
|
||||
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
var shell = scope.ServiceProvider.GetRequiredService<SwarmShellService>();
|
||||
|
||||
var ctx = await BuildContextAsync(job, db, ct);
|
||||
var runner = new StepRunner(db, hub, _logger, job.Id, TotalSteps);
|
||||
@@ -57,33 +57,22 @@ public sealed class DecommissionPipeline : IProvisioningPipeline
|
||||
// ── Step 1: stack-remove ────────────────────────────────────────────
|
||||
await runner.RunAsync("stack-remove", async () =>
|
||||
{
|
||||
var sshHost = await GetSwarmSshHostAsync(settings);
|
||||
using var sshClient = CreateSshClient(sshHost);
|
||||
sshClient.Connect();
|
||||
var result = await shell.RunCommandAsync($"docker stack rm {stackName}");
|
||||
|
||||
try
|
||||
db.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
var result = RunSshCommand(sshClient, $"docker stack rm {stackName}");
|
||||
Id = Guid.NewGuid(),
|
||||
InstanceId = ctx.InstanceId,
|
||||
Actor = "system/decommission",
|
||||
Action = "stack-remove",
|
||||
Target = stackName,
|
||||
Outcome = "success",
|
||||
Detail = $"Docker stack '{stackName}' removed. Job {job.Id}.",
|
||||
OccurredAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
db.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
InstanceId = ctx.InstanceId,
|
||||
Actor = "system/decommission",
|
||||
Action = "stack-remove",
|
||||
Target = stackName,
|
||||
Outcome = "success",
|
||||
Detail = $"Docker stack '{stackName}' removed. Job {job.Id}.",
|
||||
OccurredAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return $"Docker stack '{stackName}' removed. Output: {result}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
sshClient.Disconnect();
|
||||
}
|
||||
return $"Docker stack '{stackName}' removed. Output: {result}";
|
||||
}, ct);
|
||||
|
||||
// ── Step 2: authentik-cleanup ───────────────────────────────────────
|
||||
@@ -205,43 +194,32 @@ public sealed class DecommissionPipeline : IProvisioningPipeline
|
||||
var dbName = $"xibo_{abbrev}";
|
||||
var userName = $"xibo_{abbrev}";
|
||||
|
||||
var sshHost = await GetSwarmSshHostAsync(settings);
|
||||
using var sshClient = CreateSshClient(sshHost);
|
||||
sshClient.Connect();
|
||||
// DROP DATABASE
|
||||
var dropDbCmd = $"mysql -h {mysqlHost} -P {port} -u {mysqlAdminUser} " +
|
||||
$"-p'{mysqlAdminPassword}' -e " +
|
||||
$"\"DROP DATABASE IF EXISTS \\`{dbName}\\`\"";
|
||||
await shell.RunCommandAsync(dropDbCmd);
|
||||
|
||||
try
|
||||
// DROP USER
|
||||
var dropUserCmd = $"mysql -h {mysqlHost} -P {port} -u {mysqlAdminUser} " +
|
||||
$"-p'{mysqlAdminPassword}' -e " +
|
||||
$"\"DROP USER IF EXISTS '{userName}'@'%'\"";
|
||||
await shell.RunCommandAsync(dropUserCmd);
|
||||
|
||||
db.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
// DROP DATABASE
|
||||
var dropDbCmd = $"mysql -h {mysqlHost} -P {port} -u {mysqlAdminUser} " +
|
||||
$"-p'{mysqlAdminPassword}' -e " +
|
||||
$"\"DROP DATABASE IF EXISTS \\`{dbName}\\`\"";
|
||||
RunSshCommand(sshClient, dropDbCmd);
|
||||
Id = Guid.NewGuid(),
|
||||
InstanceId = ctx.InstanceId,
|
||||
Actor = "system/decommission",
|
||||
Action = "mysql-cleanup",
|
||||
Target = dbName,
|
||||
Outcome = "success",
|
||||
Detail = $"Database '{dbName}' and user '{userName}' dropped. Job {job.Id}.",
|
||||
OccurredAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
// DROP USER
|
||||
var dropUserCmd = $"mysql -h {mysqlHost} -P {port} -u {mysqlAdminUser} " +
|
||||
$"-p'{mysqlAdminPassword}' -e " +
|
||||
$"\"DROP USER IF EXISTS '{userName}'@'%'\"";
|
||||
RunSshCommand(sshClient, dropUserCmd);
|
||||
|
||||
db.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
InstanceId = ctx.InstanceId,
|
||||
Actor = "system/decommission",
|
||||
Action = "mysql-cleanup",
|
||||
Target = dbName,
|
||||
Outcome = "success",
|
||||
Detail = $"Database '{dbName}' and user '{userName}' dropped. Job {job.Id}.",
|
||||
OccurredAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return $"Database '{dbName}' and user '{userName}' dropped on {mysqlHost}:{port}.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
sshClient.Disconnect();
|
||||
}
|
||||
return $"Database '{dbName}' and user '{userName}' dropped on {mysqlHost}:{port}.";
|
||||
}, ct);
|
||||
|
||||
// ── Step 5: nfs-archive ─────────────────────────────────────────────
|
||||
@@ -262,49 +240,37 @@ public sealed class DecommissionPipeline : IProvisioningPipeline
|
||||
var sourcePath = $"{basePath}/{abbrev}";
|
||||
var archivePath = $"{basePath}/archived/{abbrev}-{timestamp}";
|
||||
|
||||
var sshHost = await GetSwarmSshHostAsync(settings);
|
||||
using var sshClient = CreateSshClient(sshHost);
|
||||
sshClient.Connect();
|
||||
// Temporarily mount NFS, archive directory, unmount.
|
||||
// In local (privileged container) mode, sudo is a no-op as root.
|
||||
var mountPoint = $"/tmp/nfs-decommission-{abbrev}";
|
||||
await shell.RunCommandAsync($"sudo mkdir -p {mountPoint}");
|
||||
await shell.RunCommandAsync($"sudo mount -t nfs4 {nfsServer}:{basePath} {mountPoint}");
|
||||
|
||||
try
|
||||
{
|
||||
// Temporarily mount NFS to move directories
|
||||
var mountPoint = $"/tmp/nfs-decommission-{abbrev}";
|
||||
RunSshCommand(sshClient, $"sudo mkdir -p {mountPoint}");
|
||||
RunSshCommand(sshClient, $"sudo mount -t nfs4 {nfsServer}:{basePath} {mountPoint}");
|
||||
|
||||
try
|
||||
{
|
||||
// Ensure archive directory exists
|
||||
RunSshCommand(sshClient, $"sudo mkdir -p {mountPoint}/archived");
|
||||
// Move — DO NOT delete (retain for 30 days minimum)
|
||||
RunSshCommand(sshClient, $"sudo mv {mountPoint}/{abbrev} {mountPoint}/archived/{abbrev}-{timestamp}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
RunSshCommandAllowFailure(sshClient, $"sudo umount {mountPoint}");
|
||||
RunSshCommandAllowFailure(sshClient, $"sudo rmdir {mountPoint}");
|
||||
}
|
||||
|
||||
db.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
InstanceId = ctx.InstanceId,
|
||||
Actor = "system/decommission",
|
||||
Action = "nfs-archive",
|
||||
Target = sourcePath,
|
||||
Outcome = "success",
|
||||
Detail = $"NFS data archived to {archivePath}. Retained for minimum 30 days. Job {job.Id}.",
|
||||
OccurredAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return $"NFS data moved from {sourcePath} to {archivePath}. Retained for 30+ days.";
|
||||
await shell.RunCommandAsync($"sudo mkdir -p {mountPoint}/archived");
|
||||
await shell.RunCommandAsync($"sudo mv {mountPoint}/{abbrev} {mountPoint}/archived/{abbrev}-{timestamp}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
sshClient.Disconnect();
|
||||
await shell.RunCommandAllowFailureAsync($"sudo umount {mountPoint}");
|
||||
await shell.RunCommandAllowFailureAsync($"sudo rmdir {mountPoint}");
|
||||
}
|
||||
|
||||
db.AuditLogs.Add(new AuditLog
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
InstanceId = ctx.InstanceId,
|
||||
Actor = "system/decommission",
|
||||
Action = "nfs-archive",
|
||||
Target = sourcePath,
|
||||
Outcome = "success",
|
||||
Detail = $"NFS data archived to {archivePath}. Retained for minimum 30 days. Job {job.Id}.",
|
||||
OccurredAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return $"NFS data moved from {sourcePath} to {archivePath}. Retained for 30+ days.";
|
||||
}, ct);
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Renci.SshNet;
|
||||
using OTSSignsOrchestrator.Configuration;
|
||||
using OTSSignsOrchestrator.Services;
|
||||
using OTSSignsOrchestrator.Clients;
|
||||
@@ -51,6 +50,7 @@ public sealed class Phase1Pipeline : IProvisioningPipeline
|
||||
var gitService = scope.ServiceProvider.GetRequiredService<GitTemplateService>();
|
||||
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
||||
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
var shell = scope.ServiceProvider.GetRequiredService<SwarmShellService>();
|
||||
|
||||
var ctx = await BuildContextAsync(job, db, ct);
|
||||
var runner = new StepRunner(db, hub, _logger, job.Id, TotalSteps);
|
||||
@@ -74,77 +74,48 @@ public sealed class Phase1Pipeline : IProvisioningPipeline
|
||||
|
||||
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();
|
||||
// Create database
|
||||
var createDbCmd = $"mysql -h {mysqlHost} -P {port} -u {mysqlAdminUser} " +
|
||||
$"-p'{mysqlAdminPassword}' -e " +
|
||||
$"\"CREATE DATABASE IF NOT EXISTS \\`{mysqlDbName}\\`\"";
|
||||
await shell.RunCommandAsync(createDbCmd);
|
||||
|
||||
try
|
||||
{
|
||||
// Create database
|
||||
var createDbCmd = $"mysql -h {mysqlHost} -P {port} -u {mysqlAdminUser} " +
|
||||
$"-p'{mysqlAdminPassword}' -e " +
|
||||
$"\"CREATE DATABASE IF NOT EXISTS \\`{mysqlDbName}\\`\"";
|
||||
RunSshCommand(sshClient, createDbCmd);
|
||||
// Create user
|
||||
var createUserCmd = $"mysql -h {mysqlHost} -P {port} -u {mysqlAdminUser} " +
|
||||
$"-p'{mysqlAdminPassword}' -e " +
|
||||
$"\"CREATE USER IF NOT EXISTS '{mysqlUserName}'@'%' IDENTIFIED BY '{mysqlPassword}'\"";
|
||||
await shell.RunCommandAsync(createUserCmd);
|
||||
|
||||
// Create user
|
||||
var createUserCmd = $"mysql -h {mysqlHost} -P {port} -u {mysqlAdminUser} " +
|
||||
$"-p'{mysqlAdminPassword}' -e " +
|
||||
$"\"CREATE USER IF NOT EXISTS '{mysqlUserName}'@'%' IDENTIFIED BY '{mysqlPassword}'\"";
|
||||
RunSshCommand(sshClient, createUserCmd);
|
||||
// Grant privileges
|
||||
var grantCmd = $"mysql -h {mysqlHost} -P {port} -u {mysqlAdminUser} " +
|
||||
$"-p'{mysqlAdminPassword}' -e " +
|
||||
$"\"GRANT ALL PRIVILEGES ON \\`{mysqlDbName}\\`.* TO '{mysqlUserName}'@'%'; FLUSH PRIVILEGES;\"";
|
||||
await shell.RunCommandAsync(grantCmd);
|
||||
|
||||
// Grant privileges
|
||||
var grantCmd = $"mysql -h {mysqlHost} -P {port} -u {mysqlAdminUser} " +
|
||||
$"-p'{mysqlAdminPassword}' -e " +
|
||||
$"\"GRANT ALL PRIVILEGES ON \\`{mysqlDbName}\\`.* TO '{mysqlUserName}'@'%'; FLUSH PRIVILEGES;\"";
|
||||
RunSshCommand(sshClient, grantCmd);
|
||||
|
||||
return $"Database '{mysqlDbName}' and user '{mysqlUserName}' created on {mysqlHost}:{port}.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
sshClient.Disconnect();
|
||||
}
|
||||
return $"Database '{mysqlDbName}' and user '{mysqlUserName}' created on {mysqlHost}:{port}.";
|
||||
}, ct);
|
||||
|
||||
// ── Step 2: docker-secrets ──────────────────────────────────────────
|
||||
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>
|
||||
{
|
||||
[CustomerMysqlPasswordSecretName(abbrev)] = mysqlPassword,
|
||||
[CustomerMysqlUserSecretName(abbrev)] = mysqlUserName,
|
||||
[GlobalMysqlHostSecretName] = await settings.GetAsync(SettingsService.MySqlHost, "localhost"),
|
||||
[GlobalMysqlPortSecretName] = await settings.GetAsync(SettingsService.MySqlPort, "3306"),
|
||||
};
|
||||
[CustomerMysqlPasswordSecretName(abbrev)] = mysqlPassword,
|
||||
[CustomerMysqlUserSecretName(abbrev)] = mysqlUserName,
|
||||
[GlobalMysqlHostSecretName] = await settings.GetAsync(SettingsService.MySqlHost, "localhost"),
|
||||
[GlobalMysqlPortSecretName] = await settings.GetAsync(SettingsService.MySqlPort, "3306"),
|
||||
};
|
||||
|
||||
var created = new List<string>();
|
||||
foreach (var (name, value) in secrets)
|
||||
{
|
||||
// Remove existing secret if present (idempotent rotate)
|
||||
RunSshCommandAllowFailure(sshClient, $"docker secret rm {name}");
|
||||
|
||||
var safeValue = value.Replace("'", "'\\''");
|
||||
var cmd = $"printf '%s' '{safeValue}' | docker secret create {name} -";
|
||||
RunSshCommand(sshClient, cmd);
|
||||
created.Add(name);
|
||||
}
|
||||
|
||||
return $"Docker secrets created: {string.Join(", ", created)}.";
|
||||
}
|
||||
finally
|
||||
var created = new List<string>();
|
||||
foreach (var (name, value) in secrets)
|
||||
{
|
||||
sshClient.Disconnect();
|
||||
// Remove existing secret if present (idempotent rotate)
|
||||
await shell.RunCommandAllowFailureAsync($"docker secret rm {name}");
|
||||
await shell.RunCommandWithStdinAsync($"docker secret create {name} -", value);
|
||||
created.Add(name);
|
||||
}
|
||||
|
||||
return $"Docker secrets created: {string.Join(", ", created)}.";
|
||||
}, ct);
|
||||
|
||||
// ── Step 3: nfs-dirs ────────────────────────────────────────────────
|
||||
@@ -157,53 +128,40 @@ public sealed class Phase1Pipeline : IProvisioningPipeline
|
||||
if (string.IsNullOrWhiteSpace(nfsServer))
|
||||
return "NFS server not configured — skipping directory creation.";
|
||||
|
||||
var sshHost = await GetSwarmSshHostAsync(settings);
|
||||
using var sshClient = CreateSshClient(sshHost);
|
||||
sshClient.Connect();
|
||||
// Build the base path for NFS dirs
|
||||
var export = (nfsExport ?? string.Empty).TrimEnd('/');
|
||||
var folder = (nfsExportFolder ?? string.Empty).Trim('/');
|
||||
var basePath = string.IsNullOrEmpty(folder) ? export : $"{export}/{folder}";
|
||||
|
||||
var subdirs = new[]
|
||||
{
|
||||
$"{abbrev}/cms-custom",
|
||||
$"{abbrev}/cms-backup",
|
||||
$"{abbrev}/cms-library",
|
||||
$"{abbrev}/cms-userscripts",
|
||||
$"{abbrev}/cms-ca-certs",
|
||||
};
|
||||
|
||||
// Temporarily mount the NFS export to create directories.
|
||||
// In local (privileged container) mode, sudo is a no-op as root.
|
||||
var mountPoint = $"/tmp/nfs-provision-{abbrev}";
|
||||
await shell.RunCommandAsync($"sudo mkdir -p {mountPoint}");
|
||||
await shell.RunCommandAsync($"sudo mount -t nfs4 {nfsServer}:{basePath} {mountPoint}");
|
||||
|
||||
try
|
||||
{
|
||||
// Build the base path for NFS dirs
|
||||
var export = (nfsExport ?? string.Empty).TrimEnd('/');
|
||||
var folder = (nfsExportFolder ?? string.Empty).Trim('/');
|
||||
var basePath = string.IsNullOrEmpty(folder) ? export : $"{export}/{folder}";
|
||||
|
||||
var subdirs = new[]
|
||||
foreach (var subdir in subdirs)
|
||||
{
|
||||
$"{abbrev}/cms-custom",
|
||||
$"{abbrev}/cms-backup",
|
||||
$"{abbrev}/cms-library",
|
||||
$"{abbrev}/cms-userscripts",
|
||||
$"{abbrev}/cms-ca-certs",
|
||||
};
|
||||
|
||||
// Create directories via sudo on the NFS host
|
||||
// The swarm node mounts NFS, so we can create directories by
|
||||
// temporarily mounting, creating, then unmounting.
|
||||
var mountPoint = $"/tmp/nfs-provision-{abbrev}";
|
||||
RunSshCommand(sshClient, $"sudo mkdir -p {mountPoint}");
|
||||
RunSshCommand(sshClient,
|
||||
$"sudo mount -t nfs4 {nfsServer}:{basePath} {mountPoint}");
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var subdir in subdirs)
|
||||
{
|
||||
RunSshCommand(sshClient, $"sudo mkdir -p {mountPoint}/{subdir}");
|
||||
}
|
||||
await shell.RunCommandAsync($"sudo mkdir -p {mountPoint}/{subdir}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
RunSshCommandAllowFailure(sshClient, $"sudo umount {mountPoint}");
|
||||
RunSshCommandAllowFailure(sshClient, $"sudo rmdir {mountPoint}");
|
||||
}
|
||||
|
||||
return $"NFS directories created under {basePath}: {string.Join(", ", subdirs)}.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
sshClient.Disconnect();
|
||||
await shell.RunCommandAllowFailureAsync($"sudo umount {mountPoint}");
|
||||
await shell.RunCommandAllowFailureAsync($"sudo rmdir {mountPoint}");
|
||||
}
|
||||
|
||||
return $"NFS directories created under {basePath}: {string.Join(", ", subdirs)}.";
|
||||
}, ct);
|
||||
|
||||
// ── Step 4: authentik-provision ─────────────────────────────────────
|
||||
@@ -323,22 +281,10 @@ public sealed class Phase1Pipeline : IProvisioningPipeline
|
||||
|
||||
var composeYaml = composeRenderer.Render(templateConfig.Yaml, renderCtx);
|
||||
|
||||
// Deploy via SSH: pipe compose YAML to docker stack deploy
|
||||
var sshHost = await GetSwarmSshHostAsync(settings);
|
||||
using var sshClient = CreateSshClient(sshHost);
|
||||
sshClient.Connect();
|
||||
|
||||
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();
|
||||
}
|
||||
// Pipe compose YAML to docker stack deploy
|
||||
var deployResult = await shell.RunCommandWithStdinAsync(
|
||||
$"docker stack deploy -c - {stackName}", composeYaml);
|
||||
return $"Stack '{stackName}' deployed. Output: {deployResult}";
|
||||
}, ct);
|
||||
|
||||
// ── 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)
|
||||
{
|
||||
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
|
||||
@@ -484,11 +361,4 @@ public sealed class Phase1Pipeline : IProvisioningPipeline
|
||||
$"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);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ using OTSSignsOrchestrator.Clients;
|
||||
using OTSSignsOrchestrator.Data;
|
||||
using OTSSignsOrchestrator.Data.Entities;
|
||||
using OTSSignsOrchestrator.Hubs;
|
||||
using OTSSignsOrchestrator.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.Workers;
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Renci.SshNet;
|
||||
using OTSSignsOrchestrator.Services;
|
||||
using OTSSignsOrchestrator.Data;
|
||||
using OTSSignsOrchestrator.Data.Entities;
|
||||
@@ -42,6 +41,7 @@ public sealed class ReactivatePipeline : IProvisioningPipeline
|
||||
var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
|
||||
var hub = scope.ServiceProvider.GetRequiredService<IHubContext<FleetHub, IFleetClient>>();
|
||||
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
var shell = scope.ServiceProvider.GetRequiredService<SwarmShellService>();
|
||||
|
||||
var ctx = await BuildContextAsync(job, db, ct);
|
||||
var runner = new StepRunner(db, hub, _logger, job.Id, TotalSteps);
|
||||
@@ -51,20 +51,9 @@ public sealed class ReactivatePipeline : IProvisioningPipeline
|
||||
// ── Step 1: scale-up ────────────────────────────────────────────────
|
||||
await runner.RunAsync("scale-up", async () =>
|
||||
{
|
||||
var sshHost = await GetSwarmSshHostAsync(settings);
|
||||
using var sshClient = CreateSshClient(sshHost);
|
||||
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}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
sshClient.Disconnect();
|
||||
}
|
||||
var result = await shell.RunCommandAsync(
|
||||
$"docker service scale xibo-{abbrev}_web=1 xibo-{abbrev}_xmr=1");
|
||||
return $"Scaled up services for xibo-{abbrev}. Output: {result}";
|
||||
}, ct);
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Renci.SshNet;
|
||||
using OTSSignsOrchestrator.Services;
|
||||
using OTSSignsOrchestrator.Data;
|
||||
using OTSSignsOrchestrator.Data.Entities;
|
||||
@@ -41,6 +40,7 @@ public sealed class SuspendPipeline : IProvisioningPipeline
|
||||
var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
|
||||
var hub = scope.ServiceProvider.GetRequiredService<IHubContext<FleetHub, IFleetClient>>();
|
||||
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
var shell = scope.ServiceProvider.GetRequiredService<SwarmShellService>();
|
||||
|
||||
var ctx = await BuildContextAsync(job, db, ct);
|
||||
var runner = new StepRunner(db, hub, _logger, job.Id, TotalSteps);
|
||||
@@ -50,20 +50,9 @@ public sealed class SuspendPipeline : IProvisioningPipeline
|
||||
// ── Step 1: scale-down ──────────────────────────────────────────────
|
||||
await runner.RunAsync("scale-down", async () =>
|
||||
{
|
||||
var sshHost = await GetSwarmSshHostAsync(settings);
|
||||
using var sshClient = CreateSshClient(sshHost);
|
||||
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}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
sshClient.Disconnect();
|
||||
}
|
||||
var result = await shell.RunCommandAsync(
|
||||
$"docker service scale xibo-{abbrev}_web=0 xibo-{abbrev}_xmr=0");
|
||||
return $"Scaled down services for xibo-{abbrev}. Output: {result}";
|
||||
}, ct);
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
282
OTSSignsOrchestrator/wwwroot/assets/index-BJlKibYj.js
Normal file
282
OTSSignsOrchestrator/wwwroot/assets/index-BJlKibYj.js
Normal file
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
Reference in New Issue
Block a user