Compare commits

11 Commits
main ... setup

Author SHA1 Message Date
Matt Batchelder
40e3be0e85 feat: Add customer type and update related logic for provisioning and customer management 2026-03-26 19:39:53 -04:00
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
Matt Batchelder
c6d46098dd feat: Implement provisioning pipelines for subscription management
- Add ReactivatePipeline to handle subscription reactivation, including scaling Docker services, health verification, status updates, audit logging, and broadcasting status changes.
- Introduce RotateCredentialsPipeline for OAuth2 credential rotation, managing the deletion of old apps, creation of new ones, credential storage, access verification, and audit logging.
- Create StepRunner to manage job step execution, including lifecycle management and progress broadcasting via SignalR.
- Implement SuspendPipeline for subscription suspension, scaling down services, updating statuses, logging audits, and broadcasting changes.
- Add UpdateScreenLimitPipeline to update Xibo CMS screen limits and record snapshots.
- Introduce XiboFeatureManifests for hardcoded feature ACLs per role.
- Add docker-compose.dev.yml for local development with PostgreSQL setup.
2026-03-18 10:27:26 -04:00
Matt Batchelder
c2e03de8bb feat: Update Authentik integration and enhance Docker Compose templates 2026-03-14 22:51:52 -04:00
Matt Batchelder
150549a20d feat: Implement customer invitation infrastructure in Authentik
- Added IInvitationSetupService and InvitationSetupService to orchestrate the setup of invitation infrastructure for customers.
- Introduced methods for creating groups, enrollment flows, invitation stages, roles, and policies in Authentik.
- Updated PostInstanceInitService to call the new invitation setup methods during post-initialization.
- Enhanced InstanceService to pass customer name during SAML configuration deployment.
- Updated App.axaml.cs to register the new IInvitationSetupService.
- Modified settings-custom.php.template to include documentation for SAML authentication configuration with group-based admin assignment.
- Added logic to exclude specific groups from being synced to Xibo during group synchronization.
2026-03-04 21:58:59 -05:00
Matt Batchelder
9493bdb9df feat: Implement Authentik group synchronization and add confirmation dialogs for service management 2026-03-04 21:33:29 -05:00
Matt Batchelder
56d48b6062 Refactor SAML configuration deployment and enhance Authentik integration
- Removed SAML configuration deployment calls from PostInstanceInitService.
- Updated DeploySamlConfigurationAsync to improve template fetching logic from Git and local directories.
- Added Authentik flow and keypair models for better representation in the UI.
- Enhanced SettingsViewModel to include Authentik settings with save and test functionality.
- Updated UI to support Authentik configuration, including dropdowns for flows and keypairs.
- Changed default CMS server name template to "app.ots-signs.com" across various files.
- Improved password handling in SshDockerCliService for secure shell command execution.
- Added new template file for settings-custom.php in the project structure.
2026-02-27 22:15:24 -05:00
Matt Batchelder
2aaa0442b2 work with authentik 2026-02-27 17:48:21 -05:00
Matt Batchelder
90eb649940 feat: Implement container logs functionality in InstancesViewModel
- Added properties for managing container logs, including log entries, service filters, and auto-refresh options.
- Introduced commands for refreshing logs, toggling auto-refresh, and closing the logs panel.
- Implemented log fetching logic with error handling and status messages.
- Integrated log display in the InstancesView with a dedicated logs panel.

feat: Enhance navigation to Instances page with auto-selection

- Added method to navigate to the Instances page and auto-select an instance based on abbreviation.

feat: Update SettingsViewModel to load and save Bitwarden configuration

- Integrated Bitwarden configuration loading from IOptions and saving to appsettings.json.
- Added properties for Bitwarden instance project ID and connection status.
- Updated UI to reflect Bitwarden settings and connection status.

feat: Add advanced options for instance creation

- Introduced a new expander in CreateInstanceView for advanced options, including purging stale volumes.

feat: Improve InstanceDetailsWindow with pending setup banner

- Added a banner to indicate pending setup for Xibo OAuth credentials, with editable fields for client ID and secret.

fix: Update appsettings.json to include Bitwarden configuration structure

- Added Bitwarden section to appsettings.json for storing configuration values.

chore: Update Docker Compose template with health checks

- Added health check configuration for web service in template.yml to ensure service availability.

refactor: Drop AppSettings table from database

- Removed AppSettings table and related migration files as part of database cleanup.

feat: Create ServiceLogEntry DTO for log management

- Added ServiceLogEntry class to represent individual log entries from Docker services.
2026-02-25 17:39:17 -05:00
Matt Batchelder
a1c987ff21 feat: Add Instance Details ViewModel and UI for managing instance credentials
- Introduced InstanceDetailsViewModel to handle loading and displaying instance-specific credentials.
- Created InstanceDetailsWindow and associated XAML for displaying admin, database, and OAuth2 credentials.
- Updated InstancesViewModel to include command for opening instance details.
- Enhanced SettingsViewModel to manage Bitwarden and Xibo Bootstrap configurations, including connection testing.
- Added UI components for Bitwarden Secrets Manager and Xibo Bootstrap OAuth2 settings in the SettingsView.
- Implemented password visibility toggles and clipboard copy functionality for sensitive information.
2026-02-25 08:05:44 -05:00
279 changed files with 33900 additions and 7545 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/

15
.env.example Normal file
View File

@@ -0,0 +1,15 @@
# OTSSignsOrchestrator — environment variables
# Copy to .env and fill in real values before running.
#
# Only TWO secrets are required here — everything else is configured
# via the admin UI (Settings page) and stored encrypted in PostgreSQL.
# ── PostgreSQL ───────────────────────────────────────────────────────────────
# 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

@@ -1,55 +0,0 @@
# Gitea Actions workflow: build Docker image and push to a container registry
# Place secrets in the repository settings: REGISTRY (host[:port]), IMAGE_NAME, DOCKER_USERNAME, DOCKER_PASSWORD
name: Build and Publish Docker Image
on:
push:
branches:
- main
workflow_dispatch: {}
jobs:
build-and-push:
# Use an appropriate runner that has Docker available (self-hosted runner)
runs-on: self-hosted
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build and push image
# run everything in a single shell step to keep tag calculation simple
run: |
set -euo pipefail
REGISTRY="${{ secrets.REGISTRY }}"
IMAGE_NAME="${{ secrets.IMAGE_NAME }}"
DOCKER_USERNAME="${{ secrets.DOCKER_USERNAME }}"
DOCKER_PASSWORD="${{ secrets.DOCKER_PASSWORD }}"
if [ -z "$REGISTRY" ] || [ -z "$IMAGE_NAME" ]; then
echo "Missing required secrets: REGISTRY and IMAGE_NAME must be set." >&2
exit 1
fi
TAG=$(git rev-parse --short HEAD)
IMAGE="$REGISTRY/$IMAGE_NAME:$TAG"
LATEST="$REGISTRY/$IMAGE_NAME:latest"
echo "Logging in to $REGISTRY"
echo "$DOCKER_PASSWORD" | docker login "$REGISTRY" -u "$DOCKER_USERNAME" --password-stdin
echo "Building $IMAGE (and tagging as latest)"
docker build -t "$IMAGE" -t "$LATEST" .
echo "Pushing $IMAGE"
docker push "$IMAGE"
echo "Pushing $LATEST"
docker push "$LATEST"
env:
# secrets are available via ${{ secrets.<name> }} in Gitea Actions
REGISTRY: ${{ secrets.REGISTRY }}
IMAGE_NAME: ${{ secrets.IMAGE_NAME }}
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}

