SelectCnpgBackup(mc, bk)">
@bk.Name
@if (bk.Type == CnpgBackupType.Scheduled)
@@ -602,12 +689,113 @@ else
}
else if (mc.StorageLinkId.HasValue)
{
- No backups yet. Click to create one.
+ 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)
+ {
+
+
+
+
+
+ @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")
+ {
+
+
+ New Cluster Name
+
+
+
+ }
+ 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 (backupRestoring) { }
+ Restore
+
+ { selectedCnpgBackup = null; selectedCnpgBackupCluster = null; backupRestoreError = null; }">
+ Cancel
+
+
+
+ @if (!string.IsNullOrEmpty(backupRestoreError))
+ {
+
@backupRestoreError
+ }
+
+
+ }
+
+ @if (configuringScheduleId == mc.Id)
+ {
+
+
+ ApplyScheduledBackup(mc.Id, scheduleInput)"
+ disabled="@(applyingScheduledBackupId == mc.Id || string.IsNullOrWhiteSpace(scheduleInput))">
+ Apply
+
+ Cancel
+
+ }
}
@if (!string.IsNullOrEmpty(mc.LastError))
@@ -757,29 +945,29 @@ else
MongoDB Clusters
- @if (mongoClusters is not null)
+ @if (FilteredMongoClusters is not null)
{
- @mongoClusters.Count
+ @FilteredMongoClusters.Count
}
- @if (mongoClusters is null)
+ @if (FilteredMongoClusters is null)
{
}
- else if (mongoClusters.Count == 0)
+ else if (FilteredMongoClusters.Count == 0)
{
- No MongoDB clusters found. Create a MongoDBCommunity resource in your namespace.
+ No MongoDB clusters discovered on Kubernetes.
}
else
{
- @foreach (DatabaseClusterInfo dbCluster in mongoClusters)
+ @foreach (DatabaseClusterInfo dbCluster in FilteredMongoClusters)
{
@@ -838,6 +1026,683 @@ else
}
+ @* ── Managed MongoDB Clusters ── *@
+ @if (operatorStatus!.MongoDbAvailable)
+ {
+
+
+ Managed MongoDB Clusters
+ @if (FilteredManagedMongoClusters is not null)
+ {
+ @FilteredManagedMongoClusters.Count
+ }
+
+ Create Cluster
+
+
+
+ @* ── Create MongoDB Cluster Form ── *@
+ @if (showMongoCreate)
+ {
+
+
+
+
+
+ Cluster Name
+
+
+
+ Namespace
+
+
+
+ Target Cluster
+
+ Select cluster...
+ @if (mongoAvailableClusters is not null)
+ {
+ @foreach (KubernetesCluster k in mongoAvailableClusters)
+ {
+ @k.Name
+ }
+ }
+
+
+
+ Members (Replica Set)
+
+ 1 (Standalone)
+ 3 (HA)
+ 5 (Large HA)
+
+
+
+ Storage Size
+
+
+
+ Backup S3 Bucket (optional)
+
+ No backup
+ @if (storageLinks is not null)
+ {
+ @foreach (StorageLink sl in storageLinks)
+ {
+ @sl.Name (@sl.BucketName)
+ }
+ }
+
+
+
+ Backup Schedule (cron, optional)
+
+
+
+ Backup Retention (days)
+
+
+
+
+
+ @if (mongoCreating)
+ {
+
+ Creating...
+ }
+ else
+ {
+
+ Create MongoDB Cluster
+ }
+
+ Cancel
+
+ @if (!string.IsNullOrEmpty(mongoCreateError))
+ {
+
+ @mongoCreateError
+
+ }
+
+
+ }
+
+ @* ── Managed MongoDB Cluster Cards ── *@
+ @if (FilteredManagedMongoClusters is null)
+ {
+
+ }
+ else if (FilteredManagedMongoClusters.Count == 0 && !showMongoCreate)
+ {
+
+
+ No managed MongoDB clusters yet. Click "Create Cluster" to provision a new MongoDB replica set.
+
+ }
+ else
+ {
+ @foreach (MongoCluster mc in FilteredManagedMongoClusters)
+ {
+
+
+
+ @* ── 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)
+ {
+
+
+
+
+ Pod
+ Status
+ Ready
+ Node
+ Age
+ Restarts
+
+
+
+ @foreach (MongoPodInfo pod in mongoClusterDetail.Pods)
+ {
+
+ @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)
+ {
+
+
+
+
+ Database
+ Status
+ Created
+
+
+
+
+ @foreach (MongoDatabase mdb in mc.Databases)
+ {
+
+ @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")
+
+ DeleteMongoDatabase(mc.Id, mdb.Id)">
+
+
+
+
+ }
+
+
+
+ }
+ else
+ {
+
No databases created yet. Use the button to add one.
+ }
+
+ @* ── Backups ── *@
+
+ Backups
+ @mongoClusterDetail.Backups.Count
+
+ @if (mongoClusterDetail.Backups.Count > 0)
+ {
+
+
+
+
+ Name
+ Type
+ Status
+ Started
+ Completed
+
+
+
+ @foreach (MongoBackup bk in mongoClusterDetail.Backups)
+ {
+ SelectMongoBackup(mc, bk)">
+ @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
+ {
+
Backups are not configured. Assign an S3 bucket to enable backups.
+ }
+
+ @* ── Backup Restore Panel (MongoDB) ── *@
+ @if (selectedMongoBackup != null && selectedMongoBackupCluster?.Id == mc.Id)
+ {
+
+
+
+
+
+ @if (backupRestoreTarget == "current")
+ {
+
+
+ Destructive: Runs mongorestore --drop against the running cluster. All collections are replaced with backup data.
+
+ }
+ else if (backupRestoreTarget == "new")
+ {
+
+
+ New Cluster Name
+
+
+
+ }
+ else if (backupRestoreTarget == "external")
+ {
+
+
+
MongoDB Connection URI
+
+
The backup will be restored (mongorestore --drop) to this external MongoDB instance.
+
+
+ }
+
+
+
+ @if (backupRestoring) { }
+ Restore
+
+ { selectedMongoBackup = null; selectedMongoBackupCluster = null; backupRestoreError = null; }">
+ Cancel
+
+
+
+ @if (!string.IsNullOrEmpty(backupRestoreError))
+ {
+
@backupRestoreError
+ }
+
+
+ }
+ }
+
+ @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)
+ {
+
+
+
+
+
+ Database Name
+
+
+
+
+ @if (mongoAddingDb) { }
+ Create
+
+ mongoAddDbCluster = null">Cancel
+
+
+ @if (!string.IsNullOrEmpty(mongoDbError))
+ {
+
@mongoDbError
+ }
+
+
+ }
+
+ @* ── MongoDB Restore Form ── *@
+ @if (mongoRestoreSource is not null)
+ {
+
+
+
+
+
+
Source Backup
+ @{
+ var completedBackups = mongoRestoreSource.Backups
+ .Where(b => b.Status == MongoBackupStatus.Completed)
+ .OrderByDescending(b => b.StartedAt)
+ .ToList();
+ }
+
+ — select backup —
+ @foreach (var b in completedBackups)
+ {
+ @b.Name (@b.StartedAt.ToString("yyyy-MM-dd HH:mm") UTC)
+ }
+
+ @if (!completedBackups.Any())
+ {
+
No completed backups available.
+ }
+
+
+
+ @if (mongoRestoring) { }
+ Restore
+
+ mongoRestoreSource = null">Cancel
+
+
+ @if (!string.IsNullOrEmpty(mongoRestoreError))
+ {
+
@mongoRestoreError
+ }
+
+
+ }
+
+ @* ── MongoDB Upgrade Form ── *@
+ @if (mongoUpgradeCluster is not null)
+ {
+
+
+
+
+
+ Target Version
+
+ MongoDB 8.0
+ MongoDB 7.0
+ MongoDB 6.0
+
+
+
+
+ @if (mongoUpgrading) { }
+ Upgrade
+
+ mongoUpgradeCluster = null">Cancel
+
+
+ @if (!string.IsNullOrEmpty(mongoUpgradeError))
+ {
+
@mongoUpgradeError
+ }
+
+
+ }
+
+ }
+
@* ── Refresh button ── *@
@@ -863,11 +1728,47 @@ else
private List? cnpgClusters;
private List? mongoClusters;
+ // Environment tab filter (null = All)
+ private string? selectedEnvironment;
+
+ private IReadOnlyList AllEnvironments
+ {
+ get
+ {
+ var names = new HashSet(StringComparer.Ordinal);
+ if (cnpgClusters is not null)
+ foreach (var c in cnpgClusters) names.Add(c.EnvironmentName);
+ if (mongoClusters is not null)
+ foreach (var c in mongoClusters) names.Add(c.EnvironmentName);
+ 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);
+ return names.OrderBy(n => n).ToList();
+ }
+ }
+
+ private List? FilteredCnpgClusters =>
+ cnpgClusters?.Where(c => selectedEnvironment is null || c.EnvironmentName == selectedEnvironment).ToList();
+
+ private List? FilteredManagedClusters =>
+ managedClusters?.Where(c => selectedEnvironment is null || c.KubernetesCluster.Environment.Name == selectedEnvironment).ToList();
+
+ private List? FilteredMongoClusters =>
+ mongoClusters?.Where(c => selectedEnvironment is null || c.EnvironmentName == 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;
@@ -879,6 +1780,7 @@ else
private string createStorageSize = "10Gi";
private Guid createStorageLinkId;
private string createSchedule = "";
+ private int createRetentionDays = 30;
// Cluster detail panel
private Guid? expandedClusterId;
@@ -890,6 +1792,9 @@ else
// Backup
private Guid? backingUpId;
+ private Guid? applyingScheduledBackupId;
+ private Guid? configuringScheduleId;
+ private string scheduleInput = "";
// Restore form
private CnpgCluster? restoreSource;
@@ -910,6 +1815,65 @@ else
private bool addingDb;
private string? dbError;
+ // Database credentials
+ private Guid? credentialsDatabaseId;
+ private Dictionary? databaseCredentials;
+ private Guid? rotatingDatabaseId;
+
+ // ── MongoDB State ──
+ // Create MongoDB cluster form
+ private bool showMongoCreate;
+ private bool mongoCreating;
+ private string? mongoCreateError;
+ private string mongoCreateName = "";
+ private string mongoCreateNamespace = "databases";
+ private Guid mongoCreateClusterId;
+ private int mongoCreateMembers = 3;
+ private string mongoCreateStorageSize = "10Gi";
+ private Guid mongoCreateStorageLinkId;
+ private string mongoCreateSchedule = "";
+ private int mongoCreateRetentionDays = 30;
+
+ // Mongo cluster detail panel
+ private Guid? expandedMongoClusterId;
+ private MongoClusterDetail? mongoClusterDetail;
+ private bool mongoLoadingDetail;
+
+ // Mongo delete
+ private Guid? mongoDeletingId;
+
+ // 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.0";
+ private bool mongoUpgrading;
+ private string? mongoUpgradeError;
+
+ // Mongo add database form
+ private MongoCluster? mongoAddDbCluster;
+ private string mongoNewDbName = "";
+ private bool mongoAddingDb;
+ private string? mongoDbError;
+
+ // 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();
@@ -938,10 +1902,15 @@ else
? CnpgService.GetClustersAsync(TenantId)
: Task.FromResult(new List());
- await Task.WhenAll(cnpgTask, mongoTask, managedTask);
+ Task> managedMongoTask = operatorStatus.MongoDbAvailable
+ ? MongoService.GetClustersAsync(TenantId)
+ : Task.FromResult(new List());
+
+ await Task.WhenAll(cnpgTask, mongoTask, managedTask, managedMongoTask);
cnpgClusters = cnpgTask.Result;
mongoClusters = mongoTask.Result;
managedClusters = managedTask.Result;
+ managedMongoClusters = managedMongoTask.Result;
}
loading = false;
@@ -955,6 +1924,7 @@ else
try
{
await LoadData();
+ await RefreshOpenDetailPanels();
}
finally
{
@@ -962,6 +1932,27 @@ else
}
}
+ 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)
@@ -1066,6 +2057,7 @@ else
createStorageSize = "10Gi";
createStorageLinkId = Guid.Empty;
createSchedule = "";
+ createRetentionDays = 30;
}
private async Task CreateCluster()
@@ -1087,7 +2079,8 @@ else
await CnpgService.CreateClusterAsync(
TenantId, createClusterId, createName.Trim(), createNamespace.Trim(),
- createInstances, createStorageSize.Trim(), storageLinkId, schedule);
+ createInstances, createStorageSize.Trim(), storageLinkId, schedule,
+ createRetentionDays);
CancelCreate();
managedClusters = await CnpgService.GetClustersAsync(TenantId);
@@ -1146,6 +2139,48 @@ else
}
}
+ private void ToggleScheduleForm(CnpgCluster cluster)
+ {
+ if (!string.IsNullOrWhiteSpace(cluster.BackupSchedule))
+ {
+ // Schedule already set — apply directly without showing the form.
+ _ = ApplyScheduledBackup(cluster.Id, null);
+ return;
+ }
+
+ // No schedule yet — expand the card and show the input form.
+ expandedClusterId = cluster.Id;
+ configuringScheduleId = cluster.Id;
+ scheduleInput = "";
+ }
+
+ private void CancelScheduleForm()
+ {
+ configuringScheduleId = null;
+ scheduleInput = "";
+ }
+
+ private async Task ApplyScheduledBackup(Guid cnpgClusterId, string? schedule)
+ {
+ applyingScheduledBackupId = cnpgClusterId;
+ configuringScheduleId = null;
+ 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)
@@ -1174,7 +2209,6 @@ else
TenantId, restoreSource!.Id, restoreName.Trim(), restoreTargetTime);
restoreSource = null;
- managedClusters = await CnpgService.GetClustersAsync(TenantId);
}
catch (Exception ex)
{
@@ -1182,6 +2216,7 @@ else
}
finally
{
+ managedClusters = await CnpgService.GetClustersAsync(TenantId);
restoring = false;
}
}
@@ -1282,8 +2317,472 @@ else
}
}
+ 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;
+ }
+ }
+
// ──────── Status Helpers ────────
+ // ──────── Managed MongoDB Methods ────────
+
+ 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;
+ mongoCreateMembers = 3;
+ mongoCreateStorageSize = "10Gi";
+ mongoCreateStorageLinkId = Guid.Empty;
+ mongoCreateSchedule = "";
+ mongoCreateRetentionDays = 30;
+ }
+
+ 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);
+
+ CancelMongoCreate();
+ managedMongoClusters = await MongoService.GetClustersAsync(TenantId);
+ }
+ catch (Exception ex)
+ {
+ mongoCreateError = ex.Message;
+ }
+ finally
+ {
+ mongoCreating = false;
+ }
+ }
+
+ private async Task DeleteMongoCluster(Guid mongoClusterId)
+ {
+ mongoDeletingId = mongoClusterId;
+ StateHasChanged();
+
+ try
+ {
+ await MongoService.DeleteClusterAsync(TenantId, mongoClusterId);
+ managedMongoClusters = await MongoService.GetClustersAsync(TenantId);
+ }
+ catch (Exception ex)
+ {
+ mongoCreateError = $"Delete failed: {ex.Message}";
+ }
+ finally
+ {
+ mongoDeletingId = 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.0";
+ mongoUpgradeError = null;
+ }
+
+ private async Task ExecuteMongoUpgrade()
+ {
+ 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 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 async Task DeleteMongoDatabase(Guid mongoClusterId, Guid databaseId)
+ {
+ try
+ {
+ await MongoService.DeleteDatabaseAsync(TenantId, mongoClusterId, databaseId);
+ managedMongoClusters = await MongoService.GetClustersAsync(TenantId);
+ }
+ catch (Exception ex)
+ {
+ mongoDbError = $"Failed to delete database: {ex.Message}";
+ }
+ }
+
+ 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 =>
diff --git a/src/EntKube.Web/Components/Pages/Tenants/IdentityTab.razor b/src/EntKube.Web/Components/Pages/Tenants/IdentityTab.razor
new file mode 100644
index 0000000..3403ea3
--- /dev/null
+++ b/src/EntKube.Web/Components/Pages/Tenants/IdentityTab.razor
@@ -0,0 +1,1703 @@
+@using EntKube.Web.Data
+@using EntKube.Web.Services
+@inject KeycloakService KeycloakService
+@inject StorageService StorageService
+@inject ComponentLifecycleService LifecycleService
+
+@if (loading)
+{
+
+
+
Scanning for Keycloak instances...
+
+}
+else if (selectedRealm is not null && selectedKeycloak is not null)
+{
+ @* ── Level 3: Realm detail ── *@
+
+
+ Back to Realms
+
+
+
+
+
+
+
+
@selectedRealm.DisplayName
+ @selectedRealm.RealmName
+
+
+ @if (selectedRealm.LinkedApp is not null)
+ {
+
+ @selectedRealm.LinkedApp.Customer.Name / @selectedRealm.LinkedApp.Name
+
+ }
+
+
+
+ @foreach (string tab in new[] { "users", "idp", "groups", "orgs", "themes", "backup", "portal" })
+ {
+ string label = tab switch
+ {
+ "users" => "Users",
+ "idp" => "Identity Providers",
+ "groups" => "Groups",
+ "orgs" => "Organizations",
+ "themes" => "Themes",
+ "backup" => "Backup & Restore",
+ "portal" => "Customer Portal",
+ _ => tab
+ };
+
+ SwitchRealmTab(tab)">@label
+
+ }
+
+
+ @* ── Users ── *@
+ @if (realmTab == "users")
+ {
+ @if (loadingUsers)
+ {
+
+ }
+ else
+ {
+
+ @(users?.Count ?? 0) users
+ showAddUser = !showAddUser">
+ Add User
+
+
+
+ @if (showAddUser)
+ {
+
+
+ @if (userError is not null)
+ {
+
@userError
+ }
+
+
+
+ @if (savingUser) { }
+ Create
+
+ showAddUser = false">Cancel
+
+
+
+ }
+
+ @if (users is not null)
+ {
+
+ @foreach (KeycloakUserInfo user in users)
+ {
+
+
+
+
@user.Username
+
@user.Email
+ @if (!user.Enabled)
+ {
+
Disabled
+ }
+ @if (!user.EmailVerified)
+ {
+
Unverified
+ }
+ @if (user.Attributes.Count > 0)
+ {
+
+ @foreach (KeyValuePair> attr in user.Attributes)
+ {
+
+ @attr.Key: @string.Join(", ", attr.Value)
+
+ }
+
+ }
+
+
DeleteUser(user.Id)">
+
+
+
+
+ }
+
+ }
+ }
+ }
+
+ @* ── Identity Providers ── *@
+ @if (realmTab == "idp")
+ {
+ @if (loadingIdps)
+ {
+
+ }
+ else
+ {
+
+ @(idps?.Count ?? 0) providers
+ showAddIdp = !showAddIdp">
+ Add Provider
+
+
+
+ @if (showAddIdp)
+ {
+
+
+ @if (idpError is not null)
+ {
+
@idpError
+ }
+
+
+
+
+
+
+ OpenID Connect
+ SAML
+ Google
+ GitHub
+ Microsoft
+
+
+
+
+
+
+
+
+
+
+
+ @if (savingIdp) { }
+ Add
+
+ showAddIdp = false">Cancel
+
+
+
+ }
+
+ @if (idps is not null)
+ {
+
+ @foreach (KeycloakIdpInfo idp in idps)
+ {
+
+
+
+ @idp.Alias
+ @idp.ProviderId
+ @if (!idp.Enabled)
+ {
+ Disabled
+ }
+
+
DeleteIdp(idp.Alias)">
+
+
+
+ }
+
+ }
+ }
+ }
+
+ @* ── Groups ── *@
+ @if (realmTab == "groups")
+ {
+ @if (loadingGroups)
+ {
+
+ }
+ else
+ {
+
+ @(groups?.Count ?? 0) groups
+ showAddGroup = !showAddGroup">
+ Add Group
+
+
+
+ @if (showAddGroup)
+ {
+
+
+
+ @if (savingGroup) { }
+ Create
+
+ showAddGroup = false">Cancel
+
+ @if (groupError is not null)
+ {
+ @groupError
+ }
+ }
+
+ @if (groups is not null)
+ {
+
+ @foreach (KeycloakGroupInfo g in groups)
+ {
+
+ @g.Name @g.Path
+ DeleteGroup(g.Id)">
+
+
+
+ }
+
+ }
+ }
+ }
+
+ @* ── Organizations ── *@
+ @if (realmTab == "orgs")
+ {
+ @if (loadingOrgs)
+ {
+
+ }
+ else
+ {
+
+ @(orgs?.Count ?? 0) organizations (Keycloak 26+)
+ showAddOrg = !showAddOrg">
+ Add Organization
+
+
+
+ @if (showAddOrg)
+ {
+
+
+ @if (orgError is not null)
+ {
+
@orgError
+ }
+
+
+
+ @if (savingOrg) { }
+ Create
+
+ showAddOrg = false">Cancel
+
+
+
+ }
+
+ @if (orgs is not null)
+ {
+
+ @foreach (KeycloakOrganizationInfo org in orgs)
+ {
+
+ @org.Name
+ @if (!string.IsNullOrWhiteSpace(org.Description))
+ {
+ @org.Description
+ }
+ @if (!org.Enabled)
+ {
+ Disabled
+ }
+
+ }
+
+ }
+ }
+ }
+
+ @* ── Themes ── *@
+ @if (realmTab == "themes")
+ {
+ @if (realmDetails is null)
+ {
+
+ }
+ else
+ {
+ @if (realmThemeError is not null)
+ {
+ @realmThemeError
+ }
+
+
+ Login Theme
+
+ (default)
+ @foreach (string t in realmDetails.Themes)
+ {
+ @t
+ }
+
+
+
+ Account Console Theme
+
+ (default)
+ @foreach (string t in realmDetails.Themes)
+ {
+ @t
+ }
+
+
+
+
+ @if (savingThemes) { }
+ Save
+
+
+
+ }
+ }
+
+ @* ── Backup & Restore ── *@
+ @if (realmTab == "backup")
+ {
+ @if (loadingBackups)
+ {
+
+ }
+ else
+ {
+ @if (backupError is not null)
+ {
+ @backupError
+ }
+
+
+
+
Create Backup
+
+
+ Storage Bucket
+
+ Select bucket...
+ @if (storageLinks is not null)
+ {
+ @foreach (StorageLink link in storageLinks)
+ {
+ @link.Name
+ }
+ }
+
+
+
+
+ @if (creatingBackup) { }
+ Backup Now
+
+
+
+
+
+
+ @if (backups is not null && backups.Count > 0)
+ {
+ Backups
+
+ @foreach (KeycloakBackup b in backups)
+ {
+
+
+
+ @b.RealmName
+ @b.CreatedAt.ToString("yyyy-MM-dd HH:mm")
+ @if (b.Status == KeycloakBackupStatus.Ready)
+ {
+ Ready
+ @FormatSize(b.SizeBytes)
+ }
+ else if (b.Status == KeycloakBackupStatus.Failed)
+ {
+ Failed
+ @b.LastError
+ }
+ else
+ {
+ Creating
+ }
+
+ @if (b.Status == KeycloakBackupStatus.Ready)
+ {
+
+ RestoreBackup(b.Id)">
+ Restore
+
+ DeleteBackup(b.Id)">
+
+
+
+ }
+
+
+ }
+
+ }
+ }
+ }
+
+ @* ── Customer Portal ── *@
+ @if (realmTab == "portal")
+ {
+ @if (portalError is not null)
+ {
+ @portalError
+ }
+ @if (selectedRealm.LinkedApp is not null)
+ {
+
+
+ Linked to @selectedRealm.LinkedApp.Customer.Name / @selectedRealm.LinkedApp.Name
+ Unlink
+
+ }
+ else
+ {
+ Link this realm to a customer app so the customer can manage users, groups, and IdPs through their portal.
+
+
+ App
+
+ Select app...
+ @if (apps is not null)
+ {
+ @foreach (Data.App app in apps)
+ {
+ @app.Customer.Name / @app.Name
+ }
+ }
+
+
+
+
+ Link
+
+
+
+ }
+ }
+}
+else if (selectedKeycloak is not null)
+{
+ @* ── Level 2: Realm list for a specific Keycloak ── *@
+
+
+ All Keycloak Instances
+
+
+
+
+
+
+
@selectedKeycloak.Component.Name
+ @selectedKeycloak.Component.Cluster.Name
+
+ @if (selectedKeycloak.Config?.AdminUrl is not null)
+ {
+ @selectedKeycloak.Config.AdminUrl
+ }
+
+
showEditConfig = !showEditConfig">
+ Settings
+
+
+
+ @if (showEditConfig && selectedKeycloak.Config is not null)
+ {
+
+
+
+ @if (configError is not null)
+ {
+
@configError
+ }
+
+
+
+ @if (savingConfig) { }
+ Save
+
+ showEditConfig = false">Cancel
+
+
+
+ }
+
+ @if (!selectedKeycloak.IsConfigured)
+ {
+ @* Setup form for unconfigured keycloak *@
+
+
+
+ @if (selectedKeycloak.Component.Status == ComponentStatus.Failed)
+ {
+
+
+ The install failed — likely because the database credentials weren't available before
+ the pod started. Configure the database here, then retry the install from the
+ Cluster tab.
+
+ }
+
+ Enter the admin credentials for this Keycloak instance. Optionally link a managed
+ CNPG database — if the instance already runs against its own Postgres, leave the
+ database unselected. Credentials are synced to a Kubernetes Secret in the
+ @(selectedKeycloak.Component.Namespace ?? "keycloak") namespace.
+
+ @if (setupError is not null)
+ {
+
@setupError
+ }
+
+
+
Database
+
+ Select CNPG database...
+ @if (availableDatabases is not null)
+ {
+ @foreach (CnpgDatabase db in availableDatabases)
+ {
+ @db.CnpgCluster.Name / @db.Name (@db.Owner)
+ }
+ }
+
+ @if (availableDatabases is not null && availableDatabases.Count == 0)
+ {
+
+ No ready CNPG databases found on this cluster. Create one in the Databases tab first.
+
+ }
+
+
+
Admin URL
+
+ @if (!string.IsNullOrWhiteSpace(selectedKeycloak.SuggestedAdminUrl))
+ {
+
+ }
+
+
+ Admin Username
+
+
+
+ Admin Password
+
+
+
+
+
+ @if (configuringKeycloak) { }
+ Configure
+
+
+
+
+ }
+ else
+ {
+ @* Instance-level tabs: Realms | Themes *@
+
+
+ SwitchInstanceTab("realms")'>
+ Realms
+
+
+
+ SwitchInstanceTab("themes")'>
+ Themes
+
+
+
+
+ @if (instanceTab == "themes")
+ {
+ @* ── Themes tab ── *@
+
+ @foreach (string type in new[] { "login", "account", "admin", "email" })
+ {
+ SwitchThemeType(type)">
+ @(char.ToUpper(type[0]) + type[1..])
+
+ }
+
+
+ @if (loadingThemes)
+ {
+
+ Loading themes...
+ }
+ else
+ {
+ @if (availableThemes is not null && availableThemes.TryGetValue(themeType, out List? themeNames) && themeNames.Count > 0)
+ {
+
+
Available themes
+
+ @foreach (string name in themeNames)
+ {
+ @name
+ }
+
+
+ }
+
+
+
Custom CSS override
+
+ Stored in EntKube. Apply to realms individually via the realm's Themes tab.
+
+
+
+
+
+ @if (savingTheme) { }
+ Save
+
+ @if (themeSaved)
+ {
+ Saved
+ }
+ @if (themeError is not null)
+ {
+ @themeError
+ }
+
+ }
+ }
+ else
+ {
+ @* Realms list *@
+ @if (realmSyncError is not null)
+ {
+
+
+
+ Could not reach Keycloak — showing cached realms.
+ @realmSyncError
+
+
+ }
+
+ @(realms?.Count ?? 0) realms
+ showCreateRealm = !showCreateRealm">
+ New Realm
+
+
+
+ @if (showCreateRealm)
+ {
+
+
+ @if (realmError is not null)
+ {
+
@realmError
+ }
+
+
+
+ @if (creatingRealm) { }
+ Create
+
+ showCreateRealm = false">Cancel
+
+
+
+ }
+
+ @if (realms is not null)
+ {
+ @if (realms.Count == 0)
+ {
+
+
+ No realms yet. Create one above.
+
+ }
+ else
+ {
+
+ @foreach (KeycloakRealm realm in realms)
+ {
+
+
OpenRealm(realm)" style="cursor:pointer">
+
+
+ @realm.DisplayName
+ @realm.RealmName
+ @if (!realm.Enabled)
+ {
+ Disabled
+ }
+ @if (realm.LinkedApp is not null)
+ {
+
+ @realm.LinkedApp.Customer.Name / @realm.LinkedApp.Name
+
+ }
+
+
+
+ OpenRealm(realm)">
+
+
+ DeleteRealm(realm.Id)"
+ disabled="@(deletingRealmId == realm.Id)">
+ @if (deletingRealmId == realm.Id)
+ {
+
+ }
+ else
+ {
+
+ }
+
+
+
+ }
+
+ }
+ }
+ } @* end realms tab else *@
+ }
+}
+else
+{
+ @* ── Level 1: Keycloak instance list ── *@
+ @if (detectedKeycloaks is null || detectedKeycloaks.Count == 0)
+ {
+
+
+
No Keycloak instances found on this tenant's clusters.
+
Install the Keycloak component from the Components tab on one of your clusters.
+
+ }
+ else
+ {
+
+ Detected @detectedKeycloaks.Count Keycloak @(detectedKeycloaks.Count == 1 ? "instance" : "instances").
+ Select one to manage realms, users, identity providers, and backups.
+
+
+
+ @foreach (DetectedKeycloak kc in detectedKeycloaks)
+ {
+
+ }
+
+ }
+}
+
+@code {
+ [Parameter] public Guid TenantId { get; set; }
+
+ // ── Load state ────────────────────────────────────────────────────────────
+ private bool loading = true;
+ private List? detectedKeycloaks;
+ private List? realms;
+ private List? availableDatabases;
+ private List? storageLinks;
+ private List? apps;
+
+ // ── Navigation ────────────────────────────────────────────────────────────
+ private DetectedKeycloak? selectedKeycloak;
+ private KeycloakRealm? selectedRealm;
+ private string realmTab = "users";
+ private string instanceTab = "realms";
+
+ // ── Themes tab ───────────────────────────────────────────────────────────
+ private string themeType = "login";
+ private Dictionary>? availableThemes;
+ private bool loadingThemes;
+ private string? themeCss;
+ private bool savingTheme;
+ private bool themeSaved;
+ private string? themeError;
+
+ // ── Per-tab lazy data ─────────────────────────────────────────────────────
+ private List? users;
+ private List? idps;
+ private List? groups;
+ private List? orgs;
+ private List? backups;
+ private KeycloakRealmDetails? realmDetails;
+
+ private bool loadingUsers, loadingIdps, loadingGroups, loadingOrgs, loadingBackups;
+
+ // ── Setup form ────────────────────────────────────────────────────────────
+ private bool configuringKeycloak;
+ private string? setupError;
+ private Guid setupDatabaseId;
+ private string setupAdminUrl = "";
+ private string setupUsername = "admin";
+ private string setupPassword = "";
+
+ // ── Edit config form ──────────────────────────────────────────────────────
+ private bool showEditConfig;
+ private bool savingConfig;
+ private string? configError;
+ private string editAdminUrl = "";
+ private string editAdminUsername = "admin";
+ private string editAdminPassword = "";
+
+ // ── Realm form ────────────────────────────────────────────────────────────
+ private bool showCreateRealm;
+ private bool creatingRealm;
+ private string? realmError;
+ private string? realmSyncError;
+ private string newRealmName = "";
+ private string newRealmDisplayName = "";
+ private string newRealmTheme = "";
+ private Guid? deletingRealmId;
+
+ // ── User form ─────────────────────────────────────────────────────────────
+ private bool showAddUser;
+ private bool savingUser;
+ private string? userError;
+ private string newUserUsername = "";
+ private string newUserEmail = "";
+ private string newUserFirstName = "";
+ private string newUserLastName = "";
+ private string newUserPassword = "";
+
+ // ── IdP form ──────────────────────────────────────────────────────────────
+ private bool showAddIdp;
+ private bool savingIdp;
+ private string? idpError;
+ private string newIdpAlias = "";
+ private string newIdpProviderId = "oidc";
+ private string newIdpClientId = "";
+ private string newIdpClientSecret = "";
+
+ // ── Group form ────────────────────────────────────────────────────────────
+ private bool showAddGroup;
+ private bool savingGroup;
+ private string? groupError;
+ private string newGroupName = "";
+
+ // ── Org form ─────────────────────────────────────────────────────────────
+ private bool showAddOrg;
+ private bool savingOrg;
+ private string? orgError;
+ private string newOrgName = "";
+ private string newOrgDescription = "";
+
+ // ── Realm theme form ──────────────────────────────────────────────────────
+ private bool savingThemes;
+ private string? realmThemeError;
+ private string editLoginTheme = "";
+ private string editAccountTheme = "";
+
+ // ── Backup form ───────────────────────────────────────────────────────────
+ private bool creatingBackup;
+ private string? backupError;
+ private Guid backupStorageLinkId;
+
+ // ── Portal form ───────────────────────────────────────────────────────────
+ private string? portalError;
+ private Guid linkAppId;
+
+ protected override async Task OnInitializedAsync()
+ {
+ detectedKeycloaks = await KeycloakService.GetDetectedKeycloaksAsync(TenantId);
+ loading = false;
+ }
+
+ private async Task SelectKeycloak(DetectedKeycloak kc)
+ {
+ selectedKeycloak = kc;
+ realms = null;
+ availableDatabases = null;
+ setupError = null;
+ setupDatabaseId = Guid.Empty;
+ setupAdminUrl = kc.SuggestedAdminUrl ?? "";
+ setupPassword = "";
+ instanceTab = "realms";
+ availableThemes = null;
+ themeCss = null;
+ themeSaved = false;
+ themeError = null;
+ realmSyncError = null;
+
+ if (kc.IsConfigured)
+ {
+ try
+ {
+ realms = await KeycloakService.SyncRealmsFromKeycloakAsync(TenantId, kc.Config!.Id);
+ }
+ catch (Exception ex)
+ {
+ realmSyncError = ex.Message;
+ realms = await KeycloakService.GetRealmsForConfigAsync(TenantId, kc.Config!.Id);
+ }
+ storageLinks ??= await StorageService.GetStorageLinksAsync(TenantId);
+ apps ??= await KeycloakService.GetAppsAsync(TenantId);
+ }
+ else
+ {
+ availableDatabases = await KeycloakService.GetDatabasesForComponentAsync(
+ TenantId, kc.Component.Id);
+ }
+
+ if (kc.Config is not null)
+ {
+ editAdminUrl = kc.Config.AdminUrl ?? "";
+ editAdminUsername = kc.Config.AdminUsername;
+ }
+ }
+
+ private async Task SwitchInstanceTab(string tab)
+ {
+ instanceTab = tab;
+ themeSaved = false;
+ themeError = null;
+
+ if (tab == "themes" && selectedKeycloak?.Config is not null)
+ {
+ await LoadThemesTab();
+ }
+ }
+
+ private async Task SwitchThemeType(string type)
+ {
+ themeType = type;
+ themeSaved = false;
+ themeError = null;
+ await LoadThemeCss();
+ }
+
+ private async Task LoadThemesTab()
+ {
+ if (selectedKeycloak?.Config is null) return;
+
+ loadingThemes = true;
+ StateHasChanged();
+
+ try
+ {
+ availableThemes = await KeycloakService.GetAvailableThemesAsync(TenantId, selectedKeycloak.Config.Id);
+ await LoadThemeCss();
+ }
+ catch
+ {
+ availableThemes = [];
+ }
+ finally
+ {
+ loadingThemes = false;
+ }
+ }
+
+ private async Task LoadThemeCss()
+ {
+ if (selectedKeycloak is null) return;
+ themeCss = await KeycloakService.GetThemeCssAsync(TenantId, selectedKeycloak.Component.Id, themeType);
+ }
+
+ private async Task SaveThemeCss()
+ {
+ if (selectedKeycloak is null) return;
+
+ savingTheme = true;
+ themeSaved = false;
+ themeError = null;
+
+ try
+ {
+ await KeycloakService.SaveThemeCssAsync(
+ TenantId, selectedKeycloak.Component.Id, themeType, themeCss ?? "");
+ themeSaved = true;
+ }
+ catch (Exception ex)
+ {
+ themeError = ex.Message;
+ }
+ finally
+ {
+ savingTheme = false;
+ }
+ }
+
+ private void BackToList()
+ {
+ selectedKeycloak = null;
+ selectedRealm = null;
+ realms = null;
+ }
+
+ private async Task OpenRealm(KeycloakRealm realm)
+ {
+ selectedRealm = realm;
+ realmTab = "users";
+ users = null;
+ idps = null;
+ groups = null;
+ orgs = null;
+ backups = null;
+ realmDetails = null;
+ await SwitchRealmTab("users");
+ }
+
+ private void BackToRealms()
+ {
+ selectedRealm = null;
+ users = null;
+ idps = null;
+ groups = null;
+ orgs = null;
+ backups = null;
+ realmDetails = null;
+ }
+
+ private async Task SwitchRealmTab(string tab)
+ {
+ realmTab = tab;
+
+ if (selectedRealm is null) return;
+
+ switch (tab)
+ {
+ case "users" when users is null:
+ loadingUsers = true;
+ StateHasChanged();
+ try { users = await KeycloakService.GetUsersAsync(TenantId, selectedRealm.Id); }
+ catch (Exception ex) { userError = ex.Message; }
+ finally { loadingUsers = false; }
+ break;
+
+ case "idp" when idps is null:
+ loadingIdps = true;
+ StateHasChanged();
+ try { idps = await KeycloakService.GetIdpsAsync(TenantId, selectedRealm.Id); }
+ catch (Exception ex) { idpError = ex.Message; }
+ finally { loadingIdps = false; }
+ break;
+
+ case "groups" when groups is null:
+ loadingGroups = true;
+ StateHasChanged();
+ try { groups = await KeycloakService.GetGroupsAsync(TenantId, selectedRealm.Id); }
+ catch (Exception ex) { groupError = ex.Message; }
+ finally { loadingGroups = false; }
+ break;
+
+ case "orgs" when orgs is null:
+ loadingOrgs = true;
+ StateHasChanged();
+ try { orgs = await KeycloakService.GetOrganizationsAsync(TenantId, selectedRealm.Id); }
+ catch (Exception ex) { orgError = ex.Message; }
+ finally { loadingOrgs = false; }
+ break;
+
+ case "themes" when realmDetails is null:
+ StateHasChanged();
+ try
+ {
+ realmDetails = await KeycloakService.GetRealmDetailsAsync(TenantId, selectedRealm.Id);
+ editLoginTheme = realmDetails.LoginTheme ?? "";
+ editAccountTheme = realmDetails.AccountTheme ?? "";
+ }
+ catch (Exception ex) { realmThemeError = ex.Message; }
+ break;
+
+ case "backup" when backups is null:
+ loadingBackups = true;
+ storageLinks ??= await StorageService.GetStorageLinksAsync(TenantId);
+ StateHasChanged();
+ try { backups = await KeycloakService.GetBackupsAsync(TenantId, selectedRealm.Id); }
+ catch (Exception ex) { backupError = ex.Message; }
+ finally { loadingBackups = false; }
+ break;
+
+ case "portal":
+ apps ??= await KeycloakService.GetAppsAsync(TenantId);
+ break;
+ }
+ }
+
+ // ── Setup ─────────────────────────────────────────────────────────────────
+
+ private void UseSuggestedUrl()
+ {
+ setupAdminUrl = selectedKeycloak?.SuggestedAdminUrl ?? "";
+ }
+
+ private async Task ConfigureKeycloak()
+ {
+ if (selectedKeycloak is null) return;
+
+ configuringKeycloak = true;
+ setupError = null;
+
+ try
+ {
+ KeycloakComponentConfig config = await KeycloakService.ConfigureAsync(
+ TenantId,
+ selectedKeycloak.Component.Id,
+ setupDatabaseId == Guid.Empty ? null : setupDatabaseId,
+ setupUsername,
+ setupPassword,
+ setupAdminUrl);
+
+ // Sync the credentials to the K8s secret immediately so the pod can start.
+ await LifecycleService.SyncComponentSecretsAsync(selectedKeycloak.Component.Id);
+
+ // Import any realms that already exist in this Keycloak instance.
+ await KeycloakService.SyncRealmsFromKeycloakAsync(TenantId, config.Id);
+
+ // Refresh detection list and re-select the now-configured keycloak.
+ detectedKeycloaks = await KeycloakService.GetDetectedKeycloaksAsync(TenantId);
+ DetectedKeycloak? updated = detectedKeycloaks.FirstOrDefault(
+ k => k.Component.Id == selectedKeycloak.Component.Id);
+
+ if (updated is not null)
+ {
+ await SelectKeycloak(updated);
+ }
+ }
+ catch (Exception ex)
+ {
+ setupError = ex.Message;
+ }
+ finally
+ {
+ configuringKeycloak = false;
+ }
+ }
+
+ private async Task SaveConfig()
+ {
+ if (selectedKeycloak?.Config is null) return;
+
+ savingConfig = true;
+ configError = null;
+
+ try
+ {
+ await KeycloakService.UpdateConfigAsync(
+ TenantId, selectedKeycloak.Config.Id,
+ editAdminUrl, editAdminUsername,
+ string.IsNullOrWhiteSpace(editAdminPassword) ? null : editAdminPassword);
+
+ showEditConfig = false;
+ detectedKeycloaks = await KeycloakService.GetDetectedKeycloaksAsync(TenantId);
+ DetectedKeycloak? updated = detectedKeycloaks.FirstOrDefault(
+ k => k.Component.Id == selectedKeycloak.Component.Id);
+ if (updated is not null) selectedKeycloak = updated;
+ }
+ catch (Exception ex)
+ {
+ configError = ex.Message;
+ }
+ finally
+ {
+ savingConfig = false;
+ }
+ }
+
+ // ── Realm CRUD ────────────────────────────────────────────────────────────
+
+ private async Task CreateRealm()
+ {
+ if (selectedKeycloak?.Config is null) return;
+
+ creatingRealm = true;
+ realmError = null;
+
+ try
+ {
+ await KeycloakService.CreateRealmAsync(
+ TenantId, selectedKeycloak.Config.Id,
+ newRealmName, newRealmDisplayName,
+ string.IsNullOrWhiteSpace(newRealmTheme) ? null : newRealmTheme);
+
+ realms = await KeycloakService.GetRealmsForConfigAsync(TenantId, selectedKeycloak.Config.Id);
+ showCreateRealm = false;
+ newRealmName = "";
+ newRealmDisplayName = "";
+ newRealmTheme = "";
+ }
+ catch (Exception ex)
+ {
+ realmError = ex.Message;
+ }
+ finally
+ {
+ creatingRealm = false;
+ }
+ }
+
+ private async Task DeleteRealm(Guid realmId)
+ {
+ if (selectedKeycloak?.Config is null) return;
+
+ deletingRealmId = realmId;
+
+ try
+ {
+ await KeycloakService.DeleteRealmAsync(TenantId, realmId);
+ realms = await KeycloakService.GetRealmsForConfigAsync(TenantId, selectedKeycloak.Config.Id);
+ }
+ catch (Exception ex)
+ {
+ realmError = ex.Message;
+ }
+ finally
+ {
+ deletingRealmId = null;
+ }
+ }
+
+ // ── User CRUD ─────────────────────────────────────────────────────────────
+
+ private async Task AddUser()
+ {
+ if (selectedRealm is null) return;
+
+ savingUser = true;
+ userError = null;
+
+ try
+ {
+ await KeycloakService.CreateUserAsync(
+ TenantId, selectedRealm.Id,
+ newUserUsername, newUserEmail, newUserFirstName, newUserLastName,
+ string.IsNullOrWhiteSpace(newUserPassword) ? null : newUserPassword);
+
+ users = await KeycloakService.GetUsersAsync(TenantId, selectedRealm.Id);
+ showAddUser = false;
+ newUserUsername = "";
+ newUserEmail = "";
+ newUserFirstName = "";
+ newUserLastName = "";
+ newUserPassword = "";
+ }
+ catch (Exception ex)
+ {
+ userError = ex.Message;
+ }
+ finally
+ {
+ savingUser = false;
+ }
+ }
+
+ private async Task DeleteUser(string userId)
+ {
+ if (selectedRealm is null) return;
+
+ try
+ {
+ await KeycloakService.DeleteUserAsync(TenantId, selectedRealm.Id, userId);
+ users = await KeycloakService.GetUsersAsync(TenantId, selectedRealm.Id);
+ }
+ catch (Exception ex)
+ {
+ userError = ex.Message;
+ }
+ }
+
+ // ── IdP CRUD ──────────────────────────────────────────────────────────────
+
+ private async Task AddIdp()
+ {
+ if (selectedRealm is null) return;
+
+ savingIdp = true;
+ idpError = null;
+
+ try
+ {
+ await KeycloakService.CreateIdpAsync(
+ TenantId, selectedRealm.Id,
+ newIdpAlias, newIdpProviderId, newIdpClientId, newIdpClientSecret);
+
+ idps = await KeycloakService.GetIdpsAsync(TenantId, selectedRealm.Id);
+ showAddIdp = false;
+ newIdpAlias = "";
+ newIdpClientId = "";
+ newIdpClientSecret = "";
+ }
+ catch (Exception ex)
+ {
+ idpError = ex.Message;
+ }
+ finally
+ {
+ savingIdp = false;
+ }
+ }
+
+ private async Task DeleteIdp(string alias)
+ {
+ if (selectedRealm is null) return;
+
+ try
+ {
+ await KeycloakService.DeleteIdpAsync(TenantId, selectedRealm.Id, alias);
+ idps = await KeycloakService.GetIdpsAsync(TenantId, selectedRealm.Id);
+ }
+ catch (Exception ex)
+ {
+ idpError = ex.Message;
+ }
+ }
+
+ // ── Group CRUD ────────────────────────────────────────────────────────────
+
+ private async Task AddGroup()
+ {
+ if (selectedRealm is null) return;
+
+ savingGroup = true;
+ groupError = null;
+
+ try
+ {
+ await KeycloakService.CreateGroupAsync(TenantId, selectedRealm.Id, newGroupName);
+ groups = await KeycloakService.GetGroupsAsync(TenantId, selectedRealm.Id);
+ showAddGroup = false;
+ newGroupName = "";
+ }
+ catch (Exception ex)
+ {
+ groupError = ex.Message;
+ }
+ finally
+ {
+ savingGroup = false;
+ }
+ }
+
+ private async Task DeleteGroup(string groupId)
+ {
+ if (selectedRealm is null) return;
+
+ try
+ {
+ await KeycloakService.DeleteGroupAsync(TenantId, selectedRealm.Id, groupId);
+ groups = await KeycloakService.GetGroupsAsync(TenantId, selectedRealm.Id);
+ }
+ catch (Exception ex)
+ {
+ groupError = ex.Message;
+ }
+ }
+
+ // ── Org CRUD ──────────────────────────────────────────────────────────────
+
+ private async Task AddOrg()
+ {
+ if (selectedRealm is null) return;
+
+ savingOrg = true;
+ orgError = null;
+
+ try
+ {
+ await KeycloakService.CreateOrganizationAsync(
+ TenantId, selectedRealm.Id, newOrgName,
+ string.IsNullOrWhiteSpace(newOrgDescription) ? null : newOrgDescription);
+
+ orgs = await KeycloakService.GetOrganizationsAsync(TenantId, selectedRealm.Id);
+ showAddOrg = false;
+ newOrgName = "";
+ newOrgDescription = "";
+ }
+ catch (Exception ex)
+ {
+ orgError = ex.Message;
+ }
+ finally
+ {
+ savingOrg = false;
+ }
+ }
+
+ // ── Themes ────────────────────────────────────────────────────────────────
+
+ private async Task SaveThemes()
+ {
+ if (selectedRealm is null) return;
+
+ savingThemes = true;
+ realmThemeError = null;
+
+ try
+ {
+ await KeycloakService.UpdateRealmThemesAsync(
+ TenantId, selectedRealm.Id,
+ string.IsNullOrWhiteSpace(editLoginTheme) ? null : editLoginTheme,
+ string.IsNullOrWhiteSpace(editAccountTheme) ? null : editAccountTheme);
+ }
+ catch (Exception ex)
+ {
+ realmThemeError = ex.Message;
+ }
+ finally
+ {
+ savingThemes = false;
+ }
+ }
+
+ // ── Backup ────────────────────────────────────────────────────────────────
+
+ private async Task CreateBackup()
+ {
+ if (selectedRealm is null || backupStorageLinkId == Guid.Empty) return;
+
+ creatingBackup = true;
+ backupError = null;
+
+ try
+ {
+ await KeycloakService.CreateBackupAsync(TenantId, selectedRealm.Id, backupStorageLinkId);
+ backups = await KeycloakService.GetBackupsAsync(TenantId, selectedRealm.Id);
+ }
+ catch (Exception ex)
+ {
+ backupError = ex.Message;
+ }
+ finally
+ {
+ creatingBackup = false;
+ }
+ }
+
+ private async Task RestoreBackup(Guid backupId)
+ {
+ backupError = null;
+
+ try
+ {
+ await KeycloakService.RestoreBackupAsync(TenantId, backupId, null);
+ }
+ catch (Exception ex)
+ {
+ backupError = ex.Message;
+ }
+ }
+
+ private async Task DeleteBackup(Guid backupId)
+ {
+ backupError = null;
+
+ try
+ {
+ await KeycloakService.DeleteBackupAsync(TenantId, backupId);
+ if (selectedRealm is not null)
+ {
+ backups = await KeycloakService.GetBackupsAsync(TenantId, selectedRealm.Id);
+ }
+ }
+ catch (Exception ex)
+ {
+ backupError = ex.Message;
+ }
+ }
+
+ // ── Portal ────────────────────────────────────────────────────────────────
+
+ private async Task LinkApp()
+ {
+ if (selectedRealm is null || linkAppId == Guid.Empty) return;
+
+ portalError = null;
+
+ try
+ {
+ await KeycloakService.LinkRealmToAppAsync(TenantId, selectedRealm.Id, linkAppId);
+
+ if (selectedKeycloak?.Config is not null)
+ {
+ realms = await KeycloakService.GetRealmsForConfigAsync(TenantId, selectedKeycloak.Config.Id);
+ selectedRealm = realms.FirstOrDefault(r => r.Id == selectedRealm.Id);
+ }
+ }
+ catch (Exception ex)
+ {
+ portalError = ex.Message;
+ }
+ }
+
+ private async Task UnlinkApp()
+ {
+ if (selectedRealm is null) return;
+
+ portalError = null;
+
+ try
+ {
+ await KeycloakService.UnlinkRealmFromAppAsync(TenantId, selectedRealm.Id);
+
+ if (selectedKeycloak?.Config is not null)
+ {
+ realms = await KeycloakService.GetRealmsForConfigAsync(TenantId, selectedKeycloak.Config.Id);
+ selectedRealm = realms.FirstOrDefault(r => r.Id == selectedRealm.Id);
+ }
+ }
+ catch (Exception ex)
+ {
+ portalError = ex.Message;
+ }
+ }
+
+ // ── Utilities ─────────────────────────────────────────────────────────────
+
+ private static string FormatSize(long bytes)
+ {
+ if (bytes < 1024) return $"{bytes} B";
+ if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB";
+ return $"{bytes / (1024.0 * 1024):F1} MB";
+ }
+}
diff --git a/src/EntKube.Web/Components/Pages/Tenants/StorageTab.razor b/src/EntKube.Web/Components/Pages/Tenants/StorageTab.razor
index e60b9b8..d42cec8 100644
--- a/src/EntKube.Web/Components/Pages/Tenants/StorageTab.razor
+++ b/src/EntKube.Web/Components/Pages/Tenants/StorageTab.razor
@@ -172,6 +172,19 @@ else
title="Manage bucket (CORS, Policy)">
+
BrowseBuckets(link)"
+ disabled="@(browsingLinkId.HasValue)"
+ title="Browse all buckets accessible with these credentials">
+ @if (browsingLinkId == link.Id)
+ {
+
+ }
+ else
+ {
+
+ }
+
DeleteCleuraS3Bucket(link.Id)"
disabled="@deletingLinkId.HasValue"
@@ -327,6 +340,82 @@ else
}
+ @* ── Browse Buckets Panel ── *@
+ @if (browsedLink is not null)
+ {
+
+
+
+ @if (!string.IsNullOrEmpty(browseError))
+ {
+
+ @browseError
+
+ }
+ else if (browsedBuckets is null)
+ {
+
+ }
+ else if (browsedBuckets.Count == 0)
+ {
+
No buckets found under this project.
+ }
+ else
+ {
+
+
+
+
+ Bucket Name
+ Created
+ Registered in EntKube
+
+
+
+ @foreach (S3BucketInfo bucket in browsedBuckets)
+ {
+ bool registered = storageLinks.Any(l =>
+ l.Provider == StorageProvider.CleuraS3 &&
+ l.BucketName == bucket.Name);
+
+ @bucket.Name
+
+ @(bucket.CreatedAt == default ? "—" : bucket.CreatedAt.ToString("yyyy-MM-dd"))
+
+
+ @if (registered)
+ {
+ Yes
+ }
+ else
+ {
+ No
+ }
+
+
+ }
+
+
+
+
+
+ @browsedBuckets.Count bucket(s) accessible. Buckets not registered in EntKube were created outside the platform.
+
+ }
+
+
+ }
+
@* ── Add Storage Link Form ── *@
@if (showForm)
{
@@ -652,6 +741,12 @@ else
private int corsMaxAge = 3600;
private string bucketPolicyJson = "";
+ // Browse buckets state
+ private StorageLink? browsedLink;
+ private Guid? browsingLinkId;
+ private List
? browsedBuckets;
+ private string? browseError;
+
protected override async Task OnInitializedAsync()
{
await LoadData();
@@ -846,6 +941,35 @@ else
manageSuccess = null;
}
+ private async Task BrowseBuckets(StorageLink link)
+ {
+ browsingLinkId = link.Id;
+ browsedLink = link;
+ browsedBuckets = null;
+ browseError = null;
+ StateHasChanged();
+
+ try
+ {
+ browsedBuckets = await StorageService.ListCleuraS3BucketsAsync(TenantId, link.Id);
+ }
+ catch (Exception ex)
+ {
+ browseError = $"Failed to list buckets: {ex.Message}";
+ }
+ finally
+ {
+ browsingLinkId = null;
+ }
+ }
+
+ private void CloseBrowseBuckets()
+ {
+ browsedLink = null;
+ browsedBuckets = null;
+ browseError = null;
+ }
+
private async Task LoadCors()
{
if (managedLink is null)
diff --git a/src/EntKube.Web/Components/Pages/Tenants/TenantDetail.razor b/src/EntKube.Web/Components/Pages/Tenants/TenantDetail.razor
index 7d7222c..a5cd41e 100644
--- a/src/EntKube.Web/Components/Pages/Tenants/TenantDetail.razor
+++ b/src/EntKube.Web/Components/Pages/Tenants/TenantDetail.razor
@@ -63,6 +63,11 @@ else
Storage
+
+ activeTab = "identity"'>
+ Identity
+
+
@@ -86,6 +91,9 @@ else
case "storage":
break;
+ case "identity":
+
+ break;
}
}
diff --git a/src/EntKube.Web/Data/AppDeployment.cs b/src/EntKube.Web/Data/AppDeployment.cs
index 472dba7..8077106 100644
--- a/src/EntKube.Web/Data/AppDeployment.cs
+++ b/src/EntKube.Web/Data/AppDeployment.cs
@@ -86,4 +86,5 @@ public class AppDeployment
public ICollection Manifests { get; set; } = [];
public ICollection Resources { get; set; } = [];
public ICollection StorageBindings { get; set; } = [];
+ public ICollection DatabaseBindings { get; set; } = [];
}
diff --git a/src/EntKube.Web/Data/ApplicationDbContext.cs b/src/EntKube.Web/Data/ApplicationDbContext.cs
index 8f18489..f1d4ade 100644
--- a/src/EntKube.Web/Data/ApplicationDbContext.cs
+++ b/src/EntKube.Web/Data/ApplicationDbContext.cs
@@ -36,6 +36,13 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
public DbSet CnpgClusters => Set();
public DbSet CnpgDatabases => Set();
public DbSet CnpgBackups => Set();
+ public DbSet MongoClusters => Set();
+ public DbSet MongoDatabases => Set();
+ public DbSet DatabaseBindings => Set();
+ public DbSet MongoBackups => Set();
+ public DbSet KeycloakComponentConfigs => Set();
+ public DbSet KeycloakRealms => Set();
+ public DbSet KeycloakBackups => Set();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
@@ -265,6 +272,16 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
.WithMany()
.HasForeignKey(s => s.CnpgDatabaseId)
.OnDelete(DeleteBehavior.Cascade);
+
+ entity.HasOne(s => s.MongoDatabase)
+ .WithMany()
+ .HasForeignKey(s => s.MongoDatabaseId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ entity.HasOne(s => s.MongoCluster)
+ .WithMany()
+ .HasForeignKey(s => s.MongoClusterId)
+ .OnDelete(DeleteBehavior.Cascade);
});
// ClusterComponent — a deployable unit (Helm chart, operator, etc.) on a cluster.
@@ -476,6 +493,151 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
.HasForeignKey(b => b.CnpgClusterId)
.OnDelete(DeleteBehavior.Cascade);
});
+
+ builder.Entity(entity =>
+ {
+ entity.HasKey(c => c.Id);
+ entity.Property(c => c.Name).HasMaxLength(63).IsRequired();
+ entity.Property(c => c.Namespace).HasMaxLength(63).IsRequired();
+ entity.Property(c => c.MongoVersion).HasMaxLength(20).IsRequired();
+ entity.Property(c => c.StorageSize).HasMaxLength(20).IsRequired();
+ entity.Property(c => c.BackupSchedule).HasMaxLength(100);
+
+ entity.HasIndex(c => new { c.KubernetesClusterId, c.Name, c.Namespace }).IsUnique();
+
+ entity.HasOne(c => c.Tenant)
+ .WithMany()
+ .HasForeignKey(c => c.TenantId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ entity.HasOne(c => c.KubernetesCluster)
+ .WithMany()
+ .HasForeignKey(c => c.KubernetesClusterId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ entity.HasOne(c => c.StorageLink)
+ .WithMany()
+ .HasForeignKey(c => c.StorageLinkId)
+ .OnDelete(DeleteBehavior.SetNull);
+ });
+
+ builder.Entity(entity =>
+ {
+ entity.HasKey(d => d.Id);
+ entity.Property(d => d.Name).HasMaxLength(63).IsRequired();
+
+ entity.HasIndex(d => new { d.MongoClusterId, d.Name }).IsUnique();
+
+ entity.HasOne(d => d.MongoCluster)
+ .WithMany(c => c.Databases)
+ .HasForeignKey(d => d.MongoClusterId)
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ builder.Entity(entity =>
+ {
+ entity.HasKey(b => b.Id);
+ entity.Property(b => b.Name).HasMaxLength(253).IsRequired();
+
+ entity.HasIndex(b => new { b.MongoClusterId, b.Name }).IsUnique();
+
+ entity.HasOne(b => b.MongoCluster)
+ .WithMany(c => c.Backups)
+ .HasForeignKey(b => b.MongoClusterId)
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ // KeycloakComponentConfig — links a detected Keycloak ClusterComponent to its
+ // backing CNPG database. DB credentials and the admin password are stored as
+ // component vault secrets (ComponentId = ClusterComponentId) and synced to K8s.
+
+ builder.Entity(entity =>
+ {
+ entity.HasKey(c => c.Id);
+ entity.Property(c => c.AdminUsername).HasMaxLength(100).IsRequired();
+ entity.Property(c => c.AdminUrl).HasMaxLength(500);
+
+ entity.HasOne(c => c.Tenant)
+ .WithMany()
+ .HasForeignKey(c => c.TenantId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ entity.HasOne(c => c.ClusterComponent)
+ .WithMany()
+ .HasForeignKey(c => c.ClusterComponentId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ entity.HasOne(c => c.CnpgDatabase)
+ .WithMany()
+ .HasForeignKey(c => c.CnpgDatabaseId)
+ .OnDelete(DeleteBehavior.SetNull);
+ });
+
+ // KeycloakRealm — a realm managed via a component config. RealmName must be
+ // unique within a config (Keycloak enforces this globally per server).
+
+ builder.Entity(entity =>
+ {
+ entity.HasKey(r => r.Id);
+ entity.Property(r => r.RealmName).HasMaxLength(100).IsRequired();
+ entity.Property(r => r.DisplayName).HasMaxLength(200).IsRequired();
+
+ entity.HasIndex(r => new { r.KeycloakComponentConfigId, r.RealmName }).IsUnique();
+
+ entity.HasOne(r => r.ComponentConfig)
+ .WithMany(c => c.Realms)
+ .HasForeignKey(r => r.KeycloakComponentConfigId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ entity.HasOne(r => r.LinkedApp)
+ .WithMany()
+ .HasForeignKey(r => r.LinkedAppId)
+ .OnDelete(DeleteBehavior.SetNull);
+ });
+
+ // KeycloakBackup — realm JSON snapshots stored in S3.
+
+ builder.Entity(entity =>
+ {
+ entity.HasKey(b => b.Id);
+ entity.Property(b => b.ObjectKey).HasMaxLength(1024).IsRequired();
+ entity.Property(b => b.RealmName).HasMaxLength(100).IsRequired();
+
+ entity.HasOne(b => b.Realm)
+ .WithMany(r => r.Backups)
+ .HasForeignKey(b => b.KeycloakRealmId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ entity.HasOne(b => b.StorageLink)
+ .WithMany()
+ .HasForeignKey(b => b.StorageLinkId)
+ .OnDelete(DeleteBehavior.Restrict);
+ });
+
+ // DatabaseBinding — connects a managed database (CNPG or MongoDB) to an
+ // AppDeployment. The platform syncs credentials into the app's namespace
+ // automatically, including after password rotations.
+
+ builder.Entity(entity =>
+ {
+ entity.HasKey(b => b.Id);
+ entity.Property(b => b.KubernetesSecretName).HasMaxLength(253).IsRequired();
+
+ entity.HasOne(b => b.CnpgDatabase)
+ .WithMany(d => d.DatabaseBindings)
+ .HasForeignKey(b => b.CnpgDatabaseId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ entity.HasOne(b => b.MongoDatabase)
+ .WithMany(d => d.DatabaseBindings)
+ .HasForeignKey(b => b.MongoDatabaseId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ entity.HasOne(b => b.AppDeployment)
+ .WithMany(d => d.DatabaseBindings)
+ .HasForeignKey(b => b.AppDeploymentId)
+ .OnDelete(DeleteBehavior.Cascade);
+ });
}
}
diff --git a/src/EntKube.Web/Data/CnpgBackup.cs b/src/EntKube.Web/Data/CnpgBackup.cs
index c5f777d..c398e79 100644
--- a/src/EntKube.Web/Data/CnpgBackup.cs
+++ b/src/EntKube.Web/Data/CnpgBackup.cs
@@ -1,3 +1,5 @@
+using System.ComponentModel.DataAnnotations.Schema;
+
namespace EntKube.Web.Data;
///
@@ -69,6 +71,14 @@ public class CnpgBackup
///
public string? LastError { get; set; }
+ ///
+ /// The barman-cloud backup identifier (e.g. "20260519T130201").
+ /// Populated from the Backup CR status.backupId at display time.
+ /// Not persisted — only available when the backup list comes from live K8s.
+ ///
+ [NotMapped]
+ public string? BarmanId { get; set; }
+
// Navigation
public CnpgCluster CnpgCluster { get; set; } = null!;
}
diff --git a/src/EntKube.Web/Data/CnpgCluster.cs b/src/EntKube.Web/Data/CnpgCluster.cs
index 5abdaae..675bbcc 100644
--- a/src/EntKube.Web/Data/CnpgCluster.cs
+++ b/src/EntKube.Web/Data/CnpgCluster.cs
@@ -77,6 +77,12 @@ public class CnpgCluster
///
public string? BackupSchedule { get; set; }
+ ///
+ /// Number of days to retain backups. The Barman Cloud Plugin uses this as the
+ /// recovery window (e.g. "30d"). Expired backups are cleaned up automatically.
+ ///
+ public int RetentionDays { get; set; } = 30;
+
///
/// Current lifecycle status of the cluster.
///
diff --git a/src/EntKube.Web/Data/CnpgClusterDetail.cs b/src/EntKube.Web/Data/CnpgClusterDetail.cs
index 98106f6..cceac73 100644
--- a/src/EntKube.Web/Data/CnpgClusterDetail.cs
+++ b/src/EntKube.Web/Data/CnpgClusterDetail.cs
@@ -41,6 +41,12 @@ public class CnpgClusterDetail
/// The current write LAG in bytes across replicas (0 = fully caught up).
///
public long ReplicationLagBytes { get; set; }
+
+ ///
+ /// Live-synced backup list reconciled from K8s Backup CRs plus the DB records.
+ /// Populated by GetClusterDetailAsync; falls back to Cluster.Backups if sync is skipped.
+ ///
+ public List Backups { get; set; } = [];
}
///
diff --git a/src/EntKube.Web/Data/CnpgDatabase.cs b/src/EntKube.Web/Data/CnpgDatabase.cs
index 8c9c462..5fe1acb 100644
--- a/src/EntKube.Web/Data/CnpgDatabase.cs
+++ b/src/EntKube.Web/Data/CnpgDatabase.cs
@@ -51,4 +51,5 @@ public class CnpgDatabase
// Navigation
public CnpgCluster CnpgCluster { get; set; } = null!;
+ public ICollection DatabaseBindings { get; set; } = [];
}
diff --git a/src/EntKube.Web/Data/DatabaseBinding.cs b/src/EntKube.Web/Data/DatabaseBinding.cs
new file mode 100644
index 0000000..326ccbd
--- /dev/null
+++ b/src/EntKube.Web/Data/DatabaseBinding.cs
@@ -0,0 +1,60 @@
+namespace EntKube.Web.Data;
+
+///
+/// Connects a managed database (CNPG or MongoDB) to an app deployment so the
+/// platform can sync credentials into the app's namespace automatically.
+///
+/// When a binding exists and SyncEnabled is true, any credential sync (including
+/// after password rotation) will also push a Kubernetes Secret named
+/// KubernetesSecretName into the AppDeployment's namespace on its cluster.
+/// The app can then mount the secret as env vars without knowing which database
+/// provider backs it.
+///
+public class DatabaseBinding
+{
+ public Guid Id { get; set; }
+
+ ///
+ /// The CNPG database whose credentials should be synced to the app.
+ /// Mutually exclusive with MongoDatabaseId.
+ ///
+ public Guid? CnpgDatabaseId { get; set; }
+
+ ///
+ /// The MongoDB database whose credentials should be synced to the app.
+ /// Mutually exclusive with CnpgDatabaseId.
+ ///
+ public Guid? MongoDatabaseId { get; set; }
+
+ ///
+ /// The deployment that consumes the database credentials.
+ /// The secret is written into this deployment's namespace on its cluster.
+ ///
+ public Guid AppDeploymentId { get; set; }
+
+ ///
+ /// The Kubernetes Secret name to create in the app's namespace.
+ /// Choose something meaningful to the app (e.g. "billing-db", "app-postgres").
+ ///
+ public required string KubernetesSecretName { get; set; }
+
+ ///
+ /// When true, credential syncs (including password rotations) automatically
+ /// propagate to this app's namespace. Disable to pause propagation without
+ /// removing the binding record.
+ ///
+ public bool SyncEnabled { get; set; } = true;
+
+ ///
+ /// Last time credentials were successfully written to the app's namespace.
+ /// Null means the binding exists but has not been synced yet.
+ ///
+ public DateTime? LastSyncedAt { get; set; }
+
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+
+ // Navigation
+ public CnpgDatabase? CnpgDatabase { get; set; }
+ public MongoDatabase? MongoDatabase { get; set; }
+ public AppDeployment AppDeployment { get; set; } = null!;
+}
diff --git a/src/EntKube.Web/Data/KeycloakBackup.cs b/src/EntKube.Web/Data/KeycloakBackup.cs
new file mode 100644
index 0000000..37a4a3e
--- /dev/null
+++ b/src/EntKube.Web/Data/KeycloakBackup.cs
@@ -0,0 +1,49 @@
+namespace EntKube.Web.Data;
+
+public enum KeycloakBackupStatus
+{
+ Creating,
+ Ready,
+ Failed
+}
+
+///
+/// A backup of a Keycloak realm — the full realm JSON exported via the Keycloak
+/// Admin REST API and stored in an S3 bucket. Used for disaster recovery and
+/// migrating realms between environments.
+///
+public class KeycloakBackup
+{
+ public Guid Id { get; set; }
+
+ public Guid KeycloakRealmId { get; set; }
+
+ public Guid TenantId { get; set; }
+
+ public Guid StorageLinkId { get; set; }
+
+ ///
+ /// S3 object key (e.g. "keycloak-backups/my-realm/2026-05-19T14-32-00.json").
+ ///
+ public required string ObjectKey { get; set; }
+
+ ///
+ /// Snapshot of the realm name at backup time, for display after the realm
+ /// may have been renamed or deleted.
+ ///
+ public required string RealmName { get; set; }
+
+ public long SizeBytes { get; set; }
+
+ public KeycloakBackupStatus Status { get; set; } = KeycloakBackupStatus.Creating;
+
+ public string? LastError { get; set; }
+
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+
+ public DateTime? CompletedAt { get; set; }
+
+ // Navigation
+ public KeycloakRealm Realm { get; set; } = null!;
+ public StorageLink StorageLink { get; set; } = null!;
+}
diff --git a/src/EntKube.Web/Data/KeycloakComponentConfig.cs b/src/EntKube.Web/Data/KeycloakComponentConfig.cs
new file mode 100644
index 0000000..b059515
--- /dev/null
+++ b/src/EntKube.Web/Data/KeycloakComponentConfig.cs
@@ -0,0 +1,52 @@
+namespace EntKube.Web.Data;
+
+///
+/// Keycloak-specific configuration attached to an installed Keycloak ClusterComponent.
+/// Stores which CNPG database backs this Keycloak instance and the admin credentials
+/// needed for the Admin REST API.
+///
+/// DB credentials (KC_DB_URL, KC_DB_USERNAME, KC_DB_PASSWORD) are stored as
+/// component vault secrets and synced to a Kubernetes Secret so the Helm chart
+/// can consume them without any out-of-band secret management.
+///
+/// The admin password is stored as a component vault secret named
+/// "KEYCLOAK_ADMIN_PASSWORD" and synced to the same Kubernetes Secret.
+///
+public class KeycloakComponentConfig
+{
+ public Guid Id { get; set; }
+
+ public Guid TenantId { get; set; }
+
+ ///
+ /// The installed Keycloak ClusterComponent this config belongs to.
+ /// One config per Keycloak component instance.
+ ///
+ public Guid ClusterComponentId { get; set; }
+
+ ///
+ /// The managed CNPG database backing this Keycloak instance.
+ /// Null until the operator selects a database.
+ ///
+ public Guid? CnpgDatabaseId { get; set; }
+
+ ///
+ /// Keycloak admin username (default "admin").
+ ///
+ public string AdminUsername { get; set; } = "admin";
+
+ ///
+ /// URL used by the platform to call the Keycloak Admin REST API.
+ /// Typically the external hostname (e.g. "https://auth.example.com").
+ /// Can be auto-suggested from the component's ExternalRoutes.
+ ///
+ public string? AdminUrl { get; set; }
+
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+
+ // Navigation
+ public Tenant Tenant { get; set; } = null!;
+ public ClusterComponent ClusterComponent { get; set; } = null!;
+ public CnpgDatabase? CnpgDatabase { get; set; }
+ public ICollection Realms { get; set; } = [];
+}
diff --git a/src/EntKube.Web/Data/KeycloakRealm.cs b/src/EntKube.Web/Data/KeycloakRealm.cs
new file mode 100644
index 0000000..1d9a942
--- /dev/null
+++ b/src/EntKube.Web/Data/KeycloakRealm.cs
@@ -0,0 +1,51 @@
+namespace EntKube.Web.Data;
+
+///
+/// A Keycloak realm managed by EntKube. The realm exists in Keycloak;
+/// this entity tracks it so EntKube can surface it in the UI, manage backups,
+/// and optionally link it to a customer app for self-service portal access.
+///
+public class KeycloakRealm
+{
+ public Guid Id { get; set; }
+
+ public Guid KeycloakComponentConfigId { get; set; }
+
+ public Guid TenantId { get; set; }
+
+ ///
+ /// The Keycloak realm ID (e.g. "acme-prod"). Used in all API calls.
+ ///
+ public required string RealmName { get; set; }
+
+ ///
+ /// Human-readable display name shown inside the Keycloak login UI.
+ ///
+ public required string DisplayName { get; set; }
+
+ public bool Enabled { get; set; } = true;
+
+ ///
+ /// Login page theme assigned to this realm (e.g. "keycloak", "custom-brand").
+ /// Null means the Keycloak default.
+ ///
+ public string? LoginTheme { get; set; }
+
+ ///
+ /// Account console theme for self-service password management.
+ ///
+ public string? AccountTheme { get; set; }
+
+ ///
+ /// When set, this realm is linked to a customer app and the customer can
+ /// manage their realm (users, groups, IdPs) through the portal.
+ ///
+ public Guid? LinkedAppId { get; set; }
+
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+
+ // Navigation
+ public KeycloakComponentConfig ComponentConfig { get; set; } = null!;
+ public App? LinkedApp { get; set; }
+ public ICollection Backups { get; set; } = [];
+}
diff --git a/src/EntKube.Web/Data/Migrations/Postgres/20260518072619_AddCnpgRetentionDays.Designer.cs b/src/EntKube.Web/Data/Migrations/Postgres/20260518072619_AddCnpgRetentionDays.Designer.cs
new file mode 100644
index 0000000..3df3d0d
--- /dev/null
+++ b/src/EntKube.Web/Data/Migrations/Postgres/20260518072619_AddCnpgRetentionDays.Designer.cs
@@ -0,0 +1,1723 @@
+//
+using System;
+using EntKube.Web.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace EntKube.Web.Data.Migrations.Postgres
+{
+ [DbContext(typeof(PostgresApplicationDbContext))]
+ [Migration("20260518072619_AddCnpgRetentionDays")]
+ partial class AddCnpgRetentionDays
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("EntKube.Web.Data.App", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CustomerId")
+ .HasColumnType("uuid");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CustomerId", "Name")
+ .IsUnique();
+
+ b.ToTable("Apps");
+ });
+
+ modelBuilder.Entity("EntKube.Web.Data.AppDeployment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AppId")
+ .HasColumnType("uuid");
+
+ b.Property("ClusterId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EnvironmentId")
+ .HasColumnType("uuid");
+
+ b.Property("HealthStatus")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("HelmChartName")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("HelmChartVersion")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("HelmRepoUrl")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("HelmValues")
+ .HasColumnType("text");
+
+ b.Property("LastSyncedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Namespace")
+ .IsRequired()
+ .HasMaxLength(63)
+ .HasColumnType("character varying(63)");
+
+ b.Property("StatusMessage")
+ .HasColumnType("text");
+
+ b.Property("SyncStatus")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClusterId");
+
+ b.HasIndex("EnvironmentId");
+
+ b.HasIndex("AppId", "Name")
+ .IsUnique();
+
+ b.ToTable("AppDeployments");
+ });
+
+ modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b =>
+ {
+ b.Property("AppId")
+ .HasColumnType("uuid");
+
+ b.Property("EnvironmentId")
+ .HasColumnType("uuid");
+
+ b.Property("LinkedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("AppId", "EnvironmentId");
+
+ b.HasIndex("EnvironmentId");
+
+ b.ToTable("AppEnvironments");
+ });
+
+ modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("integer");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("text");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("boolean");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("PasswordHash")
+ .HasColumnType("text");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("text");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("boolean");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("text");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedEmail")
+ .HasDatabaseName("EmailIndex");
+
+ b.HasIndex("NormalizedUserName")
+ .IsUnique()
+ .HasDatabaseName("UserNameIndex");
+
+ b.ToTable("AspNetUsers", (string)null);
+ });
+
+ modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ClusterId")
+ .HasColumnType("uuid");
+
+ b.Property("ComponentType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("Configuration")
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("HelmChartName")
+ .HasColumnType("text");
+
+ b.Property("HelmChartVersion")
+ .HasColumnType("text");
+
+ b.Property("HelmRepoUrl")
+ .HasColumnType("text");
+
+ b.Property("HelmValues")
+ .HasColumnType("text");
+
+ b.Property("InstalledAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastError")
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Namespace")
+ .HasColumnType("text");
+
+ b.Property("ReleaseName")
+ .HasColumnType("text");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClusterId", "Name")
+ .IsUnique();
+
+ b.ToTable("ClusterComponents");
+ });
+
+ modelBuilder.Entity("EntKube.Web.Data.CnpgBackup", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CnpgClusterId")
+ .HasColumnType("uuid");
+
+ b.Property("CompletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastError")
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(253)
+ .HasColumnType("character varying(253)");
+
+ b.Property("SizeBytes")
+ .HasColumnType("bigint");
+
+ b.Property("StartedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("Type")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CnpgClusterId", "Name")
+ .IsUnique();
+
+ b.ToTable("CnpgBackups");
+ });
+
+ modelBuilder.Entity("EntKube.Web.Data.CnpgCluster", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("BackupSchedule")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Instances")
+ .HasColumnType("integer");
+
+ b.Property("KubernetesClusterId")
+ .HasColumnType("uuid");
+
+ b.Property("LastError")
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(63)
+ .HasColumnType("character varying(63)");
+
+ b.Property("Namespace")
+ .IsRequired()
+ .HasMaxLength(63)
+ .HasColumnType("character varying(63)");
+
+ b.Property("PostgresVersion")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.Property("RetentionDays")
+ .HasColumnType("integer");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("StorageLinkId")
+ .HasColumnType("uuid");
+
+ b.Property("StorageSize")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("StorageLinkId");
+
+ b.HasIndex("TenantId");
+
+ b.HasIndex("KubernetesClusterId", "Name", "Namespace")
+ .IsUnique();
+
+ b.ToTable("CnpgClusters");
+ });
+
+ modelBuilder.Entity("EntKube.Web.Data.CnpgDatabase", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CnpgClusterId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(63)
+ .HasColumnType("character varying(63)");
+
+ b.Property("Owner")
+ .IsRequired()
+ .HasMaxLength(63)
+ .HasColumnType("character varying(63)");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CnpgClusterId", "Name")
+ .IsUnique();
+
+ b.ToTable("CnpgDatabases");
+ });
+
+ modelBuilder.Entity("EntKube.Web.Data.Customer", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "Name")
+ .IsUnique();
+
+ b.ToTable("Customers");
+ });
+
+ modelBuilder.Entity("EntKube.Web.Data.CustomerAccess", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("text");
+
+ b.Property("CustomerId")
+ .HasColumnType("uuid");
+
+ b.Property("GrantedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.HasKey("UserId", "CustomerId");
+
+ b.HasIndex("CustomerId");
+
+ b.ToTable("CustomerAccesses");
+ });
+
+ modelBuilder.Entity("EntKube.Web.Data.DeploymentManifest", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeploymentId")
+ .HasColumnType("uuid");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(253)
+ .HasColumnType("character varying(253)");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("YamlContent")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeploymentId");
+
+ b.ToTable("DeploymentManifests");
+ });
+
+ modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeploymentId")
+ .HasColumnType("uuid");
+
+ b.Property("Group")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("HealthStatus")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("LastUpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(253)
+ .HasColumnType("character varying(253)");
+
+ b.Property("Namespace")
+ .HasMaxLength(63)
+ .HasColumnType("character varying(63)");
+
+ b.Property("ParentResourceId")
+ .HasColumnType("uuid");
+
+ b.Property("StatusMessage")
+ .HasColumnType("text");
+
+ b.Property("SyncStatus")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("Version")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeploymentId");
+
+ b.HasIndex("ParentResourceId");
+
+ b.ToTable("DeploymentResources");
+ });
+
+ modelBuilder.Entity("EntKube.Web.Data.Environment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "Name")
+ .IsUnique();
+
+ b.ToTable("Environments");
+ });
+
+ modelBuilder.Entity("EntKube.Web.Data.ExternalRoute", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ClusterIssuerName")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("ComponentId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("GatewayName")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("GatewayNamespace")
+ .HasMaxLength(63)
+ .HasColumnType("character varying(63)");
+
+ b.Property("Hostname")
+ .IsRequired()
+ .HasMaxLength(253)
+ .HasColumnType("character varying(253)");
+
+ b.Property("PathPrefix")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("ServiceName")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("ServicePort")
+ .HasColumnType("integer");
+
+ b.Property("TlsCertificate")
+ .HasColumnType("text");
+
+ b.Property("TlsMode")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("TlsPrivateKey")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ComponentId");
+
+ b.ToTable("ExternalRoutes");
+ });
+
+ modelBuilder.Entity("EntKube.Web.Data.Group", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "Name")
+ .IsUnique();
+
+ b.ToTable("Groups");
+ });
+
+ modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("text");
+
+ b.Property("GroupId")
+ .HasColumnType("uuid");
+
+ b.Property("JoinedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("UserId", "GroupId");
+
+ b.HasIndex("GroupId");
+
+ b.ToTable("GroupMemberships");
+ });
+
+ modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ApiServerUrl")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("ContextName")
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EnvironmentId")
+ .HasColumnType("uuid");
+
+ b.Property("Kubeconfig")
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EnvironmentId");
+
+ b.HasIndex("TenantId", "Name")
+ .IsUnique();
+
+ b.ToTable("KubernetesClusters");
+ });
+
+ modelBuilder.Entity("EntKube.Web.Data.OpenStackConnection", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AuthUrl")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ProjectDomainName")
+ .HasColumnType("text");
+
+ b.Property("ProjectId")
+ .HasColumnType("text");
+
+ b.Property("ProjectName")
+ .HasColumnType("text");
+
+ b.Property("Region")
+ .HasColumnType("text");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid");
+
+ b.Property("UserDomainName")
+ .HasColumnType("text");
+
+ b.Property("Username")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId");
+
+ b.ToTable("OpenStackConnections");
+ });
+
+ modelBuilder.Entity("EntKube.Web.Data.SecretVault", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EncryptedDataKey")
+ .IsRequired()
+ .HasColumnType("bytea");
+
+ b.Property("Nonce")
+ .IsRequired()
+ .HasColumnType("bytea");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId")
+ .IsUnique();
+
+ b.ToTable("SecretVaults");
+ });
+
+ modelBuilder.Entity("EntKube.Web.Data.StorageBinding", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AppDeploymentId")
+ .HasColumnType("uuid");
+
+ b.Property("ComponentId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("KubernetesSecretName")
+ .IsRequired()
+ .HasMaxLength(253)
+ .HasColumnType("character varying(253)");
+
+ b.Property("LastSyncedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("StorageLinkId")
+ .HasColumnType("uuid");
+
+ b.Property("SyncEnabled")
+ .HasColumnType("boolean");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppDeploymentId");
+
+ b.HasIndex("ComponentId");
+
+ b.HasIndex("StorageLinkId");
+
+ b.ToTable("StorageBindings");
+ });
+
+ modelBuilder.Entity("EntKube.Web.Data.StorageLink", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("BucketName")
+ .HasColumnType("text");
+
+ b.Property("ComponentId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Endpoint")
+ .HasColumnType("text");
+
+ b.Property("EnvironmentId")
+ .HasColumnType("uuid");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Notes")
+ .HasColumnType("text");
+
+ b.Property("OpenStackConnectionId")
+ .HasColumnType("uuid");
+
+ b.Property("Provider")
+ .HasColumnType("integer");
+
+ b.Property("Region")
+ .HasColumnType("text");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ComponentId");
+
+ b.HasIndex("EnvironmentId");
+
+ b.HasIndex("OpenStackConnectionId");
+
+ b.HasIndex("TenantId");
+
+ b.ToTable("StorageLinks");
+ });
+
+ modelBuilder.Entity("EntKube.Web.Data.Tenant", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Slug")
+ .IsUnique();
+
+ b.ToTable("Tenants");
+ });
+
+ modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("text");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid");
+
+ b.Property("JoinedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RoleId")
+ .HasColumnType("uuid");
+
+ b.HasKey("UserId", "TenantId");
+
+ b.HasIndex("RoleId");
+
+ b.HasIndex("TenantId");
+
+ b.ToTable("TenantMemberships");
+ });
+
+ modelBuilder.Entity("EntKube.Web.Data.TenantRole", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "Name")
+ .IsUnique();
+
+ b.ToTable("TenantRoles");
+ });
+
+ modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AppId")
+ .HasColumnType("uuid");
+
+ b.Property("CnpgDatabaseId")
+ .HasColumnType("uuid");
+
+ b.Property("ComponentId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EncryptedValue")
+ .IsRequired()
+ .HasColumnType("bytea");
+
+ b.Property("KubernetesNamespace")
+ .HasMaxLength(63)
+ .HasColumnType("character varying(63)");
+
+ b.Property("KubernetesSecretName")
+ .HasMaxLength(253)
+ .HasColumnType("character varying(253)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Nonce")
+ .IsRequired()
+ .HasColumnType("bytea");
+
+ b.Property("OpenStackConnectionId")
+ .HasColumnType("uuid");
+
+ b.Property("StorageLinkId")
+ .HasColumnType("uuid");
+
+ b.Property("SyncToKubernetes")
+ .HasColumnType("boolean");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property