Compare commits

...

2 Commits

Author SHA1 Message Date
Matt Batchelder
fc510b9b20 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.
2026-03-26 19:34:12 -04:00
Matt Batchelder
9a35e40083 feat: Add initial deployment setup for OTSSignsOrchestrator
- Create index.html for the web application interface.
- Implement deploy.sh script for building and deploying the application to a Docker Swarm manager.
- Add docker-compose.yml for defining application and PostgreSQL service configurations.
2026-03-23 21:28:14 -04:00
289 changed files with 21853 additions and 12173 deletions

42
.dockerignore Normal file
View File

@@ -0,0 +1,42 @@
# .NET build artifacts
**/bin/
**/obj/
# Node / frontend
**/node_modules/
OTSSignsOrchestrator/ClientApp/dist/
# Built frontend (generated by Vite into wwwroot — included via Docker COPY from frontend-build stage)
OTSSignsOrchestrator/wwwroot/
# Test project
OTSSignsOrchestrator.Tests/
# Logs
logs/
*.log
# User-specific files
.vs/
.vscode/
*.user
*.suo
# Secrets / local config overrides (never bake these into the image)
appsettings.Development.json
**/secrets.json
# Git
.git/
.gitignore
# Docker
Dockerfile
.dockerignore
docker-compose*.yml
# Misc
README.md
CLAUDE.md
.github/
templates/

View File

@@ -1,11 +1,15 @@
# OTSSignsOrchestrator.Server — required environment variables
# Copy to .env and fill in real values.
# 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.
ConnectionStrings__OrchestratorDb=Host=localhost;Port=5432;Database=orchestrator_dev;Username=ots;Password=devpassword
Stripe__WebhookSecret=whsec_...
Stripe__SecretKey=sk_test_...
Jwt__Key=change-me-to-a-random-256-bit-key
Authentik__BaseUrl=https://auth.example.com
Authentik__ApiToken=
SendGrid__ApiKey=SG....
OTS_SIGNS_SERVER_URL=http://localhost:5000
# ── PostgreSQL ───────────────────────────────────────────────────────────────
# 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: openssl rand -base64 48
JWT_KEY=change-me-to-a-random-256-bit-key

View File

@@ -2,27 +2,21 @@
## Architecture
Layered MVVM desktop app for provisioning and managing Xibo CMS instances on Docker Swarm.
Web-based system for provisioning and managing Xibo CMS instances on Docker Swarm.
- **OTSSignsOrchestrator.Core** — class library: services, EF Core data access, models, configuration. Reusable across UIs.
- **OTSSignsOrchestrator.Desktop** — Avalonia 11 UI: views, view models, DI setup. References Core.
- **templates/** — Docker Compose + PHP templates with `{{PLACEHOLDER}}` substitution.
- **OTSSignsOrchestrator** — ASP.NET Core API + React web UI (Vite + TypeScript + Tailwind CSS) + SignalR + Quartz scheduler. PostgreSQL 16. Contains all services, models, configuration, and business logic.
- **OTSSignsOrchestrator.Tests** — xUnit test project.
### Key patterns
- Services injected via `IServiceProvider` (registered in `App.axaml.cs``ConfigureServices()`)
- Singletons: stateful services (SSH connections, Docker CLI). Transient: stateless logic.
- Services injected via DI (registered in `Program.cs`)
- Singletons: stateful services (SSH connection factory). Scoped: per-request services (Docker CLI, secrets). Transient: stateless logic.
- Configuration via `IOptions<T>` bound from `appsettings.json` (see `AppOptions.cs` for all sections).
- Bitwarden Secrets Manager is the source of truth for all sensitive config. `SettingsService` caches in-memory.
- Local SQLite DB (`otssigns-desktop.db`) stores SSH hosts + operation logs. Credentials encrypted via Data Protection API.
### Scope & file discipline
**The Server project is net-new — keep concerns separated.**
- Never modify `OTSSignsOrchestrator.Core` or `OTSSignsOrchestrator.Desktop` unless the prompt explicitly says to.
- When in doubt, add new code to `OTSSignsOrchestrator.Server`.
- Never modify `XiboContext.cs` without explicit instruction.
- PostgreSQL database via `OrchestratorDbContext`. Credentials encrypted via Data Protection API.
- React frontend in `ClientApp/`, built to `wwwroot/` via Vite. Cookie-based JWT auth.
### External integrations
Xibo CMS REST API (OAuth2), Authentik SAML IdP, Bitwarden Secrets SDK, Docker Swarm (via SSH), Git (LibGit2Sharp), MySQL 8.4, NFS volumes, Pangolin/Newt VPN.
Xibo CMS REST API (OAuth2), Authentik SAML IdP, Bitwarden Secrets SDK, Docker Swarm (via SSH), Git (LibGit2Sharp), MySQL 8.4, NFS volumes, Pangolin/Newt VPN, Stripe, SendGrid.
#### Xibo API rules — non-negotiable
- `GET /api/application` is **BLOCKED**. Only POST and DELETE exist.
@@ -43,17 +37,20 @@ Xibo CMS REST API (OAuth2), Authentik SAML IdP, Bitwarden Secrets SDK, Docker Sw
```bash
# Build
dotnet build OTSSignsOrchestrator.Desktop/OTSSignsOrchestrator.Desktop.csproj
dotnet build
# Run
dotnet run --project OTSSignsOrchestrator.Desktop/OTSSignsOrchestrator.Desktop.csproj
# Run Server
dotnet run --project OTSSignsOrchestrator/OTSSignsOrchestrator.csproj
# No test suite currently — no xUnit/NUnit projects
# Run tests
dotnet test OTSSignsOrchestrator.Tests/OTSSignsOrchestrator.Tests.csproj
# Frontend dev
cd OTSSignsOrchestrator/ClientApp && npm run dev
```
- .NET 9.0, Avalonia 11.2.3, CommunityToolkit.Mvvm 8.4
- Runtime identifiers: `linux-x64`, `win-x64`, `osx-x64`, `osx-arm64`
- EF Core migrations in `OTSSignsOrchestrator.Core/Migrations/`
- .NET 9.0, React 18, Vite, TypeScript, Tailwind CSS, shadcn/ui
- EF Core migrations in `OTSSignsOrchestrator/Migrations/`
### Test coverage non-negotiables
Unit tests are **required** for:
@@ -67,28 +64,10 @@ Integration tests **require** Testcontainers with a real PostgreSQL 16 instance
## Conventions
### ViewModels
- Inherit `ObservableObject` (CommunityToolkit.Mvvm). Use `[ObservableProperty]` for bindable fields and `[RelayCommand]` for commands.
- React to changes via `partial void OnXxxChanged(T value)` methods generated by the toolkit.
- Resolve services from `IServiceProvider` in constructors. Navigation via `MainWindowViewModel.CurrentView`.
- Confirmation dialogs use `Func<string, string, Task<bool>> ConfirmAsync` property — wired by the View.
### Avalonia threading — critical for stability
All SignalR message handlers and background thread continuations that touch `ObservableProperty` or `ObservableCollection` **MUST** be wrapped in:
```csharp
Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => { ... });
```
**Failure to do this causes silent cross-thread exceptions in Avalonia.** Never suggest direct property assignment from a non-UI thread.
### Views (Avalonia XAML)
- Compiled bindings enabled (`x:CompileBindings="True"`). DataTemplates in `MainWindow.axaml` map ViewModel types to View UserControls.
- Layout: DockPanel with status bar (bottom), sidebar nav (left), dynamic ContentControl (center).
- Style: Fluent theme, dark palette (`#0C0C14` accents).
### Services
- Interface + implementation pattern for testable services (`IXiboApiService`, `IDockerCliService`, etc.).
- `SshDockerCliService` is a singleton**must call `SetHost(host)` before each operation** in loops.
- All long operations are `async Task`. Use `IsBusy` + `StatusMessage` properties for UI feedback.
- `SshDockerCliService` is scoped**must call `SetHost(host)` before each operation** in loops.
- All long operations are `async Task`.
### Naming
- Customer abbreviation: exactly 3 lowercase letters (`^[a-z]{3}$`).
@@ -97,27 +76,23 @@ Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => { ... });
- `AppConstants.SanitizeName()` filters to `[a-z0-9_-]`.
### Credential handling
Never store OAuth2 client secrets, Stripe keys, or SSH passwords in the database. Secrets go to the Bitwarden CLI wrapper only. `OauthAppRegistry` stores `clientId` only — never the secret. Log credentials to `JobStep` output **ONLY** as a last-resort break-glass fallback, and mark it explicitly as emergency recovery data in the log.
Never store OAuth2 client secrets, Stripe keys, or SSH passwords in the database. Secrets go to the Bitwarden CLI wrapper only. `OauthAppRegistry` stores `clientId` only — never the secret.
### Code generation verification
After generating any class that implements an interface, **verify all interface members are implemented.** After generating any pipeline, **verify all steps are implemented as `JobStep` entities with progress broadcast via `IHubContext<FleetHub>`.** Do not stub steps as TODO — implement them fully or flag explicitly that the step requires external infrastructure access that cannot be completed in this context.
After generating any class that implements an interface, **verify all interface members are implemented.** After generating any pipeline, **verify all steps are implemented as `JobStep` entities with progress broadcast via `IHubContext<FleetHub>`.** Do not stub steps as TODO — implement them fully or flag explicitly.
### Data layer
- Entities in `Core/Models/Entities/`, DTOs in `Core/Models/DTOs/`.
- `XiboContext` applies unique index on `SshHost.Label` and encrypts credential fields.
- Add new migrations via: `dotnet ef migrations add <Name> --project OTSSignsOrchestrator.Core --startup-project OTSSignsOrchestrator.Desktop`
- Entities in `Core/Models/Entities/` and `Server/Data/Entities/`.
- DTOs in `Core/Models/DTOs/`.
- `OrchestratorDbContext` is the primary database context (PostgreSQL).
### Immutability enforcement
**AuditLog, Message, and Evidence are append-only by design.** Never generate `Update()` or `Delete()` methods on these repositories. Add an explicit comment on each repository class:
```csharp
// IMMUTABLE — no update or delete operations permitted.
```
**AuditLog, Message, and Evidence are append-only by design.** Never generate `Update()` or `Delete()` methods on these repositories.
## Pitfalls
- **SSH singleton state**: `SshDockerCliService.SetHost()` must be called before each host operation — stale host causes silent failures.
- **SSH host state**: `SshDockerCliService.SetHost()` must be called before each host operation — stale host causes silent failures.
- **Bitwarden cache**: After creating secrets during deployment, call `FlushCacheAsync()` before reading them back.
- **Data Protection keys**: Stored in `%APPDATA%/OTSSignsOrchestrator/keys`. If lost, encrypted SSH passwords are unrecoverable.
- **Docker volumes are sticky**: Failed deploys leave volumes with old NFS driver options. Use `PurgeStaleVolumes: true` to force fresh volumes (causes data loss).
- **No saga/rollback**: Instance creation spans Git → MySQL → Docker → Xibo. Partial failures leave orphaned resources; cleanup is manual via `OperationLog`.
- **Template CIFS→NFS compat**: Old `{{CIFS_*}}` tokens still render correctly as NFS equivalents.

32
.gitignore vendored
View File

@@ -1,12 +1,14 @@
# .gitignore for C#/.NET projects on macOS
# .gitignore for ASP.NET Core + React (Vite) project on macOS
# Generated for Visual Studio, Rider, and dotnet CLI workflows
# Visual Studio
# Visual Studio & Rider
.vs/
.idea/
*.suo
*.user
*.userosscache
*.sln.docstates
*.DotSettings.user
# Build results
[Bb]in/
@@ -15,19 +17,15 @@ build/
publish/
artifacts/
# Rider
.idea/
# Resharper
_ReSharper*/
*.DotSettings.user
# NuGet
*.nupkg
packages/
project.lock.json
# Dotnet
# Dotnet & EF Core
*.db
*.db-journal
secrets.json
@@ -36,6 +34,8 @@ dotnet_user_secrets
# Logs
*.log
TestResults/
coverage/
*.trx
# OS generated files
.DS_Store
@@ -51,9 +51,23 @@ Icon
*~
*.tmp
# Node/React Frontend (Vite)
OTSSignsOrchestrator/ClientApp/node_modules/
OTSSignsOrchestrator/ClientApp/dist/
OTSSignsOrchestrator/ClientApp/.env.local
OTSSignsOrchestrator/ClientApp/.env.*.local
OTSSignsOrchestrator/ClientApp/npm-debug.log*
OTSSignsOrchestrator/ClientApp/yarn-debug.log*
OTSSignsOrchestrator/ClientApp/yarn-error.log*
# Docker
docker-compose.override.yml
# Ignore appsettings development files (if you keep secrets locally)
# Configuration & Secrets
appsettings.Development.json
.template-cache/
.template-cache/
.env
*.env
# Application-specific
logs/

125
CLAUDE.md Normal file
View File

@@ -0,0 +1,125 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
OTS Signs Orchestrator — a .NET 9.0 system for provisioning and managing Xibo CMS instances on Docker Swarm. Two projects in one solution:
- **OTSSignsOrchestrator** — ASP.NET Core API + React web UI + SignalR + Quartz scheduler. PostgreSQL 16. Contains all services, models, configuration, and business logic.
- **OTSSignsOrchestrator.Tests** — xUnit test project.
External integrations: Xibo CMS API (OAuth2), Authentik (SAML IdP), Bitwarden Secrets, Docker Swarm (SSH), Git (LibGit2Sharp), MySQL 8.4, Stripe, SendGrid, NFS volumes.
## Build & Run Commands
```bash
# Build entire solution
dotnet build
# Run application
dotnet run --project OTSSignsOrchestrator/OTSSignsOrchestrator.csproj
# Run tests
dotnet test OTSSignsOrchestrator.Tests/OTSSignsOrchestrator.Tests.csproj
# Run a single test
dotnet test OTSSignsOrchestrator.Tests --filter "FullyQualifiedName~TestClassName.TestMethodName"
# Frontend dev server (from ClientApp/)
cd OTSSignsOrchestrator/ClientApp && npm run dev
# Build frontend for production (outputs to wwwroot/)
cd OTSSignsOrchestrator/ClientApp && npm run build
# EF Core migrations
dotnet ef migrations add <Name> --project OTSSignsOrchestrator --startup-project OTSSignsOrchestrator
# Local dev PostgreSQL
docker compose -f docker-compose.dev.yml up -d
```
## Architecture
The application uses a job-queue architecture with a React web UI:
1. **React Web UI** (`ClientApp/`) — Vite + React + TypeScript + Tailwind CSS, served from `wwwroot/`. Cookie-based JWT auth (httpOnly, Secure, SameSite=Strict).
2. **REST API** (`Api/`) — Minimal API endpoint groups via `.MapGroup()` with JWT auth
3. **SignalR Hub** (`Hubs/FleetHub.cs`) — Real-time updates to web UI clients
4. **ProvisioningWorker** (`Workers/`) — Background service that polls `Jobs` table, claims jobs, resolves the correct `IProvisioningPipeline`, and executes steps
5. **Pipelines** — Each job type has a pipeline (Phase1, Phase2, BYOI SAML, Suspend, Reactivate, Decommission, etc.). Steps emit `JobStep` records broadcast via SignalR
6. **HealthCheckEngine** (`Health/`) — Background service running 16 health check types
7. **Quartz Jobs** (`Jobs/`) — Scheduled tasks (cert expiry, daily snapshots, reports)
8. **Stripe Webhooks** (`Webhooks/`) — Idempotent webhook processing
**Data flow:** Web UI creates `Job``ProvisioningWorker` claims it → pipeline runs steps → `JobStep` records broadcast via SignalR → Web UI updates in real-time.
## Project Structure
```
OTSSignsOrchestrator/
├── Api/ # Minimal API endpoint groups
├── Auth/ # JWT/auth services
├── ClientApp/ # React + Vite frontend
├── Clients/ # External API clients (Xibo, Authentik)
├── Configuration/ # AppConstants, AppOptions
├── Data/ # OrchestratorDbContext
│ └── Entities/ # EF Core entity models
├── Health/ # Health check engine + checks
├── Hubs/ # SignalR hubs
├── Jobs/ # Quartz scheduled jobs
├── Models/DTOs/ # Data transfer objects
├── Reports/ # PDF report generation
├── Services/ # Business logic + integrations
├── Webhooks/ # Stripe webhook handler
├── Workers/ # Provisioning pipelines + worker
└── wwwroot/ # Built frontend assets
```
## Critical Rules
### Xibo API — non-negotiable
- `GET /api/application` is **BLOCKED** — only POST and DELETE exist
- Group endpoints are `/api/group`, never `/api/usergroup`
- Feature assignment is `POST /api/group/{id}/acl`, NOT `/features`
- **Always pass `length=200`** and use `GetAllPagesAsync()` — default pagination is 10 items, causing silent data truncation
- OAuth2 client secret returned **ONCE** on creation — capture immediately
### Stripe webhooks — idempotency mandatory
- Check `StripeEvents` table for `stripe_event_id` before processing
- Insert the `StripeEvent` row first, then process
### No AI autonomy in infrastructure actions
- All infrastructure actions must flow through the `ProvisioningWorker` job queue via an operator-initiated `Job` record
### Immutability
`AuditLog`, `Message`, and `Evidence` are append-only. Never generate Update/Delete methods on their repositories.
### Credential handling
Never store secrets in the database. Secrets go to Bitwarden only. `OauthAppRegistry` stores `clientId` only.
## Naming Conventions
- Customer abbreviation: exactly 3 lowercase letters (`^[a-z]{3}$`)
- Stack name: `{abbrev}-cms-stack`, Service: `{abbrev}-web`, DB: `{abbrev}_cms_db`
- Secret names via `AppConstants` helpers
- `AppConstants.SanitizeName()` filters to `[a-z0-9_-]`
## Testing Requirements
- Integration tests **require** Testcontainers with real PostgreSQL 16 — no SQLite substitutions
- Unit tests required for: evidence hashing, AI context assembly, pattern detection, abbreviation uniqueness, Stripe idempotency
## Pitfalls
- **SSH host state**: `SshDockerCliService.SetHost()` must be called before each host operation in loops
- **Bitwarden cache**: Call `FlushCacheAsync()` after creating secrets before reading them back
- **No saga/rollback**: Partial failures across Git → MySQL → Docker → Xibo leave orphaned resources; cleanup is manual
- **Docker volumes are sticky**: Failed deploys leave volumes with old NFS driver options
- **Template CIFS→NFS compat**: Old `{{CIFS_*}}` tokens still render correctly as NFS equivalents
## Code Generation Checklist
- After generating a class implementing an interface, verify all members are implemented
- After generating a pipeline, verify all steps produce `JobStep` entities with progress broadcast via `IHubContext<FleetHub>`
- Do not stub steps as TODO — implement fully or flag explicitly

56
Dockerfile Normal file
View File

@@ -0,0 +1,56 @@
# ── Stage 1: Build React frontend ────────────────────────────────────────────
FROM node:20-alpine AS frontend-build
WORKDIR /app/ClientApp
COPY OTSSignsOrchestrator/ClientApp/package.json OTSSignsOrchestrator/ClientApp/package-lock.json* ./
RUN npm ci
COPY OTSSignsOrchestrator/ClientApp/ ./
# Build outputs to ../wwwroot (relative to ClientApp)
RUN npm run build
# ── Stage 2: Publish .NET app ─────────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS dotnet-build
WORKDIR /src
# Restore dependencies (layer-cached separately for fast rebuilds)
COPY OTSSignsOrchestrator/OTSSignsOrchestrator.csproj OTSSignsOrchestrator/
RUN dotnet restore OTSSignsOrchestrator/OTSSignsOrchestrator.csproj
# Copy source (excluding ClientApp — frontend handled in stage 1)
COPY OTSSignsOrchestrator/ OTSSignsOrchestrator/
# Copy built frontend assets from stage 1
COPY --from=frontend-build /app/wwwroot OTSSignsOrchestrator/wwwroot/
RUN dotnet publish OTSSignsOrchestrator/OTSSignsOrchestrator.csproj \
-c Release \
-o /app/publish \
--no-restore
# ── Stage 3: Runtime image ────────────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
# LibGit2Sharp requires git native libraries (libgit2 is bundled in the NuGet package,
# but git2-ssh requires libssh2 on Linux)
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
VOLUME ["/app/dataprotection-keys"]
# Expose HTTP only — use a reverse proxy (nginx/Caddy/Traefik) for TLS termination
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production
ENTRYPOINT ["dotnet", "OTSSignsOrchestrator.dll"]

View File

@@ -1,27 +0,0 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.DependencyInjection;
namespace OTSSignsOrchestrator.Core.Data;
/// <summary>
/// Design-time factory for EF Core migrations tooling.
/// </summary>
public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<XiboContext>
{
public XiboContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<XiboContext>();
optionsBuilder.UseSqlite("Data Source=design-time.db");
// Set up a temporary DataProtection provider for design-time use
var services = new ServiceCollection();
services.AddDataProtection()
.SetApplicationName("OTSSignsOrchestrator");
var sp = services.BuildServiceProvider();
var dpProvider = sp.GetRequiredService<IDataProtectionProvider>();
return new XiboContext(optionsBuilder.Options, dpProvider);
}
}

View File

@@ -1,53 +0,0 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OTSSignsOrchestrator.Core.Models.Entities;
namespace OTSSignsOrchestrator.Core.Data;
public class XiboContext : DbContext
{
private readonly IDataProtectionProvider? _dataProtection;
public XiboContext(DbContextOptions<XiboContext> options, IDataProtectionProvider? dataProtection = null)
: base(options)
{
_dataProtection = dataProtection;
}
public DbSet<SshHost> SshHosts => Set<SshHost>();
public DbSet<OperationLog> OperationLogs => Set<OperationLog>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// --- SshHost ---
modelBuilder.Entity<SshHost>(entity =>
{
entity.HasIndex(e => e.Label).IsUnique();
if (_dataProtection != null)
{
var protector = _dataProtection.CreateProtector("OTSSignsOrchestrator.SshHost");
var passphraseConverter = new ValueConverter<string?, string?>(
v => v != null ? protector.Protect(v) : null,
v => v != null ? protector.Unprotect(v) : null);
var passwordConverter = new ValueConverter<string?, string?>(
v => v != null ? protector.Protect(v) : null,
v => v != null ? protector.Unprotect(v) : null);
entity.Property(e => e.KeyPassphrase).HasConversion(passphraseConverter);
entity.Property(e => e.Password).HasConversion(passwordConverter);
}
});
// --- OperationLog ---
modelBuilder.Entity<OperationLog>(entity =>
{
entity.HasIndex(e => e.Timestamp);
entity.HasIndex(e => e.StackName);
entity.HasIndex(e => e.Operation);
});
}
}

View File

@@ -1,290 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OTSSignsOrchestrator.Core.Data;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
[DbContext(typeof(XiboContext))]
[Migration("20260217004115_DesktopInitial")]
partial class DesktopInitial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("CmsServerName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Constraints")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CustomerName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<int>("HostHttpPort")
.HasColumnType("INTEGER");
b.Property<string>("LibraryHostPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("SmtpServer")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SmtpUsername")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<Guid?>("SshHostId")
.HasColumnType("TEXT");
b.Property<string>("StackName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("TemplateCacheKey")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("TemplateLastFetch")
.HasColumnType("TEXT");
b.Property<string>("TemplateRepoPat")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("TemplateRepoUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("ThemeHostPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("XiboApiTestStatus")
.HasColumnType("INTEGER");
b.Property<DateTime?>("XiboApiTestedAt")
.HasColumnType("TEXT");
b.Property<string>("XiboPassword")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<string>("XiboUsername")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CustomerName");
b.HasIndex("SshHostId");
b.HasIndex("StackName")
.IsUnique();
b.ToTable("CmsInstances");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("DurationMs")
.HasColumnType("INTEGER");
b.Property<Guid?>("InstanceId")
.HasColumnType("TEXT");
b.Property<string>("Message")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Operation")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<DateTime>("Timestamp")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstanceId");
b.HasIndex("Operation");
b.HasIndex("Timestamp");
b.ToTable("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SecretMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CustomerName")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("IsGlobal")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastRotatedAt")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("SecretMetadata");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Host")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("KeyPassphrase")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool?>("LastTestSuccess")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastTestedAt")
.HasColumnType("TEXT");
b.Property<string>("Password")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Port")
.HasColumnType("INTEGER");
b.Property<string>("PrivateKeyPath")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<bool>("UseKeyAuth")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Label")
.IsUnique();
b.ToTable("SshHosts");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.SshHost", "SshHost")
.WithMany("Instances")
.HasForeignKey("SshHostId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SshHost");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", "Instance")
.WithMany("OperationLogs")
.HasForeignKey("InstanceId");
b.Navigation("Instance");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.Navigation("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Navigation("Instances");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,175 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
/// <inheritdoc />
public partial class DesktopInitial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "SecretMetadata",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
IsGlobal = table.Column<bool>(type: "INTEGER", nullable: false),
CustomerName = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
LastRotatedAt = table.Column<DateTime>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_SecretMetadata", x => x.Id);
});
migrationBuilder.CreateTable(
name: "SshHosts",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Label = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
Host = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
Port = table.Column<int>(type: "INTEGER", nullable: false),
Username = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
PrivateKeyPath = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true),
KeyPassphrase = table.Column<string>(type: "TEXT", maxLength: 2000, nullable: true),
Password = table.Column<string>(type: "TEXT", maxLength: 2000, nullable: true),
UseKeyAuth = table.Column<bool>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
LastTestedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
LastTestSuccess = table.Column<bool>(type: "INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_SshHosts", x => x.Id);
});
migrationBuilder.CreateTable(
name: "CmsInstances",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
CustomerName = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
StackName = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
CmsServerName = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
HostHttpPort = table.Column<int>(type: "INTEGER", nullable: false),
ThemeHostPath = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
LibraryHostPath = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
SmtpServer = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
SmtpUsername = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
Constraints = table.Column<string>(type: "TEXT", maxLength: 2000, nullable: true),
TemplateRepoUrl = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
TemplateRepoPat = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
TemplateLastFetch = table.Column<DateTime>(type: "TEXT", nullable: true),
TemplateCacheKey = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
Status = table.Column<int>(type: "INTEGER", nullable: false),
XiboUsername = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
XiboPassword = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true),
XiboApiTestStatus = table.Column<int>(type: "INTEGER", nullable: false),
XiboApiTestedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
DeletedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
SshHostId = table.Column<Guid>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_CmsInstances", x => x.Id);
table.ForeignKey(
name: "FK_CmsInstances_SshHosts_SshHostId",
column: x => x.SshHostId,
principalTable: "SshHosts",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "OperationLogs",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Operation = table.Column<int>(type: "INTEGER", nullable: false),
InstanceId = table.Column<Guid>(type: "TEXT", nullable: true),
UserId = table.Column<string>(type: "TEXT", maxLength: 200, nullable: true),
Status = table.Column<int>(type: "INTEGER", nullable: false),
Message = table.Column<string>(type: "TEXT", maxLength: 2000, nullable: true),
DurationMs = table.Column<long>(type: "INTEGER", nullable: true),
Timestamp = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OperationLogs", x => x.Id);
table.ForeignKey(
name: "FK_OperationLogs_CmsInstances_InstanceId",
column: x => x.InstanceId,
principalTable: "CmsInstances",
principalColumn: "Id");
});
migrationBuilder.CreateIndex(
name: "IX_CmsInstances_CustomerName",
table: "CmsInstances",
column: "CustomerName");
migrationBuilder.CreateIndex(
name: "IX_CmsInstances_SshHostId",
table: "CmsInstances",
column: "SshHostId");
migrationBuilder.CreateIndex(
name: "IX_CmsInstances_StackName",
table: "CmsInstances",
column: "StackName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OperationLogs_InstanceId",
table: "OperationLogs",
column: "InstanceId");
migrationBuilder.CreateIndex(
name: "IX_OperationLogs_Operation",
table: "OperationLogs",
column: "Operation");
migrationBuilder.CreateIndex(
name: "IX_OperationLogs_Timestamp",
table: "OperationLogs",
column: "Timestamp");
migrationBuilder.CreateIndex(
name: "IX_SecretMetadata_Name",
table: "SecretMetadata",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_SshHosts_Label",
table: "SshHosts",
column: "Label",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "OperationLogs");
migrationBuilder.DropTable(
name: "SecretMetadata");
migrationBuilder.DropTable(
name: "CmsInstances");
migrationBuilder.DropTable(
name: "SshHosts");
}
}
}

View File

@@ -1,295 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OTSSignsOrchestrator.Core.Data;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
[DbContext(typeof(XiboContext))]
[Migration("20260218140239_AddCustomerAbbrev")]
partial class AddCustomerAbbrev
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("CmsServerName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Constraints")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CustomerAbbrev")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("TEXT");
b.Property<string>("CustomerName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<int>("HostHttpPort")
.HasColumnType("INTEGER");
b.Property<string>("LibraryHostPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("SmtpServer")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SmtpUsername")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<Guid?>("SshHostId")
.HasColumnType("TEXT");
b.Property<string>("StackName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("TemplateCacheKey")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("TemplateLastFetch")
.HasColumnType("TEXT");
b.Property<string>("TemplateRepoPat")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("TemplateRepoUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("ThemeHostPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("XiboApiTestStatus")
.HasColumnType("INTEGER");
b.Property<DateTime?>("XiboApiTestedAt")
.HasColumnType("TEXT");
b.Property<string>("XiboPassword")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<string>("XiboUsername")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CustomerName");
b.HasIndex("SshHostId");
b.HasIndex("StackName")
.IsUnique();
b.ToTable("CmsInstances");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("DurationMs")
.HasColumnType("INTEGER");
b.Property<Guid?>("InstanceId")
.HasColumnType("TEXT");
b.Property<string>("Message")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Operation")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<DateTime>("Timestamp")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstanceId");
b.HasIndex("Operation");
b.HasIndex("Timestamp");
b.ToTable("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SecretMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CustomerName")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("IsGlobal")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastRotatedAt")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("SecretMetadata");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Host")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("KeyPassphrase")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool?>("LastTestSuccess")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastTestedAt")
.HasColumnType("TEXT");
b.Property<string>("Password")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Port")
.HasColumnType("INTEGER");
b.Property<string>("PrivateKeyPath")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<bool>("UseKeyAuth")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Label")
.IsUnique();
b.ToTable("SshHosts");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.SshHost", "SshHost")
.WithMany("Instances")
.HasForeignKey("SshHostId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SshHost");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", "Instance")
.WithMany("OperationLogs")
.HasForeignKey("InstanceId");
b.Navigation("Instance");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.Navigation("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Navigation("Instances");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,323 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OTSSignsOrchestrator.Core.Data;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
[DbContext(typeof(XiboContext))]
[Migration("20260218143812_AddAppSettings")]
partial class AddAppSettings
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.AppSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<bool>("IsSensitive")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.HasKey("Key");
b.HasIndex("Category");
b.ToTable("AppSettings");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("CmsServerName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Constraints")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CustomerAbbrev")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("TEXT");
b.Property<string>("CustomerName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<int>("HostHttpPort")
.HasColumnType("INTEGER");
b.Property<string>("LibraryHostPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("SmtpServer")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SmtpUsername")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<Guid?>("SshHostId")
.HasColumnType("TEXT");
b.Property<string>("StackName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("TemplateCacheKey")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("TemplateLastFetch")
.HasColumnType("TEXT");
b.Property<string>("TemplateRepoPat")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("TemplateRepoUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("ThemeHostPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("XiboApiTestStatus")
.HasColumnType("INTEGER");
b.Property<DateTime?>("XiboApiTestedAt")
.HasColumnType("TEXT");
b.Property<string>("XiboPassword")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<string>("XiboUsername")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CustomerName");
b.HasIndex("SshHostId");
b.HasIndex("StackName")
.IsUnique();
b.ToTable("CmsInstances");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("DurationMs")
.HasColumnType("INTEGER");
b.Property<Guid?>("InstanceId")
.HasColumnType("TEXT");
b.Property<string>("Message")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Operation")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<DateTime>("Timestamp")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstanceId");
b.HasIndex("Operation");
b.HasIndex("Timestamp");
b.ToTable("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SecretMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CustomerName")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("IsGlobal")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastRotatedAt")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("SecretMetadata");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Host")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("KeyPassphrase")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool?>("LastTestSuccess")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastTestedAt")
.HasColumnType("TEXT");
b.Property<string>("Password")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Port")
.HasColumnType("INTEGER");
b.Property<string>("PrivateKeyPath")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<bool>("UseKeyAuth")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Label")
.IsUnique();
b.ToTable("SshHosts");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.SshHost", "SshHost")
.WithMany("Instances")
.HasForeignKey("SshHostId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SshHost");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", "Instance")
.WithMany("OperationLogs")
.HasForeignKey("InstanceId");
b.Navigation("Instance");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.Navigation("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Navigation("Instances");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,42 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
/// <inheritdoc />
public partial class AddAppSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AppSettings",
columns: table => new
{
Key = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
Value = table.Column<string>(type: "TEXT", maxLength: 4000, nullable: true),
Category = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
IsSensitive = table.Column<bool>(type: "INTEGER", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AppSettings", x => x.Key);
});
migrationBuilder.CreateIndex(
name: "IX_AppSettings_Category",
table: "AppSettings",
column: "Category");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AppSettings");
}
}
}

View File

@@ -1,343 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OTSSignsOrchestrator.Core.Data;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
[DbContext(typeof(XiboContext))]
[Migration("20260218144537_AddPerInstanceCifsCredentials")]
partial class AddPerInstanceCifsCredentials
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.AppSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<bool>("IsSensitive")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.HasKey("Key");
b.HasIndex("Category");
b.ToTable("AppSettings");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("CifsExtraOptions")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("CifsPassword")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<string>("CifsServer")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("CifsShareBasePath")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("CifsUsername")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("CmsServerName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Constraints")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CustomerAbbrev")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("TEXT");
b.Property<string>("CustomerName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<int>("HostHttpPort")
.HasColumnType("INTEGER");
b.Property<string>("LibraryHostPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("SmtpServer")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SmtpUsername")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<Guid?>("SshHostId")
.HasColumnType("TEXT");
b.Property<string>("StackName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("TemplateCacheKey")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("TemplateLastFetch")
.HasColumnType("TEXT");
b.Property<string>("TemplateRepoPat")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("TemplateRepoUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("ThemeHostPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("XiboApiTestStatus")
.HasColumnType("INTEGER");
b.Property<DateTime?>("XiboApiTestedAt")
.HasColumnType("TEXT");
b.Property<string>("XiboPassword")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<string>("XiboUsername")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CustomerName");
b.HasIndex("SshHostId");
b.HasIndex("StackName")
.IsUnique();
b.ToTable("CmsInstances");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("DurationMs")
.HasColumnType("INTEGER");
b.Property<Guid?>("InstanceId")
.HasColumnType("TEXT");
b.Property<string>("Message")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Operation")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<DateTime>("Timestamp")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstanceId");
b.HasIndex("Operation");
b.HasIndex("Timestamp");
b.ToTable("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SecretMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CustomerName")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("IsGlobal")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastRotatedAt")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("SecretMetadata");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Host")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("KeyPassphrase")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool?>("LastTestSuccess")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastTestedAt")
.HasColumnType("TEXT");
b.Property<string>("Password")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Port")
.HasColumnType("INTEGER");
b.Property<string>("PrivateKeyPath")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<bool>("UseKeyAuth")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Label")
.IsUnique();
b.ToTable("SshHosts");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.SshHost", "SshHost")
.WithMany("Instances")
.HasForeignKey("SshHostId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SshHost");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", "Instance")
.WithMany("OperationLogs")
.HasForeignKey("InstanceId");
b.Navigation("Instance");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.Navigation("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Navigation("Instances");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,73 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
/// <inheritdoc />
public partial class AddPerInstanceCifsCredentials : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "CifsExtraOptions",
table: "CmsInstances",
type: "TEXT",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "CifsPassword",
table: "CmsInstances",
type: "TEXT",
maxLength: 1000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "CifsServer",
table: "CmsInstances",
type: "TEXT",
maxLength: 200,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "CifsShareBasePath",
table: "CmsInstances",
type: "TEXT",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "CifsUsername",
table: "CmsInstances",
type: "TEXT",
maxLength: 200,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CifsExtraOptions",
table: "CmsInstances");
migrationBuilder.DropColumn(
name: "CifsPassword",
table: "CmsInstances");
migrationBuilder.DropColumn(
name: "CifsServer",
table: "CmsInstances");
migrationBuilder.DropColumn(
name: "CifsShareBasePath",
table: "CmsInstances");
migrationBuilder.DropColumn(
name: "CifsUsername",
table: "CmsInstances");
}
}
}

View File

@@ -1,343 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OTSSignsOrchestrator.Core.Data;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
[DbContext(typeof(XiboContext))]
[Migration("20260218180240_RenameShareBasePathToShareName")]
partial class RenameShareBasePathToShareName
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.AppSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<bool>("IsSensitive")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.HasKey("Key");
b.HasIndex("Category");
b.ToTable("AppSettings");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("CifsExtraOptions")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("CifsPassword")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<string>("CifsServer")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("CifsShareName")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("CifsUsername")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("CmsServerName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Constraints")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CustomerAbbrev")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("TEXT");
b.Property<string>("CustomerName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<int>("HostHttpPort")
.HasColumnType("INTEGER");
b.Property<string>("LibraryHostPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("SmtpServer")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SmtpUsername")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<Guid?>("SshHostId")
.HasColumnType("TEXT");
b.Property<string>("StackName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("TemplateCacheKey")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("TemplateLastFetch")
.HasColumnType("TEXT");
b.Property<string>("TemplateRepoPat")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("TemplateRepoUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("ThemeHostPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("XiboApiTestStatus")
.HasColumnType("INTEGER");
b.Property<DateTime?>("XiboApiTestedAt")
.HasColumnType("TEXT");
b.Property<string>("XiboPassword")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<string>("XiboUsername")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CustomerName");
b.HasIndex("SshHostId");
b.HasIndex("StackName")
.IsUnique();
b.ToTable("CmsInstances");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("DurationMs")
.HasColumnType("INTEGER");
b.Property<Guid?>("InstanceId")
.HasColumnType("TEXT");
b.Property<string>("Message")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Operation")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<DateTime>("Timestamp")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstanceId");
b.HasIndex("Operation");
b.HasIndex("Timestamp");
b.ToTable("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SecretMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CustomerName")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("IsGlobal")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastRotatedAt")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("SecretMetadata");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Host")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("KeyPassphrase")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool?>("LastTestSuccess")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastTestedAt")
.HasColumnType("TEXT");
b.Property<string>("Password")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Port")
.HasColumnType("INTEGER");
b.Property<string>("PrivateKeyPath")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<bool>("UseKeyAuth")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Label")
.IsUnique();
b.ToTable("SshHosts");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.SshHost", "SshHost")
.WithMany("Instances")
.HasForeignKey("SshHostId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SshHost");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", "Instance")
.WithMany("OperationLogs")
.HasForeignKey("InstanceId");
b.Navigation("Instance");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.Navigation("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Navigation("Instances");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,28 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
/// <inheritdoc />
public partial class RenameShareBasePathToShareName : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "CifsShareBasePath",
table: "CmsInstances",
newName: "CifsShareName");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "CifsShareName",
table: "CmsInstances",
newName: "CifsShareBasePath");
}
}
}

View File

@@ -1,347 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OTSSignsOrchestrator.Core.Data;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
[DbContext(typeof(XiboContext))]
[Migration("20260218202617_AddCifsShareFolder")]
partial class AddCifsShareFolder
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.AppSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<bool>("IsSensitive")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.HasKey("Key");
b.HasIndex("Category");
b.ToTable("AppSettings");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("CifsExtraOptions")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("CifsPassword")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<string>("CifsServer")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("CifsShareFolder")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("CifsShareName")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("CifsUsername")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("CmsServerName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Constraints")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CustomerAbbrev")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("TEXT");
b.Property<string>("CustomerName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<int>("HostHttpPort")
.HasColumnType("INTEGER");
b.Property<string>("LibraryHostPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("SmtpServer")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SmtpUsername")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<Guid?>("SshHostId")
.HasColumnType("TEXT");
b.Property<string>("StackName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("TemplateCacheKey")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("TemplateLastFetch")
.HasColumnType("TEXT");
b.Property<string>("TemplateRepoPat")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("TemplateRepoUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("ThemeHostPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("XiboApiTestStatus")
.HasColumnType("INTEGER");
b.Property<DateTime?>("XiboApiTestedAt")
.HasColumnType("TEXT");
b.Property<string>("XiboPassword")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<string>("XiboUsername")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CustomerName");
b.HasIndex("SshHostId");
b.HasIndex("StackName")
.IsUnique();
b.ToTable("CmsInstances");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("DurationMs")
.HasColumnType("INTEGER");
b.Property<Guid?>("InstanceId")
.HasColumnType("TEXT");
b.Property<string>("Message")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Operation")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<DateTime>("Timestamp")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstanceId");
b.HasIndex("Operation");
b.HasIndex("Timestamp");
b.ToTable("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SecretMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CustomerName")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("IsGlobal")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastRotatedAt")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("SecretMetadata");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Host")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("KeyPassphrase")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool?>("LastTestSuccess")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastTestedAt")
.HasColumnType("TEXT");
b.Property<string>("Password")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Port")
.HasColumnType("INTEGER");
b.Property<string>("PrivateKeyPath")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<bool>("UseKeyAuth")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Label")
.IsUnique();
b.ToTable("SshHosts");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.SshHost", "SshHost")
.WithMany("Instances")
.HasForeignKey("SshHostId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SshHost");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", "Instance")
.WithMany("OperationLogs")
.HasForeignKey("InstanceId");
b.Navigation("Instance");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.Navigation("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Navigation("Instances");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,29 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
/// <inheritdoc />
public partial class AddCifsShareFolder : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "CifsShareFolder",
table: "CmsInstances",
type: "TEXT",
maxLength: 500,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CifsShareFolder",
table: "CmsInstances");
}
}
}

View File

@@ -1,339 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OTSSignsOrchestrator.Core.Data;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
[DbContext(typeof(XiboContext))]
[Migration("20260219005507_ReplaceCifsWithNfs")]
partial class ReplaceCifsWithNfs
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.AppSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<bool>("IsSensitive")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.HasKey("Key");
b.HasIndex("Category");
b.ToTable("AppSettings");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("CmsServerName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Constraints")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CustomerAbbrev")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("TEXT");
b.Property<string>("CustomerName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<int>("HostHttpPort")
.HasColumnType("INTEGER");
b.Property<string>("LibraryHostPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("NfsExport")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("NfsExportFolder")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("NfsExtraOptions")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("NfsServer")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SmtpServer")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SmtpUsername")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<Guid?>("SshHostId")
.HasColumnType("TEXT");
b.Property<string>("StackName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("TemplateCacheKey")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("TemplateLastFetch")
.HasColumnType("TEXT");
b.Property<string>("TemplateRepoPat")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("TemplateRepoUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("ThemeHostPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("XiboApiTestStatus")
.HasColumnType("INTEGER");
b.Property<DateTime?>("XiboApiTestedAt")
.HasColumnType("TEXT");
b.Property<string>("XiboPassword")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<string>("XiboUsername")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CustomerName");
b.HasIndex("SshHostId");
b.HasIndex("StackName")
.IsUnique();
b.ToTable("CmsInstances");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("DurationMs")
.HasColumnType("INTEGER");
b.Property<Guid?>("InstanceId")
.HasColumnType("TEXT");
b.Property<string>("Message")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Operation")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<DateTime>("Timestamp")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstanceId");
b.HasIndex("Operation");
b.HasIndex("Timestamp");
b.ToTable("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SecretMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CustomerName")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("IsGlobal")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastRotatedAt")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("SecretMetadata");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Host")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("KeyPassphrase")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool?>("LastTestSuccess")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastTestedAt")
.HasColumnType("TEXT");
b.Property<string>("Password")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Port")
.HasColumnType("INTEGER");
b.Property<string>("PrivateKeyPath")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<bool>("UseKeyAuth")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Label")
.IsUnique();
b.ToTable("SshHosts");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.SshHost", "SshHost")
.WithMany("Instances")
.HasForeignKey("SshHostId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SshHost");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", "Instance")
.WithMany("OperationLogs")
.HasForeignKey("InstanceId");
b.Navigation("Instance");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.Navigation("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Navigation("Instances");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,98 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
/// <inheritdoc />
public partial class ReplaceCifsWithNfs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// 1. Add new NFS columns
migrationBuilder.AddColumn<string>(
name: "NfsServer",
table: "CmsInstances",
type: "TEXT",
maxLength: 200,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "NfsExport",
table: "CmsInstances",
type: "TEXT",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "NfsExportFolder",
table: "CmsInstances",
type: "TEXT",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "NfsExtraOptions",
table: "CmsInstances",
type: "TEXT",
maxLength: 500,
nullable: true);
// 2. Migrate existing CIFS data into NFS columns
// NfsServer = CifsServer, NfsExport = '/' + CifsShareName, NfsExportFolder = CifsShareFolder
migrationBuilder.Sql(
"""
UPDATE CmsInstances
SET NfsServer = CifsServer,
NfsExport = CASE WHEN CifsShareName IS NOT NULL THEN '/' || CifsShareName ELSE NULL END,
NfsExportFolder = CifsShareFolder,
NfsExtraOptions = CifsExtraOptions
WHERE CifsServer IS NOT NULL;
""");
// 3. Drop old CIFS columns
migrationBuilder.DropColumn(name: "CifsServer", table: "CmsInstances");
migrationBuilder.DropColumn(name: "CifsShareName", table: "CmsInstances");
migrationBuilder.DropColumn(name: "CifsShareFolder", table: "CmsInstances");
migrationBuilder.DropColumn(name: "CifsUsername", table: "CmsInstances");
migrationBuilder.DropColumn(name: "CifsPassword", table: "CmsInstances");
migrationBuilder.DropColumn(name: "CifsExtraOptions", table: "CmsInstances");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Re-add CIFS columns
migrationBuilder.AddColumn<string>(
name: "CifsServer", table: "CmsInstances", type: "TEXT", maxLength: 200, nullable: true);
migrationBuilder.AddColumn<string>(
name: "CifsShareName", table: "CmsInstances", type: "TEXT", maxLength: 500, nullable: true);
migrationBuilder.AddColumn<string>(
name: "CifsShareFolder", table: "CmsInstances", type: "TEXT", maxLength: 500, nullable: true);
migrationBuilder.AddColumn<string>(
name: "CifsUsername", table: "CmsInstances", type: "TEXT", maxLength: 200, nullable: true);
migrationBuilder.AddColumn<string>(
name: "CifsPassword", table: "CmsInstances", type: "TEXT", maxLength: 1000, nullable: true);
migrationBuilder.AddColumn<string>(
name: "CifsExtraOptions", table: "CmsInstances", type: "TEXT", maxLength: 500, nullable: true);
// Copy NFS data back to CIFS columns
migrationBuilder.Sql(
"""
UPDATE CmsInstances
SET CifsServer = NfsServer,
CifsShareName = CASE WHEN NfsExport IS NOT NULL THEN LTRIM(NfsExport, '/') ELSE NULL END,
CifsShareFolder = NfsExportFolder,
CifsExtraOptions = NfsExtraOptions
WHERE NfsServer IS NOT NULL;
""");
// Drop NFS columns
migrationBuilder.DropColumn(name: "NfsServer", table: "CmsInstances");
migrationBuilder.DropColumn(name: "NfsExport", table: "CmsInstances");
migrationBuilder.DropColumn(name: "NfsExportFolder", table: "CmsInstances");
migrationBuilder.DropColumn(name: "NfsExtraOptions", table: "CmsInstances");
}
}
}

View File

@@ -1,347 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OTSSignsOrchestrator.Core.Data;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
[DbContext(typeof(XiboContext))]
[Migration("20260219020727_AddNewtCredentials")]
partial class AddNewtCredentials
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.AppSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<bool>("IsSensitive")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.HasKey("Key");
b.HasIndex("Category");
b.ToTable("AppSettings");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("CmsServerName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Constraints")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CustomerAbbrev")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("TEXT");
b.Property<string>("CustomerName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("TEXT");
b.Property<int>("HostHttpPort")
.HasColumnType("INTEGER");
b.Property<string>("LibraryHostPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("NewtId")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("NewtSecret")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("NfsExport")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("NfsExportFolder")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("NfsExtraOptions")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("NfsServer")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SmtpServer")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("SmtpUsername")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<Guid?>("SshHostId")
.HasColumnType("TEXT");
b.Property<string>("StackName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("TemplateCacheKey")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime?>("TemplateLastFetch")
.HasColumnType("TEXT");
b.Property<string>("TemplateRepoPat")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("TemplateRepoUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("ThemeHostPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("XiboApiTestStatus")
.HasColumnType("INTEGER");
b.Property<DateTime?>("XiboApiTestedAt")
.HasColumnType("TEXT");
b.Property<string>("XiboPassword")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<string>("XiboUsername")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CustomerName");
b.HasIndex("SshHostId");
b.HasIndex("StackName")
.IsUnique();
b.ToTable("CmsInstances");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("DurationMs")
.HasColumnType("INTEGER");
b.Property<Guid?>("InstanceId")
.HasColumnType("TEXT");
b.Property<string>("Message")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Operation")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<DateTime>("Timestamp")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstanceId");
b.HasIndex("Operation");
b.HasIndex("Timestamp");
b.ToTable("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SecretMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CustomerName")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("IsGlobal")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastRotatedAt")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("SecretMetadata");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Host")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("KeyPassphrase")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool?>("LastTestSuccess")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastTestedAt")
.HasColumnType("TEXT");
b.Property<string>("Password")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Port")
.HasColumnType("INTEGER");
b.Property<string>("PrivateKeyPath")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<bool>("UseKeyAuth")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Label")
.IsUnique();
b.ToTable("SshHosts");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.SshHost", "SshHost")
.WithMany("Instances")
.HasForeignKey("SshHostId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SshHost");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.HasOne("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", "Instance")
.WithMany("OperationLogs")
.HasForeignKey("InstanceId");
b.Navigation("Instance");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.CmsInstance", b =>
{
b.Navigation("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Navigation("Instances");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,40 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
/// <inheritdoc />
public partial class AddNewtCredentials : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "NewtId",
table: "CmsInstances",
type: "TEXT",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "NewtSecret",
table: "CmsInstances",
type: "TEXT",
maxLength: 500,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "NewtId",
table: "CmsInstances");
migrationBuilder.DropColumn(
name: "NewtSecret",
table: "CmsInstances");
}
}
}

View File

@@ -1,153 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OTSSignsOrchestrator.Core.Data;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
[DbContext(typeof(XiboContext))]
[Migration("20260219121529_RemoveCmsInstancesAndSecretMetadata")]
partial class RemoveCmsInstancesAndSecretMetadata
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.AppSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<bool>("IsSensitive")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.HasKey("Key");
b.HasIndex("Category");
b.ToTable("AppSettings");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("DurationMs")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Operation")
.HasColumnType("INTEGER");
b.Property<string>("StackName")
.HasMaxLength(150)
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<DateTime>("Timestamp")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Operation");
b.HasIndex("StackName");
b.HasIndex("Timestamp");
b.ToTable("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Host")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("KeyPassphrase")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool?>("LastTestSuccess")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastTestedAt")
.HasColumnType("TEXT");
b.Property<string>("Password")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Port")
.HasColumnType("INTEGER");
b.Property<string>("PrivateKeyPath")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<bool>("UseKeyAuth")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Label")
.IsUnique();
b.ToTable("SshHosts");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,159 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
/// <inheritdoc />
public partial class RemoveCmsInstancesAndSecretMetadata : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_OperationLogs_CmsInstances_InstanceId",
table: "OperationLogs");
migrationBuilder.DropTable(
name: "CmsInstances");
migrationBuilder.DropTable(
name: "SecretMetadata");
migrationBuilder.DropIndex(
name: "IX_OperationLogs_InstanceId",
table: "OperationLogs");
migrationBuilder.DropColumn(
name: "InstanceId",
table: "OperationLogs");
migrationBuilder.AddColumn<string>(
name: "StackName",
table: "OperationLogs",
type: "TEXT",
maxLength: 150,
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_OperationLogs_StackName",
table: "OperationLogs",
column: "StackName");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_OperationLogs_StackName",
table: "OperationLogs");
migrationBuilder.DropColumn(
name: "StackName",
table: "OperationLogs");
migrationBuilder.AddColumn<Guid>(
name: "InstanceId",
table: "OperationLogs",
type: "TEXT",
nullable: true);
migrationBuilder.CreateTable(
name: "CmsInstances",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
SshHostId = table.Column<Guid>(type: "TEXT", nullable: true),
CmsServerName = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
Constraints = table.Column<string>(type: "TEXT", maxLength: 2000, nullable: true),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
CustomerAbbrev = table.Column<string>(type: "TEXT", maxLength: 3, nullable: false),
CustomerName = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
DeletedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
HostHttpPort = table.Column<int>(type: "INTEGER", nullable: false),
LibraryHostPath = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
NewtId = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
NewtSecret = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
NfsExport = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
NfsExportFolder = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
NfsExtraOptions = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
NfsServer = table.Column<string>(type: "TEXT", maxLength: 200, nullable: true),
SmtpServer = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
SmtpUsername = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
StackName = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
Status = table.Column<int>(type: "INTEGER", nullable: false),
TemplateCacheKey = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
TemplateLastFetch = table.Column<DateTime>(type: "TEXT", nullable: true),
TemplateRepoPat = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
TemplateRepoUrl = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
ThemeHostPath = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
XiboApiTestStatus = table.Column<int>(type: "INTEGER", nullable: false),
XiboApiTestedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
XiboPassword = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true),
XiboUsername = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_CmsInstances", x => x.Id);
table.ForeignKey(
name: "FK_CmsInstances_SshHosts_SshHostId",
column: x => x.SshHostId,
principalTable: "SshHosts",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "SecretMetadata",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
CustomerName = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
IsGlobal = table.Column<bool>(type: "INTEGER", nullable: false),
LastRotatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
Name = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SecretMetadata", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_OperationLogs_InstanceId",
table: "OperationLogs",
column: "InstanceId");
migrationBuilder.CreateIndex(
name: "IX_CmsInstances_CustomerName",
table: "CmsInstances",
column: "CustomerName");
migrationBuilder.CreateIndex(
name: "IX_CmsInstances_SshHostId",
table: "CmsInstances",
column: "SshHostId");
migrationBuilder.CreateIndex(
name: "IX_CmsInstances_StackName",
table: "CmsInstances",
column: "StackName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_SecretMetadata_Name",
table: "SecretMetadata",
column: "Name",
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_OperationLogs_CmsInstances_InstanceId",
table: "OperationLogs",
column: "InstanceId",
principalTable: "CmsInstances",
principalColumn: "Id");
}
}
}

View File

@@ -1,125 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OTSSignsOrchestrator.Core.Data;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
[DbContext(typeof(XiboContext))]
[Migration("20260225135644_DropAppSettings")]
partial class DropAppSettings
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("DurationMs")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Operation")
.HasColumnType("INTEGER");
b.Property<string>("StackName")
.HasMaxLength(150)
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<DateTime>("Timestamp")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Operation");
b.HasIndex("StackName");
b.HasIndex("Timestamp");
b.ToTable("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Host")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("KeyPassphrase")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool?>("LastTestSuccess")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastTestedAt")
.HasColumnType("TEXT");
b.Property<string>("Password")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Port")
.HasColumnType("INTEGER");
b.Property<string>("PrivateKeyPath")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<bool>("UseKeyAuth")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Label")
.IsUnique();
b.ToTable("SshHosts");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,42 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
/// <inheritdoc />
public partial class DropAppSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AppSettings");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AppSettings",
columns: table => new
{
Key = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
Category = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
IsSensitive = table.Column<bool>(type: "INTEGER", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
Value = table.Column<string>(type: "TEXT", maxLength: 4000, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AppSettings", x => x.Key);
});
migrationBuilder.CreateIndex(
name: "IX_AppSettings_Category",
table: "AppSettings",
column: "Category");
}
}
}

View File

@@ -1,122 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using OTSSignsOrchestrator.Core.Data;
#nullable disable
namespace OTSSignsOrchestrator.Core.Migrations
{
[DbContext(typeof(XiboContext))]
partial class XiboContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("DurationMs")
.HasColumnType("INTEGER");
b.Property<string>("Message")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Operation")
.HasColumnType("INTEGER");
b.Property<string>("StackName")
.HasMaxLength(150)
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<DateTime>("Timestamp")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Operation");
b.HasIndex("StackName");
b.HasIndex("Timestamp");
b.ToTable("OperationLogs");
});
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Host")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("KeyPassphrase")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<bool?>("LastTestSuccess")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastTestedAt")
.HasColumnType("TEXT");
b.Property<string>("Password")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<int>("Port")
.HasColumnType("INTEGER");
b.Property<string>("PrivateKeyPath")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<bool>("UseKeyAuth")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Label")
.IsUnique();
b.ToTable("SshHosts");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,23 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace OTSSignsOrchestrator.Core.Models.Entities;
/// <summary>
/// Key-value application setting persisted in the local database.
/// Sensitive values are encrypted at rest via DataProtection.
/// </summary>
public class AppSetting
{
[Key, MaxLength(200)]
public string Key { get; set; } = string.Empty;
[MaxLength(4000)]
public string? Value { get; set; }
[Required, MaxLength(50)]
public string Category { get; set; } = string.Empty;
public bool IsSensitive { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -1,29 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bitwarden.Secrets.Sdk" Version="1.0.0" />
<PackageReference Include="LibGit2Sharp" Version="0.31.0" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="9.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.2" />
<PackageReference Include="MySqlConnector" Version="2.5.0" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
</ItemGroup>
</Project>

View File

@@ -1,57 +0,0 @@
namespace OTSSignsOrchestrator.Core.Services;
/// <summary>
/// Orchestrates the complete Authentik invitation infrastructure setup for a customer.
/// Creates a group, enrollment flow with stages, role with invitation permissions,
/// and scoping policies so the customer admin can invite new users without OTS involvement.
/// </summary>
public interface IInvitationSetupService
{
/// <summary>
/// Sets up the full invitation infrastructure for a customer in Authentik:
/// <list type="number">
/// <item>Create customer group (e.g. <c>customer-acme</c>).</item>
/// <item>Create invitation stage (invite-only, no anonymous enrollment).</item>
/// <item>Create enrollment flow with stages: Invitation → Prompt → UserWrite → UserLogin.</item>
/// <item>Bind expression policy to UserWrite stage to auto-assign users to the customer group.</item>
/// <item>Create invite-manager role with invitation CRUD permissions.</item>
/// <item>Assign role to customer group and bind scoping policy to flow.</item>
/// </list>
/// All operations are idempotent — safe to call multiple times for the same customer.
/// </summary>
/// <param name="customerAbbrev">Short customer identifier (e.g. "acme").</param>
/// <param name="customerName">Human-readable customer name (e.g. "Acme Corp").</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Result describing what was created and the enrollment flow URL.</returns>
Task<InvitationSetupResult> SetupCustomerInvitationAsync(
string customerAbbrev,
string customerName,
CancellationToken ct = default);
}
/// <summary>
/// Result of the invitation infrastructure setup.
/// </summary>
public class InvitationSetupResult
{
/// <summary>Whether the setup completed successfully.</summary>
public bool Success { get; set; }
/// <summary>Human-readable status message.</summary>
public string Message { get; set; } = string.Empty;
/// <summary>Name of the customer group created in Authentik.</summary>
public string GroupName { get; set; } = string.Empty;
/// <summary>Slug of the enrollment flow (used in invite links).</summary>
public string EnrollmentFlowSlug { get; set; } = string.Empty;
/// <summary>Name of the role created for invitation management.</summary>
public string RoleName { get; set; } = string.Empty;
/// <summary>
/// Full URL to the Authentik user portal where the customer admin
/// can manage invitations.
/// </summary>
public string? InvitationManagementUrl { get; set; }
}

View File

@@ -1,230 +0,0 @@
using Microsoft.Extensions.Logging;
namespace OTSSignsOrchestrator.Core.Services;
/// <summary>
/// Orchestrates the 6-step Authentik invitation infrastructure setup for a customer.
/// All operations are idempotent — the underlying Authentik API methods check for
/// existing resources before creating new ones.
///
/// Per customer, this creates:
/// 1. A group (<c>customer-{abbrev}</c>)
/// 2. An invitation stage (<c>{abbrev}-invitation-stage</c>)
/// 3. An enrollment flow (<c>{abbrev}-enrollment</c>) with stages bound in order
/// 4. An expression policy on the UserWrite stage to auto-assign users to the group
/// 5. A role (<c>{abbrev}-invite-manager</c>) with invitation CRUD permissions
/// 6. A scoping policy on the flow so only group members can access it
/// </summary>
public class InvitationSetupService : IInvitationSetupService
{
private readonly IAuthentikService _authentik;
private readonly SettingsService _settings;
private readonly ILogger<InvitationSetupService> _logger;
public InvitationSetupService(
IAuthentikService authentik,
SettingsService settings,
ILogger<InvitationSetupService> logger)
{
_authentik = authentik;
_settings = settings;
_logger = logger;
}
/// <inheritdoc />
public async Task<InvitationSetupResult> SetupCustomerInvitationAsync(
string customerAbbrev,
string customerName,
CancellationToken ct = default)
{
var abbrev = customerAbbrev.Trim().ToLowerInvariant();
var groupName = $"customer-{abbrev}";
var flowSlug = $"{abbrev}-enrollment";
var flowName = $"{customerName} Enrollment";
var roleName = $"{abbrev}-invite-manager";
var invitationStageName = $"{abbrev}-invitation-stage";
_logger.LogInformation(
"[InviteSetup] Starting invitation infrastructure setup for customer '{Customer}' (abbrev={Abbrev})",
customerName, abbrev);
try
{
// ═══════════════════════════════════════════════════════════════════
// Step 1: Create customer group
// ═══════════════════════════════════════════════════════════════════
_logger.LogInformation("[InviteSetup] Step 1/6: Creating customer group '{Group}'", groupName);
var groupPk = await _authentik.CreateGroupAsync(groupName, ct);
// ═══════════════════════════════════════════════════════════════════
// Step 2: Create invitation stage
// ═══════════════════════════════════════════════════════════════════
_logger.LogInformation("[InviteSetup] Step 2/6: Creating invitation stage '{Stage}'", invitationStageName);
var invitationStagePk = await _authentik.CreateInvitationStageAsync(
invitationStageName, continueWithoutInvitation: false, ct);
// ═══════════════════════════════════════════════════════════════════
// Step 3: Create enrollment flow and bind stages
// ═══════════════════════════════════════════════════════════════════
_logger.LogInformation("[InviteSetup] Step 3/6: Creating enrollment flow '{Slug}'", flowSlug);
var flowPk = await _authentik.CreateEnrollmentFlowAsync(flowName, flowSlug, ct);
// Resolve built-in Authentik stages for the enrollment pipeline
var promptStagePk = await _authentik.FindStageByNameAsync("default-enrollment-prompt", ct);
var userWriteStagePk = await _authentik.FindStageByNameAsync("default-enrollment-user-write", ct);
var userLoginStagePk = await _authentik.FindStageByNameAsync("default-enrollment-user-login", ct);
// Bind stages in order: 10=Invitation, 20=Prompt, 30=UserWrite, 40=UserLogin
await _authentik.BindStageToFlowAsync(flowSlug, invitationStagePk, 10, ct);
if (promptStagePk != null)
{
await _authentik.BindStageToFlowAsync(flowSlug, promptStagePk, 20, ct);
_logger.LogInformation("[InviteSetup] Bound default prompt stage at order 20");
}
else
{
_logger.LogWarning("[InviteSetup] Built-in 'default-enrollment-prompt' stage not found — " +
"you may need to create a prompt stage manually and bind it to flow '{Slug}' at order 20", flowSlug);
}
if (userWriteStagePk != null)
{
await _authentik.BindStageToFlowAsync(flowSlug, userWriteStagePk, 30, ct);
_logger.LogInformation("[InviteSetup] Bound default user-write stage at order 30");
}
else
{
_logger.LogWarning("[InviteSetup] Built-in 'default-enrollment-user-write' stage not found — " +
"you may need to create a user-write stage manually and bind it to flow '{Slug}' at order 30", flowSlug);
}
if (userLoginStagePk != null)
{
await _authentik.BindStageToFlowAsync(flowSlug, userLoginStagePk, 40, ct);
_logger.LogInformation("[InviteSetup] Bound default user-login stage at order 40");
}
else
{
_logger.LogWarning("[InviteSetup] Built-in 'default-enrollment-user-login' stage not found — " +
"you may need to create a user-login stage manually and bind it to flow '{Slug}' at order 40", flowSlug);
}
// ═══════════════════════════════════════════════════════════════════
// Step 4: Create group-assignment policy and bind to UserWrite stage
// ═══════════════════════════════════════════════════════════════════
_logger.LogInformation("[InviteSetup] Step 4/6: Creating group-assignment expression policy");
var groupAssignPolicyName = $"{abbrev}-auto-assign-group";
var groupAssignExpression = $"""
from authentik.core.models import Group
# Auto-assign to customer group on registration
group = Group.objects.filter(name="{groupName}").first()
if group and context.get("pending_user"):
context["pending_user"].ak_groups.add(group)
return True
""";
var groupAssignPolicyPk = await _authentik.CreateExpressionPolicyAsync(
groupAssignPolicyName, groupAssignExpression, ct);
// Bind policy to the UserWrite stage binding (order 30)
if (userWriteStagePk != null)
{
var userWriteBindingPk = await _authentik.GetFlowStageBindingPkAsync(flowSlug, 30, ct);
if (userWriteBindingPk != null)
{
await _authentik.BindPolicyToFlowStageBoundAsync(userWriteBindingPk, groupAssignPolicyPk, ct);
_logger.LogInformation("[InviteSetup] Group-assignment policy bound to UserWrite stage");
}
else
{
_logger.LogWarning("[InviteSetup] Could not find flow-stage binding at order 30 to attach group policy");
}
}
// ═══════════════════════════════════════════════════════════════════
// Step 5: Create invite-manager role with permissions
// ═══════════════════════════════════════════════════════════════════
_logger.LogInformation("[InviteSetup] Step 5/6: Creating invite-manager role '{Role}'", roleName);
var rolePk = await _authentik.CreateRoleAsync(roleName, ct);
// Assign invitation CRUD permissions
var invitationPermissions = new[]
{
"add_invitation",
"view_invitation",
"delete_invitation",
};
await _authentik.AssignPermissionsToRoleAsync(rolePk, invitationPermissions, ct);
// Assign role to the customer group
await _authentik.AssignRoleToGroupAsync(rolePk, groupPk, ct);
_logger.LogInformation("[InviteSetup] Role '{Role}' assigned to group '{Group}'", roleName, groupName);
// ═══════════════════════════════════════════════════════════════════
// Step 6: Create scoping policy and bind to flow
// ═══════════════════════════════════════════════════════════════════
_logger.LogInformation("[InviteSetup] Step 6/6: Creating invitation scoping policy");
var scopePolicyName = $"scope-invitations-to-{abbrev}";
var scopeExpression = $"""
# Only allow users in {groupName} group to manage these invitations
user = context.get("pending_user") or request.user
if user.ak_groups.filter(name="{groupName}").exists():
return True
return False
""";
var scopePolicyPk = await _authentik.CreateExpressionPolicyAsync(scopePolicyName, scopeExpression, ct);
// Bind scoping policy to the enrollment flow
await _authentik.BindPolicyToFlowAsync(flowSlug, scopePolicyPk, ct);
_logger.LogInformation("[InviteSetup] Scoping policy bound to enrollment flow");
// ═══════════════════════════════════════════════════════════════════
// Build result with management URL
// ═══════════════════════════════════════════════════════════════════
var authentikUrl = await _settings.GetAsync(SettingsService.AuthentikUrl);
var managementUrl = !string.IsNullOrWhiteSpace(authentikUrl)
? $"{authentikUrl.TrimEnd('/')}/if/admin/#/core/invitations"
: null;
var result = new InvitationSetupResult
{
Success = true,
Message = $"Invitation infrastructure created for {customerName}. " +
$"Group: {groupName}, Flow: {flowSlug}, Role: {roleName}.",
GroupName = groupName,
EnrollmentFlowSlug = flowSlug,
RoleName = roleName,
InvitationManagementUrl = managementUrl,
};
_logger.LogInformation(
"[InviteSetup] Setup complete for '{Customer}': group={Group}, flow={Flow}, role={Role}, url={Url}",
customerName, groupName, flowSlug, roleName, managementUrl ?? "(no URL)");
return result;
}
catch (Exception ex)
{
_logger.LogError(ex,
"[InviteSetup] Invitation setup failed for '{Customer}' (abbrev={Abbrev}): {Message}",
customerName, abbrev, ex.Message);
return new InvitationSetupResult
{
Success = false,
Message = $"Invitation setup failed: {ex.Message}",
GroupName = groupName,
EnrollmentFlowSlug = flowSlug,
RoleName = roleName,
};
}
}
}

View File

@@ -1,261 +0,0 @@
using Microsoft.Extensions.Logging;
namespace OTSSignsOrchestrator.Core.Services;
/// <summary>
/// Reads and writes application settings from Bitwarden Secrets Manager.
/// Each setting is stored as a Bitwarden secret with key prefix "ots-config/".
/// The secret's Note field stores metadata (category|isSensitive).
/// </summary>
public class SettingsService
{
private readonly IBitwardenSecretService _bws;
private readonly ILogger<SettingsService> _logger;
/// <summary>Prefix applied to all config secret keys in Bitwarden.</summary>
private const string KeyPrefix = "ots-config/";
// ── Category constants ─────────────────────────────────────────────────
public const string CatGit = "Git";
public const string CatMySql = "MySql";
public const string CatSmtp = "Smtp";
public const string CatPangolin = "Pangolin";
public const string CatNfs = "Nfs";
public const string CatDefaults = "Defaults";
public const string CatAuthentik = "Authentik";
// ── Key constants ──────────────────────────────────────────────────────
// Git
public const string GitRepoUrl = "Git.RepoUrl";
public const string GitRepoPat = "Git.RepoPat";
// MySQL Admin
public const string MySqlHost = "MySql.Host";
public const string MySqlPort = "MySql.Port";
public const string MySqlAdminUser = "MySql.AdminUser";
public const string MySqlAdminPassword = "MySql.AdminPassword";
// SMTP
public const string SmtpServer = "Smtp.Server";
public const string SmtpPort = "Smtp.Port";
public const string SmtpUsername = "Smtp.Username";
public const string SmtpPassword = "Smtp.Password";
public const string SmtpUseTls = "Smtp.UseTls";
public const string SmtpUseStartTls = "Smtp.UseStartTls";
public const string SmtpRewriteDomain = "Smtp.RewriteDomain";
public const string SmtpHostname = "Smtp.Hostname";
public const string SmtpFromLineOverride = "Smtp.FromLineOverride";
// Pangolin
public const string PangolinEndpoint = "Pangolin.Endpoint";
// NFS
public const string NfsServer = "Nfs.Server";
public const string NfsExport = "Nfs.Export";
public const string NfsExportFolder = "Nfs.ExportFolder";
public const string NfsOptions = "Nfs.Options";
// Instance Defaults
public const string DefaultCmsImage = "Defaults.CmsImage";
public const string DefaultNewtImage = "Defaults.NewtImage";
public const string DefaultMemcachedImage = "Defaults.MemcachedImage";
public const string DefaultQuickChartImage = "Defaults.QuickChartImage";
public const string DefaultCmsServerNameTemplate = "Defaults.CmsServerNameTemplate";
public const string DefaultThemeHostPath = "Defaults.ThemeHostPath";
public const string DefaultMySqlDbTemplate = "Defaults.MySqlDbTemplate";
public const string DefaultMySqlUserTemplate = "Defaults.MySqlUserTemplate";
public const string DefaultPhpPostMaxSize = "Defaults.PhpPostMaxSize";
public const string DefaultPhpUploadMaxFilesize = "Defaults.PhpUploadMaxFilesize";
public const string DefaultPhpMaxExecutionTime = "Defaults.PhpMaxExecutionTime";
// Xibo bootstrap OAuth2 credentials (one-time global setup for orchestrator API access)
public const string CatXibo = "Xibo";
public const string XiboBootstrapClientId = "Xibo.BootstrapClientId";
public const string XiboBootstrapClientSecret = "Xibo.BootstrapClientSecret";
// Authentik (SAML IdP provisioning)
public const string AuthentikUrl = "Authentik.Url";
public const string AuthentikApiKey = "Authentik.ApiKey";
public const string AuthentikAuthorizationFlowSlug = "Authentik.AuthorizationFlowSlug";
public const string AuthentikInvalidationFlowSlug = "Authentik.InvalidationFlowSlug";
public const string AuthentikSigningKeypairId = "Authentik.SigningKeypairId";
// Instance-specific (keyed by abbreviation)
/// <summary>
/// Builds a per-instance settings key for the MySQL password.
/// </summary>
public static string InstanceMySqlPassword(string abbrev) => $"Instance.{abbrev}.MySqlPassword";
/// <summary>Bitwarden secret ID for the instance's OTS admin password.</summary>
public static string InstanceAdminPasswordSecretId(string abbrev) => $"Instance.{abbrev}.AdminPasswordBwsId";
/// <summary>Bitwarden secret ID for the instance's Xibo OAuth2 client secret.</summary>
public static string InstanceOAuthSecretId(string abbrev) => $"Instance.{abbrev}.OAuthSecretBwsId";
/// <summary>Xibo OAuth2 client_id generated for this instance's OTS application.</summary>
public static string InstanceOAuthClientId(string abbrev) => $"Instance.{abbrev}.OAuthClientId";
public const string CatInstance = "Instance";
// ── In-memory cache of secrets (loaded on first access) ────────────────
// Maps Bitwarden secret key (with prefix) → (id, value)
// Static so the cache is shared across all transient SettingsService instances.
private static Dictionary<string, (string Id, string Value)>? s_cache;
public SettingsService(
IBitwardenSecretService bws,
ILogger<SettingsService> logger)
{
_bws = bws;
_logger = logger;
}
/// <summary>Get a single setting value from Bitwarden.</summary>
public async Task<string?> GetAsync(string key)
{
var cache = await EnsureCacheAsync();
var bwKey = KeyPrefix + key;
if (!cache.TryGetValue(bwKey, out var entry))
return null;
// Treat single-space sentinel as empty (used to work around SDK marshalling limitation)
return string.IsNullOrWhiteSpace(entry.Value) ? null : entry.Value;
}
/// <summary>Get a setting with a fallback default.</summary>
public async Task<string> GetAsync(string key, string defaultValue)
=> await GetAsync(key) ?? defaultValue;
/// <summary>Set a single setting in Bitwarden (creates or updates).</summary>
public async Task SetAsync(string key, string? value, string category, bool isSensitive = false)
{
var cache = await EnsureCacheAsync();
var bwKey = KeyPrefix + key;
var note = $"{category}|{(isSensitive ? "sensitive" : "plain")}";
// Use a single space for empty/null values — the Bitwarden SDK native FFI
// cannot marshal empty strings reliably.
var safeValue = string.IsNullOrEmpty(value) ? " " : value;
if (cache.TryGetValue(bwKey, out var existing))
{
// Update existing secret
await _bws.UpdateSecretAsync(existing.Id, bwKey, safeValue, note);
cache[bwKey] = (existing.Id, safeValue);
s_cache = cache;
}
else if (!string.IsNullOrWhiteSpace(value))
{
// Only create new secrets when there is an actual value to store
var newId = await _bws.CreateSecretAsync(bwKey, safeValue, note);
cache[bwKey] = (newId, safeValue);
s_cache = cache;
}
}
/// <summary>Save multiple settings in a batch.</summary>
public async Task SaveManyAsync(IEnumerable<(string Key, string? Value, string Category, bool IsSensitive)> settings)
{
var count = 0;
var errors = new List<string>();
foreach (var (key, value, category, isSensitive) in settings)
{
try
{
await SetAsync(key, value, category, isSensitive);
count++;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to save setting {Key}", key);
errors.Add(key);
}
}
_logger.LogInformation("Saved {Count} setting(s) to Bitwarden", count);
if (errors.Count > 0)
throw new AggregateException(
$"Failed to save {errors.Count} setting(s): {string.Join(", ", errors)}");
}
/// <summary>Get all settings in a category (by examining cached keys).</summary>
public async Task<Dictionary<string, string?>> GetCategoryAsync(string category)
{
var cache = await EnsureCacheAsync();
var prefix = KeyPrefix + category + ".";
var result = new Dictionary<string, string?>();
foreach (var (bwKey, entry) in cache)
{
if (bwKey.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
// Strip the "ots-config/" prefix to return the original key
var originalKey = bwKey[KeyPrefix.Length..];
result[originalKey] = entry.Value;
}
}
return result;
}
/// <summary>
/// Invalidates the in-memory cache so next access re-fetches from Bitwarden.
/// </summary>
public void InvalidateCache() => s_cache = null;
/// <summary>
/// Pre-loads the settings cache from Bitwarden.
/// Call once at startup so settings are available immediately.
/// </summary>
public async Task PreloadCacheAsync()
{
InvalidateCache();
await EnsureCacheAsync();
}
// ─────────────────────────────────────────────────────────────────────────
// Cache management
// ─────────────────────────────────────────────────────────────────────────
private async Task<Dictionary<string, (string Id, string Value)>> EnsureCacheAsync()
{
if (s_cache is not null)
return s_cache;
var cache = new Dictionary<string, (string, string)>(StringComparer.OrdinalIgnoreCase);
// Skip loading if Bitwarden is not yet configured (normal on first run)
if (!await _bws.IsConfiguredAsync())
{
_logger.LogInformation("Bitwarden is not configured yet — settings will be available after setup");
s_cache = cache;
return s_cache;
}
try
{
// List all secrets, then fetch full value for those matching our prefix
var summaries = await _bws.ListSecretsAsync();
var configSecrets = summaries
.Where(s => s.Key.StartsWith(KeyPrefix, StringComparison.OrdinalIgnoreCase))
.ToList();
_logger.LogInformation("Loading {Count} config secrets from Bitwarden", configSecrets.Count);
foreach (var summary in configSecrets)
{
try
{
var full = await _bws.GetSecretAsync(summary.Id);
cache[full.Key] = (full.Id, full.Value);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load secret {Key} ({Id})", summary.Key, summary.Id);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load settings from Bitwarden — settings will be empty until Bitwarden is configured");
}
s_cache = cache;
return s_cache;
}
}

View File

@@ -1,184 +0,0 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="OTSSignsOrchestrator.Desktop.App"
RequestedThemeVariant="Dark">
<Application.Resources>
<ResourceDictionary>
<!-- ═══ Brand Palette ═══ -->
<Color x:Key="Accent">#D93A00</Color>
<Color x:Key="AccentHover">#E54B14</Color>
<Color x:Key="AccentPressed">#BF3300</Color>
<Color x:Key="AccentSubtle">#2A1A14</Color>
<Color x:Key="BgDeep">#0C0C14</Color>
<Color x:Key="BgBase">#11111B</Color>
<Color x:Key="Surface">#181825</Color>
<Color x:Key="SurfaceRaised">#1E1E2E</Color>
<Color x:Key="SurfaceOverlay">#232336</Color>
<Color x:Key="BorderSubtle">#2A2A40</Color>
<Color x:Key="TextPrimary">#CDD6F4</Color>
<Color x:Key="TextSecondary">#A6ADC8</Color>
<Color x:Key="TextMuted">#6C7086</Color>
<Color x:Key="Green">#4ADE80</Color>
<Color x:Key="Blue">#60A5FA</Color>
<Color x:Key="Purple">#C084FC</Color>
<Color x:Key="Pink">#F472B6</Color>
<Color x:Key="Amber">#FBBF24</Color>
<Color x:Key="Teal">#2DD4BF</Color>
<Color x:Key="Red">#F87171</Color>
<!-- ═══ Brushes ═══ -->
<SolidColorBrush x:Key="AccentBrush" Color="{StaticResource Accent}" />
<SolidColorBrush x:Key="AccentHoverBrush" Color="{StaticResource AccentHover}" />
<SolidColorBrush x:Key="AccentSubtleBrush" Color="{StaticResource AccentSubtle}" />
<SolidColorBrush x:Key="BgDeepBrush" Color="{StaticResource BgDeep}" />
<SolidColorBrush x:Key="BgBaseBrush" Color="{StaticResource BgBase}" />
<SolidColorBrush x:Key="SurfaceBrush" Color="{StaticResource Surface}" />
<SolidColorBrush x:Key="SurfaceRaisedBrush" Color="{StaticResource SurfaceRaised}" />
<SolidColorBrush x:Key="BorderSubtleBrush" Color="{StaticResource BorderSubtle}" />
<SolidColorBrush x:Key="TextPrimaryBrush" Color="{StaticResource TextPrimary}" />
<SolidColorBrush x:Key="TextSecondaryBrush" Color="{StaticResource TextSecondary}" />
<SolidColorBrush x:Key="TextMutedBrush" Color="{StaticResource TextMuted}" />
</ResourceDictionary>
</Application.Resources>
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
<!-- ═══ Global Typography ═══ -->
<Style Selector="Window">
<Setter Property="FontFamily" Value="Inter, Segoe UI, Helvetica Neue, sans-serif" />
<Setter Property="Background" Value="{StaticResource BgBaseBrush}" />
</Style>
<!-- ═══ Buttons — base ═══ -->
<Style Selector="Button">
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Padding" Value="14,7" />
<Setter Property="FontSize" Value="13" />
</Style>
<!-- Accent button -->
<Style Selector="Button.accent">
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
<Setter Property="Foreground" Value="White" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="Button.accent:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource AccentHoverBrush}" />
<Setter Property="Foreground" Value="White" />
</Style>
<!-- Danger button -->
<Style Selector="Button.danger">
<Setter Property="Background" Value="#7F1D1D" />
<Setter Property="Foreground" Value="#FCA5A5" />
</Style>
<Style Selector="Button.danger:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#991B1B" />
<Setter Property="Foreground" Value="#FCA5A5" />
</Style>
<!-- ═══ TextBox ═══ -->
<Style Selector="TextBox">
<Setter Property="CornerRadius" Value="6" />
</Style>
<!-- ═══ ComboBox ═══ -->
<Style Selector="ComboBox">
<Setter Property="CornerRadius" Value="6" />
</Style>
<!-- ═══ Card border ═══ -->
<Style Selector="Border.card">
<Setter Property="Background" Value="{StaticResource SurfaceRaisedBrush}" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Padding" Value="20" />
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<!-- ═══ Toolbar border ═══ -->
<Style Selector="Border.toolbar">
<Setter Property="Background" Value="{StaticResource SurfaceRaisedBrush}" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Padding" Value="14,10" />
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<!-- ═══ Sidebar ListBox ═══ -->
<Style Selector="ListBox.sidebar">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Padding" Value="8,0" />
</Style>
<Style Selector="ListBox.sidebar ListBoxItem">
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Margin" Value="0,1" />
<Setter Property="Padding" Value="14,10" />
<Setter Property="Foreground" Value="{StaticResource TextSecondaryBrush}" />
</Style>
<Style Selector="ListBox.sidebar ListBoxItem:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#1E1E30" />
</Style>
<Style Selector="ListBox.sidebar ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource AccentSubtleBrush}" />
</Style>
<Style Selector="ListBox.sidebar ListBoxItem:selected">
<Setter Property="Foreground" Value="#F0F0F8" />
</Style>
<!-- ═══ DataGrid ═══ -->
<Style Selector="DataGrid">
<Setter Property="CornerRadius" Value="8" />
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtleBrush}" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<!-- ═══ Page header ═══ -->
<Style Selector="TextBlock.pageTitle">
<Setter Property="FontSize" Value="22" />
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}" />
<Setter Property="Margin" Value="0,0,0,4" />
</Style>
<Style Selector="TextBlock.pageSubtitle">
<Setter Property="FontSize" Value="13" />
<Setter Property="Foreground" Value="{StaticResource TextMutedBrush}" />
<Setter Property="Margin" Value="0,0,0,16" />
</Style>
<Style Selector="TextBlock.sectionTitle">
<Setter Property="FontSize" Value="16" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Margin" Value="0,0,0,4" />
</Style>
<!-- ═══ Separator ═══ -->
<Style Selector="Separator">
<Setter Property="Background" Value="{StaticResource BorderSubtleBrush}" />
<Setter Property="Height" Value="1" />
</Style>
<!-- ═══ Expander ═══ -->
<Style Selector="Expander">
<Setter Property="CornerRadius" Value="8" />
</Style>
<!-- ═══ TabControl ═══ -->
<Style Selector="TabControl">
<Setter Property="Background" Value="Transparent" />
</Style>
<!-- ═══ Status label ═══ -->
<Style Selector="TextBlock.status">
<Setter Property="FontSize" Value="12" />
<Setter Property="Foreground" Value="{StaticResource TextMutedBrush}" />
</Style>
</Application.Styles>
</Application>

View File

@@ -1,208 +0,0 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Polly;
using Polly.Extensions.Http;
using Refit;
using Serilog;
using OTSSignsOrchestrator.Core.Configuration;
using OTSSignsOrchestrator.Core.Data;
using OTSSignsOrchestrator.Core.Services;
using OTSSignsOrchestrator.Desktop.Services;
using OTSSignsOrchestrator.Desktop.ViewModels;
using OTSSignsOrchestrator.Desktop.Views;
namespace OTSSignsOrchestrator.Desktop;
public class App : Application
{
public static IServiceProvider Services { get; private set; } = null!;
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
var services = new ServiceCollection();
ConfigureServices(services);
Services = services.BuildServiceProvider();
// Apply migrations
using (var scope = Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
db.Database.Migrate();
}
Log.Information("ApplicationLifetime type: {Type}", ApplicationLifetime?.GetType().FullName ?? "null");
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
Log.Information("Creating MainWindow...");
// Import existing instance secrets from Bitwarden (fire-and-forget, non-blocking)
_ = Task.Run(async () =>
{
try
{
// Pre-load config settings from Bitwarden so they're available immediately
using var scope = Services.CreateScope();
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
await settings.PreloadCacheAsync();
Log.Information("Bitwarden config settings pre-loaded");
// Import existing instance secrets that aren't yet tracked
var postInit = Services.GetRequiredService<PostInstanceInitService>();
await postInit.ImportExistingInstanceSecretsAsync();
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to pre-load settings or import instance secrets on startup");
}
});
var vm = Services.GetRequiredService<MainWindowViewModel>();
Log.Information("MainWindowViewModel resolved");
var window = new MainWindow
{
DataContext = vm
};
desktop.MainWindow = window;
Log.Information("MainWindow assigned to lifetime");
window.Show();
window.Activate();
Log.Information("MainWindow Show() + Activate() called");
// Start the SignalR connection (fire-and-forget, reconnect handles failures)
_ = Task.Run(async () =>
{
try
{
var signalR = Services.GetRequiredService<ServerSignalRService>();
await signalR.StartAsync();
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to start SignalR connection on startup");
}
});
desktop.ShutdownRequested += (_, _) =>
{
var ssh = Services.GetService<SshConnectionService>();
ssh?.Dispose();
var signalR = Services.GetService<ServerSignalRService>();
signalR?.DisposeAsync().AsTask().GetAwaiter().GetResult();
};
}
else
{
Log.Warning("ApplicationLifetime is NOT IClassicDesktopStyleApplicationLifetime — window cannot be shown");
}
base.OnFrameworkInitializationCompleted();
}
private static void ConfigureServices(IServiceCollection services)
{
// Configuration (reloadOnChange so runtime writes to appsettings.json are picked up)
var config = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.Build();
services.AddSingleton<IConfiguration>(config);
// Options
services.Configure<GitOptions>(config.GetSection(GitOptions.SectionName));
services.Configure<DockerOptions>(config.GetSection(DockerOptions.SectionName));
services.Configure<XiboOptions>(config.GetSection(XiboOptions.SectionName));
services.Configure<DatabaseOptions>(config.GetSection(DatabaseOptions.SectionName));
services.Configure<FileLoggingOptions>(config.GetSection(FileLoggingOptions.SectionName));
services.Configure<BitwardenOptions>(config.GetSection(BitwardenOptions.SectionName));
// Logging
services.AddLogging(builder =>
{
builder.ClearProviders();
builder.AddSerilog(dispose: true);
});
// Data Protection
var keysDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"OTSSignsOrchestrator", "keys");
Directory.CreateDirectory(keysDir);
services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(keysDir))
.SetApplicationName("OTSSignsOrchestrator");
// Database
var connStr = config.GetConnectionString("Default") ?? "Data Source=otssigns-desktop.db";
services.AddDbContext<XiboContext>(options => options.UseSqlite(connStr));
// HTTP
services.AddHttpClient();
services.AddHttpClient("XiboApi");
services.AddHttpClient("XiboHealth");
services.AddHttpClient("AuthentikApi");
// ── Server API integration ──────────────────────────────────────────
services.AddSingleton<TokenStoreService>();
services.AddTransient<AuthHeaderHandler>();
var serverBaseUrl = config["ServerBaseUrl"] ?? "https://localhost:5001";
services.AddRefitClient<IServerApiClient>()
.ConfigureHttpClient(c => c.BaseAddress = new Uri(serverBaseUrl))
.AddHttpMessageHandler<AuthHeaderHandler>()
.AddPolicyHandler(HttpPolicyExtensions
.HandleTransientHttpError()
.WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt))));
services.AddSingleton<ServerSignalRService>();
// SSH services (singletons — maintain connections)
services.AddSingleton<SshConnectionService>();
// Docker services via SSH (singletons — SetHost() must persist across scopes)
services.AddSingleton<SshDockerCliService>();
services.AddSingleton<SshDockerSecretsService>();
services.AddSingleton<IDockerCliService>(sp => sp.GetRequiredService<SshDockerCliService>());
services.AddSingleton<IDockerSecretsService>(sp => sp.GetRequiredService<SshDockerSecretsService>());
// Core services
services.AddTransient<SettingsService>();
services.AddTransient<GitTemplateService>();
services.AddTransient<ComposeRenderService>();
services.AddTransient<ComposeValidationService>();
services.AddTransient<XiboApiService>();
services.AddTransient<InstanceService>();
services.AddTransient<IBitwardenSecretService, BitwardenSecretService>();
services.AddTransient<IAuthentikService, AuthentikService>();
services.AddTransient<IInvitationSetupService, InvitationSetupService>();
services.AddSingleton<PostInstanceInitService>();
// ViewModels
services.AddSingleton<MainWindowViewModel>(); // singleton: one main window, nav state shared
services.AddTransient<HostsViewModel>();
services.AddTransient<InstancesViewModel>();
services.AddTransient<InstanceDetailsViewModel>();
services.AddTransient<CreateInstanceViewModel>();
services.AddTransient<SecretsViewModel>();
services.AddTransient<SettingsViewModel>();
services.AddTransient<LogsViewModel>();
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -1,31 +0,0 @@
using OTSSignsOrchestrator.Core.Models.Entities;
namespace OTSSignsOrchestrator.Desktop.Models;
/// <summary>
/// Represents a CMS stack discovered live from a Docker Swarm host.
/// No data is persisted locally — all values come from <c>docker stack ls</c> / inspect.
/// </summary>
public class LiveStackItem
{
/// <summary>Docker stack name, e.g. "acm-cms-stack".</summary>
public string StackName { get; set; } = string.Empty;
/// <summary>3-letter abbreviation derived from the stack name.</summary>
public string CustomerAbbrev { get; set; } = string.Empty;
/// <summary>Number of services reported by <c>docker stack ls</c>.</summary>
public int ServiceCount { get; set; }
/// <summary>The SSH host this stack was found on.</summary>
public SshHost Host { get; set; } = null!;
/// <summary>Label of the host — convenience property for data-binding.</summary>
public string HostLabel => Host?.Label ?? string.Empty;
/// <summary>
/// Server-side customer ID. Populated when fleet data is loaded from the server API.
/// Null when loaded only from local Docker discovery.
/// </summary>
public Guid? CustomerId { get; set; }
}

View File

@@ -1,62 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<!-- Ensure the Bitwarden SDK native runtime libraries are included on publish -->
<RuntimeIdentifiers>linux-x64;win-x64;osx-x64;osx-arm64</RuntimeIdentifiers>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.2.3" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.3" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.3" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.3" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.3" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.3" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.2" />
<PackageReference Include="MySqlConnector" Version="2.5.0" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.2" />
<PackageReference Include="Refit.HttpClientFactory" Version="8.0.0" />
<PackageReference Include="SSH.NET" Version="2024.2.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OTSSignsOrchestrator.Core\OTSSignsOrchestrator.Core.csproj" />
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<Content Include="..\templates\settings-custom.php.template">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>templates/settings-custom.php.template</Link>
</Content>
</ItemGroup>
</Project>

View File

@@ -1,40 +0,0 @@
using Avalonia;
using Avalonia.ReactiveUI;
using Microsoft.Extensions.DependencyInjection;
using Serilog;
using System;
namespace OTSSignsOrchestrator.Desktop;
sealed class Program
{
[STAThread]
public static void Main(string[] args)
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console()
.WriteTo.File("logs/app-.log", rollingInterval: RollingInterval.Day, retainedFileCountLimit: 7)
.CreateLogger();
try
{
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}
}
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace()
.UseReactiveUI();
}

View File

@@ -1,152 +0,0 @@
using Refit;
namespace OTSSignsOrchestrator.Desktop.Services;
// ── DTOs matching server REST API responses ─────────────────────────────────
public record FleetSummaryDto
{
public Guid CustomerId { get; init; }
public string Abbreviation { get; init; } = string.Empty;
public string CompanyName { get; init; } = string.Empty;
public string Plan { get; init; } = string.Empty;
public int ScreenCount { get; init; }
public string HealthStatus { get; init; } = "Unknown";
public DateTime? LastHealthCheck { get; init; }
public bool HasRunningJob { get; init; }
}
public record CustomerDetailDto
{
public Guid Id { get; init; }
public string Abbreviation { get; init; } = string.Empty;
public string CompanyName { get; init; } = string.Empty;
public string? AdminEmail { get; init; }
public string Plan { get; init; } = string.Empty;
public int ScreenCount { get; init; }
public string Status { get; init; } = string.Empty;
public DateTime CreatedAt { get; init; }
public List<CustomerInstanceDto> Instances { get; init; } = [];
public List<CustomerJobDto> ActiveJobs { get; init; } = [];
}
public record CustomerInstanceDto
{
public Guid Id { get; init; }
public string? XiboUrl { get; init; }
public string? DockerStackName { get; init; }
public string HealthStatus { get; init; } = "Unknown";
public DateTime? LastHealthCheck { get; init; }
}
public record CustomerJobDto
{
public Guid Id { get; init; }
public string JobType { get; init; } = string.Empty;
public string Status { get; init; } = string.Empty;
public DateTime CreatedAt { get; init; }
public DateTime? StartedAt { get; init; }
}
public record CreateJobRequest(Guid CustomerId, string JobType, string? Parameters);
public record CreateJobResponse
{
public Guid Id { get; init; }
public string JobType { get; init; } = string.Empty;
public string Status { get; init; } = string.Empty;
}
public record JobDetailDto
{
public Guid Id { get; init; }
public Guid CustomerId { get; init; }
public string JobType { get; init; } = string.Empty;
public string Status { get; init; } = string.Empty;
public string? TriggeredBy { get; init; }
public string? Parameters { get; init; }
public DateTime CreatedAt { get; init; }
public DateTime? StartedAt { get; init; }
public DateTime? CompletedAt { get; init; }
public string? ErrorMessage { get; init; }
public List<JobStepDto> Steps { get; init; } = [];
}
public record JobStepDto
{
public Guid Id { get; init; }
public string StepName { get; init; } = string.Empty;
public string Status { get; init; } = string.Empty;
public string? LogOutput { get; init; }
public DateTime? StartedAt { get; init; }
public DateTime? CompletedAt { get; init; }
}
public record LoginRequest(string Email, string Password);
public record RefreshRequest(string RefreshToken);
public record AuthResponse
{
public string Token { get; init; } = string.Empty;
public string RefreshToken { get; init; } = string.Empty;
}
public record RefreshResponse
{
public string Token { get; init; } = string.Empty;
}
// ── Refit interface ─────────────────────────────────────────────────────────
[Headers("Accept: application/json")]
public interface IServerApiClient
{
[Get("/api/fleet")]
Task<List<FleetSummaryDto>> GetFleetAsync();
[Get("/api/fleet/{id}")]
Task<CustomerDetailDto> GetCustomerDetailAsync(Guid id);
[Post("/api/jobs")]
Task<CreateJobResponse> CreateJobAsync([Body] CreateJobRequest body);
[Get("/api/jobs/{id}")]
Task<JobDetailDto> GetJobAsync(Guid id);
[Post("/api/auth/login")]
Task<AuthResponse> LoginAsync([Body] LoginRequest body);
[Post("/api/auth/refresh")]
Task<RefreshResponse> RefreshAsync([Body] RefreshRequest body);
[Get("/api/reports/billing")]
Task<HttpResponseMessage> GetBillingCsvAsync([Query] DateOnly from, [Query] DateOnly to);
[Get("/api/reports/fleet-health")]
Task<HttpResponseMessage> GetFleetHealthPdfAsync();
[Post("/api/fleet/bulk/{action}")]
Task<HttpResponseMessage> BulkActionAsync(string action);
}
// ── DelegatingHandler for Bearer token injection ────────────────────────────
public class AuthHeaderHandler : DelegatingHandler
{
private readonly TokenStoreService _tokenStore;
public AuthHeaderHandler(TokenStoreService tokenStore)
{
_tokenStore = tokenStore;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var jwt = _tokenStore.GetJwt();
if (!string.IsNullOrEmpty(jwt))
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", jwt);
return await base.SendAsync(request, cancellationToken);
}
}

View File

@@ -1,112 +0,0 @@
using Avalonia.Threading;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace OTSSignsOrchestrator.Desktop.Services;
/// <summary>
/// Singleton service managing the persistent SignalR connection to the server's FleetHub.
/// All handlers dispatch to the UI thread and republish via <see cref="WeakReferenceMessenger"/>.
/// </summary>
public sealed class ServerSignalRService : IAsyncDisposable
{
private readonly HubConnection _connection;
private readonly ILogger<ServerSignalRService> _logger;
public ServerSignalRService(
TokenStoreService tokenStore,
IConfiguration config,
ILogger<ServerSignalRService> logger)
{
_logger = logger;
var baseUrl = config["ServerBaseUrl"] ?? "https://localhost:5001";
_connection = new HubConnectionBuilder()
.WithUrl($"{baseUrl}/hubs/fleet", options =>
{
options.AccessTokenProvider = () => Task.FromResult(tokenStore.GetJwt());
})
.WithAutomaticReconnect(new[] { TimeSpan.Zero, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(30) })
.Build();
RegisterHandlers();
_connection.Reconnecting += ex =>
{
_logger.LogWarning(ex, "SignalR reconnecting...");
return Task.CompletedTask;
};
_connection.Reconnected += connectionId =>
{
_logger.LogInformation("SignalR reconnected (connId={ConnectionId})", connectionId);
return Task.CompletedTask;
};
_connection.Closed += ex =>
{
_logger.LogWarning(ex, "SignalR connection closed");
return Task.CompletedTask;
};
}
/// <summary>
/// Starts the SignalR connection. Call from <c>App.OnFrameworkInitializationCompleted</c>.
/// Failures are logged but do not throw — automatic reconnect will retry.
/// </summary>
public async Task StartAsync()
{
try
{
await _connection.StartAsync();
_logger.LogInformation("SignalR connected to FleetHub");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "SignalR initial connection failed — will retry via automatic reconnect");
}
}
public async Task StopAsync()
{
try { await _connection.StopAsync(); }
catch (Exception ex) { _logger.LogWarning(ex, "Error stopping SignalR connection"); }
}
public HubConnectionState State => _connection.State;
private void RegisterHandlers()
{
_connection.On<string, string, string>("SendJobCreated", (jobId, abbrev, jobType) =>
Dispatcher.UIThread.InvokeAsync(() =>
WeakReferenceMessenger.Default.Send(
new JobCreatedMessage(new(jobId, abbrev, jobType)))));
_connection.On<string, string, int, string>("SendJobProgressUpdate", (jobId, stepName, pct, logLine) =>
Dispatcher.UIThread.InvokeAsync(() =>
WeakReferenceMessenger.Default.Send(
new JobProgressUpdateMessage(new(jobId, stepName, pct, logLine)))));
_connection.On<string, bool, string>("SendJobCompleted", (jobId, success, summary) =>
Dispatcher.UIThread.InvokeAsync(() =>
WeakReferenceMessenger.Default.Send(
new JobCompletedMessage(new(jobId, success, summary)))));
_connection.On<string, string>("SendInstanceStatusChanged", (customerId, status) =>
Dispatcher.UIThread.InvokeAsync(() =>
WeakReferenceMessenger.Default.Send(
new InstanceStatusChangedMessage(new(customerId, status)))));
_connection.On<string, string>("SendAlertRaised", (severity, message) =>
Dispatcher.UIThread.InvokeAsync(() =>
WeakReferenceMessenger.Default.Send(
new AlertRaisedMessage(new(severity, message)))));
}
public async ValueTask DisposeAsync()
{
await _connection.DisposeAsync();
}
}

View File

@@ -1,35 +0,0 @@
using CommunityToolkit.Mvvm.Messaging.Messages;
namespace OTSSignsOrchestrator.Desktop.Services;
/// <summary>SignalR push messages republished via WeakReferenceMessenger for ViewModel consumption.</summary>
public sealed class JobCreatedMessage : ValueChangedMessage<JobCreatedMessage.Payload>
{
public JobCreatedMessage(Payload value) : base(value) { }
public record Payload(string JobId, string Abbrev, string JobType);
}
public sealed class JobProgressUpdateMessage : ValueChangedMessage<JobProgressUpdateMessage.Payload>
{
public JobProgressUpdateMessage(Payload value) : base(value) { }
public record Payload(string JobId, string StepName, int Pct, string LogLine);
}
public sealed class JobCompletedMessage : ValueChangedMessage<JobCompletedMessage.Payload>
{
public JobCompletedMessage(Payload value) : base(value) { }
public record Payload(string JobId, bool Success, string Summary);
}
public sealed class InstanceStatusChangedMessage : ValueChangedMessage<InstanceStatusChangedMessage.Payload>
{
public InstanceStatusChangedMessage(Payload value) : base(value) { }
public record Payload(string CustomerId, string Status);
}
public sealed class AlertRaisedMessage : ValueChangedMessage<AlertRaisedMessage.Payload>
{
public AlertRaisedMessage(Payload value) : base(value) { }
public record Payload(string Severity, string Message);
}

View File

@@ -1,780 +0,0 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MySqlConnector;
using OTSSignsOrchestrator.Core.Configuration;
using OTSSignsOrchestrator.Core.Models.DTOs;
using OTSSignsOrchestrator.Core.Models.Entities;
using OTSSignsOrchestrator.Core.Services;
using ServiceLogEntry = OTSSignsOrchestrator.Core.Models.DTOs.ServiceLogEntry;
namespace OTSSignsOrchestrator.Desktop.Services;
/// <summary>
/// Docker CLI service that executes docker commands on a remote host over SSH.
/// Requires an SshHost to be set before use via SetHost().
/// </summary>
public class SshDockerCliService : IDockerCliService
{
private readonly SshConnectionService _ssh;
private readonly DockerOptions _options;
private readonly ILogger<SshDockerCliService> _logger;
private SshHost? _currentHost;
public SshDockerCliService(
SshConnectionService ssh,
IOptions<DockerOptions> options,
ILogger<SshDockerCliService> logger)
{
_ssh = ssh;
_options = options.Value;
_logger = logger;
}
/// <summary>
/// Set the SSH host to use for Docker commands.
/// </summary>
public void SetHost(SshHost host)
{
_currentHost = host;
}
public SshHost? CurrentHost => _currentHost;
private void EnsureHost()
{
if (_currentHost == null)
throw new InvalidOperationException("No SSH host configured. Call SetHost() before using Docker commands.");
}
/// <summary>
/// Escape password for safe use in shell scripts with proper quoting.
/// Uses printf-safe escaping to avoid newline injection and special character issues.
/// </summary>
private string EscapePasswordForShell(string password)
{
if (string.IsNullOrEmpty(password))
{
_logger.LogWarning("Password is null or empty");
return string.Empty;
}
_logger.LogDebug("Original password length: {Length} characters", password.Length);
// Use printf-safe format: escape single quotes and other problematic characters
// Replace ' with '\'' (close quote, escaped quote, open quote)
var escaped = password.Replace("'", "'\\''");
_logger.LogDebug("Escaped password length: {Length} characters (added {Extra} chars for escaping)",
escaped.Length, escaped.Length - password.Length);
_logger.LogDebug("Password first char: '{FirstChar}', last char: '{LastChar}'",
password.Length > 0 ? password[0].ToString() : "N/A",
password.Length > 0 ? password[^1].ToString() : "N/A");
return escaped;
}
/// <summary>
/// Test if the current host's password works with sudo by running a no-op sudo command.
/// </summary>
private async Task<(bool Success, string? Error)> TestSudoPasswordAsync()
{
EnsureHost();
if (string.IsNullOrEmpty(_currentHost!.Password))
{
return (false, "No password configured for SSH host");
}
var escapedPassword = EscapePasswordForShell(_currentHost!.Password);
var testCmd = $"printf '%s\\n' '{escapedPassword}' | sudo -S -v 2>&1";
_logger.LogInformation("Testing sudo password for host {Host} user {User}...",
_currentHost!.Label, _currentHost!.Username);
var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(_currentHost!, testCmd, TimeSpan.FromSeconds(10));
if (exitCode == 0)
{
_logger.LogInformation("Sudo password test PASSED for {Host}", _currentHost!.Label);
return (true, null);
}
var error = (stderr ?? stdout ?? "unknown error").Trim();
_logger.LogWarning("Sudo password test FAILED for {Host}: {Error}", _currentHost!.Label, error);
return (false, error);
}
public async Task<DeploymentResultDto> DeployStackAsync(string stackName, string composeYaml, bool resolveImage = false)
{
EnsureHost();
var sw = Stopwatch.StartNew();
var args = "docker stack deploy --compose-file -";
if (resolveImage)
args += " --resolve-image changed";
args += $" {stackName}";
_logger.LogInformation("Deploying stack via SSH: {StackName} on {Host}", stackName, _currentHost!.Label);
var (exitCode, stdout, stderr) = await _ssh.RunCommandWithStdinAsync(_currentHost, 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 via SSH: {StackName} | duration={DurationMs}ms", stackName, result.DurationMs);
else
_logger.LogError("Stack deploy failed via SSH: {StackName} | error={Error}", stackName, result.ErrorMessage);
return result;
}
public async Task<DeploymentResultDto> RemoveStackAsync(string stackName)
{
EnsureHost();
var sw = Stopwatch.StartNew();
_logger.LogInformation("Removing stack via SSH: {StackName} on {Host}", stackName, _currentHost!.Label);
var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(_currentHost, $"docker stack rm {stackName}");
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 removed via SSH: {StackName}", stackName);
else
_logger.LogError("Stack remove failed via SSH: {StackName} | error={Error}", stackName, result.ErrorMessage);
return result;
}
public async Task<List<StackInfo>> ListStacksAsync()
{
EnsureHost();
var (exitCode, stdout, _) = await _ssh.RunCommandAsync(
_currentHost!, "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)
{
EnsureHost();
var (exitCode, stdout, _) = await _ssh.RunCommandAsync(
_currentHost!, $"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();
}
public async Task<bool> EnsureDirectoryAsync(string path)
{
EnsureHost();
var (exitCode, _, stderr) = await _ssh.RunCommandAsync(_currentHost!, $"mkdir -p {path}");
if (exitCode != 0)
_logger.LogWarning("Failed to create directory {Path} on {Host}: {Error}", path, _currentHost!.Label, stderr);
else
_logger.LogInformation("Ensured directory exists on {Host}: {Path}", _currentHost!.Label, path);
return exitCode == 0;
}
public async Task<bool> EnsureNfsFoldersAsync(
string nfsServer,
string nfsExport,
IEnumerable<string> folderNames,
string? nfsExportFolder = null)
{
EnsureHost();
var exportPath = (nfsExport ?? string.Empty).Trim('/');
var subFolder = (nfsExportFolder ?? string.Empty).Trim('/');
// Build the sub-path beneath the mount point where volume folders will be created
var subPath = string.IsNullOrEmpty(subFolder) ? string.Empty : $"/{subFolder}";
// Build mkdir targets relative to the temporary mount point
var folderList = folderNames.Select(f => $"\"$MNT{subPath}/{f}\"").ToList();
var mkdirTargets = string.Join(" ", folderList);
// Single SSH command: create temp dir, mount NFS, mkdir -p all folders, unmount, cleanup
// Use addr= to pin the server IP — avoids "Server address does not match proto= option"
// errors when the hostname resolves to IPv6 but proto=tcp implies IPv4.
// Properly escape password for shell use (handle special characters like single quotes)
var escapedPassword = EscapePasswordForShell(_currentHost!.Password ?? string.Empty);
if (string.IsNullOrEmpty(escapedPassword))
{
_logger.LogWarning(
"No password configured for SSH host {Host}. NFS operations may fail if sudo requires a password.",
_currentHost!.Label);
}
var script = $"""
set -e
MNT=$(mktemp -d)
printf '%s\n' '{escapedPassword}' | sudo -S mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
printf '%s\n' '{escapedPassword}' | sudo -S mkdir -p {mkdirTargets}
printf '%s\n' '{escapedPassword}' | sudo -S umount "$MNT"
rmdir "$MNT"
""";
_logger.LogInformation(
"Mounting NFS export {Server}:/{Export} on Docker host {Host} to create {Count} folders",
nfsServer, exportPath, _currentHost!.Label, folderList.Count);
var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(_currentHost!, script, TimeSpan.FromSeconds(30));
if (exitCode == 0)
{
_logger.LogInformation(
"NFS export folders ensured via mount on {Host}: {Server}:/{Export}{Sub} ({Count} folders)",
_currentHost.Label, nfsServer, exportPath, subPath, folderList.Count);
}
else
{
_logger.LogWarning(
"Failed to create NFS export folders on {Host}: {Error}",
_currentHost.Label, (stderr ?? stdout ?? "unknown error").Trim());
return false;
}
return true;
}
public async Task<(bool Success, string? Error)> EnsureNfsFoldersWithErrorAsync(
string nfsServer,
string nfsExport,
IEnumerable<string> folderNames,
string? nfsExportFolder = null)
{
EnsureHost();
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);
// Properly escape password for shell use (handle special characters like single quotes)
var escapedPassword = EscapePasswordForShell(_currentHost!.Password ?? string.Empty);
if (string.IsNullOrEmpty(escapedPassword))
{
_logger.LogWarning(
"No password configured for SSH host {Host}. NFS operations may fail if sudo requires a password.",
_currentHost!.Label);
}
var script = $"""
set -e
MNT=$(mktemp -d)
printf '%s\n' '{escapedPassword}' | sudo -S mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
printf '%s\n' '{escapedPassword}' | sudo -S mkdir -p {mkdirTargets}
printf '%s\n' '{escapedPassword}' | sudo -S umount "$MNT"
rmdir "$MNT"
""";
_logger.LogInformation(
"Mounting NFS export {Server}:/{Export} on Docker host {Host} to create {Count} folders",
nfsServer, exportPath, _currentHost!.Label, folderList.Count);
var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(_currentHost!, script, TimeSpan.FromSeconds(30));
if (exitCode == 0)
{
_logger.LogInformation(
"NFS export folders ensured via mount on {Host}: {Server}:/{Export}{Sub} ({Count} folders)",
_currentHost.Label, nfsServer, exportPath, subPath, folderList.Count);
return (true, null);
}
var error = (stderr ?? stdout ?? "unknown error").Trim();
_logger.LogWarning(
"Failed to create NFS export folders on {Host}: {Error}",
_currentHost.Label, error);
return (false, error);
}
public async Task<(bool Success, string? Error)> WriteFileToNfsAsync(
string nfsServer,
string nfsExport,
string relativePath,
string content,
string? nfsExportFolder = null)
{
EnsureHost();
var exportPath = (nfsExport ?? string.Empty).Trim('/');
var subFolder = (nfsExportFolder ?? string.Empty).Trim('/');
var subPath = string.IsNullOrEmpty(subFolder) ? string.Empty : $"/{subFolder}";
// Ensure parent directory exists
var targetPath = $"$MNT{subPath}/{relativePath.TrimStart('/')}";
var parentDir = $"$(dirname \"{targetPath}\")";
// Properly escape password for shell use (handle special characters like single quotes)
var escapedPassword = EscapePasswordForShell(_currentHost!.Password ?? string.Empty);
_logger.LogInformation("NFS WriteFile: Host={Host}, User={User}, HasPassword={HasPw}, PwLen={PwLen}",
_currentHost!.Label, _currentHost!.Username,
!string.IsNullOrEmpty(_currentHost!.Password), _currentHost!.Password?.Length ?? 0);
if (string.IsNullOrEmpty(escapedPassword))
{
_logger.LogWarning(
"No password configured for SSH host {Host}. NFS operations may fail if sudo requires a password.",
_currentHost!.Label);
return (false, "No password configured for SSH host");
}
// Base64-encode the file content to avoid heredoc/stdin conflicts with sudo -S.
// The heredoc approach fails because the shell's heredoc redirects stdin for the
// entire pipeline, so sudo -S reads the PHP content instead of the password.
var base64Content = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(content));
// Strategy: base64-decode content to a temp file (no sudo needed), then use
// printf | sudo -S for each privileged command — matching the proven pattern
// in EnsureNfsFoldersAsync. We avoid sudo -v timestamp caching because SSH
// exec channels have no TTY and timestamps may not persist between commands.
var script = $"""
set -e
TMPFILE=$(mktemp)
echo '{base64Content}' | base64 -d > "$TMPFILE"
MNT=$(mktemp -d)
printf '%s\n' '{escapedPassword}' | sudo -S mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
printf '%s\n' '{escapedPassword}' | sudo -S mkdir -p {parentDir}
printf '%s\n' '{escapedPassword}' | sudo -S cp "$TMPFILE" "{targetPath}"
rm -f "$TMPFILE"
printf '%s\n' '{escapedPassword}' | sudo -S umount "$MNT"
rmdir "$MNT"
""";
_logger.LogInformation(
"Writing file to NFS {Server}:/{Export}{Sub}/{Path} on Docker host {Host}",
nfsServer, exportPath, subPath, relativePath, _currentHost!.Label);
var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(_currentHost!, script, TimeSpan.FromSeconds(30));
if (exitCode == 0)
{
_logger.LogInformation(
"File written to NFS on {Host}: {Server}:/{Export}{Sub}/{Path}",
_currentHost.Label, nfsServer, exportPath, subPath, relativePath);
return (true, null);
}
var error = (stderr ?? stdout ?? "unknown error").Trim();
_logger.LogWarning(
"Failed to write file to NFS on {Host}: {Error}",
_currentHost.Label, error);
return (false, error);
}
public async Task<bool> ForceUpdateServiceAsync(string serviceName)
{
EnsureHost();
_logger.LogInformation("Force-updating service {ServiceName} on {Host}", serviceName, _currentHost!.Label);
var (exitCode, _, stderr) = await _ssh.RunCommandAsync(_currentHost!, $"docker service update --force {serviceName}");
if (exitCode != 0)
_logger.LogWarning("Force-update failed for {ServiceName}: {Error}", serviceName, stderr);
return exitCode == 0;
}
public async Task<(MySqlConnection Connection, IDisposable Tunnel)> OpenMySqlConnectionAsync(
string mysqlHost, int port,
string adminUser, string adminPassword)
{
EnsureHost();
_logger.LogInformation(
"Opening tunnelled MySQL connection to {MysqlHost}:{Port} via SSH",
mysqlHost, port);
var tunnel = _ssh.OpenForwardedPort(_currentHost!, mysqlHost, (uint)port);
var localPort = (int)tunnel.BoundPort;
var csb = new MySqlConnectionStringBuilder
{
Server = "127.0.0.1",
Port = (uint)localPort,
UserID = adminUser,
Password = adminPassword,
ConnectionTimeout = 15,
SslMode = MySqlSslMode.Disabled,
};
var connection = new MySqlConnection(csb.ConnectionString);
try
{
await connection.OpenAsync();
return (connection, tunnel);
}
catch
{
await connection.DisposeAsync();
tunnel.Dispose();
throw;
}
}
public async Task<(bool Success, string Error)> AlterMySqlUserPasswordAsync(
string mysqlHost, int port,
string adminUser, string adminPassword,
string targetUser, string newPassword)
{
_logger.LogInformation(
"Altering MySQL password for user {User} on {MysqlHost}:{Port} via SSH tunnel",
targetUser, mysqlHost, port);
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();
}
_logger.LogInformation("MySQL password updated for user {User} via SSH tunnel", targetUser);
return (true, string.Empty);
}
catch (MySqlException ex)
{
_logger.LogError(ex, "MySQL ALTER USER failed via SSH tunnel for user {User}", targetUser);
return (false, ex.Message);
}
}
public async Task<bool> ServiceSwapSecretAsync(string serviceName, string oldSecretName, string newSecretName, string? targetAlias = null)
{
EnsureHost();
var target = targetAlias ?? oldSecretName;
var cmd = $"docker service update --secret-rm {oldSecretName} --secret-add \"source={newSecretName},target={target}\" {serviceName}";
_logger.LogInformation(
"Swapping secret on {ServiceName}: {OldSecret} → {NewSecret} (target={Target})",
serviceName, oldSecretName, newSecretName, target);
var (exitCode, _, stderr) = await _ssh.RunCommandAsync(_currentHost!, cmd);
if (exitCode != 0)
_logger.LogError("Secret swap failed for {ServiceName}: {Error}", serviceName, stderr);
return exitCode == 0;
}
public async Task<List<NodeInfo>> ListNodesAsync()
{
EnsureHost();
_logger.LogInformation("Listing swarm nodes via SSH on {Host}", _currentHost!.Label);
// Use docker node inspect on all nodes to get IP addresses (Status.Addr)
// that are not available from 'docker node ls'.
// First, get all node IDs.
var (lsExit, lsOut, lsErr) = await _ssh.RunCommandAsync(
_currentHost!, "docker node ls --format '{{.ID}}'");
if (lsExit != 0)
{
var msg = (lsErr ?? lsOut ?? "unknown error").Trim();
_logger.LogWarning("docker node ls failed on {Host} (exit {Code}): {Error}",
_currentHost.Label, lsExit, msg);
throw new InvalidOperationException(
$"Failed to list swarm nodes on {_currentHost.Label}: {msg}");
}
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>();
// Inspect all nodes in a single call to get full details including IP address
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 _ssh.RunCommandAsync(
_currentHost!, $"docker node inspect --format {format} {ids}");
if (exitCode != 0)
{
var msg = (stderr ?? stdout ?? "unknown error").Trim();
_logger.LogWarning("docker node inspect failed on {Host} (exit {Code}): {Error}",
_currentHost.Label, exitCode, msg);
throw new InvalidOperationException(
$"Failed to inspect swarm nodes on {_currentHost.Label}: {msg}");
}
if (string.IsNullOrWhiteSpace(stdout))
return new List<NodeInfo>();
return stdout
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(line =>
{
var parts = line.Split('\t', 8);
// ManagerStatus.Addr includes port (e.g. "10.0.0.1:2377"); Status.Addr is just the IP.
// Prefer Status.Addr; fall back to ManagerStatus.Addr (strip port) if Status.Addr is empty/template-error.
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("{"))
{
// managerAddr may be "10.0.0.1:2377"
ip = managerAddr.Contains(':') ? managerAddr[..managerAddr.LastIndexOf(':')] : managerAddr;
}
// Clean up template rendering artefacts like "<no value>"
if (ip.StartsWith("<") || ip.StartsWith("{"))
ip = "";
var role = parts.Length > 7 ? parts[7].Trim() : "";
var managerStatus = "";
if (string.Equals(role, "manager", StringComparison.OrdinalIgnoreCase))
{
// Determine if this is the leader by checking if ManagerStatus.Addr is non-empty
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();
}
public async Task<List<ServiceLogEntry>> GetServiceLogsAsync(string stackName, string? serviceName = null, int tailLines = 200)
{
EnsureHost();
// Determine which services to fetch logs for
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 cmd = $"docker service logs --timestamps --no-trunc --tail {tailLines} {svcName} 2>&1";
var (exitCode, stdout, _) = await _ssh.RunCommandAsync(_currentHost!, cmd, TimeSpan.FromSeconds(15));
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout))
{
_logger.LogDebug("No logs returned for service {Service} (exit={ExitCode})", svcName, exitCode);
continue;
}
// Parse each line. Docker service logs format with --timestamps:
// <timestamp> <service>.<replica>.<taskid>@<node> | <message>
// or sometimes just:
// <timestamp> <service>.<replica>.<taskid> <message>
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();
}
/// <summary>
/// Parses a single line from <c>docker service logs --timestamps</c> output.
/// </summary>
private static ServiceLogEntry? ParseLogLine(string line, string serviceName, string stackName)
{
if (string.IsNullOrWhiteSpace(line))
return null;
// Format: "2026-02-25T14:30:45.123456789Z service.replica.taskid@node | message"
// The timestamp is always the first space-delimited token when --timestamps is used.
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();
// Try to parse the timestamp
if (!DateTimeOffset.TryParse(timestampStr, out var timestamp))
{
// If timestamp parsing fails, treat the whole line as the message
return new ServiceLogEntry
{
Timestamp = DateTimeOffset.UtcNow,
Source = serviceName,
ServiceName = StripStackPrefix(serviceName, stackName),
Message = line
};
}
// Split source and message on the pipe separator
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
};
}
/// <summary>
/// Strips the stack name prefix from a fully-qualified service name.
/// e.g. "acm-cms-stack_acm-web" → "acm-web"
/// </summary>
private static string StripStackPrefix(string serviceName, string stackName)
{
var prefix = stackName + "_";
return serviceName.StartsWith(prefix) ? serviceName[prefix.Length..] : serviceName;
}
public async Task<bool> RemoveStackVolumesAsync(string stackName)
{
EnsureHost();
// ── 1. Remove the stack first so containers release the volumes ─────
_logger.LogInformation("Removing stack {StackName} before volume cleanup", stackName);
var (rmExit, _, rmErr) = await _ssh.RunCommandAsync(_currentHost!,
$"docker stack rm {stackName} 2>&1 || true");
if (rmExit != 0)
_logger.LogWarning("Stack rm returned non-zero for {StackName}: {Err}", stackName, rmErr);
// Give Swarm a moment to tear down containers on all nodes
await Task.Delay(5000);
// ── 2. Clean volumes on the local (manager) node ────────────────────
var localCmd = $"docker volume ls --filter \"name={stackName}_\" -q | xargs -r docker volume rm 2>&1 || true";
var (_, localOut, _) = await _ssh.RunCommandAsync(_currentHost!, localCmd);
if (!string.IsNullOrEmpty(localOut?.Trim()))
_logger.LogInformation("Volume cleanup (manager): {Output}", localOut!.Trim());
// ── 3. Clean volumes on ALL swarm nodes via a temporary global service ──
// This deploys a short-lived container on every node that mounts the Docker
// socket and removes matching volumes. This handles worker nodes that the
// orchestrator has no direct SSH access to.
var cleanupSvcName = $"vol-cleanup-{stackName}".Replace("_", "-");
// Remove leftover cleanup service from a previous run (if any)
await _ssh.RunCommandAsync(_currentHost!,
$"docker service rm {cleanupSvcName} 2>/dev/null || true");
var createCmd = string.Join(" ",
"docker 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'");
_logger.LogInformation("Deploying global volume-cleanup service on all swarm nodes for {StackName}", stackName);
var (svcExit, svcOut, svcErr) = await _ssh.RunCommandAsync(_currentHost!, createCmd);
if (svcExit != 0)
{
_logger.LogWarning("Global volume cleanup service creation failed: {Err}", svcErr);
}
else
{
// Wait for the cleanup tasks to finish on all nodes
_logger.LogInformation("Waiting for volume cleanup tasks to complete on all nodes...");
await Task.Delay(10000);
}
// Remove the cleanup service
await _ssh.RunCommandAsync(_currentHost!,
$"docker service rm {cleanupSvcName} 2>/dev/null || true");
_logger.LogInformation("Volume cleanup complete for stack {StackName}", stackName);
return true;
}
}

View File

@@ -1,268 +0,0 @@
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
namespace OTSSignsOrchestrator.Desktop.Services;
/// <summary>
/// Stores and retrieves operator JWT and refresh tokens using the OS credential store.
/// Windows: advapi32 Credential Manager; macOS: Security.framework Keychain;
/// Linux: AES-encrypted file fallback in AppData.
/// </summary>
public sealed class TokenStoreService
{
private const string ServiceName = "OTSSignsOrchestrator";
private const string JwtAccount = "operator-jwt";
private const string RefreshAccount = "operator-refresh";
private readonly ILogger<TokenStoreService> _logger;
public TokenStoreService(ILogger<TokenStoreService> logger)
{
_logger = logger;
}
public void StoreTokens(string jwt, string refreshToken)
{
WriteCredential(JwtAccount, jwt);
WriteCredential(RefreshAccount, refreshToken);
_logger.LogDebug("Tokens stored in OS credential store");
}
public string? GetJwt() => ReadCredential(JwtAccount);
public string? GetRefreshToken() => ReadCredential(RefreshAccount);
public void ClearTokens()
{
DeleteCredential(JwtAccount);
DeleteCredential(RefreshAccount);
_logger.LogDebug("Tokens cleared from OS credential store");
}
// ── Platform dispatch ────────────────────────────────────────────────────
private void WriteCredential(string account, string secret)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
WindowsCredentialManager.Write(ServiceName, account, secret);
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
MacKeychain.Write(ServiceName, account, secret);
else
LinuxEncryptedFile.Write(ServiceName, account, secret);
}
private string? ReadCredential(string account)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return WindowsCredentialManager.Read(ServiceName, account);
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
return MacKeychain.Read(ServiceName, account);
return LinuxEncryptedFile.Read(ServiceName, account);
}
private void DeleteCredential(string account)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
WindowsCredentialManager.Delete(ServiceName, account);
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
MacKeychain.Delete(ServiceName, account);
else
LinuxEncryptedFile.Delete(ServiceName, account);
}
// ═══════════════════════════════════════════════════════════════════════
// Windows — advapi32.dll Credential Manager
// ═══════════════════════════════════════════════════════════════════════
private static class WindowsCredentialManager
{
private const int CredTypeGeneric = 1;
private const int CredPersistLocalMachine = 2;
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct CREDENTIAL
{
public uint Flags;
public uint Type;
public string TargetName;
public string Comment;
public long LastWritten;
public uint CredentialBlobSize;
public IntPtr CredentialBlob;
public uint Persist;
public uint AttributeCount;
public IntPtr Attributes;
public string TargetAlias;
public string UserName;
}
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool CredWriteW(ref CREDENTIAL credential, uint flags);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool CredReadW(string target, uint type, uint flags, out IntPtr credential);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool CredDeleteW(string target, uint type, uint flags);
[DllImport("advapi32.dll")]
private static extern void CredFree(IntPtr buffer);
private static string TargetName(string service, string account) => $"{service}/{account}";
public static void Write(string service, string account, string secret)
{
var bytes = Encoding.UTF8.GetBytes(secret);
var handle = GCHandle.Alloc(bytes, GCHandleType.Pinned);
try
{
var cred = new CREDENTIAL
{
Type = CredTypeGeneric,
TargetName = TargetName(service, account),
UserName = account,
CredentialBlob = handle.AddrOfPinnedObject(),
CredentialBlobSize = (uint)bytes.Length,
Persist = CredPersistLocalMachine,
};
CredWriteW(ref cred, 0);
}
finally { handle.Free(); }
}
public static string? Read(string service, string account)
{
if (!CredReadW(TargetName(service, account), CredTypeGeneric, 0, out var credPtr))
return null;
try
{
var cred = Marshal.PtrToStructure<CREDENTIAL>(credPtr);
if (cred.CredentialBlobSize == 0 || cred.CredentialBlob == IntPtr.Zero) return null;
var bytes = new byte[cred.CredentialBlobSize];
Marshal.Copy(cred.CredentialBlob, bytes, 0, bytes.Length);
return Encoding.UTF8.GetString(bytes);
}
finally { CredFree(credPtr); }
}
public static void Delete(string service, string account)
=> CredDeleteW(TargetName(service, account), CredTypeGeneric, 0);
}
// ═══════════════════════════════════════════════════════════════════════
// macOS — Security.framework Keychain via /usr/bin/security CLI
// ═══════════════════════════════════════════════════════════════════════
private static class MacKeychain
{
public static void Write(string service, string account, string secret)
{
// Delete first to avoid "duplicate" errors on update
Delete(service, account);
RunSecurity($"add-generic-password -s \"{service}\" -a \"{account}\" -w \"{EscapeShell(secret)}\" -U");
}
public static string? Read(string service, string account)
{
var (exitCode, stdout) = RunSecurity($"find-generic-password -s \"{service}\" -a \"{account}\" -w");
return exitCode == 0 ? stdout.Trim() : null;
}
public static void Delete(string service, string account)
=> RunSecurity($"delete-generic-password -s \"{service}\" -a \"{account}\"");
private static (int exitCode, string stdout) RunSecurity(string args)
{
using var proc = new System.Diagnostics.Process();
proc.StartInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = "/usr/bin/security",
Arguments = args,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
proc.Start();
var stdout = proc.StandardOutput.ReadToEnd();
proc.WaitForExit(5000);
return (proc.ExitCode, stdout);
}
private static string EscapeShell(string s) => s.Replace("\\", "\\\\").Replace("\"", "\\\"");
}
// ═══════════════════════════════════════════════════════════════════════
// Linux — AES-256-GCM encrypted file in ~/.local/share
// ═══════════════════════════════════════════════════════════════════════
private static class LinuxEncryptedFile
{
// Machine-specific key derived from machine-id + user name
private static byte[] DeriveKey()
{
var machineId = "linux-default";
try
{
if (File.Exists("/etc/machine-id"))
machineId = File.ReadAllText("/etc/machine-id").Trim();
}
catch { /* fallback */ }
var material = $"{machineId}:{Environment.UserName}:{ServiceName}";
return SHA256.HashData(Encoding.UTF8.GetBytes(material));
}
private static string FilePath(string service, string account)
{
var dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
service, "credentials");
Directory.CreateDirectory(dir);
return Path.Combine(dir, $"{account}.enc");
}
public static void Write(string service, string account, string secret)
{
var key = DeriveKey();
var plaintext = Encoding.UTF8.GetBytes(secret);
var nonce = new byte[12];
RandomNumberGenerator.Fill(nonce);
var ciphertext = new byte[plaintext.Length];
var tag = new byte[16];
using var aes = new AesGcm(key, 16);
aes.Encrypt(nonce, plaintext, ciphertext, tag);
// File format: [12 nonce][16 tag][ciphertext]
var output = new byte[12 + 16 + ciphertext.Length];
nonce.CopyTo(output, 0);
tag.CopyTo(output, 12);
ciphertext.CopyTo(output, 28);
File.WriteAllBytes(FilePath(service, account), output);
}
public static string? Read(string service, string account)
{
var path = FilePath(service, account);
if (!File.Exists(path)) return null;
var data = File.ReadAllBytes(path);
if (data.Length < 28) return null;
var nonce = data[..12];
var tag = data[12..28];
var ciphertext = data[28..];
var plaintext = new byte[ciphertext.Length];
using var aes = new AesGcm(DeriveKey(), 16);
aes.Decrypt(nonce, ciphertext, tag, plaintext);
return Encoding.UTF8.GetString(plaintext);
}
public static void Delete(string service, string account)
{
var path = FilePath(service, account);
if (File.Exists(path)) File.Delete(path);
}
}
}

View File

@@ -1,362 +0,0 @@
using System.Collections.ObjectModel;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using OTSSignsOrchestrator.Core.Data;
using OTSSignsOrchestrator.Core.Models.DTOs;
using OTSSignsOrchestrator.Core.Models.Entities;
using OTSSignsOrchestrator.Core.Services;
using OTSSignsOrchestrator.Desktop.Services;
namespace OTSSignsOrchestrator.Desktop.ViewModels;
/// <summary>
/// ViewModel for the Create Instance form.
/// Simplified: Customer Name, Abbreviation, SSH Host, and optional Newt credentials.
/// All other config comes from the Settings page.
/// </summary>
public partial class CreateInstanceViewModel : ObservableObject
{
private readonly IServiceProvider _services;
private readonly MainWindowViewModel _mainVm;
[ObservableProperty] private string _statusMessage = string.Empty;
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string _deployOutput = string.Empty;
[ObservableProperty] private double _progressPercent;
[ObservableProperty] private string _progressStep = string.Empty;
// Core form fields — only these two are required from the user
[ObservableProperty] private string _customerName = string.Empty;
[ObservableProperty] private string _customerAbbrev = string.Empty;
// Optional Pangolin/Newt credentials (per-instance)
[ObservableProperty] private string _newtId = string.Empty;
[ObservableProperty] private string _newtSecret = string.Empty;
// NFS volume settings (per-instance, defaults loaded from global settings)
[ObservableProperty] private string _nfsServer = string.Empty;
[ObservableProperty] private string _nfsExport = string.Empty;
[ObservableProperty] private string _nfsExportFolder = string.Empty;
[ObservableProperty] private string _nfsExtraOptions = string.Empty;
/// <summary>When enabled, existing Docker volumes for the stack are removed before deploying.</summary>
[ObservableProperty] private bool _purgeStaleVolumes = false;
// SSH host selection
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
[ObservableProperty] private SshHost? _selectedSshHost;
// YML preview
[ObservableProperty] private string _previewYml = string.Empty;
[ObservableProperty] private bool _isLoadingYml;
public bool HasPreviewYml => !string.IsNullOrEmpty(PreviewYml);
partial void OnPreviewYmlChanged(string value) => OnPropertyChanged(nameof(HasPreviewYml));
// ── Derived preview properties ───────────────────────────────────────────
public string PreviewStackName => Valid ? $"{Abbrev}-cms-stack" : "—";
public string PreviewServiceWeb => Valid ? $"{Abbrev}-web" : "—";
public string PreviewServiceCache => Valid ? $"{Abbrev}-memcached" : "—";
public string PreviewServiceChart => Valid ? $"{Abbrev}-quickchart" : "—";
public string PreviewServiceNewt => Valid ? $"{Abbrev}-newt" : "—";
public string PreviewNetwork => Valid ? $"{Abbrev}-net" : "—";
public string PreviewVolCustom => Valid ? $"{Abbrev}/cms-custom" : "—";
public string PreviewVolBackup => Valid ? $"{Abbrev}/cms-backup" : "—";
public string PreviewVolLibrary => Valid ? $"{Abbrev}/cms-library" : "—";
public string PreviewVolUserscripts => Valid ? $"{Abbrev}/cms-userscripts": "—";
public string PreviewVolCaCerts => Valid ? $"{Abbrev}/cms-ca-certs" : "—";
public string PreviewSecret => Valid ? $"{Abbrev}-cms-db-password": "—";
public string PreviewSecretUser => Valid ? $"{Abbrev}-cms-db-user" : "—";
public string PreviewSecretHost => "global_mysql_host";
public string PreviewSecretPort => "global_mysql_port";
public string PreviewMySqlDb => Valid ? $"{Abbrev}_cms_db" : "—";
public string PreviewMySqlUser => Valid ? $"{Abbrev}_cms_user" : "—";
public string PreviewCmsUrl => Valid ? $"https://{Abbrev}.ots-signs.com" : "—";
private string Abbrev => CustomerAbbrev.Trim().ToLowerInvariant();
private bool Valid => Abbrev.Length == 3 && System.Text.RegularExpressions.Regex.IsMatch(Abbrev, "^[a-z]{3}$");
// ─────────────────────────────────────────────────────────────────────────
public CreateInstanceViewModel(IServiceProvider services, MainWindowViewModel mainVm)
{
_services = services;
_mainVm = mainVm;
_ = LoadHostsAsync();
_ = LoadNfsDefaultsAsync();
}
partial void OnCustomerAbbrevChanged(string value) => RefreshPreview();
private void RefreshPreview()
{
OnPropertyChanged(nameof(PreviewStackName));
OnPropertyChanged(nameof(PreviewServiceWeb));
OnPropertyChanged(nameof(PreviewServiceCache));
OnPropertyChanged(nameof(PreviewServiceChart));
OnPropertyChanged(nameof(PreviewServiceNewt));
OnPropertyChanged(nameof(PreviewNetwork));
OnPropertyChanged(nameof(PreviewVolCustom));
OnPropertyChanged(nameof(PreviewVolBackup));
OnPropertyChanged(nameof(PreviewVolLibrary));
OnPropertyChanged(nameof(PreviewVolUserscripts));
OnPropertyChanged(nameof(PreviewVolCaCerts));
OnPropertyChanged(nameof(PreviewSecret));
OnPropertyChanged(nameof(PreviewSecretUser));
OnPropertyChanged(nameof(PreviewSecretHost));
OnPropertyChanged(nameof(PreviewSecretPort));
OnPropertyChanged(nameof(PreviewMySqlDb));
OnPropertyChanged(nameof(PreviewMySqlUser));
OnPropertyChanged(nameof(PreviewCmsUrl));
}
private async Task LoadHostsAsync()
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync();
AvailableHosts = new ObservableCollection<SshHost>(hosts);
}
private async Task LoadNfsDefaultsAsync()
{
using var scope = _services.CreateScope();
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
NfsServer = await settings.GetAsync(SettingsService.NfsServer) ?? string.Empty;
NfsExport = await settings.GetAsync(SettingsService.NfsExport) ?? string.Empty;
NfsExportFolder = await settings.GetAsync(SettingsService.NfsExportFolder) ?? string.Empty;
NfsExtraOptions = await settings.GetAsync(SettingsService.NfsOptions) ?? string.Empty;
}
[RelayCommand]
private async Task LoadYmlPreviewAsync()
{
if (!Valid)
{
PreviewYml = "# Abbreviation must be exactly 3 lowercase letters (a-z) before loading the YML preview.";
return;
}
IsLoadingYml = true;
try
{
using var scope = _services.CreateScope();
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
var git = scope.ServiceProvider.GetRequiredService<GitTemplateService>();
var composer = scope.ServiceProvider.GetRequiredService<ComposeRenderService>();
var repoUrl = await settings.GetAsync(SettingsService.GitRepoUrl);
var repoPat = await settings.GetAsync(SettingsService.GitRepoPat);
if (string.IsNullOrWhiteSpace(repoUrl))
{
PreviewYml = "# Git template repository URL is not configured. Set it in Settings → Git Repo URL.";
return;
}
var templateConfig = await git.FetchAsync(repoUrl, repoPat);
var abbrev = Abbrev;
var stackName = $"{abbrev}-cms-stack";
var mySqlHost = await settings.GetAsync(SettingsService.MySqlHost, "localhost");
var mySqlPort = await settings.GetAsync(SettingsService.MySqlPort, "3306");
var mySqlDbName = (await settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev);
var mySqlUser = (await settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user")).Replace("{abbrev}", abbrev);
var cmsServerName = (await settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com")).Replace("{abbrev}", abbrev);
var themePath = (await settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev);
var smtpServer = await settings.GetAsync(SettingsService.SmtpServer, string.Empty);
var smtpUsername = await settings.GetAsync(SettingsService.SmtpUsername, string.Empty);
var smtpPassword = await settings.GetAsync(SettingsService.SmtpPassword, string.Empty);
var smtpUseTls = await settings.GetAsync(SettingsService.SmtpUseTls, "YES");
var smtpUseStartTls = await settings.GetAsync(SettingsService.SmtpUseStartTls, "YES");
var smtpRewriteDomain = await settings.GetAsync(SettingsService.SmtpRewriteDomain, string.Empty);
var smtpHostname = await settings.GetAsync(SettingsService.SmtpHostname, string.Empty);
var smtpFromLineOverride = await settings.GetAsync(SettingsService.SmtpFromLineOverride, "NO");
var pangolinEndpoint = await settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
var cmsImage = await settings.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3");
var newtImage = await settings.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt");
var memcachedImage = await settings.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine");
var quickChartImage = await settings.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart");
var phpPostMaxSize = await settings.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G");
var phpUploadMaxFilesize = await settings.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
var phpMaxExecutionTime = await settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
// Use form values; fall back to saved global settings
var nfsServer = string.IsNullOrWhiteSpace(NfsServer) ? await settings.GetAsync(SettingsService.NfsServer) : NfsServer;
var nfsExport = string.IsNullOrWhiteSpace(NfsExport) ? await settings.GetAsync(SettingsService.NfsExport) : NfsExport;
var nfsExportFolder = string.IsNullOrWhiteSpace(NfsExportFolder) ? await settings.GetAsync(SettingsService.NfsExportFolder) : NfsExportFolder;
var nfsOptions = string.IsNullOrWhiteSpace(NfsExtraOptions) ? await settings.GetAsync(SettingsService.NfsOptions, string.Empty) : NfsExtraOptions;
var ctx = new RenderContext
{
CustomerName = CustomerName.Trim(),
CustomerAbbrev = abbrev,
StackName = stackName,
CmsServerName = cmsServerName,
HostHttpPort = 80,
CmsImage = cmsImage,
MemcachedImage = memcachedImage,
QuickChartImage = quickChartImage,
NewtImage = newtImage,
ThemeHostPath = themePath,
MySqlHost = mySqlHost,
MySqlPort = mySqlPort,
MySqlDatabase = mySqlDbName,
MySqlUser = mySqlUser,
SmtpServer = smtpServer,
SmtpUsername = smtpUsername,
SmtpPassword = smtpPassword,
SmtpUseTls = smtpUseTls,
SmtpUseStartTls = smtpUseStartTls,
SmtpRewriteDomain = smtpRewriteDomain,
SmtpHostname = smtpHostname,
SmtpFromLineOverride = smtpFromLineOverride,
PhpPostMaxSize = phpPostMaxSize,
PhpUploadMaxFilesize = phpUploadMaxFilesize,
PhpMaxExecutionTime = phpMaxExecutionTime,
PangolinEndpoint = pangolinEndpoint,
NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(),
NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.Trim(),
NfsServer = nfsServer,
NfsExport = nfsExport,
NfsExportFolder = nfsExportFolder,
NfsExtraOptions = nfsOptions,
};
PreviewYml = composer.Render(templateConfig.Yaml, ctx);
}
catch (Exception ex)
{
PreviewYml = $"# Error rendering YML preview:\n# {ex.Message}";
}
finally
{
IsLoadingYml = false;
}
}
[RelayCommand]
private async Task CopyYmlAsync()
{
if (string.IsNullOrEmpty(PreviewYml)) return;
var mainWindow = (Application.Current?.ApplicationLifetime
as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
if (mainWindow is null) return;
var clipboard = TopLevel.GetTopLevel(mainWindow)?.Clipboard;
if (clipboard is not null)
await clipboard.SetTextAsync(PreviewYml);
}
[RelayCommand]
private async Task DeployAsync()
{
// ── Validation ───────────────────────────────────────────────────
if (SelectedSshHost == null)
{
StatusMessage = "Select an SSH host first.";
return;
}
if (string.IsNullOrWhiteSpace(CustomerName))
{
StatusMessage = "Customer Name is required.";
return;
}
if (!Valid)
{
StatusMessage = "Abbreviation must be exactly 3 lowercase letters (a-z).";
return;
}
IsBusy = true;
StatusMessage = "Starting deployment...";
DeployOutput = string.Empty;
ProgressPercent = 0;
try
{
// Wire SSH host into docker services (singletons must know the target host before
// InstanceService uses them internally for secrets and CLI operations)
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
dockerCli.SetHost(SelectedSshHost);
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
dockerSecrets.SetHost(SelectedSshHost);
using var scope = _services.CreateScope();
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
// InstanceService.CreateInstanceAsync handles the full provisioning flow:
// 1. Clone template repo
// 2. Generate MySQL password → create Docker Swarm secret
// 3. Create MySQL database + SQL user (same password as the secret)
// 4. Render compose YAML → deploy stack
SetProgress(30, "Provisioning instance (MySQL user, secrets, stack)...");
var dto = new CreateInstanceDto
{
CustomerName = CustomerName.Trim(),
CustomerAbbrev = Abbrev,
SshHostId = SelectedSshHost.Id,
NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(),
NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.Trim(),
NfsServer = string.IsNullOrWhiteSpace(NfsServer) ? null : NfsServer.Trim(),
NfsExport = string.IsNullOrWhiteSpace(NfsExport) ? null : NfsExport.Trim(),
NfsExportFolder = string.IsNullOrWhiteSpace(NfsExportFolder) ? null : NfsExportFolder.Trim(),
NfsExtraOptions = string.IsNullOrWhiteSpace(NfsExtraOptions) ? null : NfsExtraOptions.Trim(),
PurgeStaleVolumes = PurgeStaleVolumes,
};
var result = await instanceSvc.CreateInstanceAsync(dto);
AppendOutput(result.Output ?? string.Empty);
if (result.Success)
{
SetProgress(100, "Stack deployed successfully.");
StatusMessage = $"Instance '{Abbrev}-cms-stack' deployed in {result.DurationMs}ms. " +
"Open the details pane on the Instances page to complete setup.";
_mainVm.NavigateToInstancesWithSelection(Abbrev);
}
else
{
SetProgress(0, "Deployment failed.");
StatusMessage = $"Deploy failed: {result.ErrorMessage}";
}
}
catch (Exception ex)
{
StatusMessage = $"Error: {ex.Message}";
AppendOutput(ex.ToString());
SetProgress(0, "Failed.");
}
finally
{
IsBusy = false;
}
}
private void SetProgress(double pct, string step)
{
ProgressPercent = pct;
ProgressStep = step;
AppendOutput($"[{pct:0}%] {step}");
}
private void AppendOutput(string text)
{
if (!string.IsNullOrWhiteSpace(text))
DeployOutput += (DeployOutput.Length > 0 ? "\n" : "") + text;
}
}

View File

@@ -1,276 +0,0 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using OTSSignsOrchestrator.Core.Data;
using OTSSignsOrchestrator.Core.Models.DTOs;
using OTSSignsOrchestrator.Core.Models.Entities;
using OTSSignsOrchestrator.Desktop.Services;
namespace OTSSignsOrchestrator.Desktop.ViewModels;
/// <summary>
/// ViewModel for managing SSH host connections.
/// Allows adding, editing, testing, and removing remote Docker Swarm hosts.
/// </summary>
public partial class HostsViewModel : ObservableObject
{
private readonly IServiceProvider _services;
[ObservableProperty] private ObservableCollection<SshHost> _hosts = new();
[ObservableProperty] private SshHost? _selectedHost;
[ObservableProperty] private bool _isEditing;
[ObservableProperty] private string _statusMessage = string.Empty;
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private ObservableCollection<NodeInfo> _remoteNodes = new();
[ObservableProperty] private string _nodesStatusMessage = string.Empty;
// Edit form fields
[ObservableProperty] private string _editLabel = string.Empty;
[ObservableProperty] private string _editHost = string.Empty;
[ObservableProperty] private int _editPort = 22;
[ObservableProperty] private string _editUsername = string.Empty;
[ObservableProperty] private string _editPrivateKeyPath = string.Empty;
[ObservableProperty] private string _editKeyPassphrase = string.Empty;
[ObservableProperty] private string _editPassword = string.Empty;
[ObservableProperty] private bool _editUseKeyAuth = true;
private Guid? _editingHostId;
public HostsViewModel(IServiceProvider services)
{
_services = services;
_ = LoadHostsAsync();
}
[RelayCommand]
private async Task LoadHostsAsync()
{
IsBusy = true;
try
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync();
Hosts = new ObservableCollection<SshHost>(hosts);
StatusMessage = $"Loaded {hosts.Count} host(s).";
}
catch (Exception ex)
{
StatusMessage = $"Error loading hosts: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private void NewHost()
{
_editingHostId = null;
EditLabel = string.Empty;
EditHost = string.Empty;
EditPort = 22;
EditUsername = string.Empty;
EditPrivateKeyPath = string.Empty;
EditKeyPassphrase = string.Empty;
EditPassword = string.Empty;
EditUseKeyAuth = true;
IsEditing = true;
}
[RelayCommand]
private void EditSelectedHost()
{
if (SelectedHost == null) return;
_editingHostId = SelectedHost.Id;
EditLabel = SelectedHost.Label;
EditHost = SelectedHost.Host;
EditPort = SelectedHost.Port;
EditUsername = SelectedHost.Username;
EditPrivateKeyPath = SelectedHost.PrivateKeyPath ?? string.Empty;
EditKeyPassphrase = string.Empty; // Don't show existing passphrase
EditPassword = string.Empty; // Don't show existing password
EditUseKeyAuth = SelectedHost.UseKeyAuth;
IsEditing = true;
}
[RelayCommand]
private void CancelEdit()
{
IsEditing = false;
}
[RelayCommand]
private async Task SaveHostAsync()
{
if (string.IsNullOrWhiteSpace(EditLabel) || string.IsNullOrWhiteSpace(EditHost) || string.IsNullOrWhiteSpace(EditUsername))
{
StatusMessage = "Label, Host, and Username are required.";
return;
}
IsBusy = true;
try
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
SshHost host;
if (_editingHostId.HasValue)
{
host = await db.SshHosts.FindAsync(_editingHostId.Value)
?? throw new KeyNotFoundException("Host not found.");
host.Label = EditLabel;
host.Host = EditHost;
host.Port = EditPort;
host.Username = EditUsername;
host.PrivateKeyPath = string.IsNullOrWhiteSpace(EditPrivateKeyPath) ? null : EditPrivateKeyPath;
host.UseKeyAuth = EditUseKeyAuth;
host.UpdatedAt = DateTime.UtcNow;
if (!string.IsNullOrEmpty(EditKeyPassphrase))
host.KeyPassphrase = EditKeyPassphrase;
if (!string.IsNullOrEmpty(EditPassword))
host.Password = EditPassword;
}
else
{
host = new SshHost
{
Label = EditLabel,
Host = EditHost,
Port = EditPort,
Username = EditUsername,
PrivateKeyPath = string.IsNullOrWhiteSpace(EditPrivateKeyPath) ? null : EditPrivateKeyPath,
KeyPassphrase = string.IsNullOrEmpty(EditKeyPassphrase) ? null : EditKeyPassphrase,
Password = string.IsNullOrEmpty(EditPassword) ? null : EditPassword,
UseKeyAuth = EditUseKeyAuth
};
db.SshHosts.Add(host);
}
await db.SaveChangesAsync();
IsEditing = false;
StatusMessage = $"Host '{host.Label}' saved.";
await LoadHostsAsync();
}
catch (Exception ex)
{
StatusMessage = $"Error saving host: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task DeleteHostAsync()
{
if (SelectedHost == null) return;
IsBusy = true;
try
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
var host = await db.SshHosts.FindAsync(SelectedHost.Id);
if (host != null)
{
db.SshHosts.Remove(host);
await db.SaveChangesAsync();
}
// Disconnect if connected
var ssh = _services.GetRequiredService<SshConnectionService>();
ssh.Disconnect(SelectedHost.Id);
StatusMessage = $"Host '{SelectedHost.Label}' deleted.";
await LoadHostsAsync();
}
catch (Exception ex)
{
StatusMessage = $"Error deleting host: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task ListNodesAsync()
{
if (SelectedHost == null)
{
NodesStatusMessage = "Select a host first.";
return;
}
IsBusy = true;
NodesStatusMessage = $"Listing nodes on {SelectedHost.Label}...";
try
{
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
dockerCli.SetHost(SelectedHost);
var nodes = await dockerCli.ListNodesAsync();
RemoteNodes = new ObservableCollection<NodeInfo>(nodes);
NodesStatusMessage = $"Found {nodes.Count} node(s) on {SelectedHost.Label}.";
}
catch (Exception ex)
{
RemoteNodes.Clear();
NodesStatusMessage = $"Error: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task TestConnectionAsync()
{
if (SelectedHost == null) return;
IsBusy = true;
StatusMessage = $"Testing connection to {SelectedHost.Label}...";
try
{
var ssh = _services.GetRequiredService<SshConnectionService>();
var (success, message) = await ssh.TestConnectionAsync(SelectedHost);
// Update DB
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
var host = await db.SshHosts.FindAsync(SelectedHost.Id);
if (host != null)
{
host.LastTestedAt = DateTime.UtcNow;
host.LastTestSuccess = success;
await db.SaveChangesAsync();
}
StatusMessage = success
? $"✓ {SelectedHost.Label}: {message}"
: $"✗ {SelectedHost.Label}: {message}";
await LoadHostsAsync();
}
catch (Exception ex)
{
StatusMessage = $"Connection test error: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View File

@@ -1,387 +0,0 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using OTSSignsOrchestrator.Core.Models.DTOs;
using OTSSignsOrchestrator.Core.Models.Entities;
using OTSSignsOrchestrator.Core.Services;
using OTSSignsOrchestrator.Desktop.Models;
using OTSSignsOrchestrator.Desktop.Services;
namespace OTSSignsOrchestrator.Desktop.ViewModels;
/// <summary>
/// ViewModel for the instance details modal.
/// Shows admin credentials, DB credentials, and OAuth2 app details
/// with options to rotate passwords.
/// </summary>
public partial class InstanceDetailsViewModel : ObservableObject
{
private readonly IServiceProvider _services;
// ── Instance metadata ─────────────────────────────────────────────────────
[ObservableProperty] private string _stackName = string.Empty;
[ObservableProperty] private string _customerAbbrev = string.Empty;
[ObservableProperty] private string _hostLabel = string.Empty;
[ObservableProperty] private string _instanceUrl = string.Empty;
// ── OTS admin credentials ─────────────────────────────────────────────────
[ObservableProperty] private string _adminUsername = string.Empty;
[ObservableProperty] private string _adminPassword = string.Empty;
[ObservableProperty] private bool _adminPasswordVisible = false;
[ObservableProperty] private string _adminPasswordDisplay = "••••••••";
// ── Database credentials ──────────────────────────────────────────────────
[ObservableProperty] private string _dbUsername = string.Empty;
[ObservableProperty] private string _dbPassword = string.Empty;
[ObservableProperty] private bool _dbPasswordVisible = false;
[ObservableProperty] private string _dbPasswordDisplay = "••••••••";
// ── OAuth2 application ────────────────────────────────────────────────────
[ObservableProperty] private string _oAuthClientId = string.Empty;
[ObservableProperty] private string _oAuthClientSecret = string.Empty;
[ObservableProperty] private bool _oAuthSecretVisible = false;
[ObservableProperty] private string _oAuthSecretDisplay = "••••••••";
// ── Status ────────────────────────────────────────────────────────────────
[ObservableProperty] private string _statusMessage = string.Empty;
[ObservableProperty] private bool _isBusy;
// ── Pending-setup inputs (shown when instance hasn't been initialised yet) ────────────
[ObservableProperty] private bool _isPendingSetup;
[ObservableProperty] private string _initClientId = string.Empty;
[ObservableProperty] private string _initClientSecret = string.Empty;
// ── Services (for restart) ─────────────────────────────────────────────────────
[ObservableProperty] private ObservableCollection<ServiceInfo> _stackServices = new();
[ObservableProperty] private bool _isLoadingServices;
/// <summary>
/// Callback the View wires up to show a confirmation dialog.
/// Parameters: (title, message) → returns true if the user confirmed.
/// </summary>
public Func<string, string, Task<bool>>? ConfirmAsync { get; set; }
// Cached instance — needed by InitializeCommand to reload after setup
private LiveStackItem? _currentInstance;
public InstanceDetailsViewModel(IServiceProvider services)
{
_services = services;
}
// ─────────────────────────────────────────────────────────────────────────
// Load
// ─────────────────────────────────────────────────────────────────────────
/// <summary>Populates the ViewModel from a live <see cref="LiveStackItem"/>.</summary>
public async Task LoadAsync(LiveStackItem instance)
{
_currentInstance = instance;
StackName = instance.StackName;
CustomerAbbrev = instance.CustomerAbbrev;
HostLabel = instance.HostLabel;
IsBusy = true;
StatusMessage = "Loading credentials...";
try
{
using var scope = _services.CreateScope();
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
var postInit = scope.ServiceProvider.GetRequiredService<PostInstanceInitService>();
// Derive the instance URL from the CMS server name template
var serverTemplate = await settings.GetAsync(
SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com");
var serverName = serverTemplate.Replace("{abbrev}", instance.CustomerAbbrev);
InstanceUrl = $"https://{serverName}/{instance.CustomerAbbrev.Trim().ToLowerInvariant()}";
// ── Admin credentials ─────────────────────────────────────────
var creds = await postInit.GetCredentialsAsync(instance.CustomerAbbrev);
AdminUsername = creds.AdminUsername;
SetAdminPassword(creds.AdminPassword ?? string.Empty);
OAuthClientId = creds.OAuthClientId ?? string.Empty;
SetOAuthSecret(creds.OAuthClientSecret ?? string.Empty);
// ── DB credentials ────────────────────────────────────────────
var mySqlUserTemplate = await settings.GetAsync(
SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user");
DbUsername = mySqlUserTemplate.Replace("{abbrev}", instance.CustomerAbbrev);
var dbPw = await settings.GetAsync(SettingsService.InstanceMySqlPassword(instance.CustomerAbbrev));
SetDbPassword(dbPw ?? string.Empty);
StatusMessage = creds.HasAdminPassword
? "Credentials loaded."
: "Pending setup — enter your Xibo OAuth credentials below to initialise this instance.";
IsPendingSetup = !creds.HasAdminPassword;
// Clear any previous init inputs when re-loading
if (IsPendingSetup)
{
InitClientId = string.Empty;
InitClientSecret = string.Empty;
}
// ── Load stack services ───────────────────────────────────────
await LoadServicesAsync();
}
catch (Exception ex)
{
StatusMessage = $"Error loading credentials: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
// ─────────────────────────────────────────────────────────────────────────
// Initialise (pending setup)
// ─────────────────────────────────────────────────────────────────────────
[RelayCommand]
private async Task InitializeAsync()
{
if (string.IsNullOrWhiteSpace(InitClientId) || string.IsNullOrWhiteSpace(InitClientSecret))
{
StatusMessage = "Both Client ID and Client Secret are required.";
return;
}
if (_currentInstance is null) return;
IsBusy = true;
StatusMessage = "Waiting for Xibo and running initialisation (this may take several minutes)...";
try
{
var postInit = _services.GetRequiredService<PostInstanceInitService>();
await postInit.InitializeWithOAuthAsync(
CustomerAbbrev,
InstanceUrl,
InitClientId.Trim(),
InitClientSecret.Trim());
// Reload credentials — IsPendingSetup will flip to false
IsBusy = false;
await LoadAsync(_currentInstance);
}
catch (Exception ex)
{
StatusMessage = $"Initialisation failed: {ex.Message}";
IsBusy = false;
}
}
// ─────────────────────────────────────────────────────────────────────────
// Visibility toggles
// ─────────────────────────────────────────────────────────────────────────
[RelayCommand]
private void ToggleAdminPasswordVisibility()
{
AdminPasswordVisible = !AdminPasswordVisible;
AdminPasswordDisplay = AdminPasswordVisible
? AdminPassword
: (AdminPassword.Length > 0 ? "••••••••" : "(not set)");
}
[RelayCommand]
private void ToggleDbPasswordVisibility()
{
DbPasswordVisible = !DbPasswordVisible;
DbPasswordDisplay = DbPasswordVisible
? DbPassword
: (DbPassword.Length > 0 ? "••••••••" : "(not set)");
}
[RelayCommand]
private void ToggleOAuthSecretVisibility()
{
OAuthSecretVisible = !OAuthSecretVisible;
OAuthSecretDisplay = OAuthSecretVisible
? OAuthClientSecret
: (OAuthClientSecret.Length > 0 ? "••••••••" : "(not set)");
}
// ─────────────────────────────────────────────────────────────────────────
// Clipboard
// ─────────────────────────────────────────────────────────────────────────
[RelayCommand]
private async Task CopyAdminPasswordAsync()
=> await CopyToClipboardAsync(AdminPassword, "Admin password");
[RelayCommand]
private async Task CopyDbPasswordAsync()
=> await CopyToClipboardAsync(DbPassword, "DB password");
[RelayCommand]
private async Task CopyOAuthClientIdAsync()
=> await CopyToClipboardAsync(OAuthClientId, "OAuth client ID");
[RelayCommand]
private async Task CopyOAuthSecretAsync()
=> await CopyToClipboardAsync(OAuthClientSecret, "OAuth client secret");
// ─────────────────────────────────────────────────────────────────────────
// Rotation
// ─────────────────────────────────────────────────────────────────────────
[RelayCommand]
private async Task RestartServiceAsync(ServiceInfo? service)
{
if (service is null || _currentInstance is null) return;
if (ConfirmAsync is not null)
{
var confirmed = await ConfirmAsync(
"Restart Service",
$"Are you sure you want to restart '{service.Name}'?\n\nThis will force-update the service, causing its tasks to be recreated.");
if (!confirmed) return;
}
IsBusy = true;
StatusMessage = $"Restarting service '{service.Name}'...";
try
{
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
var ok = await dockerCli.ForceUpdateServiceAsync(service.Name);
StatusMessage = ok
? $"Service '{service.Name}' restarted successfully."
: $"Failed to restart service '{service.Name}'.";
// Refresh service list to show updated replica status
await LoadServicesAsync();
}
catch (Exception ex)
{
StatusMessage = $"Error restarting service: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
private async Task LoadServicesAsync()
{
if (_currentInstance is null) return;
IsLoadingServices = true;
try
{
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
dockerCli.SetHost(_currentInstance.Host);
var services = await dockerCli.InspectStackServicesAsync(_currentInstance.StackName);
StackServices = new ObservableCollection<ServiceInfo>(services);
}
catch (Exception ex)
{
StatusMessage = $"Error loading services: {ex.Message}";
}
finally
{
IsLoadingServices = false;
}
}
[RelayCommand]
private async Task RotateAdminPasswordAsync()
{
if (string.IsNullOrWhiteSpace(CustomerAbbrev)) return;
IsBusy = true;
StatusMessage = "Rotating OTS admin password...";
try
{
var postInit = _services.GetRequiredService<PostInstanceInitService>();
var newPassword = await postInit.RotateAdminPasswordAsync(CustomerAbbrev, InstanceUrl);
SetAdminPassword(newPassword);
StatusMessage = "Admin password rotated successfully.";
}
catch (Exception ex)
{
StatusMessage = $"Error rotating admin password: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task RotateDbPasswordAsync()
{
if (string.IsNullOrWhiteSpace(CustomerAbbrev)) return;
IsBusy = true;
StatusMessage = $"Rotating MySQL password for {StackName}...";
try
{
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
// We need the Host — retrieve from the HostLabel lookup
using var scope = _services.CreateScope();
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
// Get the host from the loaded stack — caller must have set the SSH host before
var (ok, msg) = await instanceSvc.RotateMySqlPasswordAsync(StackName);
if (ok)
{
// Reload DB password
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
var dbPw = await settings.GetAsync(SettingsService.InstanceMySqlPassword(CustomerAbbrev));
SetDbPassword(dbPw ?? string.Empty);
StatusMessage = $"DB password rotated: {msg}";
}
else
{
StatusMessage = $"DB rotation failed: {msg}";
}
}
catch (Exception ex)
{
StatusMessage = $"Error rotating DB password: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
private void SetAdminPassword(string value)
{
AdminPassword = value;
AdminPasswordVisible = false;
AdminPasswordDisplay = value.Length > 0 ? "••••••••" : "(not set)";
}
private void SetDbPassword(string value)
{
DbPassword = value;
DbPasswordVisible = false;
DbPasswordDisplay = value.Length > 0 ? "••••••••" : "(not set)";
}
private void SetOAuthSecret(string value)
{
OAuthClientSecret = value;
OAuthSecretVisible = false;
OAuthSecretDisplay = value.Length > 0 ? "••••••••" : "(not set)";
}
private static async Task CopyToClipboardAsync(string text, string label)
{
if (string.IsNullOrEmpty(text)) return;
var topLevel = Avalonia.Application.Current?.ApplicationLifetime is
Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime dt
? dt.MainWindow
: null;
var clipboard = topLevel is not null ? Avalonia.Controls.TopLevel.GetTopLevel(topLevel)?.Clipboard : null;
if (clipboard is not null)
await clipboard.SetTextAsync(text);
}
}

View File

@@ -1,627 +0,0 @@
using System.Collections.ObjectModel;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using OTSSignsOrchestrator.Core.Data;
using OTSSignsOrchestrator.Core.Models.DTOs;
using OTSSignsOrchestrator.Core.Models.Entities;
using OTSSignsOrchestrator.Core.Services;
using OTSSignsOrchestrator.Desktop.Models;
using OTSSignsOrchestrator.Desktop.Services;
namespace OTSSignsOrchestrator.Desktop.ViewModels;
/// <summary>
/// ViewModel for listing, viewing, and managing CMS instances.
/// All data is fetched live from Docker Swarm hosts — nothing stored locally.
/// Server operations (decommission, suspend, reactivate) go through the REST API.
/// Real-time updates arrive via SignalR → WeakReferenceMessenger.
/// </summary>
public partial class InstancesViewModel : ObservableObject,
IRecipient<AlertRaisedMessage>,
IRecipient<InstanceStatusChangedMessage>,
IRecipient<JobCreatedMessage>,
IRecipient<JobCompletedMessage>
{
private readonly IServiceProvider _services;
private readonly ILogger<InstancesViewModel> _logger;
private readonly IServerApiClient? _serverApi;
[ObservableProperty] private ObservableCollection<LiveStackItem> _instances = new();
[ObservableProperty] private LiveStackItem? _selectedInstance;
[ObservableProperty] private string _statusMessage = string.Empty;
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string _filterText = string.Empty;
[ObservableProperty] private ObservableCollection<ServiceInfo> _selectedServices = new();
// Available SSH hosts — loaded for display and used to scope operations
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
[ObservableProperty] private SshHost? _selectedSshHost;
// ── P1 Authentik Banner ──────────────────────────────────────────────────
[ObservableProperty] private bool _isAuthentikP1BannerVisible;
[ObservableProperty] private string _authentikP1Message = string.Empty;
// ── Container Logs ──────────────────────────────────────────────────────
[ObservableProperty] private ObservableCollection<ServiceLogEntry> _logEntries = new();
[ObservableProperty] private ObservableCollection<string> _logServiceFilter = new();
[ObservableProperty] private string _selectedLogService = "All Services";
[ObservableProperty] private bool _isLogsPanelVisible;
[ObservableProperty] private bool _isLogsAutoRefresh = true;
[ObservableProperty] private bool _isLoadingLogs;
[ObservableProperty] private string _logsStatusMessage = string.Empty;
[ObservableProperty] private int _logTailLines = 200;
private DispatcherTimer? _logRefreshTimer;
private bool _isLogRefreshRunning;
/// <summary>Raised when the instance details modal should be opened for the given ViewModel.</summary>
public event Action<InstanceDetailsViewModel>? OpenDetailsRequested;
/// <summary>
/// Callback the View wires up to show a confirmation dialog.
/// Parameters: (title, message) → returns true if the user confirmed.
/// </summary>
public Func<string, string, Task<bool>>? ConfirmAsync { get; set; }
/// <summary>
/// Callback the View wires up to show a multi-step confirmation dialog for decommission.
/// Parameters: (abbreviation) → returns true if confirmed through all steps.
/// </summary>
public Func<string, Task<bool>>? ConfirmDecommissionAsync { get; set; }
private string? _pendingSelectAbbrev;
public InstancesViewModel(IServiceProvider services)
{
_services = services;
_logger = services.GetRequiredService<ILogger<InstancesViewModel>>();
_serverApi = services.GetService<IServerApiClient>();
// Register for SignalR messages via WeakReferenceMessenger
WeakReferenceMessenger.Default.Register<AlertRaisedMessage>(this);
WeakReferenceMessenger.Default.Register<InstanceStatusChangedMessage>(this);
WeakReferenceMessenger.Default.Register<JobCreatedMessage>(this);
WeakReferenceMessenger.Default.Register<JobCompletedMessage>(this);
_ = RefreshAllAsync();
}
/// <summary>
/// Queues an abbreviation to be auto-selected once the next live refresh completes.
/// Call immediately after construction (before <see cref="RefreshAllAsync"/> finishes).
/// </summary>
public void SetPendingSelection(string abbrev)
=> _pendingSelectAbbrev = abbrev;
// ── SignalR Message Handlers ─────────────────────────────────────────────
// These are called on the UI thread (SignalR handlers dispatch via Dispatcher.UIThread).
void IRecipient<AlertRaisedMessage>.Receive(AlertRaisedMessage message)
{
var (severity, msg) = message.Value;
if (string.Equals(severity, "Critical", StringComparison.OrdinalIgnoreCase) &&
msg.Contains("Authentik", StringComparison.OrdinalIgnoreCase))
{
AuthentikP1Message = msg;
IsAuthentikP1BannerVisible = true;
}
}
void IRecipient<InstanceStatusChangedMessage>.Receive(InstanceStatusChangedMessage message)
{
var (customerId, status) = message.Value;
_logger.LogInformation("Instance status changed: customer={CustomerId} status={Status}", customerId, status);
StatusMessage = $"Instance {customerId} status → {status}";
// Refresh the list to pick up the new status
_ = RefreshAllAsync();
}
void IRecipient<JobCreatedMessage>.Receive(JobCreatedMessage message)
{
var (jobId, abbrev, jobType) = message.Value;
_logger.LogInformation("Job created: {JobId} type={JobType} abbrev={Abbrev}", jobId, jobType, abbrev);
StatusMessage = $"Job '{jobType}' created for {abbrev} (id: {jobId[..8]}…)";
}
void IRecipient<JobCompletedMessage>.Receive(JobCompletedMessage message)
{
var (jobId, success, summary) = message.Value;
_logger.LogInformation("Job completed: {JobId} success={Success} summary={Summary}", jobId, success, summary);
StatusMessage = success
? $"Job {jobId[..8]}… completed: {summary}"
: $"Job {jobId[..8]}… failed: {summary}";
// Refresh the instance list to reflect changes from the completed job
_ = RefreshAllAsync();
}
// ── Load / Refresh ──────────────────────────────────────────────────────
/// <summary>
/// Enumerates all SSH hosts, then calls docker stack ls on each to build the
/// live instance list. Only stacks matching *-cms-stack are shown.
/// </summary>
[RelayCommand]
private async Task LoadInstancesAsync() => await RefreshAllAsync();
private async Task RefreshAllAsync()
{
IsBusy = true;
StatusMessage = "Loading live instances from all hosts...";
SelectedServices = new ObservableCollection<ServiceInfo>();
try
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync();
AvailableHosts = new ObservableCollection<SshHost>(hosts);
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
var all = new List<LiveStackItem>();
var errors = new List<string>();
foreach (var host in hosts)
{
try
{
dockerCli.SetHost(host);
var stacks = await dockerCli.ListStacksAsync();
foreach (var stack in stacks.Where(s => s.Name.EndsWith("-cms-stack")))
{
all.Add(new LiveStackItem
{
StackName = stack.Name,
CustomerAbbrev = stack.Name[..^10],
ServiceCount = stack.ServiceCount,
Host = host,
});
}
}
catch (Exception ex) { errors.Add($"{host.Label}: {ex.Message}"); }
}
// Enrich with server-side customer IDs if the server API is available
if (_serverApi is not null)
{
try
{
var fleet = await _serverApi.GetFleetAsync();
var lookup = fleet.ToDictionary(f => f.Abbreviation, f => f.CustomerId, StringComparer.OrdinalIgnoreCase);
foreach (var item in all)
{
if (lookup.TryGetValue(item.CustomerAbbrev, out var customerId))
item.CustomerId = customerId;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not enrich instances with server fleet data");
}
}
if (!string.IsNullOrWhiteSpace(FilterText))
all = all.Where(i =>
i.StackName.Contains(FilterText, StringComparison.OrdinalIgnoreCase) ||
i.CustomerAbbrev.Contains(FilterText, StringComparison.OrdinalIgnoreCase) ||
i.HostLabel.Contains(FilterText, StringComparison.OrdinalIgnoreCase)).ToList();
Instances = new ObservableCollection<LiveStackItem>(all);
// Auto-select a pending instance (e.g. just deployed from Create Instance page)
if (_pendingSelectAbbrev is not null)
{
SelectedInstance = all.FirstOrDefault(i =>
i.CustomerAbbrev.Equals(_pendingSelectAbbrev, StringComparison.OrdinalIgnoreCase));
_pendingSelectAbbrev = null;
}
var msg = $"Found {all.Count} instance(s) across {hosts.Count} host(s).";
if (errors.Count > 0) msg += $" | Errors: {string.Join(" | ", errors)}";
StatusMessage = msg;
}
catch (Exception ex) { StatusMessage = $"Error: {ex.Message}"; }
finally { IsBusy = false; }
}
[RelayCommand]
private async Task InspectInstanceAsync()
{
if (SelectedInstance == null) return;
IsBusy = true;
StatusMessage = $"Inspecting '{SelectedInstance.StackName}'...";
try
{
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
dockerCli.SetHost(SelectedInstance.Host);
var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName);
SelectedServices = new ObservableCollection<ServiceInfo>(services);
StatusMessage = $"Found {services.Count} service(s) in stack '{SelectedInstance.StackName}'.";
// Populate service filter dropdown and show logs panel
var filterItems = new List<string> { "All Services" };
filterItems.AddRange(services.Select(s => s.Name));
LogServiceFilter = new ObservableCollection<string>(filterItems);
SelectedLogService = "All Services";
IsLogsPanelVisible = true;
// Fetch initial logs and start auto-refresh
await FetchLogsInternalAsync();
StartLogAutoRefresh();
}
catch (Exception ex) { StatusMessage = $"Error inspecting: {ex.Message}"; }
finally { IsBusy = false; }
}
// ── Container Log Commands ──────────────────────────────────────────────
[RelayCommand]
private async Task RefreshLogsAsync()
{
await FetchLogsInternalAsync();
}
[RelayCommand]
private void ToggleLogsAutoRefresh()
{
IsLogsAutoRefresh = !IsLogsAutoRefresh;
if (IsLogsAutoRefresh)
StartLogAutoRefresh();
else
StopLogAutoRefresh();
}
[RelayCommand]
private void CloseLogsPanel()
{
StopLogAutoRefresh();
IsLogsPanelVisible = false;
LogEntries = new ObservableCollection<ServiceLogEntry>();
LogsStatusMessage = string.Empty;
}
partial void OnSelectedLogServiceChanged(string value)
{
// When user changes the service filter, refresh logs immediately
if (IsLogsPanelVisible)
_ = FetchLogsInternalAsync();
}
private async Task FetchLogsInternalAsync()
{
if (SelectedInstance == null || _isLogRefreshRunning) return;
_isLogRefreshRunning = true;
IsLoadingLogs = true;
try
{
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
dockerCli.SetHost(SelectedInstance.Host);
string? serviceFilter = SelectedLogService == "All Services" ? null : SelectedLogService;
var entries = await dockerCli.GetServiceLogsAsync(
SelectedInstance.StackName, serviceFilter, LogTailLines);
LogEntries = new ObservableCollection<ServiceLogEntry>(entries);
LogsStatusMessage = $"{entries.Count} log line(s) · last fetched {DateTime.Now:HH:mm:ss}";
}
catch (Exception ex)
{
LogsStatusMessage = $"Error fetching logs: {ex.Message}";
}
finally
{
IsLoadingLogs = false;
_isLogRefreshRunning = false;
}
}
private void StartLogAutoRefresh()
{
StopLogAutoRefresh();
if (!IsLogsAutoRefresh) return;
_logRefreshTimer = new DispatcherTimer
{
Interval = TimeSpan.FromSeconds(5)
};
_logRefreshTimer.Tick += async (_, _) =>
{
if (IsLogsPanelVisible && IsLogsAutoRefresh && !_isLogRefreshRunning)
await FetchLogsInternalAsync();
};
_logRefreshTimer.Start();
}
private void StopLogAutoRefresh()
{
_logRefreshTimer?.Stop();
_logRefreshTimer = null;
}
// ── Restart Commands ────────────────────────────────────────────────
[RelayCommand]
private async Task RestartStackAsync()
{
if (SelectedInstance == null) return;
if (ConfirmAsync is not null)
{
var confirmed = await ConfirmAsync(
"Restart Stack",
$"Are you sure you want to restart all services in '{SelectedInstance.StackName}'?\n\nThis will force-update every service in the stack, causing brief downtime.");
if (!confirmed) return;
}
IsBusy = true;
StatusMessage = $"Restarting all services in '{SelectedInstance.StackName}'...";
try
{
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
dockerCli.SetHost(SelectedInstance.Host);
var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName);
var failures = new List<string>();
for (var i = 0; i < services.Count; i++)
{
var svc = services[i];
StatusMessage = $"Restarting service {i + 1}/{services.Count}: {svc.Name}...";
var ok = await dockerCli.ForceUpdateServiceAsync(svc.Name);
if (!ok) failures.Add(svc.Name);
}
StatusMessage = failures.Count == 0
? $"All {services.Count} service(s) in '{SelectedInstance.StackName}' restarted successfully."
: $"Restarted with errors — failed services: {string.Join(", ", failures)}";
}
catch (Exception ex) { StatusMessage = $"Error restarting stack: {ex.Message}"; }
finally { IsBusy = false; }
}
[RelayCommand]
private async Task RestartServiceAsync(ServiceInfo? service)
{
if (service is null || SelectedInstance is null) return;
if (ConfirmAsync is not null)
{
var confirmed = await ConfirmAsync(
"Restart Service",
$"Are you sure you want to restart '{service.Name}'?\n\nThis will force-update the service, causing its tasks to be recreated.");
if (!confirmed) return;
}
IsBusy = true;
StatusMessage = $"Restarting service '{service.Name}'...";
try
{
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
dockerCli.SetHost(SelectedInstance.Host);
var ok = await dockerCli.ForceUpdateServiceAsync(service.Name);
StatusMessage = ok
? $"Service '{service.Name}' restarted successfully."
: $"Failed to restart service '{service.Name}'.";
// Refresh services to show updated replica status
var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName);
SelectedServices = new ObservableCollection<ServiceInfo>(services);
}
catch (Exception ex) { StatusMessage = $"Error restarting service: {ex.Message}"; }
finally { IsBusy = false; }
}
[RelayCommand]
private async Task DeleteInstanceAsync()
{
if (SelectedInstance == null) return;
IsBusy = true;
StatusMessage = $"Deleting {SelectedInstance.StackName}...";
try
{
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
dockerCli.SetHost(SelectedInstance.Host);
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
dockerSecrets.SetHost(SelectedInstance.Host);
using var scope = _services.CreateScope();
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
var result = await instanceSvc.DeleteInstanceAsync(
SelectedInstance.StackName, SelectedInstance.CustomerAbbrev);
StatusMessage = result.Success
? $"Instance '{SelectedInstance.StackName}' deleted."
: $"Delete failed: {result.ErrorMessage}";
await RefreshAllAsync();
}
catch (Exception ex) { StatusMessage = $"Error deleting: {ex.Message}"; }
finally { IsBusy = false; }
}
[RelayCommand]
private async Task RotateMySqlPasswordAsync()
{
if (SelectedInstance == null) return;
IsBusy = true;
StatusMessage = $"Rotating MySQL password for {SelectedInstance.StackName}...";
try
{
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
dockerCli.SetHost(SelectedInstance.Host);
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
dockerSecrets.SetHost(SelectedInstance.Host);
using var scope = _services.CreateScope();
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
var (ok, msg) = await instanceSvc.RotateMySqlPasswordAsync(SelectedInstance.StackName);
StatusMessage = ok ? $"Done: {msg}" : $"Failed: {msg}";
await RefreshAllAsync();
}
catch (Exception ex) { StatusMessage = $"Error rotating password: {ex.Message}"; }
finally { IsBusy = false; }
}
// ── P1 Banner Commands ────────────────────────────────────────────────
[RelayCommand]
private void DismissP1Banner()
{
IsAuthentikP1BannerVisible = false;
AuthentikP1Message = string.Empty;
}
/// <summary>
/// Called from a SignalR <c>AlertRaised</c> handler (runs on a background thread).
/// CRITICAL: wraps all property updates with <see cref="Dispatcher.UIThread"/> to
/// avoid silent cross-thread exceptions in Avalonia.
/// </summary>
public void HandleAlertRaised(string severity, string message)
{
if (string.Equals(severity, "Critical", StringComparison.OrdinalIgnoreCase) &&
message.Contains("Authentik", StringComparison.OrdinalIgnoreCase))
{
Dispatcher.UIThread.InvokeAsync(() =>
{
AuthentikP1Message = message;
IsAuthentikP1BannerVisible = true;
});
}
}
// ── Server-side Job Commands (decommission, suspend, reactivate) ────────
// Desktop has NO direct infrastructure access — all operations go through the server REST API.
[RelayCommand]
private async Task DecommissionAsync(LiveStackItem? instance)
{
instance ??= SelectedInstance;
if (instance is null) return;
if (instance.CustomerId is null)
{
StatusMessage = "Cannot decommission: no server-side customer ID available for this instance.";
return;
}
// Multi-step confirmation: user must type the abbreviation to confirm
if (ConfirmDecommissionAsync is not null)
{
var confirmed = await ConfirmDecommissionAsync(instance.CustomerAbbrev);
if (!confirmed) return;
}
else if (ConfirmAsync is not null)
{
var confirmed = await ConfirmAsync(
"Decommission Instance",
$"Are you sure you want to decommission '{instance.CustomerAbbrev}'?\n\n" +
"This will:\n" +
" • Remove all Docker services and stack\n" +
" • Delete Docker secrets\n" +
" • Remove NFS volumes and data\n" +
" • Revoke Authentik provider\n" +
" • Mark the customer as decommissioned\n\n" +
"This action is IRREVERSIBLE.");
if (!confirmed) return;
}
await CreateServerJobAsync(instance, "decommission");
}
[RelayCommand]
private async Task SuspendInstanceAsync(LiveStackItem? instance)
{
instance ??= SelectedInstance;
if (instance is null) return;
if (instance.CustomerId is null)
{
StatusMessage = "Cannot suspend: no server-side customer ID available for this instance.";
return;
}
if (ConfirmAsync is not null)
{
var confirmed = await ConfirmAsync(
"Suspend Instance",
$"Are you sure you want to suspend '{instance.CustomerAbbrev}'?\n\n" +
"The instance will be scaled to zero replicas. Data will be preserved.");
if (!confirmed) return;
}
await CreateServerJobAsync(instance, "suspend");
}
[RelayCommand]
private async Task ReactivateInstanceAsync(LiveStackItem? instance)
{
instance ??= SelectedInstance;
if (instance is null) return;
if (instance.CustomerId is null)
{
StatusMessage = "Cannot reactivate: no server-side customer ID available for this instance.";
return;
}
await CreateServerJobAsync(instance, "reactivate");
}
private async Task CreateServerJobAsync(LiveStackItem instance, string jobType)
{
if (_serverApi is null)
{
StatusMessage = "Server API client is not configured.";
return;
}
IsBusy = true;
StatusMessage = $"Requesting '{jobType}' for {instance.CustomerAbbrev}...";
try
{
var response = await _serverApi.CreateJobAsync(
new CreateJobRequest(instance.CustomerId!.Value, jobType, null));
StatusMessage = $"Job '{jobType}' created (id: {response.Id.ToString()[..8]}…). Status: {response.Status}";
_logger.LogInformation("Server job created: {JobId} type={JobType} customer={CustomerId}",
response.Id, jobType, instance.CustomerId);
}
catch (Refit.ApiException ex)
{
StatusMessage = $"Server error creating '{jobType}' job: {ex.StatusCode} — {ex.Content}";
_logger.LogError(ex, "Failed to create {JobType} job for customer {CustomerId}", jobType, instance.CustomerId);
}
catch (Exception ex)
{
StatusMessage = $"Error creating '{jobType}' job: {ex.Message}";
_logger.LogError(ex, "Failed to create {JobType} job for customer {CustomerId}", jobType, instance.CustomerId);
}
finally { IsBusy = false; }
}
// ── Details ─────────────────────────────────────────────────────────────
[RelayCommand]
private async Task OpenDetailsAsync()
{
if (SelectedInstance == null) return;
IsBusy = true;
StatusMessage = $"Loading details for '{SelectedInstance.StackName}'...";
try
{
// Set the SSH host on singleton Docker services so modal operations target the right host
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
dockerCli.SetHost(SelectedInstance.Host);
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
dockerSecrets.SetHost(SelectedInstance.Host);
var detailsVm = _services.GetRequiredService<InstanceDetailsViewModel>();
await detailsVm.LoadAsync(SelectedInstance);
OpenDetailsRequested?.Invoke(detailsVm);
StatusMessage = string.Empty;
}
catch (Exception ex) { StatusMessage = $"Error opening details: {ex.Message}"; }
finally { IsBusy = false; }
}
}

View File

@@ -1,55 +0,0 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using OTSSignsOrchestrator.Core.Data;
using OTSSignsOrchestrator.Core.Models.Entities;
namespace OTSSignsOrchestrator.Desktop.ViewModels;
/// <summary>
/// ViewModel for viewing operation logs.
/// </summary>
public partial class LogsViewModel : ObservableObject
{
private readonly IServiceProvider _services;
[ObservableProperty] private ObservableCollection<OperationLog> _logs = new();
[ObservableProperty] private string _statusMessage = string.Empty;
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private int _maxEntries = 100;
public LogsViewModel(IServiceProvider services)
{
_services = services;
_ = LoadLogsAsync();
}
[RelayCommand]
private async Task LoadLogsAsync()
{
IsBusy = true;
try
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
var items = await db.OperationLogs
.OrderByDescending(l => l.Timestamp)
.Take(MaxEntries)
.ToListAsync();
Logs = new ObservableCollection<OperationLog>(items);
StatusMessage = $"Showing {items.Count} log entries.";
}
catch (Exception ex)
{
StatusMessage = $"Error loading logs: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View File

@@ -1,71 +0,0 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace OTSSignsOrchestrator.Desktop.ViewModels;
public partial class MainWindowViewModel : ObservableObject
{
private readonly IServiceProvider _services;
[ObservableProperty]
private ObservableObject? _currentView;
[ObservableProperty]
private string _selectedNav = "Hosts";
[ObservableProperty]
private string _statusMessage = "Ready";
public ObservableCollection<string> NavItems { get; } = new()
{
"Hosts",
"Instances",
"Create Instance",
"Secrets",
"Settings",
"Logs"
};
public MainWindowViewModel(IServiceProvider services)
{
_services = services;
NavigateTo("Hosts");
}
partial void OnSelectedNavChanged(string value)
{
NavigateTo(value);
}
[RelayCommand]
private void NavigateTo(string page)
{
CurrentView = page switch
{
"Hosts" => (ObservableObject)_services.GetService(typeof(HostsViewModel))!,
"Instances" => (ObservableObject)_services.GetService(typeof(InstancesViewModel))!,
"Create Instance" => (ObservableObject)_services.GetService(typeof(CreateInstanceViewModel))!,
"Secrets" => (ObservableObject)_services.GetService(typeof(SecretsViewModel))!,
"Settings" => (ObservableObject)_services.GetService(typeof(SettingsViewModel))!,
"Logs" => (ObservableObject)_services.GetService(typeof(LogsViewModel))!,
_ => CurrentView
};
}
/// <summary>
/// Navigates to the Instances page and auto-selects the instance with the given abbreviation
/// once the live refresh completes.
/// </summary>
public void NavigateToInstancesWithSelection(string abbrev)
{
SelectedNav = "Instances"; // triggers OnSelectedNavChanged → NavigateTo("Instances")
if (CurrentView is InstancesViewModel instancesVm)
instancesVm.SetPendingSelection(abbrev);
}
public void SetStatus(string message)
{
StatusMessage = message;
}
}

View File

@@ -1,68 +0,0 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using OTSSignsOrchestrator.Core.Data;
using OTSSignsOrchestrator.Core.Models.Entities;
using OTSSignsOrchestrator.Core.Services;
using OTSSignsOrchestrator.Desktop.Services;
namespace OTSSignsOrchestrator.Desktop.ViewModels;
/// <summary>
/// ViewModel for viewing and managing Docker Swarm secrets on a remote host.
/// </summary>
public partial class SecretsViewModel : ObservableObject
{
private readonly IServiceProvider _services;
[ObservableProperty] private ObservableCollection<SecretListItem> _secrets = new();
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
[ObservableProperty] private SshHost? _selectedSshHost;
[ObservableProperty] private string _statusMessage = string.Empty;
[ObservableProperty] private bool _isBusy;
public SecretsViewModel(IServiceProvider services)
{
_services = services;
_ = LoadHostsAsync();
}
private async Task LoadHostsAsync()
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync();
AvailableHosts = new ObservableCollection<SshHost>(hosts);
}
[RelayCommand]
private async Task LoadSecretsAsync()
{
if (SelectedSshHost == null)
{
StatusMessage = "Select an SSH host first.";
return;
}
IsBusy = true;
try
{
var secretsSvc = _services.GetRequiredService<SshDockerSecretsService>();
secretsSvc.SetHost(SelectedSshHost);
var items = await secretsSvc.ListSecretsAsync();
Secrets = new ObservableCollection<SecretListItem>(items);
StatusMessage = $"Found {items.Count} secret(s) on {SelectedSshHost.Label}.";
}
catch (Exception ex)
{
StatusMessage = $"Error: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View File

@@ -1,584 +0,0 @@
using System.Collections.ObjectModel;
using System.Text.Json;
using System.Text.Json.Nodes;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using OTSSignsOrchestrator.Core.Configuration;
using OTSSignsOrchestrator.Core.Models.DTOs;
using OTSSignsOrchestrator.Core.Services;
namespace OTSSignsOrchestrator.Desktop.ViewModels;
/// <summary>
/// ViewModel for the Settings page — manages Git, MySQL, SMTP, Pangolin, NFS,
/// and Instance Defaults configuration, persisted via SettingsService.
/// </summary>
public partial class SettingsViewModel : ObservableObject
{
private readonly IServiceProvider _services;
[ObservableProperty] private string _statusMessage = string.Empty;
[ObservableProperty] private bool _isBusy;
// ── Git ──────────────────────────────────────────────────────────────────
[ObservableProperty] private string _gitRepoUrl = string.Empty;
[ObservableProperty] private string _gitRepoPat = string.Empty;
// ── MySQL Admin ─────────────────────────────────────────────────────────
[ObservableProperty] private string _mySqlHost = string.Empty;
[ObservableProperty] private string _mySqlPort = "3306";
[ObservableProperty] private string _mySqlAdminUser = string.Empty;
[ObservableProperty] private string _mySqlAdminPassword = string.Empty;
// ── SMTP ────────────────────────────────────────────────────────────────
[ObservableProperty] private string _smtpServer = string.Empty;
[ObservableProperty] private string _smtpUsername = string.Empty;
[ObservableProperty] private string _smtpPassword = string.Empty;
[ObservableProperty] private bool _smtpUseTls = true;
[ObservableProperty] private bool _smtpUseStartTls = true;
[ObservableProperty] private string _smtpRewriteDomain = string.Empty;
[ObservableProperty] private string _smtpHostname = string.Empty;
[ObservableProperty] private string _smtpFromLineOverride = "NO";
// ── Pangolin ────────────────────────────────────────────────────────────
[ObservableProperty] private string _pangolinEndpoint = "https://app.pangolin.net";
// ── NFS ─────────────────────────────────────────────────────────────────
[ObservableProperty] private string _nfsServer = string.Empty;
[ObservableProperty] private string _nfsExport = string.Empty;
[ObservableProperty] private string _nfsExportFolder = string.Empty;
[ObservableProperty] private string _nfsOptions = string.Empty;
// ── Instance Defaults ───────────────────────────────────────────────────
[ObservableProperty] private string _defaultCmsImage = "ghcr.io/xibosignage/xibo-cms:release-4.2.3";
[ObservableProperty] private string _defaultNewtImage = "fosrl/newt";
[ObservableProperty] private string _defaultMemcachedImage = "memcached:alpine";
[ObservableProperty] private string _defaultQuickChartImage = "ianw/quickchart";
[ObservableProperty] private string _defaultCmsServerNameTemplate = "app.ots-signs.com";
[ObservableProperty] private string _defaultThemeHostPath = "/cms/{abbrev}-cms-theme-custom";
[ObservableProperty] private string _defaultMySqlDbTemplate = "{abbrev}_cms_db";
[ObservableProperty] private string _defaultMySqlUserTemplate = "{abbrev}_cms_user";
[ObservableProperty] private string _defaultPhpPostMaxSize = "10G";
[ObservableProperty] private string _defaultPhpUploadMaxFilesize = "10G";
[ObservableProperty] private string _defaultPhpMaxExecutionTime = "600";
// ── Bitwarden Secrets Manager ─────────────────────────────────
[ObservableProperty] private string _bitwardenIdentityUrl = "https://identity.bitwarden.com";
[ObservableProperty] private string _bitwardenApiUrl = "https://api.bitwarden.com";
[ObservableProperty] private string _bitwardenAccessToken = string.Empty;
[ObservableProperty] private string _bitwardenOrganizationId = string.Empty;
[ObservableProperty] private string _bitwardenProjectId = string.Empty;
[ObservableProperty] private string _bitwardenInstanceProjectId = string.Empty;
// ── Authentik (SAML IdP) ────────────────────────────────────────
[ObservableProperty] private string _authentikUrl = string.Empty;
[ObservableProperty] private string _authentikApiKey = string.Empty;
[ObservableProperty] private string _authentikAuthorizationFlowSlug = string.Empty;
[ObservableProperty] private string _authentikInvalidationFlowSlug = string.Empty;
[ObservableProperty] private string _authentikSigningKeypairId = string.Empty;
[ObservableProperty] private string _authentikStatusMessage = string.Empty;
[ObservableProperty] private bool _isAuthentikBusy;
// Dropdown collections for Authentik flows / keypairs
public ObservableCollection<AuthentikFlowItem> AuthentikAuthorizationFlows { get; } = new();
public ObservableCollection<AuthentikFlowItem> AuthentikInvalidationFlows { get; } = new();
public ObservableCollection<AuthentikKeypairItem> AuthentikKeypairs { get; } = new();
[ObservableProperty] private AuthentikFlowItem? _selectedAuthorizationFlow;
[ObservableProperty] private AuthentikFlowItem? _selectedInvalidationFlow;
[ObservableProperty] private AuthentikKeypairItem? _selectedSigningKeypair;
// ── Xibo Bootstrap OAuth2 ─────────────────────────────────────
[ObservableProperty] private string _xiboBootstrapClientId = string.Empty;
[ObservableProperty] private string _xiboBootstrapClientSecret = string.Empty;
public SettingsViewModel(IServiceProvider services)
{
_services = services;
_ = LoadAsync();
}
/// <summary>Whether Bitwarden is configured and reachable.</summary>
[ObservableProperty] private bool _isBitwardenConfigured;
[RelayCommand]
private Task LoadAsync() => LoadCoreAsync(skipBitwarden: false);
private async Task LoadCoreAsync(bool skipBitwarden)
{
IsBusy = true;
try
{
if (!skipBitwarden)
{
// ── Load Bitwarden bootstrap config (IOptionsMonitor picks up appsettings.json changes) ──
var bwOptions = _services.GetRequiredService<IOptionsMonitor<BitwardenOptions>>().CurrentValue;
BitwardenIdentityUrl = bwOptions.IdentityUrl;
BitwardenApiUrl = bwOptions.ApiUrl;
BitwardenAccessToken = bwOptions.AccessToken;
BitwardenOrganizationId = bwOptions.OrganizationId;
BitwardenProjectId = bwOptions.ProjectId;
BitwardenInstanceProjectId = bwOptions.InstanceProjectId;
}
IsBitwardenConfigured = !string.IsNullOrWhiteSpace(BitwardenAccessToken)
&& !string.IsNullOrWhiteSpace(BitwardenOrganizationId);
if (!IsBitwardenConfigured)
{
StatusMessage = "Bitwarden is not configured. Fill in the Bitwarden section and save to get started.";
return;
}
// ── Load all other settings from Bitwarden ──
using var scope = _services.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
svc.InvalidateCache();
// Git
GitRepoUrl = await svc.GetAsync(SettingsService.GitRepoUrl, string.Empty);
GitRepoPat = await svc.GetAsync(SettingsService.GitRepoPat, string.Empty);
// MySQL
MySqlHost = await svc.GetAsync(SettingsService.MySqlHost, string.Empty);
MySqlPort = await svc.GetAsync(SettingsService.MySqlPort, "3306");
MySqlAdminUser = await svc.GetAsync(SettingsService.MySqlAdminUser, string.Empty);
MySqlAdminPassword = await svc.GetAsync(SettingsService.MySqlAdminPassword, string.Empty);
// SMTP
SmtpServer = await svc.GetAsync(SettingsService.SmtpServer, string.Empty);
SmtpUsername = await svc.GetAsync(SettingsService.SmtpUsername, string.Empty);
SmtpPassword = await svc.GetAsync(SettingsService.SmtpPassword, string.Empty);
SmtpUseTls = (await svc.GetAsync(SettingsService.SmtpUseTls, "YES")) == "YES";
SmtpUseStartTls = (await svc.GetAsync(SettingsService.SmtpUseStartTls, "YES")) == "YES";
SmtpRewriteDomain = await svc.GetAsync(SettingsService.SmtpRewriteDomain, string.Empty);
SmtpHostname = await svc.GetAsync(SettingsService.SmtpHostname, string.Empty);
SmtpFromLineOverride = await svc.GetAsync(SettingsService.SmtpFromLineOverride, "NO");
// Pangolin
PangolinEndpoint = await svc.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
// NFS
NfsServer = await svc.GetAsync(SettingsService.NfsServer, string.Empty);
NfsExport = await svc.GetAsync(SettingsService.NfsExport, string.Empty);
NfsExportFolder = await svc.GetAsync(SettingsService.NfsExportFolder, string.Empty);
NfsOptions = await svc.GetAsync(SettingsService.NfsOptions, string.Empty);
// Instance Defaults
DefaultCmsImage = await svc.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3");
DefaultNewtImage = await svc.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt");
DefaultMemcachedImage = await svc.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine");
DefaultQuickChartImage = await svc.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart");
DefaultCmsServerNameTemplate = await svc.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com");
DefaultThemeHostPath = await svc.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/{abbrev}-cms-theme-custom");
DefaultMySqlDbTemplate = await svc.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db");
DefaultMySqlUserTemplate = await svc.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user");
DefaultPhpPostMaxSize = await svc.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G");
DefaultPhpUploadMaxFilesize = await svc.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
DefaultPhpMaxExecutionTime = await svc.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
// Authentik
AuthentikUrl = await svc.GetAsync(SettingsService.AuthentikUrl, string.Empty);
AuthentikApiKey = await svc.GetAsync(SettingsService.AuthentikApiKey, string.Empty);
AuthentikAuthorizationFlowSlug = await svc.GetAsync(SettingsService.AuthentikAuthorizationFlowSlug, string.Empty);
AuthentikInvalidationFlowSlug = await svc.GetAsync(SettingsService.AuthentikInvalidationFlowSlug, string.Empty);
AuthentikSigningKeypairId = await svc.GetAsync(SettingsService.AuthentikSigningKeypairId, string.Empty);
// If Authentik URL + key are configured, try loading dropdowns
if (!string.IsNullOrWhiteSpace(AuthentikUrl) && !string.IsNullOrWhiteSpace(AuthentikApiKey))
await FetchAuthentikDropdownsInternalAsync();
// Xibo Bootstrap
XiboBootstrapClientId = await svc.GetAsync(SettingsService.XiboBootstrapClientId, string.Empty);
XiboBootstrapClientSecret = await svc.GetAsync(SettingsService.XiboBootstrapClientSecret, string.Empty);
StatusMessage = "Settings loaded from Bitwarden.";
}
catch (Exception ex)
{
StatusMessage = $"Error loading settings: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task SaveBitwardenLocalAsync()
{
IsBusy = true;
try
{
await SaveBitwardenConfigToFileAsync();
IsBitwardenConfigured = !string.IsNullOrWhiteSpace(BitwardenAccessToken)
&& !string.IsNullOrWhiteSpace(BitwardenOrganizationId);
StatusMessage = IsBitwardenConfigured
? "Bitwarden config saved to appsettings.json."
: "Bitwarden config saved. Fill in Access Token and Org ID to enable all settings.";
}
catch (Exception ex)
{
StatusMessage = $"Error saving Bitwarden config: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task PullFromBitwardenAsync()
{
await LoadCoreAsync(skipBitwarden: true);
}
[RelayCommand]
private async Task PushToBitwardenAsync()
{
IsBusy = true;
try
{
if (!IsBitwardenConfigured)
{
StatusMessage = "Bitwarden is not configured. Save Bitwarden config first.";
return;
}
using var scope = _services.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
svc.InvalidateCache();
var settings = new List<(string Key, string? Value, string Category, bool IsSensitive)>
{
// Git
(SettingsService.GitRepoUrl, NullIfEmpty(GitRepoUrl), SettingsService.CatGit, false),
(SettingsService.GitRepoPat, NullIfEmpty(GitRepoPat), SettingsService.CatGit, true),
// MySQL
(SettingsService.MySqlHost, NullIfEmpty(MySqlHost), SettingsService.CatMySql, false),
(SettingsService.MySqlPort, MySqlPort, SettingsService.CatMySql, false),
(SettingsService.MySqlAdminUser, NullIfEmpty(MySqlAdminUser), SettingsService.CatMySql, false),
(SettingsService.MySqlAdminPassword, NullIfEmpty(MySqlAdminPassword), SettingsService.CatMySql, true),
// SMTP
(SettingsService.SmtpServer, NullIfEmpty(SmtpServer), SettingsService.CatSmtp, false),
(SettingsService.SmtpUsername, NullIfEmpty(SmtpUsername), SettingsService.CatSmtp, false),
(SettingsService.SmtpPassword, NullIfEmpty(SmtpPassword), SettingsService.CatSmtp, true),
(SettingsService.SmtpUseTls, SmtpUseTls ? "YES" : "NO", SettingsService.CatSmtp, false),
(SettingsService.SmtpUseStartTls, SmtpUseStartTls ? "YES" : "NO", SettingsService.CatSmtp, false),
(SettingsService.SmtpRewriteDomain, NullIfEmpty(SmtpRewriteDomain), SettingsService.CatSmtp, false),
(SettingsService.SmtpHostname, NullIfEmpty(SmtpHostname), SettingsService.CatSmtp, false),
(SettingsService.SmtpFromLineOverride, SmtpFromLineOverride, SettingsService.CatSmtp, false),
// Pangolin
(SettingsService.PangolinEndpoint, NullIfEmpty(PangolinEndpoint), SettingsService.CatPangolin, false),
// NFS
(SettingsService.NfsServer, NullIfEmpty(NfsServer), SettingsService.CatNfs, false),
(SettingsService.NfsExport, NullIfEmpty(NfsExport), SettingsService.CatNfs, false),
(SettingsService.NfsExportFolder, NullIfEmpty(NfsExportFolder), SettingsService.CatNfs, false),
(SettingsService.NfsOptions, NullIfEmpty(NfsOptions), SettingsService.CatNfs, false),
// Instance Defaults
(SettingsService.DefaultCmsImage, DefaultCmsImage, SettingsService.CatDefaults, false),
(SettingsService.DefaultNewtImage, DefaultNewtImage, SettingsService.CatDefaults, false),
(SettingsService.DefaultMemcachedImage, DefaultMemcachedImage, SettingsService.CatDefaults, false),
(SettingsService.DefaultQuickChartImage, DefaultQuickChartImage, SettingsService.CatDefaults, false),
(SettingsService.DefaultCmsServerNameTemplate, DefaultCmsServerNameTemplate, SettingsService.CatDefaults, false),
(SettingsService.DefaultThemeHostPath, DefaultThemeHostPath, SettingsService.CatDefaults, false),
(SettingsService.DefaultMySqlDbTemplate, DefaultMySqlDbTemplate, SettingsService.CatDefaults, false),
(SettingsService.DefaultMySqlUserTemplate, DefaultMySqlUserTemplate, SettingsService.CatDefaults, false),
(SettingsService.DefaultPhpPostMaxSize, DefaultPhpPostMaxSize, SettingsService.CatDefaults, false),
(SettingsService.DefaultPhpUploadMaxFilesize, DefaultPhpUploadMaxFilesize, SettingsService.CatDefaults, false),
(SettingsService.DefaultPhpMaxExecutionTime, DefaultPhpMaxExecutionTime, SettingsService.CatDefaults, false),
// Authentik
(SettingsService.AuthentikUrl, NullIfEmpty(AuthentikUrl), SettingsService.CatAuthentik, false),
(SettingsService.AuthentikApiKey, NullIfEmpty(AuthentikApiKey), SettingsService.CatAuthentik, true),
(SettingsService.AuthentikAuthorizationFlowSlug, NullIfEmpty(SelectedAuthorizationFlow?.Slug ?? AuthentikAuthorizationFlowSlug), SettingsService.CatAuthentik, false),
(SettingsService.AuthentikInvalidationFlowSlug, NullIfEmpty(SelectedInvalidationFlow?.Slug ?? AuthentikInvalidationFlowSlug), SettingsService.CatAuthentik, false),
(SettingsService.AuthentikSigningKeypairId, NullIfEmpty(SelectedSigningKeypair?.Pk ?? AuthentikSigningKeypairId), SettingsService.CatAuthentik, false),
// Xibo Bootstrap
(SettingsService.XiboBootstrapClientId, NullIfEmpty(XiboBootstrapClientId), SettingsService.CatXibo, false),
(SettingsService.XiboBootstrapClientSecret, NullIfEmpty(XiboBootstrapClientSecret), SettingsService.CatXibo, true),
};
await svc.SaveManyAsync(settings);
StatusMessage = "Settings pushed to Bitwarden.";
}
catch (Exception ex)
{
StatusMessage = $"Error saving settings: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task TestBitwardenConnectionAsync()
{
if (string.IsNullOrWhiteSpace(BitwardenAccessToken) || string.IsNullOrWhiteSpace(BitwardenOrganizationId))
{
StatusMessage = "Bitwarden Access Token and Organization ID are required.";
return;
}
IsBusy = true;
StatusMessage = "Saving Bitwarden config and testing connection...";
try
{
// Save to appsettings.json first so the service picks up fresh values
await SaveBitwardenConfigToFileAsync();
using var scope = _services.CreateScope();
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
var secrets = await bws.ListSecretsAsync();
IsBitwardenConfigured = true;
StatusMessage = $"Bitwarden connected. Found {secrets.Count} secret(s) in project.";
}
catch (Exception ex)
{
IsBitwardenConfigured = false;
StatusMessage = $"Bitwarden connection failed: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
// ─────────────────────────────────────────────────────────────────────────
// Authentik: save, test, fetch dropdowns
// ─────────────────────────────────────────────────────────────────────────
[RelayCommand]
private async Task SaveAndTestAuthentikAsync()
{
if (string.IsNullOrWhiteSpace(AuthentikUrl) || string.IsNullOrWhiteSpace(AuthentikApiKey))
{
AuthentikStatusMessage = "Authentik URL and API Token are required.";
return;
}
IsAuthentikBusy = true;
AuthentikStatusMessage = "Saving Authentik settings and testing connection...";
try
{
// Persist URL + API key first so subsequent calls work
using var scope = _services.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
await svc.SetAsync(SettingsService.AuthentikUrl, AuthentikUrl.Trim(), SettingsService.CatAuthentik);
await svc.SetAsync(SettingsService.AuthentikApiKey, AuthentikApiKey.Trim(), SettingsService.CatAuthentik, isSensitive: true);
var authentik = scope.ServiceProvider.GetRequiredService<IAuthentikService>();
var (ok, msg) = await authentik.TestConnectionAsync(AuthentikUrl.Trim(), AuthentikApiKey.Trim());
if (!ok)
{
AuthentikStatusMessage = $"Connection failed: {msg}";
return;
}
AuthentikStatusMessage = "Connected — loading flows and keypairs...";
// Now fetch dropdowns
await FetchAuthentikDropdownsInternalAsync(AuthentikUrl.Trim(), AuthentikApiKey.Trim());
// Save selected flow/keypair values
var authSlug = SelectedAuthorizationFlow?.Slug ?? AuthentikAuthorizationFlowSlug;
var invalSlug = SelectedInvalidationFlow?.Slug ?? AuthentikInvalidationFlowSlug;
var kpId = SelectedSigningKeypair?.Pk ?? AuthentikSigningKeypairId;
await svc.SetAsync(SettingsService.AuthentikAuthorizationFlowSlug, authSlug, SettingsService.CatAuthentik);
await svc.SetAsync(SettingsService.AuthentikInvalidationFlowSlug, invalSlug, SettingsService.CatAuthentik);
await svc.SetAsync(SettingsService.AuthentikSigningKeypairId, kpId, SettingsService.CatAuthentik);
AuthentikStatusMessage = $"Authentik connected. {AuthentikAuthorizationFlows.Count} flows, {AuthentikKeypairs.Count} keypair(s) loaded.";
}
catch (Exception ex)
{
AuthentikStatusMessage = $"Error: {ex.Message}";
}
finally
{
IsAuthentikBusy = false;
}
}
[RelayCommand]
private async Task FetchAuthentikDropdownsAsync()
{
IsAuthentikBusy = true;
AuthentikStatusMessage = "Fetching flows and keypairs from Authentik...";
try
{
await FetchAuthentikDropdownsInternalAsync();
AuthentikStatusMessage = $"Loaded {AuthentikAuthorizationFlows.Count} flows, {AuthentikKeypairs.Count} keypair(s).";
}
catch (Exception ex)
{
AuthentikStatusMessage = $"Error fetching data: {ex.Message}";
}
finally
{
IsAuthentikBusy = false;
}
}
private async Task FetchAuthentikDropdownsInternalAsync(
string? overrideUrl = null, string? overrideApiKey = null)
{
using var scope = _services.CreateScope();
var authentik = scope.ServiceProvider.GetRequiredService<IAuthentikService>();
var flows = await authentik.ListFlowsAsync(overrideUrl, overrideApiKey);
var keypairs = await authentik.ListKeypairsAsync(overrideUrl, overrideApiKey);
// Populate authorization flows (designation = "authorization")
AuthentikAuthorizationFlows.Clear();
foreach (var f in flows.Where(f => f.Designation == "authorization"))
AuthentikAuthorizationFlows.Add(f);
// Populate invalidation flows (designation = "invalidation")
AuthentikInvalidationFlows.Clear();
foreach (var f in flows.Where(f => f.Designation == "invalidation"))
AuthentikInvalidationFlows.Add(f);
// Populate keypairs
AuthentikKeypairs.Clear();
// Add a "None" option
AuthentikKeypairs.Add(new AuthentikKeypairItem { Pk = "", Name = "(none)" });
foreach (var k in keypairs)
AuthentikKeypairs.Add(k);
// Select items matching saved slugs
SelectedAuthorizationFlow = AuthentikAuthorizationFlows
.FirstOrDefault(f => string.Equals(f.Slug, AuthentikAuthorizationFlowSlug, StringComparison.OrdinalIgnoreCase))
?? AuthentikAuthorizationFlows.FirstOrDefault(f => f.Slug == "default-provider-authorization-implicit-consent")
?? AuthentikAuthorizationFlows.FirstOrDefault();
SelectedInvalidationFlow = AuthentikInvalidationFlows
.FirstOrDefault(f => string.Equals(f.Slug, AuthentikInvalidationFlowSlug, StringComparison.OrdinalIgnoreCase))
?? AuthentikInvalidationFlows.FirstOrDefault(f => f.Slug == "default-provider-invalidation-flow")
?? AuthentikInvalidationFlows.FirstOrDefault();
SelectedSigningKeypair = string.IsNullOrWhiteSpace(AuthentikSigningKeypairId)
? AuthentikKeypairs.First() // "(none)"
: AuthentikKeypairs.FirstOrDefault(k => k.Pk == AuthentikSigningKeypairId)
?? AuthentikKeypairs.First();
// Update slug fields to match selection
if (SelectedAuthorizationFlow != null)
AuthentikAuthorizationFlowSlug = SelectedAuthorizationFlow.Slug;
if (SelectedInvalidationFlow != null)
AuthentikInvalidationFlowSlug = SelectedInvalidationFlow.Slug;
if (SelectedSigningKeypair != null)
AuthentikSigningKeypairId = SelectedSigningKeypair.Pk;
}
[RelayCommand]
private async Task TestXiboBootstrapAsync()
{
if (string.IsNullOrWhiteSpace(XiboBootstrapClientId) || string.IsNullOrWhiteSpace(XiboBootstrapClientSecret))
{
StatusMessage = "Xibo Bootstrap Client ID and Secret are required.";
return;
}
IsBusy = true;
StatusMessage = "Testing Xibo bootstrap credentials...";
try
{
using var scope = _services.CreateScope();
var xibo = scope.ServiceProvider.GetRequiredService<XiboApiService>();
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
var cmsServerTemplate = await svc.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com");
// Use a placeholder URL — user must configure a live instance for full test
StatusMessage = "Xibo bootstrap credentials saved. Connect test requires a live instance URL — use 'Details' on an instance to verify.";
}
catch (Exception ex)
{
StatusMessage = $"Error: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task TestMySqlConnectionAsync()
{
if (string.IsNullOrWhiteSpace(MySqlHost) || string.IsNullOrWhiteSpace(MySqlAdminUser))
{
StatusMessage = "MySQL Host and Admin User are required for connection test.";
return;
}
IsBusy = true;
StatusMessage = "Testing MySQL connection...";
try
{
if (!int.TryParse(MySqlPort, out var port))
port = 3306;
var docker = _services.GetRequiredService<IDockerCliService>();
var (connection, tunnel) = await docker.OpenMySqlConnectionAsync(
MySqlHost, port, MySqlAdminUser, MySqlAdminPassword);
await using var _ = connection;
using var __ = tunnel;
await using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT 1";
await cmd.ExecuteScalarAsync();
StatusMessage = $"MySQL connection successful ({MySqlHost}:{port} via SSH tunnel).";
}
catch (Exception ex)
{
StatusMessage = $"MySQL connection failed: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
/// <summary>
/// Persists Bitwarden bootstrap credentials to appsettings.json so they survive restarts.
/// </summary>
private async Task SaveBitwardenConfigToFileAsync()
{
var path = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
var json = await File.ReadAllTextAsync(path);
var doc = JsonNode.Parse(json, documentOptions: new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip })!;
var bw = doc["Bitwarden"]?.AsObject();
if (bw == null)
{
bw = new JsonObject();
doc.AsObject()["Bitwarden"] = bw;
}
bw["IdentityUrl"] = BitwardenIdentityUrl;
bw["ApiUrl"] = BitwardenApiUrl;
bw["AccessToken"] = BitwardenAccessToken;
bw["OrganizationId"] = BitwardenOrganizationId;
bw["ProjectId"] = BitwardenProjectId;
bw["InstanceProjectId"] = BitwardenInstanceProjectId;
var options = new JsonSerializerOptions { WriteIndented = true };
await File.WriteAllTextAsync(path, doc.ToJsonString(options));
}
private static string? NullIfEmpty(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}

View File

@@ -1,27 +0,0 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="OTSSignsOrchestrator.Desktop.Views.ConfirmationDialog"
Title="Confirm"
Width="420" Height="200"
MinWidth="320" MinHeight="160"
WindowStartupLocation="CenterOwner"
CanResize="False"
SizeToContent="Height">
<DockPanel Margin="24">
<!-- Buttons -->
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal"
HorizontalAlignment="Right" Spacing="10" Margin="0,16,0,0">
<Button Content="Cancel" Name="CancelButton" Width="90" />
<Button Content="Confirm" Name="ConfirmButton" Classes="accent" Width="90" />
</StackPanel>
<!-- Message -->
<StackPanel Spacing="8">
<TextBlock Name="TitleText" FontSize="16" FontWeight="SemiBold"
Foreground="{StaticResource AccentBrush}" />
<TextBlock Name="MessageText" FontSize="13" TextWrapping="Wrap"
Foreground="{StaticResource TextSecondaryBrush}" />
</StackPanel>
</DockPanel>
</Window>

View File

@@ -1,50 +0,0 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace OTSSignsOrchestrator.Desktop.Views;
/// <summary>
/// A simple Yes/No confirmation dialog that can be shown modally.
/// Use <see cref="ShowAsync"/> for a convenient one-liner.
/// </summary>
public partial class ConfirmationDialog : Window
{
public bool Result { get; private set; }
public ConfirmationDialog()
{
InitializeComponent();
}
public ConfirmationDialog(string title, string message) : this()
{
TitleText.Text = title;
MessageText.Text = message;
Title = title;
ConfirmButton.Click += OnConfirmClicked;
CancelButton.Click += OnCancelClicked;
}
private void OnConfirmClicked(object? sender, RoutedEventArgs e)
{
Result = true;
Close();
}
private void OnCancelClicked(object? sender, RoutedEventArgs e)
{
Result = false;
Close();
}
/// <summary>
/// Shows a modal confirmation dialog and returns true if the user confirmed.
/// </summary>
public static async Task<bool> ShowAsync(Window owner, string title, string message)
{
var dialog = new ConfirmationDialog(title, message);
await dialog.ShowDialog(owner);
return dialog.Result;
}
}

View File

@@ -1,218 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
x:Class="OTSSignsOrchestrator.Desktop.Views.CreateInstanceView"
x:DataType="vm:CreateInstanceViewModel">
<ScrollViewer>
<Grid ColumnDefinitions="1*,20,1*" Margin="0,0,8,16">
<!-- ══ LEFT COLUMN — inputs ══ -->
<StackPanel Grid.Column="0" Spacing="10">
<TextBlock Text="Create New Instance" Classes="pageTitle" />
<TextBlock Text="Deploy a new CMS stack to a Docker Swarm host" Classes="pageSubtitle" />
<!-- SSH Host -->
<Border Classes="card">
<StackPanel Spacing="8">
<TextBlock Text="Target Host" FontSize="13" FontWeight="SemiBold"
Foreground="{StaticResource AccentBrush}" />
<ComboBox ItemsSource="{Binding AvailableHosts}"
SelectedItem="{Binding SelectedSshHost}"
PlaceholderText="Select SSH Host..."
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
</Border>
<!-- Core fields -->
<Border Classes="card">
<StackPanel Spacing="8">
<TextBlock Text="Customer Details" FontSize="13" FontWeight="SemiBold"
Foreground="#60A5FA" />
<TextBlock Text="Customer Name" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding CustomerName}" Watermark="e.g. Acme Corp" />
<TextBlock Text="Abbreviation (3 letters)" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding CustomerAbbrev}"
Watermark="e.g. acm"
MaxLength="3" />
</StackPanel>
</Border>
<!-- Pangolin / Newt (optional) -->
<Expander Header="Pangolin / Newt credentials (optional)">
<Border Classes="card" Margin="0,8,0,0">
<StackPanel Spacing="8">
<TextBlock Text="Newt ID" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding NewtId}" Watermark="(from Pangolin dashboard)" />
<TextBlock Text="Newt Secret" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding NewtSecret}" PasswordChar="●" Watermark="(from Pangolin dashboard)" />
</StackPanel>
</Border>
</Expander>
<!-- NFS volume settings -->
<Expander Header="NFS volume settings">
<Border Classes="card" Margin="0,8,0,0">
<StackPanel Spacing="8">
<TextBlock Text="NFS Server" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding NfsServer}" Watermark="e.g. 192.168.1.100" />
<TextBlock Text="Export Path" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding NfsExport}" Watermark="e.g. /srv/nfs" />
<TextBlock Text="Export Folder (optional)" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding NfsExportFolder}" Watermark="e.g. ots_cms (leave empty for export root)" />
<TextBlock Text="Extra Mount Options" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding NfsExtraOptions}" Watermark="Additional options after nfsvers=4,proto=tcp" />
</StackPanel>
</Border>
</Expander>
<!-- Advanced options -->
<Expander Header="Advanced options">
<Border Classes="card" Margin="0,8,0,0">
<StackPanel Spacing="8">
<CheckBox IsChecked="{Binding PurgeStaleVolumes}">
<StackPanel Spacing="2">
<TextBlock Text="Purge stale volumes before deploying" FontSize="12" />
<TextBlock Text="Removes existing Docker volumes for this stack so fresh volumes are created. Only needed if volumes were created with wrong settings." FontSize="11" Foreground="{StaticResource TextMutedBrush}" TextWrapping="Wrap" />
</StackPanel>
</CheckBox>
</StackPanel>
</Border>
</Expander>
<!-- Deploy button + progress -->
<Button Content="Deploy Instance"
Classes="accent"
Command="{Binding DeployCommand}"
IsEnabled="{Binding !IsBusy}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Padding="14,10" FontSize="14"
Margin="0,8,0,0" />
<!-- Progress bar -->
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0"
IsVisible="{Binding IsBusy}">
<ProgressBar Value="{Binding ProgressPercent}"
Maximum="100" Height="6"
CornerRadius="3"
Foreground="{StaticResource AccentBrush}" />
<TextBlock Grid.Column="1" Text="{Binding ProgressStep}"
FontSize="11" Foreground="{StaticResource TextMutedBrush}"
Margin="10,0,0,0" VerticalAlignment="Center" />
</Grid>
<TextBlock Text="{Binding StatusMessage}" Classes="status"
Margin="0,4,0,0" TextWrapping="Wrap" />
<!-- Deploy output -->
<TextBox Text="{Binding DeployOutput}" IsReadOnly="True"
AcceptsReturn="True" MaxHeight="260"
FontFamily="Cascadia Mono, Consolas, monospace" FontSize="11"
IsVisible="{Binding DeployOutput.Length}"
CornerRadius="8" />
</StackPanel>
<!-- ══ RIGHT COLUMN — tabbed preview ══ -->
<TabControl Grid.Column="2" VerticalAlignment="Top">
<!-- Tab 1: Resource preview -->
<TabItem Header="Resource Preview">
<Border Classes="card" Margin="0,8,0,0">
<StackPanel Spacing="6">
<TextBlock Text="Resource Preview" FontSize="14" FontWeight="SemiBold"
Foreground="{StaticResource AccentBrush}" Margin="0,0,0,10" />
<TextBlock Text="Stack" FontSize="11" Foreground="{StaticResource TextMutedBrush}" />
<TextBlock Text="{Binding PreviewStackName}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#60A5FA" Margin="0,0,0,8" />
<TextBlock Text="Services" FontSize="11" Foreground="{StaticResource TextMutedBrush}" />
<TextBlock Text="{Binding PreviewServiceWeb}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#4ADE80" />
<TextBlock Text="{Binding PreviewServiceCache}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#4ADE80" />
<TextBlock Text="{Binding PreviewServiceChart}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#4ADE80" />
<TextBlock Text="{Binding PreviewServiceNewt}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#4ADE80" Margin="0,0,0,8" />
<TextBlock Text="Network" FontSize="11" Foreground="{StaticResource TextMutedBrush}" />
<TextBlock Text="{Binding PreviewNetwork}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#2DD4BF" Margin="0,0,0,8" />
<TextBlock Text="NFS Volumes" FontSize="11" Foreground="{StaticResource TextMutedBrush}" />
<TextBlock Text="{Binding PreviewVolCustom}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#F472B6" />
<TextBlock Text="{Binding PreviewVolBackup}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#F472B6" />
<TextBlock Text="{Binding PreviewVolLibrary}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#F472B6" />
<TextBlock Text="{Binding PreviewVolUserscripts}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#F472B6" />
<TextBlock Text="{Binding PreviewVolCaCerts}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#F472B6" Margin="0,0,0,8" />
<TextBlock Text="Docker Secrets" FontSize="11" Foreground="{StaticResource TextMutedBrush}" />
<TextBlock Text="{Binding PreviewSecret}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#FBBF24" />
<TextBlock Text="{Binding PreviewSecretUser}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#FBBF24" />
<TextBlock Text="{Binding PreviewSecretHost}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#FBBF24" />
<TextBlock Text="{Binding PreviewSecretPort}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#FBBF24" Margin="0,0,0,8" />
<TextBlock Text="MySQL Database" FontSize="11" Foreground="{StaticResource TextMutedBrush}" />
<TextBlock Text="{Binding PreviewMySqlDb}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#C084FC" />
<TextBlock Text="{Binding PreviewMySqlUser}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#C084FC" Margin="0,0,0,8" />
<TextBlock Text="CMS URL" FontSize="11" Foreground="{StaticResource TextMutedBrush}" />
<TextBlock Text="{Binding PreviewCmsUrl}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#2DD4BF" />
</StackPanel>
</Border>
</TabItem>
<!-- Tab 2: Rendered compose YML -->
<TabItem Header="Compose YML">
<Border Classes="card" Margin="0,8,0,0">
<Grid RowDefinitions="Auto,*,Auto">
<!-- Load button -->
<Button Grid.Row="0"
Content="Load / Refresh YML"
Command="{Binding LoadYmlPreviewCommand}"
IsEnabled="{Binding !IsLoadingYml}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Padding="10,7"
Margin="0,0,0,10" />
<!-- YML text box -->
<TextBox Grid.Row="1"
Text="{Binding PreviewYml}"
IsReadOnly="True"
AcceptsReturn="True"
FontFamily="Cascadia Mono, Consolas, monospace"
FontSize="11"
MinHeight="320"
CornerRadius="6"
Watermark="Click 'Load / Refresh YML' to preview the rendered compose file..."
TextWrapping="NoWrap" />
<!-- Copy button -->
<Button Grid.Row="2"
Content="Copy to Clipboard"
Command="{Binding CopyYmlCommand}"
IsEnabled="{Binding HasPreviewYml}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Padding="10,7"
Margin="0,10,0,0" />
</Grid>
</Border>
</TabItem>
</TabControl>
</Grid>
</ScrollViewer>
</UserControl>

View File

@@ -1,11 +0,0 @@
using Avalonia.Controls;
namespace OTSSignsOrchestrator.Desktop.Views;
public partial class CreateInstanceView : UserControl
{
public CreateInstanceView()
{
InitializeComponent();
}
}

View File

@@ -1,127 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
x:Class="OTSSignsOrchestrator.Desktop.Views.HostsView"
x:DataType="vm:HostsViewModel">
<DockPanel>
<!-- Page header -->
<StackPanel DockPanel.Dock="Top" Margin="0,0,0,4">
<TextBlock Text="SSH Hosts" Classes="pageTitle" />
<TextBlock Text="Manage remote Docker Swarm manager connections" Classes="pageSubtitle" />
</StackPanel>
<!-- Toolbar -->
<Border DockPanel.Dock="Top" Classes="toolbar" Margin="0,0,0,16">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Add Host" Classes="accent" Command="{Binding NewHostCommand}" />
<Button Content="Edit" Command="{Binding EditSelectedHostCommand}" />
<Button Content="Test Connection" Command="{Binding TestConnectionCommand}" />
<Button Content="Delete" Classes="danger" Command="{Binding DeleteHostCommand}" />
<Border Width="1" Background="{StaticResource BorderSubtleBrush}" Margin="4,2" />
<Button Content="Refresh" Command="{Binding LoadHostsCommand}" />
</StackPanel>
</Border>
<!-- Status -->
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Classes="status"
Margin="0,10,0,0" />
<!-- Remote Nodes panel -->
<Border DockPanel.Dock="Bottom" Classes="card" Margin="0,16,0,0"
MinHeight="120" MaxHeight="300">
<DockPanel>
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="10" Margin="0,0,0,10">
<TextBlock Text="Cluster Nodes" Classes="sectionTitle" VerticalAlignment="Center" />
<Button Content="List Nodes" Command="{Binding ListNodesCommand}" />
<TextBlock Text="{Binding NodesStatusMessage}" Classes="status"
VerticalAlignment="Center" Margin="4,0,0,0" />
</StackPanel>
<DataGrid ItemsSource="{Binding RemoteNodes}"
AutoGenerateColumns="False"
IsReadOnly="True"
GridLinesVisibility="Horizontal"
CanUserResizeColumns="True"
HeadersVisibility="Column">
<DataGrid.Columns>
<DataGridTextColumn Header="Hostname" Binding="{Binding Hostname}" Width="160" />
<DataGridTextColumn Header="IP Address" Binding="{Binding IpAddress}" Width="130" />
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="80" />
<DataGridTextColumn Header="Availability" Binding="{Binding Availability}" Width="100" />
<DataGridTextColumn Header="Manager Status" Binding="{Binding ManagerStatus}" Width="120" />
<DataGridTextColumn Header="Engine" Binding="{Binding EngineVersion}" Width="100" />
<DataGridTextColumn Header="ID" Binding="{Binding Id}" Width="200" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</Border>
<!-- Edit panel (shown when editing) -->
<Border DockPanel.Dock="Right" Width="360" IsVisible="{Binding IsEditing}"
Classes="card" Margin="16,0,0,0">
<ScrollViewer>
<StackPanel Spacing="10">
<TextBlock Text="SSH Host" FontSize="17" FontWeight="SemiBold"
Foreground="{StaticResource AccentBrush}" Margin="0,0,0,6" />
<TextBlock Text="Label" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding EditLabel}" Watermark="e.g. Production Swarm" />
<TextBlock Text="Host" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding EditHost}" Watermark="hostname or IP" />
<TextBlock Text="Port" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<NumericUpDown Value="{Binding EditPort}" Minimum="1" Maximum="65535" CornerRadius="6" />
<TextBlock Text="Username" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding EditUsername}" Watermark="ssh username" />
<CheckBox Content="Use Key Authentication" IsChecked="{Binding EditUseKeyAuth}"
Margin="0,4,0,0" />
<TextBlock Text="Private Key Path" FontSize="12"
Foreground="{StaticResource TextSecondaryBrush}"
IsVisible="{Binding EditUseKeyAuth}" />
<TextBox Text="{Binding EditPrivateKeyPath}" Watermark="~/.ssh/id_rsa"
IsVisible="{Binding EditUseKeyAuth}" />
<TextBlock Text="Key Passphrase (optional)" FontSize="12"
Foreground="{StaticResource TextSecondaryBrush}"
IsVisible="{Binding EditUseKeyAuth}" />
<TextBox Text="{Binding EditKeyPassphrase}" PasswordChar="●"
IsVisible="{Binding EditUseKeyAuth}" />
<TextBlock Text="Password (if not using key)" FontSize="12"
Foreground="{StaticResource TextSecondaryBrush}"
IsVisible="{Binding !EditUseKeyAuth}" />
<TextBox Text="{Binding EditPassword}" PasswordChar="●"
IsVisible="{Binding !EditUseKeyAuth}" />
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,14,0,0">
<Button Content="Save" Classes="accent" Command="{Binding SaveHostCommand}" />
<Button Content="Cancel" Command="{Binding CancelEditCommand}" />
</StackPanel>
</StackPanel>
</ScrollViewer>
</Border>
<!-- Host list -->
<DataGrid ItemsSource="{Binding Hosts}"
SelectedItem="{Binding SelectedHost}"
AutoGenerateColumns="False"
IsReadOnly="True"
GridLinesVisibility="Horizontal"
CanUserResizeColumns="True">
<DataGrid.Columns>
<DataGridTextColumn Header="Label" Binding="{Binding Label}" Width="150" />
<DataGridTextColumn Header="Host" Binding="{Binding Host}" Width="200" />
<DataGridTextColumn Header="Port" Binding="{Binding Port}" Width="60" />
<DataGridTextColumn Header="User" Binding="{Binding Username}" Width="100" />
<DataGridCheckBoxColumn Header="Key Auth" Binding="{Binding UseKeyAuth}" Width="70" />
<DataGridTextColumn Header="Last Tested" Binding="{Binding LastTestedAt, StringFormat='{}{0:g}'}" Width="150" />
<DataGridCheckBoxColumn Header="OK" Binding="{Binding LastTestSuccess}" Width="50" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</UserControl>

View File

@@ -1,11 +0,0 @@
using Avalonia.Controls;
namespace OTSSignsOrchestrator.Desktop.Views;
public partial class HostsView : UserControl
{
public HostsView()
{
InitializeComponent();
}
}

View File

@@ -1,240 +0,0 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
xmlns:dto="using:OTSSignsOrchestrator.Core.Models.DTOs"
xmlns:svc="using:OTSSignsOrchestrator.Core.Services"
x:Class="OTSSignsOrchestrator.Desktop.Views.InstanceDetailsWindow"
x:DataType="vm:InstanceDetailsViewModel"
Title="Instance Details"
Width="620" Height="860"
MinWidth="520" MinHeight="700"
WindowStartupLocation="CenterOwner"
CanResize="True">
<DockPanel Margin="24">
<!-- Header -->
<StackPanel DockPanel.Dock="Top" Margin="0,0,0,16">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="{Binding StackName}" FontSize="22" FontWeight="Bold"
Foreground="{StaticResource AccentBrush}" />
</StackPanel>
<TextBlock Text="{Binding HostLabel, StringFormat='Host: {0}'}"
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,2,0,0" />
<TextBlock Text="{Binding InstanceUrl}"
FontSize="11" Foreground="{StaticResource TextMutedBrush}" />
</StackPanel>
<!-- Status bar -->
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Classes="status"
Margin="0,12,0,0" TextWrapping="Wrap" />
<!-- Main scrollable content -->
<ScrollViewer>
<StackPanel Spacing="16">
<!-- ═══ Pending Setup Banner ═══ -->
<Border IsVisible="{Binding IsPendingSetup}"
Background="#1F2A1A" BorderBrush="#4ADE80" BorderThickness="1"
CornerRadius="8" Padding="14,10">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="⚙" FontSize="18" VerticalAlignment="Center" Foreground="#4ADE80" />
<StackPanel>
<TextBlock Text="Pending Setup" FontSize="14" FontWeight="SemiBold"
Foreground="#4ADE80" />
<TextBlock FontSize="12" Foreground="{StaticResource TextSecondaryBrush}"
TextWrapping="Wrap"
Text="Enter your Xibo OAuth credentials below to complete instance initialisation." />
</StackPanel>
</StackPanel>
</Border>
<!-- ═══ OTS Admin Account ═══ -->
<Border Classes="card">
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
<Border Width="4" Height="20" CornerRadius="2" Background="#F97316" />
<TextBlock Text="OTS Admin Account" FontSize="16" FontWeight="SemiBold"
Foreground="#F97316" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Text="Username" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<Grid ColumnDefinitions="*,Auto">
<TextBox Grid.Column="0" Text="{Binding AdminUsername}" IsReadOnly="True" />
<Button Grid.Column="1" Content="Copy" Margin="4,0,0,0"
Command="{Binding CopyAdminPasswordCommand}" />
</Grid>
<TextBlock Text="Password" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" Margin="0,4,0,0" />
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
<TextBox Grid.Column="0" Text="{Binding AdminPasswordDisplay}" IsReadOnly="True"
FontFamily="Consolas,monospace" />
<Button Grid.Column="1" Content="Show" Margin="4,0,0,0"
IsVisible="{Binding !AdminPasswordVisible}"
Command="{Binding ToggleAdminPasswordVisibilityCommand}" />
<Button Grid.Column="2" Content="Hide" Margin="4,0,0,0"
IsVisible="{Binding AdminPasswordVisible}"
Command="{Binding ToggleAdminPasswordVisibilityCommand}" />
<Button Grid.Column="3" Content="Copy" Margin="4,0,0,0"
Command="{Binding CopyAdminPasswordCommand}" />
</Grid>
<Button Content="Rotate Admin Password"
Command="{Binding RotateAdminPasswordCommand}"
IsEnabled="{Binding !IsBusy}"
Classes="accent"
Margin="0,6,0,0" />
</StackPanel>
</Border>
<!-- ═══ Database Credentials ═══ -->
<Border Classes="card">
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
<Border Width="4" Height="20" CornerRadius="2" Background="#4ADE80" />
<TextBlock Text="Database Credentials" FontSize="16" FontWeight="SemiBold"
Foreground="#4ADE80" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Text="MySQL Username" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<Grid ColumnDefinitions="*">
<TextBox Text="{Binding DbUsername}" IsReadOnly="True" />
</Grid>
<TextBlock Text="MySQL Password" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" Margin="0,4,0,0" />
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
<TextBox Grid.Column="0" Text="{Binding DbPasswordDisplay}" IsReadOnly="True"
FontFamily="Consolas,monospace" />
<Button Grid.Column="1" Content="Show" Margin="4,0,0,0"
IsVisible="{Binding !DbPasswordVisible}"
Command="{Binding ToggleDbPasswordVisibilityCommand}" />
<Button Grid.Column="2" Content="Hide" Margin="4,0,0,0"
IsVisible="{Binding DbPasswordVisible}"
Command="{Binding ToggleDbPasswordVisibilityCommand}" />
<Button Grid.Column="3" Content="Copy" Margin="4,0,0,0"
Command="{Binding CopyDbPasswordCommand}" />
</Grid>
<Button Content="Rotate DB Password"
Command="{Binding RotateDbPasswordCommand}"
IsEnabled="{Binding !IsBusy}"
Margin="0,6,0,0" />
</StackPanel>
</Border>
<!-- ═══ Xibo OAuth2 Application ═══ -->
<Border Classes="card">
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
<Border Width="4" Height="20" CornerRadius="2" Background="#60A5FA" />
<TextBlock Text="OTS OAuth2 Application" FontSize="16" FontWeight="SemiBold"
Foreground="#60A5FA" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Text="Client credentials used by the OTS orchestrator for Xibo API access."
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6"
TextWrapping="Wrap" />
<!-- ── Pending: editable credential input ── -->
<StackPanel Spacing="8" IsVisible="{Binding IsPendingSetup}">
<TextBlock FontSize="12" Foreground="{StaticResource TextSecondaryBrush}"
TextWrapping="Wrap"
Text="Log into the Xibo CMS as xibo_admin (password: password), go to Administration → Applications, create a client_credentials app, then paste the credentials here." />
<TextBlock Text="Client ID" FontSize="12"
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,4,0,0" />
<TextBox Text="{Binding InitClientId}" Watermark="OAuth2 Client ID" />
<TextBlock Text="Client Secret" FontSize="12"
Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding InitClientSecret}" PasswordChar="●"
Watermark="(paste from Xibo Applications page)" />
<Button Content="Initialize Instance"
Command="{Binding InitializeCommand}"
IsEnabled="{Binding !IsBusy}"
Classes="accent"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Padding="14,10" FontSize="14"
Margin="0,8,0,0" />
</StackPanel>
<!-- ── Initialized: read-only display ── -->
<StackPanel Spacing="8" IsVisible="{Binding !IsPendingSetup}">
<TextBlock Text="Client ID" FontSize="12"
Foreground="{StaticResource TextSecondaryBrush}" />
<Grid ColumnDefinitions="*,Auto">
<TextBox Grid.Column="0" Text="{Binding OAuthClientId}" IsReadOnly="True"
FontFamily="Consolas,monospace" />
<Button Grid.Column="1" Content="Copy" Margin="4,0,0,0"
Command="{Binding CopyOAuthClientIdCommand}" />
</Grid>
<TextBlock Text="Client Secret" FontSize="12"
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,4,0,0" />
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
<TextBox Grid.Column="0" Text="{Binding OAuthSecretDisplay}" IsReadOnly="True"
FontFamily="Consolas,monospace" />
<Button Grid.Column="1" Content="Show" Margin="4,0,0,0"
IsVisible="{Binding !OAuthSecretVisible}"
Command="{Binding ToggleOAuthSecretVisibilityCommand}" />
<Button Grid.Column="2" Content="Hide" Margin="4,0,0,0"
IsVisible="{Binding OAuthSecretVisible}"
Command="{Binding ToggleOAuthSecretVisibilityCommand}" />
<Button Grid.Column="3" Content="Copy" Margin="4,0,0,0"
Command="{Binding CopyOAuthSecretCommand}" />
</Grid>
</StackPanel>
</StackPanel>
</Border>
<!-- ═══ Stack Services ═══ -->
<Border Classes="card">
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
<Border Width="4" Height="20" CornerRadius="2" Background="#A78BFA" />
<TextBlock Text="Stack Services" FontSize="16" FontWeight="SemiBold"
Foreground="#A78BFA" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Text="Force-restart individual services within this stack."
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6"
TextWrapping="Wrap" />
<!-- Loading indicator -->
<TextBlock Text="Loading services..." FontSize="12"
Foreground="{StaticResource TextMutedBrush}"
IsVisible="{Binding IsLoadingServices}" />
<!-- Services list -->
<ItemsControl ItemsSource="{Binding StackServices}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="svc:ServiceInfo">
<Border Background="#232336" CornerRadius="6" Padding="12,10" Margin="0,3"
BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="1">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="3">
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="13" />
<TextBlock Text="{Binding Image}" FontSize="11"
Foreground="{StaticResource TextMutedBrush}" />
<TextBlock Text="{Binding Replicas, StringFormat='Replicas: {0}'}"
FontSize="11" Foreground="{StaticResource TextSecondaryBrush}" />
</StackPanel>
<Button Grid.Column="1" Content="Restart"
Command="{Binding $parent[ItemsControl].((vm:InstanceDetailsViewModel)DataContext).RestartServiceCommand}"
CommandParameter="{Binding}"
IsEnabled="{Binding $parent[ItemsControl].((vm:InstanceDetailsViewModel)DataContext).IsBusy, Converter={x:Static BoolConverters.Not}}"
VerticalAlignment="Center"
FontSize="12" Padding="10,6"
ToolTip.Tip="Force-restart this service" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</DockPanel>
</Window>

View File

@@ -1,22 +0,0 @@
using Avalonia.Controls;
using OTSSignsOrchestrator.Desktop.ViewModels;
namespace OTSSignsOrchestrator.Desktop.Views;
public partial class InstanceDetailsWindow : Window
{
public InstanceDetailsWindow()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
}
private void OnDataContextChanged(object? sender, EventArgs e)
{
if (DataContext is InstanceDetailsViewModel vm)
{
vm.ConfirmAsync = async (title, message) =>
await ConfirmationDialog.ShowAsync(this, title, message);
}
}
}

View File

@@ -1,168 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
xmlns:dto="using:OTSSignsOrchestrator.Core.Models.DTOs"
xmlns:svc="using:OTSSignsOrchestrator.Core.Services"
x:Class="OTSSignsOrchestrator.Desktop.Views.InstancesView"
x:DataType="vm:InstancesViewModel">
<DockPanel>
<!-- Page header -->
<StackPanel DockPanel.Dock="Top" Margin="0,0,0,4">
<TextBlock Text="Instances" Classes="pageTitle" />
<TextBlock Text="View and manage deployed CMS stacks" Classes="pageSubtitle" />
</StackPanel>
<!-- Toolbar -->
<Border DockPanel.Dock="Top" Classes="toolbar" Margin="0,0,0,16">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Refresh" Command="{Binding LoadInstancesCommand}" />
<Button Content="Details" Classes="accent" Command="{Binding OpenDetailsCommand}"
IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="View credentials and manage this instance." />
<Button Content="Inspect" Command="{Binding InspectInstanceCommand}" />
<Button Content="Restart Stack" Command="{Binding RestartStackCommand}"
IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Force-restart all services in the selected stack." />
<Button Content="Delete" Classes="danger" Command="{Binding DeleteInstanceCommand}" />
<Border Width="1" Background="{StaticResource BorderSubtleBrush}" Margin="4,2" />
<Button Content="Rotate DB Password" Command="{Binding RotateMySqlPasswordCommand}"
IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Generate a new MySQL password, update the Docker secret, and redeploy the stack." />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<TextBox Text="{Binding FilterText}" Watermark="Filter by name..." Width="220" />
<Button Content="Search" Command="{Binding LoadInstancesCommand}" />
</StackPanel>
</Grid>
</Border>
<!-- Status -->
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Classes="status"
Margin="0,10,0,0" />
<!-- Main content: split into upper (grid + services) and lower (logs) -->
<Grid RowDefinitions="*,Auto,Auto">
<!-- Upper area: instance list + services side panel -->
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
<!-- Instance list -->
<DataGrid Grid.Column="0"
ItemsSource="{Binding Instances}"
SelectedItem="{Binding SelectedInstance}"
AutoGenerateColumns="False"
IsReadOnly="True"
GridLinesVisibility="Horizontal"
CanUserResizeColumns="True">
<DataGrid.Columns>
<DataGridTextColumn Header="Stack" Binding="{Binding StackName}" Width="150" />
<DataGridTextColumn Header="Abbrev" Binding="{Binding CustomerAbbrev}" Width="70" />
<DataGridTextColumn Header="Services" Binding="{Binding ServiceCount}" Width="70" />
<DataGridTextColumn Header="Host" Binding="{Binding HostLabel}" Width="150" />
</DataGrid.Columns>
</DataGrid>
<!-- Services panel (shown when inspecting) -->
<Border Grid.Column="1" Width="360"
IsVisible="{Binding SelectedServices.Count}"
Classes="card" Margin="16,0,0,0">
<StackPanel Spacing="4">
<TextBlock Text="Stack Services" Classes="sectionTitle"
Foreground="{StaticResource AccentBrush}" Margin="0,0,0,10" />
<ItemsControl ItemsSource="{Binding SelectedServices}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="svc:ServiceInfo">
<Border Background="#232336" CornerRadius="6" Padding="12,10" Margin="0,3"
BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="1">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="3">
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="13" />
<TextBlock Text="{Binding Image}" FontSize="11"
Foreground="{StaticResource TextMutedBrush}" />
<TextBlock Text="{Binding Replicas, StringFormat='Replicas: {0}'}"
FontSize="11" Foreground="{StaticResource TextSecondaryBrush}" />
</StackPanel>
<Button Grid.Column="1" Content="Restart"
Command="{Binding $parent[ItemsControl].((vm:InstancesViewModel)DataContext).RestartServiceCommand}"
CommandParameter="{Binding}"
IsEnabled="{Binding $parent[ItemsControl].((vm:InstancesViewModel)DataContext).IsBusy, Converter={x:Static BoolConverters.Not}}"
VerticalAlignment="Center"
FontSize="12" Padding="10,6"
ToolTip.Tip="Force-restart this service" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</Grid>
<!-- Grid splitter between instances and logs -->
<GridSplitter Grid.Row="1" Height="6" HorizontalAlignment="Stretch"
IsVisible="{Binding IsLogsPanelVisible}"
Background="Transparent" />
<!-- Container Logs Panel -->
<Border Grid.Row="2" Classes="card" Margin="0,4,0,0"
IsVisible="{Binding IsLogsPanelVisible}"
MinHeight="180" MaxHeight="400">
<DockPanel>
<!-- Logs toolbar -->
<Grid DockPanel.Dock="Top" ColumnDefinitions="Auto,*,Auto" Margin="0,0,0,8">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8"
VerticalAlignment="Center">
<TextBlock Text="Container Logs" Classes="sectionTitle"
Foreground="{StaticResource AccentBrush}" />
<ComboBox ItemsSource="{Binding LogServiceFilter}"
SelectedItem="{Binding SelectedLogService}"
MinWidth="200" FontSize="12"
ToolTip.Tip="Filter logs by service" />
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding LogsStatusMessage}"
FontSize="11" Foreground="{StaticResource TextMutedBrush}"
VerticalAlignment="Center" HorizontalAlignment="Center" />
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6"
VerticalAlignment="Center">
<Button Content="Refresh" Command="{Binding RefreshLogsCommand}"
FontSize="11" Padding="8,4"
ToolTip.Tip="Fetch latest logs" />
<ToggleButton IsChecked="{Binding IsLogsAutoRefresh}"
Content="Auto"
FontSize="11" Padding="8,4"
ToolTip.Tip="Toggle auto-refresh (every 5 seconds)"
Command="{Binding ToggleLogsAutoRefreshCommand}" />
<Button Content="✕" Command="{Binding CloseLogsPanelCommand}"
FontSize="11" Padding="6,4"
ToolTip.Tip="Close logs panel" />
</StackPanel>
</Grid>
<!-- Log entries list -->
<Border Background="#1a1a2e" CornerRadius="4" Padding="8"
BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="1">
<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding LogEntries}"
x:DataType="vm:InstancesViewModel">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="dto:ServiceLogEntry">
<TextBlock Text="{Binding DisplayLine}"
FontFamily="Cascadia Mono,Consolas,Menlo,monospace"
FontSize="11" Padding="0,1"
TextWrapping="NoWrap"
Foreground="#cccccc" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
</DockPanel>
</Border>
</Grid>
</DockPanel>
</UserControl>

View File

@@ -1,47 +0,0 @@
using Avalonia.Controls;
using OTSSignsOrchestrator.Desktop.ViewModels;
namespace OTSSignsOrchestrator.Desktop.Views;
public partial class InstancesView : UserControl
{
private InstancesViewModel? _vm;
public InstancesView()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
}
private void OnDataContextChanged(object? sender, EventArgs e)
{
if (_vm is not null)
_vm.OpenDetailsRequested -= OnOpenDetailsRequested;
_vm = DataContext as InstancesViewModel;
if (_vm is not null)
{
_vm.OpenDetailsRequested += OnOpenDetailsRequested;
_vm.ConfirmAsync = ShowConfirmationAsync;
}
}
private async Task<bool> ShowConfirmationAsync(string title, string message)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return false;
return await ConfirmationDialog.ShowAsync(owner, title, message);
}
private async void OnOpenDetailsRequested(InstanceDetailsViewModel detailsVm)
{
var window = new InstanceDetailsWindow { DataContext = detailsVm };
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is not null)
await window.ShowDialog(owner);
else
window.Show();
}
}

View File

@@ -1,39 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
x:Class="OTSSignsOrchestrator.Desktop.Views.LogsView"
x:DataType="vm:LogsViewModel">
<DockPanel>
<!-- Page header -->
<StackPanel DockPanel.Dock="Top" Margin="0,0,0,4">
<TextBlock Text="Activity Logs" Classes="pageTitle" />
<TextBlock Text="Deployment history and operations log" Classes="pageSubtitle" />
</StackPanel>
<!-- Toolbar -->
<Border DockPanel.Dock="Top" Classes="toolbar" Margin="0,0,0,16">
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Content="Refresh" Classes="accent" Command="{Binding LoadLogsCommand}" />
<TextBlock Text="{Binding StatusMessage}" Classes="status"
VerticalAlignment="Center" Margin="4,0,0,0" />
</StackPanel>
</Border>
<!-- Logs grid -->
<DataGrid ItemsSource="{Binding Logs}"
AutoGenerateColumns="False"
IsReadOnly="True"
GridLinesVisibility="Horizontal"
CanUserResizeColumns="True">
<DataGrid.Columns>
<DataGridTextColumn Header="Time" Binding="{Binding Timestamp, StringFormat='{}{0:g}'}" Width="150" />
<DataGridTextColumn Header="Operation" Binding="{Binding Operation}" Width="100" />
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="80" />
<DataGridTextColumn Header="Stack" Binding="{Binding StackName}" Width="120" />
<DataGridTextColumn Header="Message" Binding="{Binding Message}" Width="*" />
<DataGridTextColumn Header="Duration" Binding="{Binding DurationMs, StringFormat='{}{0}ms'}" Width="80" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</UserControl>

View File

@@ -1,11 +0,0 @@
using Avalonia.Controls;
namespace OTSSignsOrchestrator.Desktop.Views;
public partial class LogsView : UserControl
{
public LogsView()
{
InitializeComponent();
}
}

View File

@@ -1,123 +0,0 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
xmlns:views="using:OTSSignsOrchestrator.Desktop.Views"
x:Class="OTSSignsOrchestrator.Desktop.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Title="OTS Signs Orchestrator"
Width="1280" Height="850"
WindowStartupLocation="CenterScreen"
ExtendClientAreaToDecorationsHint="False">
<Window.DataTemplates>
<DataTemplate DataType="vm:HostsViewModel">
<views:HostsView />
</DataTemplate>
<DataTemplate DataType="vm:InstancesViewModel">
<views:InstancesView />
</DataTemplate>
<DataTemplate DataType="vm:SecretsViewModel">
<views:SecretsView />
</DataTemplate>
<DataTemplate DataType="vm:LogsViewModel">
<views:LogsView />
</DataTemplate>
<DataTemplate DataType="vm:CreateInstanceViewModel">
<views:CreateInstanceView />
</DataTemplate>
<DataTemplate DataType="vm:SettingsViewModel">
<views:SettingsView />
</DataTemplate>
</Window.DataTemplates>
<DockPanel>
<!-- ═══ Status Bar ═══ -->
<Border DockPanel.Dock="Bottom"
Background="#0C0C14"
BorderBrush="{StaticResource BorderSubtleBrush}"
BorderThickness="0,1,0,0"
Padding="16,6">
<Grid ColumnDefinitions="Auto,*,Auto">
<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<Ellipse Width="7" Height="7" Fill="{StaticResource AccentBrush}" />
<TextBlock Text="Ready" FontSize="11" Foreground="{StaticResource TextMutedBrush}" />
</StackPanel>
<TextBlock Grid.Column="1"
Text="{Binding StatusMessage}"
FontSize="11"
Foreground="{StaticResource TextSecondaryBrush}"
Margin="16,0,0,0"
TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2"
Text="OTS Signs Orchestrator"
FontSize="10"
Foreground="#45475A" />
</Grid>
</Border>
<!-- ═══ Sidebar ═══ -->
<Border DockPanel.Dock="Left"
Width="220"
Background="{StaticResource SurfaceBrush}"
BorderBrush="{StaticResource BorderSubtleBrush}"
BorderThickness="0,0,1,0">
<DockPanel>
<!-- Logo + title area -->
<StackPanel DockPanel.Dock="Top"
HorizontalAlignment="Center"
Margin="20,28,20,8">
<Image Source="avares://OTSSignsOrchestrator.Desktop/Assets/OTS-Signs.png"
Width="56" Height="56"
RenderOptions.BitmapInterpolationMode="HighQuality"
HorizontalAlignment="Center"
Margin="0,0,0,14" />
<TextBlock Text="OTS Signs"
FontSize="18" FontWeight="Bold"
Foreground="{StaticResource TextPrimaryBrush}"
HorizontalAlignment="Center" />
<TextBlock Text="Orchestrator"
FontSize="12"
Foreground="{StaticResource TextMutedBrush}"
HorizontalAlignment="Center"
Margin="0,2,0,0" />
</StackPanel>
<!-- Divider -->
<Border DockPanel.Dock="Top"
Height="1"
Background="{StaticResource BorderSubtleBrush}"
Margin="20,16,20,12" />
<!-- Version at bottom -->
<TextBlock DockPanel.Dock="Bottom"
Text="v1.0.0"
FontSize="10"
Foreground="#3B3B50"
HorizontalAlignment="Center"
Margin="0,0,0,12" />
<!-- Navigation -->
<ListBox ItemsSource="{Binding NavItems}"
SelectedItem="{Binding SelectedNav}"
Classes="sidebar">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}"
FontSize="14"
FontWeight="Medium" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Border>
<!-- ═══ Main Content ═══ -->
<Border Padding="28,24">
<ContentControl Content="{Binding CurrentView}" />
</Border>
</DockPanel>
</Window>

View File

@@ -1,11 +0,0 @@
using Avalonia.Controls;
namespace OTSSignsOrchestrator.Desktop.Views;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}

View File

@@ -1,48 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
x:Class="OTSSignsOrchestrator.Desktop.Views.SecretsView"
x:DataType="vm:SecretsViewModel">
<DockPanel>
<!-- Page header -->
<StackPanel DockPanel.Dock="Top" Margin="0,0,0,4">
<TextBlock Text="Secrets" Classes="pageTitle" />
<TextBlock Text="View Docker secrets on remote hosts" Classes="pageSubtitle" />
</StackPanel>
<!-- Toolbar -->
<Border DockPanel.Dock="Top" Classes="toolbar" Margin="0,0,0,16">
<StackPanel Orientation="Horizontal" Spacing="10">
<ComboBox ItemsSource="{Binding AvailableHosts}"
SelectedItem="{Binding SelectedSshHost}"
PlaceholderText="Select SSH Host..."
Width="260">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Content="Load Secrets" Classes="accent" Command="{Binding LoadSecretsCommand}" />
</StackPanel>
</Border>
<!-- Status -->
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Classes="status"
Margin="0,10,0,0" />
<!-- Secrets grid -->
<DataGrid ItemsSource="{Binding Secrets}"
AutoGenerateColumns="False"
IsReadOnly="True"
GridLinesVisibility="Horizontal"
CanUserResizeColumns="True">
<DataGrid.Columns>
<DataGridTextColumn Header="ID" Binding="{Binding Id}" Width="200" />
<DataGridTextColumn Header="Name" Binding="{Binding Name}" Width="250" />
<DataGridTextColumn Header="Created" Binding="{Binding CreatedAt, StringFormat='{}{0:g}'}" Width="180" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</UserControl>

View File

@@ -1,11 +0,0 @@
using Avalonia.Controls;
namespace OTSSignsOrchestrator.Desktop.Views;
public partial class SecretsView : UserControl
{
public SecretsView()
{
InitializeComponent();
}
}

View File

@@ -1,407 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
x:Class="OTSSignsOrchestrator.Desktop.Views.SettingsView"
x:DataType="vm:SettingsViewModel">
<DockPanel>
<!-- Page header -->
<StackPanel DockPanel.Dock="Top" Margin="0,0,0,4">
<TextBlock Text="Settings" Classes="pageTitle" />
<TextBlock Text="Global configuration for deployments and integrations" Classes="pageSubtitle" />
</StackPanel>
<!-- Top toolbar -->
<Border DockPanel.Dock="Top" Classes="toolbar" Margin="0,0,0,16">
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Content="Push to Bitwarden"
Classes="accent"
Command="{Binding PushToBitwardenCommand}"
IsEnabled="{Binding !IsBusy}"
FontWeight="SemiBold" Padding="20,8" />
<Button Content="Pull from Bitwarden"
Command="{Binding PullFromBitwardenCommand}"
IsEnabled="{Binding !IsBusy}" />
<TextBlock Text="{Binding StatusMessage}" Classes="status"
VerticalAlignment="Center" Margin="6,0,0,0" />
</StackPanel>
</Border>
<!-- Scrollable settings content -->
<ScrollViewer>
<StackPanel Spacing="16" Margin="0,0,16,16" MaxWidth="820">
<!-- ═══ Bitwarden Secrets Manager (Bootstrap — always shown first) ═══ -->
<Border Classes="card">
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
<Border Width="4" Height="20" CornerRadius="2" Background="#818CF8" />
<TextBlock Text="Bitwarden Secrets Manager" FontSize="16" FontWeight="SemiBold"
Foreground="#818CF8" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Text="All application settings are stored in Bitwarden. Configure these credentials first — they are saved to appsettings.json on disk."
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6"
TextWrapping="Wrap" />
<Grid ColumnDefinitions="1*,12,1*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Identity URL" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding BitwardenIdentityUrl}"
Watermark="https://identity.bitwarden.com" />
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="API URL" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding BitwardenApiUrl}"
Watermark="https://api.bitwarden.com" />
</StackPanel>
</Grid>
<TextBlock Text="Machine Account Access Token" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding BitwardenAccessToken}" PasswordChar="●"
Watermark="0.xxxxxxxx.yyyyyyy:zzzzzz" />
<TextBlock Text="Organization ID" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding BitwardenOrganizationId}"
Watermark="00000000-0000-0000-0000-000000000000" />
<TextBlock Text="Project ID (required — config secrets are stored in this project)" FontSize="12"
Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding BitwardenProjectId}"
Watermark="00000000-0000-0000-0000-000000000000" />
<TextBlock Text="Instance Project ID (optional — instance secrets like DB passwords go here; falls back to Project ID if empty)" FontSize="12"
Foreground="{StaticResource TextSecondaryBrush}" TextWrapping="Wrap" />
<TextBox Text="{Binding BitwardenInstanceProjectId}"
Watermark="00000000-0000-0000-0000-000000000000 (leave empty to use default project)" />
<StackPanel Orientation="Horizontal" Spacing="10" Margin="0,6,0,0">
<Button Content="Save Bitwarden Config"
Classes="accent"
Command="{Binding SaveBitwardenLocalCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Content="Test Connection"
Command="{Binding TestBitwardenConnectionCommand}"
IsEnabled="{Binding !IsBusy}" />
</StackPanel>
</StackPanel>
</Border>
<!-- ═══ Remaining settings — disabled until Bitwarden is configured ═══ -->
<StackPanel Spacing="16" IsEnabled="{Binding IsBitwardenConfigured}">
<!-- ═══ Git Repository ═══ -->
<Border Classes="card">
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
<Border Width="4" Height="20" CornerRadius="2" Background="#60A5FA" />
<TextBlock Text="Git Repository" FontSize="16" FontWeight="SemiBold"
Foreground="#60A5FA" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Text="Template repository cloned for each new instance."
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6" />
<TextBlock Text="Repository URL" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding GitRepoUrl}"
Watermark="https://github.com/org/template-repo.git" />
<TextBlock Text="Personal Access Token (PAT)" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding GitRepoPat}" PasswordChar="●"
Watermark="ghp_xxxx..." />
</StackPanel>
</Border>
<!-- ═══ MySQL Connection ═══ -->
<Border Classes="card">
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
<Border Width="4" Height="20" CornerRadius="2" Background="#4ADE80" />
<TextBlock Text="MySQL Connection" FontSize="16" FontWeight="SemiBold"
Foreground="#4ADE80" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Text="Admin credentials used to create databases and users for new instances."
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6" />
<Grid ColumnDefinitions="3*,12,1*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Host" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding MySqlHost}" Watermark="cms-sql.otshosting.app" />
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Port" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding MySqlPort}" Watermark="3306" />
</StackPanel>
</Grid>
<TextBlock Text="Admin Username" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding MySqlAdminUser}" Watermark="root" />
<TextBlock Text="Admin Password" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding MySqlAdminPassword}" PasswordChar="●" />
<Button Content="Test MySQL Connection"
Command="{Binding TestMySqlConnectionCommand}"
IsEnabled="{Binding !IsBusy}"
Margin="0,6,0,0" />
</StackPanel>
</Border>
<!-- ═══ SMTP Settings ═══ -->
<Border Classes="card">
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
<Border Width="4" Height="20" CornerRadius="2" Background="#F472B6" />
<TextBlock Text="SMTP Settings" FontSize="16" FontWeight="SemiBold"
Foreground="#F472B6" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Text="Email configuration applied to all CMS instances."
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6" />
<TextBlock Text="SMTP Server (host:port)" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding SmtpServer}" Watermark="smtp.azurecomm.net:587" />
<TextBlock Text="SMTP Username" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding SmtpUsername}" Watermark="user@domain.com" />
<TextBlock Text="SMTP Password" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding SmtpPassword}" PasswordChar="●" />
<StackPanel Orientation="Horizontal" Spacing="20" Margin="0,4,0,0">
<CheckBox Content="Use TLS" IsChecked="{Binding SmtpUseTls}" />
<CheckBox Content="Use STARTTLS" IsChecked="{Binding SmtpUseStartTls}" />
</StackPanel>
<TextBlock Text="Rewrite Domain" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding SmtpRewriteDomain}" Watermark="ots-signs.com" />
<TextBlock Text="SMTP Hostname" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding SmtpHostname}" Watermark="demo.ots-signs.com" />
<TextBlock Text="From Line Override" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding SmtpFromLineOverride}" Watermark="NO" />
</StackPanel>
</Border>
<!-- ═══ Pangolin / Newt ═══ -->
<Border Classes="card">
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
<Border Width="4" Height="20" CornerRadius="2" Background="#FBBF24" />
<TextBlock Text="Pangolin (Newt Tunnel)" FontSize="16" FontWeight="SemiBold"
Foreground="#FBBF24" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Text="Global Pangolin endpoint. Newt ID and Secret are configured per-instance."
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6" />
<TextBlock Text="Pangolin Endpoint URL" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding PangolinEndpoint}" Watermark="https://app.pangolin.net" />
</StackPanel>
</Border>
<!-- ═══ NFS Volumes ═══ -->
<Border Classes="card">
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
<Border Width="4" Height="20" CornerRadius="2" Background="#C084FC" />
<TextBlock Text="NFS Volumes" FontSize="16" FontWeight="SemiBold"
Foreground="#C084FC" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Text="Network share settings for Docker volumes. Volumes will be mounted via NFS."
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6" />
<TextBlock Text="NFS Server (hostname/IP)" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding NfsServer}" Watermark="nas.local" />
<TextBlock Text="Export Path" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding NfsExport}" Watermark="/srv/nfs" />
<TextBlock Text="Export Folder (optional)" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding NfsExportFolder}" Watermark="ots_cms (leave empty for export root)" />
<TextBlock Text="Extra Mount Options" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding NfsOptions}" Watermark="Additional options after nfsvers=4,proto=tcp" />
</StackPanel>
</Border>
<!-- ═══ Instance Defaults ═══ -->
<Border Classes="card">
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
<Border Width="4" Height="20" CornerRadius="2" Background="#2DD4BF" />
<TextBlock Text="Instance Defaults" FontSize="16" FontWeight="SemiBold"
Foreground="#2DD4BF" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Text="Default Docker images, naming templates, and PHP settings for new instances. Use {abbrev} as a placeholder for the customer abbreviation."
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6" TextWrapping="Wrap" />
<!-- Sub-section: Docker Images -->
<TextBlock Text="Docker Images" FontSize="13" FontWeight="SemiBold" Margin="0,8,0,4"
Foreground="{StaticResource TextPrimaryBrush}" />
<TextBlock Text="CMS Image" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding DefaultCmsImage}"
Watermark="ghcr.io/xibosignage/xibo-cms:release-4.2.3" />
<TextBlock Text="Newt Image" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding DefaultNewtImage}" Watermark="fosrl/newt" />
<TextBlock Text="Memcached Image" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding DefaultMemcachedImage}" Watermark="memcached:alpine" />
<TextBlock Text="QuickChart Image" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding DefaultQuickChartImage}" Watermark="ianw/quickchart" />
<!-- Sub-section: Naming Templates -->
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,12,0,4" />
<TextBlock Text="Naming Templates" FontSize="13" FontWeight="SemiBold" Margin="0,4,0,4"
Foreground="{StaticResource TextPrimaryBrush}" />
<TextBlock Text="CMS Server Name Template" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding DefaultCmsServerNameTemplate}"
Watermark="{}{abbrev}.ots-signs.com" />
<TextBlock Text="Theme Host Path Template" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding DefaultThemeHostPath}"
Watermark="/cms/{abbrev}-cms-theme-custom" />
<TextBlock Text="MySQL Database Name Template" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding DefaultMySqlDbTemplate}" Watermark="{}{abbrev}_cms_db" />
<TextBlock Text="MySQL User Template" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding DefaultMySqlUserTemplate}" Watermark="{}{abbrev}_cms_user" />
<!-- Sub-section: PHP Settings -->
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,12,0,4" />
<TextBlock Text="PHP Settings" FontSize="13" FontWeight="SemiBold" Margin="0,4,0,4"
Foreground="{StaticResource TextPrimaryBrush}" />
<Grid ColumnDefinitions="1*,12,1*,12,1*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Post Max Size" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding DefaultPhpPostMaxSize}" Watermark="10G" />
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Upload Max Filesize" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding DefaultPhpUploadMaxFilesize}" Watermark="10G" />
</StackPanel>
<StackPanel Grid.Column="4" Spacing="4">
<TextBlock Text="Max Execution Time" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding DefaultPhpMaxExecutionTime}" Watermark="600" />
</StackPanel>
</Grid>
</StackPanel>
</Border>
<!-- ═══ Xibo Bootstrap OAuth2 ═══ -->
<Border Classes="card">
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
<Border Width="4" Height="20" CornerRadius="2" Background="#F97316" />
<TextBlock Text="Xibo Bootstrap OAuth2" FontSize="16" FontWeight="SemiBold"
Foreground="#F97316" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Text="A pre-configured Xibo OAuth2 client_credentials application used for post-install setup (creating admin users, registering OTS app, setting theme). Create once in the Xibo admin panel of any instance."
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6"
TextWrapping="Wrap" />
<TextBlock Text="Bootstrap Client ID" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding XiboBootstrapClientId}"
Watermark="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" />
<TextBlock Text="Bootstrap Client Secret" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding XiboBootstrapClientSecret}" PasswordChar="●" />
<Button Content="Save &amp; Verify"
Command="{Binding TestXiboBootstrapCommand}"
IsEnabled="{Binding !IsBusy}"
Margin="0,6,0,0" />
</StackPanel>
</Border>
<!-- ═══ Authentik (SAML IdP) ═══ -->
<Border Classes="card">
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
<Border Width="4" Height="20" CornerRadius="2" Background="#FB923C" />
<TextBlock Text="Authentik (SAML IdP)" FontSize="16" FontWeight="SemiBold"
Foreground="#FB923C" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Text="Authentik identity provider settings. A SAML application is automatically provisioned in Authentik for each new instance during post-init, and a settings-custom.php file is deployed."
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6"
TextWrapping="Wrap" />
<TextBlock Text="Authentik Base URL" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding AuthentikUrl}"
Watermark="https://id.oribi-tech.com" />
<TextBlock Text="API Token" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<TextBox Text="{Binding AuthentikApiKey}" PasswordChar="●"
Watermark="Bearer token for /api/v3/" />
<!-- Save & Test button -->
<StackPanel Orientation="Horizontal" Spacing="10" Margin="0,8,0,0">
<Button Content="Save &amp; Test Connection"
Classes="accent"
Command="{Binding SaveAndTestAuthentikCommand}"
IsEnabled="{Binding !IsAuthentikBusy}"
FontWeight="SemiBold" Padding="16,8" />
<Button Content="Refresh Dropdowns"
Command="{Binding FetchAuthentikDropdownsCommand}"
IsEnabled="{Binding !IsAuthentikBusy}"
Padding="16,8" />
</StackPanel>
<TextBlock Text="{Binding AuthentikStatusMessage}"
FontSize="12" Foreground="{StaticResource TextSecondaryBrush}"
TextWrapping="Wrap" Margin="0,2,0,0" />
<!-- Flow / Keypair dropdowns -->
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,12,0,4" />
<TextBlock Text="Flows &amp; Keypairs" FontSize="13" FontWeight="SemiBold" Margin="0,4,0,4"
Foreground="{StaticResource TextPrimaryBrush}" />
<TextBlock Text="These are loaded from your Authentik instance. Save &amp; Test to populate."
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,4"
TextWrapping="Wrap" />
<TextBlock Text="Authorization Flow" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<ComboBox ItemsSource="{Binding AuthentikAuthorizationFlows}"
SelectedItem="{Binding SelectedAuthorizationFlow}"
HorizontalAlignment="Stretch"
PlaceholderText="(save &amp; test to load flows)">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:SettingsViewModel">
<TextBlock Text="{Binding}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="Invalidation Flow" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<ComboBox ItemsSource="{Binding AuthentikInvalidationFlows}"
SelectedItem="{Binding SelectedInvalidationFlow}"
HorizontalAlignment="Stretch"
PlaceholderText="(save &amp; test to load flows)">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:SettingsViewModel">
<TextBlock Text="{Binding}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="Signing Keypair" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
<ComboBox ItemsSource="{Binding AuthentikKeypairs}"
SelectedItem="{Binding SelectedSigningKeypair}"
HorizontalAlignment="Stretch"
PlaceholderText="(save &amp; test to load keypairs)">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:SettingsViewModel">
<TextBlock Text="{Binding}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
</Border>
</StackPanel> <!-- end of IsBitwardenConfigured wrapper -->
</StackPanel>
</ScrollViewer>
</DockPanel>
</UserControl>

View File

@@ -1,11 +0,0 @@
using Avalonia.Controls;
namespace OTSSignsOrchestrator.Desktop.Views;
public partial class SettingsView : UserControl
{
public SettingsView()
{
InitializeComponent();
}
}

View File

@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="OTSSignsOrchestrator.Desktop"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>

View File

@@ -1,53 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.EntityFrameworkCore": "Information"
}
},
"FileLogging": {
"Enabled": true,
"Path": "logs",
"RollingInterval": "Day",
"RetentionDays": 7
},
"Git": {
"CacheDir": ".template-cache"
},
"Docker": {
"DefaultConstraints": [ "node.labels.xibo==true" ],
"DeployTimeoutSeconds": 30,
"ValidateBeforeDeploy": true
},
"Xibo": {
"DefaultImages": {
"Cms": "ghcr.io/xibosignage/xibo-cms:release-4.4.0",
"Mysql": "mysql:8.4",
"Memcached": "memcached:alpine",
"QuickChart": "ianw/quickchart"
},
"TestConnectionTimeoutSeconds": 10
},
"Database": {
"Provider": "Sqlite"
},
"Bitwarden": {
"IdentityUrl": "https://identity.bitwarden.com",
"ApiUrl": "https://api.bitwarden.com",
"AccessToken": "",
"OrganizationId": "",
"ProjectId": "",
"InstanceProjectId": ""
},
"ConnectionStrings": {
"Default": "Data Source=otssigns-desktop.db"
},
"InstanceDefaults": {
"CmsServerNameTemplate": "app.ots-signs.com",
"ThemeHostPath": "/cms/ots-theme",
"LibraryShareSubPath": "{abbrev}-cms-library",
"MySqlDatabaseTemplate": "{abbrev}_cms_db",
"MySqlUserTemplate": "{abbrev}_cms_user",
"BaseHostHttpPort": 8080
}
}

View File

@@ -1,102 +0,0 @@
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.Server.Data;
using OTSSignsOrchestrator.Server.Data.Entities;
namespace OTSSignsOrchestrator.Server.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)
{
_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)
{
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.Jti, Guid.NewGuid().ToString()),
};
var token = new JwtSecurityToken(
issuer: _jwt.Issuer,
audience: _jwt.Audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(15),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
private async Task<string> CreateRefreshTokenAsync(Guid operatorId)
{
var tokenValue = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
_db.RefreshTokens.Add(new RefreshToken
{
Id = Guid.NewGuid(),
OperatorId = operatorId,
Token = tokenValue,
ExpiresAt = DateTime.UtcNow.AddDays(7),
});
await _db.SaveChangesAsync();
return tokenValue;
}
}

View File

@@ -1,18 +0,0 @@
namespace OTSSignsOrchestrator.Server.Data.Entities;
public enum OperatorRole
{
Admin,
Viewer
}
public class Operator
{
public Guid Id { get; set; }
public string Email { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
public OperatorRole Role { get; set; }
public DateTime CreatedAt { get; set; }
public ICollection<RefreshToken> RefreshTokens { get; set; } = [];
}

View File

@@ -1,12 +0,0 @@
namespace OTSSignsOrchestrator.Server.Data.Entities;
public class RefreshToken
{
public Guid Id { get; set; }
public Guid OperatorId { get; set; }
public string Token { get; set; } = string.Empty;
public DateTime ExpiresAt { get; set; }
public DateTime? RevokedAt { get; set; }
public Operator Operator { get; set; } = null!;
}

View File

@@ -1,106 +0,0 @@
using Renci.SshNet;
using OTSSignsOrchestrator.Server.Data.Entities;
namespace OTSSignsOrchestrator.Server.Health.Checks;
/// <summary>
/// Verifies connectivity to the instance's MySQL database by running a simple query
/// via SSH against the Docker Swarm host.
/// </summary>
public sealed class MySqlConnectHealthCheck : IHealthCheck
{
private readonly IServiceProvider _services;
private readonly ILogger<MySqlConnectHealthCheck> _logger;
public string CheckName => "MySqlConnect";
public bool AutoRemediate => false;
public MySqlConnectHealthCheck(
IServiceProvider services,
ILogger<MySqlConnectHealthCheck> logger)
{
_services = services;
_logger = logger;
}
public async Task<HealthCheckResult> RunAsync(Instance instance, CancellationToken ct)
{
var dbName = instance.MysqlDatabase;
if (string.IsNullOrEmpty(dbName))
return new HealthCheckResult(HealthStatus.Critical, "No MySQL database configured");
try
{
var settings = _services.GetRequiredService<Core.Services.SettingsService>();
var sshInfo = await GetSwarmSshHostAsync(settings);
var mysqlHost = await settings.GetAsync(Core.Services.SettingsService.MySqlHost, "localhost");
var mysqlPort = await settings.GetAsync(Core.Services.SettingsService.MySqlPort, "3306");
var mysqlUser = await settings.GetAsync(Core.Services.SettingsService.MySqlAdminUser, "root");
var mysqlPass = await settings.GetAsync(Core.Services.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);
return new HealthCheckResult(HealthStatus.Healthy,
$"MySQL connection to {dbName} successful");
}
finally
{
sshClient.Disconnect();
}
}
catch (Exception ex)
{
return new HealthCheckResult(HealthStatus.Critical,
$"MySQL connection failed for {dbName}: {ex.Message}");
}
}
private static async Task<SshConnectionInfo> GetSwarmSshHostAsync(Core.Services.SettingsService settings)
{
var host = await settings.GetAsync("Ssh.SwarmHost")
?? throw new InvalidOperationException("SSH Swarm host not configured.");
var portStr = await settings.GetAsync("Ssh.SwarmPort", "22");
var user = await settings.GetAsync("Ssh.SwarmUser", "root");
var keyPath = await settings.GetAsync("Ssh.SwarmKeyPath");
var password = await settings.GetAsync("Ssh.SwarmPassword");
if (!int.TryParse(portStr, out var port)) port = 22;
return new SshConnectionInfo(host, port, user, keyPath, password);
}
private static SshClient CreateSshClient(SshConnectionInfo info)
{
var authMethods = new List<AuthenticationMethod>();
if (!string.IsNullOrEmpty(info.KeyPath))
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(info.KeyPath)));
if (!string.IsNullOrEmpty(info.Password))
authMethods.Add(new PasswordAuthenticationMethod(info.Username, info.Password));
if (authMethods.Count == 0)
{
var defaultKeyPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh", "id_rsa");
if (File.Exists(defaultKeyPath))
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(defaultKeyPath)));
else
throw new InvalidOperationException($"No SSH auth method for {info.Host}:{info.Port}.");
}
var connInfo = new Renci.SshNet.ConnectionInfo(info.Host, info.Port, info.Username, authMethods.ToArray());
return new SshClient(connInfo);
}
private static string RunSshCommand(SshClient client, string command)
{
using var cmd = client.RunCommand(command);
if (cmd.ExitStatus != 0)
throw new InvalidOperationException($"SSH command failed (exit {cmd.ExitStatus}): {cmd.Error}");
return cmd.Result;
}
internal sealed record SshConnectionInfo(string Host, int Port, string Username, string? KeyPath, string? Password);
}

View File

@@ -1,121 +0,0 @@
using Renci.SshNet;
using OTSSignsOrchestrator.Server.Data.Entities;
namespace OTSSignsOrchestrator.Server.Health.Checks;
/// <summary>
/// Verifies NFS paths for the instance are accessible by running <c>ls</c> via SSH.
/// </summary>
public sealed class NfsAccessHealthCheck : IHealthCheck
{
private readonly IServiceProvider _services;
private readonly ILogger<NfsAccessHealthCheck> _logger;
public string CheckName => "NfsAccess";
public bool AutoRemediate => false;
public NfsAccessHealthCheck(
IServiceProvider services,
ILogger<NfsAccessHealthCheck> logger)
{
_services = services;
_logger = logger;
}
public async Task<HealthCheckResult> RunAsync(Instance instance, CancellationToken ct)
{
var nfsPath = instance.NfsPath;
if (string.IsNullOrEmpty(nfsPath))
return new HealthCheckResult(HealthStatus.Critical, "No NFS path configured");
try
{
var settings = _services.GetRequiredService<Core.Services.SettingsService>();
var sshInfo = await GetSwarmSshHostAsync(settings);
var nfsServer = await settings.GetAsync(Core.Services.SettingsService.NfsServer);
var nfsExport = await settings.GetAsync(Core.Services.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();
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");
return new HealthCheckResult(HealthStatus.Healthy,
$"NFS path accessible: {nfsPath}");
}
finally
{
RunSshCommandAllowFailure(sshClient, $"sudo umount {mountPoint}");
RunSshCommandAllowFailure(sshClient, $"sudo rmdir {mountPoint}");
}
}
finally
{
sshClient.Disconnect();
}
}
catch (Exception ex)
{
return new HealthCheckResult(HealthStatus.Critical,
$"NFS access check failed for {nfsPath}: {ex.Message}");
}
}
private static async Task<SshConnectionInfo> GetSwarmSshHostAsync(Core.Services.SettingsService settings)
{
var host = await settings.GetAsync("Ssh.SwarmHost")
?? throw new InvalidOperationException("SSH Swarm host not configured.");
var portStr = await settings.GetAsync("Ssh.SwarmPort", "22");
var user = await settings.GetAsync("Ssh.SwarmUser", "root");
var keyPath = await settings.GetAsync("Ssh.SwarmKeyPath");
var password = await settings.GetAsync("Ssh.SwarmPassword");
if (!int.TryParse(portStr, out var port)) port = 22;
return new SshConnectionInfo(host, port, user, keyPath, password);
}
private static SshClient CreateSshClient(SshConnectionInfo info)
{
var authMethods = new List<AuthenticationMethod>();
if (!string.IsNullOrEmpty(info.KeyPath))
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(info.KeyPath)));
if (!string.IsNullOrEmpty(info.Password))
authMethods.Add(new PasswordAuthenticationMethod(info.Username, info.Password));
if (authMethods.Count == 0)
{
var defaultKeyPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh", "id_rsa");
if (File.Exists(defaultKeyPath))
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(defaultKeyPath)));
else
throw new InvalidOperationException($"No SSH auth method for {info.Host}:{info.Port}.");
}
var connInfo = new Renci.SshNet.ConnectionInfo(info.Host, info.Port, info.Username, authMethods.ToArray());
return new SshClient(connInfo);
}
private static string RunSshCommand(SshClient client, string command)
{
using var cmd = client.RunCommand(command);
if (cmd.ExitStatus != 0)
throw new InvalidOperationException($"SSH command failed (exit {cmd.ExitStatus}): {cmd.Error}");
return cmd.Result;
}
private static void RunSshCommandAllowFailure(SshClient client, string command)
{
using var cmd = client.RunCommand(command);
// Intentionally ignore exit code — cleanup operations
}
internal sealed record SshConnectionInfo(string Host, int Port, string Username, string? KeyPath, string? Password);
}

View File

@@ -1,127 +0,0 @@
using Renci.SshNet;
using OTSSignsOrchestrator.Server.Data.Entities;
namespace OTSSignsOrchestrator.Server.Health.Checks;
/// <summary>
/// Verifies the Docker stack is healthy by running <c>docker stack ps {stackName}</c>
/// via SSH and checking that all services report Running state.
/// </summary>
public sealed class StackHealthCheck : IHealthCheck
{
private readonly IServiceProvider _services;
private readonly ILogger<StackHealthCheck> _logger;
public string CheckName => "StackHealth";
public bool AutoRemediate => false;
public StackHealthCheck(
IServiceProvider services,
ILogger<StackHealthCheck> logger)
{
_services = services;
_logger = logger;
}
public async Task<HealthCheckResult> RunAsync(Instance instance, CancellationToken ct)
{
var stackName = instance.DockerStackName;
if (string.IsNullOrEmpty(stackName))
return new HealthCheckResult(HealthStatus.Critical, "No Docker stack name configured");
try
{
var settings = _services.GetRequiredService<Core.Services.SettingsService>();
var sshInfo = await GetSwarmSshHostAsync(settings);
using var sshClient = CreateSshClient(sshInfo);
sshClient.Connect();
try
{
// Get task status for all services in the stack
var output = RunSshCommand(sshClient,
$"docker stack ps {stackName} --format '{{{{.Name}}}}|{{{{.CurrentState}}}}|{{{{.DesiredState}}}}'");
var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
var notRunning = new List<string>();
foreach (var line in lines)
{
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}");
}
}
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();
}
}
catch (Exception ex)
{
return new HealthCheckResult(HealthStatus.Critical,
$"SSH check failed for {stackName}: {ex.Message}");
}
}
private static async Task<SshConnectionInfo> GetSwarmSshHostAsync(Core.Services.SettingsService settings)
{
var host = await settings.GetAsync("Ssh.SwarmHost")
?? throw new InvalidOperationException("SSH Swarm host not configured.");
var portStr = await settings.GetAsync("Ssh.SwarmPort", "22");
var user = await settings.GetAsync("Ssh.SwarmUser", "root");
var keyPath = await settings.GetAsync("Ssh.SwarmKeyPath");
var password = await settings.GetAsync("Ssh.SwarmPassword");
if (!int.TryParse(portStr, out var port)) port = 22;
return new SshConnectionInfo(host, port, user, keyPath, password);
}
private static SshClient CreateSshClient(SshConnectionInfo info)
{
var authMethods = new List<AuthenticationMethod>();
if (!string.IsNullOrEmpty(info.KeyPath))
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(info.KeyPath)));
if (!string.IsNullOrEmpty(info.Password))
authMethods.Add(new PasswordAuthenticationMethod(info.Username, info.Password));
if (authMethods.Count == 0)
{
var defaultKeyPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh", "id_rsa");
if (File.Exists(defaultKeyPath))
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(defaultKeyPath)));
else
throw new InvalidOperationException($"No SSH auth method for {info.Host}:{info.Port}.");
}
var connInfo = new Renci.SshNet.ConnectionInfo(info.Host, info.Port, info.Username, authMethods.ToArray());
return new SshClient(connInfo);
}
private static string RunSshCommand(SshClient client, string command)
{
using var cmd = client.RunCommand(command);
if (cmd.ExitStatus != 0)
throw new InvalidOperationException($"SSH command failed (exit {cmd.ExitStatus}): {cmd.Error}");
return cmd.Result;
}
internal sealed record SshConnectionInfo(string Host, int Port, string Username, string? KeyPath, string? Password);
}

View File

@@ -1,257 +0,0 @@
using System.Text;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Microsoft.Extensions.Options;
using OTSSignsOrchestrator.Server.Api;
using OTSSignsOrchestrator.Server.Auth;
using OTSSignsOrchestrator.Server.Clients;
using OTSSignsOrchestrator.Server.Data;
using OTSSignsOrchestrator.Server.Hubs;
using OTSSignsOrchestrator.Server.Reports;
using OTSSignsOrchestrator.Server.Services;
using OTSSignsOrchestrator.Server.Webhooks;
using OTSSignsOrchestrator.Server.Workers;
using Refit;
using Quartz;
using OTSSignsOrchestrator.Server.Jobs;
using OTSSignsOrchestrator.Server.Health;
using OTSSignsOrchestrator.Server.Health.Checks;
using Serilog;
using Stripe;
var builder = WebApplication.CreateBuilder(args);
// ── Serilog ──────────────────────────────────────────────────────────────────
builder.Host.UseSerilog((context, config) =>
config.ReadFrom.Configuration(context.Configuration));
// ── EF Core — PostgreSQL ─────────────────────────────────────────────────────
builder.Services.AddDbContext<OrchestratorDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("OrchestratorDb")));
// ── JWT Authentication ──────────────────────────────────────────────────────
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection(JwtOptions.Section));
var jwtKey = builder.Configuration[$"{JwtOptions.Section}:Key"]
?? throw new InvalidOperationException("Jwt:Key must be configured.");
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration[$"{JwtOptions.Section}:Issuer"] ?? "OTSSignsOrchestrator",
ValidAudience = builder.Configuration[$"{JwtOptions.Section}:Audience"] ?? "OTSSignsOrchestrator",
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
ClockSkew = TimeSpan.FromSeconds(30),
};
// Allow SignalR to receive the JWT via query string
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
context.Token = accessToken;
return Task.CompletedTask;
},
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("CustomerPortal", policy =>
policy.RequireClaim("customer_id"));
});
// ── Application services ────────────────────────────────────────────────────
builder.Services.AddScoped<OperatorAuthService>();
builder.Services.AddScoped<AbbreviationService>();
builder.Services.Configure<EmailOptions>(builder.Configuration.GetSection(EmailOptions.Section));
builder.Services.AddSingleton<EmailService>();
// ── Report services ─────────────────────────────────────────────────────────
builder.Services.AddScoped<BillingReportService>();
builder.Services.AddScoped<FleetHealthPdfService>();
// ── Provisioning pipelines + worker ─────────────────────────────────────────
builder.Services.AddScoped<IProvisioningPipeline, Phase1Pipeline>();
builder.Services.AddScoped<IProvisioningPipeline, Phase2Pipeline>();
builder.Services.AddScoped<IProvisioningPipeline, ByoiSamlPipeline>();
builder.Services.AddScoped<IProvisioningPipeline, SuspendPipeline>();
builder.Services.AddScoped<IProvisioningPipeline, ReactivatePipeline>();
builder.Services.AddScoped<IProvisioningPipeline, UpdateScreenLimitPipeline>();
builder.Services.AddScoped<IProvisioningPipeline, DecommissionPipeline>();
builder.Services.AddScoped<IProvisioningPipeline, RotateCredentialsPipeline>();
builder.Services.AddHostedService<ProvisioningWorker>();
// ── External API clients ────────────────────────────────────────────────────
builder.Services.AddHttpClient();
builder.Services.AddSingleton<XiboClientFactory>();
builder.Services.Configure<AuthentikOptions>(
builder.Configuration.GetSection(AuthentikOptions.Section));
builder.Services.AddRefitClient<IAuthentikClient>()
.ConfigureHttpClient((sp, client) =>
{
var opts = sp.GetRequiredService<IOptions<AuthentikOptions>>().Value;
client.BaseAddress = new Uri(opts.BaseUrl.TrimEnd('/'));
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", opts.ApiToken);
});
// ── Stripe ──────────────────────────────────────────────────────────────────
StripeConfiguration.ApiKey = builder.Configuration["Stripe:SecretKey"];
// ── Health check engine + individual checks ─────────────────────────────────
builder.Services.AddHostedService<HealthCheckEngine>();
builder.Services.AddScoped<IHealthCheck, XiboApiHealthCheck>();
builder.Services.AddScoped<IHealthCheck, AdminIntegrityHealthCheck>();
builder.Services.AddScoped<IHealthCheck, GroupStructureHealthCheck>();
builder.Services.AddScoped<IHealthCheck, OauthAppHealthCheck>();
builder.Services.AddScoped<IHealthCheck, DisplayAuthorisedHealthCheck>();
builder.Services.AddScoped<IHealthCheck, StackHealthCheck>();
builder.Services.AddScoped<IHealthCheck, MySqlConnectHealthCheck>();
builder.Services.AddScoped<IHealthCheck, NfsAccessHealthCheck>();
builder.Services.AddScoped<IHealthCheck, ThemeHealthCheck>();
builder.Services.AddScoped<IHealthCheck, XiboVersionHealthCheck>();
builder.Services.AddScoped<IHealthCheck, OauthAppAgeHealthCheck>();
builder.Services.AddScoped<IHealthCheck, ByoiCertExpiryHealthCheck>();
builder.Services.AddScoped<IHealthCheck, AuthentikGlobalHealthCheck>();
builder.Services.AddScoped<IHealthCheck, AuthentikSamlProviderHealthCheck>();
builder.Services.AddScoped<IHealthCheck, InvitationFlowHealthCheck>();
// AuthentikGlobalHealthCheck also registered as concrete type for the Quartz job
builder.Services.AddScoped<AuthentikGlobalHealthCheck>();
// ── Quartz scheduler ─────────────────────────────────────────────────────────
builder.Services.AddQuartz(q =>
{
var certExpiryKey = new JobKey("byoi-cert-expiry-global", "byoi-cert-expiry");
q.AddJob<ByoiCertExpiryJob>(opts => opts.WithIdentity(certExpiryKey).StoreDurably());
q.AddTrigger(opts => opts
.ForJob(certExpiryKey)
.WithIdentity("byoi-cert-expiry-global-trigger", "byoi-cert-expiry")
.WithSimpleSchedule(s => s
.WithIntervalInHours(24)
.RepeatForever())
.StartNow());
// Authentik global health check — every 2 minutes
var authentikHealthKey = new JobKey("authentik-global-health", "health-checks");
q.AddJob<AuthentikGlobalHealthJob>(opts => opts.WithIdentity(authentikHealthKey).StoreDurably());
q.AddTrigger(opts => opts
.ForJob(authentikHealthKey)
.WithIdentity("authentik-global-health-trigger", "health-checks")
.WithSimpleSchedule(s => s
.WithIntervalInMinutes(2)
.RepeatForever())
.StartNow());
// Daily screen snapshot — 2 AM UTC
var dailySnapshotKey = new JobKey("daily-snapshot", "snapshots");
q.AddJob<OTSSignsOrchestrator.Server.Jobs.DailySnapshotJob>(opts => opts.WithIdentity(dailySnapshotKey).StoreDurably());
q.AddTrigger(opts => opts
.ForJob(dailySnapshotKey)
.WithIdentity("daily-snapshot-trigger", "snapshots")
.WithCronSchedule("0 0 2 * * ?"));
// Scheduled reports — weekly (Monday 08:00 UTC) + monthly (1st 08:00 UTC)
var reportJobKey = new JobKey("scheduled-report", "reports");
q.AddJob<ScheduledReportJob>(opts => opts.WithIdentity(reportJobKey).StoreDurably());
q.AddTrigger(opts => opts
.ForJob(reportJobKey)
.WithIdentity("weekly-report-trigger", "reports")
.UsingJobData(ScheduledReportJob.IsMonthlyKey, false)
.WithCronSchedule("0 0 8 ? * MON *"));
q.AddTrigger(opts => opts
.ForJob(reportJobKey)
.WithIdentity("monthly-report-trigger", "reports")
.UsingJobData(ScheduledReportJob.IsMonthlyKey, true)
.WithCronSchedule("0 0 8 1 * ? *"));
});
builder.Services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
// ── SignalR ──────────────────────────────────────────────────────────────────
builder.Services.AddSignalR();
// ── Rate limiting ────────────────────────────────────────────────────────────
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = 429;
options.AddFixedWindowLimiter("fixed", limiter =>
{
limiter.PermitLimit = 60;
limiter.Window = TimeSpan.FromMinutes(1);
});
options.AddSlidingWindowLimiter("signup", limiter =>
{
limiter.PermitLimit = 3;
limiter.Window = TimeSpan.FromMinutes(10);
limiter.SegmentsPerWindow = 2;
});
});
var app = builder.Build();
// ── Middleware ────────────────────────────────────────────────────────────────
app.UseSerilogRequestLogging();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
// ── Auth endpoints (no auth required) ────────────────────────────────────────
app.MapPost("/api/auth/login", async (LoginRequest req, OperatorAuthService auth) =>
{
try
{
var (jwt, refresh) = await auth.LoginAsync(req.Email, req.Password);
return Results.Ok(new { token = jwt, refreshToken = refresh });
}
catch (UnauthorizedAccessException)
{
return Results.Unauthorized();
}
});
app.MapPost("/api/auth/refresh", async (RefreshRequest req, OperatorAuthService auth) =>
{
try
{
var jwt = await auth.RefreshAsync(req.RefreshToken);
return Results.Ok(new { token = jwt });
}
catch (UnauthorizedAccessException)
{
return Results.Unauthorized();
}
});
// ── Signup endpoints (no auth) ──────────────────────────────────────────────
app.MapSignupEndpoints();
// ── Stripe webhook (no auth, no rate limit) ─────────────────────────────────
app.MapStripeWebhook();
// ── Fleet + Jobs REST endpoints (auth required) ─────────────────────────────
app.MapFleetEndpoints();
// ── Customer Portal BYOI endpoints (customer JWT required) ──────────────
app.MapCustomerPortalEndpoints();
// ── SignalR hub ─────────────────────────────────────────────────────────────
app.MapHub<FleetHub>("/hubs/fleet");
app.Run();
// ── Request DTOs for auth endpoints ─────────────────────────────────────────
public record LoginRequest(string Email, string Password);
public record RefreshRequest(string RefreshToken);

View File

@@ -1,6 +1,6 @@
using OTSSignsOrchestrator.Server.Jobs;
using OTSSignsOrchestrator.Jobs;
namespace OTSSignsOrchestrator.Server.Tests;
namespace OTSSignsOrchestrator.Tests;
public class ByoiCertExpiryThresholdTests
{

View File

@@ -19,7 +19,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OTSSignsOrchestrator.Server\OTSSignsOrchestrator.Server.csproj" />
<ProjectReference Include="..\OTSSignsOrchestrator\OTSSignsOrchestrator.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,74 +1,26 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OTSSignsOrchestrator.Core", "OTSSignsOrchestrator.Core\OTSSignsOrchestrator.Core.csproj", "{A1B2C3D4-1111-2222-3333-444455556666}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OTSSignsOrchestrator", "OTSSignsOrchestrator\OTSSignsOrchestrator.csproj", "{C36D7809-5824-4AE0-912E-DBB18E05CF46}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OTSSignsOrchestrator.Desktop", "OTSSignsOrchestrator.Desktop\OTSSignsOrchestrator.Desktop.csproj", "{B2C3D4E5-5555-6666-7777-888899990000}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OTSSignsOrchestrator.Server", "OTSSignsOrchestrator.Server\OTSSignsOrchestrator.Server.csproj", "{C36D7809-5824-4AE0-912E-DBB18E05CF46}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OTSSignsOrchestrator.Server.Tests", "OTSSignsOrchestrator.Server.Tests\OTSSignsOrchestrator.Server.Tests.csproj", "{452C671A-9730-44CF-A9B8-083CE36A4578}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OTSSignsOrchestrator.Tests", "OTSSignsOrchestrator.Tests\OTSSignsOrchestrator.Tests.csproj", "{452C671A-9730-44CF-A9B8-083CE36A4578}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A1B2C3D4-1111-2222-3333-444455556666}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-1111-2222-3333-444455556666}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-1111-2222-3333-444455556666}.Debug|x64.ActiveCfg = Debug|Any CPU
{A1B2C3D4-1111-2222-3333-444455556666}.Debug|x64.Build.0 = Debug|Any CPU
{A1B2C3D4-1111-2222-3333-444455556666}.Debug|x86.ActiveCfg = Debug|Any CPU
{A1B2C3D4-1111-2222-3333-444455556666}.Debug|x86.Build.0 = Debug|Any CPU
{A1B2C3D4-1111-2222-3333-444455556666}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-1111-2222-3333-444455556666}.Release|Any CPU.Build.0 = Release|Any CPU
{A1B2C3D4-1111-2222-3333-444455556666}.Release|x64.ActiveCfg = Release|Any CPU
{A1B2C3D4-1111-2222-3333-444455556666}.Release|x64.Build.0 = Release|Any CPU
{A1B2C3D4-1111-2222-3333-444455556666}.Release|x86.ActiveCfg = Release|Any CPU
{A1B2C3D4-1111-2222-3333-444455556666}.Release|x86.Build.0 = Release|Any CPU
{B2C3D4E5-5555-6666-7777-888899990000}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B2C3D4E5-5555-6666-7777-888899990000}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B2C3D4E5-5555-6666-7777-888899990000}.Debug|x64.ActiveCfg = Debug|Any CPU
{B2C3D4E5-5555-6666-7777-888899990000}.Debug|x64.Build.0 = Debug|Any CPU
{B2C3D4E5-5555-6666-7777-888899990000}.Debug|x86.ActiveCfg = Debug|Any CPU
{B2C3D4E5-5555-6666-7777-888899990000}.Debug|x86.Build.0 = Debug|Any CPU
{B2C3D4E5-5555-6666-7777-888899990000}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B2C3D4E5-5555-6666-7777-888899990000}.Release|Any CPU.Build.0 = Release|Any CPU
{B2C3D4E5-5555-6666-7777-888899990000}.Release|x64.ActiveCfg = Release|Any CPU
{B2C3D4E5-5555-6666-7777-888899990000}.Release|x64.Build.0 = Release|Any CPU
{B2C3D4E5-5555-6666-7777-888899990000}.Release|x86.ActiveCfg = Release|Any CPU
{B2C3D4E5-5555-6666-7777-888899990000}.Release|x86.Build.0 = Release|Any CPU
{C36D7809-5824-4AE0-912E-DBB18E05CF46}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C36D7809-5824-4AE0-912E-DBB18E05CF46}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C36D7809-5824-4AE0-912E-DBB18E05CF46}.Debug|x64.ActiveCfg = Debug|Any CPU
{C36D7809-5824-4AE0-912E-DBB18E05CF46}.Debug|x64.Build.0 = Debug|Any CPU
{C36D7809-5824-4AE0-912E-DBB18E05CF46}.Debug|x86.ActiveCfg = Debug|Any CPU
{C36D7809-5824-4AE0-912E-DBB18E05CF46}.Debug|x86.Build.0 = Debug|Any CPU
{C36D7809-5824-4AE0-912E-DBB18E05CF46}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C36D7809-5824-4AE0-912E-DBB18E05CF46}.Release|Any CPU.Build.0 = Release|Any CPU
{C36D7809-5824-4AE0-912E-DBB18E05CF46}.Release|x64.ActiveCfg = Release|Any CPU
{C36D7809-5824-4AE0-912E-DBB18E05CF46}.Release|x64.Build.0 = Release|Any CPU
{C36D7809-5824-4AE0-912E-DBB18E05CF46}.Release|x86.ActiveCfg = Release|Any CPU
{C36D7809-5824-4AE0-912E-DBB18E05CF46}.Release|x86.Build.0 = Release|Any CPU
{452C671A-9730-44CF-A9B8-083CE36A4578}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{452C671A-9730-44CF-A9B8-083CE36A4578}.Debug|Any CPU.Build.0 = Debug|Any CPU
{452C671A-9730-44CF-A9B8-083CE36A4578}.Debug|x64.ActiveCfg = Debug|Any CPU
{452C671A-9730-44CF-A9B8-083CE36A4578}.Debug|x64.Build.0 = Debug|Any CPU
{452C671A-9730-44CF-A9B8-083CE36A4578}.Debug|x86.ActiveCfg = Debug|Any CPU
{452C671A-9730-44CF-A9B8-083CE36A4578}.Debug|x86.Build.0 = Debug|Any CPU
{452C671A-9730-44CF-A9B8-083CE36A4578}.Release|Any CPU.ActiveCfg = Release|Any CPU
{452C671A-9730-44CF-A9B8-083CE36A4578}.Release|Any CPU.Build.0 = Release|Any CPU
{452C671A-9730-44CF-A9B8-083CE36A4578}.Release|x64.ActiveCfg = Release|Any CPU
{452C671A-9730-44CF-A9B8-083CE36A4578}.Release|x64.Build.0 = Release|Any CPU
{452C671A-9730-44CF-A9B8-083CE36A4578}.Release|x86.ActiveCfg = Release|Any CPU
{452C671A-9730-44CF-A9B8-083CE36A4578}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@@ -0,0 +1,46 @@
using Microsoft.EntityFrameworkCore;
using OTSSignsOrchestrator.Data;
namespace OTSSignsOrchestrator.Api;
public static class AuditApi
{
public static void MapAuditEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/audit-logs").RequireAuthorization();
group.MapGet("/", async (
int? limit, int? offset, Guid? instanceId, string? actor, string? action,
OrchestratorDbContext db) =>
{
var take = Math.Clamp(limit ?? 100, 1, 500);
var skip = Math.Max(offset ?? 0, 0);
var query = db.AuditLogs.AsNoTracking().AsQueryable();
if (instanceId.HasValue)
query = query.Where(a => a.InstanceId == instanceId.Value);
if (!string.IsNullOrEmpty(actor))
query = query.Where(a => a.Actor.Contains(actor));
if (!string.IsNullOrEmpty(action))
query = query.Where(a => a.Action.Contains(action));
var total = await query.CountAsync();
var logs = await query
.OrderByDescending(a => a.OccurredAt)
.Skip(skip)
.Take(take)
.Select(a => new
{
a.Id, a.InstanceId, a.Actor, a.Action, a.Target,
a.Outcome, a.Detail, a.OccurredAt
})
.ToListAsync();
return Results.Ok(new { total, logs });
});
}
}

View File

@@ -2,13 +2,13 @@ using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using OTSSignsOrchestrator.Server.Clients;
using OTSSignsOrchestrator.Server.Data;
using OTSSignsOrchestrator.Server.Data.Entities;
using OTSSignsOrchestrator.Server.Hubs;
using OTSSignsOrchestrator.Server.Workers;
using OTSSignsOrchestrator.Clients;
using OTSSignsOrchestrator.Data;
using OTSSignsOrchestrator.Data.Entities;
using OTSSignsOrchestrator.Hubs;
using OTSSignsOrchestrator.Workers;
namespace OTSSignsOrchestrator.Server.Api;
namespace OTSSignsOrchestrator.Api;
public static class CustomerPortalApi
{

View File

@@ -0,0 +1,140 @@
using Microsoft.EntityFrameworkCore;
using OTSSignsOrchestrator.Data;
using OTSSignsOrchestrator.Data.Entities;
namespace OTSSignsOrchestrator.Api;
public static class CustomersApi
{
public static void MapCustomersEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/customers").RequireAuthorization();
// ── All customers with detailed admin view ──────────────────────────
group.MapGet("/", async (string? status, OrchestratorDbContext db) =>
{
var query = db.Customers
.AsNoTracking()
.Include(c => c.Instances)
.AsQueryable();
if (!string.IsNullOrEmpty(status) &&
Enum.TryParse<CustomerStatus>(status, true, out var s))
query = query.Where(c => c.Status == s);
var customers = await query
.OrderBy(c => c.CompanyName)
.Select(c => new
{
c.Id,
c.Abbreviation,
c.CompanyName,
c.AdminEmail,
c.AdminFirstName,
c.AdminLastName,
Plan = c.Plan.ToString(),
c.ScreenCount,
c.StripeCustomerId,
c.StripeSubscriptionId,
Status = c.Status.ToString(),
c.FailedPaymentCount,
c.FirstPaymentFailedAt,
c.CreatedAt,
InstanceCount = c.Instances.Count,
})
.ToListAsync();
return Results.Ok(customers);
});
// ── Customer detail ─────────────────────────────────────────────────
group.MapGet("/{id:guid}", async (Guid id, OrchestratorDbContext db) =>
{
var customer = await db.Customers
.AsNoTracking()
.Include(c => c.Instances)
.Include(c => c.Jobs.OrderByDescending(j => j.CreatedAt).Take(20))
.FirstOrDefaultAsync(c => c.Id == id);
if (customer is null) return Results.NotFound();
// Get BYOI configs for customer's instances
var instanceIds = customer.Instances.Select(i => i.Id).ToList();
var byoiConfigs = await db.ByoiConfigs
.AsNoTracking()
.Where(b => instanceIds.Contains(b.InstanceId))
.Select(b => new
{
b.Id, b.InstanceId, b.Slug, b.EntityId,
b.CertExpiry, b.Enabled, b.CreatedAt
})
.ToListAsync();
// Get screen snapshots (last 30 days)
var since = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-30));
var snapshots = await db.ScreenSnapshots
.AsNoTracking()
.Where(s => instanceIds.Contains(s.InstanceId) && s.SnapshotDate >= since)
.OrderBy(s => s.SnapshotDate)
.Select(s => new { s.InstanceId, s.SnapshotDate, s.ScreenCount })
.ToListAsync();
return Results.Ok(new
{
customer.Id,
customer.Abbreviation,
customer.CompanyName,
customer.AdminEmail,
customer.AdminFirstName,
customer.AdminLastName,
Plan = customer.Plan.ToString(),
customer.ScreenCount,
customer.StripeCustomerId,
customer.StripeSubscriptionId,
Status = customer.Status.ToString(),
customer.FailedPaymentCount,
customer.FirstPaymentFailedAt,
customer.CreatedAt,
Instances = customer.Instances.Select(i => new
{
i.Id, StackName = i.DockerStackName, i.XiboUrl, i.CreatedAt
}),
Jobs = customer.Jobs.Select(j => new
{
j.Id, j.JobType, Status = j.Status.ToString(),
j.TriggeredBy, j.CreatedAt, j.CompletedAt, j.ErrorMessage
}),
ByoiConfigs = byoiConfigs,
ScreenSnapshots = snapshots,
});
});
// ── Stripe events for a customer (admin only) ───────────────────────
group.MapGet("/{id:guid}/stripe-events", async (
Guid id, int? limit, OrchestratorDbContext db) =>
{
var customer = await db.Customers.FindAsync(id);
if (customer is null) return Results.NotFound();
if (string.IsNullOrEmpty(customer.StripeCustomerId))
return Results.Ok(Array.Empty<object>());
var take = Math.Clamp(limit ?? 50, 1, 200);
// Search Stripe events that contain this customer's Stripe ID in the payload
var events = await db.StripeEvents
.AsNoTracking()
.Where(e => e.Payload != null && e.Payload.Contains(customer.StripeCustomerId))
.OrderByDescending(e => e.ProcessedAt)
.Take(take)
.Select(e => new
{
e.StripeEventId, e.EventType, e.ProcessedAt
})
.ToListAsync();
return Results.Ok(events);
}).RequireAuthorization(policy => policy.RequireRole("admin"));
}
}

View File

@@ -1,11 +1,11 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using OTSSignsOrchestrator.Server.Data;
using OTSSignsOrchestrator.Server.Data.Entities;
using OTSSignsOrchestrator.Server.Hubs;
using OTSSignsOrchestrator.Server.Reports;
using OTSSignsOrchestrator.Data;
using OTSSignsOrchestrator.Data.Entities;
using OTSSignsOrchestrator.Hubs;
using OTSSignsOrchestrator.Reports;
namespace OTSSignsOrchestrator.Server.Api;
namespace OTSSignsOrchestrator.Api;
public static class FleetApi
{

View File

@@ -0,0 +1,98 @@
using Microsoft.EntityFrameworkCore;
using OTSSignsOrchestrator.Data;
using OTSSignsOrchestrator.Data.Entities;
namespace OTSSignsOrchestrator.Api;
public static class HealthApi
{
public static void MapHealthEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/health-events").RequireAuthorization();
// Latest status per instance (dashboard summary)
group.MapGet("/summary", async (OrchestratorDbContext db) =>
{
var latest = await db.HealthEvents
.AsNoTracking()
.GroupBy(h => new { h.InstanceId, h.CheckName })
.Select(g => g.OrderByDescending(h => h.OccurredAt).First())
.Join(db.Instances.AsNoTracking(),
h => h.InstanceId,
i => i.Id,
(h, i) => new
{
h.Id,
h.InstanceId,
InstanceName = i.DockerStackName,
h.CheckName,
Status = h.Status.ToString(),
h.Message,
h.Remediated,
h.OccurredAt
})
.OrderBy(h => h.InstanceName)
.ThenBy(h => h.CheckName)
.ToListAsync();
return Results.Ok(latest);
});
// Health events filtered/paginated
group.MapGet("/", async (
int? limit, int? offset, Guid? instanceId, string? checkName, string? status,
OrchestratorDbContext db) =>
{
var take = Math.Clamp(limit ?? 100, 1, 500);
var skip = Math.Max(offset ?? 0, 0);
var query = db.HealthEvents
.AsNoTracking()
.Include(h => h.Instance)
.AsQueryable();
if (instanceId.HasValue)
query = query.Where(h => h.InstanceId == instanceId.Value);
if (!string.IsNullOrEmpty(checkName))
query = query.Where(h => h.CheckName == checkName);
if (!string.IsNullOrEmpty(status) &&
Enum.TryParse<HealthEventStatus>(status, true, out var s))
query = query.Where(h => h.Status == s);
var total = await query.CountAsync();
var events = await query
.OrderByDescending(h => h.OccurredAt)
.Skip(skip)
.Take(take)
.Select(h => new
{
h.Id,
h.InstanceId,
InstanceName = h.Instance.DockerStackName,
h.CheckName,
Status = h.Status.ToString(),
h.Message,
h.Remediated,
h.OccurredAt
})
.ToListAsync();
return Results.Ok(new { total, events });
});
// Distinct check names (for filter dropdowns)
group.MapGet("/check-names", async (OrchestratorDbContext db) =>
{
var names = await db.HealthEvents
.AsNoTracking()
.Select(h => h.CheckName)
.Distinct()
.OrderBy(n => n)
.ToListAsync();
return Results.Ok(names);
});
}
}

View File

@@ -0,0 +1,121 @@
using Microsoft.EntityFrameworkCore;
using OTSSignsOrchestrator.Data;
using OTSSignsOrchestrator.Data.Entities;
using OTSSignsOrchestrator.Services;
namespace OTSSignsOrchestrator.Api;
public static class HostsApi
{
public static void MapHostsEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/hosts").RequireAuthorization();
group.MapGet("/", async (OrchestratorDbContext db) =>
{
var hosts = await db.SshHosts
.OrderBy(h => h.Label)
.Select(h => new
{
h.Id, h.Label, h.Host, h.Port, h.Username,
h.PrivateKeyPath, h.UseKeyAuth,
h.CreatedAt, h.UpdatedAt, h.LastTestedAt, h.LastTestSuccess
})
.ToListAsync();
return Results.Ok(hosts);
});
group.MapGet("/{id:guid}", async (Guid id, OrchestratorDbContext db) =>
{
var host = await db.SshHosts.FindAsync(id);
if (host == null) return Results.NotFound();
return Results.Ok(new
{
host.Id, host.Label, host.Host, host.Port, host.Username,
host.PrivateKeyPath, host.UseKeyAuth,
host.CreatedAt, host.UpdatedAt, host.LastTestedAt, host.LastTestSuccess
});
});
group.MapPost("/", async (CreateSshHostRequest req, OrchestratorDbContext db) =>
{
var host = new SshHost
{
Label = req.Label, Host = req.Host, Port = req.Port,
Username = req.Username, PrivateKeyPath = req.PrivateKeyPath,
KeyPassphrase = req.KeyPassphrase, Password = req.Password,
UseKeyAuth = req.UseKeyAuth,
};
db.SshHosts.Add(host);
await db.SaveChangesAsync();
return Results.Created($"/api/hosts/{host.Id}", new
{
host.Id, host.Label, host.Host, host.Port, host.Username,
host.PrivateKeyPath, host.UseKeyAuth,
host.CreatedAt, host.UpdatedAt, host.LastTestedAt, host.LastTestSuccess
});
});
group.MapPut("/{id:guid}", async (Guid id, CreateSshHostRequest req, OrchestratorDbContext db) =>
{
var host = await db.SshHosts.FindAsync(id);
if (host == null) return Results.NotFound();
host.Label = req.Label;
host.Host = req.Host;
host.Port = req.Port;
host.Username = req.Username;
host.PrivateKeyPath = req.PrivateKeyPath;
host.UseKeyAuth = req.UseKeyAuth;
host.UpdatedAt = DateTime.UtcNow;
if (req.KeyPassphrase != null) host.KeyPassphrase = req.KeyPassphrase;
if (req.Password != null) host.Password = req.Password;
await db.SaveChangesAsync();
return Results.Ok(new
{
host.Id, host.Label, host.Host, host.Port, host.Username,
host.PrivateKeyPath, host.UseKeyAuth,
host.CreatedAt, host.UpdatedAt, host.LastTestedAt, host.LastTestSuccess
});
});
group.MapDelete("/{id:guid}", async (Guid id, OrchestratorDbContext db) =>
{
var host = await db.SshHosts.FindAsync(id);
if (host == null) return Results.NotFound();
db.SshHosts.Remove(host);
await db.SaveChangesAsync();
return Results.NoContent();
});
group.MapPost("/{id:guid}/test", async (Guid id, OrchestratorDbContext db, SshConnectionFactory ssh) =>
{
var host = await db.SshHosts.FindAsync(id);
if (host == null) return Results.NotFound();
var (success, message) = await ssh.TestConnectionAsync(host);
host.LastTestedAt = DateTime.UtcNow;
host.LastTestSuccess = success;
await db.SaveChangesAsync();
return Results.Ok(new { success, message });
});
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();
var docker = dockerFactory.GetCliService(host);
var nodes = await docker.ListNodesAsync();
return Results.Ok(nodes);
});
}
}
public record CreateSshHostRequest(
string Label, string Host, int Port, string Username,
string? PrivateKeyPath, string? KeyPassphrase, string? Password,
bool UseKeyAuth);

View File

@@ -0,0 +1,181 @@
using Microsoft.EntityFrameworkCore;
using OTSSignsOrchestrator.Services;
using OTSSignsOrchestrator.Data;
namespace OTSSignsOrchestrator.Api;
public static class InstancesApi
{
public static void MapInstancesEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/instances").RequireAuthorization();
group.MapGet("/live", async (OrchestratorDbContext db, IDockerServiceFactory dockerFactory, ILogger<Program> logger) =>
{
var hosts = await db.SshHosts.ToListAsync();
var allInstances = new List<object>();
foreach (var host in hosts)
{
try
{
var docker = dockerFactory.GetCliService(host);
var stacks = await docker.ListStacksAsync();
foreach (var stack in stacks.Where(s => s.Name.EndsWith("-cms-stack")))
{
var abbrev = stack.Name[..^"-cms-stack".Length];
allInstances.Add(new
{
stackName = stack.Name,
abbreviation = abbrev,
serviceCount = stack.ServiceCount,
hostId = host.Id,
hostLabel = host.Label,
});
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to query instances on host {Host}", host.Label);
}
}
return Results.Ok(allInstances);
});
group.MapGet("/live/{stackName}/services", async (string stackName, OrchestratorDbContext db, IDockerServiceFactory dockerFactory) =>
{
var host = await FindHostForStack(db, dockerFactory, stackName);
if (host == null) return Results.NotFound("Stack not found on any 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, IDockerServiceFactory dockerFactory) =>
{
var host = await FindHostForStack(db, dockerFactory, stackName);
if (host == null) return Results.NotFound("Stack not found on any host");
var docker = dockerFactory.GetCliService(host);
var services = await docker.InspectStackServicesAsync(stackName);
foreach (var svc in services)
await docker.ForceUpdateServiceAsync(svc.Name);
return Results.Ok(new { message = $"Restarted {services.Count} services" });
});
group.MapPost("/live/{stackName}/services/{serviceName}/restart",
async (string stackName, string serviceName, OrchestratorDbContext db, IDockerServiceFactory dockerFactory) =>
{
var host = await FindHostForStack(db, dockerFactory, stackName);
if (host == null) return Results.NotFound("Stack not found on any 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, IDockerServiceFactory dockerFactory,
InstanceService instanceService) =>
{
var host = await FindHostForStack(db, dockerFactory, stackName);
if (host == null) return Results.NotFound("Stack not found on any host");
var abbrev = stackName.EndsWith("-cms-stack")
? stackName[..^"-cms-stack".Length] : stackName.Split('-')[0];
var result = await instanceService.DeleteInstanceAsync(stackName, abbrev);
return result.Success ? Results.Ok(result) : Results.StatusCode(500);
});
group.MapPost("/live/{stackName}/rotate-mysql",
async (string stackName, OrchestratorDbContext db, IDockerServiceFactory dockerFactory, InstanceService instanceService) =>
{
var host = await FindHostForStack(db, dockerFactory, stackName);
if (host == null) return Results.NotFound("Stack not found on any 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, IDockerServiceFactory dockerFactory) =>
{
var host = await FindHostForStack(db, dockerFactory, stackName);
if (host == null) return Results.NotFound("Stack not found on any host");
var docker = dockerFactory.GetCliService(host);
var logs = await docker.GetServiceLogsAsync(stackName, service, tailLines ?? 200);
return Results.Ok(logs);
});
// Credential endpoints use abbreviation
group.MapGet("/{abbrev}/credentials",
async (string abbrev, PostInstanceInitService postInit, SettingsService settings) =>
{
var creds = await postInit.GetCredentialsAsync(abbrev);
var mysqlPassword = await settings.GetAsync(SettingsService.InstanceMySqlPassword(abbrev));
var mysqlUser = (await settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user")).Replace("{abbrev}", abbrev);
var cmsServerName = (await settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com")).Replace("{abbrev}", abbrev);
var instanceUrl = $"https://{cmsServerName}/{abbrev}";
return Results.Ok(new
{
adminUsername = creds.AdminUsername,
adminPassword = creds.AdminPassword ?? "",
mysqlUser,
mysqlPassword = mysqlPassword ?? "",
oauthClientId = creds.OAuthClientId,
instanceUrl,
hasPendingSetup = string.IsNullOrEmpty(creds.AdminPassword),
});
});
group.MapPost("/{abbrev}/initialize", async (string abbrev, InitializeRequest req,
SettingsService settings, PostInstanceInitService postInit) =>
{
var cmsServerName = (await settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com")).Replace("{abbrev}", abbrev);
var instanceUrl = $"https://{cmsServerName}/{abbrev}";
await postInit.RunAsync(abbrev, instanceUrl, req.ClientId, req.ClientSecret);
return Results.Ok(new { message = "Initialization complete" });
});
group.MapPost("/{abbrev}/rotate-admin-password", async (string abbrev,
SettingsService settings, PostInstanceInitService postInit) =>
{
var cmsServerName = (await settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com")).Replace("{abbrev}", abbrev);
var instanceUrl = $"https://{cmsServerName}/{abbrev}";
var newPassword = await postInit.RotateAdminPasswordAsync(abbrev, instanceUrl);
return Results.Ok(new { success = true, message = "Admin password rotated" });
});
}
/// <summary>
/// Finds which host a given stack lives on by querying all registered hosts.
/// </summary>
private static async Task<Data.Entities.SshHost?> FindHostForStack(
OrchestratorDbContext db, IDockerServiceFactory dockerFactory, string stackName)
{
var hosts = await db.SshHosts.ToListAsync();
foreach (var host in hosts)
{
try
{
var docker = dockerFactory.GetCliService(host);
var stacks = await docker.ListStacksAsync();
if (stacks.Any(s => s.Name == stackName))
return host;
}
catch (Exception)
{
// Host unreachable — continue searching other hosts
}
}
return null;
}
}
public record InitializeRequest(string ClientId, string ClientSecret);

View File

@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore;
using OTSSignsOrchestrator.Data;
namespace OTSSignsOrchestrator.Api;
public static class LogsApi
{
public static void MapLogsEndpoints(this WebApplication app)
{
app.MapGet("/api/logs/operations", async (
int? limit, int? offset, string? stackName, string? operation,
OrchestratorDbContext db) =>
{
var take = Math.Clamp(limit ?? 100, 1, 500);
var skip = Math.Max(offset ?? 0, 0);
var query = db.OperationLogs.AsQueryable();
if (!string.IsNullOrEmpty(stackName))
query = query.Where(o => o.StackName == stackName);
if (!string.IsNullOrEmpty(operation) &&
Enum.TryParse<Data.Entities.OperationType>(operation, true, out var opType))
query = query.Where(o => o.Operation == opType);
var logs = await query
.OrderByDescending(o => o.Timestamp)
.Skip(skip)
.Take(take)
.Select(o => new
{
o.Id, operation = o.Operation.ToString(), o.StackName, o.UserId,
status = o.Status.ToString(), o.Message, o.DurationMs, o.Timestamp
})
.ToListAsync();
return Results.Ok(logs);
}).RequireAuthorization();
}
}

View File

@@ -0,0 +1,4 @@
// This file is intentionally empty — OperatorsApi has been removed.
// Operator management is now handled via OIDC + admin token.
// Delete this file from the project.

View File

@@ -0,0 +1,243 @@
using System.Text.Json;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using OTSSignsOrchestrator.Models.DTOs;
using OTSSignsOrchestrator.Services;
using OTSSignsOrchestrator.Data;
using OTSSignsOrchestrator.Data.Entities;
using OTSSignsOrchestrator.Hubs;
namespace OTSSignsOrchestrator.Api;
public static class ProvisionApi
{
public static void MapProvisionEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/provision").RequireAuthorization();
group.MapPost("/preview-yaml", async (YamlPreviewRequest req,
SettingsService settings, GitTemplateService git, ComposeRenderService compose) =>
{
var abbrev = req.CustomerAbbrev.Trim().ToLowerInvariant();
var stackName = $"{abbrev}-cms-stack";
var repoUrl = await settings.GetAsync(SettingsService.GitRepoUrl);
var repoPat = await settings.GetAsync(SettingsService.GitRepoPat);
if (string.IsNullOrWhiteSpace(repoUrl))
return Results.BadRequest("Git template repository URL is not configured.");
var templateConfig = await git.FetchAsync(repoUrl, repoPat);
var cmsServerName = (await settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com")).Replace("{abbrev}", abbrev);
var themePath = (await settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev);
var mySqlDbName = (await settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev);
var mySqlUser = (await settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user")).Replace("{abbrev}", abbrev);
var renderCtx = new RenderContext
{
CustomerName = req.CustomerName,
CustomerAbbrev = abbrev,
StackName = stackName,
CmsServerName = cmsServerName,
HostHttpPort = 80,
CmsImage = await settings.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3"),
MemcachedImage = await settings.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine"),
QuickChartImage = await settings.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart"),
NewtImage = await settings.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt"),
ThemeHostPath = themePath,
MySqlHost = await settings.GetAsync(SettingsService.MySqlHost, "localhost"),
MySqlPort = await settings.GetAsync(SettingsService.MySqlPort, "3306"),
MySqlDatabase = mySqlDbName,
MySqlUser = mySqlUser,
MySqlPassword = "(generated-at-deploy-time)",
SmtpServer = await settings.GetAsync(SettingsService.SmtpServer, string.Empty),
SmtpUsername = await settings.GetAsync(SettingsService.SmtpUsername, string.Empty),
SmtpPassword = await settings.GetAsync(SettingsService.SmtpPassword, string.Empty),
SmtpUseTls = await settings.GetAsync(SettingsService.SmtpUseTls, "YES"),
SmtpUseStartTls = await settings.GetAsync(SettingsService.SmtpUseStartTls, "YES"),
SmtpRewriteDomain = await settings.GetAsync(SettingsService.SmtpRewriteDomain, string.Empty),
SmtpHostname = await settings.GetAsync(SettingsService.SmtpHostname, string.Empty),
SmtpFromLineOverride = await settings.GetAsync(SettingsService.SmtpFromLineOverride, "NO"),
PangolinEndpoint = await settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net"),
NfsServer = await settings.GetAsync(SettingsService.NfsServer),
NfsExport = await settings.GetAsync(SettingsService.NfsExport),
NfsExportFolder = await settings.GetAsync(SettingsService.NfsExportFolder),
NfsExtraOptions = await settings.GetAsync(SettingsService.NfsOptions, string.Empty),
PhpPostMaxSize = await settings.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G"),
PhpUploadMaxFilesize = await settings.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G"),
PhpMaxExecutionTime = await settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600"),
};
var yaml = compose.Render(templateConfig.Yaml, renderCtx);
return Results.Ok(new { yaml });
});
group.MapPost("/deploy", async (DeployRequest req,
OrchestratorDbContext db, IDockerServiceFactory dockerFactory,
InstanceService instanceService) =>
{
if (!Guid.TryParse(req.HostId, out var hostId))
return Results.BadRequest("Invalid host ID format");
var host = await db.SshHosts.FindAsync(hostId);
if (host == null) return Results.BadRequest("Host not found");
// Resolve and configure both services via the factory
_ = dockerFactory.GetCliService(host);
_ = dockerFactory.GetSecretsService(host);
var dto = new CreateInstanceDto
{
CustomerName = req.CustomerName,
CustomerAbbrev = req.CustomerAbbrev,
NewtId = req.NewtId,
NewtSecret = req.NewtSecret,
NfsServer = req.NfsServer,
NfsExport = req.NfsExport,
NfsExportFolder = req.NfsExportFolder,
NfsExtraOptions = req.NfsExtraOptions,
PurgeStaleVolumes = req.PurgeStaleVolumes,
};
var result = await instanceService.CreateInstanceAsync(dto);
return Results.Ok(new
{
result.Success, result.StackName, result.Message,
result.Output, result.InstanceUrl, result.Abbrev,
});
});
// ── Suggest abbreviation from company name ──────────────────────────
group.MapGet("/suggest-abbreviation", async (string companyName, AbbreviationService abbrService) =>
{
if (string.IsNullOrWhiteSpace(companyName))
return Results.BadRequest("companyName is required.");
var abbrev = await abbrService.GenerateAsync(companyName);
return Results.Ok(new { abbreviation = abbrev.ToLowerInvariant() });
});
// ── Manual provision: create Customer + Instance + queue job ────────
group.MapPost("/manual", async (ManualProvisionRequest req,
OrchestratorDbContext db,
AbbreviationService abbrService,
SettingsService settings,
IHubContext<FleetHub, IFleetClient> hub,
ILogger<ManualProvisionRequest> logger) =>
{
// Validate abbreviation format
var abbrev = req.Abbreviation?.Trim().ToLowerInvariant() ?? string.Empty;
if (string.IsNullOrEmpty(abbrev))
{
abbrev = (await abbrService.GenerateAsync(req.CompanyName)).ToLowerInvariant();
}
if (!System.Text.RegularExpressions.Regex.IsMatch(abbrev, @"^[a-z][a-z0-9]{2}$"))
return Results.BadRequest("Abbreviation must be exactly 3 lowercase alphanumeric characters starting with a letter.");
if (await db.Customers.AnyAsync(c => c.Abbreviation == abbrev))
return Results.Conflict(new { message = $"Abbreviation '{abbrev}' is already in use." });
if (!Enum.TryParse<CustomerPlan>(req.Plan, true, out var plan))
return Results.BadRequest("Invalid plan. Must be Essentials or Pro.");
// Create Customer record
var customer = new Customer
{
Id = Guid.NewGuid(),
CompanyName = req.CompanyName.Trim(),
AdminEmail = req.AdminEmail.Trim().ToLowerInvariant(),
AdminFirstName = req.AdminFirstName?.Trim() ?? string.Empty,
AdminLastName = req.AdminLastName?.Trim() ?? string.Empty,
Abbreviation = abbrev,
Plan = plan,
ScreenCount = Math.Max(1, req.ScreenCount),
Status = CustomerStatus.Provisioning,
CreatedAt = DateTime.UtcNow,
};
db.Customers.Add(customer);
// Build Instance record
var stackName = $"{abbrev}-cms-stack";
var cmsServerName = (await settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com"))
.Replace("{abbrev}", abbrev);
var instanceUrl = $"https://{cmsServerName}/{abbrev}";
var mysqlDb = (await settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db"))
.Replace("{abbrev}", abbrev);
var instance = new Instance
{
Id = Guid.NewGuid(),
CustomerId = customer.Id,
DockerStackName = stackName,
XiboUrl = instanceUrl,
MysqlDatabase = mysqlDb,
NfsPath = abbrev,
HealthStatus = HealthStatus.Unknown,
CreatedAt = DateTime.UtcNow,
};
db.Instances.Add(instance);
// Serialize optional deployment overrides into job parameters
var jobParams = new Dictionary<string, string>();
if (!string.IsNullOrWhiteSpace(req.NewtId)) jobParams["newtId"] = req.NewtId;
if (!string.IsNullOrWhiteSpace(req.NewtSecret)) jobParams["newtSecret"] = req.NewtSecret;
if (!string.IsNullOrWhiteSpace(req.NfsServer)) jobParams["nfsServer"] = req.NfsServer;
if (!string.IsNullOrWhiteSpace(req.NfsExport)) jobParams["nfsExport"] = req.NfsExport;
if (!string.IsNullOrWhiteSpace(req.NfsExportFolder)) jobParams["nfsExportFolder"] = req.NfsExportFolder;
if (!string.IsNullOrWhiteSpace(req.NfsExtraOptions)) jobParams["nfsExtraOptions"] = req.NfsExtraOptions;
// Create provision Job
var job = new Job
{
Id = Guid.NewGuid(),
CustomerId = customer.Id,
JobType = "provision",
Status = JobStatus.Queued,
TriggeredBy = "operator",
Parameters = jobParams.Count > 0 ? JsonSerializer.Serialize(jobParams) : null,
CreatedAt = DateTime.UtcNow,
};
db.Jobs.Add(job);
await db.SaveChangesAsync();
logger.LogInformation(
"Manual provision: customer={CustomerId}, abbrev={Abbrev}, job={JobId}",
customer.Id, abbrev, job.Id);
await hub.Clients.All.SendJobCreated(job.Id.ToString(), abbrev, "provision");
return Results.Created($"/api/jobs/{job.Id}", new
{
jobId = job.Id,
customerId = customer.Id,
abbreviation = abbrev,
stackName,
instanceUrl,
});
});
}
}
public record YamlPreviewRequest(string CustomerAbbrev, string CustomerName);
public record DeployRequest(
string CustomerName, string CustomerAbbrev, string HostId,
string? NewtId, string? NewtSecret,
string? NfsServer, string? NfsExport, string? NfsExportFolder, string? NfsExtraOptions,
bool PurgeStaleVolumes);
public record ManualProvisionRequest(
string CompanyName,
string AdminEmail,
string? AdminFirstName,
string? AdminLastName,
string? Abbreviation,
string Plan,
int ScreenCount,
string? NewtId,
string? NewtSecret,
string? NfsServer,
string? NfsExport,
string? NfsExportFolder,
string? NfsExtraOptions);

View File

@@ -0,0 +1,21 @@
using OTSSignsOrchestrator.Data;
using OTSSignsOrchestrator.Services;
namespace OTSSignsOrchestrator.Api;
public static class SecretsApi
{
public static void MapSecretsEndpoints(this WebApplication app)
{
app.MapGet("/api/hosts/{hostId:guid}/secrets",
async (Guid hostId, OrchestratorDbContext db, IDockerServiceFactory dockerFactory) =>
{
var host = await db.SshHosts.FindAsync(hostId);
if (host == null) return Results.NotFound();
var secrets = dockerFactory.GetSecretsService(host);
var list = await secrets.ListSecretsAsync();
return Results.Ok(list);
}).RequireAuthorization();
}
}

View File

@@ -0,0 +1,154 @@
using Microsoft.EntityFrameworkCore;
using OTSSignsOrchestrator.Services;
using OTSSignsOrchestrator.Data;
using Stripe;
namespace OTSSignsOrchestrator.Api;
public static class SettingsApi
{
public static void MapSettingsEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/settings").RequireAuthorization();
group.MapGet("/", async (SettingsService settings) =>
{
// Canonical keys per category — always shown even on a fresh install
var knownKeys = new Dictionary<string, string[]>
{
["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, canonicalKeys) in knownKeys)
{
// Merge stored values with the canonical key list
var stored = await settings.GetCategoryAsync(category);
// 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 =>
{
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 });
}
return Results.Ok(groups);
});
group.MapPut("/", async (List<SettingUpdateItem> items, SettingsService settings) =>
{
foreach (var item in items)
{
await settings.SetAsync(item.Key, item.Value, item.Category, item.IsSensitive);
}
// If Stripe secret key was updated, apply it to the global config
var stripeKeyItem = items.FirstOrDefault(i => i.Key == SettingsService.StripeSecretKey);
if (stripeKeyItem is not null && !string.IsNullOrWhiteSpace(stripeKeyItem.Value))
StripeConfiguration.ApiKey = stripeKeyItem.Value;
return Results.NoContent();
});
group.MapPost("/test-bitwarden", async (IBitwardenSecretService bws) =>
{
try
{
var secrets = await bws.ListSecretsAsync();
return Results.Ok(new { success = true, message = $"Connected. {secrets.Count} secrets found." });
}
catch (Exception ex)
{
return Results.Ok(new { success = false, message = $"Connection failed: {ex.Message}" });
}
});
group.MapPost("/test-mysql", async (SettingsService settings,
IDockerServiceFactory dockerFactory, OrchestratorDbContext db) =>
{
try
{
var mySqlHost = await settings.GetAsync(SettingsService.MySqlHost, "localhost");
var mySqlPort = await settings.GetAsync(SettingsService.MySqlPort, "3306");
var adminUser = await settings.GetAsync(SettingsService.MySqlAdminUser, "root");
var adminPassword = await settings.GetAsync(SettingsService.MySqlAdminPassword, string.Empty);
if (!int.TryParse(mySqlPort, out var port)) port = 3306;
var host = await db.SshHosts.FirstOrDefaultAsync();
if (host == null)
return Results.Ok(new { success = false, message = "No host configured for MySQL connection" });
var docker = dockerFactory.GetCliService(host);
var (conn, tunnel) = await docker.OpenMySqlConnectionAsync(mySqlHost, port, adminUser, adminPassword);
await using (conn)
using (tunnel)
{
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT 1";
await cmd.ExecuteScalarAsync();
}
return Results.Ok(new { success = true, message = "MySQL connection successful" });
}
catch (Exception ex)
{
return Results.Ok(new { success = false, message = $"MySQL test failed: {ex.Message}" });
}
});
group.MapPost("/test-authentik", async (IAuthentikService authentik) =>
{
try
{
var (success, message) = await authentik.TestConnectionAsync();
return Results.Ok(new { success, message });
}
catch (Exception ex)
{
return Results.Ok(new { success = false, message = $"Authentik test failed: {ex.Message}" });
}
});
group.MapGet("/authentik/flows", async (IAuthentikService authentik) =>
{
var flows = await authentik.ListFlowsAsync();
return Results.Ok(flows);
});
group.MapGet("/authentik/keypairs", async (IAuthentikService authentik) =>
{
var keypairs = await authentik.ListKeypairsAsync();
return Results.Ok(keypairs);
});
}
}
public record SettingUpdateItem(string Key, string Value, string Category, bool IsSensitive);

View File

@@ -1,12 +1,12 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using OTSSignsOrchestrator.Server.Data;
using OTSSignsOrchestrator.Server.Data.Entities;
using OTSSignsOrchestrator.Server.Hubs;
using OTSSignsOrchestrator.Data;
using OTSSignsOrchestrator.Data.Entities;
using OTSSignsOrchestrator.Hubs;
using Stripe.Checkout;
namespace OTSSignsOrchestrator.Server.Api;
namespace OTSSignsOrchestrator.Api;
public static class SignupApi
{

View File

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

View File

@@ -1,4 +1,4 @@
namespace OTSSignsOrchestrator.Server.Auth;
namespace OTSSignsOrchestrator.Auth;
public class JwtOptions
{

View File

@@ -0,0 +1,45 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
namespace OTSSignsOrchestrator.Auth;
public class OperatorAuthService
{
private readonly JwtOptions _jwt;
public OperatorAuthService(IOptions<JwtOptions> jwt)
{
_jwt = jwt.Value;
}
/// <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, email),
new Claim(JwtRegisteredClaimNames.Email, email),
new Claim(ClaimTypes.Name, email),
new Claim(ClaimTypes.Role, role),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
var token = new JwtSecurityToken(
issuer: _jwt.Issuer,
audience: _jwt.Audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(15),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}

View File

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

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<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-background text-foreground antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

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