98
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,98 @@
# Project Guidelines — OTS Signs Orchestrator
## Architecture
Web-based system for provisioning and managing Xibo CMS instances on Docker Swarm.
- **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 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.
- 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, Stripe, SendGrid.
#### Xibo API rules — non-negotiable
- `GET /api/application` is **BLOCKED**. Only POST and DELETE exist.
- All group endpoints are `/api/group`, never `/api/usergroup`.
- Feature assignment is `POST /api/group/{id}/acl`, NOT `/features`.
- Xibo paginates at 10 items by default. **Always pass `length=200`** and use `GetAllPagesAsync` for every list call. Missing this causes silent data truncation.
- OAuth2 client secret is returned **ONCE** in the `POST /api/application` response. Capture it immediately — **it cannot be retrieved again**.
#### Stripe webhooks — idempotency is mandatory
- Every Stripe webhook handler must check `OrchestratorDbContext.StripeEvents` for the `stripe_event_id` before processing anything.
- Insert the `StripeEvent` row first, then process the webhook. This is not optional — duplicate webhook delivery is guaranteed by Stripe.
#### No AI autonomy in infrastructure actions
- Never generate any endpoint or method that sends a message, makes an external call, or takes infrastructure action without an explicit operator-initiated `Job` record being created first.
- All automated actions flow through the `ProvisioningWorker` job queue.
## Build and Test
```bash
# Build
dotnet build
# Run Server
dotnet run --project OTSSignsOrchestrator/OTSSignsOrchestrator.csproj
# Run tests
dotnet test OTSSignsOrchestrator.Tests/OTSSignsOrchestrator.Tests.csproj
# Frontend dev
cd OTSSignsOrchestrator/ClientApp && npm run dev
```
- .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:
- Evidence hashing and tamper detection
- AI context assembly
- Pattern detection ruleset engine
- `AbbreviationService` uniqueness logic
- Stripe webhook idempotency
Integration tests **require** Testcontainers with a real PostgreSQL 16 instance — **no SQLite substitutions**.
## Conventions
### Services
- Interface + implementation pattern for testable services (`IXiboApiService`, `IDockerCliService`, etc.).
- `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}$`).
- Stack name: `{abbrev}-cms-stack`. Service: `{abbrev}-web`. DB: `{abbrev}_cms_db`.
- Secret names built via `AppConstants` helpers (e.g., `CustomerMysqlPasswordSecretName(abbrev)`).
- `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.
### 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.
### Data layer
- 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.
## Pitfalls
- **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.
- **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.

31
.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,8 +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/
.env
*.env
# Application-specific
logs/

Submodule .template-cache/053604496cfa3867 added at eaf06cf624

Submodule .template-cache/2dc03e2b2b45fef3 updated: a6ab3c254b...9663c2ade8

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,61 +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>();
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
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);
});
// --- AppSetting ---
modelBuilder.Entity<AppSetting>(entity =>
{
entity.HasKey(e => e.Key);
entity.HasIndex(e => e.Category);
});
}
}

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,150 +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.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,7 +0,0 @@
namespace OTSSignsOrchestrator.Core.Models.DTOs;
public class TemplateConfig
{
public string Yaml { get; set; } = string.Empty;
public DateTime FetchedAt { get; set; } = DateTime.UtcNow;
}

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,28 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<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,154 +0,0 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using OTSSignsOrchestrator.Core.Data;
using OTSSignsOrchestrator.Core.Models.Entities;
namespace OTSSignsOrchestrator.Core.Services;
/// <summary>
/// Reads and writes typed application settings from the AppSetting table.
/// Sensitive values are encrypted/decrypted transparently via DataProtection.
/// </summary>
public class SettingsService
{
private readonly XiboContext _db;
private readonly IDataProtector _protector;
private readonly ILogger<SettingsService> _logger;
// ── 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";
// ── 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";
// Instance-specific (keyed by abbreviation)
/// <summary>
/// Builds a per-instance settings key for the MySQL password.
/// Stored encrypted via DataProtection so it can be retrieved on update/redeploy.
/// </summary>
public static string InstanceMySqlPassword(string abbrev) => $"Instance.{abbrev}.MySqlPassword";
public const string CatInstance = "Instance";
public SettingsService(
XiboContext db,
IDataProtectionProvider dataProtection,
ILogger<SettingsService> logger)
{
_db = db;
_protector = dataProtection.CreateProtector("OTSSignsOrchestrator.Settings");
_logger = logger;
}
/// <summary>Get a single setting value, decrypting if sensitive.</summary>
public async Task<string?> GetAsync(string key)
{
var setting = await _db.AppSettings.FindAsync(key);
if (setting == null) return null;
return setting.IsSensitive && setting.Value != null
? Unprotect(setting.Value)
: setting.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, encrypting if sensitive.</summary>
public async Task SetAsync(string key, string? value, string category, bool isSensitive = false)
{
var setting = await _db.AppSettings.FindAsync(key);
if (setting == null)
{
setting = new AppSetting { Key = key, Category = category, IsSensitive = isSensitive };
_db.AppSettings.Add(setting);
}
setting.Value = isSensitive && value != null ? _protector.Protect(value) : value;
setting.IsSensitive = isSensitive;
setting.Category = category;
setting.UpdatedAt = DateTime.UtcNow;
}
/// <summary>Save multiple settings in a single transaction.</summary>
public async Task SaveManyAsync(IEnumerable<(string Key, string? Value, string Category, bool IsSensitive)> settings)
{
foreach (var (key, value, category, isSensitive) in settings)
await SetAsync(key, value, category, isSensitive);
await _db.SaveChangesAsync();
_logger.LogInformation("Saved {Count} setting(s)",
settings is ICollection<(string, string?, string, bool)> c ? c.Count : -1);
}
/// <summary>Get all settings in a category (values decrypted).</summary>
public async Task<Dictionary<string, string?>> GetCategoryAsync(string category)
{
var settings = await _db.AppSettings
.Where(s => s.Category == category)
.ToListAsync();
return settings.ToDictionary(
s => s.Key,
s => s.IsSensitive && s.Value != null ? Unprotect(s.Value) : s.Value);
}
private string? Unprotect(string protectedValue)
{
try
{
return _protector.Unprotect(protectedValue);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to unprotect setting value — returning null");
return null;
}
}
}

View File

@@ -1,90 +0,0 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OTSSignsOrchestrator.Core.Configuration;
namespace OTSSignsOrchestrator.Core.Services;
/// <summary>
/// Tests connectivity to deployed Xibo CMS instances using OAuth2.
/// </summary>
public class XiboApiService
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly XiboOptions _options;
private readonly ILogger<XiboApiService> _logger;
public XiboApiService(
IHttpClientFactory httpClientFactory,
IOptions<XiboOptions> options,
ILogger<XiboApiService> logger)
{
_httpClientFactory = httpClientFactory;
_options = options.Value;
_logger = logger;
}
public async Task<XiboTestResult> TestConnectionAsync(string instanceUrl, string username, string password)
{
_logger.LogInformation("Testing Xibo connection to {InstanceUrl}", instanceUrl);
var client = _httpClientFactory.CreateClient("XiboApi");
client.Timeout = TimeSpan.FromSeconds(_options.TestConnectionTimeoutSeconds);
try
{
var baseUrl = instanceUrl.TrimEnd('/');
var tokenUrl = $"{baseUrl}/api/authorize/access_token";
var formContent = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("grant_type", "client_credentials"),
new KeyValuePair<string, string>("client_id", username),
new KeyValuePair<string, string>("client_secret", password)
});
var response = await client.PostAsync(tokenUrl, formContent);
if (response.IsSuccessStatusCode)
{
_logger.LogInformation("Xibo connection test succeeded for {InstanceUrl}", instanceUrl);
return new XiboTestResult
{
IsValid = true,
Message = "Connected successfully.",
HttpStatus = (int)response.StatusCode
};
}
_logger.LogWarning("Xibo connection test failed: {InstanceUrl} | status={StatusCode}",
instanceUrl, (int)response.StatusCode);
return new XiboTestResult
{
IsValid = false,
Message = response.StatusCode switch
{
System.Net.HttpStatusCode.Unauthorized => "Invalid Xibo credentials.",
System.Net.HttpStatusCode.Forbidden => "User lacks API permissions.",
System.Net.HttpStatusCode.ServiceUnavailable => "Xibo instance not ready.",
_ => $"Unexpected response: {(int)response.StatusCode}"
},
HttpStatus = (int)response.StatusCode
};
}
catch (TaskCanceledException)
{
return new XiboTestResult { IsValid = false, Message = "Connection timed out." };
}
catch (HttpRequestException ex)
{
return new XiboTestResult { IsValid = false, Message = $"Cannot reach Xibo instance: {ex.Message}" };
}
}
}
public class XiboTestResult
{
public bool IsValid { get; set; }
public string Message { get; set; } = string.Empty;
public int HttpStatus { get; set; }
}

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,144 +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.Logging;
using Microsoft.Extensions.Options;
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...");
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");
desktop.ShutdownRequested += (_, _) =>
{
var ssh = Services.GetService<SshConnectionService>();
ssh?.Dispose();
};
}
else
{
Log.Warning("ApplicationLifetime is NOT IClassicDesktopStyleApplicationLifetime — window cannot be shown");
}
base.OnFrameworkInitializationCompleted();
}
private static void ConfigureServices(IServiceCollection services)
{
// Configuration
var config = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: false)
.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));
// 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");
// 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>();
// ViewModels
services.AddTransient<MainWindowViewModel>();
services.AddTransient<HostsViewModel>();
services.AddTransient<InstancesViewModel>();
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,25 +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;
}

View File

@@ -1,50 +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>
</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="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>
</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,512 +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;
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;
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.
var script = $"""
set -e
MNT=$(mktemp -d)
sudo mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
sudo mkdir -p {mkdirTargets}
sudo 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);
var script = $"""
set -e
MNT=$(mktemp -d)
sudo mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
sudo mkdir -p {mkdirTargets}
sudo 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> 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();
}
private void EnsureHost()
{
if (_currentHost == null)
throw new InvalidOperationException("No SSH host configured. Call SetHost() before using Docker commands.");
}
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,348 +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;
[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;
// 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)
{
_services = services;
_ = 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, "{abbrev}.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(),
};
var result = await instanceSvc.CreateInstanceAsync(dto);
AppendOutput(result.Output ?? string.Empty);
SetProgress(100, result.Success ? "Deployment complete!" : "Deployment failed.");
StatusMessage = result.Success
? $"Instance '{Abbrev}-cms-stack' deployed in {result.DurationMs}ms!"
: $"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,161 +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.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.
/// </summary>
public partial class InstancesViewModel : ObservableObject
{
private readonly IServiceProvider _services;
[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;
public InstancesViewModel(IServiceProvider services)
{
_services = services;
_ = RefreshAllAsync();
}
/// <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}"); }
}
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);
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}'.";
}
catch (Exception ex) { StatusMessage = $"Error inspecting: {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; }
}
}

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,60 +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
};
}
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,238 +0,0 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
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 = "{abbrev}.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";
public SettingsViewModel(IServiceProvider services)
{
_services = services;
_ = LoadAsync();
}
[RelayCommand]
private async Task LoadAsync()
{
IsBusy = true;
try
{
using var scope = _services.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
// 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, "{abbrev}.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");
StatusMessage = "Settings loaded.";
}
catch (Exception ex)
{
StatusMessage = $"Error loading settings: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task SaveAsync()
{
IsBusy = true;
try
{
using var scope = _services.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
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),
};
await svc.SaveManyAsync(settings);
StatusMessage = "Settings saved successfully.";
}
catch (Exception ex)
{
StatusMessage = $"Error saving settings: {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;
}
}
private static string? NullIfEmpty(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}

View File

@@ -1,204 +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>
<!-- 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,78 +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.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="Inspect" Command="{Binding InspectInstanceCommand}" />
<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" />
<!-- Services panel (shown when inspecting) -->
<Border DockPanel.Dock="Right" 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>
<Border Background="#232336" CornerRadius="6" Padding="12,10" Margin="0,3"
BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="1">
<StackPanel 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>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- Instance list -->
<DataGrid 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>
</DockPanel>
</UserControl>

View File

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

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,237 +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="Save All Settings"
Classes="accent"
Command="{Binding SaveCommand}"
IsEnabled="{Binding !IsBusy}"
FontWeight="SemiBold" Padding="20,8" />
<Button Content="Reload" Command="{Binding LoadCommand}" 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">
<!-- ═══ 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>
</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,45 +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"
},
"ConnectionStrings": {
"Default": "Data Source=otssigns-desktop.db"
},
"InstanceDefaults": {
"CmsServerNameTemplate": "{abbrev}.ots-signs.com",
"ThemeHostPath": "/cms/ots-theme",
"LibraryShareSubPath": "{abbrev}-cms-library",
"MySqlDatabaseTemplate": "{abbrev}_cms_db",
"MySqlUserTemplate": "{abbrev}_cms_user",
"BaseHostHttpPort": 8080
}
}

View File

@@ -0,0 +1,96 @@
using OTSSignsOrchestrator.Jobs;
namespace OTSSignsOrchestrator.Tests;
public class ByoiCertExpiryThresholdTests
{
// ── ShouldAlert ─────────────────────────────────────────────────────────
[Theory]
[InlineData(61, false)] // 61 days: above all thresholds → no alert
[InlineData(60, true)] // 60 days: at first threshold → alert
[InlineData(59, true)] // 59 days: below 60 → alert
[InlineData(31, true)] // 31 days: between 60 and 30 → alert
[InlineData(30, true)] // 30 days: at second threshold → alert
[InlineData(8, true)] // 8 days: between 30 and 7 → alert
[InlineData(7, true)] // 7 days: at critical threshold → alert
[InlineData(1, true)] // 1 day: below critical → alert
[InlineData(0, true)] // 0 days: expiry day → alert
[InlineData(-1, true)] // -1 day: already expired → alert
public void ShouldAlert_ReturnsCorrectValue(double daysRemaining, bool expected)
{
Assert.Equal(expected, ByoiCertExpiryJob.ShouldAlert(daysRemaining));
}
[Fact]
public void ShouldAlert_LargeValue_NoAlert()
{
Assert.False(ByoiCertExpiryJob.ShouldAlert(365));
}
// ── GetSeverity ─────────────────────────────────────────────────────────
[Theory]
[InlineData(60, "Warning")]
[InlineData(30, "Warning")]
[InlineData(8, "Warning")]
[InlineData(7.01, "Warning")]
[InlineData(7, "Critical")] // Exactly at critical boundary
[InlineData(6, "Critical")]
[InlineData(1, "Critical")]
[InlineData(0, "Critical")]
[InlineData(-1, "Critical")] // Already expired
public void GetSeverity_ReturnsCorrectLevel(double daysRemaining, string expected)
{
Assert.Equal(expected, ByoiCertExpiryJob.GetSeverity(daysRemaining));
}
// ── Threshold constants ─────────────────────────────────────────────────
[Fact]
public void AlertThresholds_AreDescending()
{
var thresholds = ByoiCertExpiryJob.AlertThresholdDays;
for (int i = 1; i < thresholds.Length; i++)
{
Assert.True(thresholds[i - 1] > thresholds[i],
$"Thresholds must be in descending order: {thresholds[i - 1]} should be > {thresholds[i]}");
}
}
[Fact]
public void CriticalThreshold_IsSmallestAlertThreshold()
{
Assert.Equal(
ByoiCertExpiryJob.CriticalThresholdDays,
ByoiCertExpiryJob.AlertThresholdDays[^1]);
}
// ── Boundary precision ──────────────────────────────────────────────────
[Fact]
public void ShouldAlert_JustAboveThreshold_NoAlert()
{
// 60.001 days — just above 60-day threshold
Assert.False(ByoiCertExpiryJob.ShouldAlert(60.001));
}
[Fact]
public void ShouldAlert_JustBelowThreshold_Alerts()
{
// 59.999 days — just below 60-day threshold
Assert.True(ByoiCertExpiryJob.ShouldAlert(59.999));
}
[Fact]
public void GetSeverity_JustAboveCritical_IsWarning()
{
Assert.Equal("Warning", ByoiCertExpiryJob.GetSeverity(7.001));
}
[Fact]
public void GetSeverity_ExactlyCritical_IsCritical()
{
Assert.Equal("Critical", ByoiCertExpiryJob.GetSeverity(7.0));
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OTSSignsOrchestrator\OTSSignsOrchestrator.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,28 +1,28 @@
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}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OTSSignsOrchestrator.Desktop", "OTSSignsOrchestrator.Desktop\OTSSignsOrchestrator.Desktop.csproj", "{B2C3D4E5-5555-6666-7777-888899990000}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
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}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-1111-2222-3333-444455556666}.Release|Any CPU.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}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B2C3D4E5-5555-6666-7777-888899990000}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
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", "OTSSignsOrchestrator\OTSSignsOrchestrator.csproj", "{C36D7809-5824-4AE0-912E-DBB18E05CF46}"
EndProject
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
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{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}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C36D7809-5824-4AE0-912E-DBB18E05CF46}.Release|Any CPU.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}.Release|Any CPU.ActiveCfg = Release|Any CPU
{452C671A-9730-44CF-A9B8-083CE36A4578}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal

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

@@ -0,0 +1,248 @@
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using OTSSignsOrchestrator.Clients;
using OTSSignsOrchestrator.Data;
using OTSSignsOrchestrator.Data.Entities;
using OTSSignsOrchestrator.Hubs;
using OTSSignsOrchestrator.Workers;
namespace OTSSignsOrchestrator.Api;
public static class CustomerPortalApi
{
private const int MinCertDaysRemaining = 30;
public static void MapCustomerPortalEndpoints(this WebApplication app)
{
var portal = app.MapGroup("/api/portal/byoi")
.RequireAuthorization("CustomerPortal");
portal.MapPost("/configure", HandleConfigureByoi);
portal.MapGet("/sp-metadata", HandleGetSpMetadata);
portal.MapPost("/rotate-cert", HandleRotateCert);
}
// ── POST /api/portal/byoi/configure ─────────────────────────────────────
private static async Task<IResult> HandleConfigureByoi(
ConfigureByoiRequest req,
OrchestratorDbContext db,
IHubContext<FleetHub, IFleetClient> hub,
HttpContext httpContext,
ILogger<ConfigureByoiRequest> logger)
{
// Resolve customer from the authenticated JWT
var customer = await ResolveCustomerAsync(httpContext, db);
if (customer is null)
return Results.Forbid();
if (customer.Plan != CustomerPlan.Pro)
return Results.Problem("BYOI SAML is only available for Pro tier customers.", statusCode: 403);
// Validate cert PEM
var certValidation = ValidateCertPem(req.CertPem);
if (certValidation is not null)
return Results.ValidationProblem(
new Dictionary<string, string[]> { ["certPem"] = [certValidation] });
// Validate required fields
var errors = new Dictionary<string, string[]>();
if (string.IsNullOrWhiteSpace(req.SsoUrl))
errors["ssoUrl"] = ["ssoUrl is required."];
if (string.IsNullOrWhiteSpace(req.IdpEntityId))
errors["idpEntityId"] = ["idpEntityId is required."];
if (errors.Count > 0)
return Results.ValidationProblem(errors);
// Create a provision-byoi Job
var parametersJson = JsonSerializer.Serialize(new ByoiParameters
{
CustomerCertPem = req.CertPem!,
CustomerSsoUrl = req.SsoUrl!,
CustomerIdpEntityId = req.IdpEntityId!,
CustomerSloUrl = req.SloUrl,
});
var job = new Job
{
Id = Guid.NewGuid(),
CustomerId = customer.Id,
JobType = "provision-byoi",
Status = JobStatus.Queued,
TriggeredBy = $"customer-portal:{customer.AdminEmail}",
Parameters = parametersJson,
CreatedAt = DateTime.UtcNow,
};
db.Jobs.Add(job);
await db.SaveChangesAsync();
logger.LogInformation("BYOI configure job {JobId} created for customer {CustomerId}",
job.Id, customer.Id);
await hub.Clients.All.SendJobCreated(
job.Id.ToString(), customer.Abbreviation, job.JobType);
return Results.Created($"/api/jobs/{job.Id}", new { jobId = job.Id });
}
// ── GET /api/portal/byoi/sp-metadata ────────────────────────────────────
private static async Task<IResult> HandleGetSpMetadata(
OrchestratorDbContext db,
IAuthentikClient authentikClient,
HttpContext httpContext,
ILogger<ConfigureByoiRequest> logger)
{
var customer = await ResolveCustomerAsync(httpContext, db);
if (customer is null)
return Results.Forbid();
var instance = customer.Instances.FirstOrDefault();
if (instance is null)
return Results.NotFound("No instance found for this customer.");
var byoiConfig = await db.ByoiConfigs
.AsNoTracking()
.FirstOrDefaultAsync(b => b.InstanceId == instance.Id && b.Enabled);
if (byoiConfig is null)
return Results.NotFound("No BYOI configuration found for this instance.");
var metadataResponse = await authentikClient.GetSamlSourceMetadataAsync(byoiConfig.Slug);
if (!metadataResponse.IsSuccessStatusCode || metadataResponse.Content is null)
{
logger.LogError("Failed to fetch SP metadata for slug {Slug}: {Error}",
byoiConfig.Slug, metadataResponse.Error?.Content ?? metadataResponse.ReasonPhrase);
return Results.Problem("Failed to retrieve SP metadata from Authentik.", statusCode: 502);
}
return Results.Content(metadataResponse.Content, "application/xml");
}
// ── POST /api/portal/byoi/rotate-cert ───────────────────────────────────
private static async Task<IResult> HandleRotateCert(
RotateCertRequest req,
OrchestratorDbContext db,
IHubContext<FleetHub, IFleetClient> hub,
HttpContext httpContext,
ILogger<RotateCertRequest> logger)
{
var customer = await ResolveCustomerAsync(httpContext, db);
if (customer is null)
return Results.Forbid();
if (customer.Plan != CustomerPlan.Pro)
return Results.Problem("BYOI SAML is only available for Pro tier customers.", statusCode: 403);
// Validate cert PEM
var certValidation = ValidateCertPem(req.CertPem);
if (certValidation is not null)
return Results.ValidationProblem(
new Dictionary<string, string[]> { ["certPem"] = [certValidation] });
var instance = customer.Instances.FirstOrDefault();
if (instance is null)
return Results.NotFound("No instance found for this customer.");
var existingConfig = await db.ByoiConfigs
.FirstOrDefaultAsync(b => b.InstanceId == instance.Id && b.Enabled);
if (existingConfig is null)
return Results.NotFound("No active BYOI configuration found to rotate.");
// Create a re-provisioning job with the new cert
var parametersJson = JsonSerializer.Serialize(new ByoiParameters
{
CustomerCertPem = req.CertPem!,
CustomerSsoUrl = existingConfig.SsoUrl,
CustomerIdpEntityId = existingConfig.EntityId,
CustomerSloUrl = null,
});
var job = new Job
{
Id = Guid.NewGuid(),
CustomerId = customer.Id,
JobType = "provision-byoi",
Status = JobStatus.Queued,
TriggeredBy = $"customer-portal:cert-rotate:{customer.AdminEmail}",
Parameters = parametersJson,
CreatedAt = DateTime.UtcNow,
};
db.Jobs.Add(job);
await db.SaveChangesAsync();
logger.LogInformation("BYOI cert rotate job {JobId} created for customer {CustomerId}",
job.Id, customer.Id);
await hub.Clients.All.SendJobCreated(
job.Id.ToString(), customer.Abbreviation, job.JobType);
return Results.Ok(new { jobId = job.Id });
}
// ── Helpers ─────────────────────────────────────────────────────────────
/// <summary>
/// Validates a PEM certificate string. Returns an error message on failure, or null if valid.
/// Rejects self-signed, expired, and certs expiring in &lt; 30 days.
/// </summary>
private static string? ValidateCertPem(string? certPem)
{
if (string.IsNullOrWhiteSpace(certPem))
return "certPem is required.";
X509Certificate2 cert;
try
{
var base64 = certPem
.Replace("-----BEGIN CERTIFICATE-----", "")
.Replace("-----END CERTIFICATE-----", "")
.Replace("\r", "")
.Replace("\n", "")
.Trim();
cert = X509CertificateLoader.LoadCertificate(Convert.FromBase64String(base64));
}
catch (Exception)
{
return "Invalid certificate PEM format.";
}
using (cert)
{
if (cert.NotAfter.ToUniversalTime() < DateTime.UtcNow)
return "Certificate has already expired.";
if ((cert.NotAfter.ToUniversalTime() - DateTime.UtcNow).TotalDays < MinCertDaysRemaining)
return $"Certificate expires in less than {MinCertDaysRemaining} days. Provide a certificate with a longer validity period.";
// Reject self-signed: issuer == subject
if (string.Equals(cert.Issuer, cert.Subject, StringComparison.OrdinalIgnoreCase))
return "Self-signed certificates are not accepted. Provide a CA-signed certificate.";
}
return null;
}
/// <summary>
/// Resolves the current customer from the authenticated JWT claims.
/// Expects a "customer_id" claim in the token.
/// </summary>
private static async Task<Customer?> ResolveCustomerAsync(HttpContext httpContext, OrchestratorDbContext db)
{
var customerIdClaim = httpContext.User.FindFirst("customer_id")?.Value;
if (customerIdClaim is null || !Guid.TryParse(customerIdClaim, out var customerId))
return null;
return await db.Customers
.Include(c => c.Instances)
.FirstOrDefaultAsync(c => c.Id == customerId);
}
}
// ── Request DTOs ────────────────────────────────────────────────────────────
public record ConfigureByoiRequest(string? CertPem, string? SsoUrl, string? IdpEntityId, string? SloUrl);
public record RotateCertRequest(string? CertPem);

View File

@@ -0,0 +1,142 @@
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.HasValue ? c.Plan.Value.ToString() : null,
c.ScreenCount,
CustomerType = c.CustomerType.ToString(),
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.HasValue ? customer.Plan.Value.ToString() : null,
customer.ScreenCount,
CustomerType = customer.CustomerType.ToString(),
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

@@ -0,0 +1,245 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using OTSSignsOrchestrator.Data;
using OTSSignsOrchestrator.Data.Entities;
using OTSSignsOrchestrator.Hubs;
using OTSSignsOrchestrator.Reports;
namespace OTSSignsOrchestrator.Api;
public static class FleetApi
{
public static void MapFleetEndpoints(this WebApplication app)
{
var fleet = app.MapGroup("/api/fleet").RequireAuthorization();
fleet.MapGet("/", GetFleetSummary);
fleet.MapGet("/{id:guid}", GetFleetDetail);
var jobs = app.MapGroup("/api/jobs").RequireAuthorization();
jobs.MapPost("/", CreateJob);
jobs.MapGet("/{id:guid}", GetJob);
app.MapGet("/api/health", () => Results.Ok(new { status = "healthy" }));
// ── Report endpoints (admin only) ────────────────────────────────────
var reports = app.MapGroup("/api/reports").RequireAuthorization()
.RequireAuthorization(policy => policy.RequireRole("admin"));
reports.MapGet("/billing", GetBillingCsv);
reports.MapGet("/version-drift", GetVersionDriftCsv);
reports.MapGet("/fleet-health", GetFleetHealthPdf);
reports.MapGet("/customer/{id:guid}/usage", GetCustomerUsagePdf);
fleet.MapPost("/bulk/export-fleet-report", ExportFleetReport)
.RequireAuthorization(policy => policy.RequireRole("admin"));
}
// ── GET /api/fleet ──────────────────────────────────────────────────────
private static async Task<IResult> GetFleetSummary(OrchestratorDbContext db)
{
var customers = await db.Customers
.AsNoTracking()
.Include(c => c.Instances)
.Include(c => c.Jobs)
.ToListAsync();
// Get latest health event per instance in one query
var latestHealth = await db.HealthEvents
.AsNoTracking()
.GroupBy(h => h.InstanceId)
.Select(g => g.OrderByDescending(h => h.OccurredAt).First())
.ToDictionaryAsync(h => h.InstanceId);
var result = customers.Select(c =>
{
var primaryInstance = c.Instances.FirstOrDefault();
HealthEvent? health = null;
if (primaryInstance is not null)
latestHealth.TryGetValue(primaryInstance.Id, out health);
return new FleetSummaryDto
{
CustomerId = c.Id,
Abbreviation = c.Abbreviation,
CompanyName = c.CompanyName,
Plan = c.Plan.ToString(),
ScreenCount = c.ScreenCount,
HealthStatus = health?.Status.ToString() ?? primaryInstance?.HealthStatus.ToString() ?? "Unknown",
LastHealthCheck = health?.OccurredAt ?? primaryInstance?.LastHealthCheck,
HasRunningJob = c.Jobs.Any(j => j.Status == JobStatus.Running || j.Status == JobStatus.Queued),
};
}).ToArray();
return Results.Ok(result);
}
// ── GET /api/fleet/{id} ─────────────────────────────────────────────────
private static async Task<IResult> GetFleetDetail(Guid id, OrchestratorDbContext db)
{
var customer = await db.Customers
.AsNoTracking()
.Include(c => c.Instances)
.Include(c => c.Jobs.Where(j => j.Status == JobStatus.Running || j.Status == JobStatus.Queued))
.FirstOrDefaultAsync(c => c.Id == id);
if (customer is null)
return Results.NotFound();
return Results.Ok(new
{
customer.Id,
customer.Abbreviation,
customer.CompanyName,
customer.AdminEmail,
Plan = customer.Plan.ToString(),
customer.ScreenCount,
Status = customer.Status.ToString(),
customer.CreatedAt,
Instances = customer.Instances.Select(i => new
{
i.Id,
i.XiboUrl,
i.DockerStackName,
HealthStatus = i.HealthStatus.ToString(),
i.LastHealthCheck,
}),
ActiveJobs = customer.Jobs.Select(j => new
{
j.Id,
j.JobType,
Status = j.Status.ToString(),
j.CreatedAt,
j.StartedAt,
}),
});
}
// ── POST /api/jobs ──────────────────────────────────────────────────────
private static async Task<IResult> CreateJob(
CreateJobRequest req,
OrchestratorDbContext db,
IHubContext<FleetHub, IFleetClient> hub,
ILogger<CreateJobRequest> logger)
{
var customer = await db.Customers.FindAsync(req.CustomerId);
if (customer is null)
return Results.NotFound("Customer not found.");
var job = new Job
{
Id = Guid.NewGuid(),
CustomerId = req.CustomerId,
JobType = req.JobType,
Status = JobStatus.Queued,
TriggeredBy = "operator",
Parameters = req.Parameters,
CreatedAt = DateTime.UtcNow,
};
db.Jobs.Add(job);
await db.SaveChangesAsync();
logger.LogInformation("Job created: {JobId} type={JobType} customer={CustomerId}",
job.Id, job.JobType, job.CustomerId);
await hub.Clients.All.SendJobCreated(job.Id.ToString(), customer.Abbreviation, job.JobType);
return Results.Created($"/api/jobs/{job.Id}", new { job.Id, job.JobType, Status = job.Status.ToString() });
}
// ── GET /api/jobs/{id} ──────────────────────────────────────────────────
private static async Task<IResult> GetJob(Guid id, OrchestratorDbContext db)
{
var job = await db.Jobs
.AsNoTracking()
.Include(j => j.Steps.OrderBy(s => s.StartedAt))
.FirstOrDefaultAsync(j => j.Id == id);
if (job is null)
return Results.NotFound();
return Results.Ok(new
{
job.Id,
job.CustomerId,
job.JobType,
Status = job.Status.ToString(),
job.TriggeredBy,
job.Parameters,
job.CreatedAt,
job.StartedAt,
job.CompletedAt,
job.ErrorMessage,
Steps = job.Steps.Select(s => new
{
s.Id,
s.StepName,
Status = s.Status.ToString(),
s.LogOutput,
s.StartedAt,
s.CompletedAt,
}),
});
}
// ── GET /api/reports/billing?from=&to= ──────────────────────────────────
private static async Task<IResult> GetBillingCsv(
DateOnly from, DateOnly to, BillingReportService billing)
{
var csv = await billing.GenerateBillingCsvAsync(from, to);
return Results.File(csv, "text/csv", $"billing-{from:yyyy-MM-dd}-{to:yyyy-MM-dd}.csv");
}
// ── GET /api/reports/version-drift ──────────────────────────────────────
private static async Task<IResult> GetVersionDriftCsv(BillingReportService billing)
{
var csv = await billing.GenerateVersionDriftCsvAsync();
return Results.File(csv, "text/csv", $"version-drift-{DateTime.UtcNow:yyyy-MM-dd}.csv");
}
// ── GET /api/reports/fleet-health?from=&to= ─────────────────────────────
private static async Task<IResult> GetFleetHealthPdf(
DateOnly from, DateOnly to, FleetHealthPdfService pdfService)
{
var pdf = await pdfService.GenerateFleetHealthPdfAsync(from, to);
return Results.File(pdf, "application/pdf", $"fleet-health-{from:yyyy-MM-dd}-{to:yyyy-MM-dd}.pdf");
}
// ── GET /api/reports/customer/{id}/usage?from=&to= ──────────────────────
private static async Task<IResult> GetCustomerUsagePdf(
Guid id, DateOnly from, DateOnly to, FleetHealthPdfService pdfService)
{
try
{
var pdf = await pdfService.GenerateCustomerUsagePdfAsync(id, from, to);
return Results.File(pdf, "application/pdf", $"customer-usage-{id}-{from:yyyy-MM-dd}-{to:yyyy-MM-dd}.pdf");
}
catch (InvalidOperationException ex)
{
return Results.NotFound(ex.Message);
}
}
// ── POST /api/fleet/bulk/export-fleet-report ────────────────────────────
private static async Task<IResult> ExportFleetReport(FleetHealthPdfService pdfService)
{
var to = DateOnly.FromDateTime(DateTime.UtcNow);
var from = to.AddDays(-7);
var pdf = await pdfService.GenerateFleetHealthPdfAsync(from, to);
return Results.File(pdf, "application/pdf", $"fleet-health-{from:yyyy-MM-dd}-{to:yyyy-MM-dd}.pdf");
}
}
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 CreateJobRequest(Guid CustomerId, string JobType, string? Parameters);

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,263 @@
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<CustomerType>(req.CustomerType ?? "Standard", true, out var customerType))
return Results.BadRequest("Invalid customer type. Must be Standard, Internal, Demo, or Trial.");
CustomerPlan? plan = null;
if (customerType == CustomerType.Standard)
{
if (string.IsNullOrWhiteSpace(req.Plan))
return Results.BadRequest("Plan is required for Standard instances.");
if (!Enum.TryParse<CustomerPlan>(req.Plan, true, out var parsedPlan))
return Results.BadRequest("Invalid plan. Must be Essentials or Pro.");
plan = parsedPlan;
}
else if (!string.IsNullOrWhiteSpace(req.Plan))
{
if (!Enum.TryParse<CustomerPlan>(req.Plan, true, out var parsedPlan))
return Results.BadRequest("Invalid plan. Must be Essentials or Pro.");
plan = parsedPlan;
}
// 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 = customerType == CustomerType.Standard
? Math.Max(1, req.ScreenCount ?? 1)
: req.ScreenCount ?? 0,
CustomerType = customerType,
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? CustomerType,
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

@@ -0,0 +1,173 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using OTSSignsOrchestrator.Data;
using OTSSignsOrchestrator.Data.Entities;
using OTSSignsOrchestrator.Hubs;
using Stripe.Checkout;
namespace OTSSignsOrchestrator.Api;
public static class SignupApi
{
public static void MapSignupEndpoints(this WebApplication app)
{
app.MapPost("/api/signup/initiate", HandleInitiate)
.RequireRateLimiting("signup");
app.MapGet("/api/signup/status/{token:guid}", HandleStatus);
}
private static async Task<IResult> HandleInitiate(
SignupRequest req,
OrchestratorDbContext db,
IConfiguration config,
ILogger<SignupRequest> logger)
{
// ── Validation ──────────────────────────────────────────────────────
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(req.CompanyName))
errors.Add("companyName is required.");
if (string.IsNullOrWhiteSpace(req.AdminEmail) || !new EmailAddressAttribute().IsValid(req.AdminEmail))
errors.Add("A valid adminEmail is required.");
if (string.IsNullOrWhiteSpace(req.Plan) ||
!req.Plan.Equals("Essentials", StringComparison.OrdinalIgnoreCase) &&
!req.Plan.Equals("Pro", StringComparison.OrdinalIgnoreCase))
errors.Add("plan must be 'Essentials' or 'Pro'.");
if (req.ScreenCount < 1)
errors.Add("screenCount must be at least 1.");
if (req.Plan?.Equals("Essentials", StringComparison.OrdinalIgnoreCase) == true && req.ScreenCount > 50)
errors.Add("Essentials plan supports a maximum of 50 screens.");
if (string.IsNullOrWhiteSpace(req.BillingFrequency) ||
!req.BillingFrequency.Equals("monthly", StringComparison.OrdinalIgnoreCase) &&
!req.BillingFrequency.Equals("annual", StringComparison.OrdinalIgnoreCase))
errors.Add("billingFrequency must be 'monthly' or 'annual'.");
if (errors.Count > 0)
return Results.ValidationProblem(
errors.ToDictionary(e => e, _ => new[] { "Validation failed." }));
// ── Create pending customer ─────────────────────────────────────────
var plan = Enum.Parse<CustomerPlan>(req.Plan!, true);
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,
Plan = plan,
ScreenCount = req.ScreenCount,
Status = CustomerStatus.PendingPayment,
CreatedAt = DateTime.UtcNow,
};
db.Customers.Add(customer);
await db.SaveChangesAsync();
// ── Stripe Checkout Session ─────────────────────────────────────────
var priceKey = $"Stripe:Prices:{req.Plan}:{req.BillingFrequency}".ToLowerInvariant();
var priceId = config[priceKey];
if (string.IsNullOrWhiteSpace(priceId))
{
logger.LogError("Stripe price ID not configured for key {PriceKey}", priceKey);
return Results.Problem("Billing configuration error. Contact support.", statusCode: 500);
}
var sessionOptions = new SessionCreateOptions
{
Mode = "subscription",
CustomerEmail = customer.AdminEmail,
LineItems = new List<SessionLineItemOptions>
{
new()
{
Price = priceId,
Quantity = req.ScreenCount,
},
},
SubscriptionData = new SessionSubscriptionDataOptions
{
TrialPeriodDays = 14,
},
Metadata = new Dictionary<string, string>
{
["ots_customer_id"] = customer.Id.ToString(),
["company_name"] = customer.CompanyName,
["admin_email"] = customer.AdminEmail,
["admin_first_name"] = customer.AdminFirstName,
["admin_last_name"] = customer.AdminLastName,
["plan"] = req.Plan!,
["screen_count"] = req.ScreenCount.ToString(),
["billing_frequency"] = req.BillingFrequency!,
},
SuccessUrl = config["Stripe:SuccessUrl"] ?? "https://app.ots-signs.com/signup/success?session_id={CHECKOUT_SESSION_ID}",
CancelUrl = config["Stripe:CancelUrl"] ?? "https://app.ots-signs.com/signup/cancel",
};
var sessionService = new SessionService();
var session = await sessionService.CreateAsync(sessionOptions);
customer.StripeCheckoutSessionId = session.Id;
await db.SaveChangesAsync();
logger.LogInformation(
"Signup initiated: customer={CustomerId}, company={Company}, plan={Plan}, screens={Screens}",
customer.Id, customer.CompanyName, req.Plan, req.ScreenCount);
return Results.Ok(new { checkoutUrl = session.Url, statusToken = customer.Id });
}
private static async Task<IResult> HandleStatus(
Guid token,
OrchestratorDbContext db)
{
var customer = await db.Customers
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == token);
if (customer is null)
return Results.NotFound();
// Find latest provisioning job if any
var job = await db.Jobs
.AsNoTracking()
.Where(j => j.CustomerId == customer.Id && j.JobType == "provision")
.OrderByDescending(j => j.CreatedAt)
.FirstOrDefaultAsync();
int pctComplete = customer.Status switch
{
CustomerStatus.PendingPayment => 0,
CustomerStatus.Provisioning => job?.Status switch
{
JobStatus.Running => 50,
JobStatus.Completed => 100,
_ => 10,
},
CustomerStatus.Active => 100,
_ => 0,
};
return Results.Ok(new
{
status = customer.Status.ToString(),
provisioningStep = job?.Steps
.Where(s => s.Status == JobStepStatus.Running)
.Select(s => s.StepName)
.FirstOrDefault() ?? (customer.Status == CustomerStatus.Active ? "complete" : "waiting"),
pctComplete,
});
}
}
public record SignupRequest(
string? CompanyName,
string? AdminFirstName,
string? AdminLastName,
string? AdminEmail,
string? Phone,
string? Plan,
int ScreenCount,
string? BillingFrequency,
string? PromoCode);

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

@@ -0,0 +1,10 @@
namespace OTSSignsOrchestrator.Auth;
public class JwtOptions
{
public const string Section = "Jwt";
public string Key { get; set; } = string.Empty;
public string Issuer { get; set; } = "OTSSignsOrchestrator";
public string Audience { get; set; } = "OTSSignsOrchestrator";
}

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>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
{
"name": "ots-signs-orchestrator",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@fontsource-variable/geist": "^5.2.8",
"@microsoft/signalr": "^8.0.7",
"@tanstack/react-query": "^5.62.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^1.0.1",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0",
"react-syntax-highlighter": "^15.6.1",
"shadcn": "^4.1.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"zustand": "^5.0.2"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"tailwindcss": "^4.1.0",
"typescript": "^5.7.0",
"vite": "^6.1.0"
}
}

View File

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

View File

@@ -0,0 +1,33 @@
import { apiGet } from './client';
export interface AuditLogEntry {
id: string;
instanceId: string | null;
actor: string;
action: string;
target: string;
outcome: string | null;
detail: string | null;
occurredAt: string;
}
export interface AuditLogPage {
total: number;
logs: AuditLogEntry[];
}
export const getAuditLogs = (params: {
limit?: number;
offset?: number;
instanceId?: string;
actor?: string;
action?: string;
}) => {
const qs = new URLSearchParams();
if (params.limit) qs.set('limit', String(params.limit));
if (params.offset) qs.set('offset', String(params.offset));
if (params.instanceId) qs.set('instanceId', params.instanceId);
if (params.actor) qs.set('actor', params.actor);
if (params.action) qs.set('action', params.action);
return apiGet<AuditLogPage>(`/api/audit-logs?${qs}`);
};

View File

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

View File

@@ -0,0 +1,47 @@
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
async function handleResponse<T>(res: Response): Promise<T> {
if (res.status === 401) {
window.location.href = '/login';
throw new ApiError(401, 'Unauthorized');
}
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new ApiError(res.status, text);
}
if (res.status === 204) return undefined as T;
return res.json();
}
export async function api<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, { credentials: 'include', ...init });
return handleResponse<T>(res);
}
export function apiGet<T>(path: string) {
return api<T>(path);
}
export function apiPost<T>(path: string, body?: unknown) {
return api<T>(path, {
method: 'POST',
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
}
export function apiPut<T>(path: string, body: unknown) {
return api<T>(path, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
export function apiDelete<T>(path: string) {
return api<T>(path, { method: 'DELETE' });
}

View File

@@ -0,0 +1,76 @@
import { apiGet } from './client';
export interface CustomerListItem {
id: string;
abbreviation: string;
companyName: string;
adminEmail: string;
adminFirstName: string;
adminLastName: string;
plan: string | null;
screenCount: number;
customerType: string;
stripeCustomerId: string | null;
stripeSubscriptionId: string | null;
status: string;
failedPaymentCount: number;
firstPaymentFailedAt: string | null;
createdAt: string;
instanceCount: number;
}
export interface ByoiConfigDto {
id: string;
instanceId: string;
slug: string;
entityId: string;
certExpiry: string;
enabled: boolean;
createdAt: string;
}
export interface ScreenSnapshotDto {
instanceId: string;
snapshotDate: string;
screenCount: number;
}
export interface StripeEventDto {
stripeEventId: string;
eventType: string;
processedAt: string;
}
export interface CustomerDetail {
id: string;
abbreviation: string;
companyName: string;
adminEmail: string;
adminFirstName: string;
adminLastName: string;
plan: string;
screenCount: number;
stripeCustomerId: string | null;
stripeSubscriptionId: string | null;
status: string;
failedPaymentCount: number;
firstPaymentFailedAt: string | null;
createdAt: string;
instances: { id: string; stackName: string; xiboUrl: string; createdAt: string }[];
jobs: { id: string; jobType: string; status: string; triggeredBy: string; createdAt: string; completedAt: string | null; errorMessage: string | null }[];
byoiConfigs: ByoiConfigDto[];
screenSnapshots: ScreenSnapshotDto[];
}
export const listCustomers = (status?: string) => {
const qs = status ? `?status=${status}` : '';
return apiGet<CustomerListItem[]>(`/api/customers${qs}`);
};
export const getCustomerAdmin = (id: string) =>
apiGet<CustomerDetail>(`/api/customers/${id}`);
export const getCustomerStripeEvents = (id: string, limit?: number) => {
const qs = limit ? `?limit=${limit}` : '';
return apiGet<StripeEventDto[]>(`/api/customers/${id}/stripe-events${qs}`);
};

View File

@@ -0,0 +1,44 @@
import { apiGet } from './client';
export interface FleetSummaryDto {
customerId: string;
abbreviation: string;
companyName: string;
plan: string;
screenCount: number;
healthStatus: string;
lastHealthCheck: string | null;
hasRunningJob: boolean;
}
export interface FleetCustomerInstance {
id: string;
xiboUrl: string;
dockerStackName: string;
healthStatus: string;
lastHealthCheck: string | null;
}
export interface FleetCustomerJob {
id: string;
jobType: string;
status: string;
createdAt: string;
startedAt: string | null;
}
export interface FleetCustomerDetail {
id: string;
abbreviation: string;
companyName: string;
adminEmail: string;
plan: string;
screenCount: number;
status: string;
createdAt: string;
instances: FleetCustomerInstance[];
activeJobs: FleetCustomerJob[];
}
export const getFleetSummary = () => apiGet<FleetSummaryDto[]>('/api/fleet');
export const getCustomerDetail = (id: string) => apiGet<FleetCustomerDetail>(`/api/fleet/${id}`);

View File

@@ -0,0 +1,39 @@
import { apiGet } from './client';
export interface HealthEventSummary {
id: string;
instanceId: string;
instanceName: string;
checkName: string;
status: string;
message: string | null;
remediated: boolean;
occurredAt: string;
}
export interface HealthEventPage {
total: number;
events: HealthEventSummary[];
}
export const getHealthSummary = () =>
apiGet<HealthEventSummary[]>('/api/health-events/summary');
export const getHealthEvents = (params: {
limit?: number;
offset?: number;
instanceId?: string;
checkName?: string;
status?: string;
}) => {
const qs = new URLSearchParams();
if (params.limit) qs.set('limit', String(params.limit));
if (params.offset) qs.set('offset', String(params.offset));
if (params.instanceId) qs.set('instanceId', params.instanceId);
if (params.checkName) qs.set('checkName', params.checkName);
if (params.status) qs.set('status', params.status);
return apiGet<HealthEventPage>(`/api/health-events?${qs}`);
};
export const getCheckNames = () =>
apiGet<string[]>('/api/health-events/check-names');

View File

@@ -0,0 +1,34 @@
import { apiGet, apiPost, apiPut, apiDelete } from './client';
export interface SshHost {
id: string;
label: string;
host: string;
port: number;
username: string;
privateKeyPath: string | null;
useKeyAuth: boolean;
createdAt: string;
updatedAt: string;
lastTestedAt: string | null;
lastTestSuccess: boolean | null;
}
export interface CreateSshHostRequest {
label: string;
host: string;
port: number;
username: string;
privateKeyPath?: string | null;
keyPassphrase?: string | null;
password?: string | null;
useKeyAuth: boolean;
}
export const listHosts = () => apiGet<SshHost[]>('/api/hosts');
export const getHost = (id: string) => apiGet<SshHost>(`/api/hosts/${id}`);
export const createHost = (req: CreateSshHostRequest) => apiPost<SshHost>('/api/hosts', req);
export const updateHost = (id: string, req: CreateSshHostRequest) => apiPut<SshHost>(`/api/hosts/${id}`, req);
export const deleteHost = (id: string) => apiDelete(`/api/hosts/${id}`);
export const testHost = (id: string) => apiPost<{ success: boolean; message: string }>(`/api/hosts/${id}/test`);
export const listNodes = (id: string) => apiGet<unknown[]>(`/api/hosts/${id}/nodes`);

View File

@@ -0,0 +1,46 @@
import { apiGet, apiPost, apiDelete } from './client';
export interface LiveInstance {
stackName: string;
abbreviation: string;
serviceCount: number;
hostId: string;
hostLabel: string;
}
export interface ServiceInfo {
name: string;
image: string;
replicas: string;
ports: string;
}
export interface InstanceCredentials {
adminUsername: string;
adminPassword: string;
mysqlUser: string;
mysqlPassword: string;
oauthClientId: string;
instanceUrl: string;
hasPendingSetup: boolean;
}
export const listLiveInstances = () => apiGet<LiveInstance[]>('/api/instances/live');
export const getStackServices = (stackName: string) => apiGet<ServiceInfo[]>(`/api/instances/live/${stackName}/services`);
export const restartStack = (stackName: string) => apiPost<{ message: string }>(`/api/instances/live/${stackName}/restart`);
export const restartService = (stackName: string, serviceName: string) =>
apiPost<{ message: string }>(`/api/instances/live/${stackName}/services/${serviceName}/restart`);
export const deleteStack = (stackName: string) => apiDelete<{ success: boolean }>(`/api/instances/live/${stackName}`);
export const rotateMySql = (stackName: string) => apiPost<{ success: boolean; message: string }>(`/api/instances/live/${stackName}/rotate-mysql`);
export const getStackLogs = (stackName: string, service?: string, tailLines?: number) => {
const params = new URLSearchParams();
if (service) params.set('service', service);
if (tailLines) params.set('tailLines', tailLines.toString());
return apiGet<string[]>(`/api/instances/live/${stackName}/logs?${params}`);
};
export const getCredentials = (abbrev: string) => apiGet<InstanceCredentials>(`/api/instances/${abbrev}/credentials`);
export const initializeInstance = (abbrev: string, clientId: string, clientSecret: string) =>
apiPost<{ message: string }>(`/api/instances/${abbrev}/initialize`, { clientId, clientSecret });
export const rotateAdminPassword = (abbrev: string) =>
apiPost<{ success: boolean; message: string }>(`/api/instances/${abbrev}/rotate-admin-password`);

View File

@@ -0,0 +1,41 @@
import { apiGet, apiPost } from './client';
export interface CreateJobRequest {
customerId: string;
jobType: string;
parameters?: string;
}
export interface CreateJobResponse {
id: string;
jobType: string;
status: string;
}
export interface JobStepDto {
id: string;
stepName: string;
status: string;
logOutput: string | null;
startedAt: string | null;
completedAt: string | null;
}
export interface JobDetailDto {
id: string;
customerId: string;
jobType: string;
status: string;
triggeredBy: string | null;
parameters: string | null;
createdAt: string;
startedAt: string | null;
completedAt: string | null;
errorMessage: string | null;
steps: JobStepDto[];
}
export const createJob = (req: CreateJobRequest) =>
apiPost<CreateJobResponse>('/api/jobs', req);
export const getJob = (id: string) => apiGet<JobDetailDto>(`/api/jobs/${id}`);

View File

@@ -0,0 +1,26 @@
import { apiGet } from './client';
export interface OperationLog {
id: string;
operation: string;
stackName: string;
userId: string;
status: string;
message: string;
durationMs: number | null;
timestamp: string;
}
export const getOperationLogs = (params?: {
limit?: number;
offset?: number;
stackName?: string;
operation?: string;
}) => {
const qs = new URLSearchParams();
if (params?.limit) qs.set('limit', params.limit.toString());
if (params?.offset) qs.set('offset', params.offset.toString());
if (params?.stackName) qs.set('stackName', params.stackName);
if (params?.operation) qs.set('operation', params.operation);
return apiGet<OperationLog[]>(`/api/logs/operations?${qs}`);
};

View File

@@ -0,0 +1,2 @@
// This file is obsolete — Operator management removed in auth overhaul.

View File

@@ -0,0 +1,51 @@
import { apiGet, apiPost } from './client';
export interface DeployRequest {
customerName: string;
customerAbbrev: string;
hostId: string;
newtId?: string;
newtSecret?: string;
nfsServer?: string;
nfsExport?: string;
nfsExportFolder?: string;
nfsExtraOptions?: string;
purgeStaleVolumes: boolean;
}
export interface ManualProvisionRequest {
companyName: string;
adminEmail: string;
adminFirstName?: string;
adminLastName?: string;
abbreviation?: string;
customerType?: string;
plan?: string;
screenCount?: number;
newtId?: string;
newtSecret?: string;
nfsServer?: string;
nfsExport?: string;
nfsExportFolder?: string;
nfsExtraOptions?: string;
}
export interface ManualProvisionResponse {
jobId: string;
customerId: string;
abbreviation: string;
stackName: string;
instanceUrl: string;
}
export const previewYaml = (customerAbbrev: string, customerName: string) =>
apiPost<{ yaml: string }>('/api/provision/preview-yaml', { customerAbbrev, customerName });
export const deploy = (req: DeployRequest) =>
apiPost<{ success: boolean; stackName: string; message: string; instanceUrl: string; abbrev: string }>('/api/provision/deploy', req);
export const suggestAbbreviation = (companyName: string) =>
apiGet<{ abbreviation: string }>(`/api/provision/suggest-abbreviation?companyName=${encodeURIComponent(companyName)}`);
export const manualProvision = (req: ManualProvisionRequest) =>
apiPost<ManualProvisionResponse>('/api/provision/manual', req);

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