Compare commits
2 Commits
c6d46098dd
...
fc510b9b20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc510b9b20 | ||
|
|
9a35e40083 |
42
.dockerignore
Normal file
42
.dockerignore
Normal 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/
|
||||
24
.env.example
24
.env.example
@@ -1,11 +1,15 @@
|
||||
# OTSSignsOrchestrator.Server — required environment variables
|
||||
# Copy to .env and fill in real values.
|
||||
# OTSSignsOrchestrator — environment variables
|
||||
# Copy to .env and fill in real values before running.
|
||||
#
|
||||
# Only TWO secrets are required here — everything else is configured
|
||||
# via the admin UI (Settings page) and stored encrypted in PostgreSQL.
|
||||
|
||||
ConnectionStrings__OrchestratorDb=Host=localhost;Port=5432;Database=orchestrator_dev;Username=ots;Password=devpassword
|
||||
Stripe__WebhookSecret=whsec_...
|
||||
Stripe__SecretKey=sk_test_...
|
||||
Jwt__Key=change-me-to-a-random-256-bit-key
|
||||
Authentik__BaseUrl=https://auth.example.com
|
||||
Authentik__ApiToken=
|
||||
SendGrid__ApiKey=SG....
|
||||
OTS_SIGNS_SERVER_URL=http://localhost:5000
|
||||
# ── PostgreSQL ───────────────────────────────────────────────────────────────
|
||||
# Password for the postgres service AND the app connection string.
|
||||
# Generate: openssl rand -base64 32
|
||||
POSTGRES_PASSWORD=changeme
|
||||
|
||||
# ── JWT ──────────────────────────────────────────────────────────────────────
|
||||
# Key must be at least 32 characters (256-bit).
|
||||
# Generate: openssl rand -base64 48
|
||||
JWT_KEY=change-me-to-a-random-256-bit-key
|
||||
|
||||
79
.github/copilot-instructions.md
vendored
79
.github/copilot-instructions.md
vendored
@@ -2,27 +2,21 @@
|
||||
|
||||
## Architecture
|
||||
|
||||
Layered MVVM desktop app for provisioning and managing Xibo CMS instances on Docker Swarm.
|
||||
Web-based system for provisioning and managing Xibo CMS instances on Docker Swarm.
|
||||
|
||||
- **OTSSignsOrchestrator.Core** — class library: services, EF Core data access, models, configuration. Reusable across UIs.
|
||||
- **OTSSignsOrchestrator.Desktop** — Avalonia 11 UI: views, view models, DI setup. References Core.
|
||||
- **templates/** — Docker Compose + PHP templates with `{{PLACEHOLDER}}` substitution.
|
||||
- **OTSSignsOrchestrator** — ASP.NET Core API + React web UI (Vite + TypeScript + Tailwind CSS) + SignalR + Quartz scheduler. PostgreSQL 16. Contains all services, models, configuration, and business logic.
|
||||
- **OTSSignsOrchestrator.Tests** — xUnit test project.
|
||||
|
||||
### Key patterns
|
||||
- Services injected via `IServiceProvider` (registered in `App.axaml.cs` → `ConfigureServices()`)
|
||||
- Singletons: stateful services (SSH connections, Docker CLI). Transient: stateless logic.
|
||||
- Services injected via DI (registered in `Program.cs`)
|
||||
- Singletons: stateful services (SSH connection factory). Scoped: per-request services (Docker CLI, secrets). Transient: stateless logic.
|
||||
- Configuration via `IOptions<T>` bound from `appsettings.json` (see `AppOptions.cs` for all sections).
|
||||
- Bitwarden Secrets Manager is the source of truth for all sensitive config. `SettingsService` caches in-memory.
|
||||
- Local SQLite DB (`otssigns-desktop.db`) stores SSH hosts + operation logs. Credentials encrypted via Data Protection API.
|
||||
|
||||
### Scope & file discipline
|
||||
**The Server project is net-new — keep concerns separated.**
|
||||
- Never modify `OTSSignsOrchestrator.Core` or `OTSSignsOrchestrator.Desktop` unless the prompt explicitly says to.
|
||||
- When in doubt, add new code to `OTSSignsOrchestrator.Server`.
|
||||
- Never modify `XiboContext.cs` without explicit instruction.
|
||||
- PostgreSQL database via `OrchestratorDbContext`. Credentials encrypted via Data Protection API.
|
||||
- React frontend in `ClientApp/`, built to `wwwroot/` via Vite. Cookie-based JWT auth.
|
||||
|
||||
### External integrations
|
||||
Xibo CMS REST API (OAuth2), Authentik SAML IdP, Bitwarden Secrets SDK, Docker Swarm (via SSH), Git (LibGit2Sharp), MySQL 8.4, NFS volumes, Pangolin/Newt VPN.
|
||||
Xibo CMS REST API (OAuth2), Authentik SAML IdP, Bitwarden Secrets SDK, Docker Swarm (via SSH), Git (LibGit2Sharp), MySQL 8.4, NFS volumes, Pangolin/Newt VPN, Stripe, SendGrid.
|
||||
|
||||
#### Xibo API rules — non-negotiable
|
||||
- `GET /api/application` is **BLOCKED**. Only POST and DELETE exist.
|
||||
@@ -43,17 +37,20 @@ Xibo CMS REST API (OAuth2), Authentik SAML IdP, Bitwarden Secrets SDK, Docker Sw
|
||||
|
||||
```bash
|
||||
# Build
|
||||
dotnet build OTSSignsOrchestrator.Desktop/OTSSignsOrchestrator.Desktop.csproj
|
||||
dotnet build
|
||||
|
||||
# Run
|
||||
dotnet run --project OTSSignsOrchestrator.Desktop/OTSSignsOrchestrator.Desktop.csproj
|
||||
# Run Server
|
||||
dotnet run --project OTSSignsOrchestrator/OTSSignsOrchestrator.csproj
|
||||
|
||||
# No test suite currently — no xUnit/NUnit projects
|
||||
# Run tests
|
||||
dotnet test OTSSignsOrchestrator.Tests/OTSSignsOrchestrator.Tests.csproj
|
||||
|
||||
# Frontend dev
|
||||
cd OTSSignsOrchestrator/ClientApp && npm run dev
|
||||
```
|
||||
|
||||
- .NET 9.0, Avalonia 11.2.3, CommunityToolkit.Mvvm 8.4
|
||||
- Runtime identifiers: `linux-x64`, `win-x64`, `osx-x64`, `osx-arm64`
|
||||
- EF Core migrations in `OTSSignsOrchestrator.Core/Migrations/`
|
||||
- .NET 9.0, React 18, Vite, TypeScript, Tailwind CSS, shadcn/ui
|
||||
- EF Core migrations in `OTSSignsOrchestrator/Migrations/`
|
||||
|
||||
### Test coverage non-negotiables
|
||||
Unit tests are **required** for:
|
||||
@@ -67,28 +64,10 @@ Integration tests **require** Testcontainers with a real PostgreSQL 16 instance
|
||||
|
||||
## Conventions
|
||||
|
||||
### ViewModels
|
||||
- Inherit `ObservableObject` (CommunityToolkit.Mvvm). Use `[ObservableProperty]` for bindable fields and `[RelayCommand]` for commands.
|
||||
- React to changes via `partial void OnXxxChanged(T value)` methods generated by the toolkit.
|
||||
- Resolve services from `IServiceProvider` in constructors. Navigation via `MainWindowViewModel.CurrentView`.
|
||||
- Confirmation dialogs use `Func<string, string, Task<bool>> ConfirmAsync` property — wired by the View.
|
||||
|
||||
### Avalonia threading — critical for stability
|
||||
All SignalR message handlers and background thread continuations that touch `ObservableProperty` or `ObservableCollection` **MUST** be wrapped in:
|
||||
```csharp
|
||||
Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => { ... });
|
||||
```
|
||||
**Failure to do this causes silent cross-thread exceptions in Avalonia.** Never suggest direct property assignment from a non-UI thread.
|
||||
|
||||
### Views (Avalonia XAML)
|
||||
- Compiled bindings enabled (`x:CompileBindings="True"`). DataTemplates in `MainWindow.axaml` map ViewModel types to View UserControls.
|
||||
- Layout: DockPanel with status bar (bottom), sidebar nav (left), dynamic ContentControl (center).
|
||||
- Style: Fluent theme, dark palette (`#0C0C14` accents).
|
||||
|
||||
### Services
|
||||
- Interface + implementation pattern for testable services (`IXiboApiService`, `IDockerCliService`, etc.).
|
||||
- `SshDockerCliService` is a singleton — **must call `SetHost(host)` before each operation** in loops.
|
||||
- All long operations are `async Task`. Use `IsBusy` + `StatusMessage` properties for UI feedback.
|
||||
- `SshDockerCliService` is scoped — **must call `SetHost(host)` before each operation** in loops.
|
||||
- All long operations are `async Task`.
|
||||
|
||||
### Naming
|
||||
- Customer abbreviation: exactly 3 lowercase letters (`^[a-z]{3}$`).
|
||||
@@ -97,27 +76,23 @@ Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => { ... });
|
||||
- `AppConstants.SanitizeName()` filters to `[a-z0-9_-]`.
|
||||
|
||||
### Credential handling
|
||||
Never store OAuth2 client secrets, Stripe keys, or SSH passwords in the database. Secrets go to the Bitwarden CLI wrapper only. `OauthAppRegistry` stores `clientId` only — never the secret. Log credentials to `JobStep` output **ONLY** as a last-resort break-glass fallback, and mark it explicitly as emergency recovery data in the log.
|
||||
Never store OAuth2 client secrets, Stripe keys, or SSH passwords in the database. Secrets go to the Bitwarden CLI wrapper only. `OauthAppRegistry` stores `clientId` only — never the secret.
|
||||
|
||||
### Code generation verification
|
||||
After generating any class that implements an interface, **verify all interface members are implemented.** After generating any pipeline, **verify all steps are implemented as `JobStep` entities with progress broadcast via `IHubContext<FleetHub>`.** Do not stub steps as TODO — implement them fully or flag explicitly that the step requires external infrastructure access that cannot be completed in this context.
|
||||
After generating any class that implements an interface, **verify all interface members are implemented.** After generating any pipeline, **verify all steps are implemented as `JobStep` entities with progress broadcast via `IHubContext<FleetHub>`.** Do not stub steps as TODO — implement them fully or flag explicitly.
|
||||
|
||||
### Data layer
|
||||
- Entities in `Core/Models/Entities/`, DTOs in `Core/Models/DTOs/`.
|
||||
- `XiboContext` applies unique index on `SshHost.Label` and encrypts credential fields.
|
||||
- Add new migrations via: `dotnet ef migrations add <Name> --project OTSSignsOrchestrator.Core --startup-project OTSSignsOrchestrator.Desktop`
|
||||
- Entities in `Core/Models/Entities/` and `Server/Data/Entities/`.
|
||||
- DTOs in `Core/Models/DTOs/`.
|
||||
- `OrchestratorDbContext` is the primary database context (PostgreSQL).
|
||||
|
||||
### Immutability enforcement
|
||||
**AuditLog, Message, and Evidence are append-only by design.** Never generate `Update()` or `Delete()` methods on these repositories. Add an explicit comment on each repository class:
|
||||
```csharp
|
||||
// IMMUTABLE — no update or delete operations permitted.
|
||||
```
|
||||
**AuditLog, Message, and Evidence are append-only by design.** Never generate `Update()` or `Delete()` methods on these repositories.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **SSH singleton state**: `SshDockerCliService.SetHost()` must be called before each host operation — stale host causes silent failures.
|
||||
- **SSH host state**: `SshDockerCliService.SetHost()` must be called before each host operation — stale host causes silent failures.
|
||||
- **Bitwarden cache**: After creating secrets during deployment, call `FlushCacheAsync()` before reading them back.
|
||||
- **Data Protection keys**: Stored in `%APPDATA%/OTSSignsOrchestrator/keys`. If lost, encrypted SSH passwords are unrecoverable.
|
||||
- **Docker volumes are sticky**: Failed deploys leave volumes with old NFS driver options. Use `PurgeStaleVolumes: true` to force fresh volumes (causes data loss).
|
||||
- **No saga/rollback**: Instance creation spans Git → MySQL → Docker → Xibo. Partial failures leave orphaned resources; cleanup is manual via `OperationLog`.
|
||||
- **Template CIFS→NFS compat**: Old `{{CIFS_*}}` tokens still render correctly as NFS equivalents.
|
||||
|
||||
32
.gitignore
vendored
32
.gitignore
vendored
@@ -1,12 +1,14 @@
|
||||
# .gitignore for C#/.NET projects on macOS
|
||||
# .gitignore for ASP.NET Core + React (Vite) project on macOS
|
||||
# Generated for Visual Studio, Rider, and dotnet CLI workflows
|
||||
|
||||
# Visual Studio
|
||||
# Visual Studio & Rider
|
||||
.vs/
|
||||
.idea/
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
*.DotSettings.user
|
||||
|
||||
# Build results
|
||||
[Bb]in/
|
||||
@@ -15,19 +17,15 @@ build/
|
||||
publish/
|
||||
artifacts/
|
||||
|
||||
# Rider
|
||||
.idea/
|
||||
|
||||
# Resharper
|
||||
_ReSharper*/
|
||||
*.DotSettings.user
|
||||
|
||||
# NuGet
|
||||
*.nupkg
|
||||
packages/
|
||||
project.lock.json
|
||||
|
||||
# Dotnet
|
||||
# Dotnet & EF Core
|
||||
*.db
|
||||
*.db-journal
|
||||
secrets.json
|
||||
@@ -36,6 +34,8 @@ dotnet_user_secrets
|
||||
# Logs
|
||||
*.log
|
||||
TestResults/
|
||||
coverage/
|
||||
*.trx
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
@@ -51,9 +51,23 @@ Icon
|
||||
*~
|
||||
*.tmp
|
||||
|
||||
# Node/React Frontend (Vite)
|
||||
OTSSignsOrchestrator/ClientApp/node_modules/
|
||||
OTSSignsOrchestrator/ClientApp/dist/
|
||||
OTSSignsOrchestrator/ClientApp/.env.local
|
||||
OTSSignsOrchestrator/ClientApp/.env.*.local
|
||||
OTSSignsOrchestrator/ClientApp/npm-debug.log*
|
||||
OTSSignsOrchestrator/ClientApp/yarn-debug.log*
|
||||
OTSSignsOrchestrator/ClientApp/yarn-error.log*
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
|
||||
# Ignore appsettings development files (if you keep secrets locally)
|
||||
# Configuration & Secrets
|
||||
appsettings.Development.json
|
||||
.template-cache/
|
||||
.template-cache/
|
||||
.env
|
||||
*.env
|
||||
|
||||
# Application-specific
|
||||
logs/
|
||||
|
||||
125
CLAUDE.md
Normal file
125
CLAUDE.md
Normal 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
56
Dockerfile
Normal 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"]
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Data;
|
||||
|
||||
public class XiboContext : DbContext
|
||||
{
|
||||
private readonly IDataProtectionProvider? _dataProtection;
|
||||
|
||||
public XiboContext(DbContextOptions<XiboContext> options, IDataProtectionProvider? dataProtection = null)
|
||||
: base(options)
|
||||
{
|
||||
_dataProtection = dataProtection;
|
||||
}
|
||||
|
||||
public DbSet<SshHost> SshHosts => Set<SshHost>();
|
||||
public DbSet<OperationLog> OperationLogs => Set<OperationLog>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// --- SshHost ---
|
||||
modelBuilder.Entity<SshHost>(entity =>
|
||||
{
|
||||
entity.HasIndex(e => e.Label).IsUnique();
|
||||
|
||||
if (_dataProtection != null)
|
||||
{
|
||||
var protector = _dataProtection.CreateProtector("OTSSignsOrchestrator.SshHost");
|
||||
var passphraseConverter = new ValueConverter<string?, string?>(
|
||||
v => v != null ? protector.Protect(v) : null,
|
||||
v => v != null ? protector.Unprotect(v) : null);
|
||||
var passwordConverter = new ValueConverter<string?, string?>(
|
||||
v => v != null ? protector.Protect(v) : null,
|
||||
v => v != null ? protector.Unprotect(v) : null);
|
||||
|
||||
entity.Property(e => e.KeyPassphrase).HasConversion(passphraseConverter);
|
||||
entity.Property(e => e.Password).HasConversion(passwordConverter);
|
||||
}
|
||||
});
|
||||
|
||||
// --- OperationLog ---
|
||||
modelBuilder.Entity<OperationLog>(entity =>
|
||||
{
|
||||
entity.HasIndex(e => e.Timestamp);
|
||||
entity.HasIndex(e => e.StackName);
|
||||
entity.HasIndex(e => e.Operation);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Migrations
|
||||
{
|
||||
[DbContext(typeof(XiboContext))]
|
||||
[Migration("20260225135644_DropAppSettings")]
|
||||
partial class DropAppSettings
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("DurationMs")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Operation")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("StackName")
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Operation");
|
||||
|
||||
b.HasIndex("StackName");
|
||||
|
||||
b.HasIndex("Timestamp");
|
||||
|
||||
b.ToTable("OperationLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Host")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("KeyPassphrase")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool?>("LastTestSuccess")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("LastTestedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Port")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PrivateKeyPath")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("UseKeyAuth")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Label")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SshHosts");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class DropAppSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AppSettings");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AppSettings",
|
||||
columns: table => new
|
||||
{
|
||||
Key = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
Category = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
|
||||
IsSensitive = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
Value = table.Column<string>(type: "TEXT", maxLength: 4000, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AppSettings", x => x.Key);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AppSettings_Category",
|
||||
table: "AppSettings",
|
||||
column: "Category");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Migrations
|
||||
{
|
||||
[DbContext(typeof(XiboContext))]
|
||||
partial class XiboContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.2");
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.OperationLog", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("DurationMs")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Operation")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("StackName")
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Operation");
|
||||
|
||||
b.HasIndex("StackName");
|
||||
|
||||
b.HasIndex("Timestamp");
|
||||
|
||||
b.ToTable("OperationLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OTSSignsOrchestrator.Core.Models.Entities.SshHost", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Host")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("KeyPassphrase")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool?>("LastTestSuccess")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("LastTestedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Port")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PrivateKeyPath")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("UseKeyAuth")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Label")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SshHosts");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bitwarden.Secrets.Sdk" Version="1.0.0" />
|
||||
<PackageReference Include="LibGit2Sharp" Version="0.31.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.2" />
|
||||
<PackageReference Include="MySqlConnector" Version="2.5.0" />
|
||||
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="9.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,57 +0,0 @@
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates the complete Authentik invitation infrastructure setup for a customer.
|
||||
/// Creates a group, enrollment flow with stages, role with invitation permissions,
|
||||
/// and scoping policies so the customer admin can invite new users without OTS involvement.
|
||||
/// </summary>
|
||||
public interface IInvitationSetupService
|
||||
{
|
||||
/// <summary>
|
||||
/// Sets up the full invitation infrastructure for a customer in Authentik:
|
||||
/// <list type="number">
|
||||
/// <item>Create customer group (e.g. <c>customer-acme</c>).</item>
|
||||
/// <item>Create invitation stage (invite-only, no anonymous enrollment).</item>
|
||||
/// <item>Create enrollment flow with stages: Invitation → Prompt → UserWrite → UserLogin.</item>
|
||||
/// <item>Bind expression policy to UserWrite stage to auto-assign users to the customer group.</item>
|
||||
/// <item>Create invite-manager role with invitation CRUD permissions.</item>
|
||||
/// <item>Assign role to customer group and bind scoping policy to flow.</item>
|
||||
/// </list>
|
||||
/// All operations are idempotent — safe to call multiple times for the same customer.
|
||||
/// </summary>
|
||||
/// <param name="customerAbbrev">Short customer identifier (e.g. "acme").</param>
|
||||
/// <param name="customerName">Human-readable customer name (e.g. "Acme Corp").</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Result describing what was created and the enrollment flow URL.</returns>
|
||||
Task<InvitationSetupResult> SetupCustomerInvitationAsync(
|
||||
string customerAbbrev,
|
||||
string customerName,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of the invitation infrastructure setup.
|
||||
/// </summary>
|
||||
public class InvitationSetupResult
|
||||
{
|
||||
/// <summary>Whether the setup completed successfully.</summary>
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>Human-readable status message.</summary>
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Name of the customer group created in Authentik.</summary>
|
||||
public string GroupName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Slug of the enrollment flow (used in invite links).</summary>
|
||||
public string EnrollmentFlowSlug { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Name of the role created for invitation management.</summary>
|
||||
public string RoleName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Full URL to the Authentik user portal where the customer admin
|
||||
/// can manage invitations.
|
||||
/// </summary>
|
||||
public string? InvitationManagementUrl { get; set; }
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates the 6-step Authentik invitation infrastructure setup for a customer.
|
||||
/// All operations are idempotent — the underlying Authentik API methods check for
|
||||
/// existing resources before creating new ones.
|
||||
///
|
||||
/// Per customer, this creates:
|
||||
/// 1. A group (<c>customer-{abbrev}</c>)
|
||||
/// 2. An invitation stage (<c>{abbrev}-invitation-stage</c>)
|
||||
/// 3. An enrollment flow (<c>{abbrev}-enrollment</c>) with stages bound in order
|
||||
/// 4. An expression policy on the UserWrite stage to auto-assign users to the group
|
||||
/// 5. A role (<c>{abbrev}-invite-manager</c>) with invitation CRUD permissions
|
||||
/// 6. A scoping policy on the flow so only group members can access it
|
||||
/// </summary>
|
||||
public class InvitationSetupService : IInvitationSetupService
|
||||
{
|
||||
private readonly IAuthentikService _authentik;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly ILogger<InvitationSetupService> _logger;
|
||||
|
||||
public InvitationSetupService(
|
||||
IAuthentikService authentik,
|
||||
SettingsService settings,
|
||||
ILogger<InvitationSetupService> logger)
|
||||
{
|
||||
_authentik = authentik;
|
||||
_settings = settings;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<InvitationSetupResult> SetupCustomerInvitationAsync(
|
||||
string customerAbbrev,
|
||||
string customerName,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var abbrev = customerAbbrev.Trim().ToLowerInvariant();
|
||||
var groupName = $"customer-{abbrev}";
|
||||
var flowSlug = $"{abbrev}-enrollment";
|
||||
var flowName = $"{customerName} Enrollment";
|
||||
var roleName = $"{abbrev}-invite-manager";
|
||||
var invitationStageName = $"{abbrev}-invitation-stage";
|
||||
|
||||
_logger.LogInformation(
|
||||
"[InviteSetup] Starting invitation infrastructure setup for customer '{Customer}' (abbrev={Abbrev})",
|
||||
customerName, abbrev);
|
||||
|
||||
try
|
||||
{
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Step 1: Create customer group
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
_logger.LogInformation("[InviteSetup] Step 1/6: Creating customer group '{Group}'", groupName);
|
||||
var groupPk = await _authentik.CreateGroupAsync(groupName, ct);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Step 2: Create invitation stage
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
_logger.LogInformation("[InviteSetup] Step 2/6: Creating invitation stage '{Stage}'", invitationStageName);
|
||||
var invitationStagePk = await _authentik.CreateInvitationStageAsync(
|
||||
invitationStageName, continueWithoutInvitation: false, ct);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Step 3: Create enrollment flow and bind stages
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
_logger.LogInformation("[InviteSetup] Step 3/6: Creating enrollment flow '{Slug}'", flowSlug);
|
||||
var flowPk = await _authentik.CreateEnrollmentFlowAsync(flowName, flowSlug, ct);
|
||||
|
||||
// Resolve built-in Authentik stages for the enrollment pipeline
|
||||
var promptStagePk = await _authentik.FindStageByNameAsync("default-enrollment-prompt", ct);
|
||||
var userWriteStagePk = await _authentik.FindStageByNameAsync("default-enrollment-user-write", ct);
|
||||
var userLoginStagePk = await _authentik.FindStageByNameAsync("default-enrollment-user-login", ct);
|
||||
|
||||
// Bind stages in order: 10=Invitation, 20=Prompt, 30=UserWrite, 40=UserLogin
|
||||
await _authentik.BindStageToFlowAsync(flowSlug, invitationStagePk, 10, ct);
|
||||
|
||||
if (promptStagePk != null)
|
||||
{
|
||||
await _authentik.BindStageToFlowAsync(flowSlug, promptStagePk, 20, ct);
|
||||
_logger.LogInformation("[InviteSetup] Bound default prompt stage at order 20");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("[InviteSetup] Built-in 'default-enrollment-prompt' stage not found — " +
|
||||
"you may need to create a prompt stage manually and bind it to flow '{Slug}' at order 20", flowSlug);
|
||||
}
|
||||
|
||||
if (userWriteStagePk != null)
|
||||
{
|
||||
await _authentik.BindStageToFlowAsync(flowSlug, userWriteStagePk, 30, ct);
|
||||
_logger.LogInformation("[InviteSetup] Bound default user-write stage at order 30");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("[InviteSetup] Built-in 'default-enrollment-user-write' stage not found — " +
|
||||
"you may need to create a user-write stage manually and bind it to flow '{Slug}' at order 30", flowSlug);
|
||||
}
|
||||
|
||||
if (userLoginStagePk != null)
|
||||
{
|
||||
await _authentik.BindStageToFlowAsync(flowSlug, userLoginStagePk, 40, ct);
|
||||
_logger.LogInformation("[InviteSetup] Bound default user-login stage at order 40");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("[InviteSetup] Built-in 'default-enrollment-user-login' stage not found — " +
|
||||
"you may need to create a user-login stage manually and bind it to flow '{Slug}' at order 40", flowSlug);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Step 4: Create group-assignment policy and bind to UserWrite stage
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
_logger.LogInformation("[InviteSetup] Step 4/6: Creating group-assignment expression policy");
|
||||
|
||||
var groupAssignPolicyName = $"{abbrev}-auto-assign-group";
|
||||
var groupAssignExpression = $"""
|
||||
from authentik.core.models import Group
|
||||
|
||||
# Auto-assign to customer group on registration
|
||||
group = Group.objects.filter(name="{groupName}").first()
|
||||
if group and context.get("pending_user"):
|
||||
context["pending_user"].ak_groups.add(group)
|
||||
|
||||
return True
|
||||
""";
|
||||
|
||||
var groupAssignPolicyPk = await _authentik.CreateExpressionPolicyAsync(
|
||||
groupAssignPolicyName, groupAssignExpression, ct);
|
||||
|
||||
// Bind policy to the UserWrite stage binding (order 30)
|
||||
if (userWriteStagePk != null)
|
||||
{
|
||||
var userWriteBindingPk = await _authentik.GetFlowStageBindingPkAsync(flowSlug, 30, ct);
|
||||
if (userWriteBindingPk != null)
|
||||
{
|
||||
await _authentik.BindPolicyToFlowStageBoundAsync(userWriteBindingPk, groupAssignPolicyPk, ct);
|
||||
_logger.LogInformation("[InviteSetup] Group-assignment policy bound to UserWrite stage");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("[InviteSetup] Could not find flow-stage binding at order 30 to attach group policy");
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Step 5: Create invite-manager role with permissions
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
_logger.LogInformation("[InviteSetup] Step 5/6: Creating invite-manager role '{Role}'", roleName);
|
||||
var rolePk = await _authentik.CreateRoleAsync(roleName, ct);
|
||||
|
||||
// Assign invitation CRUD permissions
|
||||
var invitationPermissions = new[]
|
||||
{
|
||||
"add_invitation",
|
||||
"view_invitation",
|
||||
"delete_invitation",
|
||||
};
|
||||
await _authentik.AssignPermissionsToRoleAsync(rolePk, invitationPermissions, ct);
|
||||
|
||||
// Assign role to the customer group
|
||||
await _authentik.AssignRoleToGroupAsync(rolePk, groupPk, ct);
|
||||
_logger.LogInformation("[InviteSetup] Role '{Role}' assigned to group '{Group}'", roleName, groupName);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Step 6: Create scoping policy and bind to flow
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
_logger.LogInformation("[InviteSetup] Step 6/6: Creating invitation scoping policy");
|
||||
|
||||
var scopePolicyName = $"scope-invitations-to-{abbrev}";
|
||||
var scopeExpression = $"""
|
||||
# Only allow users in {groupName} group to manage these invitations
|
||||
user = context.get("pending_user") or request.user
|
||||
|
||||
if user.ak_groups.filter(name="{groupName}").exists():
|
||||
return True
|
||||
|
||||
return False
|
||||
""";
|
||||
|
||||
var scopePolicyPk = await _authentik.CreateExpressionPolicyAsync(scopePolicyName, scopeExpression, ct);
|
||||
|
||||
// Bind scoping policy to the enrollment flow
|
||||
await _authentik.BindPolicyToFlowAsync(flowSlug, scopePolicyPk, ct);
|
||||
_logger.LogInformation("[InviteSetup] Scoping policy bound to enrollment flow");
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Build result with management URL
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
var authentikUrl = await _settings.GetAsync(SettingsService.AuthentikUrl);
|
||||
var managementUrl = !string.IsNullOrWhiteSpace(authentikUrl)
|
||||
? $"{authentikUrl.TrimEnd('/')}/if/admin/#/core/invitations"
|
||||
: null;
|
||||
|
||||
var result = new InvitationSetupResult
|
||||
{
|
||||
Success = true,
|
||||
Message = $"Invitation infrastructure created for {customerName}. " +
|
||||
$"Group: {groupName}, Flow: {flowSlug}, Role: {roleName}.",
|
||||
GroupName = groupName,
|
||||
EnrollmentFlowSlug = flowSlug,
|
||||
RoleName = roleName,
|
||||
InvitationManagementUrl = managementUrl,
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"[InviteSetup] Setup complete for '{Customer}': group={Group}, flow={Flow}, role={Role}, url={Url}",
|
||||
customerName, groupName, flowSlug, roleName, managementUrl ?? "(no URL)");
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"[InviteSetup] Invitation setup failed for '{Customer}' (abbrev={Abbrev}): {Message}",
|
||||
customerName, abbrev, ex.Message);
|
||||
|
||||
return new InvitationSetupResult
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Invitation setup failed: {ex.Message}",
|
||||
GroupName = groupName,
|
||||
EnrollmentFlowSlug = flowSlug,
|
||||
RoleName = roleName,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reads and writes application settings from Bitwarden Secrets Manager.
|
||||
/// Each setting is stored as a Bitwarden secret with key prefix "ots-config/".
|
||||
/// The secret's Note field stores metadata (category|isSensitive).
|
||||
/// </summary>
|
||||
public class SettingsService
|
||||
{
|
||||
private readonly IBitwardenSecretService _bws;
|
||||
private readonly ILogger<SettingsService> _logger;
|
||||
|
||||
/// <summary>Prefix applied to all config secret keys in Bitwarden.</summary>
|
||||
private const string KeyPrefix = "ots-config/";
|
||||
|
||||
// ── Category constants ─────────────────────────────────────────────────
|
||||
public const string CatGit = "Git";
|
||||
public const string CatMySql = "MySql";
|
||||
public const string CatSmtp = "Smtp";
|
||||
public const string CatPangolin = "Pangolin";
|
||||
public const string CatNfs = "Nfs";
|
||||
public const string CatDefaults = "Defaults";
|
||||
public const string CatAuthentik = "Authentik";
|
||||
|
||||
// ── Key constants ──────────────────────────────────────────────────────
|
||||
// Git
|
||||
public const string GitRepoUrl = "Git.RepoUrl";
|
||||
public const string GitRepoPat = "Git.RepoPat";
|
||||
|
||||
// MySQL Admin
|
||||
public const string MySqlHost = "MySql.Host";
|
||||
public const string MySqlPort = "MySql.Port";
|
||||
public const string MySqlAdminUser = "MySql.AdminUser";
|
||||
public const string MySqlAdminPassword = "MySql.AdminPassword";
|
||||
|
||||
// SMTP
|
||||
public const string SmtpServer = "Smtp.Server";
|
||||
public const string SmtpPort = "Smtp.Port";
|
||||
public const string SmtpUsername = "Smtp.Username";
|
||||
public const string SmtpPassword = "Smtp.Password";
|
||||
public const string SmtpUseTls = "Smtp.UseTls";
|
||||
public const string SmtpUseStartTls = "Smtp.UseStartTls";
|
||||
public const string SmtpRewriteDomain = "Smtp.RewriteDomain";
|
||||
public const string SmtpHostname = "Smtp.Hostname";
|
||||
public const string SmtpFromLineOverride = "Smtp.FromLineOverride";
|
||||
|
||||
// Pangolin
|
||||
public const string PangolinEndpoint = "Pangolin.Endpoint";
|
||||
|
||||
// NFS
|
||||
public const string NfsServer = "Nfs.Server";
|
||||
public const string NfsExport = "Nfs.Export";
|
||||
public const string NfsExportFolder = "Nfs.ExportFolder";
|
||||
public const string NfsOptions = "Nfs.Options";
|
||||
|
||||
// Instance Defaults
|
||||
public const string DefaultCmsImage = "Defaults.CmsImage";
|
||||
public const string DefaultNewtImage = "Defaults.NewtImage";
|
||||
public const string DefaultMemcachedImage = "Defaults.MemcachedImage";
|
||||
public const string DefaultQuickChartImage = "Defaults.QuickChartImage";
|
||||
public const string DefaultCmsServerNameTemplate = "Defaults.CmsServerNameTemplate";
|
||||
public const string DefaultThemeHostPath = "Defaults.ThemeHostPath";
|
||||
public const string DefaultMySqlDbTemplate = "Defaults.MySqlDbTemplate";
|
||||
public const string DefaultMySqlUserTemplate = "Defaults.MySqlUserTemplate";
|
||||
public const string DefaultPhpPostMaxSize = "Defaults.PhpPostMaxSize";
|
||||
public const string DefaultPhpUploadMaxFilesize = "Defaults.PhpUploadMaxFilesize";
|
||||
public const string DefaultPhpMaxExecutionTime = "Defaults.PhpMaxExecutionTime";
|
||||
|
||||
// Xibo bootstrap OAuth2 credentials (one-time global setup for orchestrator API access)
|
||||
public const string CatXibo = "Xibo";
|
||||
public const string XiboBootstrapClientId = "Xibo.BootstrapClientId";
|
||||
public const string XiboBootstrapClientSecret = "Xibo.BootstrapClientSecret";
|
||||
|
||||
// Authentik (SAML IdP provisioning)
|
||||
public const string AuthentikUrl = "Authentik.Url";
|
||||
public const string AuthentikApiKey = "Authentik.ApiKey";
|
||||
public const string AuthentikAuthorizationFlowSlug = "Authentik.AuthorizationFlowSlug";
|
||||
public const string AuthentikInvalidationFlowSlug = "Authentik.InvalidationFlowSlug";
|
||||
public const string AuthentikSigningKeypairId = "Authentik.SigningKeypairId";
|
||||
|
||||
// Instance-specific (keyed by abbreviation)
|
||||
/// <summary>
|
||||
/// Builds a per-instance settings key for the MySQL password.
|
||||
/// </summary>
|
||||
public static string InstanceMySqlPassword(string abbrev) => $"Instance.{abbrev}.MySqlPassword";
|
||||
/// <summary>Bitwarden secret ID for the instance's OTS admin password.</summary>
|
||||
public static string InstanceAdminPasswordSecretId(string abbrev) => $"Instance.{abbrev}.AdminPasswordBwsId";
|
||||
/// <summary>Bitwarden secret ID for the instance's Xibo OAuth2 client secret.</summary>
|
||||
public static string InstanceOAuthSecretId(string abbrev) => $"Instance.{abbrev}.OAuthSecretBwsId";
|
||||
/// <summary>Xibo OAuth2 client_id generated for this instance's OTS application.</summary>
|
||||
public static string InstanceOAuthClientId(string abbrev) => $"Instance.{abbrev}.OAuthClientId";
|
||||
public const string CatInstance = "Instance";
|
||||
|
||||
// ── In-memory cache of secrets (loaded on first access) ────────────────
|
||||
// Maps Bitwarden secret key (with prefix) → (id, value)
|
||||
// Static so the cache is shared across all transient SettingsService instances.
|
||||
private static Dictionary<string, (string Id, string Value)>? s_cache;
|
||||
|
||||
public SettingsService(
|
||||
IBitwardenSecretService bws,
|
||||
ILogger<SettingsService> logger)
|
||||
{
|
||||
_bws = bws;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Get a single setting value from Bitwarden.</summary>
|
||||
public async Task<string?> GetAsync(string key)
|
||||
{
|
||||
var cache = await EnsureCacheAsync();
|
||||
var bwKey = KeyPrefix + key;
|
||||
if (!cache.TryGetValue(bwKey, out var entry))
|
||||
return null;
|
||||
// Treat single-space sentinel as empty (used to work around SDK marshalling limitation)
|
||||
return string.IsNullOrWhiteSpace(entry.Value) ? null : entry.Value;
|
||||
}
|
||||
|
||||
/// <summary>Get a setting with a fallback default.</summary>
|
||||
public async Task<string> GetAsync(string key, string defaultValue)
|
||||
=> await GetAsync(key) ?? defaultValue;
|
||||
|
||||
/// <summary>Set a single setting in Bitwarden (creates or updates).</summary>
|
||||
public async Task SetAsync(string key, string? value, string category, bool isSensitive = false)
|
||||
{
|
||||
var cache = await EnsureCacheAsync();
|
||||
var bwKey = KeyPrefix + key;
|
||||
var note = $"{category}|{(isSensitive ? "sensitive" : "plain")}";
|
||||
// Use a single space for empty/null values — the Bitwarden SDK native FFI
|
||||
// cannot marshal empty strings reliably.
|
||||
var safeValue = string.IsNullOrEmpty(value) ? " " : value;
|
||||
|
||||
if (cache.TryGetValue(bwKey, out var existing))
|
||||
{
|
||||
// Update existing secret
|
||||
await _bws.UpdateSecretAsync(existing.Id, bwKey, safeValue, note);
|
||||
cache[bwKey] = (existing.Id, safeValue);
|
||||
s_cache = cache;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
// Only create new secrets when there is an actual value to store
|
||||
var newId = await _bws.CreateSecretAsync(bwKey, safeValue, note);
|
||||
cache[bwKey] = (newId, safeValue);
|
||||
s_cache = cache;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Save multiple settings in a batch.</summary>
|
||||
public async Task SaveManyAsync(IEnumerable<(string Key, string? Value, string Category, bool IsSensitive)> settings)
|
||||
{
|
||||
var count = 0;
|
||||
var errors = new List<string>();
|
||||
foreach (var (key, value, category, isSensitive) in settings)
|
||||
{
|
||||
try
|
||||
{
|
||||
await SetAsync(key, value, category, isSensitive);
|
||||
count++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to save setting {Key}", key);
|
||||
errors.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Saved {Count} setting(s) to Bitwarden", count);
|
||||
|
||||
if (errors.Count > 0)
|
||||
throw new AggregateException(
|
||||
$"Failed to save {errors.Count} setting(s): {string.Join(", ", errors)}");
|
||||
}
|
||||
|
||||
/// <summary>Get all settings in a category (by examining cached keys).</summary>
|
||||
public async Task<Dictionary<string, string?>> GetCategoryAsync(string category)
|
||||
{
|
||||
var cache = await EnsureCacheAsync();
|
||||
var prefix = KeyPrefix + category + ".";
|
||||
var result = new Dictionary<string, string?>();
|
||||
|
||||
foreach (var (bwKey, entry) in cache)
|
||||
{
|
||||
if (bwKey.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Strip the "ots-config/" prefix to return the original key
|
||||
var originalKey = bwKey[KeyPrefix.Length..];
|
||||
result[originalKey] = entry.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates the in-memory cache so next access re-fetches from Bitwarden.
|
||||
/// </summary>
|
||||
public void InvalidateCache() => s_cache = null;
|
||||
|
||||
/// <summary>
|
||||
/// Pre-loads the settings cache from Bitwarden.
|
||||
/// Call once at startup so settings are available immediately.
|
||||
/// </summary>
|
||||
public async Task PreloadCacheAsync()
|
||||
{
|
||||
InvalidateCache();
|
||||
await EnsureCacheAsync();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Cache management
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<Dictionary<string, (string Id, string Value)>> EnsureCacheAsync()
|
||||
{
|
||||
if (s_cache is not null)
|
||||
return s_cache;
|
||||
|
||||
var cache = new Dictionary<string, (string, string)>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Skip loading if Bitwarden is not yet configured (normal on first run)
|
||||
if (!await _bws.IsConfiguredAsync())
|
||||
{
|
||||
_logger.LogInformation("Bitwarden is not configured yet — settings will be available after setup");
|
||||
s_cache = cache;
|
||||
return s_cache;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// List all secrets, then fetch full value for those matching our prefix
|
||||
var summaries = await _bws.ListSecretsAsync();
|
||||
var configSecrets = summaries
|
||||
.Where(s => s.Key.StartsWith(KeyPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation("Loading {Count} config secrets from Bitwarden", configSecrets.Count);
|
||||
|
||||
foreach (var summary in configSecrets)
|
||||
{
|
||||
try
|
||||
{
|
||||
var full = await _bws.GetSecretAsync(summary.Id);
|
||||
cache[full.Key] = (full.Id, full.Value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load secret {Key} ({Id})", summary.Key, summary.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load settings from Bitwarden — settings will be empty until Bitwarden is configured");
|
||||
}
|
||||
|
||||
s_cache = cache;
|
||||
return s_cache;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -1,208 +0,0 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Polly;
|
||||
using Polly.Extensions.Http;
|
||||
using Refit;
|
||||
using Serilog;
|
||||
using OTSSignsOrchestrator.Core.Configuration;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
using OTSSignsOrchestrator.Core.Services;
|
||||
using OTSSignsOrchestrator.Desktop.Services;
|
||||
using OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
using OTSSignsOrchestrator.Desktop.Views;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop;
|
||||
|
||||
public class App : Application
|
||||
{
|
||||
public static IServiceProvider Services { get; private set; } = null!;
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
ConfigureServices(services);
|
||||
Services = services.BuildServiceProvider();
|
||||
|
||||
// Apply migrations
|
||||
using (var scope = Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||
db.Database.Migrate();
|
||||
}
|
||||
|
||||
Log.Information("ApplicationLifetime type: {Type}", ApplicationLifetime?.GetType().FullName ?? "null");
|
||||
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
Log.Information("Creating MainWindow...");
|
||||
|
||||
// Import existing instance secrets from Bitwarden (fire-and-forget, non-blocking)
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// Pre-load config settings from Bitwarden so they're available immediately
|
||||
using var scope = Services.CreateScope();
|
||||
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
await settings.PreloadCacheAsync();
|
||||
Log.Information("Bitwarden config settings pre-loaded");
|
||||
|
||||
// Import existing instance secrets that aren't yet tracked
|
||||
var postInit = Services.GetRequiredService<PostInstanceInitService>();
|
||||
await postInit.ImportExistingInstanceSecretsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to pre-load settings or import instance secrets on startup");
|
||||
}
|
||||
});
|
||||
|
||||
var vm = Services.GetRequiredService<MainWindowViewModel>();
|
||||
Log.Information("MainWindowViewModel resolved");
|
||||
|
||||
var window = new MainWindow
|
||||
{
|
||||
DataContext = vm
|
||||
};
|
||||
|
||||
desktop.MainWindow = window;
|
||||
Log.Information("MainWindow assigned to lifetime");
|
||||
|
||||
window.Show();
|
||||
window.Activate();
|
||||
Log.Information("MainWindow Show() + Activate() called");
|
||||
|
||||
// Start the SignalR connection (fire-and-forget, reconnect handles failures)
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var signalR = Services.GetRequiredService<ServerSignalRService>();
|
||||
await signalR.StartAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to start SignalR connection on startup");
|
||||
}
|
||||
});
|
||||
|
||||
desktop.ShutdownRequested += (_, _) =>
|
||||
{
|
||||
var ssh = Services.GetService<SshConnectionService>();
|
||||
ssh?.Dispose();
|
||||
var signalR = Services.GetService<ServerSignalRService>();
|
||||
signalR?.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Warning("ApplicationLifetime is NOT IClassicDesktopStyleApplicationLifetime — window cannot be shown");
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
private static void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
// Configuration (reloadOnChange so runtime writes to appsettings.json are picked up)
|
||||
var config = new ConfigurationBuilder()
|
||||
.SetBasePath(AppContext.BaseDirectory)
|
||||
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
|
||||
.Build();
|
||||
|
||||
services.AddSingleton<IConfiguration>(config);
|
||||
|
||||
// Options
|
||||
services.Configure<GitOptions>(config.GetSection(GitOptions.SectionName));
|
||||
services.Configure<DockerOptions>(config.GetSection(DockerOptions.SectionName));
|
||||
services.Configure<XiboOptions>(config.GetSection(XiboOptions.SectionName));
|
||||
services.Configure<DatabaseOptions>(config.GetSection(DatabaseOptions.SectionName));
|
||||
services.Configure<FileLoggingOptions>(config.GetSection(FileLoggingOptions.SectionName));
|
||||
services.Configure<BitwardenOptions>(config.GetSection(BitwardenOptions.SectionName));
|
||||
|
||||
// Logging
|
||||
services.AddLogging(builder =>
|
||||
{
|
||||
builder.ClearProviders();
|
||||
builder.AddSerilog(dispose: true);
|
||||
});
|
||||
|
||||
// Data Protection
|
||||
var keysDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"OTSSignsOrchestrator", "keys");
|
||||
Directory.CreateDirectory(keysDir);
|
||||
|
||||
services.AddDataProtection()
|
||||
.PersistKeysToFileSystem(new DirectoryInfo(keysDir))
|
||||
.SetApplicationName("OTSSignsOrchestrator");
|
||||
|
||||
// Database
|
||||
var connStr = config.GetConnectionString("Default") ?? "Data Source=otssigns-desktop.db";
|
||||
services.AddDbContext<XiboContext>(options => options.UseSqlite(connStr));
|
||||
|
||||
// HTTP
|
||||
services.AddHttpClient();
|
||||
services.AddHttpClient("XiboApi");
|
||||
services.AddHttpClient("XiboHealth");
|
||||
services.AddHttpClient("AuthentikApi");
|
||||
|
||||
// ── Server API integration ──────────────────────────────────────────
|
||||
services.AddSingleton<TokenStoreService>();
|
||||
services.AddTransient<AuthHeaderHandler>();
|
||||
|
||||
var serverBaseUrl = config["ServerBaseUrl"] ?? "https://localhost:5001";
|
||||
services.AddRefitClient<IServerApiClient>()
|
||||
.ConfigureHttpClient(c => c.BaseAddress = new Uri(serverBaseUrl))
|
||||
.AddHttpMessageHandler<AuthHeaderHandler>()
|
||||
.AddPolicyHandler(HttpPolicyExtensions
|
||||
.HandleTransientHttpError()
|
||||
.WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt))));
|
||||
|
||||
services.AddSingleton<ServerSignalRService>();
|
||||
|
||||
// SSH services (singletons — maintain connections)
|
||||
services.AddSingleton<SshConnectionService>();
|
||||
|
||||
// Docker services via SSH (singletons — SetHost() must persist across scopes)
|
||||
services.AddSingleton<SshDockerCliService>();
|
||||
services.AddSingleton<SshDockerSecretsService>();
|
||||
services.AddSingleton<IDockerCliService>(sp => sp.GetRequiredService<SshDockerCliService>());
|
||||
services.AddSingleton<IDockerSecretsService>(sp => sp.GetRequiredService<SshDockerSecretsService>());
|
||||
|
||||
// Core services
|
||||
services.AddTransient<SettingsService>();
|
||||
services.AddTransient<GitTemplateService>();
|
||||
services.AddTransient<ComposeRenderService>();
|
||||
services.AddTransient<ComposeValidationService>();
|
||||
services.AddTransient<XiboApiService>();
|
||||
services.AddTransient<InstanceService>();
|
||||
services.AddTransient<IBitwardenSecretService, BitwardenSecretService>();
|
||||
services.AddTransient<IAuthentikService, AuthentikService>();
|
||||
services.AddTransient<IInvitationSetupService, InvitationSetupService>();
|
||||
services.AddSingleton<PostInstanceInitService>();
|
||||
|
||||
// ViewModels
|
||||
services.AddSingleton<MainWindowViewModel>(); // singleton: one main window, nav state shared
|
||||
services.AddTransient<HostsViewModel>();
|
||||
services.AddTransient<InstancesViewModel>();
|
||||
services.AddTransient<InstanceDetailsViewModel>();
|
||||
services.AddTransient<CreateInstanceViewModel>();
|
||||
services.AddTransient<SecretsViewModel>();
|
||||
services.AddTransient<SettingsViewModel>();
|
||||
services.AddTransient<LogsViewModel>();
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 MiB |
@@ -1,31 +0,0 @@
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a CMS stack discovered live from a Docker Swarm host.
|
||||
/// No data is persisted locally — all values come from <c>docker stack ls</c> / inspect.
|
||||
/// </summary>
|
||||
public class LiveStackItem
|
||||
{
|
||||
/// <summary>Docker stack name, e.g. "acm-cms-stack".</summary>
|
||||
public string StackName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>3-letter abbreviation derived from the stack name.</summary>
|
||||
public string CustomerAbbrev { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Number of services reported by <c>docker stack ls</c>.</summary>
|
||||
public int ServiceCount { get; set; }
|
||||
|
||||
/// <summary>The SSH host this stack was found on.</summary>
|
||||
public SshHost Host { get; set; } = null!;
|
||||
|
||||
/// <summary>Label of the host — convenience property for data-binding.</summary>
|
||||
public string HostLabel => Host?.Label ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Server-side customer ID. Populated when fleet data is loaded from the server API.
|
||||
/// Null when loaded only from local Docker discovery.
|
||||
/// </summary>
|
||||
public Guid? CustomerId { get; set; }
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
<!-- Ensure the Bitwarden SDK native runtime libraries are included on publish -->
|
||||
<RuntimeIdentifiers>linux-x64;win-x64;osx-x64;osx-arm64</RuntimeIdentifiers>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.2.3" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.3" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.2.3" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.3" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.3" />
|
||||
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.3" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.2" />
|
||||
<PackageReference Include="MySqlConnector" Version="2.5.0" />
|
||||
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="9.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.2" />
|
||||
<PackageReference Include="Refit.HttpClientFactory" Version="8.0.0" />
|
||||
<PackageReference Include="SSH.NET" Version="2024.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OTSSignsOrchestrator.Core\OTSSignsOrchestrator.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\templates\settings-custom.php.template">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<Link>templates/settings-custom.php.template</Link>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
using Refit;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Services;
|
||||
|
||||
// ── DTOs matching server REST API responses ─────────────────────────────────
|
||||
|
||||
public record FleetSummaryDto
|
||||
{
|
||||
public Guid CustomerId { get; init; }
|
||||
public string Abbreviation { get; init; } = string.Empty;
|
||||
public string CompanyName { get; init; } = string.Empty;
|
||||
public string Plan { get; init; } = string.Empty;
|
||||
public int ScreenCount { get; init; }
|
||||
public string HealthStatus { get; init; } = "Unknown";
|
||||
public DateTime? LastHealthCheck { get; init; }
|
||||
public bool HasRunningJob { get; init; }
|
||||
}
|
||||
|
||||
public record CustomerDetailDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string Abbreviation { get; init; } = string.Empty;
|
||||
public string CompanyName { get; init; } = string.Empty;
|
||||
public string? AdminEmail { get; init; }
|
||||
public string Plan { get; init; } = string.Empty;
|
||||
public int ScreenCount { get; init; }
|
||||
public string Status { get; init; } = string.Empty;
|
||||
public DateTime CreatedAt { get; init; }
|
||||
public List<CustomerInstanceDto> Instances { get; init; } = [];
|
||||
public List<CustomerJobDto> ActiveJobs { get; init; } = [];
|
||||
}
|
||||
|
||||
public record CustomerInstanceDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string? XiboUrl { get; init; }
|
||||
public string? DockerStackName { get; init; }
|
||||
public string HealthStatus { get; init; } = "Unknown";
|
||||
public DateTime? LastHealthCheck { get; init; }
|
||||
}
|
||||
|
||||
public record CustomerJobDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string JobType { get; init; } = string.Empty;
|
||||
public string Status { get; init; } = string.Empty;
|
||||
public DateTime CreatedAt { get; init; }
|
||||
public DateTime? StartedAt { get; init; }
|
||||
}
|
||||
|
||||
public record CreateJobRequest(Guid CustomerId, string JobType, string? Parameters);
|
||||
|
||||
public record CreateJobResponse
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string JobType { get; init; } = string.Empty;
|
||||
public string Status { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public record JobDetailDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid CustomerId { get; init; }
|
||||
public string JobType { get; init; } = string.Empty;
|
||||
public string Status { get; init; } = string.Empty;
|
||||
public string? TriggeredBy { get; init; }
|
||||
public string? Parameters { get; init; }
|
||||
public DateTime CreatedAt { get; init; }
|
||||
public DateTime? StartedAt { get; init; }
|
||||
public DateTime? CompletedAt { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
public List<JobStepDto> Steps { get; init; } = [];
|
||||
}
|
||||
|
||||
public record JobStepDto
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public string StepName { get; init; } = string.Empty;
|
||||
public string Status { get; init; } = string.Empty;
|
||||
public string? LogOutput { get; init; }
|
||||
public DateTime? StartedAt { get; init; }
|
||||
public DateTime? CompletedAt { get; init; }
|
||||
}
|
||||
|
||||
public record LoginRequest(string Email, string Password);
|
||||
public record RefreshRequest(string RefreshToken);
|
||||
|
||||
public record AuthResponse
|
||||
{
|
||||
public string Token { get; init; } = string.Empty;
|
||||
public string RefreshToken { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public record RefreshResponse
|
||||
{
|
||||
public string Token { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
// ── Refit interface ─────────────────────────────────────────────────────────
|
||||
|
||||
[Headers("Accept: application/json")]
|
||||
public interface IServerApiClient
|
||||
{
|
||||
[Get("/api/fleet")]
|
||||
Task<List<FleetSummaryDto>> GetFleetAsync();
|
||||
|
||||
[Get("/api/fleet/{id}")]
|
||||
Task<CustomerDetailDto> GetCustomerDetailAsync(Guid id);
|
||||
|
||||
[Post("/api/jobs")]
|
||||
Task<CreateJobResponse> CreateJobAsync([Body] CreateJobRequest body);
|
||||
|
||||
[Get("/api/jobs/{id}")]
|
||||
Task<JobDetailDto> GetJobAsync(Guid id);
|
||||
|
||||
[Post("/api/auth/login")]
|
||||
Task<AuthResponse> LoginAsync([Body] LoginRequest body);
|
||||
|
||||
[Post("/api/auth/refresh")]
|
||||
Task<RefreshResponse> RefreshAsync([Body] RefreshRequest body);
|
||||
|
||||
[Get("/api/reports/billing")]
|
||||
Task<HttpResponseMessage> GetBillingCsvAsync([Query] DateOnly from, [Query] DateOnly to);
|
||||
|
||||
[Get("/api/reports/fleet-health")]
|
||||
Task<HttpResponseMessage> GetFleetHealthPdfAsync();
|
||||
|
||||
[Post("/api/fleet/bulk/{action}")]
|
||||
Task<HttpResponseMessage> BulkActionAsync(string action);
|
||||
}
|
||||
|
||||
// ── DelegatingHandler for Bearer token injection ────────────────────────────
|
||||
|
||||
public class AuthHeaderHandler : DelegatingHandler
|
||||
{
|
||||
private readonly TokenStoreService _tokenStore;
|
||||
|
||||
public AuthHeaderHandler(TokenStoreService tokenStore)
|
||||
{
|
||||
_tokenStore = tokenStore;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var jwt = _tokenStore.GetJwt();
|
||||
if (!string.IsNullOrEmpty(jwt))
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", jwt);
|
||||
|
||||
return await base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Singleton service managing the persistent SignalR connection to the server's FleetHub.
|
||||
/// All handlers dispatch to the UI thread and republish via <see cref="WeakReferenceMessenger"/>.
|
||||
/// </summary>
|
||||
public sealed class ServerSignalRService : IAsyncDisposable
|
||||
{
|
||||
private readonly HubConnection _connection;
|
||||
private readonly ILogger<ServerSignalRService> _logger;
|
||||
|
||||
public ServerSignalRService(
|
||||
TokenStoreService tokenStore,
|
||||
IConfiguration config,
|
||||
ILogger<ServerSignalRService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
var baseUrl = config["ServerBaseUrl"] ?? "https://localhost:5001";
|
||||
|
||||
_connection = new HubConnectionBuilder()
|
||||
.WithUrl($"{baseUrl}/hubs/fleet", options =>
|
||||
{
|
||||
options.AccessTokenProvider = () => Task.FromResult(tokenStore.GetJwt());
|
||||
})
|
||||
.WithAutomaticReconnect(new[] { TimeSpan.Zero, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(30) })
|
||||
.Build();
|
||||
|
||||
RegisterHandlers();
|
||||
|
||||
_connection.Reconnecting += ex =>
|
||||
{
|
||||
_logger.LogWarning(ex, "SignalR reconnecting...");
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
_connection.Reconnected += connectionId =>
|
||||
{
|
||||
_logger.LogInformation("SignalR reconnected (connId={ConnectionId})", connectionId);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
_connection.Closed += ex =>
|
||||
{
|
||||
_logger.LogWarning(ex, "SignalR connection closed");
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the SignalR connection. Call from <c>App.OnFrameworkInitializationCompleted</c>.
|
||||
/// Failures are logged but do not throw — automatic reconnect will retry.
|
||||
/// </summary>
|
||||
public async Task StartAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _connection.StartAsync();
|
||||
_logger.LogInformation("SignalR connected to FleetHub");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "SignalR initial connection failed — will retry via automatic reconnect");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
try { await _connection.StopAsync(); }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "Error stopping SignalR connection"); }
|
||||
}
|
||||
|
||||
public HubConnectionState State => _connection.State;
|
||||
|
||||
private void RegisterHandlers()
|
||||
{
|
||||
_connection.On<string, string, string>("SendJobCreated", (jobId, abbrev, jobType) =>
|
||||
Dispatcher.UIThread.InvokeAsync(() =>
|
||||
WeakReferenceMessenger.Default.Send(
|
||||
new JobCreatedMessage(new(jobId, abbrev, jobType)))));
|
||||
|
||||
_connection.On<string, string, int, string>("SendJobProgressUpdate", (jobId, stepName, pct, logLine) =>
|
||||
Dispatcher.UIThread.InvokeAsync(() =>
|
||||
WeakReferenceMessenger.Default.Send(
|
||||
new JobProgressUpdateMessage(new(jobId, stepName, pct, logLine)))));
|
||||
|
||||
_connection.On<string, bool, string>("SendJobCompleted", (jobId, success, summary) =>
|
||||
Dispatcher.UIThread.InvokeAsync(() =>
|
||||
WeakReferenceMessenger.Default.Send(
|
||||
new JobCompletedMessage(new(jobId, success, summary)))));
|
||||
|
||||
_connection.On<string, string>("SendInstanceStatusChanged", (customerId, status) =>
|
||||
Dispatcher.UIThread.InvokeAsync(() =>
|
||||
WeakReferenceMessenger.Default.Send(
|
||||
new InstanceStatusChangedMessage(new(customerId, status)))));
|
||||
|
||||
_connection.On<string, string>("SendAlertRaised", (severity, message) =>
|
||||
Dispatcher.UIThread.InvokeAsync(() =>
|
||||
WeakReferenceMessenger.Default.Send(
|
||||
new AlertRaisedMessage(new(severity, message)))));
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
using CommunityToolkit.Mvvm.Messaging.Messages;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Services;
|
||||
|
||||
/// <summary>SignalR push messages republished via WeakReferenceMessenger for ViewModel consumption.</summary>
|
||||
|
||||
public sealed class JobCreatedMessage : ValueChangedMessage<JobCreatedMessage.Payload>
|
||||
{
|
||||
public JobCreatedMessage(Payload value) : base(value) { }
|
||||
public record Payload(string JobId, string Abbrev, string JobType);
|
||||
}
|
||||
|
||||
public sealed class JobProgressUpdateMessage : ValueChangedMessage<JobProgressUpdateMessage.Payload>
|
||||
{
|
||||
public JobProgressUpdateMessage(Payload value) : base(value) { }
|
||||
public record Payload(string JobId, string StepName, int Pct, string LogLine);
|
||||
}
|
||||
|
||||
public sealed class JobCompletedMessage : ValueChangedMessage<JobCompletedMessage.Payload>
|
||||
{
|
||||
public JobCompletedMessage(Payload value) : base(value) { }
|
||||
public record Payload(string JobId, bool Success, string Summary);
|
||||
}
|
||||
|
||||
public sealed class InstanceStatusChangedMessage : ValueChangedMessage<InstanceStatusChangedMessage.Payload>
|
||||
{
|
||||
public InstanceStatusChangedMessage(Payload value) : base(value) { }
|
||||
public record Payload(string CustomerId, string Status);
|
||||
}
|
||||
|
||||
public sealed class AlertRaisedMessage : ValueChangedMessage<AlertRaisedMessage.Payload>
|
||||
{
|
||||
public AlertRaisedMessage(Payload value) : base(value) { }
|
||||
public record Payload(string Severity, string Message);
|
||||
}
|
||||
@@ -1,780 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MySqlConnector;
|
||||
using OTSSignsOrchestrator.Core.Configuration;
|
||||
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
using OTSSignsOrchestrator.Core.Services;
|
||||
using ServiceLogEntry = OTSSignsOrchestrator.Core.Models.DTOs.ServiceLogEntry;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Docker CLI service that executes docker commands on a remote host over SSH.
|
||||
/// Requires an SshHost to be set before use via SetHost().
|
||||
/// </summary>
|
||||
public class SshDockerCliService : IDockerCliService
|
||||
{
|
||||
private readonly SshConnectionService _ssh;
|
||||
private readonly DockerOptions _options;
|
||||
private readonly ILogger<SshDockerCliService> _logger;
|
||||
private SshHost? _currentHost;
|
||||
|
||||
public SshDockerCliService(
|
||||
SshConnectionService ssh,
|
||||
IOptions<DockerOptions> options,
|
||||
ILogger<SshDockerCliService> logger)
|
||||
{
|
||||
_ssh = ssh;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the SSH host to use for Docker commands.
|
||||
/// </summary>
|
||||
public void SetHost(SshHost host)
|
||||
{
|
||||
_currentHost = host;
|
||||
}
|
||||
|
||||
public SshHost? CurrentHost => _currentHost;
|
||||
|
||||
private void EnsureHost()
|
||||
{
|
||||
if (_currentHost == null)
|
||||
throw new InvalidOperationException("No SSH host configured. Call SetHost() before using Docker commands.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escape password for safe use in shell scripts with proper quoting.
|
||||
/// Uses printf-safe escaping to avoid newline injection and special character issues.
|
||||
/// </summary>
|
||||
private string EscapePasswordForShell(string password)
|
||||
{
|
||||
if (string.IsNullOrEmpty(password))
|
||||
{
|
||||
_logger.LogWarning("Password is null or empty");
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Original password length: {Length} characters", password.Length);
|
||||
|
||||
// Use printf-safe format: escape single quotes and other problematic characters
|
||||
// Replace ' with '\'' (close quote, escaped quote, open quote)
|
||||
var escaped = password.Replace("'", "'\\''");
|
||||
|
||||
_logger.LogDebug("Escaped password length: {Length} characters (added {Extra} chars for escaping)",
|
||||
escaped.Length, escaped.Length - password.Length);
|
||||
_logger.LogDebug("Password first char: '{FirstChar}', last char: '{LastChar}'",
|
||||
password.Length > 0 ? password[0].ToString() : "N/A",
|
||||
password.Length > 0 ? password[^1].ToString() : "N/A");
|
||||
|
||||
return escaped;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test if the current host's password works with sudo by running a no-op sudo command.
|
||||
/// </summary>
|
||||
private async Task<(bool Success, string? Error)> TestSudoPasswordAsync()
|
||||
{
|
||||
EnsureHost();
|
||||
|
||||
if (string.IsNullOrEmpty(_currentHost!.Password))
|
||||
{
|
||||
return (false, "No password configured for SSH host");
|
||||
}
|
||||
|
||||
var escapedPassword = EscapePasswordForShell(_currentHost!.Password);
|
||||
var testCmd = $"printf '%s\\n' '{escapedPassword}' | sudo -S -v 2>&1";
|
||||
|
||||
_logger.LogInformation("Testing sudo password for host {Host} user {User}...",
|
||||
_currentHost!.Label, _currentHost!.Username);
|
||||
|
||||
var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(_currentHost!, testCmd, TimeSpan.FromSeconds(10));
|
||||
|
||||
if (exitCode == 0)
|
||||
{
|
||||
_logger.LogInformation("Sudo password test PASSED for {Host}", _currentHost!.Label);
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
var error = (stderr ?? stdout ?? "unknown error").Trim();
|
||||
_logger.LogWarning("Sudo password test FAILED for {Host}: {Error}", _currentHost!.Label, error);
|
||||
return (false, error);
|
||||
}
|
||||
|
||||
public async Task<DeploymentResultDto> DeployStackAsync(string stackName, string composeYaml, bool resolveImage = false)
|
||||
{
|
||||
EnsureHost();
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
var args = "docker stack deploy --compose-file -";
|
||||
if (resolveImage)
|
||||
args += " --resolve-image changed";
|
||||
args += $" {stackName}";
|
||||
|
||||
_logger.LogInformation("Deploying stack via SSH: {StackName} on {Host}", stackName, _currentHost!.Label);
|
||||
|
||||
var (exitCode, stdout, stderr) = await _ssh.RunCommandWithStdinAsync(_currentHost, args, composeYaml);
|
||||
sw.Stop();
|
||||
|
||||
var result = new DeploymentResultDto
|
||||
{
|
||||
StackName = stackName,
|
||||
Success = exitCode == 0,
|
||||
ExitCode = exitCode,
|
||||
Output = stdout,
|
||||
ErrorMessage = stderr,
|
||||
Message = exitCode == 0 ? "Success" : "Failed",
|
||||
DurationMs = sw.ElapsedMilliseconds
|
||||
};
|
||||
|
||||
if (result.Success)
|
||||
_logger.LogInformation("Stack deployed via SSH: {StackName} | duration={DurationMs}ms", stackName, result.DurationMs);
|
||||
else
|
||||
_logger.LogError("Stack deploy failed via SSH: {StackName} | error={Error}", stackName, result.ErrorMessage);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<DeploymentResultDto> RemoveStackAsync(string stackName)
|
||||
{
|
||||
EnsureHost();
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
_logger.LogInformation("Removing stack via SSH: {StackName} on {Host}", stackName, _currentHost!.Label);
|
||||
|
||||
var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(_currentHost, $"docker stack rm {stackName}");
|
||||
sw.Stop();
|
||||
|
||||
var result = new DeploymentResultDto
|
||||
{
|
||||
StackName = stackName,
|
||||
Success = exitCode == 0,
|
||||
ExitCode = exitCode,
|
||||
Output = stdout,
|
||||
ErrorMessage = stderr,
|
||||
Message = exitCode == 0 ? "Success" : "Failed",
|
||||
DurationMs = sw.ElapsedMilliseconds
|
||||
};
|
||||
|
||||
if (result.Success)
|
||||
_logger.LogInformation("Stack removed via SSH: {StackName}", stackName);
|
||||
else
|
||||
_logger.LogError("Stack remove failed via SSH: {StackName} | error={Error}", stackName, result.ErrorMessage);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<List<StackInfo>> ListStacksAsync()
|
||||
{
|
||||
EnsureHost();
|
||||
|
||||
var (exitCode, stdout, _) = await _ssh.RunCommandAsync(
|
||||
_currentHost!, "docker stack ls --format '{{.Name}}\\t{{.Services}}'");
|
||||
|
||||
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout))
|
||||
return new List<StackInfo>();
|
||||
|
||||
return stdout
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(line =>
|
||||
{
|
||||
var parts = line.Split('\t', 2);
|
||||
return new StackInfo
|
||||
{
|
||||
Name = parts[0].Trim(),
|
||||
ServiceCount = parts.Length > 1 && int.TryParse(parts[1].Trim(), out var c) ? c : 0
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<List<ServiceInfo>> InspectStackServicesAsync(string stackName)
|
||||
{
|
||||
EnsureHost();
|
||||
|
||||
var (exitCode, stdout, _) = await _ssh.RunCommandAsync(
|
||||
_currentHost!, $"docker stack services {stackName} --format '{{{{.Name}}}}\\t{{{{.Image}}}}\\t{{{{.Replicas}}}}'");
|
||||
|
||||
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout))
|
||||
return new List<ServiceInfo>();
|
||||
|
||||
return stdout
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(line =>
|
||||
{
|
||||
var parts = line.Split('\t', 3);
|
||||
return new ServiceInfo
|
||||
{
|
||||
Name = parts.Length > 0 ? parts[0].Trim() : "",
|
||||
Image = parts.Length > 1 ? parts[1].Trim() : "",
|
||||
Replicas = parts.Length > 2 ? parts[2].Trim() : ""
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<bool> EnsureDirectoryAsync(string path)
|
||||
{
|
||||
EnsureHost();
|
||||
var (exitCode, _, stderr) = await _ssh.RunCommandAsync(_currentHost!, $"mkdir -p {path}");
|
||||
if (exitCode != 0)
|
||||
_logger.LogWarning("Failed to create directory {Path} on {Host}: {Error}", path, _currentHost!.Label, stderr);
|
||||
else
|
||||
_logger.LogInformation("Ensured directory exists on {Host}: {Path}", _currentHost!.Label, path);
|
||||
return exitCode == 0;
|
||||
}
|
||||
|
||||
public async Task<bool> EnsureNfsFoldersAsync(
|
||||
string nfsServer,
|
||||
string nfsExport,
|
||||
IEnumerable<string> folderNames,
|
||||
string? nfsExportFolder = null)
|
||||
{
|
||||
EnsureHost();
|
||||
var exportPath = (nfsExport ?? string.Empty).Trim('/');
|
||||
var subFolder = (nfsExportFolder ?? string.Empty).Trim('/');
|
||||
|
||||
// Build the sub-path beneath the mount point where volume folders will be created
|
||||
var subPath = string.IsNullOrEmpty(subFolder) ? string.Empty : $"/{subFolder}";
|
||||
|
||||
// Build mkdir targets relative to the temporary mount point
|
||||
var folderList = folderNames.Select(f => $"\"$MNT{subPath}/{f}\"").ToList();
|
||||
var mkdirTargets = string.Join(" ", folderList);
|
||||
|
||||
// Single SSH command: create temp dir, mount NFS, mkdir -p all folders, unmount, cleanup
|
||||
// Use addr= to pin the server IP — avoids "Server address does not match proto= option"
|
||||
// errors when the hostname resolves to IPv6 but proto=tcp implies IPv4.
|
||||
// Properly escape password for shell use (handle special characters like single quotes)
|
||||
var escapedPassword = EscapePasswordForShell(_currentHost!.Password ?? string.Empty);
|
||||
|
||||
if (string.IsNullOrEmpty(escapedPassword))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"No password configured for SSH host {Host}. NFS operations may fail if sudo requires a password.",
|
||||
_currentHost!.Label);
|
||||
}
|
||||
|
||||
var script = $"""
|
||||
set -e
|
||||
MNT=$(mktemp -d)
|
||||
printf '%s\n' '{escapedPassword}' | sudo -S mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
|
||||
printf '%s\n' '{escapedPassword}' | sudo -S mkdir -p {mkdirTargets}
|
||||
printf '%s\n' '{escapedPassword}' | sudo -S umount "$MNT"
|
||||
rmdir "$MNT"
|
||||
""";
|
||||
|
||||
_logger.LogInformation(
|
||||
"Mounting NFS export {Server}:/{Export} on Docker host {Host} to create {Count} folders",
|
||||
nfsServer, exportPath, _currentHost!.Label, folderList.Count);
|
||||
|
||||
var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(_currentHost!, script, TimeSpan.FromSeconds(30));
|
||||
|
||||
if (exitCode == 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"NFS export folders ensured via mount on {Host}: {Server}:/{Export}{Sub} ({Count} folders)",
|
||||
_currentHost.Label, nfsServer, exportPath, subPath, folderList.Count);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Failed to create NFS export folders on {Host}: {Error}",
|
||||
_currentHost.Label, (stderr ?? stdout ?? "unknown error").Trim());
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<(bool Success, string? Error)> EnsureNfsFoldersWithErrorAsync(
|
||||
string nfsServer,
|
||||
string nfsExport,
|
||||
IEnumerable<string> folderNames,
|
||||
string? nfsExportFolder = null)
|
||||
{
|
||||
EnsureHost();
|
||||
var exportPath = (nfsExport ?? string.Empty).Trim('/');
|
||||
var subFolder = (nfsExportFolder ?? string.Empty).Trim('/');
|
||||
var subPath = string.IsNullOrEmpty(subFolder) ? string.Empty : $"/{subFolder}";
|
||||
var folderList = folderNames.Select(f => $"\"$MNT{subPath}/{f}\"").ToList();
|
||||
var mkdirTargets = string.Join(" ", folderList);
|
||||
|
||||
// Properly escape password for shell use (handle special characters like single quotes)
|
||||
var escapedPassword = EscapePasswordForShell(_currentHost!.Password ?? string.Empty);
|
||||
|
||||
if (string.IsNullOrEmpty(escapedPassword))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"No password configured for SSH host {Host}. NFS operations may fail if sudo requires a password.",
|
||||
_currentHost!.Label);
|
||||
}
|
||||
|
||||
var script = $"""
|
||||
set -e
|
||||
MNT=$(mktemp -d)
|
||||
printf '%s\n' '{escapedPassword}' | sudo -S mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
|
||||
printf '%s\n' '{escapedPassword}' | sudo -S mkdir -p {mkdirTargets}
|
||||
printf '%s\n' '{escapedPassword}' | sudo -S umount "$MNT"
|
||||
rmdir "$MNT"
|
||||
""";
|
||||
|
||||
_logger.LogInformation(
|
||||
"Mounting NFS export {Server}:/{Export} on Docker host {Host} to create {Count} folders",
|
||||
nfsServer, exportPath, _currentHost!.Label, folderList.Count);
|
||||
|
||||
var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(_currentHost!, script, TimeSpan.FromSeconds(30));
|
||||
|
||||
if (exitCode == 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"NFS export folders ensured via mount on {Host}: {Server}:/{Export}{Sub} ({Count} folders)",
|
||||
_currentHost.Label, nfsServer, exportPath, subPath, folderList.Count);
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
var error = (stderr ?? stdout ?? "unknown error").Trim();
|
||||
_logger.LogWarning(
|
||||
"Failed to create NFS export folders on {Host}: {Error}",
|
||||
_currentHost.Label, error);
|
||||
return (false, error);
|
||||
}
|
||||
|
||||
public async Task<(bool Success, string? Error)> WriteFileToNfsAsync(
|
||||
string nfsServer,
|
||||
string nfsExport,
|
||||
string relativePath,
|
||||
string content,
|
||||
string? nfsExportFolder = null)
|
||||
{
|
||||
EnsureHost();
|
||||
var exportPath = (nfsExport ?? string.Empty).Trim('/');
|
||||
var subFolder = (nfsExportFolder ?? string.Empty).Trim('/');
|
||||
var subPath = string.IsNullOrEmpty(subFolder) ? string.Empty : $"/{subFolder}";
|
||||
|
||||
// Ensure parent directory exists
|
||||
var targetPath = $"$MNT{subPath}/{relativePath.TrimStart('/')}";
|
||||
var parentDir = $"$(dirname \"{targetPath}\")";
|
||||
|
||||
// Properly escape password for shell use (handle special characters like single quotes)
|
||||
var escapedPassword = EscapePasswordForShell(_currentHost!.Password ?? string.Empty);
|
||||
|
||||
_logger.LogInformation("NFS WriteFile: Host={Host}, User={User}, HasPassword={HasPw}, PwLen={PwLen}",
|
||||
_currentHost!.Label, _currentHost!.Username,
|
||||
!string.IsNullOrEmpty(_currentHost!.Password), _currentHost!.Password?.Length ?? 0);
|
||||
|
||||
if (string.IsNullOrEmpty(escapedPassword))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"No password configured for SSH host {Host}. NFS operations may fail if sudo requires a password.",
|
||||
_currentHost!.Label);
|
||||
return (false, "No password configured for SSH host");
|
||||
}
|
||||
|
||||
// Base64-encode the file content to avoid heredoc/stdin conflicts with sudo -S.
|
||||
// The heredoc approach fails because the shell's heredoc redirects stdin for the
|
||||
// entire pipeline, so sudo -S reads the PHP content instead of the password.
|
||||
var base64Content = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(content));
|
||||
|
||||
// Strategy: base64-decode content to a temp file (no sudo needed), then use
|
||||
// printf | sudo -S for each privileged command — matching the proven pattern
|
||||
// in EnsureNfsFoldersAsync. We avoid sudo -v timestamp caching because SSH
|
||||
// exec channels have no TTY and timestamps may not persist between commands.
|
||||
var script = $"""
|
||||
set -e
|
||||
TMPFILE=$(mktemp)
|
||||
echo '{base64Content}' | base64 -d > "$TMPFILE"
|
||||
MNT=$(mktemp -d)
|
||||
printf '%s\n' '{escapedPassword}' | sudo -S mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
|
||||
printf '%s\n' '{escapedPassword}' | sudo -S mkdir -p {parentDir}
|
||||
printf '%s\n' '{escapedPassword}' | sudo -S cp "$TMPFILE" "{targetPath}"
|
||||
rm -f "$TMPFILE"
|
||||
printf '%s\n' '{escapedPassword}' | sudo -S umount "$MNT"
|
||||
rmdir "$MNT"
|
||||
""";
|
||||
|
||||
_logger.LogInformation(
|
||||
"Writing file to NFS {Server}:/{Export}{Sub}/{Path} on Docker host {Host}",
|
||||
nfsServer, exportPath, subPath, relativePath, _currentHost!.Label);
|
||||
|
||||
var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(_currentHost!, script, TimeSpan.FromSeconds(30));
|
||||
|
||||
if (exitCode == 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"File written to NFS on {Host}: {Server}:/{Export}{Sub}/{Path}",
|
||||
_currentHost.Label, nfsServer, exportPath, subPath, relativePath);
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
var error = (stderr ?? stdout ?? "unknown error").Trim();
|
||||
_logger.LogWarning(
|
||||
"Failed to write file to NFS on {Host}: {Error}",
|
||||
_currentHost.Label, error);
|
||||
return (false, error);
|
||||
}
|
||||
|
||||
public async Task<bool> ForceUpdateServiceAsync(string serviceName)
|
||||
{
|
||||
EnsureHost();
|
||||
_logger.LogInformation("Force-updating service {ServiceName} on {Host}", serviceName, _currentHost!.Label);
|
||||
var (exitCode, _, stderr) = await _ssh.RunCommandAsync(_currentHost!, $"docker service update --force {serviceName}");
|
||||
if (exitCode != 0)
|
||||
_logger.LogWarning("Force-update failed for {ServiceName}: {Error}", serviceName, stderr);
|
||||
return exitCode == 0;
|
||||
}
|
||||
|
||||
public async Task<(MySqlConnection Connection, IDisposable Tunnel)> OpenMySqlConnectionAsync(
|
||||
string mysqlHost, int port,
|
||||
string adminUser, string adminPassword)
|
||||
{
|
||||
EnsureHost();
|
||||
_logger.LogInformation(
|
||||
"Opening tunnelled MySQL connection to {MysqlHost}:{Port} via SSH",
|
||||
mysqlHost, port);
|
||||
|
||||
var tunnel = _ssh.OpenForwardedPort(_currentHost!, mysqlHost, (uint)port);
|
||||
var localPort = (int)tunnel.BoundPort;
|
||||
|
||||
var csb = new MySqlConnectionStringBuilder
|
||||
{
|
||||
Server = "127.0.0.1",
|
||||
Port = (uint)localPort,
|
||||
UserID = adminUser,
|
||||
Password = adminPassword,
|
||||
ConnectionTimeout = 15,
|
||||
SslMode = MySqlSslMode.Disabled,
|
||||
};
|
||||
|
||||
var connection = new MySqlConnection(csb.ConnectionString);
|
||||
try
|
||||
{
|
||||
await connection.OpenAsync();
|
||||
return (connection, tunnel);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await connection.DisposeAsync();
|
||||
tunnel.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(bool Success, string Error)> AlterMySqlUserPasswordAsync(
|
||||
string mysqlHost, int port,
|
||||
string adminUser, string adminPassword,
|
||||
string targetUser, string newPassword)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Altering MySQL password for user {User} on {MysqlHost}:{Port} via SSH tunnel",
|
||||
targetUser, mysqlHost, port);
|
||||
|
||||
try
|
||||
{
|
||||
var (connection, tunnel) = await OpenMySqlConnectionAsync(mysqlHost, port, adminUser, adminPassword);
|
||||
await using (connection)
|
||||
using (tunnel)
|
||||
{
|
||||
var escapedUser = targetUser.Replace("'", "''");
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = $"ALTER USER '{escapedUser}'@'%' IDENTIFIED BY @pwd";
|
||||
cmd.Parameters.AddWithValue("@pwd", newPassword);
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
_logger.LogInformation("MySQL password updated for user {User} via SSH tunnel", targetUser);
|
||||
return (true, string.Empty);
|
||||
}
|
||||
catch (MySqlException ex)
|
||||
{
|
||||
_logger.LogError(ex, "MySQL ALTER USER failed via SSH tunnel for user {User}", targetUser);
|
||||
return (false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> ServiceSwapSecretAsync(string serviceName, string oldSecretName, string newSecretName, string? targetAlias = null)
|
||||
{
|
||||
EnsureHost();
|
||||
var target = targetAlias ?? oldSecretName;
|
||||
var cmd = $"docker service update --secret-rm {oldSecretName} --secret-add \"source={newSecretName},target={target}\" {serviceName}";
|
||||
_logger.LogInformation(
|
||||
"Swapping secret on {ServiceName}: {OldSecret} → {NewSecret} (target={Target})",
|
||||
serviceName, oldSecretName, newSecretName, target);
|
||||
var (exitCode, _, stderr) = await _ssh.RunCommandAsync(_currentHost!, cmd);
|
||||
if (exitCode != 0)
|
||||
_logger.LogError("Secret swap failed for {ServiceName}: {Error}", serviceName, stderr);
|
||||
return exitCode == 0;
|
||||
}
|
||||
|
||||
public async Task<List<NodeInfo>> ListNodesAsync()
|
||||
{
|
||||
EnsureHost();
|
||||
|
||||
_logger.LogInformation("Listing swarm nodes via SSH on {Host}", _currentHost!.Label);
|
||||
|
||||
// Use docker node inspect on all nodes to get IP addresses (Status.Addr)
|
||||
// that are not available from 'docker node ls'.
|
||||
// First, get all node IDs.
|
||||
var (lsExit, lsOut, lsErr) = await _ssh.RunCommandAsync(
|
||||
_currentHost!, "docker node ls --format '{{.ID}}'");
|
||||
|
||||
if (lsExit != 0)
|
||||
{
|
||||
var msg = (lsErr ?? lsOut ?? "unknown error").Trim();
|
||||
_logger.LogWarning("docker node ls failed on {Host} (exit {Code}): {Error}",
|
||||
_currentHost.Label, lsExit, msg);
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to list swarm nodes on {_currentHost.Label}: {msg}");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(lsOut))
|
||||
return new List<NodeInfo>();
|
||||
|
||||
var nodeIds = lsOut.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(id => id.Trim())
|
||||
.Where(id => !string.IsNullOrEmpty(id))
|
||||
.ToList();
|
||||
|
||||
if (nodeIds.Count == 0)
|
||||
return new List<NodeInfo>();
|
||||
|
||||
// Inspect all nodes in a single call to get full details including IP address
|
||||
var ids = string.Join(" ", nodeIds);
|
||||
var format = "'{{.ID}}\t{{.Description.Hostname}}\t{{.Status.State}}\t{{.Spec.Availability}}\t{{.ManagerStatus.Addr}}\t{{.Status.Addr}}\t{{.Description.Engine.EngineVersion}}\t{{.Spec.Role}}'";
|
||||
var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(
|
||||
_currentHost!, $"docker node inspect --format {format} {ids}");
|
||||
|
||||
if (exitCode != 0)
|
||||
{
|
||||
var msg = (stderr ?? stdout ?? "unknown error").Trim();
|
||||
_logger.LogWarning("docker node inspect failed on {Host} (exit {Code}): {Error}",
|
||||
_currentHost.Label, exitCode, msg);
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to inspect swarm nodes on {_currentHost.Label}: {msg}");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(stdout))
|
||||
return new List<NodeInfo>();
|
||||
|
||||
return stdout
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(line =>
|
||||
{
|
||||
var parts = line.Split('\t', 8);
|
||||
// ManagerStatus.Addr includes port (e.g. "10.0.0.1:2377"); Status.Addr is just the IP.
|
||||
// Prefer Status.Addr; fall back to ManagerStatus.Addr (strip port) if Status.Addr is empty/template-error.
|
||||
var statusAddr = parts.Length > 5 ? parts[5].Trim() : "";
|
||||
var managerAddr = parts.Length > 4 ? parts[4].Trim() : "";
|
||||
var ip = statusAddr;
|
||||
if (string.IsNullOrEmpty(ip) || ip.StartsWith("<") || ip.StartsWith("{"))
|
||||
{
|
||||
// managerAddr may be "10.0.0.1:2377"
|
||||
ip = managerAddr.Contains(':') ? managerAddr[..managerAddr.LastIndexOf(':')] : managerAddr;
|
||||
}
|
||||
// Clean up template rendering artefacts like "<no value>"
|
||||
if (ip.StartsWith("<") || ip.StartsWith("{"))
|
||||
ip = "";
|
||||
|
||||
var role = parts.Length > 7 ? parts[7].Trim() : "";
|
||||
var managerStatus = "";
|
||||
if (string.Equals(role, "manager", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Determine if this is the leader by checking if ManagerStatus.Addr is non-empty
|
||||
managerStatus = !string.IsNullOrEmpty(managerAddr) && !managerAddr.StartsWith("<") ? "Reachable" : "";
|
||||
}
|
||||
|
||||
return new NodeInfo
|
||||
{
|
||||
Id = parts.Length > 0 ? parts[0].Trim() : "",
|
||||
Hostname = parts.Length > 1 ? parts[1].Trim() : "",
|
||||
Status = parts.Length > 2 ? parts[2].Trim() : "",
|
||||
Availability = parts.Length > 3 ? parts[3].Trim() : "",
|
||||
ManagerStatus = managerStatus,
|
||||
IpAddress = ip,
|
||||
EngineVersion = parts.Length > 6 ? parts[6].Trim() : ""
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<List<ServiceLogEntry>> GetServiceLogsAsync(string stackName, string? serviceName = null, int tailLines = 200)
|
||||
{
|
||||
EnsureHost();
|
||||
|
||||
// Determine which services to fetch logs for
|
||||
List<string> serviceNames;
|
||||
if (!string.IsNullOrEmpty(serviceName))
|
||||
{
|
||||
serviceNames = new List<string> { serviceName };
|
||||
}
|
||||
else
|
||||
{
|
||||
var services = await InspectStackServicesAsync(stackName);
|
||||
serviceNames = services.Select(s => s.Name).ToList();
|
||||
}
|
||||
|
||||
var allEntries = new List<ServiceLogEntry>();
|
||||
foreach (var svcName in serviceNames)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cmd = $"docker service logs --timestamps --no-trunc --tail {tailLines} {svcName} 2>&1";
|
||||
var (exitCode, stdout, _) = await _ssh.RunCommandAsync(_currentHost!, cmd, TimeSpan.FromSeconds(15));
|
||||
|
||||
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout))
|
||||
{
|
||||
_logger.LogDebug("No logs returned for service {Service} (exit={ExitCode})", svcName, exitCode);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse each line. Docker service logs format with --timestamps:
|
||||
// <timestamp> <service>.<replica>.<taskid>@<node> | <message>
|
||||
// or sometimes just:
|
||||
// <timestamp> <service>.<replica>.<taskid> <message>
|
||||
foreach (var line in stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var entry = ParseLogLine(line, svcName, stackName);
|
||||
if (entry != null)
|
||||
allEntries.Add(entry);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch logs for service {Service}", svcName);
|
||||
}
|
||||
}
|
||||
|
||||
return allEntries.OrderBy(e => e.Timestamp).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a single line from <c>docker service logs --timestamps</c> output.
|
||||
/// </summary>
|
||||
private static ServiceLogEntry? ParseLogLine(string line, string serviceName, string stackName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
return null;
|
||||
|
||||
// Format: "2026-02-25T14:30:45.123456789Z service.replica.taskid@node | message"
|
||||
// The timestamp is always the first space-delimited token when --timestamps is used.
|
||||
var firstSpace = line.IndexOf(' ');
|
||||
if (firstSpace <= 0)
|
||||
return new ServiceLogEntry
|
||||
{
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Source = serviceName,
|
||||
ServiceName = StripStackPrefix(serviceName, stackName),
|
||||
Message = line
|
||||
};
|
||||
|
||||
var timestampStr = line[..firstSpace];
|
||||
var rest = line[(firstSpace + 1)..].TrimStart();
|
||||
|
||||
// Try to parse the timestamp
|
||||
if (!DateTimeOffset.TryParse(timestampStr, out var timestamp))
|
||||
{
|
||||
// If timestamp parsing fails, treat the whole line as the message
|
||||
return new ServiceLogEntry
|
||||
{
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Source = serviceName,
|
||||
ServiceName = StripStackPrefix(serviceName, stackName),
|
||||
Message = line
|
||||
};
|
||||
}
|
||||
|
||||
// Split source and message on the pipe separator
|
||||
var source = serviceName;
|
||||
var message = rest;
|
||||
var pipeIndex = rest.IndexOf('|');
|
||||
if (pipeIndex >= 0)
|
||||
{
|
||||
source = rest[..pipeIndex].Trim();
|
||||
message = rest[(pipeIndex + 1)..].TrimStart();
|
||||
}
|
||||
|
||||
return new ServiceLogEntry
|
||||
{
|
||||
Timestamp = timestamp,
|
||||
Source = source,
|
||||
ServiceName = StripStackPrefix(serviceName, stackName),
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips the stack name prefix from a fully-qualified service name.
|
||||
/// e.g. "acm-cms-stack_acm-web" → "acm-web"
|
||||
/// </summary>
|
||||
private static string StripStackPrefix(string serviceName, string stackName)
|
||||
{
|
||||
var prefix = stackName + "_";
|
||||
return serviceName.StartsWith(prefix) ? serviceName[prefix.Length..] : serviceName;
|
||||
}
|
||||
|
||||
public async Task<bool> RemoveStackVolumesAsync(string stackName)
|
||||
{
|
||||
EnsureHost();
|
||||
|
||||
// ── 1. Remove the stack first so containers release the volumes ─────
|
||||
_logger.LogInformation("Removing stack {StackName} before volume cleanup", stackName);
|
||||
var (rmExit, _, rmErr) = await _ssh.RunCommandAsync(_currentHost!,
|
||||
$"docker stack rm {stackName} 2>&1 || true");
|
||||
if (rmExit != 0)
|
||||
_logger.LogWarning("Stack rm returned non-zero for {StackName}: {Err}", stackName, rmErr);
|
||||
|
||||
// Give Swarm a moment to tear down containers on all nodes
|
||||
await Task.Delay(5000);
|
||||
|
||||
// ── 2. Clean volumes on the local (manager) node ────────────────────
|
||||
var localCmd = $"docker volume ls --filter \"name={stackName}_\" -q | xargs -r docker volume rm 2>&1 || true";
|
||||
var (_, localOut, _) = await _ssh.RunCommandAsync(_currentHost!, localCmd);
|
||||
if (!string.IsNullOrEmpty(localOut?.Trim()))
|
||||
_logger.LogInformation("Volume cleanup (manager): {Output}", localOut!.Trim());
|
||||
|
||||
// ── 3. Clean volumes on ALL swarm nodes via a temporary global service ──
|
||||
// This deploys a short-lived container on every node that mounts the Docker
|
||||
// socket and removes matching volumes. This handles worker nodes that the
|
||||
// orchestrator has no direct SSH access to.
|
||||
var cleanupSvcName = $"vol-cleanup-{stackName}".Replace("_", "-");
|
||||
|
||||
// Remove leftover cleanup service from a previous run (if any)
|
||||
await _ssh.RunCommandAsync(_currentHost!,
|
||||
$"docker service rm {cleanupSvcName} 2>/dev/null || true");
|
||||
|
||||
var createCmd = string.Join(" ",
|
||||
"docker service create",
|
||||
"--detach",
|
||||
"--mode global",
|
||||
"--restart-condition none",
|
||||
$"--name {cleanupSvcName}",
|
||||
"--mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock",
|
||||
"docker:cli",
|
||||
"sh", "-c",
|
||||
$"'docker volume ls -q --filter name={stackName}_ | xargs -r docker volume rm 2>&1; echo done'");
|
||||
|
||||
_logger.LogInformation("Deploying global volume-cleanup service on all swarm nodes for {StackName}", stackName);
|
||||
var (svcExit, svcOut, svcErr) = await _ssh.RunCommandAsync(_currentHost!, createCmd);
|
||||
|
||||
if (svcExit != 0)
|
||||
{
|
||||
_logger.LogWarning("Global volume cleanup service creation failed: {Err}", svcErr);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Wait for the cleanup tasks to finish on all nodes
|
||||
_logger.LogInformation("Waiting for volume cleanup tasks to complete on all nodes...");
|
||||
await Task.Delay(10000);
|
||||
}
|
||||
|
||||
// Remove the cleanup service
|
||||
await _ssh.RunCommandAsync(_currentHost!,
|
||||
$"docker service rm {cleanupSvcName} 2>/dev/null || true");
|
||||
|
||||
_logger.LogInformation("Volume cleanup complete for stack {StackName}", stackName);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Stores and retrieves operator JWT and refresh tokens using the OS credential store.
|
||||
/// Windows: advapi32 Credential Manager; macOS: Security.framework Keychain;
|
||||
/// Linux: AES-encrypted file fallback in AppData.
|
||||
/// </summary>
|
||||
public sealed class TokenStoreService
|
||||
{
|
||||
private const string ServiceName = "OTSSignsOrchestrator";
|
||||
private const string JwtAccount = "operator-jwt";
|
||||
private const string RefreshAccount = "operator-refresh";
|
||||
|
||||
private readonly ILogger<TokenStoreService> _logger;
|
||||
|
||||
public TokenStoreService(ILogger<TokenStoreService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void StoreTokens(string jwt, string refreshToken)
|
||||
{
|
||||
WriteCredential(JwtAccount, jwt);
|
||||
WriteCredential(RefreshAccount, refreshToken);
|
||||
_logger.LogDebug("Tokens stored in OS credential store");
|
||||
}
|
||||
|
||||
public string? GetJwt() => ReadCredential(JwtAccount);
|
||||
|
||||
public string? GetRefreshToken() => ReadCredential(RefreshAccount);
|
||||
|
||||
public void ClearTokens()
|
||||
{
|
||||
DeleteCredential(JwtAccount);
|
||||
DeleteCredential(RefreshAccount);
|
||||
_logger.LogDebug("Tokens cleared from OS credential store");
|
||||
}
|
||||
|
||||
// ── Platform dispatch ────────────────────────────────────────────────────
|
||||
|
||||
private void WriteCredential(string account, string secret)
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
WindowsCredentialManager.Write(ServiceName, account, secret);
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
MacKeychain.Write(ServiceName, account, secret);
|
||||
else
|
||||
LinuxEncryptedFile.Write(ServiceName, account, secret);
|
||||
}
|
||||
|
||||
private string? ReadCredential(string account)
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return WindowsCredentialManager.Read(ServiceName, account);
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
return MacKeychain.Read(ServiceName, account);
|
||||
return LinuxEncryptedFile.Read(ServiceName, account);
|
||||
}
|
||||
|
||||
private void DeleteCredential(string account)
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
WindowsCredentialManager.Delete(ServiceName, account);
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
MacKeychain.Delete(ServiceName, account);
|
||||
else
|
||||
LinuxEncryptedFile.Delete(ServiceName, account);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Windows — advapi32.dll Credential Manager
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
private static class WindowsCredentialManager
|
||||
{
|
||||
private const int CredTypeGeneric = 1;
|
||||
private const int CredPersistLocalMachine = 2;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct CREDENTIAL
|
||||
{
|
||||
public uint Flags;
|
||||
public uint Type;
|
||||
public string TargetName;
|
||||
public string Comment;
|
||||
public long LastWritten;
|
||||
public uint CredentialBlobSize;
|
||||
public IntPtr CredentialBlob;
|
||||
public uint Persist;
|
||||
public uint AttributeCount;
|
||||
public IntPtr Attributes;
|
||||
public string TargetAlias;
|
||||
public string UserName;
|
||||
}
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern bool CredWriteW(ref CREDENTIAL credential, uint flags);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern bool CredReadW(string target, uint type, uint flags, out IntPtr credential);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern bool CredDeleteW(string target, uint type, uint flags);
|
||||
|
||||
[DllImport("advapi32.dll")]
|
||||
private static extern void CredFree(IntPtr buffer);
|
||||
|
||||
private static string TargetName(string service, string account) => $"{service}/{account}";
|
||||
|
||||
public static void Write(string service, string account, string secret)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(secret);
|
||||
var handle = GCHandle.Alloc(bytes, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
var cred = new CREDENTIAL
|
||||
{
|
||||
Type = CredTypeGeneric,
|
||||
TargetName = TargetName(service, account),
|
||||
UserName = account,
|
||||
CredentialBlob = handle.AddrOfPinnedObject(),
|
||||
CredentialBlobSize = (uint)bytes.Length,
|
||||
Persist = CredPersistLocalMachine,
|
||||
};
|
||||
CredWriteW(ref cred, 0);
|
||||
}
|
||||
finally { handle.Free(); }
|
||||
}
|
||||
|
||||
public static string? Read(string service, string account)
|
||||
{
|
||||
if (!CredReadW(TargetName(service, account), CredTypeGeneric, 0, out var credPtr))
|
||||
return null;
|
||||
try
|
||||
{
|
||||
var cred = Marshal.PtrToStructure<CREDENTIAL>(credPtr);
|
||||
if (cred.CredentialBlobSize == 0 || cred.CredentialBlob == IntPtr.Zero) return null;
|
||||
var bytes = new byte[cred.CredentialBlobSize];
|
||||
Marshal.Copy(cred.CredentialBlob, bytes, 0, bytes.Length);
|
||||
return Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
finally { CredFree(credPtr); }
|
||||
}
|
||||
|
||||
public static void Delete(string service, string account)
|
||||
=> CredDeleteW(TargetName(service, account), CredTypeGeneric, 0);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// macOS — Security.framework Keychain via /usr/bin/security CLI
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
private static class MacKeychain
|
||||
{
|
||||
public static void Write(string service, string account, string secret)
|
||||
{
|
||||
// Delete first to avoid "duplicate" errors on update
|
||||
Delete(service, account);
|
||||
RunSecurity($"add-generic-password -s \"{service}\" -a \"{account}\" -w \"{EscapeShell(secret)}\" -U");
|
||||
}
|
||||
|
||||
public static string? Read(string service, string account)
|
||||
{
|
||||
var (exitCode, stdout) = RunSecurity($"find-generic-password -s \"{service}\" -a \"{account}\" -w");
|
||||
return exitCode == 0 ? stdout.Trim() : null;
|
||||
}
|
||||
|
||||
public static void Delete(string service, string account)
|
||||
=> RunSecurity($"delete-generic-password -s \"{service}\" -a \"{account}\"");
|
||||
|
||||
private static (int exitCode, string stdout) RunSecurity(string args)
|
||||
{
|
||||
using var proc = new System.Diagnostics.Process();
|
||||
proc.StartInfo = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "/usr/bin/security",
|
||||
Arguments = args,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
proc.Start();
|
||||
var stdout = proc.StandardOutput.ReadToEnd();
|
||||
proc.WaitForExit(5000);
|
||||
return (proc.ExitCode, stdout);
|
||||
}
|
||||
|
||||
private static string EscapeShell(string s) => s.Replace("\\", "\\\\").Replace("\"", "\\\"");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Linux — AES-256-GCM encrypted file in ~/.local/share
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
private static class LinuxEncryptedFile
|
||||
{
|
||||
// Machine-specific key derived from machine-id + user name
|
||||
private static byte[] DeriveKey()
|
||||
{
|
||||
var machineId = "linux-default";
|
||||
try
|
||||
{
|
||||
if (File.Exists("/etc/machine-id"))
|
||||
machineId = File.ReadAllText("/etc/machine-id").Trim();
|
||||
}
|
||||
catch { /* fallback */ }
|
||||
|
||||
var material = $"{machineId}:{Environment.UserName}:{ServiceName}";
|
||||
return SHA256.HashData(Encoding.UTF8.GetBytes(material));
|
||||
}
|
||||
|
||||
private static string FilePath(string service, string account)
|
||||
{
|
||||
var dir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
service, "credentials");
|
||||
Directory.CreateDirectory(dir);
|
||||
return Path.Combine(dir, $"{account}.enc");
|
||||
}
|
||||
|
||||
public static void Write(string service, string account, string secret)
|
||||
{
|
||||
var key = DeriveKey();
|
||||
var plaintext = Encoding.UTF8.GetBytes(secret);
|
||||
var nonce = new byte[12];
|
||||
RandomNumberGenerator.Fill(nonce);
|
||||
var ciphertext = new byte[plaintext.Length];
|
||||
var tag = new byte[16];
|
||||
|
||||
using var aes = new AesGcm(key, 16);
|
||||
aes.Encrypt(nonce, plaintext, ciphertext, tag);
|
||||
|
||||
// File format: [12 nonce][16 tag][ciphertext]
|
||||
var output = new byte[12 + 16 + ciphertext.Length];
|
||||
nonce.CopyTo(output, 0);
|
||||
tag.CopyTo(output, 12);
|
||||
ciphertext.CopyTo(output, 28);
|
||||
File.WriteAllBytes(FilePath(service, account), output);
|
||||
}
|
||||
|
||||
public static string? Read(string service, string account)
|
||||
{
|
||||
var path = FilePath(service, account);
|
||||
if (!File.Exists(path)) return null;
|
||||
|
||||
var data = File.ReadAllBytes(path);
|
||||
if (data.Length < 28) return null;
|
||||
|
||||
var nonce = data[..12];
|
||||
var tag = data[12..28];
|
||||
var ciphertext = data[28..];
|
||||
var plaintext = new byte[ciphertext.Length];
|
||||
|
||||
using var aes = new AesGcm(DeriveKey(), 16);
|
||||
aes.Decrypt(nonce, ciphertext, tag, plaintext);
|
||||
return Encoding.UTF8.GetString(plaintext);
|
||||
}
|
||||
|
||||
public static void Delete(string service, string account)
|
||||
{
|
||||
var path = FilePath(service, account);
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,362 +0,0 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
using OTSSignsOrchestrator.Core.Services;
|
||||
using OTSSignsOrchestrator.Desktop.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel for the Create Instance form.
|
||||
/// Simplified: Customer Name, Abbreviation, SSH Host, and optional Newt credentials.
|
||||
/// All other config comes from the Settings page.
|
||||
/// </summary>
|
||||
public partial class CreateInstanceViewModel : ObservableObject
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly MainWindowViewModel _mainVm;
|
||||
|
||||
[ObservableProperty] private string _statusMessage = string.Empty;
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
[ObservableProperty] private string _deployOutput = string.Empty;
|
||||
[ObservableProperty] private double _progressPercent;
|
||||
[ObservableProperty] private string _progressStep = string.Empty;
|
||||
|
||||
// Core form fields — only these two are required from the user
|
||||
[ObservableProperty] private string _customerName = string.Empty;
|
||||
[ObservableProperty] private string _customerAbbrev = string.Empty;
|
||||
|
||||
// Optional Pangolin/Newt credentials (per-instance)
|
||||
[ObservableProperty] private string _newtId = string.Empty;
|
||||
[ObservableProperty] private string _newtSecret = string.Empty;
|
||||
|
||||
// NFS volume settings (per-instance, defaults loaded from global settings)
|
||||
[ObservableProperty] private string _nfsServer = string.Empty;
|
||||
[ObservableProperty] private string _nfsExport = string.Empty;
|
||||
[ObservableProperty] private string _nfsExportFolder = string.Empty;
|
||||
[ObservableProperty] private string _nfsExtraOptions = string.Empty;
|
||||
|
||||
/// <summary>When enabled, existing Docker volumes for the stack are removed before deploying.</summary>
|
||||
[ObservableProperty] private bool _purgeStaleVolumes = false;
|
||||
|
||||
// SSH host selection
|
||||
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
|
||||
[ObservableProperty] private SshHost? _selectedSshHost;
|
||||
|
||||
// YML preview
|
||||
[ObservableProperty] private string _previewYml = string.Empty;
|
||||
[ObservableProperty] private bool _isLoadingYml;
|
||||
|
||||
public bool HasPreviewYml => !string.IsNullOrEmpty(PreviewYml);
|
||||
partial void OnPreviewYmlChanged(string value) => OnPropertyChanged(nameof(HasPreviewYml));
|
||||
|
||||
// ── Derived preview properties ───────────────────────────────────────────
|
||||
|
||||
public string PreviewStackName => Valid ? $"{Abbrev}-cms-stack" : "—";
|
||||
public string PreviewServiceWeb => Valid ? $"{Abbrev}-web" : "—";
|
||||
public string PreviewServiceCache => Valid ? $"{Abbrev}-memcached" : "—";
|
||||
public string PreviewServiceChart => Valid ? $"{Abbrev}-quickchart" : "—";
|
||||
public string PreviewServiceNewt => Valid ? $"{Abbrev}-newt" : "—";
|
||||
public string PreviewNetwork => Valid ? $"{Abbrev}-net" : "—";
|
||||
public string PreviewVolCustom => Valid ? $"{Abbrev}/cms-custom" : "—";
|
||||
public string PreviewVolBackup => Valid ? $"{Abbrev}/cms-backup" : "—";
|
||||
public string PreviewVolLibrary => Valid ? $"{Abbrev}/cms-library" : "—";
|
||||
public string PreviewVolUserscripts => Valid ? $"{Abbrev}/cms-userscripts": "—";
|
||||
public string PreviewVolCaCerts => Valid ? $"{Abbrev}/cms-ca-certs" : "—";
|
||||
public string PreviewSecret => Valid ? $"{Abbrev}-cms-db-password": "—";
|
||||
public string PreviewSecretUser => Valid ? $"{Abbrev}-cms-db-user" : "—";
|
||||
public string PreviewSecretHost => "global_mysql_host";
|
||||
public string PreviewSecretPort => "global_mysql_port";
|
||||
public string PreviewMySqlDb => Valid ? $"{Abbrev}_cms_db" : "—";
|
||||
public string PreviewMySqlUser => Valid ? $"{Abbrev}_cms_user" : "—";
|
||||
public string PreviewCmsUrl => Valid ? $"https://{Abbrev}.ots-signs.com" : "—";
|
||||
|
||||
private string Abbrev => CustomerAbbrev.Trim().ToLowerInvariant();
|
||||
private bool Valid => Abbrev.Length == 3 && System.Text.RegularExpressions.Regex.IsMatch(Abbrev, "^[a-z]{3}$");
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public CreateInstanceViewModel(IServiceProvider services, MainWindowViewModel mainVm)
|
||||
{
|
||||
_services = services;
|
||||
_mainVm = mainVm;
|
||||
_ = LoadHostsAsync();
|
||||
_ = LoadNfsDefaultsAsync();
|
||||
}
|
||||
|
||||
partial void OnCustomerAbbrevChanged(string value) => RefreshPreview();
|
||||
|
||||
private void RefreshPreview()
|
||||
{
|
||||
OnPropertyChanged(nameof(PreviewStackName));
|
||||
OnPropertyChanged(nameof(PreviewServiceWeb));
|
||||
OnPropertyChanged(nameof(PreviewServiceCache));
|
||||
OnPropertyChanged(nameof(PreviewServiceChart));
|
||||
OnPropertyChanged(nameof(PreviewServiceNewt));
|
||||
OnPropertyChanged(nameof(PreviewNetwork));
|
||||
OnPropertyChanged(nameof(PreviewVolCustom));
|
||||
OnPropertyChanged(nameof(PreviewVolBackup));
|
||||
OnPropertyChanged(nameof(PreviewVolLibrary));
|
||||
OnPropertyChanged(nameof(PreviewVolUserscripts));
|
||||
OnPropertyChanged(nameof(PreviewVolCaCerts));
|
||||
OnPropertyChanged(nameof(PreviewSecret));
|
||||
OnPropertyChanged(nameof(PreviewSecretUser));
|
||||
OnPropertyChanged(nameof(PreviewSecretHost));
|
||||
OnPropertyChanged(nameof(PreviewSecretPort));
|
||||
OnPropertyChanged(nameof(PreviewMySqlDb));
|
||||
OnPropertyChanged(nameof(PreviewMySqlUser));
|
||||
OnPropertyChanged(nameof(PreviewCmsUrl));
|
||||
}
|
||||
|
||||
private async Task LoadHostsAsync()
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||
var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync();
|
||||
AvailableHosts = new ObservableCollection<SshHost>(hosts);
|
||||
}
|
||||
|
||||
private async Task LoadNfsDefaultsAsync()
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
NfsServer = await settings.GetAsync(SettingsService.NfsServer) ?? string.Empty;
|
||||
NfsExport = await settings.GetAsync(SettingsService.NfsExport) ?? string.Empty;
|
||||
NfsExportFolder = await settings.GetAsync(SettingsService.NfsExportFolder) ?? string.Empty;
|
||||
NfsExtraOptions = await settings.GetAsync(SettingsService.NfsOptions) ?? string.Empty;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LoadYmlPreviewAsync()
|
||||
{
|
||||
if (!Valid)
|
||||
{
|
||||
PreviewYml = "# Abbreviation must be exactly 3 lowercase letters (a-z) before loading the YML preview.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsLoadingYml = true;
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
var git = scope.ServiceProvider.GetRequiredService<GitTemplateService>();
|
||||
var composer = scope.ServiceProvider.GetRequiredService<ComposeRenderService>();
|
||||
|
||||
var repoUrl = await settings.GetAsync(SettingsService.GitRepoUrl);
|
||||
var repoPat = await settings.GetAsync(SettingsService.GitRepoPat);
|
||||
if (string.IsNullOrWhiteSpace(repoUrl))
|
||||
{
|
||||
PreviewYml = "# Git template repository URL is not configured. Set it in Settings → Git Repo URL.";
|
||||
return;
|
||||
}
|
||||
|
||||
var templateConfig = await git.FetchAsync(repoUrl, repoPat);
|
||||
|
||||
var abbrev = Abbrev;
|
||||
var stackName = $"{abbrev}-cms-stack";
|
||||
|
||||
var mySqlHost = await settings.GetAsync(SettingsService.MySqlHost, "localhost");
|
||||
var mySqlPort = await settings.GetAsync(SettingsService.MySqlPort, "3306");
|
||||
var mySqlDbName = (await settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev);
|
||||
var mySqlUser = (await settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user")).Replace("{abbrev}", abbrev);
|
||||
|
||||
var cmsServerName = (await settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com")).Replace("{abbrev}", abbrev);
|
||||
var themePath = (await settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev);
|
||||
|
||||
var smtpServer = await settings.GetAsync(SettingsService.SmtpServer, string.Empty);
|
||||
var smtpUsername = await settings.GetAsync(SettingsService.SmtpUsername, string.Empty);
|
||||
var smtpPassword = await settings.GetAsync(SettingsService.SmtpPassword, string.Empty);
|
||||
var smtpUseTls = await settings.GetAsync(SettingsService.SmtpUseTls, "YES");
|
||||
var smtpUseStartTls = await settings.GetAsync(SettingsService.SmtpUseStartTls, "YES");
|
||||
var smtpRewriteDomain = await settings.GetAsync(SettingsService.SmtpRewriteDomain, string.Empty);
|
||||
var smtpHostname = await settings.GetAsync(SettingsService.SmtpHostname, string.Empty);
|
||||
var smtpFromLineOverride = await settings.GetAsync(SettingsService.SmtpFromLineOverride, "NO");
|
||||
|
||||
var pangolinEndpoint = await settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
|
||||
|
||||
var cmsImage = await settings.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3");
|
||||
var newtImage = await settings.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt");
|
||||
var memcachedImage = await settings.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine");
|
||||
var quickChartImage = await settings.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart");
|
||||
|
||||
var phpPostMaxSize = await settings.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G");
|
||||
var phpUploadMaxFilesize = await settings.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
|
||||
var phpMaxExecutionTime = await settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
|
||||
|
||||
// Use form values; fall back to saved global settings
|
||||
var nfsServer = string.IsNullOrWhiteSpace(NfsServer) ? await settings.GetAsync(SettingsService.NfsServer) : NfsServer;
|
||||
var nfsExport = string.IsNullOrWhiteSpace(NfsExport) ? await settings.GetAsync(SettingsService.NfsExport) : NfsExport;
|
||||
var nfsExportFolder = string.IsNullOrWhiteSpace(NfsExportFolder) ? await settings.GetAsync(SettingsService.NfsExportFolder) : NfsExportFolder;
|
||||
var nfsOptions = string.IsNullOrWhiteSpace(NfsExtraOptions) ? await settings.GetAsync(SettingsService.NfsOptions, string.Empty) : NfsExtraOptions;
|
||||
|
||||
var ctx = new RenderContext
|
||||
{
|
||||
CustomerName = CustomerName.Trim(),
|
||||
CustomerAbbrev = abbrev,
|
||||
StackName = stackName,
|
||||
CmsServerName = cmsServerName,
|
||||
HostHttpPort = 80,
|
||||
CmsImage = cmsImage,
|
||||
MemcachedImage = memcachedImage,
|
||||
QuickChartImage = quickChartImage,
|
||||
NewtImage = newtImage,
|
||||
ThemeHostPath = themePath,
|
||||
MySqlHost = mySqlHost,
|
||||
MySqlPort = mySqlPort,
|
||||
MySqlDatabase = mySqlDbName,
|
||||
MySqlUser = mySqlUser,
|
||||
SmtpServer = smtpServer,
|
||||
SmtpUsername = smtpUsername,
|
||||
SmtpPassword = smtpPassword,
|
||||
SmtpUseTls = smtpUseTls,
|
||||
SmtpUseStartTls = smtpUseStartTls,
|
||||
SmtpRewriteDomain = smtpRewriteDomain,
|
||||
SmtpHostname = smtpHostname,
|
||||
SmtpFromLineOverride = smtpFromLineOverride,
|
||||
PhpPostMaxSize = phpPostMaxSize,
|
||||
PhpUploadMaxFilesize = phpUploadMaxFilesize,
|
||||
PhpMaxExecutionTime = phpMaxExecutionTime,
|
||||
PangolinEndpoint = pangolinEndpoint,
|
||||
NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(),
|
||||
NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.Trim(),
|
||||
NfsServer = nfsServer,
|
||||
NfsExport = nfsExport,
|
||||
NfsExportFolder = nfsExportFolder,
|
||||
NfsExtraOptions = nfsOptions,
|
||||
};
|
||||
|
||||
PreviewYml = composer.Render(templateConfig.Yaml, ctx);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
PreviewYml = $"# Error rendering YML preview:\n# {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoadingYml = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CopyYmlAsync()
|
||||
{
|
||||
if (string.IsNullOrEmpty(PreviewYml)) return;
|
||||
var mainWindow = (Application.Current?.ApplicationLifetime
|
||||
as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
|
||||
if (mainWindow is null) return;
|
||||
var clipboard = TopLevel.GetTopLevel(mainWindow)?.Clipboard;
|
||||
if (clipboard is not null)
|
||||
await clipboard.SetTextAsync(PreviewYml);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DeployAsync()
|
||||
{
|
||||
// ── Validation ───────────────────────────────────────────────────
|
||||
if (SelectedSshHost == null)
|
||||
{
|
||||
StatusMessage = "Select an SSH host first.";
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(CustomerName))
|
||||
{
|
||||
StatusMessage = "Customer Name is required.";
|
||||
return;
|
||||
}
|
||||
if (!Valid)
|
||||
{
|
||||
StatusMessage = "Abbreviation must be exactly 3 lowercase letters (a-z).";
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = "Starting deployment...";
|
||||
DeployOutput = string.Empty;
|
||||
ProgressPercent = 0;
|
||||
|
||||
try
|
||||
{
|
||||
// Wire SSH host into docker services (singletons must know the target host before
|
||||
// InstanceService uses them internally for secrets and CLI operations)
|
||||
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||
dockerCli.SetHost(SelectedSshHost);
|
||||
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
|
||||
dockerSecrets.SetHost(SelectedSshHost);
|
||||
|
||||
using var scope = _services.CreateScope();
|
||||
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
|
||||
|
||||
// InstanceService.CreateInstanceAsync handles the full provisioning flow:
|
||||
// 1. Clone template repo
|
||||
// 2. Generate MySQL password → create Docker Swarm secret
|
||||
// 3. Create MySQL database + SQL user (same password as the secret)
|
||||
// 4. Render compose YAML → deploy stack
|
||||
SetProgress(30, "Provisioning instance (MySQL user, secrets, stack)...");
|
||||
|
||||
var dto = new CreateInstanceDto
|
||||
{
|
||||
CustomerName = CustomerName.Trim(),
|
||||
CustomerAbbrev = Abbrev,
|
||||
SshHostId = SelectedSshHost.Id,
|
||||
NewtId = string.IsNullOrWhiteSpace(NewtId) ? null : NewtId.Trim(),
|
||||
NewtSecret = string.IsNullOrWhiteSpace(NewtSecret) ? null : NewtSecret.Trim(),
|
||||
NfsServer = string.IsNullOrWhiteSpace(NfsServer) ? null : NfsServer.Trim(),
|
||||
NfsExport = string.IsNullOrWhiteSpace(NfsExport) ? null : NfsExport.Trim(),
|
||||
NfsExportFolder = string.IsNullOrWhiteSpace(NfsExportFolder) ? null : NfsExportFolder.Trim(),
|
||||
NfsExtraOptions = string.IsNullOrWhiteSpace(NfsExtraOptions) ? null : NfsExtraOptions.Trim(),
|
||||
PurgeStaleVolumes = PurgeStaleVolumes,
|
||||
};
|
||||
|
||||
var result = await instanceSvc.CreateInstanceAsync(dto);
|
||||
|
||||
AppendOutput(result.Output ?? string.Empty);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
SetProgress(100, "Stack deployed successfully.");
|
||||
StatusMessage = $"Instance '{Abbrev}-cms-stack' deployed in {result.DurationMs}ms. " +
|
||||
"Open the details pane on the Instances page to complete setup.";
|
||||
_mainVm.NavigateToInstancesWithSelection(Abbrev);
|
||||
}
|
||||
else
|
||||
{
|
||||
SetProgress(0, "Deployment failed.");
|
||||
StatusMessage = $"Deploy failed: {result.ErrorMessage}";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error: {ex.Message}";
|
||||
AppendOutput(ex.ToString());
|
||||
SetProgress(0, "Failed.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetProgress(double pct, string step)
|
||||
{
|
||||
ProgressPercent = pct;
|
||||
ProgressStep = step;
|
||||
AppendOutput($"[{pct:0}%] {step}");
|
||||
}
|
||||
|
||||
private void AppendOutput(string text)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
DeployOutput += (DeployOutput.Length > 0 ? "\n" : "") + text;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,387 +0,0 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
using OTSSignsOrchestrator.Core.Services;
|
||||
using OTSSignsOrchestrator.Desktop.Models;
|
||||
using OTSSignsOrchestrator.Desktop.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel for the instance details modal.
|
||||
/// Shows admin credentials, DB credentials, and OAuth2 app details
|
||||
/// with options to rotate passwords.
|
||||
/// </summary>
|
||||
public partial class InstanceDetailsViewModel : ObservableObject
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
// ── Instance metadata ─────────────────────────────────────────────────────
|
||||
[ObservableProperty] private string _stackName = string.Empty;
|
||||
[ObservableProperty] private string _customerAbbrev = string.Empty;
|
||||
[ObservableProperty] private string _hostLabel = string.Empty;
|
||||
[ObservableProperty] private string _instanceUrl = string.Empty;
|
||||
|
||||
// ── OTS admin credentials ─────────────────────────────────────────────────
|
||||
[ObservableProperty] private string _adminUsername = string.Empty;
|
||||
[ObservableProperty] private string _adminPassword = string.Empty;
|
||||
[ObservableProperty] private bool _adminPasswordVisible = false;
|
||||
[ObservableProperty] private string _adminPasswordDisplay = "••••••••";
|
||||
|
||||
// ── Database credentials ──────────────────────────────────────────────────
|
||||
[ObservableProperty] private string _dbUsername = string.Empty;
|
||||
[ObservableProperty] private string _dbPassword = string.Empty;
|
||||
[ObservableProperty] private bool _dbPasswordVisible = false;
|
||||
[ObservableProperty] private string _dbPasswordDisplay = "••••••••";
|
||||
|
||||
// ── OAuth2 application ────────────────────────────────────────────────────
|
||||
[ObservableProperty] private string _oAuthClientId = string.Empty;
|
||||
[ObservableProperty] private string _oAuthClientSecret = string.Empty;
|
||||
[ObservableProperty] private bool _oAuthSecretVisible = false;
|
||||
[ObservableProperty] private string _oAuthSecretDisplay = "••••••••";
|
||||
|
||||
// ── Status ────────────────────────────────────────────────────────────────
|
||||
[ObservableProperty] private string _statusMessage = string.Empty;
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
// ── Pending-setup inputs (shown when instance hasn't been initialised yet) ────────────
|
||||
[ObservableProperty] private bool _isPendingSetup;
|
||||
[ObservableProperty] private string _initClientId = string.Empty;
|
||||
[ObservableProperty] private string _initClientSecret = string.Empty;
|
||||
// ── Services (for restart) ─────────────────────────────────────────────────────
|
||||
[ObservableProperty] private ObservableCollection<ServiceInfo> _stackServices = new();
|
||||
[ObservableProperty] private bool _isLoadingServices;
|
||||
|
||||
/// <summary>
|
||||
/// Callback the View wires up to show a confirmation dialog.
|
||||
/// Parameters: (title, message) → returns true if the user confirmed.
|
||||
/// </summary>
|
||||
public Func<string, string, Task<bool>>? ConfirmAsync { get; set; }
|
||||
// Cached instance — needed by InitializeCommand to reload after setup
|
||||
private LiveStackItem? _currentInstance;
|
||||
public InstanceDetailsViewModel(IServiceProvider services)
|
||||
{
|
||||
_services = services;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Load
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Populates the ViewModel from a live <see cref="LiveStackItem"/>.</summary>
|
||||
public async Task LoadAsync(LiveStackItem instance)
|
||||
{
|
||||
_currentInstance = instance;
|
||||
StackName = instance.StackName;
|
||||
CustomerAbbrev = instance.CustomerAbbrev;
|
||||
HostLabel = instance.HostLabel;
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = "Loading credentials...";
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
var postInit = scope.ServiceProvider.GetRequiredService<PostInstanceInitService>();
|
||||
|
||||
// Derive the instance URL from the CMS server name template
|
||||
var serverTemplate = await settings.GetAsync(
|
||||
SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com");
|
||||
var serverName = serverTemplate.Replace("{abbrev}", instance.CustomerAbbrev);
|
||||
InstanceUrl = $"https://{serverName}/{instance.CustomerAbbrev.Trim().ToLowerInvariant()}";
|
||||
|
||||
// ── Admin credentials ─────────────────────────────────────────
|
||||
var creds = await postInit.GetCredentialsAsync(instance.CustomerAbbrev);
|
||||
AdminUsername = creds.AdminUsername;
|
||||
SetAdminPassword(creds.AdminPassword ?? string.Empty);
|
||||
|
||||
OAuthClientId = creds.OAuthClientId ?? string.Empty;
|
||||
SetOAuthSecret(creds.OAuthClientSecret ?? string.Empty);
|
||||
|
||||
// ── DB credentials ────────────────────────────────────────────
|
||||
var mySqlUserTemplate = await settings.GetAsync(
|
||||
SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user");
|
||||
DbUsername = mySqlUserTemplate.Replace("{abbrev}", instance.CustomerAbbrev);
|
||||
|
||||
var dbPw = await settings.GetAsync(SettingsService.InstanceMySqlPassword(instance.CustomerAbbrev));
|
||||
SetDbPassword(dbPw ?? string.Empty);
|
||||
|
||||
StatusMessage = creds.HasAdminPassword
|
||||
? "Credentials loaded."
|
||||
: "Pending setup — enter your Xibo OAuth credentials below to initialise this instance.";
|
||||
|
||||
IsPendingSetup = !creds.HasAdminPassword;
|
||||
// Clear any previous init inputs when re-loading
|
||||
if (IsPendingSetup)
|
||||
{
|
||||
InitClientId = string.Empty;
|
||||
InitClientSecret = string.Empty;
|
||||
}
|
||||
|
||||
// ── Load stack services ───────────────────────────────────────
|
||||
await LoadServicesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error loading credentials: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Initialise (pending setup)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[RelayCommand]
|
||||
private async Task InitializeAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(InitClientId) || string.IsNullOrWhiteSpace(InitClientSecret))
|
||||
{
|
||||
StatusMessage = "Both Client ID and Client Secret are required.";
|
||||
return;
|
||||
}
|
||||
if (_currentInstance is null) return;
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = "Waiting for Xibo and running initialisation (this may take several minutes)...";
|
||||
try
|
||||
{
|
||||
var postInit = _services.GetRequiredService<PostInstanceInitService>();
|
||||
await postInit.InitializeWithOAuthAsync(
|
||||
CustomerAbbrev,
|
||||
InstanceUrl,
|
||||
InitClientId.Trim(),
|
||||
InitClientSecret.Trim());
|
||||
|
||||
// Reload credentials — IsPendingSetup will flip to false
|
||||
IsBusy = false;
|
||||
await LoadAsync(_currentInstance);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Initialisation failed: {ex.Message}";
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Visibility toggles
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleAdminPasswordVisibility()
|
||||
{
|
||||
AdminPasswordVisible = !AdminPasswordVisible;
|
||||
AdminPasswordDisplay = AdminPasswordVisible
|
||||
? AdminPassword
|
||||
: (AdminPassword.Length > 0 ? "••••••••" : "(not set)");
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleDbPasswordVisibility()
|
||||
{
|
||||
DbPasswordVisible = !DbPasswordVisible;
|
||||
DbPasswordDisplay = DbPasswordVisible
|
||||
? DbPassword
|
||||
: (DbPassword.Length > 0 ? "••••••••" : "(not set)");
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleOAuthSecretVisibility()
|
||||
{
|
||||
OAuthSecretVisible = !OAuthSecretVisible;
|
||||
OAuthSecretDisplay = OAuthSecretVisible
|
||||
? OAuthClientSecret
|
||||
: (OAuthClientSecret.Length > 0 ? "••••••••" : "(not set)");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Clipboard
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CopyAdminPasswordAsync()
|
||||
=> await CopyToClipboardAsync(AdminPassword, "Admin password");
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CopyDbPasswordAsync()
|
||||
=> await CopyToClipboardAsync(DbPassword, "DB password");
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CopyOAuthClientIdAsync()
|
||||
=> await CopyToClipboardAsync(OAuthClientId, "OAuth client ID");
|
||||
|
||||
[RelayCommand]
|
||||
private async Task CopyOAuthSecretAsync()
|
||||
=> await CopyToClipboardAsync(OAuthClientSecret, "OAuth client secret");
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Rotation
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RestartServiceAsync(ServiceInfo? service)
|
||||
{
|
||||
if (service is null || _currentInstance is null) return;
|
||||
|
||||
if (ConfirmAsync is not null)
|
||||
{
|
||||
var confirmed = await ConfirmAsync(
|
||||
"Restart Service",
|
||||
$"Are you sure you want to restart '{service.Name}'?\n\nThis will force-update the service, causing its tasks to be recreated.");
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = $"Restarting service '{service.Name}'...";
|
||||
try
|
||||
{
|
||||
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||
var ok = await dockerCli.ForceUpdateServiceAsync(service.Name);
|
||||
StatusMessage = ok
|
||||
? $"Service '{service.Name}' restarted successfully."
|
||||
: $"Failed to restart service '{service.Name}'.";
|
||||
|
||||
// Refresh service list to show updated replica status
|
||||
await LoadServicesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error restarting service: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadServicesAsync()
|
||||
{
|
||||
if (_currentInstance is null) return;
|
||||
|
||||
IsLoadingServices = true;
|
||||
try
|
||||
{
|
||||
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||
dockerCli.SetHost(_currentInstance.Host);
|
||||
var services = await dockerCli.InspectStackServicesAsync(_currentInstance.StackName);
|
||||
StackServices = new ObservableCollection<ServiceInfo>(services);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error loading services: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoadingServices = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RotateAdminPasswordAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(CustomerAbbrev)) return;
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = "Rotating OTS admin password...";
|
||||
try
|
||||
{
|
||||
var postInit = _services.GetRequiredService<PostInstanceInitService>();
|
||||
var newPassword = await postInit.RotateAdminPasswordAsync(CustomerAbbrev, InstanceUrl);
|
||||
SetAdminPassword(newPassword);
|
||||
StatusMessage = "Admin password rotated successfully.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error rotating admin password: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RotateDbPasswordAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(CustomerAbbrev)) return;
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = $"Rotating MySQL password for {StackName}...";
|
||||
try
|
||||
{
|
||||
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
|
||||
|
||||
// We need the Host — retrieve from the HostLabel lookup
|
||||
using var scope = _services.CreateScope();
|
||||
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
|
||||
|
||||
// Get the host from the loaded stack — caller must have set the SSH host before
|
||||
var (ok, msg) = await instanceSvc.RotateMySqlPasswordAsync(StackName);
|
||||
if (ok)
|
||||
{
|
||||
// Reload DB password
|
||||
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
var dbPw = await settings.GetAsync(SettingsService.InstanceMySqlPassword(CustomerAbbrev));
|
||||
SetDbPassword(dbPw ?? string.Empty);
|
||||
StatusMessage = $"DB password rotated: {msg}";
|
||||
}
|
||||
else
|
||||
{
|
||||
StatusMessage = $"DB rotation failed: {msg}";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error rotating DB password: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private void SetAdminPassword(string value)
|
||||
{
|
||||
AdminPassword = value;
|
||||
AdminPasswordVisible = false;
|
||||
AdminPasswordDisplay = value.Length > 0 ? "••••••••" : "(not set)";
|
||||
}
|
||||
|
||||
private void SetDbPassword(string value)
|
||||
{
|
||||
DbPassword = value;
|
||||
DbPasswordVisible = false;
|
||||
DbPasswordDisplay = value.Length > 0 ? "••••••••" : "(not set)";
|
||||
}
|
||||
|
||||
private void SetOAuthSecret(string value)
|
||||
{
|
||||
OAuthClientSecret = value;
|
||||
OAuthSecretVisible = false;
|
||||
OAuthSecretDisplay = value.Length > 0 ? "••••••••" : "(not set)";
|
||||
}
|
||||
|
||||
private static async Task CopyToClipboardAsync(string text, string label)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return;
|
||||
var topLevel = Avalonia.Application.Current?.ApplicationLifetime is
|
||||
Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime dt
|
||||
? dt.MainWindow
|
||||
: null;
|
||||
var clipboard = topLevel is not null ? Avalonia.Controls.TopLevel.GetTopLevel(topLevel)?.Clipboard : null;
|
||||
if (clipboard is not null)
|
||||
await clipboard.SetTextAsync(text);
|
||||
}
|
||||
}
|
||||
@@ -1,627 +0,0 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OTSSignsOrchestrator.Core.Data;
|
||||
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
using OTSSignsOrchestrator.Core.Models.Entities;
|
||||
using OTSSignsOrchestrator.Core.Services;
|
||||
using OTSSignsOrchestrator.Desktop.Models;
|
||||
using OTSSignsOrchestrator.Desktop.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel for listing, viewing, and managing CMS instances.
|
||||
/// All data is fetched live from Docker Swarm hosts — nothing stored locally.
|
||||
/// Server operations (decommission, suspend, reactivate) go through the REST API.
|
||||
/// Real-time updates arrive via SignalR → WeakReferenceMessenger.
|
||||
/// </summary>
|
||||
public partial class InstancesViewModel : ObservableObject,
|
||||
IRecipient<AlertRaisedMessage>,
|
||||
IRecipient<InstanceStatusChangedMessage>,
|
||||
IRecipient<JobCreatedMessage>,
|
||||
IRecipient<JobCompletedMessage>
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ILogger<InstancesViewModel> _logger;
|
||||
private readonly IServerApiClient? _serverApi;
|
||||
|
||||
[ObservableProperty] private ObservableCollection<LiveStackItem> _instances = new();
|
||||
[ObservableProperty] private LiveStackItem? _selectedInstance;
|
||||
[ObservableProperty] private string _statusMessage = string.Empty;
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
[ObservableProperty] private string _filterText = string.Empty;
|
||||
[ObservableProperty] private ObservableCollection<ServiceInfo> _selectedServices = new();
|
||||
|
||||
// Available SSH hosts — loaded for display and used to scope operations
|
||||
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
|
||||
[ObservableProperty] private SshHost? _selectedSshHost;
|
||||
|
||||
// ── P1 Authentik Banner ──────────────────────────────────────────────────
|
||||
[ObservableProperty] private bool _isAuthentikP1BannerVisible;
|
||||
[ObservableProperty] private string _authentikP1Message = string.Empty;
|
||||
|
||||
// ── Container Logs ──────────────────────────────────────────────────────
|
||||
[ObservableProperty] private ObservableCollection<ServiceLogEntry> _logEntries = new();
|
||||
[ObservableProperty] private ObservableCollection<string> _logServiceFilter = new();
|
||||
[ObservableProperty] private string _selectedLogService = "All Services";
|
||||
[ObservableProperty] private bool _isLogsPanelVisible;
|
||||
[ObservableProperty] private bool _isLogsAutoRefresh = true;
|
||||
[ObservableProperty] private bool _isLoadingLogs;
|
||||
[ObservableProperty] private string _logsStatusMessage = string.Empty;
|
||||
[ObservableProperty] private int _logTailLines = 200;
|
||||
|
||||
private DispatcherTimer? _logRefreshTimer;
|
||||
private bool _isLogRefreshRunning;
|
||||
|
||||
/// <summary>Raised when the instance details modal should be opened for the given ViewModel.</summary>
|
||||
public event Action<InstanceDetailsViewModel>? OpenDetailsRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Callback the View wires up to show a confirmation dialog.
|
||||
/// Parameters: (title, message) → returns true if the user confirmed.
|
||||
/// </summary>
|
||||
public Func<string, string, Task<bool>>? ConfirmAsync { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Callback the View wires up to show a multi-step confirmation dialog for decommission.
|
||||
/// Parameters: (abbreviation) → returns true if confirmed through all steps.
|
||||
/// </summary>
|
||||
public Func<string, Task<bool>>? ConfirmDecommissionAsync { get; set; }
|
||||
|
||||
private string? _pendingSelectAbbrev;
|
||||
|
||||
public InstancesViewModel(IServiceProvider services)
|
||||
{
|
||||
_services = services;
|
||||
_logger = services.GetRequiredService<ILogger<InstancesViewModel>>();
|
||||
_serverApi = services.GetService<IServerApiClient>();
|
||||
|
||||
// Register for SignalR messages via WeakReferenceMessenger
|
||||
WeakReferenceMessenger.Default.Register<AlertRaisedMessage>(this);
|
||||
WeakReferenceMessenger.Default.Register<InstanceStatusChangedMessage>(this);
|
||||
WeakReferenceMessenger.Default.Register<JobCreatedMessage>(this);
|
||||
WeakReferenceMessenger.Default.Register<JobCompletedMessage>(this);
|
||||
|
||||
_ = RefreshAllAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues an abbreviation to be auto-selected once the next live refresh completes.
|
||||
/// Call immediately after construction (before <see cref="RefreshAllAsync"/> finishes).
|
||||
/// </summary>
|
||||
public void SetPendingSelection(string abbrev)
|
||||
=> _pendingSelectAbbrev = abbrev;
|
||||
|
||||
// ── SignalR Message Handlers ─────────────────────────────────────────────
|
||||
// These are called on the UI thread (SignalR handlers dispatch via Dispatcher.UIThread).
|
||||
|
||||
void IRecipient<AlertRaisedMessage>.Receive(AlertRaisedMessage message)
|
||||
{
|
||||
var (severity, msg) = message.Value;
|
||||
if (string.Equals(severity, "Critical", StringComparison.OrdinalIgnoreCase) &&
|
||||
msg.Contains("Authentik", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AuthentikP1Message = msg;
|
||||
IsAuthentikP1BannerVisible = true;
|
||||
}
|
||||
}
|
||||
|
||||
void IRecipient<InstanceStatusChangedMessage>.Receive(InstanceStatusChangedMessage message)
|
||||
{
|
||||
var (customerId, status) = message.Value;
|
||||
_logger.LogInformation("Instance status changed: customer={CustomerId} status={Status}", customerId, status);
|
||||
StatusMessage = $"Instance {customerId} status → {status}";
|
||||
// Refresh the list to pick up the new status
|
||||
_ = RefreshAllAsync();
|
||||
}
|
||||
|
||||
void IRecipient<JobCreatedMessage>.Receive(JobCreatedMessage message)
|
||||
{
|
||||
var (jobId, abbrev, jobType) = message.Value;
|
||||
_logger.LogInformation("Job created: {JobId} type={JobType} abbrev={Abbrev}", jobId, jobType, abbrev);
|
||||
StatusMessage = $"Job '{jobType}' created for {abbrev} (id: {jobId[..8]}…)";
|
||||
}
|
||||
|
||||
void IRecipient<JobCompletedMessage>.Receive(JobCompletedMessage message)
|
||||
{
|
||||
var (jobId, success, summary) = message.Value;
|
||||
_logger.LogInformation("Job completed: {JobId} success={Success} summary={Summary}", jobId, success, summary);
|
||||
StatusMessage = success
|
||||
? $"Job {jobId[..8]}… completed: {summary}"
|
||||
: $"Job {jobId[..8]}… failed: {summary}";
|
||||
// Refresh the instance list to reflect changes from the completed job
|
||||
_ = RefreshAllAsync();
|
||||
}
|
||||
|
||||
// ── Load / Refresh ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates all SSH hosts, then calls docker stack ls on each to build the
|
||||
/// live instance list. Only stacks matching *-cms-stack are shown.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private async Task LoadInstancesAsync() => await RefreshAllAsync();
|
||||
|
||||
private async Task RefreshAllAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
StatusMessage = "Loading live instances from all hosts...";
|
||||
SelectedServices = new ObservableCollection<ServiceInfo>();
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||
var hosts = await db.SshHosts.OrderBy(h => h.Label).ToListAsync();
|
||||
AvailableHosts = new ObservableCollection<SshHost>(hosts);
|
||||
|
||||
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||
var all = new List<LiveStackItem>();
|
||||
var errors = new List<string>();
|
||||
|
||||
foreach (var host in hosts)
|
||||
{
|
||||
try
|
||||
{
|
||||
dockerCli.SetHost(host);
|
||||
var stacks = await dockerCli.ListStacksAsync();
|
||||
foreach (var stack in stacks.Where(s => s.Name.EndsWith("-cms-stack")))
|
||||
{
|
||||
all.Add(new LiveStackItem
|
||||
{
|
||||
StackName = stack.Name,
|
||||
CustomerAbbrev = stack.Name[..^10],
|
||||
ServiceCount = stack.ServiceCount,
|
||||
Host = host,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { errors.Add($"{host.Label}: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// Enrich with server-side customer IDs if the server API is available
|
||||
if (_serverApi is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fleet = await _serverApi.GetFleetAsync();
|
||||
var lookup = fleet.ToDictionary(f => f.Abbreviation, f => f.CustomerId, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var item in all)
|
||||
{
|
||||
if (lookup.TryGetValue(item.CustomerAbbrev, out var customerId))
|
||||
item.CustomerId = customerId;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not enrich instances with server fleet data");
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(FilterText))
|
||||
all = all.Where(i =>
|
||||
i.StackName.Contains(FilterText, StringComparison.OrdinalIgnoreCase) ||
|
||||
i.CustomerAbbrev.Contains(FilterText, StringComparison.OrdinalIgnoreCase) ||
|
||||
i.HostLabel.Contains(FilterText, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
Instances = new ObservableCollection<LiveStackItem>(all);
|
||||
|
||||
// Auto-select a pending instance (e.g. just deployed from Create Instance page)
|
||||
if (_pendingSelectAbbrev is not null)
|
||||
{
|
||||
SelectedInstance = all.FirstOrDefault(i =>
|
||||
i.CustomerAbbrev.Equals(_pendingSelectAbbrev, StringComparison.OrdinalIgnoreCase));
|
||||
_pendingSelectAbbrev = null;
|
||||
}
|
||||
|
||||
var msg = $"Found {all.Count} instance(s) across {hosts.Count} host(s).";
|
||||
if (errors.Count > 0) msg += $" | Errors: {string.Join(" | ", errors)}";
|
||||
StatusMessage = msg;
|
||||
}
|
||||
catch (Exception ex) { StatusMessage = $"Error: {ex.Message}"; }
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task InspectInstanceAsync()
|
||||
{
|
||||
if (SelectedInstance == null) return;
|
||||
IsBusy = true;
|
||||
StatusMessage = $"Inspecting '{SelectedInstance.StackName}'...";
|
||||
try
|
||||
{
|
||||
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||
dockerCli.SetHost(SelectedInstance.Host);
|
||||
var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName);
|
||||
SelectedServices = new ObservableCollection<ServiceInfo>(services);
|
||||
StatusMessage = $"Found {services.Count} service(s) in stack '{SelectedInstance.StackName}'.";
|
||||
|
||||
// Populate service filter dropdown and show logs panel
|
||||
var filterItems = new List<string> { "All Services" };
|
||||
filterItems.AddRange(services.Select(s => s.Name));
|
||||
LogServiceFilter = new ObservableCollection<string>(filterItems);
|
||||
SelectedLogService = "All Services";
|
||||
IsLogsPanelVisible = true;
|
||||
|
||||
// Fetch initial logs and start auto-refresh
|
||||
await FetchLogsInternalAsync();
|
||||
StartLogAutoRefresh();
|
||||
}
|
||||
catch (Exception ex) { StatusMessage = $"Error inspecting: {ex.Message}"; }
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
// ── Container Log Commands ──────────────────────────────────────────────
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RefreshLogsAsync()
|
||||
{
|
||||
await FetchLogsInternalAsync();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleLogsAutoRefresh()
|
||||
{
|
||||
IsLogsAutoRefresh = !IsLogsAutoRefresh;
|
||||
if (IsLogsAutoRefresh)
|
||||
StartLogAutoRefresh();
|
||||
else
|
||||
StopLogAutoRefresh();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CloseLogsPanel()
|
||||
{
|
||||
StopLogAutoRefresh();
|
||||
IsLogsPanelVisible = false;
|
||||
LogEntries = new ObservableCollection<ServiceLogEntry>();
|
||||
LogsStatusMessage = string.Empty;
|
||||
}
|
||||
|
||||
partial void OnSelectedLogServiceChanged(string value)
|
||||
{
|
||||
// When user changes the service filter, refresh logs immediately
|
||||
if (IsLogsPanelVisible)
|
||||
_ = FetchLogsInternalAsync();
|
||||
}
|
||||
|
||||
private async Task FetchLogsInternalAsync()
|
||||
{
|
||||
if (SelectedInstance == null || _isLogRefreshRunning) return;
|
||||
|
||||
_isLogRefreshRunning = true;
|
||||
IsLoadingLogs = true;
|
||||
try
|
||||
{
|
||||
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||
dockerCli.SetHost(SelectedInstance.Host);
|
||||
|
||||
string? serviceFilter = SelectedLogService == "All Services" ? null : SelectedLogService;
|
||||
var entries = await dockerCli.GetServiceLogsAsync(
|
||||
SelectedInstance.StackName, serviceFilter, LogTailLines);
|
||||
|
||||
LogEntries = new ObservableCollection<ServiceLogEntry>(entries);
|
||||
LogsStatusMessage = $"{entries.Count} log line(s) · last fetched {DateTime.Now:HH:mm:ss}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogsStatusMessage = $"Error fetching logs: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoadingLogs = false;
|
||||
_isLogRefreshRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void StartLogAutoRefresh()
|
||||
{
|
||||
StopLogAutoRefresh();
|
||||
if (!IsLogsAutoRefresh) return;
|
||||
|
||||
_logRefreshTimer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromSeconds(5)
|
||||
};
|
||||
_logRefreshTimer.Tick += async (_, _) =>
|
||||
{
|
||||
if (IsLogsPanelVisible && IsLogsAutoRefresh && !_isLogRefreshRunning)
|
||||
await FetchLogsInternalAsync();
|
||||
};
|
||||
_logRefreshTimer.Start();
|
||||
}
|
||||
|
||||
private void StopLogAutoRefresh()
|
||||
{
|
||||
_logRefreshTimer?.Stop();
|
||||
_logRefreshTimer = null;
|
||||
}
|
||||
|
||||
// ── Restart Commands ────────────────────────────────────────────────
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RestartStackAsync()
|
||||
{
|
||||
if (SelectedInstance == null) return;
|
||||
|
||||
if (ConfirmAsync is not null)
|
||||
{
|
||||
var confirmed = await ConfirmAsync(
|
||||
"Restart Stack",
|
||||
$"Are you sure you want to restart all services in '{SelectedInstance.StackName}'?\n\nThis will force-update every service in the stack, causing brief downtime.");
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = $"Restarting all services in '{SelectedInstance.StackName}'...";
|
||||
try
|
||||
{
|
||||
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||
dockerCli.SetHost(SelectedInstance.Host);
|
||||
|
||||
var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName);
|
||||
var failures = new List<string>();
|
||||
|
||||
for (var i = 0; i < services.Count; i++)
|
||||
{
|
||||
var svc = services[i];
|
||||
StatusMessage = $"Restarting service {i + 1}/{services.Count}: {svc.Name}...";
|
||||
var ok = await dockerCli.ForceUpdateServiceAsync(svc.Name);
|
||||
if (!ok) failures.Add(svc.Name);
|
||||
}
|
||||
|
||||
StatusMessage = failures.Count == 0
|
||||
? $"All {services.Count} service(s) in '{SelectedInstance.StackName}' restarted successfully."
|
||||
: $"Restarted with errors — failed services: {string.Join(", ", failures)}";
|
||||
}
|
||||
catch (Exception ex) { StatusMessage = $"Error restarting stack: {ex.Message}"; }
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RestartServiceAsync(ServiceInfo? service)
|
||||
{
|
||||
if (service is null || SelectedInstance is null) return;
|
||||
|
||||
if (ConfirmAsync is not null)
|
||||
{
|
||||
var confirmed = await ConfirmAsync(
|
||||
"Restart Service",
|
||||
$"Are you sure you want to restart '{service.Name}'?\n\nThis will force-update the service, causing its tasks to be recreated.");
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = $"Restarting service '{service.Name}'...";
|
||||
try
|
||||
{
|
||||
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||
dockerCli.SetHost(SelectedInstance.Host);
|
||||
var ok = await dockerCli.ForceUpdateServiceAsync(service.Name);
|
||||
StatusMessage = ok
|
||||
? $"Service '{service.Name}' restarted successfully."
|
||||
: $"Failed to restart service '{service.Name}'.";
|
||||
|
||||
// Refresh services to show updated replica status
|
||||
var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName);
|
||||
SelectedServices = new ObservableCollection<ServiceInfo>(services);
|
||||
}
|
||||
catch (Exception ex) { StatusMessage = $"Error restarting service: {ex.Message}"; }
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DeleteInstanceAsync()
|
||||
{
|
||||
if (SelectedInstance == null) return;
|
||||
IsBusy = true;
|
||||
StatusMessage = $"Deleting {SelectedInstance.StackName}...";
|
||||
try
|
||||
{
|
||||
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||
dockerCli.SetHost(SelectedInstance.Host);
|
||||
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
|
||||
dockerSecrets.SetHost(SelectedInstance.Host);
|
||||
using var scope = _services.CreateScope();
|
||||
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
|
||||
var result = await instanceSvc.DeleteInstanceAsync(
|
||||
SelectedInstance.StackName, SelectedInstance.CustomerAbbrev);
|
||||
StatusMessage = result.Success
|
||||
? $"Instance '{SelectedInstance.StackName}' deleted."
|
||||
: $"Delete failed: {result.ErrorMessage}";
|
||||
await RefreshAllAsync();
|
||||
}
|
||||
catch (Exception ex) { StatusMessage = $"Error deleting: {ex.Message}"; }
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RotateMySqlPasswordAsync()
|
||||
{
|
||||
if (SelectedInstance == null) return;
|
||||
IsBusy = true;
|
||||
StatusMessage = $"Rotating MySQL password for {SelectedInstance.StackName}...";
|
||||
try
|
||||
{
|
||||
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||
dockerCli.SetHost(SelectedInstance.Host);
|
||||
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
|
||||
dockerSecrets.SetHost(SelectedInstance.Host);
|
||||
using var scope = _services.CreateScope();
|
||||
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
|
||||
var (ok, msg) = await instanceSvc.RotateMySqlPasswordAsync(SelectedInstance.StackName);
|
||||
StatusMessage = ok ? $"Done: {msg}" : $"Failed: {msg}";
|
||||
await RefreshAllAsync();
|
||||
}
|
||||
catch (Exception ex) { StatusMessage = $"Error rotating password: {ex.Message}"; }
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
// ── P1 Banner Commands ────────────────────────────────────────────────
|
||||
|
||||
[RelayCommand]
|
||||
private void DismissP1Banner()
|
||||
{
|
||||
IsAuthentikP1BannerVisible = false;
|
||||
AuthentikP1Message = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called from a SignalR <c>AlertRaised</c> handler (runs on a background thread).
|
||||
/// CRITICAL: wraps all property updates with <see cref="Dispatcher.UIThread"/> to
|
||||
/// avoid silent cross-thread exceptions in Avalonia.
|
||||
/// </summary>
|
||||
public void HandleAlertRaised(string severity, string message)
|
||||
{
|
||||
if (string.Equals(severity, "Critical", StringComparison.OrdinalIgnoreCase) &&
|
||||
message.Contains("Authentik", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
AuthentikP1Message = message;
|
||||
IsAuthentikP1BannerVisible = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Server-side Job Commands (decommission, suspend, reactivate) ────────
|
||||
// Desktop has NO direct infrastructure access — all operations go through the server REST API.
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DecommissionAsync(LiveStackItem? instance)
|
||||
{
|
||||
instance ??= SelectedInstance;
|
||||
if (instance is null) return;
|
||||
|
||||
if (instance.CustomerId is null)
|
||||
{
|
||||
StatusMessage = "Cannot decommission: no server-side customer ID available for this instance.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Multi-step confirmation: user must type the abbreviation to confirm
|
||||
if (ConfirmDecommissionAsync is not null)
|
||||
{
|
||||
var confirmed = await ConfirmDecommissionAsync(instance.CustomerAbbrev);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
else if (ConfirmAsync is not null)
|
||||
{
|
||||
var confirmed = await ConfirmAsync(
|
||||
"Decommission Instance",
|
||||
$"Are you sure you want to decommission '{instance.CustomerAbbrev}'?\n\n" +
|
||||
"This will:\n" +
|
||||
" • Remove all Docker services and stack\n" +
|
||||
" • Delete Docker secrets\n" +
|
||||
" • Remove NFS volumes and data\n" +
|
||||
" • Revoke Authentik provider\n" +
|
||||
" • Mark the customer as decommissioned\n\n" +
|
||||
"This action is IRREVERSIBLE.");
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
await CreateServerJobAsync(instance, "decommission");
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SuspendInstanceAsync(LiveStackItem? instance)
|
||||
{
|
||||
instance ??= SelectedInstance;
|
||||
if (instance is null) return;
|
||||
|
||||
if (instance.CustomerId is null)
|
||||
{
|
||||
StatusMessage = "Cannot suspend: no server-side customer ID available for this instance.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (ConfirmAsync is not null)
|
||||
{
|
||||
var confirmed = await ConfirmAsync(
|
||||
"Suspend Instance",
|
||||
$"Are you sure you want to suspend '{instance.CustomerAbbrev}'?\n\n" +
|
||||
"The instance will be scaled to zero replicas. Data will be preserved.");
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
await CreateServerJobAsync(instance, "suspend");
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ReactivateInstanceAsync(LiveStackItem? instance)
|
||||
{
|
||||
instance ??= SelectedInstance;
|
||||
if (instance is null) return;
|
||||
|
||||
if (instance.CustomerId is null)
|
||||
{
|
||||
StatusMessage = "Cannot reactivate: no server-side customer ID available for this instance.";
|
||||
return;
|
||||
}
|
||||
|
||||
await CreateServerJobAsync(instance, "reactivate");
|
||||
}
|
||||
|
||||
private async Task CreateServerJobAsync(LiveStackItem instance, string jobType)
|
||||
{
|
||||
if (_serverApi is null)
|
||||
{
|
||||
StatusMessage = "Server API client is not configured.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = $"Requesting '{jobType}' for {instance.CustomerAbbrev}...";
|
||||
try
|
||||
{
|
||||
var response = await _serverApi.CreateJobAsync(
|
||||
new CreateJobRequest(instance.CustomerId!.Value, jobType, null));
|
||||
StatusMessage = $"Job '{jobType}' created (id: {response.Id.ToString()[..8]}…). Status: {response.Status}";
|
||||
_logger.LogInformation("Server job created: {JobId} type={JobType} customer={CustomerId}",
|
||||
response.Id, jobType, instance.CustomerId);
|
||||
}
|
||||
catch (Refit.ApiException ex)
|
||||
{
|
||||
StatusMessage = $"Server error creating '{jobType}' job: {ex.StatusCode} — {ex.Content}";
|
||||
_logger.LogError(ex, "Failed to create {JobType} job for customer {CustomerId}", jobType, instance.CustomerId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error creating '{jobType}' job: {ex.Message}";
|
||||
_logger.LogError(ex, "Failed to create {JobType} job for customer {CustomerId}", jobType, instance.CustomerId);
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
// ── Details ─────────────────────────────────────────────────────────────
|
||||
|
||||
[RelayCommand]
|
||||
private async Task OpenDetailsAsync()
|
||||
{
|
||||
if (SelectedInstance == null) return;
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = $"Loading details for '{SelectedInstance.StackName}'...";
|
||||
try
|
||||
{
|
||||
// Set the SSH host on singleton Docker services so modal operations target the right host
|
||||
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
|
||||
dockerCli.SetHost(SelectedInstance.Host);
|
||||
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
|
||||
dockerSecrets.SetHost(SelectedInstance.Host);
|
||||
|
||||
var detailsVm = _services.GetRequiredService<InstanceDetailsViewModel>();
|
||||
await detailsVm.LoadAsync(SelectedInstance);
|
||||
|
||||
OpenDetailsRequested?.Invoke(detailsVm);
|
||||
StatusMessage = string.Empty;
|
||||
}
|
||||
catch (Exception ex) { StatusMessage = $"Error opening details: {ex.Message}"; }
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
|
||||
public partial class MainWindowViewModel : ObservableObject
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
[ObservableProperty]
|
||||
private ObservableObject? _currentView;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _selectedNav = "Hosts";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusMessage = "Ready";
|
||||
|
||||
public ObservableCollection<string> NavItems { get; } = new()
|
||||
{
|
||||
"Hosts",
|
||||
"Instances",
|
||||
"Create Instance",
|
||||
"Secrets",
|
||||
"Settings",
|
||||
"Logs"
|
||||
};
|
||||
|
||||
public MainWindowViewModel(IServiceProvider services)
|
||||
{
|
||||
_services = services;
|
||||
NavigateTo("Hosts");
|
||||
}
|
||||
|
||||
partial void OnSelectedNavChanged(string value)
|
||||
{
|
||||
NavigateTo(value);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void NavigateTo(string page)
|
||||
{
|
||||
CurrentView = page switch
|
||||
{
|
||||
"Hosts" => (ObservableObject)_services.GetService(typeof(HostsViewModel))!,
|
||||
"Instances" => (ObservableObject)_services.GetService(typeof(InstancesViewModel))!,
|
||||
"Create Instance" => (ObservableObject)_services.GetService(typeof(CreateInstanceViewModel))!,
|
||||
"Secrets" => (ObservableObject)_services.GetService(typeof(SecretsViewModel))!,
|
||||
"Settings" => (ObservableObject)_services.GetService(typeof(SettingsViewModel))!,
|
||||
"Logs" => (ObservableObject)_services.GetService(typeof(LogsViewModel))!,
|
||||
_ => CurrentView
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Navigates to the Instances page and auto-selects the instance with the given abbreviation
|
||||
/// once the live refresh completes.
|
||||
/// </summary>
|
||||
public void NavigateToInstancesWithSelection(string abbrev)
|
||||
{
|
||||
SelectedNav = "Instances"; // triggers OnSelectedNavChanged → NavigateTo("Instances")
|
||||
if (CurrentView is InstancesViewModel instancesVm)
|
||||
instancesVm.SetPendingSelection(abbrev);
|
||||
}
|
||||
|
||||
public void SetStatus(string message)
|
||||
{
|
||||
StatusMessage = message;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,584 +0,0 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OTSSignsOrchestrator.Core.Configuration;
|
||||
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
using OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel for the Settings page — manages Git, MySQL, SMTP, Pangolin, NFS,
|
||||
/// and Instance Defaults configuration, persisted via SettingsService.
|
||||
/// </summary>
|
||||
public partial class SettingsViewModel : ObservableObject
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
[ObservableProperty] private string _statusMessage = string.Empty;
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
|
||||
// ── Git ──────────────────────────────────────────────────────────────────
|
||||
[ObservableProperty] private string _gitRepoUrl = string.Empty;
|
||||
[ObservableProperty] private string _gitRepoPat = string.Empty;
|
||||
|
||||
// ── MySQL Admin ─────────────────────────────────────────────────────────
|
||||
[ObservableProperty] private string _mySqlHost = string.Empty;
|
||||
[ObservableProperty] private string _mySqlPort = "3306";
|
||||
[ObservableProperty] private string _mySqlAdminUser = string.Empty;
|
||||
[ObservableProperty] private string _mySqlAdminPassword = string.Empty;
|
||||
|
||||
// ── SMTP ────────────────────────────────────────────────────────────────
|
||||
[ObservableProperty] private string _smtpServer = string.Empty;
|
||||
[ObservableProperty] private string _smtpUsername = string.Empty;
|
||||
[ObservableProperty] private string _smtpPassword = string.Empty;
|
||||
[ObservableProperty] private bool _smtpUseTls = true;
|
||||
[ObservableProperty] private bool _smtpUseStartTls = true;
|
||||
[ObservableProperty] private string _smtpRewriteDomain = string.Empty;
|
||||
[ObservableProperty] private string _smtpHostname = string.Empty;
|
||||
[ObservableProperty] private string _smtpFromLineOverride = "NO";
|
||||
|
||||
// ── Pangolin ────────────────────────────────────────────────────────────
|
||||
[ObservableProperty] private string _pangolinEndpoint = "https://app.pangolin.net";
|
||||
|
||||
// ── NFS ─────────────────────────────────────────────────────────────────
|
||||
[ObservableProperty] private string _nfsServer = string.Empty;
|
||||
[ObservableProperty] private string _nfsExport = string.Empty;
|
||||
[ObservableProperty] private string _nfsExportFolder = string.Empty;
|
||||
[ObservableProperty] private string _nfsOptions = string.Empty;
|
||||
|
||||
// ── Instance Defaults ───────────────────────────────────────────────────
|
||||
[ObservableProperty] private string _defaultCmsImage = "ghcr.io/xibosignage/xibo-cms:release-4.2.3";
|
||||
[ObservableProperty] private string _defaultNewtImage = "fosrl/newt";
|
||||
[ObservableProperty] private string _defaultMemcachedImage = "memcached:alpine";
|
||||
[ObservableProperty] private string _defaultQuickChartImage = "ianw/quickchart";
|
||||
[ObservableProperty] private string _defaultCmsServerNameTemplate = "app.ots-signs.com";
|
||||
[ObservableProperty] private string _defaultThemeHostPath = "/cms/{abbrev}-cms-theme-custom";
|
||||
[ObservableProperty] private string _defaultMySqlDbTemplate = "{abbrev}_cms_db";
|
||||
[ObservableProperty] private string _defaultMySqlUserTemplate = "{abbrev}_cms_user";
|
||||
[ObservableProperty] private string _defaultPhpPostMaxSize = "10G";
|
||||
[ObservableProperty] private string _defaultPhpUploadMaxFilesize = "10G";
|
||||
[ObservableProperty] private string _defaultPhpMaxExecutionTime = "600";
|
||||
|
||||
// ── Bitwarden Secrets Manager ─────────────────────────────────
|
||||
[ObservableProperty] private string _bitwardenIdentityUrl = "https://identity.bitwarden.com";
|
||||
[ObservableProperty] private string _bitwardenApiUrl = "https://api.bitwarden.com";
|
||||
[ObservableProperty] private string _bitwardenAccessToken = string.Empty;
|
||||
[ObservableProperty] private string _bitwardenOrganizationId = string.Empty;
|
||||
[ObservableProperty] private string _bitwardenProjectId = string.Empty;
|
||||
[ObservableProperty] private string _bitwardenInstanceProjectId = string.Empty;
|
||||
|
||||
// ── Authentik (SAML IdP) ────────────────────────────────────────
|
||||
[ObservableProperty] private string _authentikUrl = string.Empty;
|
||||
[ObservableProperty] private string _authentikApiKey = string.Empty;
|
||||
[ObservableProperty] private string _authentikAuthorizationFlowSlug = string.Empty;
|
||||
[ObservableProperty] private string _authentikInvalidationFlowSlug = string.Empty;
|
||||
[ObservableProperty] private string _authentikSigningKeypairId = string.Empty;
|
||||
[ObservableProperty] private string _authentikStatusMessage = string.Empty;
|
||||
[ObservableProperty] private bool _isAuthentikBusy;
|
||||
|
||||
// Dropdown collections for Authentik flows / keypairs
|
||||
public ObservableCollection<AuthentikFlowItem> AuthentikAuthorizationFlows { get; } = new();
|
||||
public ObservableCollection<AuthentikFlowItem> AuthentikInvalidationFlows { get; } = new();
|
||||
public ObservableCollection<AuthentikKeypairItem> AuthentikKeypairs { get; } = new();
|
||||
|
||||
[ObservableProperty] private AuthentikFlowItem? _selectedAuthorizationFlow;
|
||||
[ObservableProperty] private AuthentikFlowItem? _selectedInvalidationFlow;
|
||||
[ObservableProperty] private AuthentikKeypairItem? _selectedSigningKeypair;
|
||||
|
||||
// ── Xibo Bootstrap OAuth2 ─────────────────────────────────────
|
||||
[ObservableProperty] private string _xiboBootstrapClientId = string.Empty;
|
||||
[ObservableProperty] private string _xiboBootstrapClientSecret = string.Empty;
|
||||
|
||||
public SettingsViewModel(IServiceProvider services)
|
||||
{
|
||||
_services = services;
|
||||
_ = LoadAsync();
|
||||
}
|
||||
|
||||
/// <summary>Whether Bitwarden is configured and reachable.</summary>
|
||||
[ObservableProperty] private bool _isBitwardenConfigured;
|
||||
|
||||
[RelayCommand]
|
||||
private Task LoadAsync() => LoadCoreAsync(skipBitwarden: false);
|
||||
|
||||
private async Task LoadCoreAsync(bool skipBitwarden)
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
if (!skipBitwarden)
|
||||
{
|
||||
// ── Load Bitwarden bootstrap config (IOptionsMonitor picks up appsettings.json changes) ──
|
||||
var bwOptions = _services.GetRequiredService<IOptionsMonitor<BitwardenOptions>>().CurrentValue;
|
||||
BitwardenIdentityUrl = bwOptions.IdentityUrl;
|
||||
BitwardenApiUrl = bwOptions.ApiUrl;
|
||||
BitwardenAccessToken = bwOptions.AccessToken;
|
||||
BitwardenOrganizationId = bwOptions.OrganizationId;
|
||||
BitwardenProjectId = bwOptions.ProjectId;
|
||||
BitwardenInstanceProjectId = bwOptions.InstanceProjectId;
|
||||
}
|
||||
|
||||
IsBitwardenConfigured = !string.IsNullOrWhiteSpace(BitwardenAccessToken)
|
||||
&& !string.IsNullOrWhiteSpace(BitwardenOrganizationId);
|
||||
|
||||
if (!IsBitwardenConfigured)
|
||||
{
|
||||
StatusMessage = "Bitwarden is not configured. Fill in the Bitwarden section and save to get started.";
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Load all other settings from Bitwarden ──
|
||||
using var scope = _services.CreateScope();
|
||||
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
svc.InvalidateCache();
|
||||
|
||||
// Git
|
||||
GitRepoUrl = await svc.GetAsync(SettingsService.GitRepoUrl, string.Empty);
|
||||
GitRepoPat = await svc.GetAsync(SettingsService.GitRepoPat, string.Empty);
|
||||
|
||||
// MySQL
|
||||
MySqlHost = await svc.GetAsync(SettingsService.MySqlHost, string.Empty);
|
||||
MySqlPort = await svc.GetAsync(SettingsService.MySqlPort, "3306");
|
||||
MySqlAdminUser = await svc.GetAsync(SettingsService.MySqlAdminUser, string.Empty);
|
||||
MySqlAdminPassword = await svc.GetAsync(SettingsService.MySqlAdminPassword, string.Empty);
|
||||
|
||||
// SMTP
|
||||
SmtpServer = await svc.GetAsync(SettingsService.SmtpServer, string.Empty);
|
||||
SmtpUsername = await svc.GetAsync(SettingsService.SmtpUsername, string.Empty);
|
||||
SmtpPassword = await svc.GetAsync(SettingsService.SmtpPassword, string.Empty);
|
||||
SmtpUseTls = (await svc.GetAsync(SettingsService.SmtpUseTls, "YES")) == "YES";
|
||||
SmtpUseStartTls = (await svc.GetAsync(SettingsService.SmtpUseStartTls, "YES")) == "YES";
|
||||
SmtpRewriteDomain = await svc.GetAsync(SettingsService.SmtpRewriteDomain, string.Empty);
|
||||
SmtpHostname = await svc.GetAsync(SettingsService.SmtpHostname, string.Empty);
|
||||
SmtpFromLineOverride = await svc.GetAsync(SettingsService.SmtpFromLineOverride, "NO");
|
||||
|
||||
// Pangolin
|
||||
PangolinEndpoint = await svc.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net");
|
||||
|
||||
// NFS
|
||||
NfsServer = await svc.GetAsync(SettingsService.NfsServer, string.Empty);
|
||||
NfsExport = await svc.GetAsync(SettingsService.NfsExport, string.Empty);
|
||||
NfsExportFolder = await svc.GetAsync(SettingsService.NfsExportFolder, string.Empty);
|
||||
NfsOptions = await svc.GetAsync(SettingsService.NfsOptions, string.Empty);
|
||||
|
||||
// Instance Defaults
|
||||
DefaultCmsImage = await svc.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3");
|
||||
DefaultNewtImage = await svc.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt");
|
||||
DefaultMemcachedImage = await svc.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine");
|
||||
DefaultQuickChartImage = await svc.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart");
|
||||
DefaultCmsServerNameTemplate = await svc.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com");
|
||||
DefaultThemeHostPath = await svc.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/{abbrev}-cms-theme-custom");
|
||||
DefaultMySqlDbTemplate = await svc.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db");
|
||||
DefaultMySqlUserTemplate = await svc.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user");
|
||||
DefaultPhpPostMaxSize = await svc.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G");
|
||||
DefaultPhpUploadMaxFilesize = await svc.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
|
||||
DefaultPhpMaxExecutionTime = await svc.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
|
||||
|
||||
// Authentik
|
||||
AuthentikUrl = await svc.GetAsync(SettingsService.AuthentikUrl, string.Empty);
|
||||
AuthentikApiKey = await svc.GetAsync(SettingsService.AuthentikApiKey, string.Empty);
|
||||
AuthentikAuthorizationFlowSlug = await svc.GetAsync(SettingsService.AuthentikAuthorizationFlowSlug, string.Empty);
|
||||
AuthentikInvalidationFlowSlug = await svc.GetAsync(SettingsService.AuthentikInvalidationFlowSlug, string.Empty);
|
||||
AuthentikSigningKeypairId = await svc.GetAsync(SettingsService.AuthentikSigningKeypairId, string.Empty);
|
||||
|
||||
// If Authentik URL + key are configured, try loading dropdowns
|
||||
if (!string.IsNullOrWhiteSpace(AuthentikUrl) && !string.IsNullOrWhiteSpace(AuthentikApiKey))
|
||||
await FetchAuthentikDropdownsInternalAsync();
|
||||
|
||||
// Xibo Bootstrap
|
||||
XiboBootstrapClientId = await svc.GetAsync(SettingsService.XiboBootstrapClientId, string.Empty);
|
||||
XiboBootstrapClientSecret = await svc.GetAsync(SettingsService.XiboBootstrapClientSecret, string.Empty);
|
||||
|
||||
StatusMessage = "Settings loaded from Bitwarden.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error loading settings: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SaveBitwardenLocalAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
await SaveBitwardenConfigToFileAsync();
|
||||
|
||||
IsBitwardenConfigured = !string.IsNullOrWhiteSpace(BitwardenAccessToken)
|
||||
&& !string.IsNullOrWhiteSpace(BitwardenOrganizationId);
|
||||
|
||||
StatusMessage = IsBitwardenConfigured
|
||||
? "Bitwarden config saved to appsettings.json."
|
||||
: "Bitwarden config saved. Fill in Access Token and Org ID to enable all settings.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error saving Bitwarden config: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task PullFromBitwardenAsync()
|
||||
{
|
||||
await LoadCoreAsync(skipBitwarden: true);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task PushToBitwardenAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
if (!IsBitwardenConfigured)
|
||||
{
|
||||
StatusMessage = "Bitwarden is not configured. Save Bitwarden config first.";
|
||||
return;
|
||||
}
|
||||
|
||||
using var scope = _services.CreateScope();
|
||||
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
svc.InvalidateCache();
|
||||
|
||||
var settings = new List<(string Key, string? Value, string Category, bool IsSensitive)>
|
||||
{
|
||||
// Git
|
||||
(SettingsService.GitRepoUrl, NullIfEmpty(GitRepoUrl), SettingsService.CatGit, false),
|
||||
(SettingsService.GitRepoPat, NullIfEmpty(GitRepoPat), SettingsService.CatGit, true),
|
||||
|
||||
// MySQL
|
||||
(SettingsService.MySqlHost, NullIfEmpty(MySqlHost), SettingsService.CatMySql, false),
|
||||
(SettingsService.MySqlPort, MySqlPort, SettingsService.CatMySql, false),
|
||||
(SettingsService.MySqlAdminUser, NullIfEmpty(MySqlAdminUser), SettingsService.CatMySql, false),
|
||||
(SettingsService.MySqlAdminPassword, NullIfEmpty(MySqlAdminPassword), SettingsService.CatMySql, true),
|
||||
|
||||
// SMTP
|
||||
(SettingsService.SmtpServer, NullIfEmpty(SmtpServer), SettingsService.CatSmtp, false),
|
||||
(SettingsService.SmtpUsername, NullIfEmpty(SmtpUsername), SettingsService.CatSmtp, false),
|
||||
(SettingsService.SmtpPassword, NullIfEmpty(SmtpPassword), SettingsService.CatSmtp, true),
|
||||
(SettingsService.SmtpUseTls, SmtpUseTls ? "YES" : "NO", SettingsService.CatSmtp, false),
|
||||
(SettingsService.SmtpUseStartTls, SmtpUseStartTls ? "YES" : "NO", SettingsService.CatSmtp, false),
|
||||
(SettingsService.SmtpRewriteDomain, NullIfEmpty(SmtpRewriteDomain), SettingsService.CatSmtp, false),
|
||||
(SettingsService.SmtpHostname, NullIfEmpty(SmtpHostname), SettingsService.CatSmtp, false),
|
||||
(SettingsService.SmtpFromLineOverride, SmtpFromLineOverride, SettingsService.CatSmtp, false),
|
||||
|
||||
// Pangolin
|
||||
(SettingsService.PangolinEndpoint, NullIfEmpty(PangolinEndpoint), SettingsService.CatPangolin, false),
|
||||
|
||||
// NFS
|
||||
(SettingsService.NfsServer, NullIfEmpty(NfsServer), SettingsService.CatNfs, false),
|
||||
(SettingsService.NfsExport, NullIfEmpty(NfsExport), SettingsService.CatNfs, false),
|
||||
(SettingsService.NfsExportFolder, NullIfEmpty(NfsExportFolder), SettingsService.CatNfs, false),
|
||||
(SettingsService.NfsOptions, NullIfEmpty(NfsOptions), SettingsService.CatNfs, false),
|
||||
|
||||
// Instance Defaults
|
||||
(SettingsService.DefaultCmsImage, DefaultCmsImage, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultNewtImage, DefaultNewtImage, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultMemcachedImage, DefaultMemcachedImage, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultQuickChartImage, DefaultQuickChartImage, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultCmsServerNameTemplate, DefaultCmsServerNameTemplate, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultThemeHostPath, DefaultThemeHostPath, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultMySqlDbTemplate, DefaultMySqlDbTemplate, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultMySqlUserTemplate, DefaultMySqlUserTemplate, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultPhpPostMaxSize, DefaultPhpPostMaxSize, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultPhpUploadMaxFilesize, DefaultPhpUploadMaxFilesize, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultPhpMaxExecutionTime, DefaultPhpMaxExecutionTime, SettingsService.CatDefaults, false),
|
||||
|
||||
// Authentik
|
||||
(SettingsService.AuthentikUrl, NullIfEmpty(AuthentikUrl), SettingsService.CatAuthentik, false),
|
||||
(SettingsService.AuthentikApiKey, NullIfEmpty(AuthentikApiKey), SettingsService.CatAuthentik, true),
|
||||
(SettingsService.AuthentikAuthorizationFlowSlug, NullIfEmpty(SelectedAuthorizationFlow?.Slug ?? AuthentikAuthorizationFlowSlug), SettingsService.CatAuthentik, false),
|
||||
(SettingsService.AuthentikInvalidationFlowSlug, NullIfEmpty(SelectedInvalidationFlow?.Slug ?? AuthentikInvalidationFlowSlug), SettingsService.CatAuthentik, false),
|
||||
(SettingsService.AuthentikSigningKeypairId, NullIfEmpty(SelectedSigningKeypair?.Pk ?? AuthentikSigningKeypairId), SettingsService.CatAuthentik, false),
|
||||
|
||||
// Xibo Bootstrap
|
||||
(SettingsService.XiboBootstrapClientId, NullIfEmpty(XiboBootstrapClientId), SettingsService.CatXibo, false),
|
||||
(SettingsService.XiboBootstrapClientSecret, NullIfEmpty(XiboBootstrapClientSecret), SettingsService.CatXibo, true),
|
||||
};
|
||||
|
||||
await svc.SaveManyAsync(settings);
|
||||
StatusMessage = "Settings pushed to Bitwarden.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error saving settings: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task TestBitwardenConnectionAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(BitwardenAccessToken) || string.IsNullOrWhiteSpace(BitwardenOrganizationId))
|
||||
{
|
||||
StatusMessage = "Bitwarden Access Token and Organization ID are required.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = "Saving Bitwarden config and testing connection...";
|
||||
try
|
||||
{
|
||||
// Save to appsettings.json first so the service picks up fresh values
|
||||
await SaveBitwardenConfigToFileAsync();
|
||||
|
||||
using var scope = _services.CreateScope();
|
||||
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
||||
var secrets = await bws.ListSecretsAsync();
|
||||
IsBitwardenConfigured = true;
|
||||
StatusMessage = $"Bitwarden connected. Found {secrets.Count} secret(s) in project.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
IsBitwardenConfigured = false;
|
||||
StatusMessage = $"Bitwarden connection failed: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Authentik: save, test, fetch dropdowns
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SaveAndTestAuthentikAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(AuthentikUrl) || string.IsNullOrWhiteSpace(AuthentikApiKey))
|
||||
{
|
||||
AuthentikStatusMessage = "Authentik URL and API Token are required.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsAuthentikBusy = true;
|
||||
AuthentikStatusMessage = "Saving Authentik settings and testing connection...";
|
||||
try
|
||||
{
|
||||
// Persist URL + API key first so subsequent calls work
|
||||
using var scope = _services.CreateScope();
|
||||
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
await svc.SetAsync(SettingsService.AuthentikUrl, AuthentikUrl.Trim(), SettingsService.CatAuthentik);
|
||||
await svc.SetAsync(SettingsService.AuthentikApiKey, AuthentikApiKey.Trim(), SettingsService.CatAuthentik, isSensitive: true);
|
||||
|
||||
var authentik = scope.ServiceProvider.GetRequiredService<IAuthentikService>();
|
||||
var (ok, msg) = await authentik.TestConnectionAsync(AuthentikUrl.Trim(), AuthentikApiKey.Trim());
|
||||
|
||||
if (!ok)
|
||||
{
|
||||
AuthentikStatusMessage = $"Connection failed: {msg}";
|
||||
return;
|
||||
}
|
||||
|
||||
AuthentikStatusMessage = "Connected — loading flows and keypairs...";
|
||||
|
||||
// Now fetch dropdowns
|
||||
await FetchAuthentikDropdownsInternalAsync(AuthentikUrl.Trim(), AuthentikApiKey.Trim());
|
||||
|
||||
// Save selected flow/keypair values
|
||||
var authSlug = SelectedAuthorizationFlow?.Slug ?? AuthentikAuthorizationFlowSlug;
|
||||
var invalSlug = SelectedInvalidationFlow?.Slug ?? AuthentikInvalidationFlowSlug;
|
||||
var kpId = SelectedSigningKeypair?.Pk ?? AuthentikSigningKeypairId;
|
||||
|
||||
await svc.SetAsync(SettingsService.AuthentikAuthorizationFlowSlug, authSlug, SettingsService.CatAuthentik);
|
||||
await svc.SetAsync(SettingsService.AuthentikInvalidationFlowSlug, invalSlug, SettingsService.CatAuthentik);
|
||||
await svc.SetAsync(SettingsService.AuthentikSigningKeypairId, kpId, SettingsService.CatAuthentik);
|
||||
|
||||
AuthentikStatusMessage = $"Authentik connected. {AuthentikAuthorizationFlows.Count} flows, {AuthentikKeypairs.Count} keypair(s) loaded.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AuthentikStatusMessage = $"Error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsAuthentikBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task FetchAuthentikDropdownsAsync()
|
||||
{
|
||||
IsAuthentikBusy = true;
|
||||
AuthentikStatusMessage = "Fetching flows and keypairs from Authentik...";
|
||||
try
|
||||
{
|
||||
await FetchAuthentikDropdownsInternalAsync();
|
||||
AuthentikStatusMessage = $"Loaded {AuthentikAuthorizationFlows.Count} flows, {AuthentikKeypairs.Count} keypair(s).";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AuthentikStatusMessage = $"Error fetching data: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsAuthentikBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FetchAuthentikDropdownsInternalAsync(
|
||||
string? overrideUrl = null, string? overrideApiKey = null)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var authentik = scope.ServiceProvider.GetRequiredService<IAuthentikService>();
|
||||
|
||||
var flows = await authentik.ListFlowsAsync(overrideUrl, overrideApiKey);
|
||||
var keypairs = await authentik.ListKeypairsAsync(overrideUrl, overrideApiKey);
|
||||
|
||||
// Populate authorization flows (designation = "authorization")
|
||||
AuthentikAuthorizationFlows.Clear();
|
||||
foreach (var f in flows.Where(f => f.Designation == "authorization"))
|
||||
AuthentikAuthorizationFlows.Add(f);
|
||||
|
||||
// Populate invalidation flows (designation = "invalidation")
|
||||
AuthentikInvalidationFlows.Clear();
|
||||
foreach (var f in flows.Where(f => f.Designation == "invalidation"))
|
||||
AuthentikInvalidationFlows.Add(f);
|
||||
|
||||
// Populate keypairs
|
||||
AuthentikKeypairs.Clear();
|
||||
// Add a "None" option
|
||||
AuthentikKeypairs.Add(new AuthentikKeypairItem { Pk = "", Name = "(none)" });
|
||||
foreach (var k in keypairs)
|
||||
AuthentikKeypairs.Add(k);
|
||||
|
||||
// Select items matching saved slugs
|
||||
SelectedAuthorizationFlow = AuthentikAuthorizationFlows
|
||||
.FirstOrDefault(f => string.Equals(f.Slug, AuthentikAuthorizationFlowSlug, StringComparison.OrdinalIgnoreCase))
|
||||
?? AuthentikAuthorizationFlows.FirstOrDefault(f => f.Slug == "default-provider-authorization-implicit-consent")
|
||||
?? AuthentikAuthorizationFlows.FirstOrDefault();
|
||||
|
||||
SelectedInvalidationFlow = AuthentikInvalidationFlows
|
||||
.FirstOrDefault(f => string.Equals(f.Slug, AuthentikInvalidationFlowSlug, StringComparison.OrdinalIgnoreCase))
|
||||
?? AuthentikInvalidationFlows.FirstOrDefault(f => f.Slug == "default-provider-invalidation-flow")
|
||||
?? AuthentikInvalidationFlows.FirstOrDefault();
|
||||
|
||||
SelectedSigningKeypair = string.IsNullOrWhiteSpace(AuthentikSigningKeypairId)
|
||||
? AuthentikKeypairs.First() // "(none)"
|
||||
: AuthentikKeypairs.FirstOrDefault(k => k.Pk == AuthentikSigningKeypairId)
|
||||
?? AuthentikKeypairs.First();
|
||||
|
||||
// Update slug fields to match selection
|
||||
if (SelectedAuthorizationFlow != null)
|
||||
AuthentikAuthorizationFlowSlug = SelectedAuthorizationFlow.Slug;
|
||||
if (SelectedInvalidationFlow != null)
|
||||
AuthentikInvalidationFlowSlug = SelectedInvalidationFlow.Slug;
|
||||
if (SelectedSigningKeypair != null)
|
||||
AuthentikSigningKeypairId = SelectedSigningKeypair.Pk;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task TestXiboBootstrapAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(XiboBootstrapClientId) || string.IsNullOrWhiteSpace(XiboBootstrapClientSecret))
|
||||
{
|
||||
StatusMessage = "Xibo Bootstrap Client ID and Secret are required.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = "Testing Xibo bootstrap credentials...";
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var xibo = scope.ServiceProvider.GetRequiredService<XiboApiService>();
|
||||
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
var cmsServerTemplate = await svc.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com");
|
||||
// Use a placeholder URL — user must configure a live instance for full test
|
||||
StatusMessage = "Xibo bootstrap credentials saved. Connect test requires a live instance URL — use 'Details' on an instance to verify.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task TestMySqlConnectionAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(MySqlHost) || string.IsNullOrWhiteSpace(MySqlAdminUser))
|
||||
{
|
||||
StatusMessage = "MySQL Host and Admin User are required for connection test.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
StatusMessage = "Testing MySQL connection...";
|
||||
try
|
||||
{
|
||||
if (!int.TryParse(MySqlPort, out var port))
|
||||
port = 3306;
|
||||
|
||||
var docker = _services.GetRequiredService<IDockerCliService>();
|
||||
var (connection, tunnel) = await docker.OpenMySqlConnectionAsync(
|
||||
MySqlHost, port, MySqlAdminUser, MySqlAdminPassword);
|
||||
await using var _ = connection;
|
||||
using var __ = tunnel;
|
||||
|
||||
await using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = "SELECT 1";
|
||||
await cmd.ExecuteScalarAsync();
|
||||
|
||||
StatusMessage = $"MySQL connection successful ({MySqlHost}:{port} via SSH tunnel).";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"MySQL connection failed: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists Bitwarden bootstrap credentials to appsettings.json so they survive restarts.
|
||||
/// </summary>
|
||||
private async Task SaveBitwardenConfigToFileAsync()
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
|
||||
var json = await File.ReadAllTextAsync(path);
|
||||
var doc = JsonNode.Parse(json, documentOptions: new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip })!;
|
||||
|
||||
var bw = doc["Bitwarden"]?.AsObject();
|
||||
if (bw == null)
|
||||
{
|
||||
bw = new JsonObject();
|
||||
doc.AsObject()["Bitwarden"] = bw;
|
||||
}
|
||||
|
||||
bw["IdentityUrl"] = BitwardenIdentityUrl;
|
||||
bw["ApiUrl"] = BitwardenApiUrl;
|
||||
bw["AccessToken"] = BitwardenAccessToken;
|
||||
bw["OrganizationId"] = BitwardenOrganizationId;
|
||||
bw["ProjectId"] = BitwardenProjectId;
|
||||
bw["InstanceProjectId"] = BitwardenInstanceProjectId;
|
||||
|
||||
var options = new JsonSerializerOptions { WriteIndented = true };
|
||||
await File.WriteAllTextAsync(path, doc.ToJsonString(options));
|
||||
}
|
||||
|
||||
private static string? NullIfEmpty(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="OTSSignsOrchestrator.Desktop.Views.ConfirmationDialog"
|
||||
Title="Confirm"
|
||||
Width="420" Height="200"
|
||||
MinWidth="320" MinHeight="160"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
CanResize="False"
|
||||
SizeToContent="Height">
|
||||
|
||||
<DockPanel Margin="24">
|
||||
<!-- Buttons -->
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal"
|
||||
HorizontalAlignment="Right" Spacing="10" Margin="0,16,0,0">
|
||||
<Button Content="Cancel" Name="CancelButton" Width="90" />
|
||||
<Button Content="Confirm" Name="ConfirmButton" Classes="accent" Width="90" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Message -->
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Name="TitleText" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource AccentBrush}" />
|
||||
<TextBlock Name="MessageText" FontSize="13" TextWrapping="Wrap"
|
||||
Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -1,50 +0,0 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Views;
|
||||
|
||||
/// <summary>
|
||||
/// A simple Yes/No confirmation dialog that can be shown modally.
|
||||
/// Use <see cref="ShowAsync"/> for a convenient one-liner.
|
||||
/// </summary>
|
||||
public partial class ConfirmationDialog : Window
|
||||
{
|
||||
public bool Result { get; private set; }
|
||||
|
||||
public ConfirmationDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public ConfirmationDialog(string title, string message) : this()
|
||||
{
|
||||
TitleText.Text = title;
|
||||
MessageText.Text = message;
|
||||
Title = title;
|
||||
|
||||
ConfirmButton.Click += OnConfirmClicked;
|
||||
CancelButton.Click += OnCancelClicked;
|
||||
}
|
||||
|
||||
private void OnConfirmClicked(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
Result = true;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void OnCancelClicked(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
Result = false;
|
||||
Close();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows a modal confirmation dialog and returns true if the user confirmed.
|
||||
/// </summary>
|
||||
public static async Task<bool> ShowAsync(Window owner, string title, string message)
|
||||
{
|
||||
var dialog = new ConfirmationDialog(title, message);
|
||||
await dialog.ShowDialog(owner);
|
||||
return dialog.Result;
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
|
||||
x:Class="OTSSignsOrchestrator.Desktop.Views.CreateInstanceView"
|
||||
x:DataType="vm:CreateInstanceViewModel">
|
||||
|
||||
<ScrollViewer>
|
||||
<Grid ColumnDefinitions="1*,20,1*" Margin="0,0,8,16">
|
||||
|
||||
<!-- ══ LEFT COLUMN — inputs ══ -->
|
||||
<StackPanel Grid.Column="0" Spacing="10">
|
||||
<TextBlock Text="Create New Instance" Classes="pageTitle" />
|
||||
<TextBlock Text="Deploy a new CMS stack to a Docker Swarm host" Classes="pageSubtitle" />
|
||||
|
||||
<!-- SSH Host -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Target Host" FontSize="13" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource AccentBrush}" />
|
||||
<ComboBox ItemsSource="{Binding AvailableHosts}"
|
||||
SelectedItem="{Binding SelectedSshHost}"
|
||||
PlaceholderText="Select SSH Host..."
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Core fields -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Customer Details" FontSize="13" FontWeight="SemiBold"
|
||||
Foreground="#60A5FA" />
|
||||
|
||||
<TextBlock Text="Customer Name" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding CustomerName}" Watermark="e.g. Acme Corp" />
|
||||
|
||||
<TextBlock Text="Abbreviation (3 letters)" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding CustomerAbbrev}"
|
||||
Watermark="e.g. acm"
|
||||
MaxLength="3" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Pangolin / Newt (optional) -->
|
||||
<Expander Header="Pangolin / Newt credentials (optional)">
|
||||
<Border Classes="card" Margin="0,8,0,0">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Newt ID" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding NewtId}" Watermark="(from Pangolin dashboard)" />
|
||||
|
||||
<TextBlock Text="Newt Secret" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding NewtSecret}" PasswordChar="●" Watermark="(from Pangolin dashboard)" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Expander>
|
||||
|
||||
<!-- NFS volume settings -->
|
||||
<Expander Header="NFS volume settings">
|
||||
<Border Classes="card" Margin="0,8,0,0">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="NFS Server" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding NfsServer}" Watermark="e.g. 192.168.1.100" />
|
||||
|
||||
<TextBlock Text="Export Path" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding NfsExport}" Watermark="e.g. /srv/nfs" />
|
||||
|
||||
<TextBlock Text="Export Folder (optional)" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding NfsExportFolder}" Watermark="e.g. ots_cms (leave empty for export root)" />
|
||||
|
||||
<TextBlock Text="Extra Mount Options" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding NfsExtraOptions}" Watermark="Additional options after nfsvers=4,proto=tcp" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Expander>
|
||||
|
||||
<!-- Advanced options -->
|
||||
<Expander Header="Advanced options">
|
||||
<Border Classes="card" Margin="0,8,0,0">
|
||||
<StackPanel Spacing="8">
|
||||
<CheckBox IsChecked="{Binding PurgeStaleVolumes}">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Text="Purge stale volumes before deploying" FontSize="12" />
|
||||
<TextBlock Text="Removes existing Docker volumes for this stack so fresh volumes are created. Only needed if volumes were created with wrong settings." FontSize="11" Foreground="{StaticResource TextMutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</CheckBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Expander>
|
||||
|
||||
<!-- Deploy button + progress -->
|
||||
<Button Content="Deploy Instance"
|
||||
Classes="accent"
|
||||
Command="{Binding DeployCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
Padding="14,10" FontSize="14"
|
||||
Margin="0,8,0,0" />
|
||||
|
||||
<!-- Progress bar -->
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0"
|
||||
IsVisible="{Binding IsBusy}">
|
||||
<ProgressBar Value="{Binding ProgressPercent}"
|
||||
Maximum="100" Height="6"
|
||||
CornerRadius="3"
|
||||
Foreground="{StaticResource AccentBrush}" />
|
||||
<TextBlock Grid.Column="1" Text="{Binding ProgressStep}"
|
||||
FontSize="11" Foreground="{StaticResource TextMutedBrush}"
|
||||
Margin="10,0,0,0" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="{Binding StatusMessage}" Classes="status"
|
||||
Margin="0,4,0,0" TextWrapping="Wrap" />
|
||||
|
||||
<!-- Deploy output -->
|
||||
<TextBox Text="{Binding DeployOutput}" IsReadOnly="True"
|
||||
AcceptsReturn="True" MaxHeight="260"
|
||||
FontFamily="Cascadia Mono, Consolas, monospace" FontSize="11"
|
||||
IsVisible="{Binding DeployOutput.Length}"
|
||||
CornerRadius="8" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- ══ RIGHT COLUMN — tabbed preview ══ -->
|
||||
<TabControl Grid.Column="2" VerticalAlignment="Top">
|
||||
|
||||
<!-- Tab 1: Resource preview -->
|
||||
<TabItem Header="Resource Preview">
|
||||
<Border Classes="card" Margin="0,8,0,0">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Resource Preview" FontSize="14" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource AccentBrush}" Margin="0,0,0,10" />
|
||||
|
||||
<TextBlock Text="Stack" FontSize="11" Foreground="{StaticResource TextMutedBrush}" />
|
||||
<TextBlock Text="{Binding PreviewStackName}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#60A5FA" Margin="0,0,0,8" />
|
||||
|
||||
<TextBlock Text="Services" FontSize="11" Foreground="{StaticResource TextMutedBrush}" />
|
||||
<TextBlock Text="{Binding PreviewServiceWeb}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#4ADE80" />
|
||||
<TextBlock Text="{Binding PreviewServiceCache}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#4ADE80" />
|
||||
<TextBlock Text="{Binding PreviewServiceChart}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#4ADE80" />
|
||||
<TextBlock Text="{Binding PreviewServiceNewt}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#4ADE80" Margin="0,0,0,8" />
|
||||
|
||||
<TextBlock Text="Network" FontSize="11" Foreground="{StaticResource TextMutedBrush}" />
|
||||
<TextBlock Text="{Binding PreviewNetwork}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#2DD4BF" Margin="0,0,0,8" />
|
||||
|
||||
<TextBlock Text="NFS Volumes" FontSize="11" Foreground="{StaticResource TextMutedBrush}" />
|
||||
<TextBlock Text="{Binding PreviewVolCustom}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#F472B6" />
|
||||
<TextBlock Text="{Binding PreviewVolBackup}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#F472B6" />
|
||||
<TextBlock Text="{Binding PreviewVolLibrary}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#F472B6" />
|
||||
<TextBlock Text="{Binding PreviewVolUserscripts}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#F472B6" />
|
||||
<TextBlock Text="{Binding PreviewVolCaCerts}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#F472B6" Margin="0,0,0,8" />
|
||||
|
||||
<TextBlock Text="Docker Secrets" FontSize="11" Foreground="{StaticResource TextMutedBrush}" />
|
||||
<TextBlock Text="{Binding PreviewSecret}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#FBBF24" />
|
||||
<TextBlock Text="{Binding PreviewSecretUser}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#FBBF24" />
|
||||
<TextBlock Text="{Binding PreviewSecretHost}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#FBBF24" />
|
||||
<TextBlock Text="{Binding PreviewSecretPort}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#FBBF24" Margin="0,0,0,8" />
|
||||
|
||||
<TextBlock Text="MySQL Database" FontSize="11" Foreground="{StaticResource TextMutedBrush}" />
|
||||
<TextBlock Text="{Binding PreviewMySqlDb}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#C084FC" />
|
||||
<TextBlock Text="{Binding PreviewMySqlUser}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#C084FC" Margin="0,0,0,8" />
|
||||
|
||||
<TextBlock Text="CMS URL" FontSize="11" Foreground="{StaticResource TextMutedBrush}" />
|
||||
<TextBlock Text="{Binding PreviewCmsUrl}" FontFamily="Cascadia Mono, Consolas, monospace" FontSize="12" Foreground="#2DD4BF" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</TabItem>
|
||||
|
||||
<!-- Tab 2: Rendered compose YML -->
|
||||
<TabItem Header="Compose YML">
|
||||
<Border Classes="card" Margin="0,8,0,0">
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
|
||||
<!-- Load button -->
|
||||
<Button Grid.Row="0"
|
||||
Content="Load / Refresh YML"
|
||||
Command="{Binding LoadYmlPreviewCommand}"
|
||||
IsEnabled="{Binding !IsLoadingYml}"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
Padding="10,7"
|
||||
Margin="0,0,0,10" />
|
||||
|
||||
<!-- YML text box -->
|
||||
<TextBox Grid.Row="1"
|
||||
Text="{Binding PreviewYml}"
|
||||
IsReadOnly="True"
|
||||
AcceptsReturn="True"
|
||||
FontFamily="Cascadia Mono, Consolas, monospace"
|
||||
FontSize="11"
|
||||
MinHeight="320"
|
||||
CornerRadius="6"
|
||||
Watermark="Click 'Load / Refresh YML' to preview the rendered compose file..."
|
||||
TextWrapping="NoWrap" />
|
||||
|
||||
<!-- Copy button -->
|
||||
<Button Grid.Row="2"
|
||||
Content="Copy to Clipboard"
|
||||
Command="{Binding CopyYmlCommand}"
|
||||
IsEnabled="{Binding HasPreviewYml}"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
Padding="10,7"
|
||||
Margin="0,10,0,0" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</TabItem>
|
||||
|
||||
</TabControl>
|
||||
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Views;
|
||||
|
||||
public partial class CreateInstanceView : UserControl
|
||||
{
|
||||
public CreateInstanceView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -1,11 +0,0 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Views;
|
||||
|
||||
public partial class HostsView : UserControl
|
||||
{
|
||||
public HostsView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
|
||||
xmlns:dto="using:OTSSignsOrchestrator.Core.Models.DTOs"
|
||||
xmlns:svc="using:OTSSignsOrchestrator.Core.Services"
|
||||
x:Class="OTSSignsOrchestrator.Desktop.Views.InstanceDetailsWindow"
|
||||
x:DataType="vm:InstanceDetailsViewModel"
|
||||
Title="Instance Details"
|
||||
Width="620" Height="860"
|
||||
MinWidth="520" MinHeight="700"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
CanResize="True">
|
||||
|
||||
<DockPanel Margin="24">
|
||||
|
||||
<!-- Header -->
|
||||
<StackPanel DockPanel.Dock="Top" Margin="0,0,0,16">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Text="{Binding StackName}" FontSize="22" FontWeight="Bold"
|
||||
Foreground="{StaticResource AccentBrush}" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding HostLabel, StringFormat='Host: {0}'}"
|
||||
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,2,0,0" />
|
||||
<TextBlock Text="{Binding InstanceUrl}"
|
||||
FontSize="11" Foreground="{StaticResource TextMutedBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Status bar -->
|
||||
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Classes="status"
|
||||
Margin="0,12,0,0" TextWrapping="Wrap" />
|
||||
|
||||
<!-- Main scrollable content -->
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="16">
|
||||
|
||||
<!-- ═══ Pending Setup Banner ═══ -->
|
||||
<Border IsVisible="{Binding IsPendingSetup}"
|
||||
Background="#1F2A1A" BorderBrush="#4ADE80" BorderThickness="1"
|
||||
CornerRadius="8" Padding="14,10">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Text="⚙" FontSize="18" VerticalAlignment="Center" Foreground="#4ADE80" />
|
||||
<StackPanel>
|
||||
<TextBlock Text="Pending Setup" FontSize="14" FontWeight="SemiBold"
|
||||
Foreground="#4ADE80" />
|
||||
<TextBlock FontSize="12" Foreground="{StaticResource TextSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
Text="Enter your Xibo OAuth credentials below to complete instance initialisation." />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ OTS Admin Account ═══ -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||
<Border Width="4" Height="20" CornerRadius="2" Background="#F97316" />
|
||||
<TextBlock Text="OTS Admin Account" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#F97316" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="Username" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBox Grid.Column="0" Text="{Binding AdminUsername}" IsReadOnly="True" />
|
||||
<Button Grid.Column="1" Content="Copy" Margin="4,0,0,0"
|
||||
Command="{Binding CopyAdminPasswordCommand}" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="Password" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" Margin="0,4,0,0" />
|
||||
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
|
||||
<TextBox Grid.Column="0" Text="{Binding AdminPasswordDisplay}" IsReadOnly="True"
|
||||
FontFamily="Consolas,monospace" />
|
||||
<Button Grid.Column="1" Content="Show" Margin="4,0,0,0"
|
||||
IsVisible="{Binding !AdminPasswordVisible}"
|
||||
Command="{Binding ToggleAdminPasswordVisibilityCommand}" />
|
||||
<Button Grid.Column="2" Content="Hide" Margin="4,0,0,0"
|
||||
IsVisible="{Binding AdminPasswordVisible}"
|
||||
Command="{Binding ToggleAdminPasswordVisibilityCommand}" />
|
||||
<Button Grid.Column="3" Content="Copy" Margin="4,0,0,0"
|
||||
Command="{Binding CopyAdminPasswordCommand}" />
|
||||
</Grid>
|
||||
|
||||
<Button Content="Rotate Admin Password"
|
||||
Command="{Binding RotateAdminPasswordCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
Classes="accent"
|
||||
Margin="0,6,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ Database Credentials ═══ -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||
<Border Width="4" Height="20" CornerRadius="2" Background="#4ADE80" />
|
||||
<TextBlock Text="Database Credentials" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#4ADE80" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="MySQL Username" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<Grid ColumnDefinitions="*">
|
||||
<TextBox Text="{Binding DbUsername}" IsReadOnly="True" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="MySQL Password" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" Margin="0,4,0,0" />
|
||||
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
|
||||
<TextBox Grid.Column="0" Text="{Binding DbPasswordDisplay}" IsReadOnly="True"
|
||||
FontFamily="Consolas,monospace" />
|
||||
<Button Grid.Column="1" Content="Show" Margin="4,0,0,0"
|
||||
IsVisible="{Binding !DbPasswordVisible}"
|
||||
Command="{Binding ToggleDbPasswordVisibilityCommand}" />
|
||||
<Button Grid.Column="2" Content="Hide" Margin="4,0,0,0"
|
||||
IsVisible="{Binding DbPasswordVisible}"
|
||||
Command="{Binding ToggleDbPasswordVisibilityCommand}" />
|
||||
<Button Grid.Column="3" Content="Copy" Margin="4,0,0,0"
|
||||
Command="{Binding CopyDbPasswordCommand}" />
|
||||
</Grid>
|
||||
|
||||
<Button Content="Rotate DB Password"
|
||||
Command="{Binding RotateDbPasswordCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
Margin="0,6,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ Xibo OAuth2 Application ═══ -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||
<Border Width="4" Height="20" CornerRadius="2" Background="#60A5FA" />
|
||||
<TextBlock Text="OTS OAuth2 Application" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#60A5FA" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="Client credentials used by the OTS orchestrator for Xibo API access."
|
||||
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<!-- ── Pending: editable credential input ── -->
|
||||
<StackPanel Spacing="8" IsVisible="{Binding IsPendingSetup}">
|
||||
<TextBlock FontSize="12" Foreground="{StaticResource TextSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
Text="Log into the Xibo CMS as xibo_admin (password: password), go to Administration → Applications, create a client_credentials app, then paste the credentials here." />
|
||||
|
||||
<TextBlock Text="Client ID" FontSize="12"
|
||||
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,4,0,0" />
|
||||
<TextBox Text="{Binding InitClientId}" Watermark="OAuth2 Client ID" />
|
||||
|
||||
<TextBlock Text="Client Secret" FontSize="12"
|
||||
Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding InitClientSecret}" PasswordChar="●"
|
||||
Watermark="(paste from Xibo Applications page)" />
|
||||
|
||||
<Button Content="Initialize Instance"
|
||||
Command="{Binding InitializeCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
Classes="accent"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
Padding="14,10" FontSize="14"
|
||||
Margin="0,8,0,0" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- ── Initialized: read-only display ── -->
|
||||
<StackPanel Spacing="8" IsVisible="{Binding !IsPendingSetup}">
|
||||
<TextBlock Text="Client ID" FontSize="12"
|
||||
Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBox Grid.Column="0" Text="{Binding OAuthClientId}" IsReadOnly="True"
|
||||
FontFamily="Consolas,monospace" />
|
||||
<Button Grid.Column="1" Content="Copy" Margin="4,0,0,0"
|
||||
Command="{Binding CopyOAuthClientIdCommand}" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="Client Secret" FontSize="12"
|
||||
Foreground="{StaticResource TextSecondaryBrush}" Margin="0,4,0,0" />
|
||||
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
|
||||
<TextBox Grid.Column="0" Text="{Binding OAuthSecretDisplay}" IsReadOnly="True"
|
||||
FontFamily="Consolas,monospace" />
|
||||
<Button Grid.Column="1" Content="Show" Margin="4,0,0,0"
|
||||
IsVisible="{Binding !OAuthSecretVisible}"
|
||||
Command="{Binding ToggleOAuthSecretVisibilityCommand}" />
|
||||
<Button Grid.Column="2" Content="Hide" Margin="4,0,0,0"
|
||||
IsVisible="{Binding OAuthSecretVisible}"
|
||||
Command="{Binding ToggleOAuthSecretVisibilityCommand}" />
|
||||
<Button Grid.Column="3" Content="Copy" Margin="4,0,0,0"
|
||||
Command="{Binding CopyOAuthSecretCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ Stack Services ═══ -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||
<Border Width="4" Height="20" CornerRadius="2" Background="#A78BFA" />
|
||||
<TextBlock Text="Stack Services" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#A78BFA" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="Force-restart individual services within this stack."
|
||||
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<TextBlock Text="Loading services..." FontSize="12"
|
||||
Foreground="{StaticResource TextMutedBrush}"
|
||||
IsVisible="{Binding IsLoadingServices}" />
|
||||
|
||||
<!-- Services list -->
|
||||
<ItemsControl ItemsSource="{Binding StackServices}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="svc:ServiceInfo">
|
||||
<Border Background="#232336" CornerRadius="6" Padding="12,10" Margin="0,3"
|
||||
BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="1">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="3">
|
||||
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="13" />
|
||||
<TextBlock Text="{Binding Image}" FontSize="11"
|
||||
Foreground="{StaticResource TextMutedBrush}" />
|
||||
<TextBlock Text="{Binding Replicas, StringFormat='Replicas: {0}'}"
|
||||
FontSize="11" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Content="Restart"
|
||||
Command="{Binding $parent[ItemsControl].((vm:InstanceDetailsViewModel)DataContext).RestartServiceCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
IsEnabled="{Binding $parent[ItemsControl].((vm:InstanceDetailsViewModel)DataContext).IsBusy, Converter={x:Static BoolConverters.Not}}"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="12" Padding="10,6"
|
||||
ToolTip.Tip="Force-restart this service" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -1,22 +0,0 @@
|
||||
using Avalonia.Controls;
|
||||
using OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Views;
|
||||
|
||||
public partial class InstanceDetailsWindow : Window
|
||||
{
|
||||
public InstanceDetailsWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
}
|
||||
|
||||
private void OnDataContextChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (DataContext is InstanceDetailsViewModel vm)
|
||||
{
|
||||
vm.ConfirmAsync = async (title, message) =>
|
||||
await ConfirmationDialog.ShowAsync(this, title, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
|
||||
xmlns:dto="using:OTSSignsOrchestrator.Core.Models.DTOs"
|
||||
xmlns:svc="using:OTSSignsOrchestrator.Core.Services"
|
||||
x:Class="OTSSignsOrchestrator.Desktop.Views.InstancesView"
|
||||
x:DataType="vm:InstancesViewModel">
|
||||
|
||||
<DockPanel>
|
||||
<!-- Page header -->
|
||||
<StackPanel DockPanel.Dock="Top" Margin="0,0,0,4">
|
||||
<TextBlock Text="Instances" Classes="pageTitle" />
|
||||
<TextBlock Text="View and manage deployed CMS stacks" Classes="pageSubtitle" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<Border DockPanel.Dock="Top" Classes="toolbar" Margin="0,0,0,16">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Refresh" Command="{Binding LoadInstancesCommand}" />
|
||||
<Button Content="Details" Classes="accent" Command="{Binding OpenDetailsCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="View credentials and manage this instance." />
|
||||
<Button Content="Inspect" Command="{Binding InspectInstanceCommand}" />
|
||||
<Button Content="Restart Stack" Command="{Binding RestartStackCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="Force-restart all services in the selected stack." />
|
||||
<Button Content="Delete" Classes="danger" Command="{Binding DeleteInstanceCommand}" />
|
||||
<Border Width="1" Background="{StaticResource BorderSubtleBrush}" Margin="4,2" />
|
||||
<Button Content="Rotate DB Password" Command="{Binding RotateMySqlPasswordCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="Generate a new MySQL password, update the Docker secret, and redeploy the stack." />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||
<TextBox Text="{Binding FilterText}" Watermark="Filter by name..." Width="220" />
|
||||
<Button Content="Search" Command="{Binding LoadInstancesCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Status -->
|
||||
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Classes="status"
|
||||
Margin="0,10,0,0" />
|
||||
|
||||
<!-- Main content: split into upper (grid + services) and lower (logs) -->
|
||||
<Grid RowDefinitions="*,Auto,Auto">
|
||||
|
||||
<!-- Upper area: instance list + services side panel -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
|
||||
|
||||
<!-- Instance list -->
|
||||
<DataGrid Grid.Column="0"
|
||||
ItemsSource="{Binding Instances}"
|
||||
SelectedItem="{Binding SelectedInstance}"
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="True"
|
||||
GridLinesVisibility="Horizontal"
|
||||
CanUserResizeColumns="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Stack" Binding="{Binding StackName}" Width="150" />
|
||||
<DataGridTextColumn Header="Abbrev" Binding="{Binding CustomerAbbrev}" Width="70" />
|
||||
<DataGridTextColumn Header="Services" Binding="{Binding ServiceCount}" Width="70" />
|
||||
<DataGridTextColumn Header="Host" Binding="{Binding HostLabel}" Width="150" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
|
||||
<!-- Services panel (shown when inspecting) -->
|
||||
<Border Grid.Column="1" Width="360"
|
||||
IsVisible="{Binding SelectedServices.Count}"
|
||||
Classes="card" Margin="16,0,0,0">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="Stack Services" Classes="sectionTitle"
|
||||
Foreground="{StaticResource AccentBrush}" Margin="0,0,0,10" />
|
||||
<ItemsControl ItemsSource="{Binding SelectedServices}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="svc:ServiceInfo">
|
||||
<Border Background="#232336" CornerRadius="6" Padding="12,10" Margin="0,3"
|
||||
BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="1">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="3">
|
||||
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="13" />
|
||||
<TextBlock Text="{Binding Image}" FontSize="11"
|
||||
Foreground="{StaticResource TextMutedBrush}" />
|
||||
<TextBlock Text="{Binding Replicas, StringFormat='Replicas: {0}'}"
|
||||
FontSize="11" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Content="Restart"
|
||||
Command="{Binding $parent[ItemsControl].((vm:InstancesViewModel)DataContext).RestartServiceCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
IsEnabled="{Binding $parent[ItemsControl].((vm:InstancesViewModel)DataContext).IsBusy, Converter={x:Static BoolConverters.Not}}"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="12" Padding="10,6"
|
||||
ToolTip.Tip="Force-restart this service" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- Grid splitter between instances and logs -->
|
||||
<GridSplitter Grid.Row="1" Height="6" HorizontalAlignment="Stretch"
|
||||
IsVisible="{Binding IsLogsPanelVisible}"
|
||||
Background="Transparent" />
|
||||
|
||||
<!-- Container Logs Panel -->
|
||||
<Border Grid.Row="2" Classes="card" Margin="0,4,0,0"
|
||||
IsVisible="{Binding IsLogsPanelVisible}"
|
||||
MinHeight="180" MaxHeight="400">
|
||||
<DockPanel>
|
||||
<!-- Logs toolbar -->
|
||||
<Grid DockPanel.Dock="Top" ColumnDefinitions="Auto,*,Auto" Margin="0,0,0,8">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="Container Logs" Classes="sectionTitle"
|
||||
Foreground="{StaticResource AccentBrush}" />
|
||||
<ComboBox ItemsSource="{Binding LogServiceFilter}"
|
||||
SelectedItem="{Binding SelectedLogService}"
|
||||
MinWidth="200" FontSize="12"
|
||||
ToolTip.Tip="Filter logs by service" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Column="1" Text="{Binding LogsStatusMessage}"
|
||||
FontSize="11" Foreground="{StaticResource TextMutedBrush}"
|
||||
VerticalAlignment="Center" HorizontalAlignment="Center" />
|
||||
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6"
|
||||
VerticalAlignment="Center">
|
||||
<Button Content="Refresh" Command="{Binding RefreshLogsCommand}"
|
||||
FontSize="11" Padding="8,4"
|
||||
ToolTip.Tip="Fetch latest logs" />
|
||||
<ToggleButton IsChecked="{Binding IsLogsAutoRefresh}"
|
||||
Content="Auto"
|
||||
FontSize="11" Padding="8,4"
|
||||
ToolTip.Tip="Toggle auto-refresh (every 5 seconds)"
|
||||
Command="{Binding ToggleLogsAutoRefreshCommand}" />
|
||||
<Button Content="✕" Command="{Binding CloseLogsPanelCommand}"
|
||||
FontSize="11" Padding="6,4"
|
||||
ToolTip.Tip="Close logs panel" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Log entries list -->
|
||||
<Border Background="#1a1a2e" CornerRadius="4" Padding="8"
|
||||
BorderBrush="{StaticResource BorderSubtleBrush}" BorderThickness="1">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Auto">
|
||||
<ItemsControl ItemsSource="{Binding LogEntries}"
|
||||
x:DataType="vm:InstancesViewModel">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="dto:ServiceLogEntry">
|
||||
<TextBlock Text="{Binding DisplayLine}"
|
||||
FontFamily="Cascadia Mono,Consolas,Menlo,monospace"
|
||||
FontSize="11" Padding="0,1"
|
||||
TextWrapping="NoWrap"
|
||||
Foreground="#cccccc" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
@@ -1,47 +0,0 @@
|
||||
using Avalonia.Controls;
|
||||
using OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Views;
|
||||
|
||||
public partial class InstancesView : UserControl
|
||||
{
|
||||
private InstancesViewModel? _vm;
|
||||
|
||||
public InstancesView()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
}
|
||||
|
||||
private void OnDataContextChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (_vm is not null)
|
||||
_vm.OpenDetailsRequested -= OnOpenDetailsRequested;
|
||||
|
||||
_vm = DataContext as InstancesViewModel;
|
||||
|
||||
if (_vm is not null)
|
||||
{
|
||||
_vm.OpenDetailsRequested += OnOpenDetailsRequested;
|
||||
_vm.ConfirmAsync = ShowConfirmationAsync;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> ShowConfirmationAsync(string title, string message)
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null) return false;
|
||||
return await ConfirmationDialog.ShowAsync(owner, title, message);
|
||||
}
|
||||
|
||||
private async void OnOpenDetailsRequested(InstanceDetailsViewModel detailsVm)
|
||||
{
|
||||
var window = new InstanceDetailsWindow { DataContext = detailsVm };
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is not null)
|
||||
await window.ShowDialog(owner);
|
||||
else
|
||||
window.Show();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -1,11 +0,0 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Views;
|
||||
|
||||
public partial class LogsView : UserControl
|
||||
{
|
||||
public LogsView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -1,11 +0,0 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Views;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -1,11 +0,0 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Views;
|
||||
|
||||
public partial class SecretsView : UserControl
|
||||
{
|
||||
public SecretsView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -1,407 +0,0 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
|
||||
x:Class="OTSSignsOrchestrator.Desktop.Views.SettingsView"
|
||||
x:DataType="vm:SettingsViewModel">
|
||||
|
||||
<DockPanel>
|
||||
<!-- Page header -->
|
||||
<StackPanel DockPanel.Dock="Top" Margin="0,0,0,4">
|
||||
<TextBlock Text="Settings" Classes="pageTitle" />
|
||||
<TextBlock Text="Global configuration for deployments and integrations" Classes="pageSubtitle" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Top toolbar -->
|
||||
<Border DockPanel.Dock="Top" Classes="toolbar" Margin="0,0,0,16">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button Content="Push to Bitwarden"
|
||||
Classes="accent"
|
||||
Command="{Binding PushToBitwardenCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
FontWeight="SemiBold" Padding="20,8" />
|
||||
<Button Content="Pull from Bitwarden"
|
||||
Command="{Binding PullFromBitwardenCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<TextBlock Text="{Binding StatusMessage}" Classes="status"
|
||||
VerticalAlignment="Center" Margin="6,0,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Scrollable settings content -->
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="16" Margin="0,0,16,16" MaxWidth="820">
|
||||
|
||||
<!-- ═══ Bitwarden Secrets Manager (Bootstrap — always shown first) ═══ -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||
<Border Width="4" Height="20" CornerRadius="2" Background="#818CF8" />
|
||||
<TextBlock Text="Bitwarden Secrets Manager" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#818CF8" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="All application settings are stored in Bitwarden. Configure these credentials first — they are saved to appsettings.json on disk."
|
||||
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<Grid ColumnDefinitions="1*,12,1*">
|
||||
<StackPanel Grid.Column="0" Spacing="4">
|
||||
<TextBlock Text="Identity URL" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding BitwardenIdentityUrl}"
|
||||
Watermark="https://identity.bitwarden.com" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="4">
|
||||
<TextBlock Text="API URL" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding BitwardenApiUrl}"
|
||||
Watermark="https://api.bitwarden.com" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="Machine Account Access Token" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding BitwardenAccessToken}" PasswordChar="●"
|
||||
Watermark="0.xxxxxxxx.yyyyyyy:zzzzzz" />
|
||||
|
||||
<TextBlock Text="Organization ID" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding BitwardenOrganizationId}"
|
||||
Watermark="00000000-0000-0000-0000-000000000000" />
|
||||
|
||||
<TextBlock Text="Project ID (required — config secrets are stored in this project)" FontSize="12"
|
||||
Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding BitwardenProjectId}"
|
||||
Watermark="00000000-0000-0000-0000-000000000000" />
|
||||
|
||||
<TextBlock Text="Instance Project ID (optional — instance secrets like DB passwords go here; falls back to Project ID if empty)" FontSize="12"
|
||||
Foreground="{StaticResource TextSecondaryBrush}" TextWrapping="Wrap" />
|
||||
<TextBox Text="{Binding BitwardenInstanceProjectId}"
|
||||
Watermark="00000000-0000-0000-0000-000000000000 (leave empty to use default project)" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" Margin="0,6,0,0">
|
||||
<Button Content="Save Bitwarden Config"
|
||||
Classes="accent"
|
||||
Command="{Binding SaveBitwardenLocalCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Content="Test Connection"
|
||||
Command="{Binding TestBitwardenConnectionCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ Remaining settings — disabled until Bitwarden is configured ═══ -->
|
||||
<StackPanel Spacing="16" IsEnabled="{Binding IsBitwardenConfigured}">
|
||||
|
||||
<!-- ═══ Git Repository ═══ -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||
<Border Width="4" Height="20" CornerRadius="2" Background="#60A5FA" />
|
||||
<TextBlock Text="Git Repository" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#60A5FA" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="Template repository cloned for each new instance."
|
||||
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6" />
|
||||
|
||||
<TextBlock Text="Repository URL" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding GitRepoUrl}"
|
||||
Watermark="https://github.com/org/template-repo.git" />
|
||||
|
||||
<TextBlock Text="Personal Access Token (PAT)" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding GitRepoPat}" PasswordChar="●"
|
||||
Watermark="ghp_xxxx..." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ MySQL Connection ═══ -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||
<Border Width="4" Height="20" CornerRadius="2" Background="#4ADE80" />
|
||||
<TextBlock Text="MySQL Connection" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#4ADE80" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="Admin credentials used to create databases and users for new instances."
|
||||
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6" />
|
||||
|
||||
<Grid ColumnDefinitions="3*,12,1*">
|
||||
<StackPanel Grid.Column="0" Spacing="4">
|
||||
<TextBlock Text="Host" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding MySqlHost}" Watermark="cms-sql.otshosting.app" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="4">
|
||||
<TextBlock Text="Port" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding MySqlPort}" Watermark="3306" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="Admin Username" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding MySqlAdminUser}" Watermark="root" />
|
||||
|
||||
<TextBlock Text="Admin Password" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding MySqlAdminPassword}" PasswordChar="●" />
|
||||
|
||||
<Button Content="Test MySQL Connection"
|
||||
Command="{Binding TestMySqlConnectionCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
Margin="0,6,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ SMTP Settings ═══ -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||
<Border Width="4" Height="20" CornerRadius="2" Background="#F472B6" />
|
||||
<TextBlock Text="SMTP Settings" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#F472B6" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="Email configuration applied to all CMS instances."
|
||||
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6" />
|
||||
|
||||
<TextBlock Text="SMTP Server (host:port)" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding SmtpServer}" Watermark="smtp.azurecomm.net:587" />
|
||||
|
||||
<TextBlock Text="SMTP Username" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding SmtpUsername}" Watermark="user@domain.com" />
|
||||
|
||||
<TextBlock Text="SMTP Password" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding SmtpPassword}" PasswordChar="●" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="20" Margin="0,4,0,0">
|
||||
<CheckBox Content="Use TLS" IsChecked="{Binding SmtpUseTls}" />
|
||||
<CheckBox Content="Use STARTTLS" IsChecked="{Binding SmtpUseStartTls}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="Rewrite Domain" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding SmtpRewriteDomain}" Watermark="ots-signs.com" />
|
||||
|
||||
<TextBlock Text="SMTP Hostname" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding SmtpHostname}" Watermark="demo.ots-signs.com" />
|
||||
|
||||
<TextBlock Text="From Line Override" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding SmtpFromLineOverride}" Watermark="NO" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ Pangolin / Newt ═══ -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||
<Border Width="4" Height="20" CornerRadius="2" Background="#FBBF24" />
|
||||
<TextBlock Text="Pangolin (Newt Tunnel)" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#FBBF24" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="Global Pangolin endpoint. Newt ID and Secret are configured per-instance."
|
||||
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6" />
|
||||
|
||||
<TextBlock Text="Pangolin Endpoint URL" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding PangolinEndpoint}" Watermark="https://app.pangolin.net" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ NFS Volumes ═══ -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||
<Border Width="4" Height="20" CornerRadius="2" Background="#C084FC" />
|
||||
<TextBlock Text="NFS Volumes" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#C084FC" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="Network share settings for Docker volumes. Volumes will be mounted via NFS."
|
||||
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6" />
|
||||
|
||||
<TextBlock Text="NFS Server (hostname/IP)" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding NfsServer}" Watermark="nas.local" />
|
||||
|
||||
<TextBlock Text="Export Path" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding NfsExport}" Watermark="/srv/nfs" />
|
||||
|
||||
<TextBlock Text="Export Folder (optional)" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding NfsExportFolder}" Watermark="ots_cms (leave empty for export root)" />
|
||||
|
||||
<TextBlock Text="Extra Mount Options" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding NfsOptions}" Watermark="Additional options after nfsvers=4,proto=tcp" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ Instance Defaults ═══ -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||
<Border Width="4" Height="20" CornerRadius="2" Background="#2DD4BF" />
|
||||
<TextBlock Text="Instance Defaults" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#2DD4BF" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="Default Docker images, naming templates, and PHP settings for new instances. Use {abbrev} as a placeholder for the customer abbreviation."
|
||||
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6" TextWrapping="Wrap" />
|
||||
|
||||
<!-- Sub-section: Docker Images -->
|
||||
<TextBlock Text="Docker Images" FontSize="13" FontWeight="SemiBold" Margin="0,8,0,4"
|
||||
Foreground="{StaticResource TextPrimaryBrush}" />
|
||||
|
||||
<TextBlock Text="CMS Image" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding DefaultCmsImage}"
|
||||
Watermark="ghcr.io/xibosignage/xibo-cms:release-4.2.3" />
|
||||
|
||||
<TextBlock Text="Newt Image" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding DefaultNewtImage}" Watermark="fosrl/newt" />
|
||||
|
||||
<TextBlock Text="Memcached Image" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding DefaultMemcachedImage}" Watermark="memcached:alpine" />
|
||||
|
||||
<TextBlock Text="QuickChart Image" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding DefaultQuickChartImage}" Watermark="ianw/quickchart" />
|
||||
|
||||
<!-- Sub-section: Naming Templates -->
|
||||
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,12,0,4" />
|
||||
<TextBlock Text="Naming Templates" FontSize="13" FontWeight="SemiBold" Margin="0,4,0,4"
|
||||
Foreground="{StaticResource TextPrimaryBrush}" />
|
||||
|
||||
<TextBlock Text="CMS Server Name Template" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding DefaultCmsServerNameTemplate}"
|
||||
Watermark="{}{abbrev}.ots-signs.com" />
|
||||
|
||||
<TextBlock Text="Theme Host Path Template" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding DefaultThemeHostPath}"
|
||||
Watermark="/cms/{abbrev}-cms-theme-custom" />
|
||||
|
||||
<TextBlock Text="MySQL Database Name Template" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding DefaultMySqlDbTemplate}" Watermark="{}{abbrev}_cms_db" />
|
||||
|
||||
<TextBlock Text="MySQL User Template" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding DefaultMySqlUserTemplate}" Watermark="{}{abbrev}_cms_user" />
|
||||
|
||||
<!-- Sub-section: PHP Settings -->
|
||||
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,12,0,4" />
|
||||
<TextBlock Text="PHP Settings" FontSize="13" FontWeight="SemiBold" Margin="0,4,0,4"
|
||||
Foreground="{StaticResource TextPrimaryBrush}" />
|
||||
|
||||
<Grid ColumnDefinitions="1*,12,1*,12,1*">
|
||||
<StackPanel Grid.Column="0" Spacing="4">
|
||||
<TextBlock Text="Post Max Size" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding DefaultPhpPostMaxSize}" Watermark="10G" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="4">
|
||||
<TextBlock Text="Upload Max Filesize" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding DefaultPhpUploadMaxFilesize}" Watermark="10G" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="4" Spacing="4">
|
||||
<TextBlock Text="Max Execution Time" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding DefaultPhpMaxExecutionTime}" Watermark="600" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ Xibo Bootstrap OAuth2 ═══ -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||
<Border Width="4" Height="20" CornerRadius="2" Background="#F97316" />
|
||||
<TextBlock Text="Xibo Bootstrap OAuth2" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#F97316" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="A pre-configured Xibo OAuth2 client_credentials application used for post-install setup (creating admin users, registering OTS app, setting theme). Create once in the Xibo admin panel of any instance."
|
||||
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Text="Bootstrap Client ID" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding XiboBootstrapClientId}"
|
||||
Watermark="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" />
|
||||
|
||||
<TextBlock Text="Bootstrap Client Secret" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding XiboBootstrapClientSecret}" PasswordChar="●" />
|
||||
|
||||
<Button Content="Save & Verify"
|
||||
Command="{Binding TestXiboBootstrapCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
Margin="0,6,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ Authentik (SAML IdP) ═══ -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||
<Border Width="4" Height="20" CornerRadius="2" Background="#FB923C" />
|
||||
<TextBlock Text="Authentik (SAML IdP)" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#FB923C" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="Authentik identity provider settings. A SAML application is automatically provisioned in Authentik for each new instance during post-init, and a settings-custom.php file is deployed."
|
||||
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Text="Authentik Base URL" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding AuthentikUrl}"
|
||||
Watermark="https://id.oribi-tech.com" />
|
||||
|
||||
<TextBlock Text="API Token" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding AuthentikApiKey}" PasswordChar="●"
|
||||
Watermark="Bearer token for /api/v3/" />
|
||||
|
||||
<!-- Save & Test button -->
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" Margin="0,8,0,0">
|
||||
<Button Content="Save & Test Connection"
|
||||
Classes="accent"
|
||||
Command="{Binding SaveAndTestAuthentikCommand}"
|
||||
IsEnabled="{Binding !IsAuthentikBusy}"
|
||||
FontWeight="SemiBold" Padding="16,8" />
|
||||
<Button Content="Refresh Dropdowns"
|
||||
Command="{Binding FetchAuthentikDropdownsCommand}"
|
||||
IsEnabled="{Binding !IsAuthentikBusy}"
|
||||
Padding="16,8" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding AuthentikStatusMessage}"
|
||||
FontSize="12" Foreground="{StaticResource TextSecondaryBrush}"
|
||||
TextWrapping="Wrap" Margin="0,2,0,0" />
|
||||
|
||||
<!-- Flow / Keypair dropdowns -->
|
||||
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,12,0,4" />
|
||||
<TextBlock Text="Flows & Keypairs" FontSize="13" FontWeight="SemiBold" Margin="0,4,0,4"
|
||||
Foreground="{StaticResource TextPrimaryBrush}" />
|
||||
<TextBlock Text="These are loaded from your Authentik instance. Save & Test to populate."
|
||||
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,4"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Text="Authorization Flow" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<ComboBox ItemsSource="{Binding AuthentikAuthorizationFlows}"
|
||||
SelectedItem="{Binding SelectedAuthorizationFlow}"
|
||||
HorizontalAlignment="Stretch"
|
||||
PlaceholderText="(save & test to load flows)">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SettingsViewModel">
|
||||
<TextBlock Text="{Binding}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<TextBlock Text="Invalidation Flow" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<ComboBox ItemsSource="{Binding AuthentikInvalidationFlows}"
|
||||
SelectedItem="{Binding SelectedInvalidationFlow}"
|
||||
HorizontalAlignment="Stretch"
|
||||
PlaceholderText="(save & test to load flows)">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SettingsViewModel">
|
||||
<TextBlock Text="{Binding}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<TextBlock Text="Signing Keypair" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<ComboBox ItemsSource="{Binding AuthentikKeypairs}"
|
||||
SelectedItem="{Binding SelectedSigningKeypair}"
|
||||
HorizontalAlignment="Stretch"
|
||||
PlaceholderText="(save & test to load keypairs)">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SettingsViewModel">
|
||||
<TextBlock Text="{Binding}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel> <!-- end of IsBitwardenConfigured wrapper -->
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
@@ -1,11 +0,0 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Views;
|
||||
|
||||
public partial class SettingsView : UserControl
|
||||
{
|
||||
public SettingsView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -1,53 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.EntityFrameworkCore": "Information"
|
||||
}
|
||||
},
|
||||
"FileLogging": {
|
||||
"Enabled": true,
|
||||
"Path": "logs",
|
||||
"RollingInterval": "Day",
|
||||
"RetentionDays": 7
|
||||
},
|
||||
"Git": {
|
||||
"CacheDir": ".template-cache"
|
||||
},
|
||||
"Docker": {
|
||||
"DefaultConstraints": [ "node.labels.xibo==true" ],
|
||||
"DeployTimeoutSeconds": 30,
|
||||
"ValidateBeforeDeploy": true
|
||||
},
|
||||
"Xibo": {
|
||||
"DefaultImages": {
|
||||
"Cms": "ghcr.io/xibosignage/xibo-cms:release-4.4.0",
|
||||
"Mysql": "mysql:8.4",
|
||||
"Memcached": "memcached:alpine",
|
||||
"QuickChart": "ianw/quickchart"
|
||||
},
|
||||
"TestConnectionTimeoutSeconds": 10
|
||||
},
|
||||
"Database": {
|
||||
"Provider": "Sqlite"
|
||||
},
|
||||
"Bitwarden": {
|
||||
"IdentityUrl": "https://identity.bitwarden.com",
|
||||
"ApiUrl": "https://api.bitwarden.com",
|
||||
"AccessToken": "",
|
||||
"OrganizationId": "",
|
||||
"ProjectId": "",
|
||||
"InstanceProjectId": ""
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"Default": "Data Source=otssigns-desktop.db"
|
||||
},
|
||||
"InstanceDefaults": {
|
||||
"CmsServerNameTemplate": "app.ots-signs.com",
|
||||
"ThemeHostPath": "/cms/ots-theme",
|
||||
"LibraryShareSubPath": "{abbrev}-cms-library",
|
||||
"MySqlDatabaseTemplate": "{abbrev}_cms_db",
|
||||
"MySqlUserTemplate": "{abbrev}_cms_user",
|
||||
"BaseHostHttpPort": 8080
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using OTSSignsOrchestrator.Server.Data;
|
||||
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||
|
||||
namespace OTSSignsOrchestrator.Server.Auth;
|
||||
|
||||
public class OperatorAuthService
|
||||
{
|
||||
private readonly OrchestratorDbContext _db;
|
||||
private readonly JwtOptions _jwt;
|
||||
private readonly ILogger<OperatorAuthService> _logger;
|
||||
|
||||
public OperatorAuthService(
|
||||
OrchestratorDbContext db,
|
||||
IOptions<JwtOptions> jwt,
|
||||
ILogger<OperatorAuthService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_jwt = jwt.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<(string Jwt, string RefreshToken)> LoginAsync(string email, string password)
|
||||
{
|
||||
var op = await _db.Operators.FirstOrDefaultAsync(
|
||||
o => o.Email == email.Trim().ToLowerInvariant());
|
||||
|
||||
if (op is null || !BCrypt.Net.BCrypt.Verify(password, op.PasswordHash))
|
||||
{
|
||||
_logger.LogWarning("Login failed for {Email}", email);
|
||||
throw new UnauthorizedAccessException("Invalid email or password.");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Operator {Email} logged in", op.Email);
|
||||
var jwt = GenerateJwt(op);
|
||||
var refresh = await CreateRefreshTokenAsync(op.Id);
|
||||
return (jwt, refresh);
|
||||
}
|
||||
|
||||
public async Task<string> RefreshAsync(string refreshToken)
|
||||
{
|
||||
var token = await _db.RefreshTokens
|
||||
.Include(r => r.Operator)
|
||||
.FirstOrDefaultAsync(r => r.Token == refreshToken);
|
||||
|
||||
if (token is null || token.RevokedAt is not null || token.ExpiresAt < DateTime.UtcNow)
|
||||
throw new UnauthorizedAccessException("Invalid or expired refresh token.");
|
||||
|
||||
// Revoke the used token (single-use rotation)
|
||||
token.RevokedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Refresh token used for operator {Email}", token.Operator.Email);
|
||||
return GenerateJwt(token.Operator);
|
||||
}
|
||||
|
||||
private string GenerateJwt(Operator op)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwt.Key));
|
||||
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, op.Id.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Email, op.Email),
|
||||
new Claim(ClaimTypes.Name, op.Email),
|
||||
new Claim(ClaimTypes.Role, op.Role.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: _jwt.Issuer,
|
||||
audience: _jwt.Audience,
|
||||
claims: claims,
|
||||
expires: DateTime.UtcNow.AddMinutes(15),
|
||||
signingCredentials: creds);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
private async Task<string> CreateRefreshTokenAsync(Guid operatorId)
|
||||
{
|
||||
var tokenValue = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
|
||||
|
||||
_db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OperatorId = operatorId,
|
||||
Token = tokenValue,
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(7),
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return tokenValue;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
namespace OTSSignsOrchestrator.Server.Data.Entities;
|
||||
|
||||
public enum OperatorRole
|
||||
{
|
||||
Admin,
|
||||
Viewer
|
||||
}
|
||||
|
||||
public class Operator
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string PasswordHash { get; set; } = string.Empty;
|
||||
public OperatorRole Role { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public ICollection<RefreshToken> RefreshTokens { get; set; } = [];
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace OTSSignsOrchestrator.Server.Data.Entities;
|
||||
|
||||
public class RefreshToken
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid OperatorId { get; set; }
|
||||
public string Token { get; set; } = string.Empty;
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public DateTime? RevokedAt { get; set; }
|
||||
|
||||
public Operator Operator { get; set; } = null!;
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
using Renci.SshNet;
|
||||
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||
|
||||
namespace OTSSignsOrchestrator.Server.Health.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies connectivity to the instance's MySQL database by running a simple query
|
||||
/// via SSH against the Docker Swarm host.
|
||||
/// </summary>
|
||||
public sealed class MySqlConnectHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ILogger<MySqlConnectHealthCheck> _logger;
|
||||
|
||||
public string CheckName => "MySqlConnect";
|
||||
public bool AutoRemediate => false;
|
||||
|
||||
public MySqlConnectHealthCheck(
|
||||
IServiceProvider services,
|
||||
ILogger<MySqlConnectHealthCheck> logger)
|
||||
{
|
||||
_services = services;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> RunAsync(Instance instance, CancellationToken ct)
|
||||
{
|
||||
var dbName = instance.MysqlDatabase;
|
||||
if (string.IsNullOrEmpty(dbName))
|
||||
return new HealthCheckResult(HealthStatus.Critical, "No MySQL database configured");
|
||||
|
||||
try
|
||||
{
|
||||
var settings = _services.GetRequiredService<Core.Services.SettingsService>();
|
||||
var sshInfo = await GetSwarmSshHostAsync(settings);
|
||||
var mysqlHost = await settings.GetAsync(Core.Services.SettingsService.MySqlHost, "localhost");
|
||||
var mysqlPort = await settings.GetAsync(Core.Services.SettingsService.MySqlPort, "3306");
|
||||
var mysqlUser = await settings.GetAsync(Core.Services.SettingsService.MySqlAdminUser, "root");
|
||||
var mysqlPass = await settings.GetAsync(Core.Services.SettingsService.MySqlAdminPassword, "");
|
||||
|
||||
using var sshClient = CreateSshClient(sshInfo);
|
||||
sshClient.Connect();
|
||||
try
|
||||
{
|
||||
// Simple connectivity test — SELECT 1 against the instance database
|
||||
var cmd = $"mysql -h {mysqlHost} -P {mysqlPort} -u {mysqlUser} " +
|
||||
$"-p'{mysqlPass}' -e 'SELECT 1' {dbName} 2>&1";
|
||||
var output = RunSshCommand(sshClient, cmd);
|
||||
|
||||
return new HealthCheckResult(HealthStatus.Healthy,
|
||||
$"MySQL connection to {dbName} successful");
|
||||
}
|
||||
finally
|
||||
{
|
||||
sshClient.Disconnect();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new HealthCheckResult(HealthStatus.Critical,
|
||||
$"MySQL connection failed for {dbName}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<SshConnectionInfo> GetSwarmSshHostAsync(Core.Services.SettingsService settings)
|
||||
{
|
||||
var host = await settings.GetAsync("Ssh.SwarmHost")
|
||||
?? throw new InvalidOperationException("SSH Swarm host not configured.");
|
||||
var portStr = await settings.GetAsync("Ssh.SwarmPort", "22");
|
||||
var user = await settings.GetAsync("Ssh.SwarmUser", "root");
|
||||
var keyPath = await settings.GetAsync("Ssh.SwarmKeyPath");
|
||||
var password = await settings.GetAsync("Ssh.SwarmPassword");
|
||||
if (!int.TryParse(portStr, out var port)) port = 22;
|
||||
return new SshConnectionInfo(host, port, user, keyPath, password);
|
||||
}
|
||||
|
||||
private static SshClient CreateSshClient(SshConnectionInfo info)
|
||||
{
|
||||
var authMethods = new List<AuthenticationMethod>();
|
||||
if (!string.IsNullOrEmpty(info.KeyPath))
|
||||
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(info.KeyPath)));
|
||||
if (!string.IsNullOrEmpty(info.Password))
|
||||
authMethods.Add(new PasswordAuthenticationMethod(info.Username, info.Password));
|
||||
if (authMethods.Count == 0)
|
||||
{
|
||||
var defaultKeyPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh", "id_rsa");
|
||||
if (File.Exists(defaultKeyPath))
|
||||
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(defaultKeyPath)));
|
||||
else
|
||||
throw new InvalidOperationException($"No SSH auth method for {info.Host}:{info.Port}.");
|
||||
}
|
||||
var connInfo = new Renci.SshNet.ConnectionInfo(info.Host, info.Port, info.Username, authMethods.ToArray());
|
||||
return new SshClient(connInfo);
|
||||
}
|
||||
|
||||
private static string RunSshCommand(SshClient client, string command)
|
||||
{
|
||||
using var cmd = client.RunCommand(command);
|
||||
if (cmd.ExitStatus != 0)
|
||||
throw new InvalidOperationException($"SSH command failed (exit {cmd.ExitStatus}): {cmd.Error}");
|
||||
return cmd.Result;
|
||||
}
|
||||
|
||||
internal sealed record SshConnectionInfo(string Host, int Port, string Username, string? KeyPath, string? Password);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
using Renci.SshNet;
|
||||
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||
|
||||
namespace OTSSignsOrchestrator.Server.Health.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies NFS paths for the instance are accessible by running <c>ls</c> via SSH.
|
||||
/// </summary>
|
||||
public sealed class NfsAccessHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ILogger<NfsAccessHealthCheck> _logger;
|
||||
|
||||
public string CheckName => "NfsAccess";
|
||||
public bool AutoRemediate => false;
|
||||
|
||||
public NfsAccessHealthCheck(
|
||||
IServiceProvider services,
|
||||
ILogger<NfsAccessHealthCheck> logger)
|
||||
{
|
||||
_services = services;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> RunAsync(Instance instance, CancellationToken ct)
|
||||
{
|
||||
var nfsPath = instance.NfsPath;
|
||||
if (string.IsNullOrEmpty(nfsPath))
|
||||
return new HealthCheckResult(HealthStatus.Critical, "No NFS path configured");
|
||||
|
||||
try
|
||||
{
|
||||
var settings = _services.GetRequiredService<Core.Services.SettingsService>();
|
||||
var sshInfo = await GetSwarmSshHostAsync(settings);
|
||||
var nfsServer = await settings.GetAsync(Core.Services.SettingsService.NfsServer);
|
||||
var nfsExport = await settings.GetAsync(Core.Services.SettingsService.NfsExport);
|
||||
|
||||
if (string.IsNullOrEmpty(nfsServer) || string.IsNullOrEmpty(nfsExport))
|
||||
return new HealthCheckResult(HealthStatus.Critical, "NFS server/export not configured");
|
||||
|
||||
using var sshClient = CreateSshClient(sshInfo);
|
||||
sshClient.Connect();
|
||||
try
|
||||
{
|
||||
// Mount temporarily and check the path is listable
|
||||
var mountPoint = $"/tmp/healthcheck-nfs-{Guid.NewGuid():N}";
|
||||
RunSshCommand(sshClient, $"sudo mkdir -p {mountPoint}");
|
||||
try
|
||||
{
|
||||
RunSshCommand(sshClient, $"sudo mount -t nfs4 {nfsServer}:{nfsExport} {mountPoint}");
|
||||
var output = RunSshCommand(sshClient, $"ls {mountPoint}/{nfsPath} 2>&1");
|
||||
|
||||
return new HealthCheckResult(HealthStatus.Healthy,
|
||||
$"NFS path accessible: {nfsPath}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
RunSshCommandAllowFailure(sshClient, $"sudo umount {mountPoint}");
|
||||
RunSshCommandAllowFailure(sshClient, $"sudo rmdir {mountPoint}");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
sshClient.Disconnect();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new HealthCheckResult(HealthStatus.Critical,
|
||||
$"NFS access check failed for {nfsPath}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<SshConnectionInfo> GetSwarmSshHostAsync(Core.Services.SettingsService settings)
|
||||
{
|
||||
var host = await settings.GetAsync("Ssh.SwarmHost")
|
||||
?? throw new InvalidOperationException("SSH Swarm host not configured.");
|
||||
var portStr = await settings.GetAsync("Ssh.SwarmPort", "22");
|
||||
var user = await settings.GetAsync("Ssh.SwarmUser", "root");
|
||||
var keyPath = await settings.GetAsync("Ssh.SwarmKeyPath");
|
||||
var password = await settings.GetAsync("Ssh.SwarmPassword");
|
||||
if (!int.TryParse(portStr, out var port)) port = 22;
|
||||
return new SshConnectionInfo(host, port, user, keyPath, password);
|
||||
}
|
||||
|
||||
private static SshClient CreateSshClient(SshConnectionInfo info)
|
||||
{
|
||||
var authMethods = new List<AuthenticationMethod>();
|
||||
if (!string.IsNullOrEmpty(info.KeyPath))
|
||||
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(info.KeyPath)));
|
||||
if (!string.IsNullOrEmpty(info.Password))
|
||||
authMethods.Add(new PasswordAuthenticationMethod(info.Username, info.Password));
|
||||
if (authMethods.Count == 0)
|
||||
{
|
||||
var defaultKeyPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh", "id_rsa");
|
||||
if (File.Exists(defaultKeyPath))
|
||||
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(defaultKeyPath)));
|
||||
else
|
||||
throw new InvalidOperationException($"No SSH auth method for {info.Host}:{info.Port}.");
|
||||
}
|
||||
var connInfo = new Renci.SshNet.ConnectionInfo(info.Host, info.Port, info.Username, authMethods.ToArray());
|
||||
return new SshClient(connInfo);
|
||||
}
|
||||
|
||||
private static string RunSshCommand(SshClient client, string command)
|
||||
{
|
||||
using var cmd = client.RunCommand(command);
|
||||
if (cmd.ExitStatus != 0)
|
||||
throw new InvalidOperationException($"SSH command failed (exit {cmd.ExitStatus}): {cmd.Error}");
|
||||
return cmd.Result;
|
||||
}
|
||||
|
||||
private static void RunSshCommandAllowFailure(SshClient client, string command)
|
||||
{
|
||||
using var cmd = client.RunCommand(command);
|
||||
// Intentionally ignore exit code — cleanup operations
|
||||
}
|
||||
|
||||
internal sealed record SshConnectionInfo(string Host, int Port, string Username, string? KeyPath, string? Password);
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
using Renci.SshNet;
|
||||
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||
|
||||
namespace OTSSignsOrchestrator.Server.Health.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the Docker stack is healthy by running <c>docker stack ps {stackName}</c>
|
||||
/// via SSH and checking that all services report Running state.
|
||||
/// </summary>
|
||||
public sealed class StackHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ILogger<StackHealthCheck> _logger;
|
||||
|
||||
public string CheckName => "StackHealth";
|
||||
public bool AutoRemediate => false;
|
||||
|
||||
public StackHealthCheck(
|
||||
IServiceProvider services,
|
||||
ILogger<StackHealthCheck> logger)
|
||||
{
|
||||
_services = services;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> RunAsync(Instance instance, CancellationToken ct)
|
||||
{
|
||||
var stackName = instance.DockerStackName;
|
||||
if (string.IsNullOrEmpty(stackName))
|
||||
return new HealthCheckResult(HealthStatus.Critical, "No Docker stack name configured");
|
||||
|
||||
try
|
||||
{
|
||||
var settings = _services.GetRequiredService<Core.Services.SettingsService>();
|
||||
var sshInfo = await GetSwarmSshHostAsync(settings);
|
||||
|
||||
using var sshClient = CreateSshClient(sshInfo);
|
||||
sshClient.Connect();
|
||||
|
||||
try
|
||||
{
|
||||
// Get task status for all services in the stack
|
||||
var output = RunSshCommand(sshClient,
|
||||
$"docker stack ps {stackName} --format '{{{{.Name}}}}|{{{{.CurrentState}}}}|{{{{.DesiredState}}}}'");
|
||||
|
||||
var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
var notRunning = new List<string>();
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var parts = line.Split('|');
|
||||
if (parts.Length < 3) continue;
|
||||
|
||||
var name = parts[0].Trim();
|
||||
var currentState = parts[1].Trim();
|
||||
var desiredState = parts[2].Trim();
|
||||
|
||||
// Only check tasks whose desired state is Running
|
||||
if (desiredState.Equals("Running", StringComparison.OrdinalIgnoreCase) &&
|
||||
!currentState.StartsWith("Running", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
notRunning.Add($"{name}: {currentState}");
|
||||
}
|
||||
}
|
||||
|
||||
if (notRunning.Count == 0)
|
||||
return new HealthCheckResult(HealthStatus.Healthy,
|
||||
$"All services in {stackName} are Running");
|
||||
|
||||
return new HealthCheckResult(HealthStatus.Critical,
|
||||
$"{notRunning.Count} service(s) not running in {stackName}",
|
||||
string.Join("\n", notRunning));
|
||||
}
|
||||
finally
|
||||
{
|
||||
sshClient.Disconnect();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new HealthCheckResult(HealthStatus.Critical,
|
||||
$"SSH check failed for {stackName}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<SshConnectionInfo> GetSwarmSshHostAsync(Core.Services.SettingsService settings)
|
||||
{
|
||||
var host = await settings.GetAsync("Ssh.SwarmHost")
|
||||
?? throw new InvalidOperationException("SSH Swarm host not configured.");
|
||||
var portStr = await settings.GetAsync("Ssh.SwarmPort", "22");
|
||||
var user = await settings.GetAsync("Ssh.SwarmUser", "root");
|
||||
var keyPath = await settings.GetAsync("Ssh.SwarmKeyPath");
|
||||
var password = await settings.GetAsync("Ssh.SwarmPassword");
|
||||
if (!int.TryParse(portStr, out var port)) port = 22;
|
||||
return new SshConnectionInfo(host, port, user, keyPath, password);
|
||||
}
|
||||
|
||||
private static SshClient CreateSshClient(SshConnectionInfo info)
|
||||
{
|
||||
var authMethods = new List<AuthenticationMethod>();
|
||||
if (!string.IsNullOrEmpty(info.KeyPath))
|
||||
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(info.KeyPath)));
|
||||
if (!string.IsNullOrEmpty(info.Password))
|
||||
authMethods.Add(new PasswordAuthenticationMethod(info.Username, info.Password));
|
||||
if (authMethods.Count == 0)
|
||||
{
|
||||
var defaultKeyPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh", "id_rsa");
|
||||
if (File.Exists(defaultKeyPath))
|
||||
authMethods.Add(new PrivateKeyAuthenticationMethod(info.Username, new PrivateKeyFile(defaultKeyPath)));
|
||||
else
|
||||
throw new InvalidOperationException($"No SSH auth method for {info.Host}:{info.Port}.");
|
||||
}
|
||||
var connInfo = new Renci.SshNet.ConnectionInfo(info.Host, info.Port, info.Username, authMethods.ToArray());
|
||||
return new SshClient(connInfo);
|
||||
}
|
||||
|
||||
private static string RunSshCommand(SshClient client, string command)
|
||||
{
|
||||
using var cmd = client.RunCommand(command);
|
||||
if (cmd.ExitStatus != 0)
|
||||
throw new InvalidOperationException($"SSH command failed (exit {cmd.ExitStatus}): {cmd.Error}");
|
||||
return cmd.Result;
|
||||
}
|
||||
|
||||
internal sealed record SshConnectionInfo(string Host, int Port, string Username, string? KeyPath, string? Password);
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
using System.Text;
|
||||
using System.Threading.RateLimiting;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OTSSignsOrchestrator.Server.Api;
|
||||
using OTSSignsOrchestrator.Server.Auth;
|
||||
using OTSSignsOrchestrator.Server.Clients;
|
||||
using OTSSignsOrchestrator.Server.Data;
|
||||
using OTSSignsOrchestrator.Server.Hubs;
|
||||
using OTSSignsOrchestrator.Server.Reports;
|
||||
using OTSSignsOrchestrator.Server.Services;
|
||||
using OTSSignsOrchestrator.Server.Webhooks;
|
||||
using OTSSignsOrchestrator.Server.Workers;
|
||||
using Refit;
|
||||
using Quartz;
|
||||
using OTSSignsOrchestrator.Server.Jobs;
|
||||
using OTSSignsOrchestrator.Server.Health;
|
||||
using OTSSignsOrchestrator.Server.Health.Checks;
|
||||
using Serilog;
|
||||
using Stripe;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// ── Serilog ──────────────────────────────────────────────────────────────────
|
||||
builder.Host.UseSerilog((context, config) =>
|
||||
config.ReadFrom.Configuration(context.Configuration));
|
||||
|
||||
// ── EF Core — PostgreSQL ─────────────────────────────────────────────────────
|
||||
builder.Services.AddDbContext<OrchestratorDbContext>(options =>
|
||||
options.UseNpgsql(builder.Configuration.GetConnectionString("OrchestratorDb")));
|
||||
|
||||
// ── JWT Authentication ──────────────────────────────────────────────────────
|
||||
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection(JwtOptions.Section));
|
||||
var jwtKey = builder.Configuration[$"{JwtOptions.Section}:Key"]
|
||||
?? throw new InvalidOperationException("Jwt:Key must be configured.");
|
||||
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = builder.Configuration[$"{JwtOptions.Section}:Issuer"] ?? "OTSSignsOrchestrator",
|
||||
ValidAudience = builder.Configuration[$"{JwtOptions.Section}:Audience"] ?? "OTSSignsOrchestrator",
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
|
||||
ClockSkew = TimeSpan.FromSeconds(30),
|
||||
};
|
||||
|
||||
// Allow SignalR to receive the JWT via query string
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnMessageReceived = context =>
|
||||
{
|
||||
var accessToken = context.Request.Query["access_token"];
|
||||
var path = context.HttpContext.Request.Path;
|
||||
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
|
||||
context.Token = accessToken;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy("CustomerPortal", policy =>
|
||||
policy.RequireClaim("customer_id"));
|
||||
});
|
||||
|
||||
// ── Application services ────────────────────────────────────────────────────
|
||||
builder.Services.AddScoped<OperatorAuthService>();
|
||||
builder.Services.AddScoped<AbbreviationService>();
|
||||
builder.Services.Configure<EmailOptions>(builder.Configuration.GetSection(EmailOptions.Section));
|
||||
builder.Services.AddSingleton<EmailService>();
|
||||
|
||||
// ── Report services ─────────────────────────────────────────────────────────
|
||||
builder.Services.AddScoped<BillingReportService>();
|
||||
builder.Services.AddScoped<FleetHealthPdfService>();
|
||||
|
||||
// ── Provisioning pipelines + worker ─────────────────────────────────────────
|
||||
builder.Services.AddScoped<IProvisioningPipeline, Phase1Pipeline>();
|
||||
builder.Services.AddScoped<IProvisioningPipeline, Phase2Pipeline>();
|
||||
builder.Services.AddScoped<IProvisioningPipeline, ByoiSamlPipeline>();
|
||||
builder.Services.AddScoped<IProvisioningPipeline, SuspendPipeline>();
|
||||
builder.Services.AddScoped<IProvisioningPipeline, ReactivatePipeline>();
|
||||
builder.Services.AddScoped<IProvisioningPipeline, UpdateScreenLimitPipeline>();
|
||||
builder.Services.AddScoped<IProvisioningPipeline, DecommissionPipeline>();
|
||||
builder.Services.AddScoped<IProvisioningPipeline, RotateCredentialsPipeline>();
|
||||
builder.Services.AddHostedService<ProvisioningWorker>();
|
||||
|
||||
// ── External API clients ────────────────────────────────────────────────────
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddSingleton<XiboClientFactory>();
|
||||
|
||||
builder.Services.Configure<AuthentikOptions>(
|
||||
builder.Configuration.GetSection(AuthentikOptions.Section));
|
||||
|
||||
builder.Services.AddRefitClient<IAuthentikClient>()
|
||||
.ConfigureHttpClient((sp, client) =>
|
||||
{
|
||||
var opts = sp.GetRequiredService<IOptions<AuthentikOptions>>().Value;
|
||||
client.BaseAddress = new Uri(opts.BaseUrl.TrimEnd('/'));
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", opts.ApiToken);
|
||||
});
|
||||
|
||||
// ── Stripe ──────────────────────────────────────────────────────────────────
|
||||
StripeConfiguration.ApiKey = builder.Configuration["Stripe:SecretKey"];
|
||||
|
||||
// ── Health check engine + individual checks ─────────────────────────────────
|
||||
builder.Services.AddHostedService<HealthCheckEngine>();
|
||||
builder.Services.AddScoped<IHealthCheck, XiboApiHealthCheck>();
|
||||
builder.Services.AddScoped<IHealthCheck, AdminIntegrityHealthCheck>();
|
||||
builder.Services.AddScoped<IHealthCheck, GroupStructureHealthCheck>();
|
||||
builder.Services.AddScoped<IHealthCheck, OauthAppHealthCheck>();
|
||||
builder.Services.AddScoped<IHealthCheck, DisplayAuthorisedHealthCheck>();
|
||||
builder.Services.AddScoped<IHealthCheck, StackHealthCheck>();
|
||||
builder.Services.AddScoped<IHealthCheck, MySqlConnectHealthCheck>();
|
||||
builder.Services.AddScoped<IHealthCheck, NfsAccessHealthCheck>();
|
||||
builder.Services.AddScoped<IHealthCheck, ThemeHealthCheck>();
|
||||
builder.Services.AddScoped<IHealthCheck, XiboVersionHealthCheck>();
|
||||
builder.Services.AddScoped<IHealthCheck, OauthAppAgeHealthCheck>();
|
||||
builder.Services.AddScoped<IHealthCheck, ByoiCertExpiryHealthCheck>();
|
||||
builder.Services.AddScoped<IHealthCheck, AuthentikGlobalHealthCheck>();
|
||||
builder.Services.AddScoped<IHealthCheck, AuthentikSamlProviderHealthCheck>();
|
||||
builder.Services.AddScoped<IHealthCheck, InvitationFlowHealthCheck>();
|
||||
// AuthentikGlobalHealthCheck also registered as concrete type for the Quartz job
|
||||
builder.Services.AddScoped<AuthentikGlobalHealthCheck>();
|
||||
|
||||
// ── Quartz scheduler ─────────────────────────────────────────────────────────
|
||||
builder.Services.AddQuartz(q =>
|
||||
{
|
||||
var certExpiryKey = new JobKey("byoi-cert-expiry-global", "byoi-cert-expiry");
|
||||
q.AddJob<ByoiCertExpiryJob>(opts => opts.WithIdentity(certExpiryKey).StoreDurably());
|
||||
q.AddTrigger(opts => opts
|
||||
.ForJob(certExpiryKey)
|
||||
.WithIdentity("byoi-cert-expiry-global-trigger", "byoi-cert-expiry")
|
||||
.WithSimpleSchedule(s => s
|
||||
.WithIntervalInHours(24)
|
||||
.RepeatForever())
|
||||
.StartNow());
|
||||
|
||||
// Authentik global health check — every 2 minutes
|
||||
var authentikHealthKey = new JobKey("authentik-global-health", "health-checks");
|
||||
q.AddJob<AuthentikGlobalHealthJob>(opts => opts.WithIdentity(authentikHealthKey).StoreDurably());
|
||||
q.AddTrigger(opts => opts
|
||||
.ForJob(authentikHealthKey)
|
||||
.WithIdentity("authentik-global-health-trigger", "health-checks")
|
||||
.WithSimpleSchedule(s => s
|
||||
.WithIntervalInMinutes(2)
|
||||
.RepeatForever())
|
||||
.StartNow());
|
||||
|
||||
// Daily screen snapshot — 2 AM UTC
|
||||
var dailySnapshotKey = new JobKey("daily-snapshot", "snapshots");
|
||||
q.AddJob<OTSSignsOrchestrator.Server.Jobs.DailySnapshotJob>(opts => opts.WithIdentity(dailySnapshotKey).StoreDurably());
|
||||
q.AddTrigger(opts => opts
|
||||
.ForJob(dailySnapshotKey)
|
||||
.WithIdentity("daily-snapshot-trigger", "snapshots")
|
||||
.WithCronSchedule("0 0 2 * * ?"));
|
||||
|
||||
// Scheduled reports — weekly (Monday 08:00 UTC) + monthly (1st 08:00 UTC)
|
||||
var reportJobKey = new JobKey("scheduled-report", "reports");
|
||||
q.AddJob<ScheduledReportJob>(opts => opts.WithIdentity(reportJobKey).StoreDurably());
|
||||
q.AddTrigger(opts => opts
|
||||
.ForJob(reportJobKey)
|
||||
.WithIdentity("weekly-report-trigger", "reports")
|
||||
.UsingJobData(ScheduledReportJob.IsMonthlyKey, false)
|
||||
.WithCronSchedule("0 0 8 ? * MON *"));
|
||||
q.AddTrigger(opts => opts
|
||||
.ForJob(reportJobKey)
|
||||
.WithIdentity("monthly-report-trigger", "reports")
|
||||
.UsingJobData(ScheduledReportJob.IsMonthlyKey, true)
|
||||
.WithCronSchedule("0 0 8 1 * ? *"));
|
||||
});
|
||||
builder.Services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
|
||||
|
||||
// ── SignalR ──────────────────────────────────────────────────────────────────
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
// ── Rate limiting ────────────────────────────────────────────────────────────
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = 429;
|
||||
|
||||
options.AddFixedWindowLimiter("fixed", limiter =>
|
||||
{
|
||||
limiter.PermitLimit = 60;
|
||||
limiter.Window = TimeSpan.FromMinutes(1);
|
||||
});
|
||||
|
||||
options.AddSlidingWindowLimiter("signup", limiter =>
|
||||
{
|
||||
limiter.PermitLimit = 3;
|
||||
limiter.Window = TimeSpan.FromMinutes(10);
|
||||
limiter.SegmentsPerWindow = 2;
|
||||
});
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// ── Middleware ────────────────────────────────────────────────────────────────
|
||||
app.UseSerilogRequestLogging();
|
||||
app.UseRateLimiter();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
// ── Auth endpoints (no auth required) ────────────────────────────────────────
|
||||
app.MapPost("/api/auth/login", async (LoginRequest req, OperatorAuthService auth) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var (jwt, refresh) = await auth.LoginAsync(req.Email, req.Password);
|
||||
return Results.Ok(new { token = jwt, refreshToken = refresh });
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
});
|
||||
|
||||
app.MapPost("/api/auth/refresh", async (RefreshRequest req, OperatorAuthService auth) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var jwt = await auth.RefreshAsync(req.RefreshToken);
|
||||
return Results.Ok(new { token = jwt });
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Signup endpoints (no auth) ──────────────────────────────────────────────
|
||||
app.MapSignupEndpoints();
|
||||
|
||||
// ── Stripe webhook (no auth, no rate limit) ─────────────────────────────────
|
||||
app.MapStripeWebhook();
|
||||
|
||||
// ── Fleet + Jobs REST endpoints (auth required) ─────────────────────────────
|
||||
app.MapFleetEndpoints();
|
||||
// ── Customer Portal BYOI endpoints (customer JWT required) ──────────────
|
||||
app.MapCustomerPortalEndpoints();
|
||||
// ── SignalR hub ─────────────────────────────────────────────────────────────
|
||||
app.MapHub<FleetHub>("/hubs/fleet");
|
||||
|
||||
app.Run();
|
||||
|
||||
// ── Request DTOs for auth endpoints ─────────────────────────────────────────
|
||||
public record LoginRequest(string Email, string Password);
|
||||
public record RefreshRequest(string RefreshToken);
|
||||
@@ -1,6 +1,6 @@
|
||||
using OTSSignsOrchestrator.Server.Jobs;
|
||||
using OTSSignsOrchestrator.Jobs;
|
||||
|
||||
namespace OTSSignsOrchestrator.Server.Tests;
|
||||
namespace OTSSignsOrchestrator.Tests;
|
||||
|
||||
public class ByoiCertExpiryThresholdTests
|
||||
{
|
||||
@@ -19,7 +19,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OTSSignsOrchestrator.Server\OTSSignsOrchestrator.Server.csproj" />
|
||||
<ProjectReference Include="..\OTSSignsOrchestrator\OTSSignsOrchestrator.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,74 +1,26 @@
|
||||
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OTSSignsOrchestrator.Core", "OTSSignsOrchestrator.Core\OTSSignsOrchestrator.Core.csproj", "{A1B2C3D4-1111-2222-3333-444455556666}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OTSSignsOrchestrator", "OTSSignsOrchestrator\OTSSignsOrchestrator.csproj", "{C36D7809-5824-4AE0-912E-DBB18E05CF46}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OTSSignsOrchestrator.Desktop", "OTSSignsOrchestrator.Desktop\OTSSignsOrchestrator.Desktop.csproj", "{B2C3D4E5-5555-6666-7777-888899990000}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OTSSignsOrchestrator.Server", "OTSSignsOrchestrator.Server\OTSSignsOrchestrator.Server.csproj", "{C36D7809-5824-4AE0-912E-DBB18E05CF46}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OTSSignsOrchestrator.Server.Tests", "OTSSignsOrchestrator.Server.Tests\OTSSignsOrchestrator.Server.Tests.csproj", "{452C671A-9730-44CF-A9B8-083CE36A4578}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OTSSignsOrchestrator.Tests", "OTSSignsOrchestrator.Tests\OTSSignsOrchestrator.Tests.csproj", "{452C671A-9730-44CF-A9B8-083CE36A4578}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{A1B2C3D4-1111-2222-3333-444455556666}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A1B2C3D4-1111-2222-3333-444455556666}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A1B2C3D4-1111-2222-3333-444455556666}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A1B2C3D4-1111-2222-3333-444455556666}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A1B2C3D4-1111-2222-3333-444455556666}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A1B2C3D4-1111-2222-3333-444455556666}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A1B2C3D4-1111-2222-3333-444455556666}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1B2C3D4-1111-2222-3333-444455556666}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A1B2C3D4-1111-2222-3333-444455556666}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A1B2C3D4-1111-2222-3333-444455556666}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A1B2C3D4-1111-2222-3333-444455556666}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A1B2C3D4-1111-2222-3333-444455556666}.Release|x86.Build.0 = Release|Any CPU
|
||||
{B2C3D4E5-5555-6666-7777-888899990000}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B2C3D4E5-5555-6666-7777-888899990000}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B2C3D4E5-5555-6666-7777-888899990000}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{B2C3D4E5-5555-6666-7777-888899990000}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{B2C3D4E5-5555-6666-7777-888899990000}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{B2C3D4E5-5555-6666-7777-888899990000}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{B2C3D4E5-5555-6666-7777-888899990000}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B2C3D4E5-5555-6666-7777-888899990000}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B2C3D4E5-5555-6666-7777-888899990000}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{B2C3D4E5-5555-6666-7777-888899990000}.Release|x64.Build.0 = Release|Any CPU
|
||||
{B2C3D4E5-5555-6666-7777-888899990000}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{B2C3D4E5-5555-6666-7777-888899990000}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C36D7809-5824-4AE0-912E-DBB18E05CF46}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C36D7809-5824-4AE0-912E-DBB18E05CF46}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C36D7809-5824-4AE0-912E-DBB18E05CF46}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C36D7809-5824-4AE0-912E-DBB18E05CF46}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C36D7809-5824-4AE0-912E-DBB18E05CF46}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C36D7809-5824-4AE0-912E-DBB18E05CF46}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C36D7809-5824-4AE0-912E-DBB18E05CF46}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C36D7809-5824-4AE0-912E-DBB18E05CF46}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C36D7809-5824-4AE0-912E-DBB18E05CF46}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C36D7809-5824-4AE0-912E-DBB18E05CF46}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C36D7809-5824-4AE0-912E-DBB18E05CF46}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C36D7809-5824-4AE0-912E-DBB18E05CF46}.Release|x86.Build.0 = Release|Any CPU
|
||||
{452C671A-9730-44CF-A9B8-083CE36A4578}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{452C671A-9730-44CF-A9B8-083CE36A4578}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{452C671A-9730-44CF-A9B8-083CE36A4578}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{452C671A-9730-44CF-A9B8-083CE36A4578}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{452C671A-9730-44CF-A9B8-083CE36A4578}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{452C671A-9730-44CF-A9B8-083CE36A4578}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{452C671A-9730-44CF-A9B8-083CE36A4578}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{452C671A-9730-44CF-A9B8-083CE36A4578}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{452C671A-9730-44CF-A9B8-083CE36A4578}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{452C671A-9730-44CF-A9B8-083CE36A4578}.Release|x64.Build.0 = Release|Any CPU
|
||||
{452C671A-9730-44CF-A9B8-083CE36A4578}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{452C671A-9730-44CF-A9B8-083CE36A4578}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
46
OTSSignsOrchestrator/Api/AuditApi.cs
Normal file
46
OTSSignsOrchestrator/Api/AuditApi.cs
Normal 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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,13 @@ using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OTSSignsOrchestrator.Server.Clients;
|
||||
using OTSSignsOrchestrator.Server.Data;
|
||||
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||
using OTSSignsOrchestrator.Server.Hubs;
|
||||
using OTSSignsOrchestrator.Server.Workers;
|
||||
using OTSSignsOrchestrator.Clients;
|
||||
using OTSSignsOrchestrator.Data;
|
||||
using OTSSignsOrchestrator.Data.Entities;
|
||||
using OTSSignsOrchestrator.Hubs;
|
||||
using OTSSignsOrchestrator.Workers;
|
||||
|
||||
namespace OTSSignsOrchestrator.Server.Api;
|
||||
namespace OTSSignsOrchestrator.Api;
|
||||
|
||||
public static class CustomerPortalApi
|
||||
{
|
||||
140
OTSSignsOrchestrator/Api/CustomersApi.cs
Normal file
140
OTSSignsOrchestrator/Api/CustomersApi.cs
Normal file
@@ -0,0 +1,140 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OTSSignsOrchestrator.Data;
|
||||
using OTSSignsOrchestrator.Data.Entities;
|
||||
|
||||
namespace OTSSignsOrchestrator.Api;
|
||||
|
||||
public static class CustomersApi
|
||||
{
|
||||
public static void MapCustomersEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/customers").RequireAuthorization();
|
||||
|
||||
// ── All customers with detailed admin view ──────────────────────────
|
||||
group.MapGet("/", async (string? status, OrchestratorDbContext db) =>
|
||||
{
|
||||
var query = db.Customers
|
||||
.AsNoTracking()
|
||||
.Include(c => c.Instances)
|
||||
.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrEmpty(status) &&
|
||||
Enum.TryParse<CustomerStatus>(status, true, out var s))
|
||||
query = query.Where(c => c.Status == s);
|
||||
|
||||
var customers = await query
|
||||
.OrderBy(c => c.CompanyName)
|
||||
.Select(c => new
|
||||
{
|
||||
c.Id,
|
||||
c.Abbreviation,
|
||||
c.CompanyName,
|
||||
c.AdminEmail,
|
||||
c.AdminFirstName,
|
||||
c.AdminLastName,
|
||||
Plan = c.Plan.ToString(),
|
||||
c.ScreenCount,
|
||||
c.StripeCustomerId,
|
||||
c.StripeSubscriptionId,
|
||||
Status = c.Status.ToString(),
|
||||
c.FailedPaymentCount,
|
||||
c.FirstPaymentFailedAt,
|
||||
c.CreatedAt,
|
||||
InstanceCount = c.Instances.Count,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Results.Ok(customers);
|
||||
});
|
||||
|
||||
// ── Customer detail ─────────────────────────────────────────────────
|
||||
group.MapGet("/{id:guid}", async (Guid id, OrchestratorDbContext db) =>
|
||||
{
|
||||
var customer = await db.Customers
|
||||
.AsNoTracking()
|
||||
.Include(c => c.Instances)
|
||||
.Include(c => c.Jobs.OrderByDescending(j => j.CreatedAt).Take(20))
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
|
||||
if (customer is null) return Results.NotFound();
|
||||
|
||||
// Get BYOI configs for customer's instances
|
||||
var instanceIds = customer.Instances.Select(i => i.Id).ToList();
|
||||
|
||||
var byoiConfigs = await db.ByoiConfigs
|
||||
.AsNoTracking()
|
||||
.Where(b => instanceIds.Contains(b.InstanceId))
|
||||
.Select(b => new
|
||||
{
|
||||
b.Id, b.InstanceId, b.Slug, b.EntityId,
|
||||
b.CertExpiry, b.Enabled, b.CreatedAt
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
// Get screen snapshots (last 30 days)
|
||||
var since = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-30));
|
||||
var snapshots = await db.ScreenSnapshots
|
||||
.AsNoTracking()
|
||||
.Where(s => instanceIds.Contains(s.InstanceId) && s.SnapshotDate >= since)
|
||||
.OrderBy(s => s.SnapshotDate)
|
||||
.Select(s => new { s.InstanceId, s.SnapshotDate, s.ScreenCount })
|
||||
.ToListAsync();
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
customer.Id,
|
||||
customer.Abbreviation,
|
||||
customer.CompanyName,
|
||||
customer.AdminEmail,
|
||||
customer.AdminFirstName,
|
||||
customer.AdminLastName,
|
||||
Plan = customer.Plan.ToString(),
|
||||
customer.ScreenCount,
|
||||
customer.StripeCustomerId,
|
||||
customer.StripeSubscriptionId,
|
||||
Status = customer.Status.ToString(),
|
||||
customer.FailedPaymentCount,
|
||||
customer.FirstPaymentFailedAt,
|
||||
customer.CreatedAt,
|
||||
Instances = customer.Instances.Select(i => new
|
||||
{
|
||||
i.Id, StackName = i.DockerStackName, i.XiboUrl, i.CreatedAt
|
||||
}),
|
||||
Jobs = customer.Jobs.Select(j => new
|
||||
{
|
||||
j.Id, j.JobType, Status = j.Status.ToString(),
|
||||
j.TriggeredBy, j.CreatedAt, j.CompletedAt, j.ErrorMessage
|
||||
}),
|
||||
ByoiConfigs = byoiConfigs,
|
||||
ScreenSnapshots = snapshots,
|
||||
});
|
||||
});
|
||||
|
||||
// ── Stripe events for a customer (admin only) ───────────────────────
|
||||
group.MapGet("/{id:guid}/stripe-events", async (
|
||||
Guid id, int? limit, OrchestratorDbContext db) =>
|
||||
{
|
||||
var customer = await db.Customers.FindAsync(id);
|
||||
if (customer is null) return Results.NotFound();
|
||||
|
||||
if (string.IsNullOrEmpty(customer.StripeCustomerId))
|
||||
return Results.Ok(Array.Empty<object>());
|
||||
|
||||
var take = Math.Clamp(limit ?? 50, 1, 200);
|
||||
|
||||
// Search Stripe events that contain this customer's Stripe ID in the payload
|
||||
var events = await db.StripeEvents
|
||||
.AsNoTracking()
|
||||
.Where(e => e.Payload != null && e.Payload.Contains(customer.StripeCustomerId))
|
||||
.OrderByDescending(e => e.ProcessedAt)
|
||||
.Take(take)
|
||||
.Select(e => new
|
||||
{
|
||||
e.StripeEventId, e.EventType, e.ProcessedAt
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Results.Ok(events);
|
||||
}).RequireAuthorization(policy => policy.RequireRole("admin"));
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OTSSignsOrchestrator.Server.Data;
|
||||
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||
using OTSSignsOrchestrator.Server.Hubs;
|
||||
using OTSSignsOrchestrator.Server.Reports;
|
||||
using OTSSignsOrchestrator.Data;
|
||||
using OTSSignsOrchestrator.Data.Entities;
|
||||
using OTSSignsOrchestrator.Hubs;
|
||||
using OTSSignsOrchestrator.Reports;
|
||||
|
||||
namespace OTSSignsOrchestrator.Server.Api;
|
||||
namespace OTSSignsOrchestrator.Api;
|
||||
|
||||
public static class FleetApi
|
||||
{
|
||||
98
OTSSignsOrchestrator/Api/HealthApi.cs
Normal file
98
OTSSignsOrchestrator/Api/HealthApi.cs
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
121
OTSSignsOrchestrator/Api/HostsApi.cs
Normal file
121
OTSSignsOrchestrator/Api/HostsApi.cs
Normal 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);
|
||||
181
OTSSignsOrchestrator/Api/InstancesApi.cs
Normal file
181
OTSSignsOrchestrator/Api/InstancesApi.cs
Normal 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);
|
||||
40
OTSSignsOrchestrator/Api/LogsApi.cs
Normal file
40
OTSSignsOrchestrator/Api/LogsApi.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
4
OTSSignsOrchestrator/Api/OperatorsApi.cs
Normal file
4
OTSSignsOrchestrator/Api/OperatorsApi.cs
Normal 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.
|
||||
|
||||
243
OTSSignsOrchestrator/Api/ProvisionApi.cs
Normal file
243
OTSSignsOrchestrator/Api/ProvisionApi.cs
Normal file
@@ -0,0 +1,243 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OTSSignsOrchestrator.Models.DTOs;
|
||||
using OTSSignsOrchestrator.Services;
|
||||
using OTSSignsOrchestrator.Data;
|
||||
using OTSSignsOrchestrator.Data.Entities;
|
||||
using OTSSignsOrchestrator.Hubs;
|
||||
|
||||
namespace OTSSignsOrchestrator.Api;
|
||||
|
||||
public static class ProvisionApi
|
||||
{
|
||||
public static void MapProvisionEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/provision").RequireAuthorization();
|
||||
|
||||
group.MapPost("/preview-yaml", async (YamlPreviewRequest req,
|
||||
SettingsService settings, GitTemplateService git, ComposeRenderService compose) =>
|
||||
{
|
||||
var abbrev = req.CustomerAbbrev.Trim().ToLowerInvariant();
|
||||
var stackName = $"{abbrev}-cms-stack";
|
||||
|
||||
var repoUrl = await settings.GetAsync(SettingsService.GitRepoUrl);
|
||||
var repoPat = await settings.GetAsync(SettingsService.GitRepoPat);
|
||||
if (string.IsNullOrWhiteSpace(repoUrl))
|
||||
return Results.BadRequest("Git template repository URL is not configured.");
|
||||
|
||||
var templateConfig = await git.FetchAsync(repoUrl, repoPat);
|
||||
|
||||
var cmsServerName = (await settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com")).Replace("{abbrev}", abbrev);
|
||||
var themePath = (await settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev);
|
||||
var mySqlDbName = (await settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev);
|
||||
var mySqlUser = (await settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user")).Replace("{abbrev}", abbrev);
|
||||
|
||||
var renderCtx = new RenderContext
|
||||
{
|
||||
CustomerName = req.CustomerName,
|
||||
CustomerAbbrev = abbrev,
|
||||
StackName = stackName,
|
||||
CmsServerName = cmsServerName,
|
||||
HostHttpPort = 80,
|
||||
CmsImage = await settings.GetAsync(SettingsService.DefaultCmsImage, "ghcr.io/xibosignage/xibo-cms:release-4.2.3"),
|
||||
MemcachedImage = await settings.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine"),
|
||||
QuickChartImage = await settings.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart"),
|
||||
NewtImage = await settings.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt"),
|
||||
ThemeHostPath = themePath,
|
||||
MySqlHost = await settings.GetAsync(SettingsService.MySqlHost, "localhost"),
|
||||
MySqlPort = await settings.GetAsync(SettingsService.MySqlPort, "3306"),
|
||||
MySqlDatabase = mySqlDbName,
|
||||
MySqlUser = mySqlUser,
|
||||
MySqlPassword = "(generated-at-deploy-time)",
|
||||
SmtpServer = await settings.GetAsync(SettingsService.SmtpServer, string.Empty),
|
||||
SmtpUsername = await settings.GetAsync(SettingsService.SmtpUsername, string.Empty),
|
||||
SmtpPassword = await settings.GetAsync(SettingsService.SmtpPassword, string.Empty),
|
||||
SmtpUseTls = await settings.GetAsync(SettingsService.SmtpUseTls, "YES"),
|
||||
SmtpUseStartTls = await settings.GetAsync(SettingsService.SmtpUseStartTls, "YES"),
|
||||
SmtpRewriteDomain = await settings.GetAsync(SettingsService.SmtpRewriteDomain, string.Empty),
|
||||
SmtpHostname = await settings.GetAsync(SettingsService.SmtpHostname, string.Empty),
|
||||
SmtpFromLineOverride = await settings.GetAsync(SettingsService.SmtpFromLineOverride, "NO"),
|
||||
PangolinEndpoint = await settings.GetAsync(SettingsService.PangolinEndpoint, "https://app.pangolin.net"),
|
||||
NfsServer = await settings.GetAsync(SettingsService.NfsServer),
|
||||
NfsExport = await settings.GetAsync(SettingsService.NfsExport),
|
||||
NfsExportFolder = await settings.GetAsync(SettingsService.NfsExportFolder),
|
||||
NfsExtraOptions = await settings.GetAsync(SettingsService.NfsOptions, string.Empty),
|
||||
PhpPostMaxSize = await settings.GetAsync(SettingsService.DefaultPhpPostMaxSize, "10G"),
|
||||
PhpUploadMaxFilesize = await settings.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G"),
|
||||
PhpMaxExecutionTime = await settings.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600"),
|
||||
};
|
||||
|
||||
var yaml = compose.Render(templateConfig.Yaml, renderCtx);
|
||||
return Results.Ok(new { yaml });
|
||||
});
|
||||
|
||||
group.MapPost("/deploy", async (DeployRequest req,
|
||||
OrchestratorDbContext db, IDockerServiceFactory dockerFactory,
|
||||
InstanceService instanceService) =>
|
||||
{
|
||||
if (!Guid.TryParse(req.HostId, out var hostId))
|
||||
return Results.BadRequest("Invalid host ID format");
|
||||
var host = await db.SshHosts.FindAsync(hostId);
|
||||
if (host == null) return Results.BadRequest("Host not found");
|
||||
|
||||
// Resolve and configure both services via the factory
|
||||
_ = dockerFactory.GetCliService(host);
|
||||
_ = dockerFactory.GetSecretsService(host);
|
||||
|
||||
var dto = new CreateInstanceDto
|
||||
{
|
||||
CustomerName = req.CustomerName,
|
||||
CustomerAbbrev = req.CustomerAbbrev,
|
||||
NewtId = req.NewtId,
|
||||
NewtSecret = req.NewtSecret,
|
||||
NfsServer = req.NfsServer,
|
||||
NfsExport = req.NfsExport,
|
||||
NfsExportFolder = req.NfsExportFolder,
|
||||
NfsExtraOptions = req.NfsExtraOptions,
|
||||
PurgeStaleVolumes = req.PurgeStaleVolumes,
|
||||
};
|
||||
|
||||
var result = await instanceService.CreateInstanceAsync(dto);
|
||||
return Results.Ok(new
|
||||
{
|
||||
result.Success, result.StackName, result.Message,
|
||||
result.Output, result.InstanceUrl, result.Abbrev,
|
||||
});
|
||||
});
|
||||
|
||||
// ── Suggest abbreviation from company name ──────────────────────────
|
||||
group.MapGet("/suggest-abbreviation", async (string companyName, AbbreviationService abbrService) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(companyName))
|
||||
return Results.BadRequest("companyName is required.");
|
||||
|
||||
var abbrev = await abbrService.GenerateAsync(companyName);
|
||||
return Results.Ok(new { abbreviation = abbrev.ToLowerInvariant() });
|
||||
});
|
||||
|
||||
// ── Manual provision: create Customer + Instance + queue job ────────
|
||||
group.MapPost("/manual", async (ManualProvisionRequest req,
|
||||
OrchestratorDbContext db,
|
||||
AbbreviationService abbrService,
|
||||
SettingsService settings,
|
||||
IHubContext<FleetHub, IFleetClient> hub,
|
||||
ILogger<ManualProvisionRequest> logger) =>
|
||||
{
|
||||
// Validate abbreviation format
|
||||
var abbrev = req.Abbreviation?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||
if (string.IsNullOrEmpty(abbrev))
|
||||
{
|
||||
abbrev = (await abbrService.GenerateAsync(req.CompanyName)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
if (!System.Text.RegularExpressions.Regex.IsMatch(abbrev, @"^[a-z][a-z0-9]{2}$"))
|
||||
return Results.BadRequest("Abbreviation must be exactly 3 lowercase alphanumeric characters starting with a letter.");
|
||||
|
||||
if (await db.Customers.AnyAsync(c => c.Abbreviation == abbrev))
|
||||
return Results.Conflict(new { message = $"Abbreviation '{abbrev}' is already in use." });
|
||||
|
||||
if (!Enum.TryParse<CustomerPlan>(req.Plan, true, out var plan))
|
||||
return Results.BadRequest("Invalid plan. Must be Essentials or Pro.");
|
||||
|
||||
// Create Customer record
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CompanyName = req.CompanyName.Trim(),
|
||||
AdminEmail = req.AdminEmail.Trim().ToLowerInvariant(),
|
||||
AdminFirstName = req.AdminFirstName?.Trim() ?? string.Empty,
|
||||
AdminLastName = req.AdminLastName?.Trim() ?? string.Empty,
|
||||
Abbreviation = abbrev,
|
||||
Plan = plan,
|
||||
ScreenCount = Math.Max(1, req.ScreenCount),
|
||||
Status = CustomerStatus.Provisioning,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.Customers.Add(customer);
|
||||
|
||||
// Build Instance record
|
||||
var stackName = $"{abbrev}-cms-stack";
|
||||
var cmsServerName = (await settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com"))
|
||||
.Replace("{abbrev}", abbrev);
|
||||
var instanceUrl = $"https://{cmsServerName}/{abbrev}";
|
||||
var mysqlDb = (await settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db"))
|
||||
.Replace("{abbrev}", abbrev);
|
||||
|
||||
var instance = new Instance
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CustomerId = customer.Id,
|
||||
DockerStackName = stackName,
|
||||
XiboUrl = instanceUrl,
|
||||
MysqlDatabase = mysqlDb,
|
||||
NfsPath = abbrev,
|
||||
HealthStatus = HealthStatus.Unknown,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.Instances.Add(instance);
|
||||
|
||||
// Serialize optional deployment overrides into job parameters
|
||||
var jobParams = new Dictionary<string, string>();
|
||||
if (!string.IsNullOrWhiteSpace(req.NewtId)) jobParams["newtId"] = req.NewtId;
|
||||
if (!string.IsNullOrWhiteSpace(req.NewtSecret)) jobParams["newtSecret"] = req.NewtSecret;
|
||||
if (!string.IsNullOrWhiteSpace(req.NfsServer)) jobParams["nfsServer"] = req.NfsServer;
|
||||
if (!string.IsNullOrWhiteSpace(req.NfsExport)) jobParams["nfsExport"] = req.NfsExport;
|
||||
if (!string.IsNullOrWhiteSpace(req.NfsExportFolder)) jobParams["nfsExportFolder"] = req.NfsExportFolder;
|
||||
if (!string.IsNullOrWhiteSpace(req.NfsExtraOptions)) jobParams["nfsExtraOptions"] = req.NfsExtraOptions;
|
||||
|
||||
// Create provision Job
|
||||
var job = new Job
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CustomerId = customer.Id,
|
||||
JobType = "provision",
|
||||
Status = JobStatus.Queued,
|
||||
TriggeredBy = "operator",
|
||||
Parameters = jobParams.Count > 0 ? JsonSerializer.Serialize(jobParams) : null,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.Jobs.Add(job);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
logger.LogInformation(
|
||||
"Manual provision: customer={CustomerId}, abbrev={Abbrev}, job={JobId}",
|
||||
customer.Id, abbrev, job.Id);
|
||||
|
||||
await hub.Clients.All.SendJobCreated(job.Id.ToString(), abbrev, "provision");
|
||||
|
||||
return Results.Created($"/api/jobs/{job.Id}", new
|
||||
{
|
||||
jobId = job.Id,
|
||||
customerId = customer.Id,
|
||||
abbreviation = abbrev,
|
||||
stackName,
|
||||
instanceUrl,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public record YamlPreviewRequest(string CustomerAbbrev, string CustomerName);
|
||||
|
||||
public record DeployRequest(
|
||||
string CustomerName, string CustomerAbbrev, string HostId,
|
||||
string? NewtId, string? NewtSecret,
|
||||
string? NfsServer, string? NfsExport, string? NfsExportFolder, string? NfsExtraOptions,
|
||||
bool PurgeStaleVolumes);
|
||||
|
||||
public record ManualProvisionRequest(
|
||||
string CompanyName,
|
||||
string AdminEmail,
|
||||
string? AdminFirstName,
|
||||
string? AdminLastName,
|
||||
string? Abbreviation,
|
||||
string Plan,
|
||||
int ScreenCount,
|
||||
string? NewtId,
|
||||
string? NewtSecret,
|
||||
string? NfsServer,
|
||||
string? NfsExport,
|
||||
string? NfsExportFolder,
|
||||
string? NfsExtraOptions);
|
||||
21
OTSSignsOrchestrator/Api/SecretsApi.cs
Normal file
21
OTSSignsOrchestrator/Api/SecretsApi.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
154
OTSSignsOrchestrator/Api/SettingsApi.cs
Normal file
154
OTSSignsOrchestrator/Api/SettingsApi.cs
Normal 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);
|
||||
@@ -1,12 +1,12 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OTSSignsOrchestrator.Server.Data;
|
||||
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||
using OTSSignsOrchestrator.Server.Hubs;
|
||||
using OTSSignsOrchestrator.Data;
|
||||
using OTSSignsOrchestrator.Data.Entities;
|
||||
using OTSSignsOrchestrator.Hubs;
|
||||
using Stripe.Checkout;
|
||||
|
||||
namespace OTSSignsOrchestrator.Server.Api;
|
||||
namespace OTSSignsOrchestrator.Api;
|
||||
|
||||
public static class SignupApi
|
||||
{
|
||||
117
OTSSignsOrchestrator/Auth/AdminTokenService.cs
Normal file
117
OTSSignsOrchestrator/Auth/AdminTokenService.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OTSSignsOrchestrator.Data;
|
||||
using OTSSignsOrchestrator.Data.Entities;
|
||||
|
||||
namespace OTSSignsOrchestrator.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Manages the one-time admin bootstrap token. On first run the plaintext is printed
|
||||
/// to stdout and only the SHA-256 hash is persisted in the <c>AppSettings</c> table.
|
||||
/// The token survives container restarts. Reset via CLI:
|
||||
/// <c>docker exec <container> /app/OTSSignsOrchestrator reset-admin-token</c>
|
||||
/// </summary>
|
||||
public sealed class AdminTokenService
|
||||
{
|
||||
private const string SettingKey = "System.AdminTokenHash";
|
||||
private const string SettingCategory = "System";
|
||||
|
||||
private byte[]? _hash;
|
||||
|
||||
/// <summary>
|
||||
/// Initialises the admin token. If no hash exists in the database a new token is
|
||||
/// generated, its hash saved, and the plaintext printed to stdout exactly once.
|
||||
/// </summary>
|
||||
public async Task InitialiseAsync(IServiceProvider services)
|
||||
{
|
||||
using var scope = services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<AdminTokenService>>();
|
||||
|
||||
var row = await db.AppSettings.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Key == SettingKey);
|
||||
|
||||
if (row is not null && !string.IsNullOrWhiteSpace(row.Value))
|
||||
{
|
||||
_hash = Convert.FromHexString(row.Value);
|
||||
logger.LogInformation("Admin token hash loaded from database (token already generated)");
|
||||
return;
|
||||
}
|
||||
|
||||
// First run — generate and persist
|
||||
logger.LogInformation("No admin token found — generating new bootstrap token");
|
||||
await GenerateAndPersistAsync(db, logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the current admin token with a freshly generated one.
|
||||
/// Prints the new plaintext to stdout and persists only the hash.
|
||||
/// </summary>
|
||||
public async Task ResetAsync(IServiceProvider services)
|
||||
{
|
||||
using var scope = services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<AdminTokenService>>();
|
||||
await GenerateAndPersistAsync(db, logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a candidate token against the stored hash using constant-time comparison.
|
||||
/// </summary>
|
||||
public bool Validate(string candidateToken)
|
||||
{
|
||||
if (_hash is null || string.IsNullOrEmpty(candidateToken))
|
||||
return false;
|
||||
|
||||
var candidateHash = SHA256.HashData(Encoding.UTF8.GetBytes(candidateToken));
|
||||
return CryptographicOperations.FixedTimeEquals(candidateHash, _hash);
|
||||
}
|
||||
|
||||
private async Task GenerateAndPersistAsync(OrchestratorDbContext db, ILogger logger)
|
||||
{
|
||||
var plaintext = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(32));
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(plaintext));
|
||||
var hashHex = Convert.ToHexStringLower(hash);
|
||||
|
||||
var existing = await db.AppSettings.FindAsync(SettingKey);
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.Value = hashHex;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
db.AppSettings.Add(new AppSetting
|
||||
{
|
||||
Key = SettingKey,
|
||||
Value = hashHex,
|
||||
Category = SettingCategory,
|
||||
IsSensitive = false, // it's a hash, not the secret
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
_hash = hash;
|
||||
|
||||
// Log via ILogger so the token appears in docker service logs
|
||||
logger.LogWarning("ADMIN BOOTSTRAP TOKEN: {Token}", plaintext);
|
||||
logger.LogWarning("This token will NOT be displayed again. Use it at /admintoken to gain SuperAdmin access.");
|
||||
logger.LogWarning("To reset: docker exec <ctr> /app/OTSSignsOrchestrator reset-admin-token");
|
||||
|
||||
// Also print to stdout for direct console access
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("╔══════════════════════════════════════════════════════════════════════╗");
|
||||
Console.WriteLine("║ ADMIN BOOTSTRAP TOKEN ║");
|
||||
Console.WriteLine("╠══════════════════════════════════════════════════════════════════════╣");
|
||||
Console.WriteLine($"║ {plaintext} ║");
|
||||
Console.WriteLine("║ ║");
|
||||
Console.WriteLine("║ This token will NOT be displayed again. ║");
|
||||
Console.WriteLine("║ Use it at /admintoken to gain SuperAdmin access. ║");
|
||||
Console.WriteLine("║ To reset: docker exec <ctr> /app/OTSSignsOrchestrator reset-admin-token ║");
|
||||
Console.WriteLine("╚══════════════════════════════════════════════════════════════════════╝");
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace OTSSignsOrchestrator.Server.Auth;
|
||||
namespace OTSSignsOrchestrator.Auth;
|
||||
|
||||
public class JwtOptions
|
||||
{
|
||||
45
OTSSignsOrchestrator/Auth/OperatorAuthService.cs
Normal file
45
OTSSignsOrchestrator/Auth/OperatorAuthService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
25
OTSSignsOrchestrator/ClientApp/components.json
Normal file
25
OTSSignsOrchestrator/ClientApp/components.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
12
OTSSignsOrchestrator/ClientApp/index.html
Normal file
12
OTSSignsOrchestrator/ClientApp/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OTS Signs Orchestrator</title>
|
||||
</head>
|
||||
<body class="bg-background text-foreground antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user