gateway api fixes

This commit is contained in:
Nils Blomgren
2026-05-21 16:57:50 +02:00
parent 8b94fb8826
commit e0f967482e
68 changed files with 44082 additions and 437 deletions

View File

@@ -3,6 +3,7 @@
@using Microsoft.EntityFrameworkCore
@inject TenantService TenantService
@inject DeploymentService DeploymentService
@inject CnpgService CnpgService
@* ═══════════════════════════════════════════════════════════════════
App Detail — a full-page view for a single application.
@@ -226,6 +227,16 @@
<i class="bi bi-diagram-3 me-1"></i>Resources
</button>
</li>
<li class="nav-item">
<button class="nav-link @(deploymentSection == "databases" ? "active" : "")"
@onclick="OpenDatabasesTab">
<i class="bi bi-database me-1"></i>Databases
@if (databaseBindings is not null && databaseBindings.Count > 0)
{
<span class="badge bg-primary ms-1">@databaseBindings.Count</span>
}
</button>
</li>
</ul>
@* ── Manifests sub-tab ── *@
@@ -393,6 +404,123 @@
</div>
</div>
}
@* ── Databases sub-tab ── *@
@if (deploymentSection == "databases")
{
<div class="card shadow-sm">
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between">
<div>
<i class="bi bi-database me-2 text-primary"></i>
<strong>Database Bindings</strong>
</div>
<button class="btn btn-sm btn-outline-primary" @onclick="() => showAddBinding = !showAddBinding">
<i class="bi bi-plus me-1"></i>Attach Database
</button>
</div>
<div class="card-body">
@if (!string.IsNullOrEmpty(bindingError))
{
<div class="alert alert-danger py-1 small mb-2">@bindingError</div>
}
@* --- Add binding form --- *@
@if (showAddBinding)
{
<div class="card border-primary mb-3">
<div class="card-body">
<h6 class="mb-3"><i class="bi bi-plus-circle me-1"></i>Attach a PostgreSQL Database</h6>
<div class="row g-2 mb-2">
<div class="col-md-6">
<label class="form-label small">Database</label>
<select class="form-select form-select-sm" @bind="newBindingDatabaseId">
<option value="">Select database...</option>
@if (availableCnpgDatabases is not null)
{
@foreach ((CnpgCluster cluster, CnpgDatabase database) in availableCnpgDatabases)
{
<option value="@database.Id">@cluster.Name / @database.Name</option>
}
}
</select>
</div>
<div class="col-md-6">
<label class="form-label small">Secret name in app namespace</label>
<input class="form-control form-control-sm" @bind="newBindingSecretName"
placeholder="e.g. my-app-db" />
<div class="form-text">The K8s Secret created in <code>@selectedDeployment?.Namespace</code>.</div>
</div>
</div>
<div class="d-flex gap-1">
<button class="btn btn-sm btn-primary" @onclick="AddDatabaseBinding"
disabled="@(newBindingDatabaseId == Guid.Empty || string.IsNullOrWhiteSpace(newBindingSecretName))">
Attach &amp; Sync
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showAddBinding = false">Cancel</button>
</div>
</div>
</div>
}
@* --- Binding list --- *@
@if (databaseBindings is null)
{
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</div>
}
else if (databaseBindings.Count == 0)
{
<div class="text-center py-4">
<i class="bi bi-database text-muted" style="font-size: 2rem;"></i>
<p class="text-muted small mt-2 mb-0">No databases attached. Attach a database to have its credentials automatically synced to this deployment's namespace.</p>
</div>
}
else
{
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th class="small">Database</th>
<th class="small">Secret name</th>
<th class="small">Last synced</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (DatabaseBinding binding in databaseBindings)
{
<tr>
<td class="small">
@if (binding.CnpgDatabase is not null)
{
<i class="bi bi-database me-1 text-primary"></i>
@binding.CnpgDatabase.CnpgCluster.Name<span class="text-muted"> / </span>@binding.CnpgDatabase.Name
}
else if (binding.MongoDatabase is not null)
{
<i class="bi bi-database me-1 text-success"></i>
@binding.MongoDatabase.MongoCluster.Name<span class="text-muted"> / </span>@binding.MongoDatabase.Name
}
</td>
<td><code class="small">@binding.KubernetesSecretName</code></td>
<td class="small text-muted">
@(binding.LastSyncedAt.HasValue ? binding.LastSyncedAt.Value.ToString("yyyy-MM-dd HH:mm") : "Never")
</td>
<td>
<button class="btn btn-link btn-sm text-danger p-0" title="Detach"
@onclick="() => RemoveDatabaseBinding(binding.Id)">
<i class="bi bi-x-circle" style="font-size:0.85rem;"></i>
</button>
</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
}
}
else
{
@@ -602,6 +730,14 @@
// Resource tree
private List<DeploymentResource>? resourceTree;
// Database bindings
private List<DatabaseBinding>? databaseBindings;
private List<(CnpgCluster Cluster, CnpgDatabase Database)>? availableCnpgDatabases;
private bool showAddBinding;
private Guid newBindingDatabaseId;
private string newBindingSecretName = "";
private string? bindingError;
protected override async Task OnInitializedAsync()
{
await LoadEnvironments();
@@ -790,6 +926,80 @@
}
}
// ──────── Database bindings ────────
private async Task OpenDatabasesTab()
{
deploymentSection = "databases";
if (selectedDeployment is not null)
{
databaseBindings = await CnpgService.GetDatabaseBindingsAsync(selectedDeployment.Id);
if (availableCnpgDatabases is null)
{
List<CnpgCluster> clusters = await CnpgService.GetClustersAsync(TenantId);
availableCnpgDatabases = clusters
.SelectMany(c => c.Databases.Select(d => (c, d)))
.ToList();
}
}
}
private async Task AddDatabaseBinding()
{
if (selectedDeployment is null || newBindingDatabaseId == Guid.Empty)
{
return;
}
bindingError = null;
try
{
DatabaseBinding binding = await CnpgService.AddCnpgDatabaseBindingAsync(
selectedDeployment.Id, newBindingDatabaseId, newBindingSecretName);
// Find the cluster that owns this database so we can call sync.
(CnpgCluster cluster, CnpgDatabase _)? match = availableCnpgDatabases?
.FirstOrDefault(x => x.Database.Id == newBindingDatabaseId);
if (match.HasValue)
{
await CnpgService.SyncDatabaseCredentialsToK8sAsync(
TenantId, match.Value.cluster.Id, newBindingDatabaseId);
}
showAddBinding = false;
newBindingDatabaseId = Guid.Empty;
newBindingSecretName = "";
databaseBindings = await CnpgService.GetDatabaseBindingsAsync(selectedDeployment.Id);
}
catch (Exception ex)
{
bindingError = $"Failed to attach database: {ex.Message}";
}
}
private async Task RemoveDatabaseBinding(Guid bindingId)
{
bindingError = null;
try
{
await CnpgService.RemoveDatabaseBindingAsync(bindingId);
if (selectedDeployment is not null)
{
databaseBindings = await CnpgService.GetDatabaseBindingsAsync(selectedDeployment.Id);
}
}
catch (Exception ex)
{
bindingError = $"Failed to detach database: {ex.Message}";
}
}
// ──────── Rendering helpers ────────
private RenderFragment GetTypeBadge(DeploymentType type) => type switch

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -172,6 +172,19 @@ else
title="Manage bucket (CORS, Policy)">
<i class="bi bi-gear"></i>
</button>
<button class="btn btn-sm btn-outline-info border-0 py-0"
@onclick="() => BrowseBuckets(link)"
disabled="@(browsingLinkId.HasValue)"
title="Browse all buckets accessible with these credentials">
@if (browsingLinkId == link.Id)
{
<span class="spinner-border spinner-border-sm"></span>
}
else
{
<i class="bi bi-list-ul"></i>
}
</button>
<button class="btn btn-sm btn-outline-danger border-0 py-0"
@onclick="() => DeleteCleuraS3Bucket(link.Id)"
disabled="@deletingLinkId.HasValue"
@@ -327,6 +340,82 @@ else
</div>
}
@* ── Browse Buckets Panel ── *@
@if (browsedLink is not null)
{
<div class="card shadow-sm mb-4 border-secondary">
<div class="card-header bg-secondary bg-opacity-10 d-flex align-items-center justify-content-between">
<h6 class="mb-0">
<i class="bi bi-list-ul me-2"></i>Buckets accessible via <strong>@browsedLink.Name</strong>
</h6>
<button class="btn btn-sm btn-outline-secondary border-0" @onclick="CloseBrowseBuckets">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="card-body">
@if (!string.IsNullOrEmpty(browseError))
{
<div class="alert alert-danger small py-2">
<i class="bi bi-exclamation-triangle me-1"></i>@browseError
</div>
}
else if (browsedBuckets is null)
{
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-secondary"></div>
<span class="ms-2 text-muted small">Loading buckets...</span>
</div>
}
else if (browsedBuckets.Count == 0)
{
<p class="text-muted small mb-0">No buckets found under this project.</p>
}
else
{
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th class="small">Bucket Name</th>
<th class="small">Created</th>
<th class="small">Registered in EntKube</th>
</tr>
</thead>
<tbody>
@foreach (S3BucketInfo bucket in browsedBuckets)
{
bool registered = storageLinks.Any(l =>
l.Provider == StorageProvider.CleuraS3 &&
l.BucketName == bucket.Name);
<tr>
<td class="small font-monospace">@bucket.Name</td>
<td class="small text-muted">
@(bucket.CreatedAt == default ? "—" : bucket.CreatedAt.ToString("yyyy-MM-dd"))
</td>
<td class="small">
@if (registered)
{
<span class="badge bg-success-subtle text-success border border-success-subtle">Yes</span>
}
else
{
<span class="badge bg-secondary-subtle text-secondary border">No</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
<p class="text-muted small mt-2 mb-0">
<i class="bi bi-info-circle me-1"></i>
@browsedBuckets.Count bucket(s) accessible. Buckets not registered in EntKube were created outside the platform.
</p>
}
</div>
</div>
}
@* ── 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<S3BucketInfo>? 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)

View File

@@ -63,6 +63,11 @@ else
<i class="bi bi-bucket me-1"></i>Storage
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "identity" ? "active" : "")" @onclick='() => activeTab = "identity"'>
<i class="bi bi-person-badge me-1"></i>Identity
</button>
</li>
</ul>
<div class="tab-content">
@@ -86,6 +91,9 @@ else
case "storage":
<StorageTab TenantId="tenant.Id" />
break;
case "identity":
<IdentityTab TenantId="tenant.Id" />
break;
}
</div>
}