@using EntKube.Web.Data @using EntKube.Web.Services @inject DatabaseService DatabaseService @inject CnpgService CnpgService @inject MongoService MongoService @inject StorageService StorageService @inject RegisteredPostgresService RegisteredPostgresService @* =========================================================================== Databases Tab — shows CNPG PostgreSQL clusters and MongoDB clusters discovered across the tenant's Kubernetes clusters. Requires the respective operators (cloudnative-pg, mongodb-operator) to be installed on at least one cluster. =========================================================================== *@ @if (loading) {

Discovering database clusters...

} else { @* ── Operator availability badges ── *@
@if (operatorStatus!.CnpgAvailable) { PostgreSQL (CNPG) on @operatorStatus.CnpgClusterName } else { PostgreSQL (CNPG) — not installed } @if (operatorStatus.MongoDbAvailable) { MongoDB on @operatorStatus.MongoDbClusterName } else { MongoDB — not installed }
@* ── Environment Tabs ── *@ @if (AllEnvironments.Count > 0) { } @* ── Registered PostgreSQL Instances ── *@
Postgres @if (FilteredRegisteredPgInstances is not null) { @FilteredRegisteredPgInstances.Count }
@* ── Register Form ── *@ @if (showRegisterPgForm) {
Register Existing PostgreSQL Instance
Used as HOST in credentials (resolves as service.ns.svc.cluster.local)
Pod to kubectl exec into for admin SQL
@if (!string.IsNullOrEmpty(registerPgError)) {
@registerPgError
}
} @* ── Instance Cards ── *@ @if (registeredPgInstances is null) {
} else if (FilteredRegisteredPgInstances!.Count == 0 && !showRegisterPgForm) {
No registered PostgreSQL instances yet. Click "Register Instance" to add a non-CNPG Postgres server.
} else { @foreach (RegisteredPostgresInstance rpi in FilteredRegisteredPgInstances!) {
@rpi.Name @rpi.ServiceName:@rpi.Port @rpi.KubernetesCluster.Environment.Name @if (rpi.Databases.Count > 0) { @rpi.Databases.Count db(s) }
@if (testRegPgResultId == rpi.Id && testRegPgResult is not null) {
@testRegPgResult
} @if (deleteRegPgError is not null && deletingRegPgId == rpi.Id) {
@deleteRegPgError
} @* ── Expanded Detail Panel ── *@ @if (expandedRegPgId == rpi.Id) {
@if (!string.IsNullOrEmpty(rpi.Notes)) {

@rpi.Notes

} @* ── Pod Health ── *@
Pod
@if (regPgPodsLoading.GetValueOrDefault(rpi.Id)) {
} else if (regPgPods.TryGetValue(rpi.Id, out List? pods) && pods.Count > 0) {
@foreach (CnpgPodInfo pod in pods) { }
Pod Status Ready Node Age Restarts
@pod.Name @if (pod.Status == "Running") { @pod.Status } else if (pod.Status is "Pending" or "ContainerCreating") { @pod.Status } else { @pod.Status } @if (pod.Ready) { } else { } @(pod.Node ?? "—") @if (pod.StartTime.HasValue) { @((DateTime.UtcNow - pod.StartTime.Value) switch { var d when d.TotalDays >= 1 => $"{(int)d.TotalDays}d", var d when d.TotalHours >= 1 => $"{(int)d.TotalHours}h", var d => $"{(int)d.TotalMinutes}m" }) } else { } @pod.Restarts
} else {

Pod info unavailable.

}
Databases
@* ── Create Database Form ── *@ @if (addDbRegPgInstance?.Id == rpi.Id) {
@if (!string.IsNullOrEmpty(addRegPgDbError)) {
@addRegPgDbError
}
} @* ── Import Database Form ── *@ @if (importRegPgInstance?.Id == rpi.Id) {

Register an existing database without creating it on the server. Stores the provided credentials in the vault.

@if (!string.IsNullOrEmpty(importRegPgError)) {
@importRegPgError
}
} @* ── Databases Table ── *@ @if (rpi.Databases.Count == 0) {

No databases managed yet.

} else {
@foreach (RegisteredPostgresDatabase db in rpi.Databases) { @if (regPgDbDeleteConfirmId == db.Id) { } @if (showDumpFormForDbId == db.Id) { } @if (showMigrateFormForDbId == db.Id) { } @if (regPgCredDbId == db.Id && regPgCredentials is not null) { } @if (!string.IsNullOrEmpty(regPgDbActionError) && regPgDbActionErrorId == db.Id) { } }
Database Owner Role Status Actions
@db.Name @db.Owner @switch (db.Status) { case RegisteredPostgresDatabaseStatus.Ready: Ready break; case RegisteredPostgresDatabaseStatus.Creating: Creating break; case RegisteredPostgresDatabaseStatus.Deleting: Deleting break; default: Failed break; }
Remove @db.Name?
@if (!string.IsNullOrEmpty(dumpError) && dumpErrorDbId == db.Id) {
@dumpError
} @if (!string.IsNullOrEmpty(dumpSuccess) && dumpErrorDbId == db.Id) {
@dumpSuccess
}
@if (!string.IsNullOrEmpty(migrateError) && migrateErrorDbId == db.Id) {
@migrateError
} @if (!string.IsNullOrEmpty(migrateSuccess) && migrateErrorDbId == db.Id) {
@migrateSuccess
}
@foreach (KeyValuePair cred in regPgCredentials) {
@cred.Key: @cred.Value
}
@regPgDbActionError
} @* ── Dump History ── *@ @if (loadingDumps) {
Loading dump history...
} else if (dumpsByDatabase is not null) { @foreach (RegisteredPostgresDatabase dumpDb in rpi.Databases) { @if (dumpsByDatabase.TryGetValue(dumpDb.Id, out List? dumps) && dumps.Count > 0) {
Dumps — @dumpDb.Name
@foreach (RegisteredPostgresDump dump in dumps) { @if (showRestoreFormForDumpId == dump.Id) { } }
Created Size Bucket Actions
@dump.CreatedAt.ToString("yyyy-MM-dd HH:mm") @FormatBytes(dump.SizeBytes) @dump.StorageLink.Name
@if (!string.IsNullOrEmpty(dumpRestoreError) && dumpRestoreErrorDumpId == dump.Id) {
@dumpRestoreError
} @if (!string.IsNullOrEmpty(dumpRestoreSuccess) && dumpRestoreErrorDumpId == dump.Id) {
@dumpRestoreSuccess
}
} } }
}
} }
@* ── Managed CNPG Clusters ── *@ @if (operatorStatus!.CnpgAvailable) {
Managed PostgreSQL Clusters @if (FilteredManagedClusters is not null) { @FilteredManagedClusters.Count }
@* ── Create Cluster Form ── *@ @if (showCreate) {
New CNPG Cluster
@if (!string.IsNullOrEmpty(createError)) {
@createError
}
} @* ── Import Existing CNPG Cluster Form ── *@ @if (showImportCnpg) {
Import Existing CNPG Cluster

Register a CloudNativePG cluster that is already running in Kubernetes. EntKube will read its configuration from the Cluster CRD and store the superuser credentials in vault. No Kubernetes resources are created or modified.

Kubernetes resource name (metadata.name)
@if (!string.IsNullOrEmpty(importCnpgError)) {
@importCnpgError
}
} @* ── Managed Cluster Cards ── *@ @if (FilteredManagedClusters is null) {
} else if (FilteredManagedClusters.Count == 0 && !showCreate && !showImportCnpg) {
No managed clusters yet. Click "Create Cluster" to provision a new PostgreSQL 18 cluster.
} else { @foreach (CnpgCluster mc in FilteredManagedClusters) {
@mc.Name PG @mc.PostgresVersion @(GetManagedStatusBadge(mc.Status)) @mc.KubernetesCluster.Environment.Name @if (mc.IsExternal) { External } @if (mc.Databases.Count > 0) { @mc.Databases.Count db(s) }
@if (mc.StorageLinkId.HasValue) { }
@* ── CNPG Delete Confirmation ── *@ @if (cnpgDeleteConfirmId == mc.Id) {
Delete @mc.Name? @if (mc.IsExternal) { This is an externally registered cluster. } else { This will remove all Kubernetes resources and cannot be undone. }
@if (mc.IsExternal) { }
} @* ── Cluster-level operation feedback ── *@ @if (clusterMessages.TryGetValue(mc.Id, out string? clusterMsg)) {
@clusterMsg
} @* ── Expanded Detail Panel ── *@ @if (expandedClusterId == mc.Id) {
@if (loadingDetail) {
Querying cluster status...
} else if (clusterDetail is not null) { @* ── Cluster Overview ── *@
Phase
@if (clusterDetail.Phase.Contains("healthy", StringComparison.OrdinalIgnoreCase)) { @clusterDetail.Phase } else if (clusterDetail.Phase.Contains("Creating", StringComparison.OrdinalIgnoreCase) || clusterDetail.Phase.Contains("Setting", StringComparison.OrdinalIgnoreCase)) { @clusterDetail.Phase } else { @clusterDetail.Phase }
Ready
@clusterDetail.ReadyInstances / @mc.Instances
Primary
@(clusterDetail.CurrentPrimary ?? "—")
Info
@mc.KubernetesCluster.Name @mc.Namespace
@* ── Pod Table ── *@
Pods
@if (clusterDetail.Pods.Count > 0) {
@foreach (CnpgPodInfo pod in clusterDetail.Pods) { }
Pod Role Status Ready Node Age Restarts
@pod.Name @if (pod.Role == "primary") { Primary } else { Replica } @if (pod.Status == "Running") { Running } else if (pod.Status == "Pending") { Pending } else { @pod.Status } @if (pod.Ready) { } else { } @(pod.Node ?? "—") @FormatAge(pod.StartTime) @if (pod.Restarts > 0) { @pod.Restarts } else { 0 }
} else {
No pods found. Cluster may still be initializing.
} @* ── Databases ── *@
Databases @mc.Databases.Count
@if (mc.Databases.Count > 0) {
@foreach (CnpgDatabase cdb in mc.Databases) { @if (cnpgDbDeleteConfirmId == cdb.Id) { } @if (dbFixMessageId == cdb.Id && dbFixMessage is not null) { } @if (credentialsDatabaseId == cdb.Id && databaseCredentials is not null) { } }
Database Owner Status Created
@cdb.Name @cdb.Owner @if (cdb.Status == CnpgDatabaseStatus.Ready) { Ready } else if (cdb.Status == CnpgDatabaseStatus.Creating) { Creating } else { @cdb.Status } @cdb.CreatedAt.ToString("yyyy-MM-dd HH:mm") @if (dbOwnershipOk.TryGetValue(cdb.Id, out bool ownerOk) && ownerOk) { } else { }
Drop @cdb.Name? This drops the PostgreSQL database and removes all credentials.
@dbFixMessage
@foreach (KeyValuePair cred in databaseCredentials) { }
@cred.Key @cred.Value
K8s Secret: @($"{mc.Name}-{cdb.Name}-credentials") in namespace @mc.Namespace
} else {

No databases created yet. Use the button to add one.

} @* ── Backups ── *@
Backups @clusterDetail.Backups.Count @if (mc.StorageLinkId.HasValue) { @if (!string.IsNullOrWhiteSpace(mc.BackupSchedule)) { @mc.BackupSchedule } @mc.RetentionDays d max @mc.MaxBackups }
@if (clusterDetail.Backups.Count > 0) {
@foreach (CnpgBackup bk in clusterDetail.Backups) { }
Name Type Status Started Completed Size
@bk.Name @if (bk.Type == CnpgBackupType.Scheduled) { Scheduled } else { On-Demand } @if (bk.Status == CnpgBackupStatus.Completed) { Completed } else if (bk.Status == CnpgBackupStatus.Running) { Running } else { Failed } @bk.StartedAt.ToString("yyyy-MM-dd HH:mm") @(bk.CompletedAt?.ToString("HH:mm") ?? "—") @(bk.SizeBytes.HasValue ? FormatBytes(bk.SizeBytes.Value) : "—")
} else if (mc.StorageLinkId.HasValue) {

No backups yet. Click to create one.

} else {

Backups are not configured. Assign an S3 bucket to enable backups.

} @* ── Backup Restore Panel (CNPG) ── *@ @if (selectedCnpgBackup != null && selectedCnpgBackupCluster?.Id == mc.Id) {
Restore from @selectedCnpgBackup.Name (@(selectedCnpgBackup.CompletedAt?.ToString("yyyy-MM-dd HH:mm") ?? selectedCnpgBackup.StartedAt.ToString("yyyy-MM-dd HH:mm")))
@if (backupRestoreTarget == "current") {
Destructive: Deletes the running cluster and recreates it from this backup. All data after the backup point is lost.
} else if (backupRestoreTarget == "new") {
} else if (backupRestoreTarget == "external") {
CNPG uses physical (Barman) backups that cannot be directly restored to an external PostgreSQL server. Use New cluster to restore within Kubernetes, then use pg_dump to export specific databases.
}
@if (!string.IsNullOrEmpty(backupRestoreError)) {
@backupRestoreError
}
} @if (configuringScheduleId == mc.Id) {
Backup Settings
Leave blank to disable scheduled backups.
Barman WAL/data retention window.
Backup CRs to keep.
@if (scheduleError is not null) {
@scheduleError
} @if (scheduleSuccess is not null) {
@scheduleSuccess
}
} } @if (!string.IsNullOrEmpty(mc.LastError)) {
@mc.LastError
}
} else { @* ── Collapsed Summary Row ── *@
@mc.KubernetesCluster.Name
@mc.Namespace
@mc.Instances instance(s)
@mc.StorageSize
@if (mc.StorageLink is not null) {
@mc.StorageLink.BucketName
} @if (mc.Databases.Count > 0) {
@string.Join(", ", mc.Databases.Select(d => d.Name))
}
}
} } @* ── Add Database Form ── *@ @if (addDbCluster is not null) {
Add Database to @addDbCluster.Name
@if (!string.IsNullOrEmpty(dbError)) {
@dbError
}
} @* ── Import Existing Database Form ── *@ @if (importDbCluster is not null) {
Import Existing Database into @importDbCluster.Name

Register a database that already exists in this CNPG cluster. Click Discover to load the list of databases, or fill in the fields manually. Credentials are stored in vault — no SQL is executed.

@if (discoveredDbs is not null) { @discoveredDbs.Count database(s) found } @if (!string.IsNullOrEmpty(discoverDbError)) { @discoverDbError }
@if (discoveredDbs is { Count: > 0 }) { } else { }
@if (!string.IsNullOrEmpty(importDbError)) {
@importDbError
}
} @* ── Restore Form ── *@ @if (restoreSource is not null) {
Point-in-Time Restore from @restoreSource.Name
@if (!string.IsNullOrEmpty(restoreError)) {
@restoreError
}
} @* ── Upgrade Form ── *@ @if (upgradeCluster is not null) {
Upgrade @upgradeCluster.Name (currently PG @upgradeCluster.PostgresVersion)
@if (!string.IsNullOrEmpty(upgradeError)) {
@upgradeError
}
}
} @* ── Prometheus Metrics for expanded CNPG cluster ── *@ @if (expandedClusterId.HasValue && managedClusters is not null) { CnpgCluster? expandedCnpg = managedClusters.FirstOrDefault(c => c.Id == expandedClusterId.Value); @if (expandedCnpg is not null) {
Prometheus Metrics — @expandedCnpg.Name
} } @* ── Managed MongoDB Clusters ── *@ @if (operatorStatus!.MongoDbAvailable) {
Managed MongoDB Clusters @if (FilteredManagedMongoClusters is not null) { @FilteredManagedMongoClusters.Count }
@* ── Register Existing MongoDB Cluster Form ── *@ @if (showMongoRegister) {
Register Existing MongoDB Cluster

Register a MongoDB cluster that was deployed outside EntKube. This creates a tracking record without modifying the live cluster.

Must match the MongoDBCommunity CR name in Kubernetes (if using community operator)
After registering, use the settings button to store the admin password as a K8s Secret — this enables S3 backup and migration. Version and databases are discovered automatically for community-operator clusters.
@if (!string.IsNullOrEmpty(mongoRegisterError)) {
@mongoRegisterError
}
} @* ── Create MongoDB Cluster Form ── *@ @if (showMongoCreate) {
New MongoDB Cluster
@if (!string.IsNullOrEmpty(mongoCreateError)) {
@mongoCreateError
}
} @* ── Managed MongoDB Cluster Cards ── *@ @if (FilteredManagedMongoClusters is null) {
} else if (FilteredManagedMongoClusters.Count == 0 && !showMongoCreate && !showMongoRegister) {
No managed MongoDB clusters yet. Click "Create Cluster" to provision a new one, or "Register Existing" to track a cluster deployed outside EntKube.
} else { @foreach (MongoCluster mc in FilteredManagedMongoClusters) {
@mc.Name Mongo @mc.MongoVersion @(GetMongoManagedStatusBadge(mc.Status)) @mc.KubernetesCluster.Environment.Name @if (mc.IsExternal) { External @if (mc.StorageLinkId.HasValue) { S3 } } @if (mc.Databases.Count > 0) { @mc.Databases.Count db(s) }
@if (mc.IsExternal) { @if (mc.StorageLinkId.HasValue) { } } else { @if (mc.StorageLinkId.HasValue) { } }
@* ── Delete / Unregister Confirmation ── *@ @if (mongoDeleteConfirmId == mc.Id) {
Delete @mc.Name? @if (mc.IsExternal) { This is an externally registered cluster. } else { This will delete all Kubernetes resources for this cluster. This cannot be undone. }
@if (mc.IsExternal) { }
@if (!string.IsNullOrEmpty(mongoCreateError) && mongoDeletingId == null) {
@mongoCreateError
}
} @* ── External Cluster Settings Panel ── *@ @if (mc.IsExternal && mongoExtSettingsClusterId == mc.Id) {
External Cluster — Credentials & Backup Settings
Stored as a K8s Secret on the cluster — never in EntKube's database. The connection endpoint is derived automatically from the cluster name and namespace, exactly like managed clusters.
Leave blank for on-demand only
@if (!string.IsNullOrEmpty(mongoExtError) && mongoExtSettingsClusterId == mc.Id) {
@mongoExtError
} @if (!string.IsNullOrEmpty(mongoExtSuccess) && mongoExtSettingsClusterId == mc.Id) {
@mongoExtSuccess
}
} @* ── Track External Database Form ── *@ @if (mc.IsExternal && mongoTrackDbClusterId == mc.Id) {
Track Existing Database

Register a database that already exists in this external cluster. No MongoDB commands are run — this only creates a tracking record.

@if (!string.IsNullOrEmpty(mongoTrackDbError) && mongoTrackDbClusterId == mc.Id) {
@mongoTrackDbError
}
} @* ── Migrate External → Managed Form ── *@ @if (mc.IsExternal && mongoMigrateSourceClusterId == mc.Id) {
Migrate to Managed Cluster

Copies all data from @mc.Name (via connection URI) to a managed Community Operator cluster using a direct dump-restore Job. The migration runs on the target cluster's Kubernetes environment. Existing data in the target is overwritten (mongorestore --drop).

@{ var managedMongoTargets = managedMongoClusters?.Where(c => !c.IsExternal).ToList() ?? []; } @if (managedMongoTargets.Count == 0) {
No managed MongoDB clusters available. Create one first.
} else {
} @if (!string.IsNullOrEmpty(mongoMigrateError) && mongoMigrateSourceClusterId == mc.Id) {
@mongoMigrateError
} @if (!string.IsNullOrEmpty(mongoMigrateSuccess) && mongoMigrateSourceClusterId == mc.Id) {
@mongoMigrateSuccess
}
} @* ── Atlas Restore Form ── *@ @if (atlasRestoreClusterId == mc.Id) {
Restore from MongoDB Atlas
Include credentials in the URI. The string is stored as a short-lived K8s Secret and never persisted in EntKube.
Defaults to the same name as source. Existing data in the target database will be dropped.
@if (!string.IsNullOrEmpty(atlasRestoreError)) {
@atlasRestoreError
} @if (!string.IsNullOrEmpty(atlasRestoreSuccess)) {
@atlasRestoreSuccess
}
} @* ── Expanded Mongo Detail Panel ── *@ @if (expandedMongoClusterId == mc.Id) {
@if (mongoLoadingDetail) {
Querying cluster status...
} else if (mongoClusterDetail is not null) { @* ── Cluster Overview ── *@
State
@if (mongoClusterDetail.Phase.Contains("ready", StringComparison.OrdinalIgnoreCase)) { @mongoClusterDetail.Phase } else if (mongoClusterDetail.Phase.Contains("initializing", StringComparison.OrdinalIgnoreCase)) { @mongoClusterDetail.Phase } else { @mongoClusterDetail.Phase }
Ready
@mongoClusterDetail.ReadyMembers / @mc.Members
Endpoint
@(mongoClusterDetail.Endpoint ?? "—")
Info
@mc.KubernetesCluster.Name @mc.Namespace
@if (!string.IsNullOrEmpty(mongoClusterDetail.Message)) {
@mongoClusterDetail.Message
} @* ── Pod Table ── *@
Pods
@if (mongoClusterDetail.Pods.Count > 0) {
@foreach (MongoPodInfo pod in mongoClusterDetail.Pods) { }
Pod Status Ready Node Age Restarts
@pod.Name @if (pod.Status == "Running") { Running } else if (pod.Status == "Pending") { Pending } else { @pod.Status } @if (pod.Ready) { } else { } @(pod.Node ?? "—") @FormatAge(pod.StartTime) @if (pod.Restarts > 0) { @pod.Restarts } else { 0 }
} else {
No pods found. Cluster may still be initializing.
} @* ── Databases ── *@
Databases @mc.Databases.Count
@if (mc.Databases.Count > 0) {
@foreach (MongoDatabase mdb in mc.Databases) { @if (mongoCredDbId == mdb.Id && mongoDbCredentials is not null) { } @if (mongoDbActionErrorId == mdb.Id && !string.IsNullOrEmpty(mongoDbActionError)) { } @if (mongoMigrateDbId == mdb.Id) { } @if (mongoDbDeleteConfirmId == mdb.Id) { } }
Database Status Created
@mdb.Name @if (mdb.Status == MongoDatabaseStatus.Ready) { Ready } else if (mdb.Status == MongoDatabaseStatus.Creating) { Creating } else { @mdb.Status } @mdb.CreatedAt.ToString("yyyy-MM-dd HH:mm") @if (!mc.IsExternal) { }
@foreach (KeyValuePair cred in mongoDbCredentials) { }
@cred.Key @cred.Value
@mongoDbActionError
@if (!string.IsNullOrEmpty(mongoMigrateDbError) && mongoMigrateDbId == mdb.Id) {
@mongoMigrateDbError
} @if (!string.IsNullOrEmpty(mongoMigrateDbSuccess) && mongoMigrateDbId == mdb.Id) {
@mongoMigrateDbSuccess
}
Delete @mdb.Name?
@if (mc.IsExternal) { }
} else if (mc.IsExternal) {

No databases tracked yet. Use the button to track an existing database, or databases are discovered automatically if this cluster uses the MongoDB Community Operator.

} else {

No databases created yet. Use the button to add one.

} @* ── Backups ── *@
Backups @mongoClusterDetail.Backups.Count @if (mc.StorageLinkId.HasValue) { @if (!string.IsNullOrWhiteSpace(mc.BackupSchedule)) { @mc.BackupSchedule } @mc.RetentionDays d max @mc.MaxBackups }
@if (mongoClusterDetail.Backups.Count > 0) {
@foreach (MongoBackup bk in mongoClusterDetail.Backups) { }
Name Type Status Started Completed
@bk.Name @if (bk.Type == MongoBackupType.Scheduled) { Scheduled } else { On-Demand } @if (bk.Status == MongoBackupStatus.Completed) { Completed } else if (bk.Status == MongoBackupStatus.Running) { Running } else { Failed } @bk.StartedAt.ToString("yyyy-MM-dd HH:mm") @(bk.CompletedAt?.ToString("HH:mm") ?? "—")
} else if (mc.StorageLinkId.HasValue) {

No backups yet. Click to create one.

} else if (mc.IsExternal) {

Backups are not configured. Set an admin password and assign an S3 bucket via the settings button to enable backups.

} else {

Backups are not configured. Assign an S3 bucket to enable backups.

} @* ── Backup Restore Panel (MongoDB) ── *@ @if (selectedMongoBackup != null && selectedMongoBackupCluster?.Id == mc.Id) {
Restore from @selectedMongoBackup.Name (@(selectedMongoBackup.CompletedAt?.ToString("yyyy-MM-dd HH:mm") ?? selectedMongoBackup.StartedAt.ToString("yyyy-MM-dd HH:mm")))
@if (backupRestoreTarget == "current") {
Destructive: Runs mongorestore --drop against the running cluster. All collections are replaced with backup data.
} else if (backupRestoreTarget == "new") {
} else if (backupRestoreTarget == "external") {
The backup will be restored (mongorestore --drop) to this external MongoDB instance.
}
@if (!string.IsNullOrEmpty(backupRestoreError)) {
@backupRestoreError
}
} @* ── Backup Settings Panel ── *@ @if (mongoConfiguringScheduleId == mc.Id) {
Backup Settings
@if (!string.IsNullOrEmpty(mongoScheduleError)) {
@mongoScheduleError
} @if (!string.IsNullOrEmpty(mongoScheduleSuccess)) {
@mongoScheduleSuccess
}
} } @if (mongoClusterMessages.TryGetValue(mc.Id, out string? mongoMsg)) {
@mongoMsg
} @if (!string.IsNullOrEmpty(mc.LastError)) {
@mc.LastError
}
} else { @* ── Collapsed Summary Row ── *@
@mc.KubernetesCluster.Name
@mc.Namespace
@mc.Members member(s)
@mc.StorageSize
@if (mc.StorageLink is not null) {
@mc.StorageLink.BucketName
} @if (mc.Databases.Count > 0) {
@string.Join(", ", mc.Databases.Select(d => d.Name))
}
}
} } @* ── Add MongoDB Database Form ── *@ @if (mongoAddDbCluster is not null) {
Add Database to @mongoAddDbCluster.Name
@if (!string.IsNullOrEmpty(mongoDbError)) {
@mongoDbError
}
} @* ── MongoDB Restore Form ── *@ @if (mongoRestoreSource is not null) {
Restore from @mongoRestoreSource.Name
@{ var completedBackups = mongoRestoreSource.Backups .Where(b => b.Status == MongoBackupStatus.Completed) .OrderByDescending(b => b.StartedAt) .ToList(); } @if (!completedBackups.Any()) {
No completed backups available.
}
@if (!string.IsNullOrEmpty(mongoRestoreError)) {
@mongoRestoreError
}
} @* ── MongoDB Resize Form ── *@ @if (mongoResizeCluster is not null) {
Resize @mongoResizeCluster.Name
Storage — patches each PVC directly. Requires allowVolumeExpansion: true on the StorageClass. Can only grow, not shrink.
CPU / Memory — re-applies the manifest; the operator performs a rolling restart.
Leave blank to keep current (@mongoResizeCluster.StorageSize)
Current: @(mongoResizeCluster.CpuRequest ?? "—")
Current: @(mongoResizeCluster.CpuLimit ?? "—")
Current: @(mongoResizeCluster.MemoryRequest ?? "—")
Current: @(mongoResizeCluster.MemoryLimit ?? "—")
@if (!string.IsNullOrEmpty(mongoResizeError)) {
@mongoResizeError
}
} @* ── MongoDB Upgrade Form ── *@ @if (mongoUpgradeCluster is not null) {
Upgrade @mongoUpgradeCluster.Name (currently @mongoUpgradeCluster.MongoVersion)
@if (mongoUpgradeCluster.IsExternal) {
This patches spec.version in the existing MongoDBCommunity CRD. The Community Operator performs a rolling upgrade of replica set members. Existing users, auth config, and StatefulSet settings are preserved.
}
Enter any X.Y.Z version supported by the MongoDB Community Operator
@if (!string.IsNullOrEmpty(mongoUpgradeError)) {
@mongoUpgradeError
}
}
} @* ── Refresh button ── *@
} @code { [Parameter] public Guid TenantId { get; set; } private bool loading = true; private bool refreshing; private DatabaseOperatorStatus? operatorStatus; // Environment tab filter (null = All) private string? selectedEnvironment; private IReadOnlyList AllEnvironments { get { var names = new HashSet(StringComparer.Ordinal); if (managedClusters is not null) foreach (var c in managedClusters) names.Add(c.KubernetesCluster.Environment.Name); if (managedMongoClusters is not null) foreach (var c in managedMongoClusters) names.Add(c.KubernetesCluster.Environment.Name); if (registeredPgInstances is not null) foreach (var i in registeredPgInstances) names.Add(i.KubernetesCluster.Environment.Name); return names.OrderBy(n => n).ToList(); } } private List? FilteredManagedClusters => managedClusters?.Where(c => selectedEnvironment is null || c.KubernetesCluster.Environment.Name == selectedEnvironment).ToList(); private List? FilteredManagedMongoClusters => managedMongoClusters?.Where(c => selectedEnvironment is null || c.KubernetesCluster.Environment.Name == selectedEnvironment).ToList(); // Managed CNPG cluster state private List? managedClusters; private List? availableClusters; private List? storageLinks; // Managed MongoDB cluster state private List? managedMongoClusters; private List? mongoAvailableClusters; // Create cluster form private bool showCreate; private bool creating; private string? createError; // Import existing CNPG cluster form private bool showImportCnpg; private bool importingCnpg; private string? importCnpgError; private Guid importCnpgK8sClusterId; private string importCnpgName = ""; private string importCnpgNamespace = ""; private string createName = ""; private string createNamespace = "databases"; private Guid createClusterId; private string createPostgresVersion = "18"; private int createInstances = 3; private string createStorageSize = "10Gi"; private Guid createStorageLinkId; private string createSchedule = ""; private int createRetentionDays = 30; private int createMaxBackups = 20; // Cluster detail panel private Guid? expandedClusterId; private CnpgClusterDetail? clusterDetail; private bool loadingDetail; // Delete cluster private Guid? deletingId; private Guid? cnpgDeleteConfirmId; private Guid? cnpgDbDeleteConfirmId; // Backup private Guid? backingUpId; private Guid? applyingScheduledBackupId; private Guid? configuringScheduleId; private string scheduleInput = ""; private int scheduleRetentionDays = 30; private int scheduleMaxBackups = 20; private string? scheduleError; private string? scheduleSuccess; private Guid? cleaningUpBackupsId; // Restore form private CnpgCluster? restoreSource; private string restoreName = ""; private DateTime restoreTargetTime = DateTime.UtcNow.AddHours(-1); private bool restoring; private string? restoreError; // Upgrade form private CnpgCluster? upgradeCluster; private string upgradeVersion = "18"; private bool upgrading; private string? upgradeError; // Add database form private CnpgCluster? addDbCluster; private string newDbName = ""; private bool addingDb; private string? dbError; // Import existing database form private CnpgCluster? importDbCluster; private string importDbName = ""; private string importDbOwner = ""; private string importDbPassword = ""; private bool importingDb; private string? importDbError; private bool discoveringDbs; private string? discoverDbError; private List<(string Name, string Owner)>? discoveredDbs; // Database credentials private Guid? credentialsDatabaseId; private Dictionary? databaseCredentials; private Guid? rotatingDatabaseId; // Database permission fix — ownership check results keyed by CnpgDatabase.Id private Guid? dbFixBusyId; private Guid? dbFixMessageId; private string? dbFixMessage; private Dictionary dbOwnershipOk = []; // Cluster-level superuser fetch private Guid? clusterSuperuserFetchingId; private Dictionary clusterMessages = []; // ── MongoDB State ── // Atlas restore form private Guid? atlasRestoreClusterId; private bool atlasRestoring; private string atlasUri = ""; private string atlasSourceDb = ""; private string atlasTargetDb = ""; private string? atlasRestoreError; private string? atlasRestoreSuccess; // Register existing MongoDB cluster form private bool showMongoRegister; private bool mongoRegistering; private string? mongoRegisterError; private string mongoRegName = ""; private string mongoRegNamespace = "database"; private Guid mongoRegClusterId; private string mongoRegExternalUri = ""; // External cluster settings panel private Guid? mongoExtSettingsClusterId; private string mongoExtUri = ""; private Guid mongoExtStorageLinkId; private string mongoExtSchedule = ""; private int mongoExtRetentionDays = 30; private int mongoExtMaxBackups = 20; private bool mongoExtSaving; private string? mongoExtError; private string? mongoExtSuccess; // Track external database form private Guid? mongoTrackDbClusterId; private string mongoTrackDbName = ""; private string mongoTrackDbOwner = ""; private bool mongoTrackingDb; private string? mongoTrackDbError; // Migrate external → managed form private Guid? mongoMigrateSourceClusterId; private Guid mongoMigrateTargetClusterId; private bool mongoMigrating; private string? mongoMigrateError; private string? mongoMigrateSuccess; // Create MongoDB cluster form private bool showMongoCreate; private bool mongoCreating; private string? mongoCreateError; private string mongoCreateName = ""; private string mongoCreateNamespace = "databases"; private Guid mongoCreateClusterId; private string mongoCreateVersion = "8.3.2"; private int mongoCreateMembers = 3; private string mongoCreateStorageSize = "10Gi"; private Guid mongoCreateStorageLinkId; private string mongoCreateSchedule = ""; private int mongoCreateRetentionDays = 30; private int mongoCreateMaxBackups = 20; // Mongo backup settings private Guid? mongoConfiguringScheduleId; private Guid? mongoApplyingScheduleId; private string mongoScheduleInput = ""; private int mongoScheduleRetentionDays = 30; private int mongoScheduleMaxBackups = 20; private string? mongoScheduleError; private string? mongoScheduleSuccess; private Guid? mongoCleaningUpBackupsId; private Dictionary mongoClusterMessages = []; // Mongo cluster detail panel private Guid? expandedMongoClusterId; private MongoClusterDetail? mongoClusterDetail; private bool mongoLoadingDetail; // Mongo delete / unregister confirm private Guid? mongoDeletingId; private Guid? mongoDeleteConfirmId; private Guid? mongoDbDeleteConfirmId; private Guid? mongoDroppingDbId; // Mongo database credentials private Guid? mongoCredDbId; private Dictionary? mongoDbCredentials; private Guid? mongoSyncingDbId; private Guid? mongoRotatingDbId; private Guid? mongoDbActionErrorId; private string? mongoDbActionError; // Per-database migrate private Guid? mongoMigrateDbId; // the MongoDatabase.Id being migrated private Guid? mongoMigrateDbClusterId; // its source MongoCluster.Id private Guid mongoMigrateDbTargetClusterId; private string mongoMigrateDbTargetName = ""; private bool mongoMigratingDb; private string? mongoMigrateDbError; private string? mongoMigrateDbSuccess; // Mongo backup private Guid? mongoBackingUpId; // Mongo restore form private MongoCluster? mongoRestoreSource; private Guid mongoRestoreBackupId; private bool mongoRestoring; private string? mongoRestoreError; // Mongo upgrade form private MongoCluster? mongoUpgradeCluster; private string mongoUpgradeVersion = "8.2.9"; private bool mongoUpgrading; private string? mongoUpgradeError; // Mongo resize form private MongoCluster? mongoResizeCluster; private string mongoResizeStorageSize = ""; private string mongoResizeCpuRequest = ""; private string mongoResizeCpuLimit = ""; private string mongoResizeMemoryRequest = ""; private string mongoResizeMemoryLimit = ""; private bool mongoResizing; private string? mongoResizeError; // Mongo add database form private MongoCluster? mongoAddDbCluster; private string mongoNewDbName = ""; private bool mongoAddingDb; private string? mongoDbError; // ── Registered Postgres State ── private List? registeredPgInstances; private List? allClusters; private List? FilteredRegisteredPgInstances => registeredPgInstances?.Where(i => selectedEnvironment is null || i.KubernetesCluster.Environment.Name == selectedEnvironment).ToList(); // Register form private bool showRegisterPgForm; private string regPgName = ""; private Guid regPgClusterId = Guid.Empty; private string regPgNamespace = ""; private string regPgServiceName = ""; private int regPgPort = 5432; private string regPgAdminPodName = ""; private string regPgAdminUsername = "postgres"; private string regPgAdminPassword = ""; private string regPgNotes = ""; private bool registeringPg; private string? registerPgError; // Per-instance UI private Guid? expandedRegPgId; private Dictionary> regPgPods = []; private Dictionary regPgPodsLoading = []; private Guid? testingRegPgId; private Guid? testRegPgResultId; private string? testRegPgResult; private Guid? deletingRegPgId; private string? deleteRegPgError; // Create database form private RegisteredPostgresInstance? addDbRegPgInstance; private string newRegPgDbName = ""; private bool addingRegPgDb; private string? addRegPgDbError; // Import database form private RegisteredPostgresInstance? importRegPgInstance; private string importRegPgDbName = ""; private string importRegPgOwner = ""; private string importRegPgPassword = ""; private bool importingRegPgDb; private string? importRegPgError; // Database actions private Guid? regPgCredDbId; private Dictionary? regPgCredentials; private Guid? syncingRegPgDbId; private Guid? rotatingRegPgDbId; private Guid? droppingRegPgDbId; private Guid? regPgDbDeleteConfirmId; private Guid? regPgDbActionErrorId; private string? regPgDbActionError; // Backup-driven restore (CNPG and MongoDB) private CnpgBackup? selectedCnpgBackup; private CnpgCluster? selectedCnpgBackupCluster; private MongoBackup? selectedMongoBackup; private MongoCluster? selectedMongoBackupCluster; private string backupRestoreTarget = "new"; private string backupRestoreNewName = ""; private string backupRestoreExternalUri = ""; private bool backupRestoring; private string? backupRestoreError; protected override async Task OnInitializedAsync() { await LoadData(); } private async Task LoadData() { loading = true; StateHasChanged(); operatorStatus = await DatabaseService.GetOperatorStatusAsync(TenantId); if (operatorStatus.CnpgAvailable || operatorStatus.MongoDbAvailable) { // Load managed clusters in parallel. Task> managedTask = operatorStatus.CnpgAvailable ? CnpgService.GetClustersAsync(TenantId) : Task.FromResult(new List()); Task> managedMongoTask = operatorStatus.MongoDbAvailable ? MongoService.GetClustersAsync(TenantId) : Task.FromResult(new List()); await Task.WhenAll(managedTask, managedMongoTask); managedClusters = managedTask.Result; managedMongoClusters = managedMongoTask.Result; } var regPgTask = RegisteredPostgresService.GetInstancesAsync(TenantId); var allClustersTask = DatabaseService.GetAllClustersAsync(TenantId); await Task.WhenAll(regPgTask, allClustersTask); registeredPgInstances = regPgTask.Result; allClusters = allClustersTask.Result; loading = false; } private async Task RefreshAll() { refreshing = true; StateHasChanged(); try { await LoadData(); await RefreshOpenDetailPanels(); } finally { refreshing = false; } } private async Task RefreshOpenDetailPanels() { if (expandedClusterId.HasValue) { try { clusterDetail = await CnpgService.GetClusterDetailAsync(TenantId, expandedClusterId.Value); } catch { } } if (expandedMongoClusterId.HasValue) { try { mongoClusterDetail = await MongoService.GetClusterDetailAsync(TenantId, expandedMongoClusterId.Value); } catch { } } } // ──────── Cluster Detail Panel ──────── private async Task ToggleClusterDetail(CnpgCluster cluster) { // If already expanded, collapse it. if (expandedClusterId == cluster.Id) { expandedClusterId = null; clusterDetail = null; return; } // Expand and fetch live pod/status info from K8s. expandedClusterId = cluster.Id; clusterDetail = null; loadingDetail = true; StateHasChanged(); try { clusterDetail = await CnpgService.GetClusterDetailAsync(TenantId, cluster.Id); } catch (Exception) { clusterDetail = new CnpgClusterDetail { Cluster = cluster, Phase = "Error fetching status" }; } finally { loadingDetail = false; } // Check ownership for each database in the background so we can hide the fix button when not needed. foreach (CnpgDatabase cdb in cluster.Databases) { _ = Task.Run(async () => { try { bool ok = await CnpgService.IsDatabaseOwnershipCorrectAsync(TenantId, cluster.Id, cdb.Id); dbOwnershipOk[cdb.Id] = ok; await InvokeAsync(StateHasChanged); } catch { // Leave button visible if check fails. } }); } } private static string FormatAge(DateTime? startTime) { if (startTime is null) { return "—"; } TimeSpan age = DateTime.UtcNow - startTime.Value.ToUniversalTime(); if (age.TotalDays >= 1) { return $"{(int)age.TotalDays}d {age.Hours}h"; } if (age.TotalHours >= 1) { return $"{(int)age.TotalHours}h {age.Minutes}m"; } return $"{(int)age.TotalMinutes}m"; } private static string FormatBytes(long bytes) { if (bytes >= 1_073_741_824) { return $"{bytes / 1_073_741_824.0:F1} GB"; } if (bytes >= 1_048_576) { return $"{bytes / 1_048_576.0:F1} MB"; } if (bytes >= 1024) { return $"{bytes / 1024.0:F0} KB"; } return $"{bytes} B"; } // ──────── Create Cluster ──────── private async Task ShowCreateForm() { showCreate = true; createError = null; // Load available K8s clusters (those with CNPG installed) and S3 storage links. availableClusters = await DatabaseService.GetCnpgEnabledClustersAsync(TenantId); storageLinks = await StorageService.GetStorageLinksAsync(TenantId); } private void CancelCreate() { showCreate = false; createError = null; createName = ""; createNamespace = "databases"; createClusterId = Guid.Empty; createPostgresVersion = "18"; createInstances = 3; createStorageSize = "10Gi"; createStorageLinkId = Guid.Empty; createSchedule = ""; createRetentionDays = 30; createMaxBackups = 20; } private async Task CreateCluster() { if (string.IsNullOrWhiteSpace(createName) || createClusterId == Guid.Empty) { createError = "Name and target cluster are required."; return; } creating = true; createError = null; StateHasChanged(); try { Guid? storageLinkId = createStorageLinkId != Guid.Empty ? createStorageLinkId : null; string? schedule = !string.IsNullOrWhiteSpace(createSchedule) ? createSchedule : null; await CnpgService.CreateClusterAsync( TenantId, createClusterId, createName.Trim(), createNamespace.Trim(), createInstances, createStorageSize.Trim(), storageLinkId, schedule, createRetentionDays, createMaxBackups, createPostgresVersion.Trim()); CancelCreate(); managedClusters = await CnpgService.GetClustersAsync(TenantId); } catch (Exception ex) { createError = ex.Message; } finally { creating = false; } } // ──────── Import Existing CNPG Cluster ──────── private async Task ShowImportCnpgForm() { showImportCnpg = true; importCnpgError = null; availableClusters = await DatabaseService.GetCnpgEnabledClustersAsync(TenantId); } private void CancelImportCnpg() { showImportCnpg = false; importCnpgError = null; importCnpgK8sClusterId = Guid.Empty; importCnpgName = ""; importCnpgNamespace = ""; } private async Task ImportCnpgCluster() { if (string.IsNullOrWhiteSpace(importCnpgName) || importCnpgK8sClusterId == Guid.Empty || string.IsNullOrWhiteSpace(importCnpgNamespace)) { importCnpgError = "Kubernetes cluster, cluster name, and namespace are required."; return; } importingCnpg = true; importCnpgError = null; StateHasChanged(); try { await CnpgService.ImportClusterAsync( TenantId, importCnpgK8sClusterId, importCnpgName.Trim(), importCnpgNamespace.Trim()); CancelImportCnpg(); managedClusters = await CnpgService.GetClustersAsync(TenantId); } catch (Exception ex) { importCnpgError = ex.Message; } finally { importingCnpg = false; } } // ──────── Delete Cluster ──────── private async Task DeleteCluster(Guid cnpgClusterId) { deletingId = cnpgClusterId; StateHasChanged(); try { await CnpgService.DeleteClusterAsync(TenantId, cnpgClusterId); managedClusters = await CnpgService.GetClustersAsync(TenantId); cnpgDeleteConfirmId = null; } catch (Exception ex) { createError = $"Delete failed: {ex.Message}"; } finally { deletingId = null; } } private async Task UnregisterCluster(Guid cnpgClusterId) { deletingId = cnpgClusterId; StateHasChanged(); try { await CnpgService.UnregisterClusterAsync(TenantId, cnpgClusterId); managedClusters = await CnpgService.GetClustersAsync(TenantId); cnpgDeleteConfirmId = null; } catch (Exception ex) { createError = $"Unregister failed: {ex.Message}"; } finally { deletingId = null; } } // ──────── Backup ──────── private async Task TriggerBackup(Guid cnpgClusterId) { backingUpId = cnpgClusterId; StateHasChanged(); try { await CnpgService.BackupAsync(TenantId, cnpgClusterId); managedClusters = await CnpgService.GetClustersAsync(TenantId); } catch (Exception ex) { createError = $"Backup failed: {ex.Message}"; } finally { backingUpId = null; } } private void ToggleScheduleForm(CnpgCluster cluster) { if (configuringScheduleId == cluster.Id) { CancelScheduleForm(); return; } expandedClusterId = cluster.Id; configuringScheduleId = cluster.Id; scheduleInput = cluster.BackupSchedule ?? ""; scheduleRetentionDays = cluster.RetentionDays; scheduleMaxBackups = cluster.MaxBackups; scheduleError = null; scheduleSuccess = null; } private void CancelScheduleForm() { configuringScheduleId = null; scheduleInput = ""; scheduleError = null; scheduleSuccess = null; } private async Task SaveBackupSettings(Guid cnpgClusterId) { applyingScheduledBackupId = cnpgClusterId; scheduleError = null; scheduleSuccess = null; StateHasChanged(); try { await CnpgService.UpdateBackupSettingsAsync( TenantId, cnpgClusterId, scheduleInput, scheduleRetentionDays, scheduleMaxBackups); scheduleSuccess = "Backup settings saved and applied to Kubernetes."; } catch (Exception ex) { scheduleError = ex.Message; } finally { applyingScheduledBackupId = null; // Always reload so the header reflects the saved values even if K8s apply failed // (the DB is committed before K8s operations, so the data is always up-to-date). managedClusters = await CnpgService.GetClustersAsync(TenantId); } } private async Task CleanupBackups(CnpgCluster cluster) { cleaningUpBackupsId = cluster.Id; StateHasChanged(); try { int deleted = await CnpgService.CleanupOldBackupsAsync(TenantId, cluster.Id); string msg = deleted == 0 ? $"No cleanup needed — {cluster.MaxBackups} or fewer completed backups." : $"Removed {deleted} Backup CR(s) from Kubernetes. Keeping the {cluster.MaxBackups} most recent. Note: backup data in S3 is managed separately by Barman via the retention policy."; clusterMessages[cluster.Id] = msg; // Reload detail so the backup list reflects the deletions. if (expandedClusterId == cluster.Id) clusterDetail = await CnpgService.GetClusterDetailAsync(TenantId, cluster.Id); } catch (Exception ex) { clusterMessages[cluster.Id] = $"Error: {ex.Message}"; } finally { cleaningUpBackupsId = null; } } private async Task ApplyScheduledBackup(Guid cnpgClusterId, string? schedule) { applyingScheduledBackupId = cnpgClusterId; StateHasChanged(); try { await CnpgService.ApplyScheduledBackupAsync(TenantId, cnpgClusterId, schedule); managedClusters = await CnpgService.GetClustersAsync(TenantId); } catch (Exception ex) { createError = $"Apply scheduled backup failed: {ex.Message}"; } finally { applyingScheduledBackupId = null; } } // ──────── Restore ──────── private void ShowRestore(CnpgCluster source) { restoreSource = source; restoreName = $"{source.Name}-restored"; restoreTargetTime = DateTime.UtcNow.AddHours(-1); restoreError = null; } private async Task ExecuteRestore() { if (string.IsNullOrWhiteSpace(restoreName)) { restoreError = "New cluster name is required."; return; } restoring = true; restoreError = null; StateHasChanged(); try { await CnpgService.RestoreAsync( TenantId, restoreSource!.Id, restoreName.Trim(), restoreTargetTime); restoreSource = null; } catch (Exception ex) { restoreError = ex.Message; } finally { managedClusters = await CnpgService.GetClustersAsync(TenantId); restoring = false; } } // ──────── Upgrade ──────── private void ShowUpgrade(CnpgCluster cluster) { upgradeCluster = cluster; upgradeVersion = "18"; upgradeError = null; } private async Task ExecuteUpgrade() { upgrading = true; upgradeError = null; StateHasChanged(); try { // Determine whether this is a minor (rolling) or major (restore-based) upgrade. // Minor: same major version, zero-downtime rolling update. // Major: restore to new cluster, swap name, remove old (~10-30s downtime). int currentMajor = int.Parse(upgradeCluster!.PostgresVersion.Split('.')[0].Split('-')[0]); int targetMajor = int.Parse(upgradeVersion.Split('.')[0].Split('-')[0]); if (targetMajor != currentMajor) { await CnpgService.MajorUpgradeAsync(TenantId, upgradeCluster.Id, upgradeVersion); } else { await CnpgService.UpgradeClusterAsync(TenantId, upgradeCluster.Id, upgradeVersion); } upgradeCluster = null; managedClusters = await CnpgService.GetClustersAsync(TenantId); } catch (Exception ex) { upgradeError = ex.Message; } finally { upgrading = false; } } // ──────── Database Management ──────── private void ShowAddDatabase(CnpgCluster cluster) { addDbCluster = cluster; newDbName = ""; dbError = null; } private async Task AddDatabase() { if (string.IsNullOrWhiteSpace(newDbName)) { dbError = "Database name is required."; return; } addingDb = true; dbError = null; StateHasChanged(); try { await CnpgService.CreateDatabaseAsync(TenantId, addDbCluster!.Id, newDbName.Trim()); addDbCluster = null; managedClusters = await CnpgService.GetClustersAsync(TenantId); } catch (Exception ex) { dbError = ex.Message; } finally { addingDb = false; } } private void ShowImportDatabase(CnpgCluster cluster) { importDbCluster = cluster; importDbName = ""; importDbOwner = ""; importDbPassword = ""; importDbError = null; discoverDbError = null; discoveredDbs = null; } private async Task DiscoverCnpgDatabases() { discoveringDbs = true; discoverDbError = null; StateHasChanged(); try { discoveredDbs = await CnpgService.DiscoverDatabasesAsync(TenantId, importDbCluster!.Id); } catch (Exception ex) { discoverDbError = ex.Message; } finally { discoveringDbs = false; } } private void OnImportDbNameChanged() { if (discoveredDbs is null) return; (string Name, string Owner) match = discoveredDbs.FirstOrDefault(d => d.Name == importDbName); if (match != default && !string.IsNullOrEmpty(match.Owner)) importDbOwner = match.Owner; } private async Task ImportCnpgDatabase() { if (string.IsNullOrWhiteSpace(importDbName) || string.IsNullOrWhiteSpace(importDbOwner) || string.IsNullOrWhiteSpace(importDbPassword)) { importDbError = "Database name, owner role, and password are required."; return; } importingDb = true; importDbError = null; StateHasChanged(); try { await CnpgService.ImportDatabaseAsync( TenantId, importDbCluster!.Id, importDbName.Trim(), importDbOwner.Trim(), importDbPassword); importDbCluster = null; managedClusters = await CnpgService.GetClustersAsync(TenantId); } catch (Exception ex) { importDbError = ex.Message; } finally { importingDb = false; } } private async Task DeleteDatabase(Guid cnpgClusterId, Guid databaseId) { try { await CnpgService.DeleteDatabaseAsync(TenantId, cnpgClusterId, databaseId); managedClusters = await CnpgService.GetClustersAsync(TenantId); cnpgDbDeleteConfirmId = null; } catch (Exception ex) { dbError = $"Failed to delete database: {ex.Message}"; } } private async Task ToggleDatabaseCredentials(Guid cnpgClusterId, Guid databaseId) { // Toggle off if already showing this database's credentials. if (credentialsDatabaseId == databaseId) { credentialsDatabaseId = null; databaseCredentials = null; return; } // Fetch credentials from the vault and display them. try { databaseCredentials = await CnpgService.GetDatabaseCredentialsAsync(TenantId, databaseId); credentialsDatabaseId = databaseId; } catch (Exception ex) { dbError = $"Failed to load credentials: {ex.Message}"; } } private async Task SyncDatabaseCredentialsToK8s(Guid cnpgClusterId, Guid databaseId) { try { await CnpgService.SyncDatabaseCredentialsToK8sAsync(TenantId, cnpgClusterId, databaseId); dbError = null; } catch (Exception ex) { dbError = $"Failed to sync credentials to K8s: {ex.Message}"; } } private async Task RotateDatabasePassword(Guid cnpgClusterId, Guid databaseId) { rotatingDatabaseId = databaseId; dbError = null; try { await CnpgService.RotateDatabasePasswordAsync(TenantId, cnpgClusterId, databaseId); // Refresh credentials panel if it was open for this database. if (credentialsDatabaseId == databaseId) { databaseCredentials = await CnpgService.GetDatabaseCredentialsAsync(TenantId, databaseId); } } catch (Exception ex) { dbError = $"Failed to rotate password: {ex.Message}"; } finally { rotatingDatabaseId = null; } } private async Task FixDatabaseOwnerPermissions(CnpgCluster cluster, CnpgDatabase database) { dbFixBusyId = database.Id; dbFixMessageId = database.Id; dbFixMessage = null; try { await CnpgService.GrantDatabaseOwnerPermissionsAsync(TenantId, cluster.Id, database.Id); dbOwnershipOk[database.Id] = true; dbFixMessage = $"Permissions granted to '{database.Owner}' on all objects in schema public."; } catch (Exception ex) { dbFixMessage = $"Error: {ex.Message}"; } finally { dbFixBusyId = null; } } private async Task FetchClusterSuperuserPassword(CnpgCluster cluster) { clusterSuperuserFetchingId = cluster.Id; clusterMessages.Remove(cluster.Id); StateHasChanged(); try { await CnpgService.FetchSuperuserCredentialsAsync(TenantId, cluster.Id); (string? user, _) = await CnpgService.GetSuperuserCredentialsAsync(TenantId, cluster.Id); clusterMessages[cluster.Id] = $"Superuser credentials stored (user: {user ?? "postgres"})."; } catch (Exception ex) { clusterMessages[cluster.Id] = $"Error: {ex.Message}"; } finally { clusterSuperuserFetchingId = null; StateHasChanged(); } } private async Task ReleaseLiquibaseLock(CnpgCluster cluster, CnpgDatabase database) { dbFixBusyId = database.Id; dbFixMessageId = database.Id; dbFixMessage = null; try { await CnpgService.ReleaseLiquibaseLockAsync(TenantId, cluster.Id, database.Id); dbFixMessage = "Liquibase lock released — restart Keycloak pods to resume startup."; } catch (Exception ex) { dbFixMessage = $"Error: {ex.Message}"; } finally { dbFixBusyId = null; } } // ──────── Status Helpers ──────── // ──────── Managed MongoDB Methods ──────── private void ShowAtlasRestore(MongoCluster mc) { atlasRestoreClusterId = mc.Id; atlasUri = ""; atlasSourceDb = ""; atlasTargetDb = ""; atlasRestoreError = null; atlasRestoreSuccess = null; } private void CancelAtlasRestore() { atlasRestoreClusterId = null; atlasRestoreError = null; atlasRestoreSuccess = null; } private async Task StartAtlasRestore(MongoCluster mc) { if (string.IsNullOrWhiteSpace(atlasUri) || string.IsNullOrWhiteSpace(atlasSourceDb)) { atlasRestoreError = "Connection string and source database are required."; return; } string targetDb = string.IsNullOrWhiteSpace(atlasTargetDb) ? atlasSourceDb.Trim() : atlasTargetDb.Trim(); atlasRestoring = true; atlasRestoreError = null; atlasRestoreSuccess = null; try { await MongoService.RestoreFromAtlasAsync( TenantId, mc.Id, atlasUri.Trim(), atlasSourceDb.Trim(), targetDb); atlasRestoreSuccess = $"Restore job started. The Job is now running in namespace '{mc.Namespace}' — check your cluster for progress."; atlasUri = ""; } catch (Exception ex) { atlasRestoreError = ex.Message; } finally { atlasRestoring = false; } } private async Task ShowMongoRegisterForm() { showMongoRegister = true; mongoRegisterError = null; mongoAvailableClusters = await DatabaseService.GetMongoEnabledClustersAsync(TenantId); } private void CancelMongoRegister() { showMongoRegister = false; mongoRegisterError = null; mongoRegName = ""; mongoRegNamespace = "database"; mongoRegClusterId = Guid.Empty; mongoRegExternalUri = ""; } private async Task RegisterMongoCluster() { if (string.IsNullOrWhiteSpace(mongoRegName) || mongoRegClusterId == Guid.Empty || string.IsNullOrWhiteSpace(mongoRegNamespace)) { mongoRegisterError = "Name, namespace, and target cluster are required."; return; } mongoRegistering = true; mongoRegisterError = null; try { await MongoService.RegisterExistingClusterAsync( TenantId, mongoRegClusterId, mongoRegName.Trim(), mongoRegNamespace.Trim(), string.IsNullOrWhiteSpace(mongoRegExternalUri) ? null : mongoRegExternalUri.Trim()); managedMongoClusters = await MongoService.GetClustersAsync(TenantId); CancelMongoRegister(); } catch (Exception ex) { mongoRegisterError = ex.Message; } finally { mongoRegistering = false; } } private async Task ShowMongoCreateForm() { showMongoCreate = true; mongoCreateError = null; mongoAvailableClusters = await DatabaseService.GetMongoEnabledClustersAsync(TenantId); storageLinks ??= await StorageService.GetStorageLinksAsync(TenantId); } private void CancelMongoCreate() { showMongoCreate = false; mongoCreateError = null; mongoCreateName = ""; mongoCreateNamespace = "databases"; mongoCreateClusterId = Guid.Empty; mongoCreateVersion = "8.3.2"; mongoCreateMembers = 3; mongoCreateStorageSize = "10Gi"; mongoCreateStorageLinkId = Guid.Empty; mongoCreateSchedule = ""; mongoCreateRetentionDays = 30; mongoCreateMaxBackups = 20; } private async Task CreateMongoCluster() { if (string.IsNullOrWhiteSpace(mongoCreateName) || mongoCreateClusterId == Guid.Empty) { mongoCreateError = "Name and target cluster are required."; return; } mongoCreating = true; mongoCreateError = null; StateHasChanged(); try { Guid? storageLinkId = mongoCreateStorageLinkId != Guid.Empty ? mongoCreateStorageLinkId : null; string? schedule = !string.IsNullOrWhiteSpace(mongoCreateSchedule) ? mongoCreateSchedule : null; await MongoService.CreateClusterAsync( TenantId, mongoCreateClusterId, mongoCreateName.Trim(), mongoCreateNamespace.Trim(), mongoCreateMembers, mongoCreateStorageSize.Trim(), storageLinkId, schedule, mongoCreateRetentionDays, mongoCreateMaxBackups, mongoCreateVersion.Trim()); CancelMongoCreate(); managedMongoClusters = await MongoService.GetClustersAsync(TenantId); } catch (Exception ex) { mongoCreateError = ex.Message; } finally { mongoCreating = false; } } private void InitiateDeleteMongoCluster(MongoCluster cluster) { mongoDeleteConfirmId = cluster.Id; } private async Task DeleteMongoCluster(Guid mongoClusterId) { mongoDeletingId = mongoClusterId; StateHasChanged(); try { await MongoService.DeleteClusterAsync(TenantId, mongoClusterId); managedMongoClusters = await MongoService.GetClustersAsync(TenantId); mongoDeleteConfirmId = null; } catch (Exception ex) { mongoCreateError = $"Delete failed: {ex.Message}"; } finally { mongoDeletingId = null; } } private async Task UnregisterMongoCluster(Guid mongoClusterId) { mongoDeletingId = mongoClusterId; StateHasChanged(); try { await MongoService.UnregisterClusterAsync(TenantId, mongoClusterId); managedMongoClusters = await MongoService.GetClustersAsync(TenantId); mongoDeleteConfirmId = null; } catch (Exception ex) { mongoCreateError = $"Unregister failed: {ex.Message}"; } finally { mongoDeletingId = null; } } private void ToggleMongoScheduleForm(MongoCluster cluster) { if (mongoConfiguringScheduleId == cluster.Id) { CancelMongoScheduleForm(); return; } expandedMongoClusterId = cluster.Id; mongoConfiguringScheduleId = cluster.Id; mongoScheduleInput = cluster.BackupSchedule ?? ""; mongoScheduleRetentionDays = cluster.RetentionDays; mongoScheduleMaxBackups = cluster.MaxBackups; mongoScheduleError = null; mongoScheduleSuccess = null; } private void CancelMongoScheduleForm() { mongoConfiguringScheduleId = null; mongoScheduleError = null; mongoScheduleSuccess = null; } private async Task SaveMongoBackupSettings(Guid mongoClusterId) { mongoApplyingScheduleId = mongoClusterId; try { await MongoService.UpdateBackupSettingsAsync(TenantId, mongoClusterId, mongoScheduleInput, mongoScheduleRetentionDays, mongoScheduleMaxBackups); mongoScheduleSuccess = "Backup settings saved and applied to Kubernetes."; } catch (Exception ex) { mongoScheduleError = ex.Message; } finally { mongoApplyingScheduleId = null; managedMongoClusters = await MongoService.GetClustersAsync(TenantId); } } private async Task CleanupMongoBackups(MongoCluster cluster) { mongoCleaningUpBackupsId = cluster.Id; try { int deleted = await MongoService.CleanupOldBackupsAsync(TenantId, cluster.Id); string msg = deleted == 0 ? $"No cleanup needed — {cluster.MaxBackups} or fewer completed backups." : $"Removed {deleted} backup Job(s) from Kubernetes. Keeping the {cluster.MaxBackups} most recent. Note: backup data in S3 is managed separately via S3 lifecycle rules."; mongoClusterMessages[cluster.Id] = msg; if (expandedMongoClusterId == cluster.Id) mongoClusterDetail = await MongoService.GetClusterDetailAsync(TenantId, cluster.Id); } catch (Exception ex) { mongoClusterMessages[cluster.Id] = $"Error: {ex.Message}"; } finally { mongoCleaningUpBackupsId = null; } } private async Task TriggerMongoBackup(Guid mongoClusterId) { mongoBackingUpId = mongoClusterId; StateHasChanged(); try { await MongoService.BackupAsync(TenantId, mongoClusterId); managedMongoClusters = await MongoService.GetClustersAsync(TenantId); await RefreshOpenDetailPanels(); } catch (Exception ex) { mongoCreateError = $"Backup failed: {ex.Message}"; } finally { mongoBackingUpId = null; } } private void ShowMongoRestore(MongoCluster source) { mongoRestoreSource = source; mongoRestoreBackupId = source.Backups .Where(b => b.Status == MongoBackupStatus.Completed) .OrderByDescending(b => b.StartedAt) .Select(b => b.Id) .FirstOrDefault(); mongoRestoreError = null; } private async Task ExecuteMongoRestore() { if (mongoRestoreBackupId == Guid.Empty) { mongoRestoreError = "Select a backup to restore from."; return; } mongoRestoring = true; mongoRestoreError = null; StateHasChanged(); try { await MongoService.RestoreAsync( TenantId, mongoRestoreSource!.Id, mongoRestoreBackupId); mongoRestoreSource = null; managedMongoClusters = await MongoService.GetClustersAsync(TenantId); } catch (Exception ex) { mongoRestoreError = ex.Message; } finally { mongoRestoring = false; } } // ──────── Backup-Driven Restore ──────── private void SelectCnpgBackup(CnpgCluster cluster, CnpgBackup backup) { if (selectedCnpgBackup?.Name == backup.Name && selectedCnpgBackupCluster?.Id == cluster.Id) { selectedCnpgBackup = null; selectedCnpgBackupCluster = null; backupRestoreError = null; return; } selectedCnpgBackup = backup; selectedCnpgBackupCluster = cluster; selectedMongoBackup = null; selectedMongoBackupCluster = null; backupRestoreTarget = "new"; backupRestoreNewName = $"{cluster.Name}-restored"; backupRestoreExternalUri = ""; backupRestoreError = null; } private void SelectMongoBackup(MongoCluster cluster, MongoBackup backup) { if (selectedMongoBackup?.Name == backup.Name && selectedMongoBackupCluster?.Id == cluster.Id) { selectedMongoBackup = null; selectedMongoBackupCluster = null; backupRestoreError = null; return; } selectedMongoBackup = backup; selectedMongoBackupCluster = cluster; selectedCnpgBackup = null; selectedCnpgBackupCluster = null; backupRestoreTarget = "new"; backupRestoreNewName = $"{cluster.Name}-restored"; backupRestoreExternalUri = ""; backupRestoreError = null; } private async Task ExecuteBackupRestore() { backupRestoring = true; backupRestoreError = null; StateHasChanged(); try { if (selectedCnpgBackup != null) { switch (backupRestoreTarget) { case "current": await CnpgService.RestoreInPlaceAsync( TenantId, selectedCnpgBackupCluster!.Id, selectedCnpgBackup.CompletedAt ?? DateTime.UtcNow); break; case "new": if (string.IsNullOrWhiteSpace(backupRestoreNewName)) { backupRestoreError = "New cluster name is required."; return; } await CnpgService.RestoreAsync( TenantId, selectedCnpgBackupCluster!.Id, backupRestoreNewName.Trim(), barmanBackupId: selectedCnpgBackup.BarmanId); break; default: return; } } else if (selectedMongoBackup != null) { switch (backupRestoreTarget) { case "current": await MongoService.RestoreAsync( TenantId, selectedMongoBackupCluster!.Id, selectedMongoBackup.Id); break; case "new": if (string.IsNullOrWhiteSpace(backupRestoreNewName)) { backupRestoreError = "New cluster name is required."; return; } await MongoService.RestoreToNewClusterAsync( TenantId, selectedMongoBackupCluster!.Id, selectedMongoBackup.Id, backupRestoreNewName.Trim()); break; case "external": if (string.IsNullOrWhiteSpace(backupRestoreExternalUri)) { backupRestoreError = "External connection URI is required."; return; } await MongoService.RestoreToExternalAsync( TenantId, selectedMongoBackupCluster!.Id, selectedMongoBackup.Id, backupRestoreExternalUri.Trim()); break; } } selectedCnpgBackup = null; selectedCnpgBackupCluster = null; selectedMongoBackup = null; selectedMongoBackupCluster = null; } catch (Exception ex) { backupRestoreError = ex.Message; } finally { managedClusters = await CnpgService.GetClustersAsync(TenantId); managedMongoClusters = await MongoService.GetClustersAsync(TenantId); backupRestoring = false; } } private void ShowMongoUpgrade(MongoCluster cluster) { mongoUpgradeCluster = cluster; mongoUpgradeVersion = "8.2.9"; mongoUpgradeError = null; } private async Task ExecuteMongoUpgrade() { if (string.IsNullOrWhiteSpace(mongoUpgradeVersion)) { mongoUpgradeError = "Version is required (e.g. 8.2.9)."; return; } mongoUpgrading = true; mongoUpgradeError = null; StateHasChanged(); try { await MongoService.UpgradeClusterAsync(TenantId, mongoUpgradeCluster!.Id, mongoUpgradeVersion); mongoUpgradeCluster = null; managedMongoClusters = await MongoService.GetClustersAsync(TenantId); } catch (Exception ex) { mongoUpgradeError = ex.Message; } finally { mongoUpgrading = false; } } private void ShowMongoResize(MongoCluster cluster) { mongoResizeCluster = cluster; mongoResizeStorageSize = ""; mongoResizeCpuRequest = cluster.CpuRequest ?? ""; mongoResizeCpuLimit = cluster.CpuLimit ?? ""; mongoResizeMemoryRequest = cluster.MemoryRequest ?? ""; mongoResizeMemoryLimit = cluster.MemoryLimit ?? ""; mongoResizeError = null; } private async Task ExecuteMongoResize() { if (string.IsNullOrWhiteSpace(mongoResizeStorageSize) && string.IsNullOrWhiteSpace(mongoResizeCpuRequest) && string.IsNullOrWhiteSpace(mongoResizeCpuLimit) && string.IsNullOrWhiteSpace(mongoResizeMemoryRequest) && string.IsNullOrWhiteSpace(mongoResizeMemoryLimit)) { mongoResizeError = "Enter at least one value to change."; return; } mongoResizing = true; mongoResizeError = null; StateHasChanged(); try { await MongoService.ResizeClusterAsync( TenantId, mongoResizeCluster!.Id, string.IsNullOrWhiteSpace(mongoResizeStorageSize) ? null : mongoResizeStorageSize, string.IsNullOrWhiteSpace(mongoResizeCpuRequest) ? null : mongoResizeCpuRequest, string.IsNullOrWhiteSpace(mongoResizeCpuLimit) ? null : mongoResizeCpuLimit, string.IsNullOrWhiteSpace(mongoResizeMemoryRequest) ? null : mongoResizeMemoryRequest, string.IsNullOrWhiteSpace(mongoResizeMemoryLimit) ? null : mongoResizeMemoryLimit); mongoResizeCluster = null; managedMongoClusters = await MongoService.GetClustersAsync(TenantId); } catch (Exception ex) { mongoResizeError = ex.Message; } finally { mongoResizing = false; } } private void ShowMongoAddDatabase(MongoCluster cluster) { mongoAddDbCluster = cluster; mongoNewDbName = ""; mongoDbError = null; } private async Task AddMongoDatabase() { if (string.IsNullOrWhiteSpace(mongoNewDbName)) { mongoDbError = "Database name is required."; return; } mongoAddingDb = true; mongoDbError = null; StateHasChanged(); try { await MongoService.CreateDatabaseAsync(TenantId, mongoAddDbCluster!.Id, mongoNewDbName.Trim()); mongoAddDbCluster = null; managedMongoClusters = await MongoService.GetClustersAsync(TenantId); } catch (Exception ex) { mongoDbError = ex.Message; } finally { mongoAddingDb = false; } } private void InitiateDeleteMongoDatabase(MongoCluster cluster, Guid databaseId) { mongoDbDeleteConfirmId = databaseId; } private async Task DeleteMongoDatabase(Guid mongoClusterId, Guid databaseId) { mongoDroppingDbId = databaseId; StateHasChanged(); try { await MongoService.DeleteDatabaseAsync(TenantId, mongoClusterId, databaseId); managedMongoClusters = await MongoService.GetClustersAsync(TenantId); mongoDbDeleteConfirmId = null; } catch (Exception ex) { mongoDbError = $"Failed to delete database: {ex.Message}"; } finally { mongoDroppingDbId = null; } } private async Task UnregisterMongoDatabase(Guid mongoClusterId, Guid databaseId) { mongoDroppingDbId = databaseId; StateHasChanged(); try { await MongoService.UnregisterDatabaseAsync(TenantId, mongoClusterId, databaseId); managedMongoClusters = await MongoService.GetClustersAsync(TenantId); mongoDbDeleteConfirmId = null; } catch (Exception ex) { mongoDbError = $"Failed to unregister database: {ex.Message}"; } finally { mongoDroppingDbId = null; } } // ── MongoDB database credentials ── private async Task ToggleMongoDatabaseCredentials(Guid mongoClusterId, Guid databaseId) { if (mongoCredDbId == databaseId) { mongoCredDbId = null; mongoDbCredentials = null; return; } mongoCredDbId = databaseId; mongoDbCredentials = null; mongoDbActionError = null; mongoDbActionErrorId = null; try { mongoDbCredentials = await MongoService.GetDatabaseCredentialsAsync(TenantId, databaseId); } catch (Exception ex) { mongoDbActionError = ex.Message; mongoDbActionErrorId = databaseId; mongoCredDbId = null; } } private async Task SyncMongoDatabaseCredentials(Guid mongoClusterId, Guid databaseId) { mongoSyncingDbId = databaseId; mongoDbActionError = null; mongoDbActionErrorId = null; StateHasChanged(); try { await MongoService.SyncDatabaseCredentialsToK8sAsync(TenantId, mongoClusterId, databaseId); } catch (Exception ex) { mongoDbActionError = ex.Message; mongoDbActionErrorId = databaseId; } finally { mongoSyncingDbId = null; } } private async Task RotateMongoDatabasePassword(Guid mongoClusterId, Guid databaseId) { mongoRotatingDbId = databaseId; mongoDbActionError = null; mongoDbActionErrorId = null; mongoCredDbId = null; mongoDbCredentials = null; StateHasChanged(); try { await MongoService.RotateDatabasePasswordAsync(TenantId, mongoClusterId, databaseId); } catch (Exception ex) { mongoDbActionError = ex.Message; mongoDbActionErrorId = databaseId; } finally { mongoRotatingDbId = null; } } // ── MongoDB per-database migrate ── private void ShowMongoDatabaseMigrate(MongoCluster sourceCluster, MongoDatabase db) { mongoMigrateDbId = db.Id; mongoMigrateDbClusterId = sourceCluster.Id; mongoMigrateDbTargetName = db.Name; // Default to first other managed cluster in the same environment, if available. mongoMigrateDbTargetClusterId = managedMongoClusters? .FirstOrDefault(c => !c.IsExternal && c.Id != sourceCluster.Id)?.Id ?? Guid.Empty; mongoMigrateDbError = null; mongoMigrateDbSuccess = null; } private async Task ExecuteMongoDatabaseMigrate() { if (mongoMigrateDbTargetClusterId == Guid.Empty) { mongoMigrateDbError = "Select a target cluster."; return; } mongoMigratingDb = true; mongoMigrateDbError = null; mongoMigrateDbSuccess = null; StateHasChanged(); try { await MongoService.MigrateDatabaseAsync( TenantId, mongoMigrateDbClusterId!.Value, mongoMigrateDbId!.Value, mongoMigrateDbTargetClusterId, mongoMigrateDbTargetName.Trim()); mongoMigrateDbSuccess = "Migration job started. Check the source cluster's Jobs for progress."; } catch (Exception ex) { mongoMigrateDbError = ex.Message; } finally { mongoMigratingDb = false; } } // ── External cluster settings ── private async Task ShowMongoExtSettings(MongoCluster cluster) { storageLinks ??= await StorageService.GetStorageLinksAsync(TenantId); mongoExtSettingsClusterId = cluster.Id; mongoExtUri = ""; // admin password — never pre-filled for security mongoExtStorageLinkId = cluster.StorageLinkId ?? Guid.Empty; mongoExtSchedule = cluster.BackupSchedule ?? ""; mongoExtRetentionDays = cluster.RetentionDays; mongoExtMaxBackups = cluster.MaxBackups; mongoExtError = null; mongoExtSuccess = null; } private async Task SaveMongoExtSettings(Guid mongoClusterId) { mongoExtSaving = true; mongoExtError = null; mongoExtSuccess = null; StateHasChanged(); try { Guid? linkId = mongoExtStorageLinkId != Guid.Empty ? mongoExtStorageLinkId : null; // mongoExtUri holds the admin password (blank = keep existing K8s Secret unchanged) await MongoService.UpdateExternalClusterSettingsAsync( TenantId, mongoClusterId, string.IsNullOrWhiteSpace(mongoExtUri) ? null : mongoExtUri.Trim(), linkId, string.IsNullOrWhiteSpace(mongoExtSchedule) ? null : mongoExtSchedule.Trim(), mongoExtRetentionDays, mongoExtMaxBackups); managedMongoClusters = await MongoService.GetClustersAsync(TenantId); mongoExtSuccess = "Settings saved."; } catch (Exception ex) { mongoExtError = ex.Message; } finally { mongoExtSaving = false; } } // ── Track external database ── private void ShowMongoTrackDb(MongoCluster cluster) { mongoTrackDbClusterId = cluster.Id; mongoTrackDbName = ""; mongoTrackDbOwner = ""; mongoTrackDbError = null; } private async Task TrackExternalDatabase() { if (string.IsNullOrWhiteSpace(mongoTrackDbName)) { mongoTrackDbError = "Database name is required."; return; } mongoTrackingDb = true; mongoTrackDbError = null; StateHasChanged(); try { await MongoService.RegisterExternalDatabaseAsync( TenantId, mongoTrackDbClusterId!.Value, mongoTrackDbName.Trim(), string.IsNullOrWhiteSpace(mongoTrackDbOwner) ? mongoTrackDbName.Trim() : mongoTrackDbOwner.Trim()); mongoTrackDbClusterId = null; managedMongoClusters = await MongoService.GetClustersAsync(TenantId); } catch (Exception ex) { mongoTrackDbError = ex.Message; } finally { mongoTrackingDb = false; } } // ── Migrate external → managed ── private void ShowMongoMigrate(MongoCluster source) { mongoMigrateSourceClusterId = source.Id; var managed = managedMongoClusters?.Where(c => !c.IsExternal).ToList(); mongoMigrateTargetClusterId = managed?.FirstOrDefault()?.Id ?? Guid.Empty; mongoMigrateError = null; mongoMigrateSuccess = null; } private async Task ExecuteMongoMigrate() { if (mongoMigrateTargetClusterId == Guid.Empty) { mongoMigrateError = "Select a target managed cluster."; return; } mongoMigrating = true; mongoMigrateError = null; mongoMigrateSuccess = null; StateHasChanged(); try { await MongoService.MigrateFromExternalToManagedAsync( TenantId, mongoMigrateSourceClusterId!.Value, mongoMigrateTargetClusterId); mongoMigrateSuccess = "Migration job started. Check the target cluster's Jobs for progress."; } catch (Exception ex) { mongoMigrateError = ex.Message; } finally { mongoMigrating = false; } } private async Task ToggleMongoClusterDetail(MongoCluster cluster) { if (expandedMongoClusterId == cluster.Id) { expandedMongoClusterId = null; mongoClusterDetail = null; return; } expandedMongoClusterId = cluster.Id; mongoClusterDetail = null; mongoLoadingDetail = true; StateHasChanged(); try { mongoClusterDetail = await MongoService.GetClusterDetailAsync(TenantId, cluster.Id); } catch (Exception) { mongoClusterDetail = new MongoClusterDetail { Cluster = cluster, Phase = "Error fetching status" }; } finally { mongoLoadingDetail = false; } } private static RenderFragment GetMongoManagedStatusBadge(MongoClusterStatus status) => status switch { MongoClusterStatus.Running => __builder => { Running }, MongoClusterStatus.Creating => __builder => { Creating }, MongoClusterStatus.Upgrading => __builder => { Upgrading }, MongoClusterStatus.Restoring => __builder => { Restoring }, MongoClusterStatus.Failed => __builder => { Failed }, MongoClusterStatus.Deleting => __builder => { Deleting }, _ => __builder => { @status } }; // ──────── Status Helpers (CNPG) ──────── private static RenderFragment GetStatusBadge(string status) => status.ToLowerInvariant() switch { "cluster in healthy state" or "healthy" or "running" => __builder => { Healthy }, "setting up primary" or "creating primary" or "pending" => __builder => { Provisioning }, "failed" or "error" => __builder => { Failed }, _ => __builder => { @status } }; // ──────── Registered PostgreSQL ──────── private void ShowRegisterPgForm() { showRegisterPgForm = true; registerPgError = null; } private void CancelRegisterPgForm() { showRegisterPgForm = false; regPgName = ""; regPgClusterId = Guid.Empty; regPgNamespace = ""; regPgServiceName = ""; regPgPort = 5432; regPgAdminPodName = ""; regPgAdminUsername = "postgres"; regPgAdminPassword = ""; regPgNotes = ""; registerPgError = null; } private async Task RegisterPgInstance() { registerPgError = null; if (string.IsNullOrWhiteSpace(regPgName) || regPgClusterId == Guid.Empty || string.IsNullOrWhiteSpace(regPgNamespace) || string.IsNullOrWhiteSpace(regPgServiceName) || string.IsNullOrWhiteSpace(regPgAdminPodName) || string.IsNullOrWhiteSpace(regPgAdminUsername) || string.IsNullOrWhiteSpace(regPgAdminPassword)) { registerPgError = "Please fill in all required fields."; return; } registeringPg = true; try { await RegisteredPostgresService.RegisterInstanceAsync( TenantId, regPgClusterId, regPgName.Trim(), regPgNamespace.Trim(), regPgServiceName.Trim(), regPgPort, regPgAdminPodName.Trim(), regPgAdminUsername.Trim(), regPgAdminPassword, string.IsNullOrWhiteSpace(regPgNotes) ? null : regPgNotes.Trim(), testConnection: true); registeredPgInstances = await RegisteredPostgresService.GetInstancesAsync(TenantId); CancelRegisterPgForm(); } catch (Exception ex) { registerPgError = ex.Message; } finally { registeringPg = false; } } private async Task ToggleRegPgDetail(Guid instanceId) { expandedRegPgId = expandedRegPgId == instanceId ? null : instanceId; if (expandedRegPgId is null) { addDbRegPgInstance = null; importRegPgInstance = null; regPgCredDbId = null; regPgCredentials = null; dumpsByDatabase = null; showDumpFormForDbId = null; showMigrateFormForDbId = null; showRestoreFormForDumpId = null; regPgPods.Clear(); regPgPodsLoading.Clear(); } else { await LoadDumpsForInstance(instanceId); regPgPodsLoading[instanceId] = true; StateHasChanged(); regPgPods[instanceId] = await RegisteredPostgresService.GetPodsAsync(TenantId, instanceId); regPgPodsLoading[instanceId] = false; } } private async Task TestRegPgConnection(Guid instanceId) { testingRegPgId = instanceId; testRegPgResultId = null; testRegPgResult = null; try { await RegisteredPostgresService.TestConnectionAsync(TenantId, instanceId); testRegPgResult = "OK — connection successful."; } catch (Exception ex) { testRegPgResult = $"Failed: {ex.Message}"; } finally { testingRegPgId = null; testRegPgResultId = instanceId; } } private async Task DeleteRegPgInstance(Guid instanceId) { deletingRegPgId = instanceId; deleteRegPgError = null; try { await RegisteredPostgresService.DeleteInstanceAsync(TenantId, instanceId); registeredPgInstances = await RegisteredPostgresService.GetInstancesAsync(TenantId); if (expandedRegPgId == instanceId) expandedRegPgId = null; } catch (Exception ex) { deleteRegPgError = ex.Message; } finally { deletingRegPgId = null; } } private void ShowAddRegPgDatabase(RegisteredPostgresInstance instance) { addDbRegPgInstance = instance; importRegPgInstance = null; newRegPgDbName = ""; addRegPgDbError = null; } private void ShowImportRegPgDatabase(RegisteredPostgresInstance instance) { importRegPgInstance = instance; addDbRegPgInstance = null; importRegPgDbName = ""; importRegPgOwner = ""; importRegPgPassword = ""; importRegPgError = null; } private async Task CreateRegPgDatabase() { addRegPgDbError = null; if (addDbRegPgInstance is null || string.IsNullOrWhiteSpace(newRegPgDbName)) { addRegPgDbError = "Database name is required."; return; } addingRegPgDb = true; try { await RegisteredPostgresService.CreateDatabaseAsync(TenantId, addDbRegPgInstance.Id, newRegPgDbName.Trim()); registeredPgInstances = await RegisteredPostgresService.GetInstancesAsync(TenantId); addDbRegPgInstance = null; newRegPgDbName = ""; } catch (Exception ex) { addRegPgDbError = ex.Message; } finally { addingRegPgDb = false; } } private async Task ImportRegPgDatabase() { importRegPgError = null; if (importRegPgInstance is null || string.IsNullOrWhiteSpace(importRegPgDbName) || string.IsNullOrWhiteSpace(importRegPgOwner) || string.IsNullOrWhiteSpace(importRegPgPassword)) { importRegPgError = "All fields are required."; return; } importingRegPgDb = true; try { await RegisteredPostgresService.ImportDatabaseAsync( TenantId, importRegPgInstance.Id, importRegPgDbName.Trim(), importRegPgOwner.Trim(), importRegPgPassword); registeredPgInstances = await RegisteredPostgresService.GetInstancesAsync(TenantId); importRegPgInstance = null; } catch (Exception ex) { importRegPgError = ex.Message; } finally { importingRegPgDb = false; } } private async Task ViewRegPgCredentials(RegisteredPostgresInstance instance, Guid databaseId) { if (regPgCredDbId == databaseId) { regPgCredDbId = null; regPgCredentials = null; return; } regPgCredDbId = databaseId; regPgCredentials = await RegisteredPostgresService.GetDatabaseCredentialsAsync(TenantId, databaseId); } private async Task SyncRegPgCredentials(Guid instanceId, Guid databaseId) { syncingRegPgDbId = databaseId; regPgDbActionError = null; regPgDbActionErrorId = null; try { await RegisteredPostgresService.SyncCredentialsToK8sAsync(TenantId, instanceId, databaseId); } catch (Exception ex) { regPgDbActionError = ex.Message; regPgDbActionErrorId = databaseId; } finally { syncingRegPgDbId = null; } } private async Task RotateRegPgPassword(Guid instanceId, Guid databaseId) { rotatingRegPgDbId = databaseId; regPgDbActionError = null; regPgDbActionErrorId = null; try { await RegisteredPostgresService.RotatePasswordAsync(TenantId, instanceId, databaseId); if (regPgCredDbId == databaseId) { regPgCredentials = await RegisteredPostgresService.GetDatabaseCredentialsAsync(TenantId, databaseId); } } catch (Exception ex) { regPgDbActionError = ex.Message; regPgDbActionErrorId = databaseId; } finally { rotatingRegPgDbId = null; } } private async Task DropRegPgDatabase(Guid instanceId, Guid databaseId) { droppingRegPgDbId = databaseId; regPgDbActionError = null; regPgDbActionErrorId = null; try { await RegisteredPostgresService.DropDatabaseAsync(TenantId, instanceId, databaseId); registeredPgInstances = await RegisteredPostgresService.GetInstancesAsync(TenantId); regPgDbDeleteConfirmId = null; } catch (Exception ex) { regPgDbActionError = ex.Message; regPgDbActionErrorId = databaseId; } finally { droppingRegPgDbId = null; } } private async Task UnregisterRegPgDatabase(Guid instanceId, Guid databaseId) { droppingRegPgDbId = databaseId; regPgDbActionError = null; regPgDbActionErrorId = null; try { await RegisteredPostgresService.UnregisterDatabaseAsync(TenantId, instanceId, databaseId); registeredPgInstances = await RegisteredPostgresService.GetInstancesAsync(TenantId); regPgDbDeleteConfirmId = null; } catch (Exception ex) { regPgDbActionError = ex.Message; regPgDbActionErrorId = databaseId; } finally { droppingRegPgDbId = null; } } // ── Dump / Migrate State ── // Per-database inline forms (keyed by database Id) private Guid? showDumpFormForDbId; private Guid dumpFormStorageLinkId; private Guid? dumpingRegPgDbId; private Guid? dumpErrorDbId; private string? dumpError; private string? dumpSuccess; private Guid? showMigrateFormForDbId; private Guid migrateFormCnpgClusterId; private string migrateFormNewDbName = ""; private Guid? migratingRegPgDbId; private Guid? migrateErrorDbId; private string? migrateError; private string? migrateSuccess; // Dump history (loaded when instance is expanded) private Dictionary>? dumpsByDatabase; private bool loadingDumps; // Restore form (one at a time) private Guid? showRestoreFormForDumpId; private Guid restoreFormCnpgClusterId; private string restoreFormNewDbName = ""; private Guid? restoringDumpId; private Guid? dumpRestoreErrorDumpId; private string? dumpRestoreError; private string? dumpRestoreSuccess; private Guid? deletingDumpId; private void ShowRegPgDumpForm(RegisteredPostgresInstance rpi, RegisteredPostgresDatabase db) { showDumpFormForDbId = db.Id; showMigrateFormForDbId = null; dumpFormStorageLinkId = storageLinks?.FirstOrDefault()?.Id ?? Guid.Empty; dumpError = null; dumpSuccess = null; dumpErrorDbId = null; } private void ShowRegPgMigrateForm(RegisteredPostgresInstance rpi, RegisteredPostgresDatabase db) { showMigrateFormForDbId = db.Id; showDumpFormForDbId = null; migrateFormNewDbName = db.Name; migrateFormCnpgClusterId = managedClusters?.FirstOrDefault()?.Id ?? Guid.Empty; migrateError = null; migrateSuccess = null; migrateErrorDbId = null; } private void ShowDumpRestoreForm(RegisteredPostgresDump dump) { showRestoreFormForDumpId = dump.Id; restoreFormCnpgClusterId = managedClusters?.FirstOrDefault()?.Id ?? Guid.Empty; restoreFormNewDbName = ""; dumpRestoreError = null; dumpRestoreSuccess = null; dumpRestoreErrorDumpId = null; } private async Task CreateRegPgDump(RegisteredPostgresInstance rpi, RegisteredPostgresDatabase db) { dumpingRegPgDbId = db.Id; dumpError = null; dumpSuccess = null; dumpErrorDbId = db.Id; try { RegisteredPostgresDump dump = await RegisteredPostgresService.CreateDumpAsync( TenantId, rpi.Id, db.Id, dumpFormStorageLinkId); // Refresh dump list for this instance await LoadDumpsForInstance(rpi.Id); dumpSuccess = $"Dump created ({FormatBytes(dump.SizeBytes)}) and uploaded to S3."; showDumpFormForDbId = null; } catch (Exception ex) { dumpError = ex.Message; } finally { dumpingRegPgDbId = null; } } private async Task MigrateRegPgToCnpg(RegisteredPostgresInstance rpi, RegisteredPostgresDatabase db) { migratingRegPgDbId = db.Id; migrateError = null; migrateSuccess = null; migrateErrorDbId = db.Id; try { await RegisteredPostgresService.MigrateDirectlyToCnpgAsync( TenantId, rpi.Id, db.Id, migrateFormCnpgClusterId, migrateFormNewDbName); migrateSuccess = $"Database '{migrateFormNewDbName}' created on CNPG cluster."; showMigrateFormForDbId = null; } catch (Exception ex) { migrateError = ex.Message; } finally { migratingRegPgDbId = null; } } private async Task RestoreRegPgDump(RegisteredPostgresDump dump) { restoringDumpId = dump.Id; dumpRestoreError = null; dumpRestoreSuccess = null; dumpRestoreErrorDumpId = dump.Id; try { await RegisteredPostgresService.RestoreDumpToCnpgAsync( dump.Id, restoreFormCnpgClusterId, restoreFormNewDbName); dumpRestoreSuccess = $"Database '{restoreFormNewDbName}' restored successfully."; showRestoreFormForDumpId = null; } catch (Exception ex) { dumpRestoreError = ex.Message; } finally { restoringDumpId = null; } } private async Task DeleteRegPgDump(RegisteredPostgresDump dump, Guid instanceId) { deletingDumpId = dump.Id; try { await RegisteredPostgresService.DeleteDumpAsync(dump.Id); await LoadDumpsForInstance(instanceId); } catch { } finally { deletingDumpId = null; } } private async Task LoadDumpsForInstance(Guid instanceId) { loadingDumps = true; StateHasChanged(); try { RegisteredPostgresInstance? inst = registeredPgInstances?.FirstOrDefault(i => i.Id == instanceId); if (inst is null) return; Dictionary> byDb = []; foreach (RegisteredPostgresDatabase db in inst.Databases) { byDb[db.Id] = await RegisteredPostgresService.GetDumpsAsync(db.Id); } dumpsByDatabase = byDb; } finally { loadingDumps = false; } } private static RenderFragment GetManagedStatusBadge(CnpgClusterStatus status) => status switch { CnpgClusterStatus.Running => __builder => { Running }, CnpgClusterStatus.Creating => __builder => { Creating }, CnpgClusterStatus.Upgrading => __builder => { Upgrading }, CnpgClusterStatus.Restoring => __builder => { Restoring }, CnpgClusterStatus.Failed => __builder => { Failed }, CnpgClusterStatus.Deleting => __builder => { Deleting }, _ => __builder => { @status } }; }