Mark subproject as dirty in CMKS - Infra

This commit is contained in:
Nils Blomgren
2026-05-18 08:37:09 +02:00
parent 328d494530
commit 8b94fb8826
635 changed files with 81145 additions and 100551 deletions

View File

@@ -1,5 +1,5 @@
@inherits LayoutComponentBase
@layout EntKube.Web.Client.Layout.MainLayout
@layout EntKube.Web.Components.Layout.MainLayout
<h1>Manage your account</h1>

View File

@@ -12,22 +12,14 @@
<link rel="stylesheet" href="@Assets["EntKube.Web.styles.css"]" />
<ImportMap />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet @rendermode="PageRenderMode" />
<HeadOutlet />
</head>
<body>
<Routes @rendermode="PageRenderMode" />
<Routes />
<ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script>
<script src="@Assets["Components/Account/Shared/PasskeySubmit.razor.js"]" type="module"></script>
</body>
</html>
@code {
[CascadingParameter]
private HttpContext HttpContext { get; set; } = default!;
private IComponentRenderMode? PageRenderMode =>
HttpContext.AcceptsInteractiveRouting() ? InteractiveAuto : null;
}

View File

@@ -1,135 +0,0 @@
using System.Net.Http.Json;
namespace EntKube.Web.Components.Clusters;
/// <summary>
/// BFF proxy endpoints for certificate management. Forwards requests from
/// the Blazor frontend to the Clusters microservice's certificate endpoints.
/// </summary>
public static class CertificateProxyEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/clusters/{clusterId:guid}/certificates")
.RequireAuthorization()
.DisableAntiforgery();
// GET /api/clusters/{clusterId}/certificates/cas — list all CAs
group.MapGet("cas", async (Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.GetAsync($"/api/clusters/{clusterId}/certificates/cas", ct);
response.EnsureSuccessStatusCode();
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// POST /api/clusters/{clusterId}/certificates/cas/internal — create internal CA
group.MapPost("cas/internal", async (Guid clusterId, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
StreamContent content = new(request.Body);
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
HttpResponseMessage response = await client.PostAsync($"/api/clusters/{clusterId}/certificates/cas/internal", content, ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// POST /api/clusters/{clusterId}/certificates/cas/domain — create domain CA
group.MapPost("cas/domain", async (Guid clusterId, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
StreamContent content = new(request.Body);
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
HttpResponseMessage response = await client.PostAsync($"/api/clusters/{clusterId}/certificates/cas/domain", content, ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// DELETE /api/clusters/{clusterId}/certificates/cas/{name} — delete a CA
group.MapDelete("cas/{name}", async (Guid clusterId, string name, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.DeleteAsync($"/api/clusters/{clusterId}/certificates/cas/{name}", ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// POST /api/clusters/{clusterId}/certificates/cas/{name}/rotate — rotate a CA
group.MapPost("cas/{name}/rotate", async (Guid clusterId, string name, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.PostAsync($"/api/clusters/{clusterId}/certificates/cas/{name}/rotate", null, ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// POST /api/clusters/{clusterId}/certificates/cas/{name}/publish — publish CA to trust bundle
group.MapPost("cas/{name}/publish", async (Guid clusterId, string name, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
StreamContent content = new(request.Body);
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
HttpResponseMessage response = await client.PostAsync($"/api/clusters/{clusterId}/certificates/cas/{name}/publish", content, ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// GET /api/clusters/{clusterId}/certificates/list — list all TLS certificates
group.MapGet("list", async (Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.GetAsync($"/api/clusters/{clusterId}/certificates/list", ct);
response.EnsureSuccessStatusCode();
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// POST /api/clusters/{clusterId}/certificates/publish — publish a leaf cert to trust bundle
group.MapPost("publish", async (Guid clusterId, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
StreamContent content = new(request.Body);
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
HttpResponseMessage response = await client.PostAsync($"/api/clusters/{clusterId}/certificates/publish", content, ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
}
}

View File

@@ -1,381 +0,0 @@
using EntKube.SharedKernel.Contracts;
using System.Net.Http.Json;
namespace EntKube.Web.Components.Clusters;
/// <summary>
/// BFF proxy endpoints for cluster management. The Blazor frontend calls these
/// endpoints, and the BFF forwards the requests to the Clusters microservice.
/// This keeps the frontend from needing to know about backend service URLs
/// and allows the BFF to attach auth tokens, apply rate limiting, etc.
/// </summary>
public static class ClustersProxyEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/clusters")
.RequireAuthorization()
.DisableAntiforgery();
// GET /api/clusters — list all clusters (optionally filtered by tenantId and/or environmentId)
group.MapGet("", async (Guid? tenantId, Guid? environmentId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
List<string> queryParams = new();
if (tenantId.HasValue)
{
queryParams.Add($"tenantId={tenantId.Value}");
}
if (environmentId.HasValue)
{
queryParams.Add($"environmentId={environmentId.Value}");
}
string url = queryParams.Count > 0
? $"/api/clusters?{string.Join("&", queryParams)}"
: "/api/clusters";
HttpResponseMessage response = await client.GetAsync(url, ct);
response.EnsureSuccessStatusCode();
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// GET /api/clusters/{id} — get a single cluster
group.MapGet("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.GetAsync($"/api/clusters/{id}", ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// POST /api/clusters — register a new cluster
group.MapPost("", async (RegisterClusterRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.PostAsJsonAsync("/api/clusters", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// DELETE /api/clusters/{id} — remove a cluster
group.MapDelete("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.DeleteAsync($"/api/clusters/{id}", ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.NoContent();
});
// POST /api/clusters/{id}/discover-prometheus — scan for Prometheus instances
group.MapPost("{id:guid}/discover-prometheus", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.PostAsync($"/api/clusters/{id}/discover-prometheus", null, ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// GET /api/clusters/{id}/prometheus — get discovered Prometheus endpoints
group.MapGet("{id:guid}/prometheus", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.GetAsync($"/api/clusters/{id}/prometheus", ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// POST /api/clusters/{id}/install-prometheus — install kube-prometheus-stack
group.MapPost("{id:guid}/install-prometheus", async (Guid id, InstallPrometheusRequest? body, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.PostAsJsonAsync($"/api/clusters/{id}/install-prometheus", body ?? new InstallPrometheusRequest(null), ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// GET /api/clusters/{id}/health — get cluster health report from Prometheus
group.MapGet("{id:guid}/health", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.GetAsync($"/api/clusters/{id}/health", ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// POST /api/clusters/{id}/check-connectivity — probe API server, update status
group.MapPost("{id:guid}/check-connectivity", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.PostAsync($"/api/clusters/{id}/check-connectivity", null, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// POST /api/clusters/{id}/adopt — run adoption checks, return component report
group.MapPost("{id:guid}/adopt", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.PostAsync($"/api/clusters/{id}/adopt", null, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// POST /api/clusters/{id}/components/deploy — deploy a component
group.MapPost("{id:guid}/components/deploy", async (Guid id, DeployComponentRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.PostAsJsonAsync($"/api/clusters/{id}/components/deploy", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// POST /api/clusters/{id}/components/upgrade — upgrade an installed component to a new version
group.MapPost("{id:guid}/components/upgrade", async (Guid id, UpgradeComponentRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.PostAsJsonAsync($"/api/clusters/{id}/components/upgrade", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// GET /api/clusters/components/{name}/versions — get available versions for a component
group.MapGet("components/{name}/versions", async (string name, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.GetAsync($"/api/components/{name}/versions", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// POST /api/clusters/{id}/components/uninstall — uninstall a component
group.MapPost("{id:guid}/components/uninstall", async (Guid id, UninstallComponentRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.PostAsJsonAsync($"/api/clusters/{id}/components/uninstall", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// PUT /api/clusters/{id}/components/{name}/configure — configure a component
group.MapPut("{id:guid}/components/{name}/configure", async (Guid id, string name, ConfigureComponentRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.PutAsJsonAsync($"/api/clusters/{id}/components/{name}/configure", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// GET /api/clusters/components/schemas — get configuration schemas for all components
group.MapGet("components/schemas", async (IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.GetAsync("/api/clusters/components/schemas", ct);
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// GET /api/clusters/{id}/components/schemas — get schemas enriched with cluster context
group.MapGet("{id:guid}/components/schemas", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.GetAsync($"/api/clusters/{id}/components/schemas", ct);
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// GET /api/clusters/{id}/services — list services for this cluster (from Provisioning)
group.MapGet("{id:guid}/services", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync($"/api/services?clusterId={id}", ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// PUT /api/clusters/{id}/provider — set or clear the cloud provider
group.MapPut("{id:guid}/provider", async (Guid id, SetProviderRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.PutAsJsonAsync($"/api/clusters/{id}/provider", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// POST /api/clusters/{id}/provider/test — test provider connection (with optional 2FA code)
group.MapPost("{id:guid}/provider/test", async (Guid id, HttpContext httpContext, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
// Forward the request body (may contain TwoFactorCode for SMS 2FA).
StreamContent? content = null;
if (httpContext.Request.ContentLength > 0 || httpContext.Request.ContentType is not null)
{
content = new StreamContent(httpContext.Request.Body);
content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(
httpContext.Request.ContentType ?? "application/json");
}
HttpResponseMessage response = await client.PostAsync($"/api/clusters/{id}/provider/test", content, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// GET /api/clusters/{id}/storage/info — S3 storage availability info
group.MapGet("{id:guid}/storage/info", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.GetAsync($"/api/clusters/{id}/storage/info", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// GET /api/clusters/{id}/storage/credentials — list S3 credentials
group.MapGet("{id:guid}/storage/credentials", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.GetAsync($"/api/clusters/{id}/storage/credentials", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// POST /api/clusters/{id}/storage/credentials — create new S3 credentials
group.MapPost("{id:guid}/storage/credentials", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.PostAsync($"/api/clusters/{id}/storage/credentials", new StringContent("", System.Text.Encoding.UTF8, "application/json"), ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// DELETE /api/clusters/{id}/storage/credentials/{accessKey} — revoke S3 credentials
group.MapDelete("{id:guid}/storage/credentials/{accessKey}", async (Guid id, string accessKey, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.DeleteAsync($"/api/clusters/{id}/storage/credentials/{accessKey}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// GET /api/clusters/{id}/storage/buckets — list storage buckets
group.MapGet("{id:guid}/storage/buckets", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.GetAsync($"/api/clusters/{id}/storage/buckets", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// POST /api/clusters/{id}/storage/buckets — create a new bucket
group.MapPost("{id:guid}/storage/buckets", async (Guid id, HttpRequest httpRequest, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
string requestBody = await new StreamReader(httpRequest.Body).ReadToEndAsync(ct);
HttpResponseMessage response = await client.PostAsync(
$"/api/clusters/{id}/storage/buckets",
new StringContent(requestBody, System.Text.Encoding.UTF8, "application/json"), ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// GET /api/clusters/{id}/storage/buckets/{bucketId} — get bucket details
group.MapGet("{id:guid}/storage/buckets/{bucketId:guid}", async (Guid id, Guid bucketId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.GetAsync($"/api/clusters/{id}/storage/buckets/{bucketId}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// DELETE /api/clusters/{id}/storage/buckets/{bucketId} — delete a bucket
group.MapDelete("{id:guid}/storage/buckets/{bucketId:guid}", async (Guid id, Guid bucketId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.DeleteAsync($"/api/clusters/{id}/storage/buckets/{bucketId}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// GET /api/clusters/{id}/settings — read cluster settings from live cluster
group.MapGet("{id:guid}/settings", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.GetAsync($"/api/clusters/{id}/settings", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// PUT /api/clusters/{id}/settings — update cluster settings on live cluster
group.MapPut("{id:guid}/settings", async (Guid id, HttpRequest httpRequest, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
string requestBody = await new StreamReader(httpRequest.Body).ReadToEndAsync(ct);
HttpResponseMessage response = await client.PutAsync(
$"/api/clusters/{id}/settings",
new StringContent(requestBody, System.Text.Encoding.UTF8, "application/json"), ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
}
}
public record RegisterClusterRequest(string Name, string ApiServerUrl, string KubeConfig, Guid TenantId, string ContextName, Guid EnvironmentId);
public record SetProviderRequest(
string Provider,
string? Username,
string? Password,
string? Region,
string? OpenStackAuthUrl = null,
string? OpenStackProjectId = null,
string? OpenStackUsername = null,
string? OpenStackPassword = null,
string? OpenStackUserDomainName = null);
public record InstallPrometheusRequest(string? Namespace);
public record DeployComponentRequest(string ComponentName, string? Version = null, string? Namespace = null, Dictionary<string, string>? Parameters = null);
public record UninstallComponentRequest(string ComponentName, string? Namespace = null, Dictionary<string, string>? Parameters = null);
public record UpgradeComponentRequest(string ComponentName, string Version);
public record ConfigureComponentRequest(string? Namespace = null, Dictionary<string, string>? Values = null);

View File

@@ -1,132 +0,0 @@
using System.Net.Http.Json;
namespace EntKube.Web.Components.Identity;
/// <summary>
/// BFF proxy endpoints for tenant management. The Blazor frontend calls these
/// endpoints, and the BFF forwards requests to the Identity microservice.
///
/// Every handler returns JSON — even on failure — because Blazor WASM clients
/// deserialize responses with GetFromJsonAsync. If we let exceptions propagate
/// or return bare status codes, UseStatusCodePagesWithReExecute kicks in and
/// serves HTML, which causes "'<' is an invalid start of a value" errors.
/// </summary>
public static class TenantsProxyEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/tenants")
.RequireAuthorization()
.DisableAntiforgery();
// GET /api/tenants — list all tenants
group.MapGet("", async (IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ProxyGetAsync(httpClientFactory, "/api/tenants", ct);
});
// GET /api/tenants/{id} — get a single tenant
group.MapGet("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ProxyGetAsync(httpClientFactory, $"/api/tenants/{id}", ct);
});
// POST /api/tenants — create a new tenant
group.MapPost("", async (CreateTenantRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ProxyPostAsync(httpClientFactory, "/api/tenants", request, ct);
});
// ─── Environment endpoints ───────────────────────────────────────
// GET /api/tenants/{tenantId}/environments — list environments for a tenant
group.MapGet("{tenantId:guid}/environments", async (Guid tenantId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ProxyGetAsync(httpClientFactory, $"/api/tenants/{tenantId}/environments", ct);
});
// POST /api/tenants/{tenantId}/environments — create an environment
group.MapPost("{tenantId:guid}/environments", async (Guid tenantId, CreateEnvironmentProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ProxyPostAsync(httpClientFactory, $"/api/tenants/{tenantId}/environments", request, ct);
});
// ─── Customer endpoints ──────────────────────────────────────────
// GET /api/tenants/{tenantId}/customers — list customers for a tenant
group.MapGet("{tenantId:guid}/customers", async (Guid tenantId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ProxyGetAsync(httpClientFactory, $"/api/tenants/{tenantId}/customers", ct);
});
// POST /api/tenants/{tenantId}/customers — create a customer
group.MapPost("{tenantId:guid}/customers", async (Guid tenantId, CreateCustomerProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ProxyPostAsync(httpClientFactory, $"/api/tenants/{tenantId}/customers", request, ct);
});
}
// ─── Shared proxy helpers ─────────────────────────────────────────────
//
// These catch HttpRequestException (backend unreachable) and non-success
// responses, always returning a JSON ApiError so Blazor clients never
// receive HTML error pages.
private static async Task<IResult> ProxyGetAsync(
IHttpClientFactory httpClientFactory, string path, CancellationToken ct)
{
try
{
HttpClient client = httpClientFactory.CreateClient("IdentityApi");
HttpResponseMessage response = await client.GetAsync(path, ct);
if (!response.IsSuccessStatusCode)
{
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Json(
new ApiError(false, $"Identity service returned {(int)response.StatusCode}: {body}"),
statusCode: (int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
}
catch (HttpRequestException)
{
return Results.Json(
new ApiError(false, "Identity service is unreachable. Make sure it is running on the configured port."),
statusCode: 502);
}
}
private static async Task<IResult> ProxyPostAsync<T>(
IHttpClientFactory httpClientFactory, string path, T payload, CancellationToken ct)
{
try
{
HttpClient client = httpClientFactory.CreateClient("IdentityApi");
HttpResponseMessage response = await client.PostAsJsonAsync(path, payload, ct);
if (!response.IsSuccessStatusCode)
{
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Json(
new ApiError(false, $"Identity service returned {(int)response.StatusCode}: {body}"),
statusCode: (int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
}
catch (HttpRequestException)
{
return Results.Json(
new ApiError(false, "Identity service is unreachable. Make sure it is running on the configured port."),
statusCode: 502);
}
}
private record ApiError(bool Success, string Error);
}
public record CreateTenantRequest(string Name, string Slug, Guid CreatingUserId);
public record CreateEnvironmentProxyRequest(string Name, string Slug);
public record CreateCustomerProxyRequest(string Name, string Slug);

View File

@@ -0,0 +1,23 @@
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar">
<NavMenu />
</div>
<main>
<div class="top-row px-4">
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
</div>
<article class="content px-4">
@Body
</article>
</main>
</div>
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>

View File

@@ -0,0 +1,98 @@
.page {
position: relative;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
text-decoration: underline;
}
.top-row ::deep a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px) {
.top-row {
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link {
margin-left: 0;
}
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.top-row.auth ::deep a:first-child {
flex: 1;
text-align: right;
width: 0;
}
.top-row, article {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
}
#blazor-error-ui {
color-scheme: light only;
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}

View File

@@ -0,0 +1,86 @@
@implements IDisposable
@inject NavigationManager NavigationManager
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">EntKube</a>
</div>
</div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="nav flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="tenants">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Tenants
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="portal">
<span class="bi bi-grid-nav-menu" aria-hidden="true"></span> Portal
</NavLink>
</div>
<AuthorizeView>
<Authorized>
<div class="nav-item px-3">
<NavLink class="nav-link" href="Account/Manage">
<span class="bi bi-person-fill-nav-menu" aria-hidden="true"></span> @context.User.Identity?.Name
</NavLink>
</div>
<div class="nav-item px-3">
<form action="Account/Logout" method="post">
<AntiforgeryToken />
<input type="hidden" name="ReturnUrl" value="@currentUrl" />
<button type="submit" class="nav-link">
<span class="bi bi-arrow-bar-left-nav-menu" aria-hidden="true"></span> Logout
</button>
</form>
</div>
</Authorized>
<NotAuthorized>
<div class="nav-item px-3">
<NavLink class="nav-link" href="Account/Register">
<span class="bi bi-person-nav-menu" aria-hidden="true"></span> Register
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="Account/Login">
<span class="bi bi-person-badge-nav-menu" aria-hidden="true"></span> Login
</NavLink>
</div>
</NotAuthorized>
</AuthorizeView>
</nav>
</div>
@code {
private string? currentUrl;
protected override void OnInitialized()
{
currentUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
NavigationManager.LocationChanged += OnLocationChanged;
}
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{
currentUrl = NavigationManager.ToBaseRelativePath(e.Location);
StateHasChanged();
}
public void Dispose()
{
NavigationManager.LocationChanged -= OnLocationChanged;
}
}

View File

@@ -0,0 +1,125 @@
.navbar-toggler {
appearance: none;
cursor: pointer;
width: 3.5rem;
height: 2.5rem;
color: white;
position: absolute;
top: 0.5rem;
right: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
}
.navbar-toggler:checked {
background-color: rgba(255, 255, 255, 0.5);
}
.top-row {
min-height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
.navbar-brand {
font-size: 1.1rem;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-plus-square-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.bi-lock-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath d='M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2zM5 8h6a1 1 0 0 1 1 1v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1z'/%3E%3C/svg%3E");
}
.bi-person-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-person' viewBox='0 0 16 16'%3E%3Cpath d='M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4Zm-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10c-2.29 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664h10Z'/%3E%3C/svg%3E");
}
.bi-person-badge-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-person-badge' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 2a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1h-3zM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0z'/%3E%3Cpath d='M4.5 0A2.5 2.5 0 0 0 2 2.5V14a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V2.5A2.5 2.5 0 0 0 11.5 0h-7zM3 2.5A1.5 1.5 0 0 1 4.5 1h7A1.5 1.5 0 0 1 13 2.5v10.795a4.2 4.2 0 0 0-.776-.492C11.392 12.387 10.063 12 8 12s-3.392.387-4.224.803a4.2 4.2 0 0 0-.776.492V2.5z'/%3E%3C/svg%3E");
}
.bi-person-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-person-fill' viewBox='0 0 16 16'%3E%3Cpath d='M3 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1H3Zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z'/%3E%3C/svg%3E");
}
.bi-arrow-bar-left-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-arrow-bar-left' viewBox='0 0 16 16'%3E%3Cpath d='M12.5 15a.5.5 0 0 1-.5-.5v-13a.5.5 0 0 1 1 0v13a.5.5 0 0 1-.5.5ZM10 8a.5.5 0 0 1-.5.5H3.707l2.147 2.146a.5.5 0 0 1-.708.708l-3-3a.5.5 0 0 1 0-.708l3-3a.5.5 0 1 1 .708.708L3.707 7.5H9.5a.5.5 0 0 1 .5.5Z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep .nav-link {
color: #d7d7d7;
background: none;
border: none;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
width: 100%;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep .nav-link:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
.nav-scrollable {
display: none;
}
.navbar-toggler:checked ~ .nav-scrollable {
display: block;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.nav-scrollable {
/* Never collapse the sidebar for wide screens */
display: block;
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
}

View File

@@ -0,0 +1,31 @@
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
<dialog id="components-reconnect-modal" data-nosnippet>
<div class="components-reconnect-container">
<div class="components-rejoining-animation" aria-hidden="true">
<div></div>
<div></div>
</div>
<p class="components-reconnect-first-attempt-visible">
Rejoining the server...
</p>
<p class="components-reconnect-repeated-attempt-visible">
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
</p>
<p class="components-reconnect-failed-visible">
Failed to rejoin.<br />Please retry or reload the page.
</p>
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
Retry
</button>
<p class="components-pause-visible">
The session has been paused by the server.
</p>
<button id="components-resume-button" class="components-pause-visible">
Resume
</button>
<p class="components-resume-failed-visible">
Failed to resume the session.<br />Please reload the page.
</p>
</div>
</dialog>

View File

@@ -0,0 +1,157 @@
.components-reconnect-first-attempt-visible,
.components-reconnect-repeated-attempt-visible,
.components-reconnect-failed-visible,
.components-pause-visible,
.components-resume-failed-visible,
.components-rejoining-animation {
display: none;
}
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
#components-reconnect-modal.components-reconnect-retrying,
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-failed,
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
display: block;
}
#components-reconnect-modal {
background-color: white;
width: 20rem;
margin: 20vh auto;
padding: 2rem;
border: 0;
border-radius: 0.5rem;
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
opacity: 0;
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
&[open]
{
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
animation-fill-mode: both;
}
}
#components-reconnect-modal::backdrop {
background-color: rgba(0, 0, 0, 0.4);
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
opacity: 1;
}
@keyframes components-reconnect-modal-slideUp {
0% {
transform: translateY(30px) scale(0.95);
}
100% {
transform: translateY(0);
}
}
@keyframes components-reconnect-modal-fadeInOpacity {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes components-reconnect-modal-fadeOutOpacity {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.components-reconnect-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
#components-reconnect-modal p {
margin: 0;
text-align: center;
}
#components-reconnect-modal button {
border: 0;
background-color: #6b9ed2;
color: white;
padding: 4px 24px;
border-radius: 4px;
}
#components-reconnect-modal button:hover {
background-color: #3b6ea2;
}
#components-reconnect-modal button:active {
background-color: #6b9ed2;
}
.components-rejoining-animation {
position: relative;
width: 80px;
height: 80px;
}
.components-rejoining-animation div {
position: absolute;
border: 3px solid #0087ff;
opacity: 1;
border-radius: 50%;
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
}
.components-rejoining-animation div:nth-child(2) {
animation-delay: -0.5s;
}
@keyframes components-rejoining-animation {
0% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
4.9% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
5% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 1;
}
100% {
top: 0px;
left: 0px;
width: 80px;
height: 80px;
opacity: 0;
}
}

View File

@@ -0,0 +1,63 @@
// Set up event handlers
const reconnectModal = document.getElementById("components-reconnect-modal");
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
const retryButton = document.getElementById("components-reconnect-button");
retryButton.addEventListener("click", retry);
const resumeButton = document.getElementById("components-resume-button");
resumeButton.addEventListener("click", resume);
function handleReconnectStateChanged(event) {
if (event.detail.state === "show") {
reconnectModal.showModal();
} else if (event.detail.state === "hide") {
reconnectModal.close();
} else if (event.detail.state === "failed") {
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
} else if (event.detail.state === "rejected") {
location.reload();
}
}
async function retry() {
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
try {
// Reconnect will asynchronously return:
// - true to mean success
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
// - exception to mean we didn't reach the server (this can be sync or async)
const successful = await Blazor.reconnect();
if (!successful) {
// We have been able to reach the server, but the circuit is no longer available.
// We'll reload the page so the user can continue using the app as quickly as possible.
const resumeSuccessful = await Blazor.resumeCircuit();
if (!resumeSuccessful) {
location.reload();
} else {
reconnectModal.close();
}
}
} catch (err) {
// We got an exception, server is currently unavailable
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
}
}
async function resume() {
try {
const successful = await Blazor.resumeCircuit();
if (!successful) {
location.reload();
}
} catch {
location.reload();
}
}
async function retryWhenDocumentBecomesVisible() {
if (document.visibilityState === "visible") {
await retry();
}
}

View File

@@ -0,0 +1,10 @@
@page "/"
<PageTitle>EntKube</PageTitle>
<h1>EntKube</h1>
<p class="lead">Multi-tenant platform for managing shared Kubernetes applications and infrastructure services.</p>
<div class="mt-4">
<a href="/tenants" class="btn btn-primary btn-lg">Manage Tenants</a>
</div>

View File

@@ -0,0 +1,5 @@
@page "/not-found"
@layout MainLayout
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>

View File

@@ -0,0 +1,293 @@
@page "/portal"
@using EntKube.Web.Data
@using EntKube.Web.Services
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@attribute [Authorize]
@rendermode InteractiveServer
@inject CustomerAccessService CustomerAccessService
@inject DeploymentService DeploymentService
@inject KubernetesOperationsService K8sOps
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
<PageTitle>Customer Portal</PageTitle>
@* ═══════════════════════════════════════════════════════════════════
Customer Portal — a scoped view where a user sees only the customers
they've been granted access to. From here they can drill into apps,
deployments, view pod logs, restart deployments, and delete pods.
Navigation flow: My Customers → Apps → Deployments → Operations
═══════════════════════════════════════════════════════════════════ *@
@if (loading)
{
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<p class="text-muted mt-2">Loading portal...</p>
</div>
}
else if (customers is null || customers.Count == 0)
{
<div class="text-center py-5">
<i class="bi bi-shield-lock text-muted" style="font-size: 3rem;"></i>
<h4 class="mt-3">No access granted</h4>
<p class="text-muted">You don't have access to any customers yet. Ask a tenant administrator to grant you access.</p>
</div>
}
else
{
@* ── Level 0: Breadcrumb navigation ── *@
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb small">
<li class="breadcrumb-item">
@if (selectedDeployment is not null)
{
<a href="javascript:void(0)" class="text-decoration-none" @onclick="BackToApps">
<i class="bi bi-grid me-1"></i>Portal
</a>
}
else if (selectedCustomer is not null)
{
<a href="javascript:void(0)" class="text-decoration-none" @onclick="BackToCustomers">
<i class="bi bi-grid me-1"></i>Portal
</a>
}
else
{
<span class="text-muted"><i class="bi bi-grid me-1"></i>Portal</span>
}
</li>
@if (selectedCustomer is not null)
{
<li class="breadcrumb-item">
@if (selectedDeployment is not null)
{
<a href="javascript:void(0)" class="text-decoration-none" @onclick="BackToApps">@selectedCustomer.Name</a>
}
else
{
<span class="text-muted">@selectedCustomer.Name</span>
}
</li>
}
@if (selectedDeployment is not null)
{
<li class="breadcrumb-item active">@selectedDeployment.Name</li>
}
</ol>
</nav>
@* ════════════════════════════════════════════════════════════════
Level 1: Customer list
════════════════════════════════════════════════════════════════ *@
@if (selectedCustomer is null)
{
<div class="d-flex align-items-center mb-4">
<i class="bi bi-grid fs-3 me-2 text-primary"></i>
<h2 class="mb-0">My Customers</h2>
</div>
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
@foreach (Customer customer in customers)
{
<div class="col">
<div class="card shadow-sm h-100" role="button" style="cursor: pointer;"
@onclick="() => SelectCustomer(customer)">
<div class="card-body">
<div class="d-flex align-items-center mb-2">
<i class="bi bi-people fs-4 me-2 text-primary"></i>
<h5 class="mb-0">@customer.Name</h5>
</div>
<div class="text-muted small">
<i class="bi bi-app-indicator me-1"></i>
@customer.Apps.Count app@(customer.Apps.Count != 1 ? "s" : "")
</div>
</div>
</div>
</div>
}
</div>
}
@* ════════════════════════════════════════════════════════════════
Level 2: Apps and deployments for a selected customer
════════════════════════════════════════════════════════════════ *@
else if (selectedDeployment is null)
{
<div class="d-flex align-items-center mb-4">
<i class="bi bi-people fs-3 me-2 text-primary"></i>
<div>
<h2 class="mb-0">@selectedCustomer.Name</h2>
<small class="text-muted">@selectedCustomer.Apps.Count app@(selectedCustomer.Apps.Count != 1 ? "s" : "")</small>
</div>
</div>
@if (selectedCustomer.Apps.Count == 0)
{
<div class="text-center py-4">
<i class="bi bi-app-indicator text-muted" style="font-size: 2rem;"></i>
<p class="text-muted mt-2">No apps configured for this customer yet.</p>
</div>
}
else
{
@foreach (Data.App app in selectedCustomer.Apps.OrderBy(a => a.Name))
{
<div class="card shadow-sm mb-3">
<div class="card-header bg-white py-2">
<div class="d-flex align-items-center">
<i class="bi bi-app-indicator me-2 text-primary"></i>
<strong>@app.Name</strong>
</div>
</div>
<div class="card-body">
@if (appDeployments.TryGetValue(app.Id, out List<AppDeployment>? deploys) && deploys.Count > 0)
{
<div class="row row-cols-1 row-cols-lg-2 g-2">
@foreach (AppDeployment deploy in deploys)
{
<div class="col">
<div class="card border-light" role="button" style="cursor: pointer;"
@onclick="() => SelectDeployment(deploy)">
<div class="card-body py-2">
<div class="d-flex align-items-center justify-content-between mb-1">
<span class="fw-medium">
<i class="bi bi-rocket-takeoff me-1 text-primary"></i>@deploy.Name
</span>
<div class="d-flex gap-1">
@SyncBadge(deploy.SyncStatus)
@HealthBadge(deploy.HealthStatus)
</div>
</div>
<div class="d-flex flex-wrap gap-2 text-muted small">
@TypeBadge(deploy.Type)
<span><i class="bi bi-layers me-1"></i>@deploy.Environment?.Name</span>
<span><i class="bi bi-hdd-network me-1"></i>@deploy.Cluster?.Name</span>
<span><i class="bi bi-box me-1"></i>@deploy.Namespace</span>
</div>
</div>
</div>
</div>
}
</div>
}
else
{
<p class="text-muted small mb-0">No deployments configured.</p>
}
</div>
</div>
}
}
}
@* ════════════════════════════════════════════════════════════════
Level 3: Deployment detail with operations
════════════════════════════════════════════════════════════════ *@
else
{
<PortalDeploymentDetail
Deployment="selectedDeployment"
AccessRole="currentAccessRole"
K8sOps="K8sOps"
DeploymentService="DeploymentService"
OnBack="BackToApps" />
}
}
@code {
private bool loading = true;
private string? currentUserId;
private CustomerAccessRole currentAccessRole;
private List<Customer>? customers;
private Customer? selectedCustomer;
private AppDeployment? selectedDeployment;
// Deployments indexed by app ID for the selected customer.
private Dictionary<Guid, List<AppDeployment>> appDeployments = new();
protected override async Task OnInitializedAsync()
{
// Determine who the logged-in user is.
AuthenticationState authState = await AuthStateProvider.GetAuthenticationStateAsync();
currentUserId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(currentUserId))
{
loading = false;
return;
}
// Load only the customers this user has access to.
customers = await CustomerAccessService.GetAccessibleCustomersAsync(currentUserId);
loading = false;
}
private async Task SelectCustomer(Customer customer)
{
selectedCustomer = customer;
// Look up the user's role for this customer.
CustomerAccess? access = await CustomerAccessService.GetAccessAsync(currentUserId!, customer.Id);
currentAccessRole = access?.Role ?? CustomerAccessRole.Viewer;
// Load deployments for all of the customer's apps.
appDeployments.Clear();
foreach (Data.App app in customer.Apps)
{
List<AppDeployment> deploys = await DeploymentService.GetDeploymentsAsync(app.Id);
appDeployments[app.Id] = deploys;
}
}
private void SelectDeployment(AppDeployment deploy)
{
selectedDeployment = deploy;
}
private void BackToCustomers()
{
selectedCustomer = null;
selectedDeployment = null;
}
private void BackToApps()
{
selectedDeployment = null;
}
// ──────── Badge helpers ────────
private RenderFragment TypeBadge(DeploymentType type) => type switch
{
DeploymentType.Manual => @<span class="badge bg-info">Manual</span>,
DeploymentType.Yaml => @<span class="badge bg-warning text-dark">YAML</span>,
DeploymentType.HelmChart => @<span class="badge bg-purple text-white" style="background-color: #6f42c1 !important;">Helm</span>,
_ => @<span class="badge bg-secondary">Unknown</span>
};
private RenderFragment SyncBadge(SyncStatus status) => status switch
{
SyncStatus.Synced => @<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Synced</span>,
SyncStatus.OutOfSync => @<span class="badge bg-warning text-dark"><i class="bi bi-exclamation-circle me-1"></i>OutOfSync</span>,
SyncStatus.Syncing => @<span class="badge bg-info"><i class="bi bi-arrow-repeat me-1"></i>Syncing</span>,
SyncStatus.Failed => @<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>Failed</span>,
_ => @<span class="badge bg-secondary">Unknown</span>
};
private RenderFragment HealthBadge(HealthStatus status) => status switch
{
HealthStatus.Healthy => @<span class="badge bg-success"><i class="bi bi-heart-fill me-1"></i>Healthy</span>,
HealthStatus.Progressing => @<span class="badge bg-info"><i class="bi bi-hourglass-split me-1"></i>Progressing</span>,
HealthStatus.Degraded => @<span class="badge bg-warning text-dark"><i class="bi bi-exclamation-triangle me-1"></i>Degraded</span>,
HealthStatus.Missing => @<span class="badge bg-danger"><i class="bi bi-question-circle me-1"></i>Missing</span>,
HealthStatus.Suspended => @<span class="badge bg-secondary"><i class="bi bi-pause-circle me-1"></i>Suspended</span>,
_ => @<span class="badge bg-secondary">Unknown</span>
};
}

View File

@@ -0,0 +1,592 @@
@using EntKube.Web.Data
@using EntKube.Web.Services
@* ═══════════════════════════════════════════════════════════════════
Deployment operations view — the heart of the customer portal.
Shows deployment info, live pods, pod logs, and allows restart/
delete/scale operations. Access is role-gated: Viewers can only
browse and read logs; Operators can restart and delete pods;
Admins have full control.
═══════════════════════════════════════════════════════════════════ *@
@* --- Deployment header --- *@
<div class="card shadow-sm mb-3">
<div class="card-body">
<div class="d-flex align-items-start justify-content-between">
<div>
<h5 class="mb-1">
<i class="bi bi-rocket-takeoff me-2 text-primary"></i>@Deployment.Name
</h5>
<div class="d-flex flex-wrap gap-2 text-muted small">
@TypeBadge(Deployment.Type)
<span><i class="bi bi-layers me-1"></i>@Deployment.Environment?.Name</span>
<span><i class="bi bi-hdd-network me-1"></i>@Deployment.Cluster?.Name</span>
<span><i class="bi bi-box me-1"></i>@Deployment.Namespace</span>
</div>
</div>
<div class="d-flex gap-1">
@SyncBadge(Deployment.SyncStatus)
@HealthBadge(Deployment.HealthStatus)
</div>
</div>
@if (Deployment.Type == DeploymentType.HelmChart)
{
<div class="mt-2 p-2 bg-light rounded small">
<i class="bi bi-box-seam me-1"></i>
@Deployment.HelmChartName <span class="text-muted">@Deployment.HelmChartVersion</span>
<span class="text-muted ms-1">from @Deployment.HelmRepoUrl</span>
</div>
}
@if (!string.IsNullOrEmpty(Deployment.StatusMessage))
{
<div class="mt-2 text-muted small">@Deployment.StatusMessage</div>
}
@if (Deployment.LastSyncedAt.HasValue)
{
<div class="mt-1 text-muted small">
<i class="bi bi-clock me-1"></i>Last synced @Deployment.LastSyncedAt.Value.ToString("g")
</div>
}
</div>
</div>
@* --- Tabs: Pods / Manifests / Resources --- *@
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<button class="nav-link @(activeTab == "pods" ? "active" : "")" @onclick="LoadPods">
<i class="bi bi-cpu me-1"></i>Pods
@if (pods is not null)
{
<span class="badge bg-primary ms-1">@pods.Count</span>
}
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "manifests" ? "active" : "")" @onclick="LoadManifests">
<i class="bi bi-file-earmark-code me-1"></i>Manifests
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "resources" ? "active" : "")" @onclick="LoadResources">
<i class="bi bi-diagram-3 me-1"></i>Resources
</button>
</li>
</ul>
@* ══════════════════════════════════════════════════════════════
Pods Tab — live pod list from the cluster
══════════════════════════════════════════════════════════════ *@
@if (activeTab == "pods")
{
@if (!string.IsNullOrEmpty(operationMessage))
{
<div class="alert @(operationSuccess ? "alert-success" : "alert-danger") alert-dismissible py-2 small mb-3">
@operationMessage
<button type="button" class="btn-close btn-close-sm" @onclick="() => operationMessage = null"></button>
</div>
}
@* --- Operation buttons (role-gated) --- *@
@if (AccessRole >= CustomerAccessRole.Operator)
{
<div class="d-flex gap-2 mb-3">
<button class="btn btn-sm btn-outline-warning" @onclick="RestartAllDeployments" disabled="@operationInProgress">
<i class="bi bi-arrow-repeat me-1"></i>Restart Deployment
</button>
</div>
}
@if (podsLoading)
{
<div class="text-center py-4">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
<p class="text-muted small mt-2">Loading pods from cluster...</p>
</div>
}
else if (!string.IsNullOrEmpty(podsError))
{
<div class="alert alert-warning py-2 small">
<i class="bi bi-exclamation-triangle me-1"></i>@podsError
</div>
}
else if (pods is null || pods.Count == 0)
{
<div class="text-center py-4">
<i class="bi bi-cpu text-muted" style="font-size: 2rem;"></i>
<p class="text-muted small mt-2">No pods found in namespace <code>@Deployment.Namespace</code>.</p>
</div>
}
else
{
@* --- Pod log viewer (shown above pods when active) --- *@
@if (showLogViewer)
{
<div class="card border-dark mb-3">
<div class="card-header bg-dark text-white py-2 d-flex align-items-center justify-content-between">
<div>
<i class="bi bi-terminal me-1"></i>
Logs: <strong>@logPodName</strong>
@if (!string.IsNullOrEmpty(logContainerName))
{
<span class="text-muted ms-1">(@logContainerName)</span>
}
</div>
<button class="btn btn-sm btn-outline-light" @onclick="CloseLogViewer">
<i class="bi bi-x"></i>
</button>
</div>
<div class="card-body p-0">
@if (logLoading)
{
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</div>
}
else if (!string.IsNullOrEmpty(logError))
{
<div class="alert alert-warning m-2 py-1 small">@logError</div>
}
else
{
<pre class="mb-0 p-2 small bg-dark text-light" style="max-height: 400px; overflow-y: auto; white-space: pre-wrap; word-break: break-all;">@logContent</pre>
}
</div>
</div>
}
@* --- Pod cards --- *@
@foreach (PodInfo pod in pods)
{
<div class="card mb-2">
<div class="card-body py-2">
<div class="d-flex align-items-center justify-content-between">
<div>
<span class="fw-medium small">
<i class="bi bi-cpu me-1 @GetPodStatusColor(pod.Status)"></i>@pod.Name
</span>
<span class="badge @GetPodStatusBadgeClass(pod.Status) ms-2">@pod.Status</span>
<span class="text-muted small ms-2">@pod.ReadyContainers/@pod.TotalContainers ready</span>
@if (pod.Restarts > 0)
{
<span class="badge bg-warning text-dark ms-1">@pod.Restarts restarts</span>
}
</div>
<div class="d-flex gap-1">
@* --- Logs button (always available) --- *@
@if (pod.Containers.Count <= 1)
{
<button class="btn btn-sm btn-outline-dark" title="View logs"
@onclick="() => ViewLogs(pod.Name, null)">
<i class="bi bi-terminal"></i>
</button>
}
else
{
@* Multi-container: dropdown to pick container *@
<div class="btn-group">
<button class="btn btn-sm btn-outline-dark dropdown-toggle" data-bs-toggle="dropdown"
title="View logs">
<i class="bi bi-terminal"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
@foreach (ContainerInfo container in pod.Containers)
{
<li>
<button class="dropdown-item small"
@onclick="() => ViewLogs(pod.Name, container.Name)">
@container.Name
</button>
</li>
}
</ul>
</div>
}
@* --- Delete pod (Operator+ only) --- *@
@if (AccessRole >= CustomerAccessRole.Operator)
{
@if (confirmDeletePod == pod.Name)
{
<button class="btn btn-sm btn-danger" @onclick="() => DeletePod(pod.Name)"
disabled="@operationInProgress">
Confirm
</button>
<button class="btn btn-sm btn-outline-secondary"
@onclick="() => confirmDeletePod = null">
Cancel
</button>
}
else
{
<button class="btn btn-sm btn-outline-danger" title="Delete pod"
@onclick="() => confirmDeletePod = pod.Name">
<i class="bi bi-trash"></i>
</button>
}
}
</div>
</div>
@* --- Container details (expanded) --- *@
@if (pod.Containers.Count > 1)
{
<div class="mt-2 ms-4">
@foreach (ContainerInfo container in pod.Containers)
{
<div class="d-flex align-items-center text-muted small mb-1">
<i class="bi bi-box me-1 @(container.Ready ? "text-success" : "text-warning")"></i>
<span class="fw-medium me-2">@container.Name</span>
<span class="me-2">@container.Image</span>
<span class="badge @(container.Ready ? "bg-success" : "bg-warning text-dark")">
@container.State
</span>
@if (container.RestartCount > 0)
{
<span class="badge bg-warning text-dark ms-1">@container.RestartCount restarts</span>
}
</div>
}
</div>
}
@if (pod.StartTime.HasValue)
{
<div class="text-muted small mt-1 ms-4">
<i class="bi bi-clock me-1"></i>Started @pod.StartTime.Value.ToString("g")
</div>
}
</div>
</div>
}
}
}
@* ══════════════════════════════════════════════════════════════
Manifests Tab
══════════════════════════════════════════════════════════════ *@
@if (activeTab == "manifests")
{
@if (manifests is null)
{
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</div>
}
else if (manifests.Count == 0)
{
<div class="text-center py-4">
<i class="bi bi-file-earmark-code text-muted" style="font-size: 2rem;"></i>
<p class="text-muted small mt-2">No manifests defined for this deployment.</p>
</div>
}
else
{
@foreach (DeploymentManifest manifest in manifests)
{
<div class="card mb-2">
<div class="card-header bg-white py-2">
<span class="badge bg-secondary me-1">@manifest.Kind</span>
<span class="fw-medium">@manifest.Name</span>
</div>
<div class="card-body p-2">
<pre class="mb-0 small" style="max-height: 200px; overflow-y: auto;">@manifest.YamlContent</pre>
</div>
</div>
}
}
}
@* ══════════════════════════════════════════════════════════════
Resources Tab (ArgoCD-style tree)
══════════════════════════════════════════════════════════════ *@
@if (activeTab == "resources")
{
@if (resourceTree is null)
{
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</div>
}
else if (resourceTree.Count == 0)
{
<div class="text-center py-4">
<i class="bi bi-diagram-3 text-muted" style="font-size: 2rem;"></i>
<p class="text-muted small mt-2">No tracked resources.</p>
</div>
}
else
{
@foreach (DeploymentResource root in resourceTree)
{
@RenderResource(root, 0)
}
}
}
@code {
[Parameter] public required AppDeployment Deployment { get; set; }
[Parameter] public CustomerAccessRole AccessRole { get; set; }
[Parameter] public required KubernetesOperationsService K8sOps { get; set; }
[Parameter] public required DeploymentService DeploymentService { get; set; }
[Parameter] public EventCallback OnBack { get; set; }
private string activeTab = "pods";
// Pods
private List<PodInfo>? pods;
private bool podsLoading;
private string? podsError;
// Operations
private bool operationInProgress;
private string? operationMessage;
private bool operationSuccess;
private string? confirmDeletePod;
// Logs
private bool showLogViewer;
private bool logLoading;
private string logPodName = "";
private string? logContainerName;
private string logContent = "";
private string? logError;
// Manifests
private List<DeploymentManifest>? manifests;
// Resources
private List<DeploymentResource>? resourceTree;
protected override async Task OnInitializedAsync()
{
await LoadPodsInternal();
}
// ──────── Pod operations ────────
private async Task LoadPods()
{
activeTab = "pods";
await LoadPodsInternal();
}
private async Task LoadPodsInternal()
{
podsLoading = true;
podsError = null;
pods = null;
StateHasChanged();
KubernetesOperationResult<List<PodInfo>> result = await K8sOps.GetPodsAsync(Deployment.Id);
if (result.IsSuccess)
{
pods = result.Data ?? [];
}
else
{
podsError = result.Error;
}
podsLoading = false;
}
private async Task ViewLogs(string podName, string? containerName)
{
showLogViewer = true;
logLoading = true;
logPodName = podName;
logContainerName = containerName;
logContent = "";
logError = null;
StateHasChanged();
KubernetesOperationResult<string> result = await K8sOps.GetPodLogsAsync(
Deployment.Id, podName, containerName);
if (result.IsSuccess)
{
logContent = result.Data ?? "(empty)";
}
else
{
logError = result.Error;
}
logLoading = false;
}
private void CloseLogViewer()
{
showLogViewer = false;
}
private async Task RestartAllDeployments()
{
operationInProgress = true;
operationMessage = null;
StateHasChanged();
// Find all Deployment-kind resources tracked for this deployment
// and restart each one. If none are tracked, try the deployment name.
List<DeploymentResource> tree = await DeploymentService.GetResourceTreeAsync(Deployment.Id);
List<string> deploymentNames = tree
.Where(r => r.Kind == "Deployment")
.Select(r => r.Name)
.ToList();
if (deploymentNames.Count == 0)
{
// Fallback: use the deployment name itself.
deploymentNames.Add(Deployment.Name);
}
List<string> failures = [];
foreach (string name in deploymentNames)
{
KubernetesOperationResult result = await K8sOps.RestartDeploymentAsync(Deployment.Id, name);
if (!result.IsSuccess)
{
failures.Add($"{name}: {result.Error}");
}
}
if (failures.Count == 0)
{
operationMessage = $"Rolling restart triggered for {deploymentNames.Count} deployment(s).";
operationSuccess = true;
}
else
{
operationMessage = $"Some restarts failed: {string.Join("; ", failures)}";
operationSuccess = false;
}
operationInProgress = false;
// Refresh pods after a short delay to show new status.
await Task.Delay(2000);
await LoadPodsInternal();
}
private async Task DeletePod(string podName)
{
operationInProgress = true;
confirmDeletePod = null;
StateHasChanged();
KubernetesOperationResult result = await K8sOps.DeletePodAsync(Deployment.Id, podName);
if (result.IsSuccess)
{
operationMessage = $"Pod '{podName}' deleted. Kubernetes will create a replacement.";
operationSuccess = true;
}
else
{
operationMessage = result.Error;
operationSuccess = false;
}
operationInProgress = false;
// Refresh pods.
await Task.Delay(2000);
await LoadPodsInternal();
}
// ──────── Manifests ────────
private async Task LoadManifests()
{
activeTab = "manifests";
if (manifests is null)
{
manifests = await DeploymentService.GetManifestsAsync(Deployment.Id);
}
}
// ──────── Resources ────────
private async Task LoadResources()
{
activeTab = "resources";
if (resourceTree is null)
{
resourceTree = await DeploymentService.GetResourceTreeAsync(Deployment.Id);
}
}
// ──────── Rendering helpers ────────
private static string GetPodStatusColor(string status) => status switch
{
"Running" => "text-success",
"Pending" => "text-warning",
"Succeeded" => "text-info",
"Failed" => "text-danger",
_ => "text-muted"
};
private static string GetPodStatusBadgeClass(string status) => status switch
{
"Running" => "bg-success",
"Pending" => "bg-warning text-dark",
"Succeeded" => "bg-info",
"Failed" => "bg-danger",
_ => "bg-secondary"
};
private RenderFragment TypeBadge(DeploymentType type) => type switch
{
DeploymentType.Manual => @<span class="badge bg-info">Manual</span>,
DeploymentType.Yaml => @<span class="badge bg-warning text-dark">YAML</span>,
DeploymentType.HelmChart => @<span class="badge bg-purple text-white" style="background-color: #6f42c1 !important;">Helm</span>,
_ => @<span class="badge bg-secondary">Unknown</span>
};
private RenderFragment SyncBadge(SyncStatus status) => status switch
{
SyncStatus.Synced => @<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Synced</span>,
SyncStatus.OutOfSync => @<span class="badge bg-warning text-dark"><i class="bi bi-exclamation-circle me-1"></i>OutOfSync</span>,
SyncStatus.Syncing => @<span class="badge bg-info"><i class="bi bi-arrow-repeat me-1"></i>Syncing</span>,
SyncStatus.Failed => @<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>Failed</span>,
_ => @<span class="badge bg-secondary">Unknown</span>
};
private RenderFragment HealthBadge(HealthStatus status) => status switch
{
HealthStatus.Healthy => @<span class="badge bg-success"><i class="bi bi-heart-fill me-1"></i>Healthy</span>,
HealthStatus.Progressing => @<span class="badge bg-info"><i class="bi bi-hourglass-split me-1"></i>Progressing</span>,
HealthStatus.Degraded => @<span class="badge bg-warning text-dark"><i class="bi bi-exclamation-triangle me-1"></i>Degraded</span>,
HealthStatus.Missing => @<span class="badge bg-danger"><i class="bi bi-question-circle me-1"></i>Missing</span>,
HealthStatus.Suspended => @<span class="badge bg-secondary"><i class="bi bi-pause-circle me-1"></i>Suspended</span>,
_ => @<span class="badge bg-secondary">Unknown</span>
};
private RenderFragment RenderResource(DeploymentResource resource, int depth) => __builder =>
{
<div class="d-flex align-items-center py-1 border-bottom" style="padding-left: @(depth * 24)px;">
<span class="badge bg-secondary me-2 small">@resource.Kind</span>
<span class="fw-medium small flex-grow-1">@resource.Name</span>
<div class="d-flex gap-1">
@SyncBadge(resource.SyncStatus)
@HealthBadge(resource.HealthStatus)
</div>
</div>
@if (resource.ChildResources?.Count > 0)
{
@foreach (DeploymentResource child in resource.ChildResources)
{
@RenderResource(child, depth + 1)
}
}
};
}

View File

@@ -0,0 +1,325 @@
@using EntKube.Web.Services
@* ═══════════════════════════════════════════════════════════════════════════
AlertPanel — shows active alerts from Alertmanager, allows creating silences,
and displays existing silences. The central tool for operations teams to
monitor and manage alert noise.
═══════════════════════════════════════════════════════════════════════════ *@
<div class="card border-0 shadow-sm">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<span class="fw-medium">
<i class="bi bi-bell me-2 text-warning"></i>Alerts
@if (alerts is not null && alerts.Count > 0)
{
<span class="badge bg-danger ms-1">@alerts.Count</span>
}
</span>
<div class="d-flex gap-1">
<button class="btn btn-sm @(view == "alerts" ? "btn-primary" : "btn-outline-primary")"
@onclick='() => view = "alerts"'>
<i class="bi bi-bell me-1"></i>Active
</button>
<button class="btn btn-sm @(view == "silences" ? "btn-primary" : "btn-outline-primary")"
@onclick="LoadSilences">
<i class="bi bi-bell-slash me-1"></i>Silences
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="RefreshAlerts" disabled="@loading">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
</div>
<div class="card-body p-0">
@if (loading)
{
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary"></div>
</div>
}
else if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-warning m-3 mb-0">
<i class="bi bi-exclamation-triangle me-1"></i>@errorMessage
</div>
}
else if (view == "alerts")
{
@* ── Active Alerts ── *@
@if (alerts is null || alerts.Count == 0)
{
<div class="text-center py-4">
<i class="bi bi-check-circle text-success" style="font-size: 2rem;"></i>
<p class="text-muted mt-2 mb-0">No active alerts. All clear!</p>
</div>
}
else
{
<div class="list-group list-group-flush">
@foreach (AlertInfo alert in alerts.OrderByDescending(a => a.Severity == "critical")
.ThenByDescending(a => a.Severity == "warning"))
{
<div class="list-group-item px-3 py-2">
<div class="d-flex align-items-start justify-content-between">
<div class="flex-grow-1">
<div class="d-flex align-items-center gap-2 mb-1">
<span class="badge @GetSeverityBadgeClass(alert.Severity)">@alert.Severity</span>
<strong class="small">@alert.Name</strong>
<span class="badge bg-light text-dark border">@alert.State</span>
</div>
@if (!string.IsNullOrEmpty(alert.Summary))
{
<p class="mb-1 small text-muted">@alert.Summary</p>
}
<div class="d-flex flex-wrap gap-1">
@foreach (KeyValuePair<string, string> label in alert.Labels
.Where(l => l.Key != "alertname" && l.Key != "severity"))
{
<span class="badge bg-light text-dark border-0 small">
<code>@label.Key</code>=@label.Value
</span>
}
</div>
<small class="text-muted">
<i class="bi bi-clock me-1"></i>Started @FormatTimeAgo(alert.StartsAt)
</small>
</div>
<button class="btn btn-sm btn-outline-warning" title="Silence this alert"
@onclick="() => StartSilence(alert)">
<i class="bi bi-bell-slash"></i>
</button>
</div>
</div>
}
</div>
}
}
else if (view == "silences")
{
@* ── Silences ── *@
@if (silences is null || silences.Count == 0)
{
<div class="text-center py-4">
<i class="bi bi-bell-slash text-muted" style="font-size: 2rem;"></i>
<p class="text-muted mt-2 mb-0">No silences configured.</p>
</div>
}
else
{
<div class="list-group list-group-flush">
@foreach (SilenceInfo silence in silences.Where(s => s.State == "active"))
{
<div class="list-group-item px-3 py-2">
<div class="d-flex align-items-start justify-content-between">
<div>
<div class="d-flex align-items-center gap-2 mb-1">
<span class="badge bg-warning text-dark">active</span>
<small class="fw-medium">@silence.Comment</small>
</div>
<div class="d-flex flex-wrap gap-1 mb-1">
@foreach (SilenceMatcher matcher in silence.Matchers)
{
<span class="badge bg-light text-dark border small">
@matcher.Name @(matcher.IsEqual ? "=" : "!=") @(matcher.IsRegex ? "~" : "")@matcher.Value
</span>
}
</div>
<small class="text-muted">
By @silence.CreatedBy · Expires @silence.EndsAt.ToString("g")
</small>
</div>
</div>
</div>
}
</div>
}
}
@* ── Create Silence Form ── *@
@if (showCreateSilence)
{
<div class="border-top p-3">
<h6 class="small fw-bold mb-2"><i class="bi bi-bell-slash me-1"></i>Create Silence</h6>
<div class="row g-2 mb-2">
<div class="col-md-4">
<label class="form-label small mb-0">Matcher (label=value)</label>
<input type="text" class="form-control form-control-sm" placeholder="alertname=HighCPU"
@bind="silenceMatcherInput" @bind:event="oninput" />
</div>
<div class="col-md-3">
<label class="form-label small mb-0">Duration</label>
<select class="form-select form-select-sm" @bind="silenceDurationHours">
<option value="1">1 hour</option>
<option value="2">2 hours</option>
<option value="4">4 hours</option>
<option value="8">8 hours</option>
<option value="24">24 hours</option>
<option value="72">3 days</option>
<option value="168">7 days</option>
</select>
</div>
<div class="col-md-5">
<label class="form-label small mb-0">Comment</label>
<input type="text" class="form-control form-control-sm" placeholder="Reason for silence"
@bind="silenceComment" @bind:event="oninput" />
</div>
</div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-warning" @onclick="SubmitSilence"
disabled="@(string.IsNullOrWhiteSpace(silenceMatcherInput) || string.IsNullOrWhiteSpace(silenceComment))">
<i class="bi bi-bell-slash me-1"></i>Create Silence
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelSilence">Cancel</button>
</div>
@if (!string.IsNullOrEmpty(silenceError))
{
<div class="text-danger small mt-1">@silenceError</div>
}
</div>
}
</div>
</div>
@code {
[Parameter] public Guid ClusterId { get; set; }
[Parameter] public PrometheusService PrometheusService { get; set; } = null!;
private List<AlertInfo>? alerts;
private List<SilenceInfo>? silences;
private string view = "alerts";
private bool loading;
private string? errorMessage;
// Silence form
private bool showCreateSilence;
private string silenceMatcherInput = "";
private int silenceDurationHours = 2;
private string silenceComment = "";
private string? silenceError;
protected override async Task OnInitializedAsync()
{
await RefreshAlerts();
}
private async Task RefreshAlerts()
{
loading = true;
errorMessage = null;
StateHasChanged();
KubernetesOperationResult<List<AlertInfo>> result =
await PrometheusService.GetAlertsAsync(ClusterId);
if (result.IsSuccess)
{
alerts = result.Data;
}
else
{
errorMessage = result.Error;
}
loading = false;
}
private async Task LoadSilences()
{
view = "silences";
loading = true;
errorMessage = null;
StateHasChanged();
KubernetesOperationResult<List<SilenceInfo>> result =
await PrometheusService.GetSilencesAsync(ClusterId);
if (result.IsSuccess)
{
silences = result.Data;
}
else
{
errorMessage = result.Error;
}
loading = false;
}
private void StartSilence(AlertInfo alert)
{
showCreateSilence = true;
silenceMatcherInput = $"alertname={alert.Name}";
silenceComment = "";
silenceError = null;
}
private void CancelSilence()
{
showCreateSilence = false;
silenceMatcherInput = "";
silenceComment = "";
silenceError = null;
}
private async Task SubmitSilence()
{
silenceError = null;
// Parse matcher input "label=value" into a SilenceMatcher.
string[] parts = silenceMatcherInput.Split('=', 2);
if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[0]))
{
silenceError = "Matcher format: label=value";
return;
}
List<SilenceMatcher> matchers =
[
new SilenceMatcher { Name = parts[0].Trim(), Value = parts[1].Trim(), IsEqual = true }
];
KubernetesOperationResult result = await PrometheusService.CreateSilenceAsync(
ClusterId,
silenceComment,
"entkube-user",
TimeSpan.FromHours(silenceDurationHours),
matchers);
if (result.IsSuccess)
{
showCreateSilence = false;
silenceMatcherInput = "";
silenceComment = "";
await LoadSilences();
}
else
{
silenceError = result.Error;
}
}
private static string GetSeverityBadgeClass(string severity) => severity switch
{
"critical" => "bg-danger",
"warning" => "bg-warning text-dark",
"info" => "bg-info text-dark",
_ => "bg-secondary"
};
private static string FormatTimeAgo(DateTime time)
{
if (time == DateTime.MinValue)
{
return "unknown";
}
TimeSpan ago = DateTime.UtcNow - time;
return ago switch
{
{ TotalMinutes: < 1 } => "just now",
{ TotalMinutes: < 60 } => $"{(int)ago.TotalMinutes}m ago",
{ TotalHours: < 24 } => $"{(int)ago.TotalHours}h ago",
_ => $"{(int)ago.TotalDays}d ago"
};
}
}

View File

@@ -0,0 +1,840 @@
@using EntKube.Web.Data
@using EntKube.Web.Services
@using Microsoft.EntityFrameworkCore
@inject TenantService TenantService
@inject DeploymentService DeploymentService
@* ═══════════════════════════════════════════════════════════════════
App Detail — a full-page view for a single application.
Shows app info, environment links, and is the extension point
for future app-level features (config, deployments, secrets, etc.).
═══════════════════════════════════════════════════════════════════ *@
@* --- Back button --- *@
<nav class="mb-3">
<button class="btn btn-sm btn-link text-decoration-none p-0" @onclick="OnBack">
<i class="bi bi-arrow-left me-1"></i>Back to @CustomerName
</button>
</nav>
@* --- App header --- *@
<div class="card shadow-sm mb-4">
<div class="card-body">
<div class="d-flex align-items-start justify-content-between">
<div class="d-flex align-items-start">
<i class="bi bi-app-indicator fs-3 me-3 text-primary"></i>
<div>
<h4 class="mb-1">@App.Name</h4>
<div class="d-flex flex-wrap gap-2 text-muted small">
<span><i class="bi bi-person me-1"></i>@CustomerName</span>
<span><i class="bi bi-calendar me-1"></i>Created @App.CreatedAt.ToString("MMM d, yyyy")</span>
</div>
</div>
</div>
@if (!confirmDelete)
{
<button class="btn btn-sm btn-outline-danger" @onclick="() => confirmDelete = true" title="Delete app">
<i class="bi bi-trash me-1"></i>Delete
</button>
}
</div>
@if (confirmDelete)
{
<div class="mt-3 p-2 bg-danger bg-opacity-10 rounded d-flex align-items-center justify-content-between">
<span class="text-danger small">
<i class="bi bi-exclamation-triangle me-1"></i>
Permanently delete <strong>@App.Name</strong> and all its data?
</span>
<div>
<button class="btn btn-sm btn-danger me-1" @onclick="DeleteApp">Yes, delete</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmDelete = false">Cancel</button>
</div>
</div>
}
</div>
</div>
@* --- App sub-sections (tabs for future expansion) --- *@
<ul class="nav nav-pills mb-3 gap-1">
<li class="nav-item">
<button class="nav-link @(section == "environments" ? "active" : "")" @onclick='() => section = "environments"'>
<i class="bi bi-layers me-1"></i>Environments
</button>
</li>
<li class="nav-item">
<button class="nav-link @(section == "deployments" ? "active" : "")" @onclick="SwitchToDeployments">
<i class="bi bi-rocket-takeoff me-1"></i>Deployments
@if (deployments is not null && deployments.Count > 0)
{
<span class="badge bg-primary ms-1">@deployments.Count</span>
}
</button>
</li>
</ul>
@if (section == "environments")
{
<div class="card shadow-sm">
<div class="card-header bg-white py-2">
<div class="d-flex align-items-center">
<i class="bi bi-layers me-2 text-primary"></i>
<strong>Environment Links</strong>
</div>
<small class="text-muted">Select which environments this app is deployed to.</small>
</div>
<div class="card-body">
@if (environments is null)
{
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</div>
}
else if (environments.Count == 0)
{
<div class="text-center py-4">
<i class="bi bi-layers text-muted" style="font-size: 2rem;"></i>
<p class="text-muted small mt-2 mb-0">No environments exist for this tenant yet. Create environments first.</p>
</div>
}
else
{
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
@foreach (Data.Environment env in environments)
{
bool isLinked = linkedEnvIds.Contains(env.Id);
<div class="col">
<div class="card @(isLinked ? "border-success bg-success bg-opacity-10" : "border-light")"
role="button" style="cursor: pointer;"
@onclick="() => ToggleEnvironment(env.Id, isLinked)">
<div class="card-body py-2 d-flex align-items-center">
<i class="bi @(isLinked ? "bi-check-circle-fill text-success" : "bi-circle text-muted") me-2 fs-5"></i>
<div class="flex-grow-1">
<span class="fw-medium">@env.Name</span>
</div>
@if (isLinked)
{
<span class="badge bg-success">Active</span>
}
</div>
</div>
</div>
}
</div>
}
</div>
</div>
}
@* ═══════════════════════════════════════════════════════════════════
Deployments Tab — manage deployments (Manual, YAML, Helm) targeting
specific clusters. Shows ArgoCD-style sync/health status.
═══════════════════════════════════════════════════════════════════ *@
@if (section == "deployments")
{
@* --- Deployment detail view (drill-down into a single deployment) --- *@
@if (selectedDeployment is not null)
{
<nav class="mb-3">
<button class="btn btn-sm btn-link text-decoration-none p-0" @onclick="() => selectedDeployment = null">
<i class="bi bi-arrow-left me-1"></i>Back to deployments
</button>
</nav>
<div class="card shadow-sm mb-3">
<div class="card-body">
<div class="d-flex align-items-start justify-content-between">
<div>
<h5 class="mb-1">
<i class="bi bi-rocket-takeoff me-2 text-primary"></i>@selectedDeployment.Name
</h5>
<div class="d-flex flex-wrap gap-2 text-muted small">
<span>@GetTypeBadge(selectedDeployment.Type)</span>
<span><i class="bi bi-layers me-1"></i>@selectedDeployment.Environment?.Name</span>
<span><i class="bi bi-hdd-network me-1"></i>@selectedDeployment.Cluster?.Name</span>
<span><i class="bi bi-box me-1"></i>@selectedDeployment.Namespace</span>
</div>
</div>
<div class="d-flex align-items-start gap-1">
@GetSyncBadge(selectedDeployment.SyncStatus)
@GetHealthBadge(selectedDeployment.HealthStatus)
@if (!confirmDeleteDeployment)
{
<button class="btn btn-sm btn-outline-danger ms-2" @onclick="() => confirmDeleteDeployment = true" title="Delete deployment">
<i class="bi bi-trash"></i>
</button>
}
</div>
</div>
@if (confirmDeleteDeployment)
{
<div class="mt-3 p-2 bg-danger bg-opacity-10 rounded d-flex align-items-center justify-content-between">
<span class="text-danger small">
<i class="bi bi-exclamation-triangle me-1"></i>
Permanently delete <strong>@selectedDeployment.Name</strong> and all its manifests and resources?
</span>
<div>
<button class="btn btn-sm btn-danger me-1" @onclick="DeleteDeployment">Yes, delete</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmDeleteDeployment = false">Cancel</button>
</div>
</div>
}
@if (selectedDeployment.Type == DeploymentType.HelmChart)
{
<div class="mt-3 p-2 bg-light rounded">
<small class="text-muted">
<i class="bi bi-box-seam me-1"></i>
@selectedDeployment.HelmChartName
<span class="mx-1">@selectedDeployment.HelmChartVersion</span>
<span class="text-muted">from @selectedDeployment.HelmRepoUrl</span>
</small>
</div>
}
</div>
</div>
@* --- Sub-tabs: Manifests / Resources / Helm Values --- *@
<ul class="nav nav-tabs mb-3">
@if (selectedDeployment.Type != DeploymentType.HelmChart)
{
<li class="nav-item">
<button class="nav-link @(deploymentSection == "manifests" ? "active" : "")"
@onclick='() => deploymentSection = "manifests"'>
<i class="bi bi-file-earmark-code me-1"></i>Manifests
</button>
</li>
}
@if (selectedDeployment.Type == DeploymentType.HelmChart)
{
<li class="nav-item">
<button class="nav-link @(deploymentSection == "helm-values" ? "active" : "")"
@onclick='() => deploymentSection = "helm-values"'>
<i class="bi bi-sliders me-1"></i>Values
</button>
</li>
}
<li class="nav-item">
<button class="nav-link @(deploymentSection == "resources" ? "active" : "")"
@onclick="OpenResourcesTab">
<i class="bi bi-diagram-3 me-1"></i>Resources
</button>
</li>
</ul>
@* ── Manifests sub-tab ── *@
@if (deploymentSection == "manifests")
{
<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-file-earmark-code me-2 text-primary"></i>
<strong>Manifests</strong>
</div>
<button class="btn btn-sm btn-outline-primary" @onclick="() => showAddManifest = !showAddManifest">
<i class="bi bi-plus me-1"></i>Add Manifest
</button>
</div>
<div class="card-body">
@if (showAddManifest)
{
<div class="card border-primary mb-3">
<div class="card-body">
<h6 class="mb-3"><i class="bi bi-plus-circle me-1"></i>Add YAML Manifest</h6>
<div class="row g-2 mb-2">
<div class="col-md-4">
<label class="form-label small">Kind</label>
<input class="form-control form-control-sm" @bind="newManifestKind" placeholder="e.g. Deployment" />
</div>
<div class="col-md-4">
<label class="form-label small">Name</label>
<input class="form-control form-control-sm" @bind="newManifestName" placeholder="e.g. billing-api" />
</div>
<div class="col-md-4">
<label class="form-label small">Sort Order</label>
<input class="form-control form-control-sm" type="number" @bind="newManifestSortOrder" />
</div>
</div>
<div class="mb-2">
<label class="form-label small">YAML Content</label>
<textarea class="form-control form-control-sm font-monospace" rows="8"
@bind="newManifestYaml" placeholder="apiVersion: v1&#10;kind: Service&#10;..."></textarea>
</div>
<div class="d-flex gap-1">
<button class="btn btn-sm btn-primary" @onclick="AddManifest"
disabled="@(string.IsNullOrWhiteSpace(newManifestKind) || string.IsNullOrWhiteSpace(newManifestYaml))">
Add
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showAddManifest = false">Cancel</button>
</div>
</div>
</div>
}
@if (manifests is null)
{
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</div>
}
else if (manifests.Count == 0)
{
<div class="text-center py-4">
<i class="bi bi-file-earmark-code text-muted" style="font-size: 2rem;"></i>
<p class="text-muted small mt-2 mb-0">No manifests yet. Add YAML manifests to define what gets deployed.</p>
</div>
}
else
{
@foreach (DeploymentManifest manifest in manifests)
{
<div class="card mb-2">
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between">
<div>
<span class="badge bg-secondary me-1">@manifest.Kind</span>
<span class="fw-medium">@manifest.Name</span>
<span class="text-muted small ms-2">#@manifest.SortOrder</span>
</div>
<div class="d-flex gap-1">
@if (editingManifestId == manifest.Id)
{
<button class="btn btn-sm btn-primary" @onclick="() => SaveManifest(manifest.Id)">Save</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => editingManifestId = null">Cancel</button>
}
else
{
<button class="btn btn-sm btn-outline-primary" @onclick="() => StartEditManifest(manifest)">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteManifest(manifest.Id)">
<i class="bi bi-trash"></i>
</button>
}
</div>
</div>
@if (editingManifestId == manifest.Id)
{
<div class="card-body p-2">
<textarea class="form-control form-control-sm font-monospace" rows="10"
@bind="editManifestYaml"></textarea>
</div>
}
else
{
<div class="card-body p-2">
<pre class="mb-0 small" style="max-height: 200px; overflow-y: auto;">@manifest.YamlContent</pre>
</div>
}
</div>
}
}
</div>
</div>
}
@* ── Helm Values sub-tab ── *@
@if (deploymentSection == "helm-values")
{
<div class="card shadow-sm">
<div class="card-header bg-white py-2">
<i class="bi bi-sliders me-2 text-primary"></i>
<strong>Helm Values</strong>
</div>
<div class="card-body">
<p class="text-muted small mb-2">Override the chart's default values (YAML format).</p>
<textarea class="form-control font-monospace" rows="12"
@bind="helmValuesYaml"
placeholder="replicas: 3&#10;persistence:&#10; size: 10Gi"></textarea>
<button class="btn btn-sm btn-primary mt-2" @onclick="SaveHelmValues">
<i class="bi bi-save me-1"></i>Save Values
</button>
</div>
</div>
}
@* ── Resources sub-tab (ArgoCD-style tree) ── *@
@if (deploymentSection == "resources")
{
<div class="card shadow-sm">
<div class="card-header bg-white py-2">
<i class="bi bi-diagram-3 me-2 text-primary"></i>
<strong>Resource Tree</strong>
</div>
<div class="card-body">
@if (resourceTree is null)
{
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</div>
}
else if (resourceTree.Count == 0)
{
<div class="text-center py-4">
<i class="bi bi-diagram-3 text-muted" style="font-size: 2rem;"></i>
<p class="text-muted small mt-2 mb-0">No tracked resources yet. Resources appear here after the deployment syncs to the cluster.</p>
</div>
}
else
{
@foreach (DeploymentResource root in resourceTree)
{
<div class="mb-2">
@RenderResource(root, 0)
</div>
}
}
</div>
</div>
}
}
else
{
@* --- Deployment list view --- *@
<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-rocket-takeoff me-2 text-primary"></i>
<strong>Deployments</strong>
</div>
<button class="btn btn-sm btn-outline-primary" @onclick="() => showCreateDeployment = !showCreateDeployment">
<i class="bi bi-plus me-1"></i>New Deployment
</button>
</div>
<div class="card-body">
@* --- Create deployment form --- *@
@if (showCreateDeployment)
{
<div class="card border-primary mb-3">
<div class="card-body">
<h6 class="mb-3"><i class="bi bi-plus-circle me-1"></i>Create Deployment</h6>
<div class="row g-2 mb-2">
<div class="col-md-6">
<label class="form-label small">Name</label>
<input class="form-control form-control-sm" @bind="newDeployName" placeholder="e.g. billing-api-prod" />
</div>
<div class="col-md-6">
<label class="form-label small">Type</label>
<select class="form-select form-select-sm" @bind="newDeployType">
<option value="@DeploymentType.Manual">Manual</option>
<option value="@DeploymentType.Yaml">YAML</option>
<option value="@DeploymentType.HelmChart">Helm Chart</option>
</select>
</div>
</div>
<div class="row g-2 mb-2">
<div class="col-md-4">
<label class="form-label small">Environment</label>
<select class="form-select form-select-sm" @bind="newDeployEnvId">
<option value="">Select...</option>
@if (environments is not null)
{
@foreach (Data.Environment env in environments)
{
<option value="@env.Id">@env.Name</option>
}
}
</select>
</div>
<div class="col-md-4">
<label class="form-label small">Cluster</label>
<select class="form-select form-select-sm" @bind="newDeployClusterId">
<option value="">Select...</option>
@if (clusters is not null)
{
@foreach (KubernetesCluster cluster in clusters.Where(c => newDeployEnvId == Guid.Empty || c.EnvironmentId == newDeployEnvId))
{
<option value="@cluster.Id">@cluster.Name</option>
}
}
</select>
</div>
<div class="col-md-4">
<label class="form-label small">Namespace</label>
<input class="form-control form-control-sm" @bind="newDeployNamespace" placeholder="e.g. billing" />
</div>
</div>
@if (newDeployType == DeploymentType.HelmChart)
{
<div class="row g-2 mb-2">
<div class="col-md-4">
<label class="form-label small">Helm Repo URL</label>
<input class="form-control form-control-sm" @bind="newHelmRepoUrl"
placeholder="https://charts.bitnami.com/bitnami" />
</div>
<div class="col-md-4">
<label class="form-label small">Chart Name</label>
<input class="form-control form-control-sm" @bind="newHelmChartName"
placeholder="e.g. postgresql" />
</div>
<div class="col-md-4">
<label class="form-label small">Chart Version</label>
<input class="form-control form-control-sm" @bind="newHelmChartVersion"
placeholder="e.g. 15.5.0" />
</div>
</div>
}
@if (!string.IsNullOrEmpty(createDeployError))
{
<div class="alert alert-danger py-1 small mb-2">@createDeployError</div>
}
<div class="d-flex gap-1">
<button class="btn btn-sm btn-primary" @onclick="CreateDeployment"
disabled="@(string.IsNullOrWhiteSpace(newDeployName) || newDeployEnvId == Guid.Empty || newDeployClusterId == Guid.Empty)">
Create
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showCreateDeployment = false">Cancel</button>
</div>
</div>
</div>
}
@* --- Deployment list --- *@
@if (deployments is null)
{
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</div>
}
else if (deployments.Count == 0)
{
<div class="text-center py-4">
<i class="bi bi-rocket-takeoff text-muted" style="font-size: 2rem;"></i>
<p class="text-muted small mt-2 mb-0">No deployments yet. Create one to start deploying to Kubernetes.</p>
</div>
}
else
{
<div class="row row-cols-1 row-cols-lg-2 g-2">
@foreach (AppDeployment deploy in deployments)
{
<div class="col">
<div class="card border-light h-100" role="button" style="cursor: pointer;"
@onclick="() => SelectDeployment(deploy)">
<div class="card-body py-2">
<div class="d-flex align-items-center justify-content-between mb-1">
<span class="fw-medium">
<i class="bi bi-rocket-takeoff me-1 text-primary"></i>@deploy.Name
</span>
<div class="d-flex gap-1">
@GetSyncBadge(deploy.SyncStatus)
@GetHealthBadge(deploy.HealthStatus)
</div>
</div>
<div class="d-flex flex-wrap gap-2 text-muted small">
@GetTypeBadge(deploy.Type)
<span><i class="bi bi-layers me-1"></i>@deploy.Environment?.Name</span>
<span><i class="bi bi-hdd-network me-1"></i>@deploy.Cluster?.Name</span>
<span><i class="bi bi-box me-1"></i>@deploy.Namespace</span>
</div>
@if (!string.IsNullOrEmpty(deploy.StatusMessage))
{
<div class="text-muted small mt-1 text-truncate">@deploy.StatusMessage</div>
}
</div>
</div>
</div>
}
</div>
}
</div>
</div>
}
}
@code {
[Parameter] public required Data.App App { get; set; }
[Parameter] public Guid TenantId { get; set; }
[Parameter] public string CustomerName { get; set; } = "";
[Parameter] public EventCallback OnBack { get; set; }
[Parameter] public EventCallback OnDeleted { get; set; }
private string section = "environments";
private bool confirmDelete;
// Environments
private List<Data.Environment>? environments;
private HashSet<Guid> linkedEnvIds = new();
// Deployments
private List<AppDeployment>? deployments;
private List<KubernetesCluster>? clusters;
private AppDeployment? selectedDeployment;
private string deploymentSection = "manifests";
private bool confirmDeleteDeployment;
// Create deployment form
private bool showCreateDeployment;
private string newDeployName = "";
private DeploymentType newDeployType = DeploymentType.Manual;
private Guid newDeployEnvId;
private Guid newDeployClusterId;
private string newDeployNamespace = "";
private string newHelmRepoUrl = "";
private string newHelmChartName = "";
private string newHelmChartVersion = "";
private string? createDeployError;
// Manifest management
private List<DeploymentManifest>? manifests;
private bool showAddManifest;
private string newManifestKind = "";
private string newManifestName = "";
private string newManifestYaml = "";
private int newManifestSortOrder;
private Guid? editingManifestId;
private string editManifestYaml = "";
// Helm values
private string helmValuesYaml = "";
// Resource tree
private List<DeploymentResource>? resourceTree;
protected override async Task OnInitializedAsync()
{
await LoadEnvironments();
}
// ──────── App ────────
private async Task DeleteApp()
{
await TenantService.DeleteAppAsync(App.Id);
await OnDeleted.InvokeAsync();
}
// ──────── Environments ────────
private async Task LoadEnvironments()
{
environments = await TenantService.GetEnvironmentsAsync(TenantId);
// Refresh the app to get current environment links.
List<Data.App> apps = await TenantService.GetAppsAsync(App.CustomerId);
Data.App? freshApp = apps.FirstOrDefault(a => a.Id == App.Id);
linkedEnvIds = freshApp?.AppEnvironments.Select(ae => ae.EnvironmentId).ToHashSet() ?? new();
}
private async Task ToggleEnvironment(Guid envId, bool currentlyLinked)
{
if (currentlyLinked)
{
await TenantService.UnlinkAppFromEnvironmentAsync(App.Id, envId);
}
else
{
await TenantService.LinkAppToEnvironmentAsync(App.Id, envId);
}
await LoadEnvironments();
}
// ──────── Deployments ────────
private async Task SwitchToDeployments()
{
section = "deployments";
if (deployments is null)
{
await LoadDeployments();
}
if (clusters is null)
{
clusters = await TenantService.GetClustersAsync(TenantId);
}
}
private async Task LoadDeployments()
{
deployments = await DeploymentService.GetDeploymentsAsync(App.Id);
}
private async Task CreateDeployment()
{
createDeployError = null;
try
{
await DeploymentService.CreateDeploymentAsync(
App.Id, newDeployName, newDeployType,
newDeployEnvId, newDeployClusterId, newDeployNamespace,
newDeployType == DeploymentType.HelmChart ? newHelmRepoUrl : null,
newDeployType == DeploymentType.HelmChart ? newHelmChartName : null,
newDeployType == DeploymentType.HelmChart ? newHelmChartVersion : null);
// Reset form and reload.
showCreateDeployment = false;
newDeployName = "";
newDeployNamespace = "";
newHelmRepoUrl = "";
newHelmChartName = "";
newHelmChartVersion = "";
await LoadDeployments();
}
catch (DbUpdateException)
{
createDeployError = $"A deployment named '{newDeployName}' already exists for this app.";
}
}
private async Task SelectDeployment(AppDeployment deploy)
{
selectedDeployment = deploy;
confirmDeleteDeployment = false;
// Default to the appropriate sub-tab based on type.
if (deploy.Type == DeploymentType.HelmChart)
{
deploymentSection = "helm-values";
helmValuesYaml = deploy.HelmValues ?? "";
}
else
{
deploymentSection = "manifests";
await LoadManifests();
}
}
private async Task DeleteDeployment()
{
if (selectedDeployment is not null)
{
await DeploymentService.DeleteDeploymentAsync(selectedDeployment.Id);
selectedDeployment = null;
confirmDeleteDeployment = false;
await LoadDeployments();
}
}
// ──────── Manifests ────────
private async Task LoadManifests()
{
if (selectedDeployment is not null)
{
manifests = await DeploymentService.GetManifestsAsync(selectedDeployment.Id);
}
}
private async Task AddManifest()
{
if (selectedDeployment is null)
{
return;
}
await DeploymentService.AddManifestAsync(
selectedDeployment.Id, newManifestKind, newManifestName, newManifestYaml, newManifestSortOrder);
showAddManifest = false;
newManifestKind = "";
newManifestName = "";
newManifestYaml = "";
newManifestSortOrder = 0;
await LoadManifests();
}
private void StartEditManifest(DeploymentManifest manifest)
{
editingManifestId = manifest.Id;
editManifestYaml = manifest.YamlContent;
}
private async Task SaveManifest(Guid manifestId)
{
await DeploymentService.UpdateManifestAsync(manifestId, editManifestYaml);
editingManifestId = null;
await LoadManifests();
}
private async Task DeleteManifest(Guid manifestId)
{
await DeploymentService.DeleteManifestAsync(manifestId);
await LoadManifests();
}
// ──────── Helm Values ────────
private async Task SaveHelmValues()
{
if (selectedDeployment is not null)
{
await DeploymentService.UpdateHelmValuesAsync(selectedDeployment.Id, helmValuesYaml);
}
}
// ──────── Resources ────────
private async Task OpenResourcesTab()
{
deploymentSection = "resources";
if (selectedDeployment is not null)
{
resourceTree = await DeploymentService.GetResourceTreeAsync(selectedDeployment.Id);
}
}
// ──────── Rendering helpers ────────
private RenderFragment GetTypeBadge(DeploymentType type) => type switch
{
DeploymentType.Manual => @<span class="badge bg-info">Manual</span>,
DeploymentType.Yaml => @<span class="badge bg-warning text-dark">YAML</span>,
DeploymentType.HelmChart => @<span class="badge bg-purple text-white" style="background-color: #6f42c1 !important;">Helm</span>,
_ => @<span class="badge bg-secondary">Unknown</span>
};
private RenderFragment GetSyncBadge(SyncStatus status) => status switch
{
SyncStatus.Synced => @<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Synced</span>,
SyncStatus.OutOfSync => @<span class="badge bg-warning text-dark"><i class="bi bi-exclamation-circle me-1"></i>OutOfSync</span>,
SyncStatus.Syncing => @<span class="badge bg-info"><i class="bi bi-arrow-repeat me-1"></i>Syncing</span>,
SyncStatus.Failed => @<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>Failed</span>,
_ => @<span class="badge bg-secondary">Unknown</span>
};
private RenderFragment GetHealthBadge(HealthStatus status) => status switch
{
HealthStatus.Healthy => @<span class="badge bg-success"><i class="bi bi-heart-fill me-1"></i>Healthy</span>,
HealthStatus.Progressing => @<span class="badge bg-info"><i class="bi bi-hourglass-split me-1"></i>Progressing</span>,
HealthStatus.Degraded => @<span class="badge bg-warning text-dark"><i class="bi bi-exclamation-triangle me-1"></i>Degraded</span>,
HealthStatus.Missing => @<span class="badge bg-danger"><i class="bi bi-question-circle me-1"></i>Missing</span>,
HealthStatus.Suspended => @<span class="badge bg-secondary"><i class="bi bi-pause-circle me-1"></i>Suspended</span>,
_ => @<span class="badge bg-secondary">Unknown</span>
};
private RenderFragment RenderResource(DeploymentResource resource, int depth) => __builder =>
{
<div class="d-flex align-items-center py-1 border-bottom" style="padding-left: @(depth * 24)px;">
<span class="badge bg-secondary me-2 small">@resource.Kind</span>
<span class="fw-medium small flex-grow-1">@resource.Name</span>
<div class="d-flex gap-1">
@GetSyncBadge(resource.SyncStatus)
@GetHealthBadge(resource.HealthStatus)
</div>
</div>
@if (resource.ChildResources?.Count > 0)
{
@foreach (DeploymentResource child in resource.ChildResources)
{
@RenderResource(child, depth + 1)
}
}
};
}

View File

@@ -0,0 +1,148 @@
@using EntKube.Web.Data
@using EntKube.Web.Services
@using Microsoft.EntityFrameworkCore
@inject TenantService TenantService
<div class="card mt-3">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Apps for @Customer.Name</strong>
<button class="btn btn-sm btn-outline-secondary" @onclick="OnClose">Close</button>
</div>
<div class="card-body">
<div class="input-group mb-3" style="max-width: 400px;">
<input type="text" class="form-control" placeholder="App name" @bind="newAppName" @bind:event="oninput" />
<button class="btn btn-primary" @onclick="CreateApp" disabled="@string.IsNullOrWhiteSpace(newAppName)">Add App</button>
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="text-danger mb-2">@errorMessage</div>
}
@if (apps is null)
{
<p><em>Loading...</em></p>
}
else if (apps.Count == 0)
{
<p class="text-muted">No apps yet for this customer.</p>
}
else
{
<table class="table table-sm">
<thead>
<tr>
<th>App Name</th>
<th>Environments</th>
<th style="width: 80px;"></th>
</tr>
</thead>
<tbody>
@foreach (Data.App app in apps)
{
<tr>
<td>@app.Name</td>
<td>
@if (app.AppEnvironments.Any())
{
@string.Join(", ", app.AppEnvironments.Select(ae => ae.Environment.Name))
}
else
{
<span class="text-muted">None</span>
}
<button class="btn btn-sm btn-link p-0 ms-2" @onclick="() => ToggleEnvPicker(app.Id)">Edit</button>
</td>
<td>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteApp(app.Id)">Delete</button>
</td>
</tr>
@if (envPickerAppId == app.Id && environments is not null)
{
<tr>
<td colspan="3">
<div class="d-flex flex-wrap gap-2 p-2 bg-light rounded">
@foreach (Data.Environment env in environments)
{
bool isLinked = app.AppEnvironments.Any(ae => ae.EnvironmentId == env.Id);
<button class="btn btn-sm @(isLinked ? "btn-success" : "btn-outline-secondary")"
@onclick="() => ToggleEnvironment(app.Id, env.Id, isLinked)">
@env.Name
</button>
}
</div>
</td>
</tr>
}
}
</tbody>
</table>
}
</div>
</div>
@code {
[Parameter] public Customer Customer { get; set; } = null!;
[Parameter] public Guid TenantId { get; set; }
[Parameter] public EventCallback OnClose { get; set; }
private List<Data.App>? apps;
private List<Data.Environment>? environments;
private string newAppName = "";
private string? errorMessage;
private Guid? envPickerAppId;
protected override async Task OnInitializedAsync()
{
await Load();
environments = await TenantService.GetEnvironmentsAsync(TenantId);
}
private async Task Load()
{
apps = await TenantService.GetAppsAsync(Customer.Id);
}
private async Task CreateApp()
{
errorMessage = null;
try
{
await TenantService.CreateAppAsync(Customer.Id, newAppName.Trim());
newAppName = "";
await Load();
}
catch (DbUpdateException)
{
errorMessage = "An app with that name already exists for this customer.";
}
}
private async Task DeleteApp(Guid id)
{
await TenantService.DeleteAppAsync(id);
await Load();
}
private void ToggleEnvPicker(Guid appId)
{
envPickerAppId = envPickerAppId == appId ? null : appId;
}
private async Task ToggleEnvironment(Guid appId, Guid envId, bool currentlyLinked)
{
if (currentlyLinked)
{
await TenantService.UnlinkAppFromEnvironmentAsync(appId, envId);
}
else
{
await TenantService.LinkAppToEnvironmentAsync(appId, envId);
}
await Load();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,216 @@
@using EntKube.Web.Data
@using EntKube.Web.Services
@inject PrometheusService PrometheusService
<div class="card border-0 shadow-sm">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
<span><i class="bi bi-heart-pulse me-2"></i>Cluster Health — Prometheus</span>
<div>
@if (loading)
{
<span class="spinner-border spinner-border-sm text-light me-2"></span>
}
<button class="btn btn-sm btn-outline-light" @onclick="RefreshHealth" disabled="@loading">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
<button class="btn btn-sm btn-outline-secondary ms-1" @onclick="OnClose">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
<div class="card-body">
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-1"></i>@errorMessage
</div>
}
else if (health is null && loading)
{
<p class="text-muted"><em>Querying Prometheus...</em></p>
}
else if (health is not null)
{
@* ── Overview cards ── *@
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card text-center @(health.ReadyNodes == health.TotalNodes ? "border-success" : "border-danger")">
<div class="card-body py-2">
<div class="fs-3 fw-bold @(health.ReadyNodes == health.TotalNodes ? "text-success" : "text-danger")">
@health.ReadyNodes / @health.TotalNodes
</div>
<small class="text-muted">Nodes Ready</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center border-primary">
<div class="card-body py-2">
<div class="fs-3 fw-bold text-primary">@health.RunningPods</div>
<small class="text-muted">Running Pods</small>
@if (health.PendingPods > 0 || health.FailedPods > 0)
{
<div class="mt-1">
@if (health.PendingPods > 0)
{
<span class="badge bg-warning text-dark me-1">@health.PendingPods pending</span>
}
@if (health.FailedPods > 0)
{
<span class="badge bg-danger">@health.FailedPods failed</span>
}
</div>
}
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center @GetUsageBorderClass(health.CpuUsagePercent)">
<div class="card-body py-2">
<div class="fs-3 fw-bold @GetUsageTextClass(health.CpuUsagePercent)">
@health.CpuUsagePercent%
</div>
<small class="text-muted">CPU Usage</small>
<div class="progress mt-1" style="height: 4px;">
<div class="progress-bar @GetProgressBarClass(health.CpuUsagePercent)"
style="width: @health.CpuUsagePercent%"></div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center @GetUsageBorderClass(health.MemoryUsagePercent)">
<div class="card-body py-2">
<div class="fs-3 fw-bold @GetUsageTextClass(health.MemoryUsagePercent)">
@health.MemoryUsagePercent%
</div>
<small class="text-muted">Memory Usage</small>
<div class="progress mt-1" style="height: 4px;">
<div class="progress-bar @GetProgressBarClass(health.MemoryUsagePercent)"
style="width: @health.MemoryUsagePercent%"></div>
</div>
</div>
</div>
</div>
</div>
@* ── Per-node table ── *@
@if (health.Nodes.Count > 0)
{
<h6 class="mt-3"><i class="bi bi-hdd-rack me-1"></i>Nodes</h6>
<table class="table table-sm table-hover">
<thead>
<tr>
<th>Node</th>
<th>Status</th>
<th>CPU</th>
<th>Memory</th>
</tr>
</thead>
<tbody>
@foreach (NodeHealthInfo node in health.Nodes)
{
<tr>
<td><code>@node.Name</code></td>
<td>
@if (node.Ready)
{
<span class="badge bg-success">Ready</span>
}
else
{
<span class="badge bg-danger">NotReady</span>
}
</td>
<td>
<div class="d-flex align-items-center gap-2">
<div class="progress flex-grow-1" style="height: 6px; min-width: 60px;">
<div class="progress-bar @GetProgressBarClass(node.CpuUsagePercent)"
style="width: @node.CpuUsagePercent%"></div>
</div>
<small>@node.CpuUsagePercent%</small>
</div>
</td>
<td>
<div class="d-flex align-items-center gap-2">
<div class="progress flex-grow-1" style="height: 6px; min-width: 60px;">
<div class="progress-bar @GetProgressBarClass(node.MemoryUsagePercent)"
style="width: @node.MemoryUsagePercent%"></div>
</div>
<small>@node.MemoryUsagePercent%</small>
</div>
</td>
</tr>
}
</tbody>
</table>
}
<div class="text-muted small mt-2">
<i class="bi bi-clock me-1"></i>Last queried: @health.QueriedAt.ToString("HH:mm:ss UTC")
</div>
}
else
{
<p class="text-muted">No health data available. Click Refresh to query Prometheus.</p>
}
</div>
</div>
@code {
[Parameter] public Guid ClusterId { get; set; }
[Parameter] public EventCallback OnClose { get; set; }
private ClusterHealthSummary? health;
private bool loading;
private string? errorMessage;
protected override async Task OnInitializedAsync()
{
await RefreshHealth();
}
private async Task RefreshHealth()
{
loading = true;
errorMessage = null;
StateHasChanged();
KubernetesOperationResult<ClusterHealthSummary> result =
await PrometheusService.GetClusterHealthAsync(ClusterId);
if (result.IsSuccess)
{
health = result.Data;
}
else
{
errorMessage = result.Error;
}
loading = false;
}
private static string GetUsageBorderClass(double percent) => percent switch
{
>= 90 => "border-danger",
>= 70 => "border-warning",
_ => "border-success"
};
private static string GetUsageTextClass(double percent) => percent switch
{
>= 90 => "text-danger",
>= 70 => "text-warning",
_ => "text-success"
};
private static string GetProgressBarClass(double percent) => percent switch
{
>= 90 => "bg-danger",
>= 70 => "bg-warning",
_ => "bg-success"
};
}

View File

@@ -0,0 +1,318 @@
@using EntKube.Web.Data
@using EntKube.Web.Services
@inject PrometheusService PrometheusService
@* ═══════════════════════════════════════════════════════════════════════════
ClusterMonitoring — full monitoring dashboard for a single cluster.
Shows real-time metrics from Prometheus, time-series graphs, and
Alertmanager alerts/silences. The operations team's single pane of glass.
═══════════════════════════════════════════════════════════════════════════ *@
<div class="mb-4">
@if (loading && health is null)
{
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<p class="text-muted mt-2">Connecting to Prometheus...</p>
</div>
}
else if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-2"></i>@errorMessage
<button class="btn btn-sm btn-outline-warning ms-3" @onclick="LoadAll">Retry</button>
</div>
}
else
{
@* ── Header with refresh ── *@
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0"><i class="bi bi-heart-pulse me-2 text-success"></i>Monitoring</h5>
<div class="d-flex align-items-center gap-2">
<select class="form-select form-select-sm" style="width: auto;" @bind="timeRange" @bind:after="LoadMetrics">
<option value="30">Last 30 min</option>
<option value="60">Last 1 hour</option>
<option value="180">Last 3 hours</option>
<option value="360">Last 6 hours</option>
<option value="720">Last 12 hours</option>
<option value="1440">Last 24 hours</option>
</select>
@if (loading)
{
<span class="spinner-border spinner-border-sm text-primary"></span>
}
<button class="btn btn-sm btn-outline-primary" @onclick="LoadAll" disabled="@loading">
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
</button>
</div>
</div>
@* ── Status Overview Cards ── *@
@if (health is not null)
{
<div class="row g-3 mb-4">
<div class="col-md-3 col-6">
<div class="card text-center h-100 @(health.ReadyNodes == health.TotalNodes ? "border-success" : "border-danger")">
<div class="card-body py-3">
<div class="fs-2 fw-bold @(health.ReadyNodes == health.TotalNodes ? "text-success" : "text-danger")">
@health.ReadyNodes<span class="fs-5 text-muted">/@health.TotalNodes</span>
</div>
<small class="text-muted">Nodes Ready</small>
</div>
</div>
</div>
<div class="col-md-3 col-6">
<div class="card text-center h-100 border-primary">
<div class="card-body py-3">
<div class="fs-2 fw-bold text-primary">@health.RunningPods</div>
<small class="text-muted">Running Pods</small>
@if (health.PendingPods > 0 || health.FailedPods > 0)
{
<div class="mt-1">
@if (health.PendingPods > 0)
{
<span class="badge bg-warning text-dark">@health.PendingPods pending</span>
}
@if (health.FailedPods > 0)
{
<span class="badge bg-danger">@health.FailedPods failed</span>
}
</div>
}
</div>
</div>
</div>
<div class="col-md-3 col-6">
<div class="card text-center h-100 @GetBorderClass(health.CpuUsagePercent)">
<div class="card-body py-3">
<div class="fs-2 fw-bold @GetTextClass(health.CpuUsagePercent)">@health.CpuUsagePercent<span class="fs-6">%</span></div>
<small class="text-muted">CPU Usage</small>
<div class="progress mt-2" style="height: 4px;">
<div class="progress-bar @GetBarClass(health.CpuUsagePercent)" style="width: @health.CpuUsagePercent%"></div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-6">
<div class="card text-center h-100 @GetBorderClass(health.MemoryUsagePercent)">
<div class="card-body py-3">
<div class="fs-2 fw-bold @GetTextClass(health.MemoryUsagePercent)">@health.MemoryUsagePercent<span class="fs-6">%</span></div>
<small class="text-muted">Memory Usage</small>
<div class="progress mt-2" style="height: 4px;">
<div class="progress-bar @GetBarClass(health.MemoryUsagePercent)" style="width: @health.MemoryUsagePercent%"></div>
</div>
</div>
</div>
</div>
</div>
}
@* ── Time-Series Graphs ── *@
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card shadow-sm h-100">
<div class="card-body">
<MetricChart Title="CPU Usage" DataPoints="cpuHistory" Color="#0d6efd" Unit="%" MaxValue="100" HeightPx="80" />
</div>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm h-100">
<div class="card-body">
<MetricChart Title="Memory Usage" DataPoints="memHistory" Color="#6f42c1" Unit="%" MaxValue="100" HeightPx="80" />
</div>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm h-100">
<div class="card-body">
<MetricChart Title="Network Receive" DataPoints="networkRxHistory" Color="#198754" Unit=" MB/s" HeightPx="80" />
</div>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm h-100">
<div class="card-body">
<MetricChart Title="Pod Count" DataPoints="podCountHistory" Color="#fd7e14" HeightPx="80" />
</div>
</div>
</div>
</div>
@* ── Per-Node Status ── *@
@if (health is not null && health.Nodes.Count > 0)
{
<div class="card shadow-sm mb-4">
<div class="card-header bg-white">
<i class="bi bi-hdd-rack me-2"></i><strong>Node Status</strong>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Node</th>
<th>Status</th>
<th style="min-width: 150px;">CPU</th>
<th style="min-width: 150px;">Memory</th>
</tr>
</thead>
<tbody>
@foreach (NodeHealthInfo node in health.Nodes)
{
<tr>
<td><code>@node.Name</code></td>
<td>
@if (node.Ready)
{
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Ready</span>
}
else
{
<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>NotReady</span>
}
</td>
<td>
<div class="d-flex align-items-center gap-2">
<div class="progress flex-grow-1" style="height: 8px;">
<div class="progress-bar @GetBarClass(node.CpuUsagePercent)"
style="width: @node.CpuUsagePercent%"></div>
</div>
<small class="fw-medium" style="min-width: 40px;">@node.CpuUsagePercent%</small>
</div>
</td>
<td>
<div class="d-flex align-items-center gap-2">
<div class="progress flex-grow-1" style="height: 8px;">
<div class="progress-bar @GetBarClass(node.MemoryUsagePercent)"
style="width: @node.MemoryUsagePercent%"></div>
</div>
<small class="fw-medium" style="min-width: 40px;">@node.MemoryUsagePercent%</small>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
@* ── Alerts ── *@
<AlertPanel ClusterId="ClusterId" PrometheusService="PrometheusService" />
}
</div>
@code {
[Parameter] public Guid ClusterId { get; set; }
private ClusterHealthSummary? health;
private List<TimeSeriesDataPoint> cpuHistory = [];
private List<TimeSeriesDataPoint> memHistory = [];
private List<TimeSeriesDataPoint> networkRxHistory = [];
private List<TimeSeriesDataPoint> podCountHistory = [];
private bool loading;
private string? errorMessage;
private int timeRange = 60; // minutes
protected override async Task OnInitializedAsync()
{
await LoadAll();
}
private async Task LoadAll()
{
loading = true;
errorMessage = null;
StateHasChanged();
// Load the instant health summary and time-series data in parallel.
Task healthTask = LoadHealth();
Task metricsTask = LoadMetrics();
await Task.WhenAll(healthTask, metricsTask);
loading = false;
}
private async Task LoadHealth()
{
KubernetesOperationResult<ClusterHealthSummary> result =
await PrometheusService.GetClusterHealthAsync(ClusterId);
if (result.IsSuccess)
{
health = result.Data;
}
else
{
errorMessage = result.Error;
}
}
private async Task LoadMetrics()
{
TimeSpan duration = TimeSpan.FromMinutes(timeRange);
// Query multiple range metrics in parallel.
Task<KubernetesOperationResult<List<PrometheusTimeSeries>>> cpuTask =
PrometheusService.GetMetricRangeAsync(ClusterId,
"100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)", duration);
Task<KubernetesOperationResult<List<PrometheusTimeSeries>>> memTask =
PrometheusService.GetMetricRangeAsync(ClusterId,
"100 - (avg(node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100)", duration);
Task<KubernetesOperationResult<List<PrometheusTimeSeries>>> netTask =
PrometheusService.GetMetricRangeAsync(ClusterId,
"sum(rate(node_network_receive_bytes_total[5m])) / 1024 / 1024", duration);
Task<KubernetesOperationResult<List<PrometheusTimeSeries>>> podTask =
PrometheusService.GetMetricRangeAsync(ClusterId,
"sum(kube_pod_status_phase{phase=\"Running\"})", duration);
await Task.WhenAll(cpuTask, memTask, netTask, podTask);
KubernetesOperationResult<List<PrometheusTimeSeries>> cpuResult = await cpuTask;
KubernetesOperationResult<List<PrometheusTimeSeries>> memResult = await memTask;
KubernetesOperationResult<List<PrometheusTimeSeries>> netResult = await netTask;
KubernetesOperationResult<List<PrometheusTimeSeries>> podResult = await podTask;
// Extract the first series from each result (these are aggregated single-line metrics).
cpuHistory = cpuResult.IsSuccess && cpuResult.Data!.Count > 0
? cpuResult.Data[0].DataPoints : [];
memHistory = memResult.IsSuccess && memResult.Data!.Count > 0
? memResult.Data[0].DataPoints : [];
networkRxHistory = netResult.IsSuccess && netResult.Data!.Count > 0
? netResult.Data[0].DataPoints : [];
podCountHistory = podResult.IsSuccess && podResult.Data!.Count > 0
? podResult.Data[0].DataPoints : [];
}
private static string GetBorderClass(double percent) => percent switch
{
>= 90 => "border-danger",
>= 70 => "border-warning",
_ => "border-success"
};
private static string GetTextClass(double percent) => percent switch
{
>= 90 => "text-danger",
>= 70 => "text-warning",
_ => "text-success"
};
private static string GetBarClass(double percent) => percent switch
{
>= 90 => "bg-danger",
>= 70 => "bg-warning",
_ => "bg-success"
};
}

View File

@@ -0,0 +1,169 @@
@using EntKube.Web.Data
@using EntKube.Web.Services
@using Microsoft.EntityFrameworkCore
@inject TenantService TenantService
@inject VaultService VaultService
<h4>Kubernetes Clusters</h4>
<p class="text-muted">Clusters registered in this tenant, each assigned to an environment.</p>
@if (environments is not null && environments.Count > 0)
{
<div class="row g-2 mb-3" style="max-width: 700px;">
<div class="col">
<input type="text" class="form-control" placeholder="Cluster name" @bind="newName" @bind:event="oninput" />
</div>
<div class="col">
<input type="text" class="form-control" placeholder="API server URL" @bind="newApiUrl" @bind:event="oninput" />
</div>
<div class="col-auto">
<select class="form-select" @bind="selectedEnvironmentId">
<option value="">Environment...</option>
@foreach (Data.Environment env in environments)
{
<option value="@env.Id">@env.Name</option>
}
</select>
</div>
<div class="col-auto">
<button class="btn btn-primary" @onclick="Create"
disabled="@(string.IsNullOrWhiteSpace(newName) || string.IsNullOrWhiteSpace(newApiUrl) || selectedEnvironmentId == Guid.Empty)">
Add
</button>
</div>
</div>
}
else
{
<div class="alert alert-info">Create at least one environment before adding clusters.</div>
}
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="text-danger mb-2">@errorMessage</div>
}
@if (clusters is null)
{
<p><em>Loading...</em></p>
}
else if (clusters.Count == 0)
{
<p class="text-muted">No clusters registered yet.</p>
}
else
{
<table class="table table-sm table-hover">
<thead>
<tr>
<th>Name</th>
<th>Environment</th>
<th>API Server</th>
<th style="width: 280px;"></th>
</tr>
</thead>
<tbody>
@foreach (KubernetesCluster cluster in clusters)
{
<tr>
<td>@cluster.Name</td>
<td><span class="badge bg-secondary">@cluster.Environment.Name</span></td>
<td class="text-truncate" style="max-width: 300px;">@cluster.ApiServerUrl</td>
<td>
<button class="btn btn-sm btn-outline-success me-1" @onclick="() => ToggleHealth(cluster.Id)">
<i class="bi bi-heart-pulse"></i> Health
</button>
<button class="btn btn-sm btn-outline-primary me-1" @onclick="() => ToggleComponents(cluster.Id)">
Components
</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => Delete(cluster.Id)">Delete</button>
</td>
</tr>
@if (expandedClusterId == cluster.Id)
{
<tr>
<td colspan="4">
<ComponentPanel ClusterId="cluster.Id" TenantId="TenantId" OnClose="() => expandedClusterId = null" />
</td>
</tr>
}
@if (healthClusterId == cluster.Id)
{
<tr>
<td colspan="4">
<ClusterHealthPanel ClusterId="cluster.Id" OnClose="() => healthClusterId = null" />
</td>
</tr>
}
}
</tbody>
</table>
}
@code {
[Parameter] public Guid TenantId { get; set; }
private List<KubernetesCluster>? clusters;
private List<Data.Environment>? environments;
private string newName = "";
private string newApiUrl = "";
private Guid selectedEnvironmentId;
private string? errorMessage;
private Guid? expandedClusterId;
private Guid? healthClusterId;
protected override async Task OnInitializedAsync()
{
await Load();
environments = await TenantService.GetEnvironmentsAsync(TenantId);
}
private async Task Load()
{
clusters = await TenantService.GetClustersAsync(TenantId);
}
private async Task Create()
{
errorMessage = null;
try
{
await TenantService.CreateClusterAsync(TenantId, selectedEnvironmentId, newName.Trim(), newApiUrl.Trim());
newName = "";
newApiUrl = "";
selectedEnvironmentId = Guid.Empty;
await Load();
}
catch (DbUpdateException)
{
errorMessage = "A cluster with that name already exists in this tenant.";
}
}
private void ToggleComponents(Guid clusterId)
{
expandedClusterId = expandedClusterId == clusterId ? null : clusterId;
if (expandedClusterId is not null)
{
healthClusterId = null;
}
}
private void ToggleHealth(Guid clusterId)
{
healthClusterId = healthClusterId == clusterId ? null : clusterId;
if (healthClusterId is not null)
{
expandedClusterId = null;
}
}
private async Task Delete(Guid id)
{
await TenantService.DeleteClusterAsync(id);
await Load();
}
}

View File

@@ -0,0 +1,293 @@
@using EntKube.Web.Data
@using EntKube.Web.Services
@using Microsoft.EntityFrameworkCore
@inject VaultService VaultService
<div class="card mt-2 mb-2">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Components</strong>
<button class="btn btn-sm btn-outline-secondary" @onclick="OnClose">Close</button>
</div>
<div class="card-body">
<div class="row g-2 mb-3" style="max-width: 500px;">
<div class="col">
<input type="text" class="form-control form-control-sm" placeholder="Component name"
@bind="newComponentName" @bind:event="oninput" />
</div>
<div class="col-auto">
<select class="form-select form-select-sm" @bind="newComponentType">
<option value="HelmChart">Helm Chart</option>
<option value="Deployment">Deployment</option>
<option value="StatefulSet">StatefulSet</option>
<option value="Operator">Operator</option>
</select>
</div>
<div class="col-auto">
<button class="btn btn-sm btn-primary" @onclick="AddComponent"
disabled="@string.IsNullOrWhiteSpace(newComponentName)">
Add
</button>
</div>
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="text-danger mb-2">@errorMessage</div>
}
@if (components is null)
{
<p><em>Loading...</em></p>
}
else if (components.Count == 0)
{
<p class="text-muted">No components deployed to this cluster yet.</p>
}
else
{
<table class="table table-sm">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th style="width: 200px;"></th>
</tr>
</thead>
<tbody>
@foreach (ClusterComponent component in components)
{
<tr>
<td><strong>@component.Name</strong></td>
<td><span class="badge bg-info text-dark">@component.ComponentType</span></td>
<td>
<button class="btn btn-sm btn-outline-warning me-1" @onclick="() => ToggleSecrets(component.Id)">
Secrets
</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteComponent(component.Id)">
Delete
</button>
</td>
</tr>
@if (expandedComponentId == component.Id)
{
<tr>
<td colspan="3">
<div class="p-2 bg-light rounded">
<div class="row g-2 mb-2" style="max-width: 600px;">
<div class="col-4">
<input type="text" class="form-control form-control-sm"
placeholder="Secret name" @bind="newSecretName" @bind:event="oninput" />
</div>
<div class="col-5">
<input type="password" class="form-control form-control-sm"
placeholder="Secret value" @bind="newSecretValue" @bind:event="oninput" />
</div>
<div class="col-auto">
<button class="btn btn-sm btn-primary" @onclick="() => AddSecret(component.Id)"
disabled="@(string.IsNullOrWhiteSpace(newSecretName) || string.IsNullOrWhiteSpace(newSecretValue))">
Set
</button>
</div>
</div>
@if (componentSecrets is not null && componentSecrets.Count > 0)
{
<table class="table table-sm table-bordered mb-0">
<thead>
<tr>
<th>Name</th>
<th>K8s Sync</th>
<th>Target Secret</th>
<th style="width: 180px;"></th>
</tr>
</thead>
<tbody>
@foreach (VaultSecret secret in componentSecrets)
{
<tr>
<td><code>@secret.Name</code></td>
<td>
@if (secret.SyncToKubernetes)
{
<span class="badge bg-success">Synced</span>
}
else
{
<span class="badge bg-secondary">Off</span>
}
</td>
<td>@(secret.KubernetesSecretName ?? "—")</td>
<td>
<button class="btn btn-sm btn-outline-info me-1"
@onclick="() => ToggleSecretSync(secret)">
@(secret.SyncToKubernetes ? "Unsync" : "Sync")
</button>
<button class="btn btn-sm btn-outline-danger"
@onclick="() => DeleteSecret(secret.Id, component.Id)">
Del
</button>
</td>
</tr>
@if (syncConfigSecretId == secret.Id)
{
<tr>
<td colspan="4">
<div class="row g-2 p-1">
<div class="col-4">
<input type="text" class="form-control form-control-sm"
placeholder="K8s Secret name" @bind="syncSecretName" />
</div>
<div class="col-4">
<input type="text" class="form-control form-control-sm"
placeholder="Namespace" @bind="syncNamespace" />
</div>
<div class="col-auto">
<button class="btn btn-sm btn-success" @onclick="() => SaveSync(component.Id)">Save</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => syncConfigSecretId = null">X</button>
</div>
</div>
</td>
</tr>
}
}
</tbody>
</table>
}
else
{
<p class="text-muted mb-0">No secrets for this component.</p>
}
</div>
</td>
</tr>
}
}
</tbody>
</table>
}
</div>
</div>
@code {
[Parameter] public Guid ClusterId { get; set; }
[Parameter] public Guid TenantId { get; set; }
[Parameter] public EventCallback OnClose { get; set; }
private List<ClusterComponent>? components;
private List<VaultSecret>? componentSecrets;
private string newComponentName = "";
private string newComponentType = "HelmChart";
private string? errorMessage;
private Guid? expandedComponentId;
private string newSecretName = "";
private string newSecretValue = "";
private Guid? syncConfigSecretId;
private string syncSecretName = "";
private string syncNamespace = "";
protected override async Task OnInitializedAsync()
{
await LoadComponents();
}
private async Task LoadComponents()
{
components = await VaultService.GetComponentsAsync(ClusterId);
}
private async Task AddComponent()
{
errorMessage = null;
try
{
await VaultService.CreateComponentAsync(ClusterId, newComponentName.Trim(), newComponentType);
newComponentName = "";
await LoadComponents();
}
catch (DbUpdateException)
{
errorMessage = "A component with that name already exists on this cluster.";
}
}
private async Task DeleteComponent(Guid componentId)
{
await VaultService.DeleteComponentAsync(componentId);
if (expandedComponentId == componentId)
{
expandedComponentId = null;
}
await LoadComponents();
}
private async Task ToggleSecrets(Guid componentId)
{
if (expandedComponentId == componentId)
{
expandedComponentId = null;
componentSecrets = null;
return;
}
expandedComponentId = componentId;
newSecretName = "";
newSecretValue = "";
// Ensure vault is initialized for this tenant.
await VaultService.InitializeVaultAsync(TenantId);
componentSecrets = await VaultService.GetComponentSecretsAsync(TenantId, componentId);
}
private async Task AddSecret(Guid componentId)
{
await VaultService.SetComponentSecretAsync(TenantId, componentId, newSecretName.Trim(), newSecretValue.Trim());
newSecretName = "";
newSecretValue = "";
componentSecrets = await VaultService.GetComponentSecretsAsync(TenantId, componentId);
}
private async Task DeleteSecret(Guid secretId, Guid componentId)
{
await VaultService.DeleteSecretAsync(secretId);
componentSecrets = await VaultService.GetComponentSecretsAsync(TenantId, componentId);
}
private void ToggleSecretSync(VaultSecret secret)
{
if (secret.SyncToKubernetes)
{
_ = DisableSync(secret);
}
else
{
syncConfigSecretId = secret.Id;
syncSecretName = secret.KubernetesSecretName ?? "";
syncNamespace = secret.KubernetesNamespace ?? "";
}
}
private async Task DisableSync(VaultSecret secret)
{
await VaultService.ConfigureKubernetesSyncAsync(secret.Id, syncEnabled: false, secretName: null, ns: null);
componentSecrets = await VaultService.GetComponentSecretsAsync(TenantId, expandedComponentId!.Value);
}
private async Task SaveSync(Guid componentId)
{
if (syncConfigSecretId is null)
{
return;
}
await VaultService.ConfigureKubernetesSyncAsync(
syncConfigSecretId.Value, syncEnabled: true, secretName: syncSecretName.Trim(), ns: syncNamespace.Trim());
syncConfigSecretId = null;
componentSecrets = await VaultService.GetComponentSecretsAsync(TenantId, componentId);
}
}

View File

@@ -0,0 +1,439 @@
@using EntKube.Web.Data
@using EntKube.Web.Services
@using Microsoft.EntityFrameworkCore
@inject TenantService TenantService
@inject CustomerAccessService CustomerAccessService
@* ===========================================================================
Three-level drill-down: Customers ▸ Apps ▸ App Detail
The user sees ONE level at a time — no nested panel clutter.
=========================================================================== *@
@if (selectedApp is not null)
{
@* ───────────── Level 3: App Detail ───────────── *@
<AppDetail App="selectedApp"
TenantId="TenantId"
CustomerName="@selectedCustomer!.Name"
OnBack="BackToApps"
OnDeleted="AppDeleted" />
}
else if (selectedCustomer is not null)
{
@* ───────────── Level 2: Apps for a customer ───────────── *@
<nav class="mb-3">
<button class="btn btn-sm btn-link text-decoration-none p-0" @onclick="BackToCustomers">
<i class="bi bi-arrow-left me-1"></i>All Customers
</button>
</nav>
<div class="d-flex align-items-center mb-3">
<i class="bi bi-person-circle fs-4 me-2 text-primary"></i>
<div>
<h4 class="mb-0">@selectedCustomer.Name</h4>
<small class="text-muted">Applications owned by this customer</small>
</div>
</div>
@* --- Add App --- *@
<div class="card border-0 shadow-sm mb-4">
<div class="card-body py-2">
<div class="input-group">
<span class="input-group-text bg-transparent border-end-0"><i class="bi bi-plus-circle"></i></span>
<input type="text" class="form-control border-start-0" placeholder="New app name (e.g. billing-api)"
@bind="newAppName" @bind:event="oninput"
@onkeydown="@(async (e) => { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newAppName)) await CreateApp(); })" />
<button class="btn btn-primary" @onclick="CreateApp" disabled="@string.IsNullOrWhiteSpace(newAppName)">
<i class="bi bi-plus-lg me-1"></i>Add App
</button>
</div>
@if (!string.IsNullOrEmpty(appError))
{
<div class="text-danger small mt-2"><i class="bi bi-exclamation-triangle me-1"></i>@appError</div>
}
</div>
</div>
@* --- App cards --- *@
@if (apps is null)
{
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</div>
}
else if (apps.Count == 0)
{
<div class="text-center py-5">
<i class="bi bi-app-indicator text-muted" style="font-size: 3rem;"></i>
<p class="text-muted mt-2 mb-0">No apps yet. Create one above.</p>
</div>
}
else
{
<div class="row row-cols-1 row-cols-md-2 g-3">
@foreach (Data.App app in apps)
{
<div class="col">
<div class="card shadow-sm h-100" role="button" @onclick="() => OpenApp(app)" style="cursor: pointer;">
<div class="card-body py-3">
<div class="d-flex align-items-start">
<i class="bi bi-app-indicator fs-4 me-3 text-primary"></i>
<div class="flex-grow-1 min-width-0">
<h6 class="fw-semibold mb-1">@app.Name</h6>
<div class="d-flex flex-wrap gap-1 mb-1">
@if (app.AppEnvironments.Any())
{
@foreach (AppEnvironment ae in app.AppEnvironments)
{
<span class="badge bg-primary bg-opacity-75">@ae.Environment.Name</span>
}
}
else
{
<small class="text-muted">No environments linked</small>
}
</div>
<small class="text-muted">Created @app.CreatedAt.ToString("MMM d, yyyy")</small>
</div>
<i class="bi bi-chevron-right text-muted"></i>
</div>
</div>
</div>
</div>
}
</div>
}
@* --- Portal Access Management --- *@
<div class="card shadow-sm mt-4">
<div class="card-header bg-white py-2">
<div class="d-flex align-items-center">
<i class="bi bi-shield-lock me-2 text-primary"></i>
<strong>Portal Access</strong>
</div>
<small class="text-muted">Grant users access to this customer's apps via the customer portal.</small>
</div>
<div class="card-body">
@* --- Grant access form --- *@
<div class="input-group mb-3">
<input type="email" class="form-control form-control-sm" placeholder="User email address"
@bind="grantEmail" @bind:event="oninput" />
<select class="form-select form-select-sm" style="max-width: 140px;" @bind="grantRole">
<option value="@CustomerAccessRole.Viewer">Viewer</option>
<option value="@CustomerAccessRole.Operator">Operator</option>
<option value="@CustomerAccessRole.Admin">Admin</option>
</select>
<button class="btn btn-sm btn-outline-primary" @onclick="GrantAccess"
disabled="@string.IsNullOrWhiteSpace(grantEmail)">
<i class="bi bi-plus me-1"></i>Grant
</button>
</div>
@if (!string.IsNullOrEmpty(accessError))
{
<div class="alert alert-danger py-1 small mb-2">@accessError</div>
}
@if (!string.IsNullOrEmpty(accessSuccess))
{
<div class="alert alert-success py-1 small mb-2">@accessSuccess</div>
}
@* --- Current access list --- *@
@if (customerAccesses is null)
{
<div class="text-center py-2">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</div>
}
else if (customerAccesses.Count == 0)
{
<p class="text-muted small mb-0">No users have portal access to this customer yet.</p>
}
else
{
@foreach (CustomerAccess access in customerAccesses)
{
<div class="d-flex align-items-center justify-content-between py-1 border-bottom">
<div class="small">
<i class="bi bi-person me-1"></i>
<span class="fw-medium">@access.User.Email</span>
<span class="badge @GetRoleBadgeClass(access.Role) ms-1">@access.Role</span>
</div>
<button class="btn btn-sm btn-outline-danger" title="Revoke access"
@onclick="() => RevokeAccess(access.UserId)">
<i class="bi bi-x"></i>
</button>
</div>
}
}
</div>
</div>
}
else
{
@* ───────────── Level 1: Customer list ───────────── *@
<div class="d-flex align-items-center mb-2">
<i class="bi bi-people fs-4 me-2 text-primary"></i>
<h4 class="mb-0">Customers</h4>
</div>
<p class="text-muted small mb-3">End-clients or accounts served by this tenant. Click a customer to manage their apps.</p>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body py-2">
<div class="input-group">
<span class="input-group-text bg-transparent border-end-0"><i class="bi bi-plus-circle"></i></span>
<input type="text" class="form-control border-start-0" placeholder="New customer name (e.g. Contoso Ltd)"
@bind="newName" @bind:event="oninput"
@onkeydown="@(async (e) => { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newName)) await Create(); })" />
<button class="btn btn-primary" @onclick="Create" disabled="@string.IsNullOrWhiteSpace(newName)">
<i class="bi bi-plus-lg me-1"></i>Add
</button>
</div>
</div>
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger alert-dismissible fade show py-2" role="alert">
<i class="bi bi-exclamation-triangle me-1"></i>@errorMessage
<button type="button" class="btn-close btn-close-sm" @onclick="() => errorMessage = null"></button>
</div>
}
@if (customers is null)
{
<div class="text-center py-4">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
<span class="ms-2 text-muted">Loading customers...</span>
</div>
}
else if (customers.Count == 0)
{
<div class="text-center py-5">
<i class="bi bi-people text-muted" style="font-size: 3rem;"></i>
<p class="text-muted mt-2 mb-0">No customers yet. Create one above to get started.</p>
</div>
}
else
{
<div class="list-group shadow-sm">
@foreach (Customer customer in customers)
{
<div class="list-group-item p-0">
@if (confirmDeleteId == customer.Id)
{
<div class="d-flex align-items-center justify-content-between px-3 py-2 bg-danger bg-opacity-10">
<span class="text-danger small"><i class="bi bi-exclamation-triangle me-1"></i>Delete <strong>@customer.Name</strong> and all apps?</span>
<div>
<button class="btn btn-sm btn-danger me-1" @onclick="() => Delete(customer.Id)">Yes, delete</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmDeleteId = null">Cancel</button>
</div>
</div>
}
else
{
<div class="d-flex align-items-center px-3 py-2">
<div class="flex-grow-1 d-flex align-items-center" role="button" @onclick="() => OpenCustomer(customer)" style="cursor: pointer;">
<i class="bi bi-person-circle me-2 text-primary"></i>
<div>
<span class="fw-semibold">@customer.Name</span>
<br />
<small class="text-muted">@(appCounts.GetValueOrDefault(customer.Id, 0)) app(s) · Created @customer.CreatedAt.ToString("MMM d, yyyy")</small>
</div>
</div>
<div class="d-flex align-items-center gap-1">
<button class="btn btn-sm btn-outline-danger" @onclick="() => confirmDeleteId = customer.Id"
@onclick:stopPropagation="true" title="Delete customer">
<i class="bi bi-trash"></i>
</button>
<i class="bi bi-chevron-right text-muted ms-2"></i>
</div>
</div>
}
</div>
}
</div>
}
}
@code {
[Parameter] public Guid TenantId { get; set; }
// --- Level 1: Customer list ---
private List<Customer>? customers;
private Dictionary<Guid, int> appCounts = new();
private string newName = "";
private string? errorMessage;
private Guid? confirmDeleteId;
// --- Level 2: Selected customer → apps ---
private Customer? selectedCustomer;
private List<Data.App>? apps;
private string newAppName = "";
private string? appError;
// --- Level 3: Selected app → detail ---
private Data.App? selectedApp;
// --- Portal access management ---
private List<CustomerAccess>? customerAccesses;
private string grantEmail = "";
private CustomerAccessRole grantRole = CustomerAccessRole.Viewer;
private string? accessError;
private string? accessSuccess;
protected override async Task OnInitializedAsync()
{
await LoadCustomers();
}
// ──────────────── Level 1 ────────────────
private async Task LoadCustomers()
{
customers = await TenantService.GetCustomersAsync(TenantId);
// Pre-load app counts for the list view.
appCounts = new Dictionary<Guid, int>();
foreach (Customer customer in customers)
{
List<Data.App> customerApps = await TenantService.GetAppsAsync(customer.Id);
appCounts[customer.Id] = customerApps.Count;
}
}
private async Task Create()
{
errorMessage = null;
try
{
await TenantService.CreateCustomerAsync(TenantId, newName.Trim());
newName = "";
await LoadCustomers();
}
catch (DbUpdateException)
{
errorMessage = "A customer with that name already exists.";
}
}
private async Task Delete(Guid id)
{
confirmDeleteId = null;
await TenantService.DeleteCustomerAsync(id);
await LoadCustomers();
}
private async Task OpenCustomer(Customer customer)
{
selectedCustomer = customer;
selectedApp = null;
appError = null;
newAppName = "";
accessError = null;
accessSuccess = null;
await LoadApps();
await LoadAccesses();
}
private async Task BackToCustomers()
{
selectedCustomer = null;
selectedApp = null;
apps = null;
await LoadCustomers();
}
// ──────────────── Level 2 ────────────────
private async Task LoadApps()
{
apps = await TenantService.GetAppsAsync(selectedCustomer!.Id);
}
private async Task CreateApp()
{
appError = null;
try
{
await TenantService.CreateAppAsync(selectedCustomer!.Id, newAppName.Trim());
newAppName = "";
await LoadApps();
}
catch (DbUpdateException)
{
appError = "An app with that name already exists for this customer.";
}
}
private void OpenApp(Data.App app)
{
selectedApp = app;
}
private async Task BackToApps()
{
selectedApp = null;
await LoadApps();
}
private async Task AppDeleted()
{
selectedApp = null;
await LoadApps();
}
// ──────────────── Portal Access ────────────────
private async Task LoadAccesses()
{
customerAccesses = await CustomerAccessService.GetCustomerUsersAsync(selectedCustomer!.Id);
}
private async Task GrantAccess()
{
accessError = null;
accessSuccess = null;
// Look up the user by email.
ApplicationUser? user = await TenantService.FindUserByEmailAsync(grantEmail.Trim());
if (user is null)
{
accessError = $"No user found with email '{grantEmail}'.";
return;
}
try
{
await CustomerAccessService.GrantAccessAsync(user.Id, selectedCustomer!.Id, grantRole);
accessSuccess = $"Access granted to {grantEmail}.";
grantEmail = "";
await LoadAccesses();
}
catch (Exception)
{
accessError = $"User '{grantEmail}' already has access to this customer.";
}
}
private async Task RevokeAccess(string userId)
{
await CustomerAccessService.RevokeAccessAsync(userId, selectedCustomer!.Id);
await LoadAccesses();
}
private static string GetRoleBadgeClass(CustomerAccessRole role) => role switch
{
CustomerAccessRole.Admin => "bg-danger",
CustomerAccessRole.Operator => "bg-warning text-dark",
CustomerAccessRole.Viewer => "bg-info",
_ => "bg-secondary"
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,447 @@
@using EntKube.Web.Data
@using EntKube.Web.Services
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@inject TenantService TenantService
@inject VaultService VaultService
@* ===========================================================================
Three-level drill-down: Environments ▸ Clusters ▸ Cluster Detail
The user sees ONE level at a time — no nested accordion clutter.
=========================================================================== *@
@if (selectedCluster is not null)
{
@* ───────────── Level 3: Cluster Detail ───────────── *@
<ClusterDetail Cluster="selectedCluster"
TenantId="TenantId"
OnBack="BackToClusters"
OnDeleted="() => ClusterDeleted()" />
}
else if (selectedEnv is not null)
{
@* ───────────── Level 2: Clusters in an environment ───────────── *@
@* Breadcrumb back to environments *@
<nav class="mb-3">
<button class="btn btn-sm btn-link text-decoration-none p-0" @onclick="BackToEnvironments">
<i class="bi bi-arrow-left me-1"></i>All Environments
</button>
</nav>
<div class="d-flex align-items-center mb-3">
<i class="bi bi-box fs-4 me-2 text-primary"></i>
<div>
<h4 class="mb-0">@selectedEnv.Name</h4>
<small class="text-muted">Clusters registered in this environment</small>
</div>
</div>
@* --- Add Cluster (kubeconfig) --- *@
@if (showAddCluster)
{
<div class="card shadow-sm mb-4">
<div class="card-header bg-white d-flex align-items-center justify-content-between py-2">
<span><i class="bi bi-plus-circle me-2 text-success"></i><strong>Register a Cluster</strong></span>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => { showAddCluster = false; ResetClusterForm(); }">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="card-body">
@if (parsedContexts is null || parsedContexts.Count == 0)
{
<p class="text-muted small mb-2">Paste your kubeconfig YAML or upload a file to auto-detect clusters and API server URLs.</p>
<textarea class="form-control mb-2" rows="5"
placeholder="apiVersion: v1&#10;kind: Config&#10;clusters:&#10; - cluster:&#10; server: https://..."
@bind="kubeconfigInput" @bind:event="oninput"></textarea>
<div class="d-flex align-items-center gap-2">
<label class="btn btn-sm btn-outline-secondary mb-0">
<i class="bi bi-upload me-1"></i>Upload file
<InputFile OnChange="HandleFileUpload" class="d-none" accept=".yaml,.yml,.conf,.config" />
</label>
<button class="btn btn-sm btn-primary"
@onclick="ParseKubeconfig"
disabled="@string.IsNullOrWhiteSpace(kubeconfigInput)">
<i class="bi bi-search me-1"></i>Parse Contexts
</button>
</div>
}
else
{
<p class="text-muted small mb-2">Found @parsedContexts.Count context(s). Select one to register:</p>
<div class="list-group mb-3">
@foreach (KubeconfigContext ctx in parsedContexts)
{
<label class="list-group-item list-group-item-action d-flex align-items-center gap-2 py-2 @(selectedContext == ctx.Name ? "active" : "")">
<input class="form-check-input mt-0" type="radio" name="contextSelect"
checked="@(selectedContext == ctx.Name)"
@onchange="() => SelectContext(ctx)" />
<div class="flex-grow-1">
<span class="fw-medium @(selectedContext == ctx.Name ? "text-white" : "")">@ctx.Name</span>
<br />
<small class="@(selectedContext == ctx.Name ? "text-white-50" : "text-muted")">@ctx.ClusterServer</small>
</div>
@if (ctx.IsCurrent)
{
<span class="badge @(selectedContext == ctx.Name ? "bg-white text-primary" : "bg-info")">current</span>
}
</label>
}
</div>
<div class="row g-2 align-items-end">
<div class="col" style="min-width: 200px; max-width: 300px;">
<label class="form-label small mb-1">Cluster display name</label>
<input type="text" class="form-control form-control-sm"
placeholder="e.g. production-eu-west"
@bind="newClusterName" @bind:event="oninput" />
</div>
<div class="col-auto">
<button class="btn btn-sm btn-success" @onclick="CreateCluster"
disabled="@(string.IsNullOrWhiteSpace(newClusterName) || string.IsNullOrWhiteSpace(selectedContext))">
<i class="bi bi-check-lg me-1"></i>Register
</button>
</div>
</div>
}
@if (!string.IsNullOrEmpty(clusterError))
{
<div class="alert alert-danger mt-2 mb-0 py-1 small">
<i class="bi bi-exclamation-triangle me-1"></i>@clusterError
</div>
}
</div>
</div>
}
else
{
<button class="btn btn-primary btn-sm mb-3" @onclick="() => showAddCluster = true">
<i class="bi bi-plus-lg me-1"></i>Register Cluster
</button>
}
@* --- Cluster Cards --- *@
@if (envClusters is null)
{
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</div>
}
else if (envClusters.Count == 0)
{
<div class="text-center py-5">
<i class="bi bi-hdd-rack text-muted" style="font-size: 3rem;"></i>
<p class="text-muted mt-2 mb-0">No clusters registered yet.</p>
</div>
}
else
{
<div class="row row-cols-1 row-cols-md-2 g-3">
@foreach (KubernetesCluster cluster in envClusters)
{
<div class="col">
<div class="card shadow-sm h-100 cluster-card" role="button" @onclick="() => OpenCluster(cluster)" style="cursor: pointer;">
<div class="card-body py-3">
<div class="d-flex align-items-start">
<i class="bi bi-hdd-rack fs-4 me-3 text-primary"></i>
<div class="flex-grow-1 min-width-0">
<h6 class="fw-semibold mb-1">@cluster.Name</h6>
@if (!string.IsNullOrEmpty(cluster.ContextName))
{
<div class="mb-1">
<small class="text-muted"><i class="bi bi-terminal me-1"></i><code>@cluster.ContextName</code></small>
</div>
}
<small class="text-muted text-truncate d-block" style="max-width: 280px;">
<i class="bi bi-globe me-1"></i>@cluster.ApiServerUrl
</small>
</div>
<i class="bi bi-chevron-right text-muted"></i>
</div>
</div>
</div>
</div>
}
</div>
}
}
else
{
@* ───────────── Level 1: Environment list ───────────── *@
<div class="d-flex align-items-center mb-2">
<i class="bi bi-layers fs-4 me-2 text-primary"></i>
<h4 class="mb-0">Environments</h4>
</div>
<p class="text-muted small mb-3">Deployment stages for this tenant. Click an environment to manage its clusters.</p>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body py-2">
<div class="input-group">
<span class="input-group-text bg-transparent border-end-0"><i class="bi bi-plus-circle"></i></span>
<input type="text" class="form-control border-start-0" placeholder="New environment name (e.g. Production)"
@bind="newName" @bind:event="oninput"
@onkeydown="@(async (e) => { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newName)) await Create(); })" />
<button class="btn btn-primary" @onclick="Create" disabled="@string.IsNullOrWhiteSpace(newName)">
<i class="bi bi-plus-lg me-1"></i>Add
</button>
</div>
</div>
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger alert-dismissible fade show py-2" role="alert">
<i class="bi bi-exclamation-triangle me-1"></i>@errorMessage
<button type="button" class="btn-close btn-close-sm" @onclick="() => errorMessage = null"></button>
</div>
}
@if (environments is null)
{
<div class="text-center py-4">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
<span class="ms-2 text-muted">Loading environments...</span>
</div>
}
else if (environments.Count == 0)
{
<div class="text-center py-5">
<i class="bi bi-layers text-muted" style="font-size: 3rem;"></i>
<p class="text-muted mt-2 mb-0">No environments yet. Create one above to get started.</p>
</div>
}
else
{
<div class="list-group shadow-sm">
@foreach (Data.Environment env in environments)
{
<div class="list-group-item p-0">
@if (confirmDeleteEnvId == env.Id)
{
<div class="d-flex align-items-center justify-content-between px-3 py-2 bg-danger bg-opacity-10">
<span class="text-danger small"><i class="bi bi-exclamation-triangle me-1"></i>Delete <strong>@env.Name</strong> and all its clusters?</span>
<div>
<button class="btn btn-sm btn-danger me-1" @onclick="() => Delete(env.Id)">Yes, delete</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmDeleteEnvId = null">Cancel</button>
</div>
</div>
}
else
{
<div class="d-flex align-items-center px-3 py-2">
<div class="flex-grow-1 d-flex align-items-center" role="button" @onclick="() => OpenEnvironment(env)" style="cursor: pointer;">
<i class="bi bi-box me-2 text-primary"></i>
<div>
<span class="fw-semibold">@env.Name</span>
<br />
<small class="text-muted">@(clusterCounts.GetValueOrDefault(env.Id, 0)) cluster(s)</small>
</div>
</div>
<div class="d-flex align-items-center gap-1">
<button class="btn btn-sm btn-outline-danger" @onclick="() => confirmDeleteEnvId = env.Id"
@onclick:stopPropagation="true" title="Delete environment">
<i class="bi bi-trash"></i>
</button>
<i class="bi bi-chevron-right text-muted ms-2"></i>
</div>
</div>
}
</div>
}
</div>
}
}
@code {
[Parameter] public Guid TenantId { get; set; }
// --- Level 1: Environment list ---
private List<Data.Environment>? environments;
private Dictionary<Guid, int> clusterCounts = new();
private string newName = "";
private string? errorMessage;
private Guid? confirmDeleteEnvId;
// --- Level 2: Selected environment → clusters ---
private Data.Environment? selectedEnv;
private List<KubernetesCluster>? envClusters;
private bool showAddCluster;
// --- Level 3: Selected cluster → detail ---
private KubernetesCluster? selectedCluster;
// --- Kubeconfig parsing ---
private string kubeconfigInput = "";
private List<KubeconfigContext>? parsedContexts;
private string? selectedContext;
private string? selectedServerUrl;
private string newClusterName = "";
private string? clusterError;
protected override async Task OnInitializedAsync()
{
await LoadEnvironments();
}
// ──────────────── Level 1 ────────────────
private async Task LoadEnvironments()
{
environments = await TenantService.GetEnvironmentsAsync(TenantId);
// Pre-load cluster counts so the list shows "N cluster(s)" without expanding.
List<KubernetesCluster> allClusters = await TenantService.GetClustersAsync(TenantId);
clusterCounts = allClusters.GroupBy(c => c.EnvironmentId).ToDictionary(g => g.Key, g => g.Count());
}
private async Task Create()
{
errorMessage = null;
try
{
await TenantService.CreateEnvironmentAsync(TenantId, newName.Trim());
newName = "";
await LoadEnvironments();
}
catch (DbUpdateException)
{
errorMessage = "An environment with that name already exists.";
}
}
private async Task Delete(Guid id)
{
confirmDeleteEnvId = null;
await TenantService.DeleteEnvironmentAsync(id);
await LoadEnvironments();
}
private async Task OpenEnvironment(Data.Environment env)
{
selectedEnv = env;
selectedCluster = null;
showAddCluster = false;
ResetClusterForm();
await LoadClusters();
}
private async Task BackToEnvironments()
{
selectedEnv = null;
selectedCluster = null;
envClusters = null;
await LoadEnvironments();
}
// ──────────────── Level 2 ────────────────
private async Task LoadClusters()
{
List<KubernetesCluster> allClusters = await TenantService.GetClustersAsync(TenantId);
envClusters = allClusters.Where(c => c.EnvironmentId == selectedEnv!.Id).ToList();
}
private void OpenCluster(KubernetesCluster cluster)
{
selectedCluster = cluster;
}
private async Task BackToClusters()
{
selectedCluster = null;
await LoadClusters();
}
private async Task ClusterDeleted()
{
selectedCluster = null;
await LoadClusters();
}
// --- Kubeconfig ---
private void ParseKubeconfig()
{
clusterError = null;
parsedContexts = KubeconfigParser.ParseContexts(kubeconfigInput);
if (parsedContexts.Count == 0)
{
clusterError = "No valid contexts found in the kubeconfig. Check the YAML format.";
return;
}
KubeconfigContext? defaultCtx = parsedContexts.FirstOrDefault(c => c.IsCurrent)
?? parsedContexts.First();
SelectContext(defaultCtx);
}
private async Task HandleFileUpload(InputFileChangeEventArgs e)
{
IBrowserFile file = e.File;
if (file.Size > 1_048_576)
{
clusterError = "File too large. Kubeconfig files should be under 1 MB.";
return;
}
using StreamReader reader = new(file.OpenReadStream(maxAllowedSize: 1_048_576));
kubeconfigInput = await reader.ReadToEndAsync();
ParseKubeconfig();
}
private void SelectContext(KubeconfigContext ctx)
{
selectedContext = ctx.Name;
selectedServerUrl = ctx.ClusterServer;
if (string.IsNullOrWhiteSpace(newClusterName))
{
newClusterName = ctx.Name;
}
}
private void ResetClusterForm()
{
kubeconfigInput = "";
parsedContexts = null;
selectedContext = null;
selectedServerUrl = null;
newClusterName = "";
clusterError = null;
}
private async Task CreateCluster()
{
clusterError = null;
if (string.IsNullOrWhiteSpace(selectedServerUrl))
{
clusterError = "No context selected.";
return;
}
try
{
await TenantService.CreateClusterAsync(
TenantId, selectedEnv!.Id, newClusterName.Trim(), selectedServerUrl,
contextName: selectedContext, kubeconfig: kubeconfigInput);
showAddCluster = false;
ResetClusterForm();
await LoadClusters();
}
catch (DbUpdateException)
{
clusterError = "A cluster with that name already exists.";
}
}
}

View File

@@ -0,0 +1,134 @@
@using EntKube.Web.Data
@using EntKube.Web.Services
@using Microsoft.EntityFrameworkCore
@inject TenantService TenantService
<div class="d-flex align-items-center mb-2">
<i class="bi bi-diagram-3 fs-4 me-2 text-primary"></i>
<h4 class="mb-0">Groups</h4>
</div>
<p class="text-muted small mb-3">Organize users within this tenant into logical groups for access control and team management.</p>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body py-2">
<div class="input-group">
<span class="input-group-text bg-transparent border-end-0"><i class="bi bi-plus-circle"></i></span>
<input type="text" class="form-control border-start-0" placeholder="New group name (e.g. Developers)"
@bind="newName" @bind:event="oninput"
@onkeydown="@(async (e) => { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newName)) await Create(); })" />
<button class="btn btn-primary" @onclick="Create" disabled="@string.IsNullOrWhiteSpace(newName)">
<i class="bi bi-plus-lg me-1"></i>Add
</button>
</div>
</div>
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger alert-dismissible fade show py-2" role="alert">
<i class="bi bi-exclamation-triangle me-1"></i>@errorMessage
<button type="button" class="btn-close btn-close-sm" @onclick="() => errorMessage = null"></button>
</div>
}
@if (groups is null)
{
<div class="text-center py-4">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
<span class="ms-2 text-muted">Loading groups...</span>
</div>
}
else if (groups.Count == 0)
{
<div class="text-center py-5">
<i class="bi bi-diagram-3 text-muted" style="font-size: 3rem;"></i>
<p class="text-muted mt-2 mb-0">No groups yet. Create one above to get started.</p>
</div>
}
else
{
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
@foreach (Group group in groups)
{
<div class="col">
<div class="card shadow-sm h-100">
<div class="card-body py-3">
<div class="d-flex align-items-start justify-content-between">
<div>
<h6 class="fw-semibold mb-1">
<i class="bi bi-people-fill me-1 text-primary"></i>@group.Name
</h6>
<div class="d-flex align-items-center gap-3">
<small class="text-muted">
<i class="bi bi-person me-1"></i>@group.Memberships.Count member(s)
</small>
<small class="text-muted">
<i class="bi bi-calendar me-1"></i>@group.CreatedAt.ToString("MMM d, yyyy")
</small>
</div>
</div>
<button class="btn btn-sm btn-outline-danger" @onclick="() => confirmDeleteId = group.Id" title="Delete group">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
@if (confirmDeleteId == group.Id)
{
<div class="card-footer py-2 bg-danger bg-opacity-10">
<div class="d-flex align-items-center justify-content-between">
<small class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>Delete <strong>@group.Name</strong>?</small>
<div>
<button class="btn btn-sm btn-danger me-1" @onclick="() => Delete(group.Id)">Delete</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmDeleteId = null">Cancel</button>
</div>
</div>
</div>
}
</div>
</div>
}
</div>
}
@code {
[Parameter] public Guid TenantId { get; set; }
private List<Group>? groups;
private string newName = "";
private string? errorMessage;
private Guid? confirmDeleteId;
protected override async Task OnInitializedAsync()
{
await Load();
}
private async Task Load()
{
groups = await TenantService.GetGroupsAsync(TenantId);
}
private async Task Create()
{
errorMessage = null;
try
{
await TenantService.CreateGroupAsync(TenantId, newName.Trim());
newName = "";
await Load();
}
catch (DbUpdateException)
{
errorMessage = "A group with that name already exists.";
}
}
private async Task Delete(Guid id)
{
confirmDeleteId = null;
await TenantService.DeleteGroupAsync(id);
await Load();
}
}

View File

@@ -0,0 +1,159 @@
@using EntKube.Web.Services
@* ═══════════════════════════════════════════════════════════════════════════
MetricChart — renders a time-series as an SVG area chart with gradient fill.
Purely server-rendered, no JS dependencies. Responsive via viewBox.
═══════════════════════════════════════════════════════════════════════════ *@
<div class="metric-chart-container">
@if (!string.IsNullOrEmpty(Title))
{
<div class="d-flex justify-content-between align-items-center mb-1">
<small class="text-muted fw-medium">@Title</small>
@if (DataPoints.Count > 0)
{
<small class="fw-bold @GetValueColorClass()">@CurrentValue</small>
}
</div>
}
@if (DataPoints.Count >= 2)
{
<svg viewBox="0 0 @Width @Height" preserveAspectRatio="none"
style="width: 100%; height: @(HeightPx)px; display: block;">
<defs>
<linearGradient id="@GradientId" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color: @Color; stop-opacity: 0.3;" />
<stop offset="100%" style="stop-color: @Color; stop-opacity: 0.02;" />
</linearGradient>
</defs>
@* Area fill below the line *@
<path d="@AreaPath" fill="url(#@GradientId)" />
@* The line itself *@
<polyline points="@LinePath" fill="none" stroke="@Color" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
}
else
{
<div class="text-center py-2">
<small class="text-muted">Insufficient data</small>
</div>
}
</div>
@code {
[Parameter] public string? Title { get; set; }
[Parameter] public List<TimeSeriesDataPoint> DataPoints { get; set; } = [];
[Parameter] public string Color { get; set; } = "#0d6efd";
[Parameter] public string? Unit { get; set; }
[Parameter] public int HeightPx { get; set; } = 60;
[Parameter] public double? MaxValue { get; set; }
private const int Width = 300;
private const int Height = 80;
private const int Padding = 2;
private string GradientId => $"grad-{Title?.GetHashCode():x}";
private string CurrentValue
{
get
{
if (DataPoints.Count == 0)
{
return "";
}
double last = DataPoints[^1].Value;
string formatted = last >= 100 ? last.ToString("F0") : last.ToString("F1");
return Unit is not null ? $"{formatted}{Unit}" : formatted;
}
}
private string LinePath
{
get
{
if (DataPoints.Count < 2)
{
return "";
}
double minVal = 0;
double maxVal = MaxValue ?? DataPoints.Max(d => d.Value);
if (maxVal <= minVal)
{
maxVal = minVal + 1;
}
double xStep = (double)(Width - 2 * Padding) / (DataPoints.Count - 1);
List<string> points = [];
for (int i = 0; i < DataPoints.Count; i++)
{
double x = Padding + i * xStep;
double y = Height - Padding - ((DataPoints[i].Value - minVal) / (maxVal - minVal) * (Height - 2 * Padding));
points.Add($"{x:F1},{y:F1}");
}
return string.Join(" ", points);
}
}
private string AreaPath
{
get
{
if (DataPoints.Count < 2)
{
return "";
}
double minVal = 0;
double maxVal = MaxValue ?? DataPoints.Max(d => d.Value);
if (maxVal <= minVal)
{
maxVal = minVal + 1;
}
double xStep = (double)(Width - 2 * Padding) / (DataPoints.Count - 1);
// Start at the bottom-left corner.
List<string> pathParts = [$"M {Padding:F1},{Height - Padding:F1}"];
// Line to first data point.
for (int i = 0; i < DataPoints.Count; i++)
{
double x = Padding + i * xStep;
double y = Height - Padding - ((DataPoints[i].Value - minVal) / (maxVal - minVal) * (Height - 2 * Padding));
pathParts.Add($"L {x:F1},{y:F1}");
}
// Close back to bottom-right.
double lastX = Padding + (DataPoints.Count - 1) * xStep;
pathParts.Add($"L {lastX:F1},{Height - Padding:F1}");
pathParts.Add("Z");
return string.Join(" ", pathParts);
}
}
private string GetValueColorClass()
{
if (DataPoints.Count == 0)
{
return "";
}
double last = DataPoints[^1].Value;
return last switch
{
>= 90 => "text-danger",
>= 70 => "text-warning",
_ => "text-success"
};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,108 @@
@page "/tenants/{Slug}"
@using EntKube.Web.Data
@using EntKube.Web.Services
@using Microsoft.EntityFrameworkCore
@rendermode InteractiveServer
@inject TenantService TenantService
@inject NavigationManager Navigation
<PageTitle>@(tenant?.Name ?? "Tenant")</PageTitle>
@if (tenant is null)
{
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<p class="text-muted mt-2">Loading tenant...</p>
</div>
}
else
{
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb small">
<li class="breadcrumb-item"><a href="/tenants" class="text-decoration-none"><i class="bi bi-building me-1"></i>Tenants</a></li>
<li class="breadcrumb-item active">@tenant.Name</li>
</ol>
</nav>
<div class="d-flex align-items-center mb-4">
<i class="bi bi-building fs-3 me-2 text-primary"></i>
<div>
<h2 class="mb-0">@tenant.Name</h2>
<small class="text-muted"><code>@tenant.Slug</code></small>
</div>
</div>
<ul class="nav nav-pills mb-4 gap-1">
<li class="nav-item">
<button class="nav-link @(activeTab == "environments" ? "active" : "")" @onclick='() => activeTab = "environments"'>
<i class="bi bi-layers me-1"></i>Environments
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "customers" ? "active" : "")" @onclick='() => activeTab = "customers"'>
<i class="bi bi-people me-1"></i>Customers
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "vault" ? "active" : "")" @onclick='() => activeTab = "vault"'>
<i class="bi bi-shield-lock me-1"></i>Vault
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "groups" ? "active" : "")" @onclick='() => activeTab = "groups"'>
<i class="bi bi-diagram-3 me-1"></i>Groups
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "databases" ? "active" : "")" @onclick='() => activeTab = "databases"'>
<i class="bi bi-database me-1"></i>Databases
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "storage" ? "active" : "")" @onclick='() => activeTab = "storage"'>
<i class="bi bi-bucket me-1"></i>Storage
</button>
</li>
</ul>
<div class="tab-content">
@switch (activeTab)
{
case "environments":
<EnvironmentTab TenantId="tenant.Id" />
break;
case "customers":
<CustomerTab TenantId="tenant.Id" />
break;
case "vault":
<VaultTab TenantId="tenant.Id" />
break;
case "groups":
<GroupTab TenantId="tenant.Id" />
break;
case "databases":
<DatabaseTab TenantId="tenant.Id" />
break;
case "storage":
<StorageTab TenantId="tenant.Id" />
break;
}
</div>
}
@code {
[Parameter] public string Slug { get; set; } = "";
private Tenant? tenant;
private string activeTab = "environments";
protected override async Task OnInitializedAsync()
{
tenant = await TenantService.GetTenantBySlugAsync(Slug);
if (tenant is null)
{
Navigation.NavigateTo("/tenants");
}
}
}

View File

@@ -0,0 +1,137 @@
@page "/tenants"
@using EntKube.Web.Data
@using EntKube.Web.Services
@using Microsoft.EntityFrameworkCore
@rendermode InteractiveServer
@inject TenantService TenantService
@inject NavigationManager Navigation
<PageTitle>Tenants</PageTitle>
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h1 class="mb-0"><i class="bi bi-building me-2 text-primary"></i>Tenants</h1>
<p class="text-muted small mb-0 mt-1">Organizations and workspaces in your platform.</p>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body py-2">
<div class="input-group">
<span class="input-group-text bg-transparent border-end-0"><i class="bi bi-plus-circle"></i></span>
<input type="text" class="form-control border-start-0" placeholder="New tenant name (e.g. Acme Corp)"
@bind="newTenantName" @bind:event="oninput"
@onkeydown="@(async (e) => { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newTenantName)) await CreateTenant(); })" />
<button class="btn btn-primary" @onclick="CreateTenant" disabled="@string.IsNullOrWhiteSpace(newTenantName)">
<i class="bi bi-plus-lg me-1"></i>Create Tenant
</button>
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="text-danger small mt-2"><i class="bi bi-exclamation-triangle me-1"></i>@errorMessage</div>
}
</div>
</div>
@if (tenants is null)
{
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<p class="text-muted mt-2">Loading tenants...</p>
</div>
}
else if (tenants.Count == 0)
{
<div class="text-center py-5">
<i class="bi bi-building text-muted" style="font-size: 4rem;"></i>
<h5 class="text-muted mt-3">No tenants yet</h5>
<p class="text-muted">Create your first tenant above to get started.</p>
</div>
}
else
{
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
@foreach (Tenant tenant in tenants)
{
<div class="col">
<div class="card h-100 shadow-sm border-0 tenant-card">
<div class="card-body">
<div class="d-flex align-items-start justify-content-between">
<div>
<h5 class="card-title mb-1">
<i class="bi bi-building me-1 text-primary"></i>@tenant.Name
</h5>
<p class="card-text text-muted small mb-0">
<code>@tenant.Slug</code>
</p>
</div>
<button class="btn btn-sm btn-outline-danger" @onclick="() => confirmDeleteId = tenant.Id"
title="Delete tenant">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
@if (confirmDeleteId == tenant.Id)
{
<div class="card-body py-2 border-top bg-danger bg-opacity-10">
<div class="d-flex align-items-center justify-content-between">
<small class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>Delete this tenant?</small>
<div>
<button class="btn btn-sm btn-danger me-1" @onclick="() => DeleteTenant(tenant.Id)">Delete</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmDeleteId = null">Cancel</button>
</div>
</div>
</div>
}
<div class="card-footer bg-transparent border-top-0 pt-0">
<a href="/tenants/@tenant.Slug" class="btn btn-primary btn-sm w-100">
<i class="bi bi-gear me-1"></i>Manage
</a>
</div>
</div>
</div>
}
</div>
}
@code {
private List<Tenant>? tenants;
private string newTenantName = "";
private string? errorMessage;
private Guid? confirmDeleteId;
protected override async Task OnInitializedAsync()
{
await LoadTenants();
}
private async Task LoadTenants()
{
tenants = await TenantService.GetAllTenantsAsync();
}
private async Task CreateTenant()
{
errorMessage = null;
try
{
await TenantService.CreateTenantAsync(newTenantName.Trim());
newTenantName = "";
await LoadTenants();
}
catch (DbUpdateException)
{
errorMessage = "A tenant with that name already exists.";
}
}
private async Task DeleteTenant(Guid id)
{
confirmDeleteId = null;
await TenantService.DeleteTenantAsync(id);
await LoadTenants();
}
}

View File

@@ -0,0 +1,583 @@
@using EntKube.Web.Data
@using EntKube.Web.Services
@using Microsoft.EntityFrameworkCore
@inject VaultService VaultService
@inject TenantService TenantService
@inject StorageService StorageService
<div class="d-flex align-items-center mb-2">
<i class="bi bi-shield-lock fs-4 me-2 text-primary"></i>
<h4 class="mb-0">Secrets Vault</h4>
</div>
<p class="text-muted small mb-3">Encrypted secrets for apps and cluster components. Per-tenant AES-256-GCM envelope encryption with auto-unseal.</p>
@if (!vaultInitialized)
{
<div class="card border-warning shadow-sm">
<div class="card-body text-center py-5">
<i class="bi bi-shield-exclamation text-warning" style="font-size: 3rem;"></i>
<h5 class="mt-3">Vault Not Initialized</h5>
<p class="text-muted mb-3">A per-tenant encryption key will be generated and sealed with the platform root key.</p>
<button class="btn btn-primary" @onclick="InitializeVault">
<i class="bi bi-key me-1"></i>Initialize Vault
</button>
</div>
</div>
}
else
{
@* --- Scope Toggle --- *@
<div class="d-flex align-items-center gap-3 mb-3">
<span class="text-muted small fw-medium">Scope:</span>
<div class="btn-group" role="group">
<button class="btn btn-sm @(scope == "app" ? "btn-primary" : "btn-outline-primary")" @onclick='() => SwitchScope("app")'>
<i class="bi bi-app-indicator me-1"></i>App Secrets
</button>
<button class="btn btn-sm @(scope == "component" ? "btn-primary" : "btn-outline-primary")" @onclick='() => SwitchScope("component")'>
<i class="bi bi-puzzle me-1"></i>Component Secrets
</button>
<button class="btn btn-sm @(scope == "storage" ? "btn-primary" : "btn-outline-primary")" @onclick='() => SwitchScope("storage")'>
<i class="bi bi-hdd me-1"></i>Storage Secrets
</button>
</div>
</div>
@* --- Selector Dropdown --- *@
@if (scope == "app")
{
@if (appOptions is not null && appOptions.Count > 0)
{
<div class="card border-0 shadow-sm mb-3">
<div class="card-body py-2">
<div class="input-group">
<span class="input-group-text bg-transparent"><i class="bi bi-app"></i></span>
<select class="form-select" @bind="selectedAppId" @bind:after="LoadSecrets">
<option value="@Guid.Empty">Select an app...</option>
@foreach ((string label, Guid id) in appOptions)
{
<option value="@id">@label</option>
}
</select>
</div>
</div>
</div>
}
else
{
<div class="text-center py-4">
<i class="bi bi-app text-muted" style="font-size: 2rem;"></i>
<p class="text-muted small mt-2 mb-0">Create a customer and app first to store app secrets.</p>
</div>
}
}
else if (scope == "component")
{
@if (componentOptions is not null && componentOptions.Count > 0)
{
<div class="card border-0 shadow-sm mb-3">
<div class="card-body py-2">
<div class="input-group">
<span class="input-group-text bg-transparent"><i class="bi bi-puzzle"></i></span>
<select class="form-select" @bind="selectedComponentId" @bind:after="LoadSecrets">
<option value="@Guid.Empty">Select a component...</option>
@foreach ((string label, Guid id) in componentOptions)
{
<option value="@id">@label</option>
}
</select>
</div>
</div>
</div>
}
else
{
<div class="text-center py-4">
<i class="bi bi-puzzle text-muted" style="font-size: 2rem;"></i>
<p class="text-muted small mt-2 mb-0">Create an environment, cluster, and component first to store component secrets.</p>
</div>
}
}
else if (scope == "storage")
{
@if (storageLinkOptions is not null && storageLinkOptions.Count > 0)
{
<div class="card border-0 shadow-sm mb-3">
<div class="card-body py-2">
<div class="input-group">
<span class="input-group-text bg-transparent"><i class="bi bi-hdd"></i></span>
<select class="form-select" @bind="selectedStorageLinkId" @bind:after="LoadSecrets">
<option value="@Guid.Empty">Select a storage link...</option>
@foreach ((string label, Guid id) in storageLinkOptions)
{
<option value="@id">@label</option>
}
</select>
</div>
</div>
</div>
}
else
{
<div class="text-center py-4">
<i class="bi bi-hdd text-muted" style="font-size: 2rem;"></i>
<p class="text-muted small mt-2 mb-0">Create a storage link first to view its secrets.</p>
</div>
}
}
@* --- Secrets Panel --- *@
@if (HasSelection())
{
<div class="card shadow-sm">
<div class="card-header bg-white d-flex align-items-center py-2">
<i class="bi bi-lock me-2 text-primary"></i>
<strong>Secrets</strong>
</div>
<div class="card-body">
@* Add secret form *@
<div class="row g-2 mb-3">
<div class="col-md-4">
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-tag"></i></span>
<input type="text" class="form-control" placeholder="SECRET_NAME"
@bind="newSecretName" @bind:event="oninput" />
</div>
</div>
<div class="col-md-5">
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-key"></i></span>
<input type="password" class="form-control" placeholder="Secret value"
@bind="newSecretValue" @bind:event="oninput" />
</div>
</div>
<div class="col-md-auto">
<button class="btn btn-sm btn-primary" @onclick="AddSecret"
disabled="@(string.IsNullOrWhiteSpace(newSecretName) || string.IsNullOrWhiteSpace(newSecretValue))">
<i class="bi bi-plus-lg me-1"></i>Set Secret
</button>
</div>
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger py-1 small mb-2">
<i class="bi bi-exclamation-triangle me-1"></i>@errorMessage
</div>
}
@if (!string.IsNullOrEmpty(successMessage))
{
<div class="alert alert-success py-1 small mb-2">
<i class="bi bi-check-circle me-1"></i>@successMessage
</div>
}
@* Secrets table *@
@if (secrets is null)
{
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</div>
}
else if (secrets.Count == 0)
{
<div class="text-center py-3">
<i class="bi bi-lock text-muted" style="font-size: 2rem;"></i>
<p class="text-muted small mt-1 mb-0">No secrets stored yet. Add one above.</p>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Name</th>
<th>K8s Sync</th>
<th>K8s Secret</th>
<th>Namespace</th>
<th>Updated</th>
<th class="text-end" style="width: 150px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (VaultSecret secret in secrets)
{
<tr>
<td><code>@secret.Name</code></td>
<td>
@if (secret.SyncToKubernetes)
{
<span class="badge bg-success"><i class="bi bi-cloud-check me-1"></i>Synced</span>
}
else
{
<span class="badge bg-secondary">Off</span>
}
</td>
<td><small class="text-muted">@(secret.KubernetesSecretName ?? "—")</small></td>
<td><small class="text-muted">@(secret.KubernetesNamespace ?? "—")</small></td>
<td><small class="text-muted">@secret.UpdatedAt.ToString("MMM d, HH:mm")</small></td>
<td class="text-end">
<button class="btn btn-sm btn-outline-secondary me-1" @onclick="() => RevealSecret(secret)"
title="@(revealedSecretId == secret.Id ? "Hide value" : "Reveal value")">
<i class="bi @(revealedSecretId == secret.Id ? "bi-eye-slash" : "bi-eye")"></i>
</button>
<button class="btn btn-sm btn-outline-warning me-1" @onclick="() => StartEdit(secret)" title="Edit value">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-info me-1" @onclick="() => ToggleSync(secret)"
title="@(secret.SyncToKubernetes ? "Disable K8s sync" : "Enable K8s sync")">
<i class="bi @(secret.SyncToKubernetes ? "bi-cloud-slash" : "bi-cloud-arrow-up")"></i>
</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteSecret(secret.Id)" title="Delete secret">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
@* Reveal value row *@
@if (revealedSecretId == secret.Id)
{
<tr>
<td colspan="6" class="bg-light">
<div class="p-2">
<span class="text-muted small me-2">Value:</span>
<code class="user-select-all">@revealedValue</code>
</div>
</td>
</tr>
}
@* Edit value row *@
@if (editSecretId == secret.Id)
{
<tr>
<td colspan="6" class="bg-light">
<div class="row g-2 p-2 align-items-center">
<div class="col-md-6">
<input type="password" class="form-control form-control-sm"
placeholder="New secret value" @bind="editSecretValue" />
</div>
<div class="col-auto">
<button class="btn btn-sm btn-success" @onclick="SaveEdit"
disabled="@string.IsNullOrWhiteSpace(editSecretValue)">
<i class="bi bi-check-lg me-1"></i>Update
</button>
<button class="btn btn-sm btn-outline-secondary ms-1" @onclick="CancelEdit">
Cancel
</button>
</div>
</div>
</td>
</tr>
}
@if (syncConfigSecretId == secret.Id)
{
<tr>
<td colspan="6" class="bg-light">
<div class="row g-2 p-2 align-items-center">
<div class="col-md-4">
<input type="text" class="form-control form-control-sm"
placeholder="K8s Secret name (e.g. my-app-secrets)" @bind="syncSecretName" />
</div>
<div class="col-md-4">
<input type="text" class="form-control form-control-sm"
placeholder="Namespace (e.g. default)" @bind="syncNamespace" />
</div>
<div class="col-auto">
<button class="btn btn-sm btn-success" @onclick="SaveSync">
<i class="bi bi-check-lg me-1"></i>Save
</button>
<button class="btn btn-sm btn-outline-secondary ms-1" @onclick="() => syncConfigSecretId = null">
Cancel
</button>
</div>
</div>
</td>
</tr>
}
}
</tbody>
</table>
</div>
}
</div>
</div>
}
}
@code {
[Parameter] public Guid TenantId { get; set; }
private bool vaultInitialized;
private string scope = "app";
// App scope
private List<(string Label, Guid Id)>? appOptions;
private Guid selectedAppId;
// Component scope
private List<(string Label, Guid Id)>? componentOptions;
private Guid selectedComponentId;
// Storage scope
private List<(string Label, Guid Id)>? storageLinkOptions;
private Guid selectedStorageLinkId;
// Secrets
private List<VaultSecret>? secrets;
private string newSecretName = "";
private string newSecretValue = "";
private string? errorMessage;
private string? successMessage;
private Guid? syncConfigSecretId;
private string syncSecretName = "";
private string syncNamespace = "";
// Reveal/Edit state
private Guid? revealedSecretId;
private string? revealedValue;
private Guid? editSecretId;
private string editSecretValue = "";
protected override async Task OnInitializedAsync()
{
SecretVault? vault = await VaultService.GetVaultAsync(TenantId);
vaultInitialized = vault is not null;
if (vaultInitialized)
{
await LoadOptions();
}
}
private async Task InitializeVault()
{
await VaultService.InitializeVaultAsync(TenantId);
vaultInitialized = true;
await LoadOptions();
}
private async Task LoadOptions()
{
List<Customer> customers = await TenantService.GetCustomersAsync(TenantId);
appOptions = [];
foreach (Customer customer in customers)
{
List<Data.App> apps = await TenantService.GetAppsAsync(customer.Id);
foreach (Data.App app in apps)
{
appOptions.Add(($"{customer.Name} / {app.Name}", app.Id));
}
}
List<KubernetesCluster> clusters = await TenantService.GetClustersAsync(TenantId);
componentOptions = [];
foreach (KubernetesCluster cluster in clusters)
{
List<ClusterComponent> components = await VaultService.GetComponentsAsync(cluster.Id);
foreach (ClusterComponent comp in components)
{
componentOptions.Add(($"{cluster.Name} / {comp.Name}", comp.Id));
}
}
// Load storage links for the storage secrets scope.
List<StorageLink> links = await StorageService.GetStorageLinksAsync(TenantId);
storageLinkOptions = links
.Select(l => ($"{l.Environment.Name} / {l.Name} ({l.Provider})", l.Id))
.ToList();
}
private void SwitchScope(string newScope)
{
scope = newScope;
secrets = null;
selectedAppId = Guid.Empty;
selectedComponentId = Guid.Empty;
selectedStorageLinkId = Guid.Empty;
errorMessage = null;
successMessage = null;
}
private bool HasSelection()
{
return (scope == "app" && selectedAppId != Guid.Empty)
|| (scope == "component" && selectedComponentId != Guid.Empty)
|| (scope == "storage" && selectedStorageLinkId != Guid.Empty);
}
private async Task LoadSecrets()
{
errorMessage = null;
successMessage = null;
if (scope == "app" && selectedAppId != Guid.Empty)
{
secrets = await VaultService.GetAppSecretsAsync(TenantId, selectedAppId);
}
else if (scope == "component" && selectedComponentId != Guid.Empty)
{
secrets = await VaultService.GetComponentSecretsAsync(TenantId, selectedComponentId);
}
else if (scope == "storage" && selectedStorageLinkId != Guid.Empty)
{
secrets = await VaultService.GetStorageLinkSecretsAsync(TenantId, selectedStorageLinkId);
}
else
{
secrets = null;
}
}
private async Task AddSecret()
{
errorMessage = null;
successMessage = null;
try
{
if (scope == "app")
{
await VaultService.SetAppSecretAsync(TenantId, selectedAppId, newSecretName.Trim(), newSecretValue.Trim());
}
else if (scope == "component")
{
await VaultService.SetComponentSecretAsync(TenantId, selectedComponentId, newSecretName.Trim(), newSecretValue.Trim());
}
else if (scope == "storage")
{
await VaultService.SetStorageLinkSecretAsync(TenantId, selectedStorageLinkId, newSecretName.Trim(), newSecretValue.Trim());
}
successMessage = $"Secret '{newSecretName.Trim()}' saved.";
newSecretName = "";
newSecretValue = "";
await LoadSecrets();
}
catch (Exception ex)
{
errorMessage = ex.Message;
}
}
private async Task DeleteSecret(Guid secretId)
{
errorMessage = null;
// Check if deletion is blocked by active storage bindings.
(bool canDelete, string? reason) = await VaultService.CanDeleteSecretAsync(secretId);
if (!canDelete)
{
errorMessage = reason;
return;
}
await VaultService.DeleteSecretAsync(secretId);
revealedSecretId = null;
editSecretId = null;
await LoadSecrets();
}
private async Task RevealSecret(VaultSecret secret)
{
// Toggle off if already revealed.
if (revealedSecretId == secret.Id)
{
revealedSecretId = null;
revealedValue = null;
return;
}
// Decrypt and reveal the value.
revealedValue = await VaultService.GetSecretValueByIdAsync(secret.Id);
revealedSecretId = secret.Id;
}
private void StartEdit(VaultSecret secret)
{
editSecretId = secret.Id;
editSecretValue = "";
revealedSecretId = null;
revealedValue = null;
}
private void CancelEdit()
{
editSecretId = null;
editSecretValue = "";
}
private async Task SaveEdit()
{
if (editSecretId is null || string.IsNullOrWhiteSpace(editSecretValue))
{
return;
}
errorMessage = null;
bool updated = await VaultService.UpdateSecretValueAsync(editSecretId.Value, editSecretValue.Trim());
if (updated)
{
successMessage = "Secret value updated.";
}
else
{
errorMessage = "Failed to update secret.";
}
editSecretId = null;
editSecretValue = "";
await LoadSecrets();
}
private void ToggleSync(VaultSecret secret)
{
if (secret.SyncToKubernetes)
{
_ = DisableSync(secret.Id);
}
else
{
syncConfigSecretId = secret.Id;
syncSecretName = secret.KubernetesSecretName ?? "";
syncNamespace = secret.KubernetesNamespace ?? "";
}
}
private async Task DisableSync(Guid secretId)
{
await VaultService.ConfigureKubernetesSyncAsync(secretId, syncEnabled: false, secretName: null, ns: null);
await LoadSecrets();
}
private async Task SaveSync()
{
if (syncConfigSecretId is null)
{
return;
}
await VaultService.ConfigureKubernetesSyncAsync(
syncConfigSecretId.Value,
syncEnabled: true,
secretName: syncSecretName.Trim(),
ns: syncNamespace.Trim());
syncConfigSecretId = null;
await LoadSecrets();
}
}

View File

@@ -1,381 +0,0 @@
using System.Net.Http.Json;
namespace EntKube.Web.Components.Provisioning;
/// <summary>
/// BFF proxy endpoints for app management. The Blazor frontend calls these
/// endpoints, and the BFF forwards requests to the Provisioning microservice.
/// Apps represent customer workloads (Deployments or Helm charts) that can
/// be deployed across multiple environments.
/// </summary>
public static class AppsProxyEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/apps")
.RequireAuthorization()
.DisableAntiforgery();
// GET /api/apps/by-customer/{customerId} — list all apps for a customer.
group.MapGet("by-customer/{customerId:guid}", async (
Guid customerId,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyGetAsync(httpClientFactory, $"/api/customers/{customerId}/apps", ct);
});
// GET /api/apps/{appId} — get full app detail with environments.
group.MapGet("{appId:guid}", async (
Guid appId,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyGetAsync(httpClientFactory, $"/api/apps/{appId}", ct);
});
// POST /api/apps/by-customer/{customerId} — create a new app.
group.MapPost("by-customer/{customerId:guid}", async (
Guid customerId,
CreateAppProxyRequest request,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyPostAsync(httpClientFactory, $"/api/customers/{customerId}/apps", request, ct);
});
// POST /api/apps/{appId}/environments — add app to an environment.
group.MapPost("{appId:guid}/environments", async (
Guid appId,
AddEnvironmentProxyRequest request,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyPostAsync(httpClientFactory, $"/api/apps/{appId}/environments", request, ct);
});
// PUT /api/apps/{appId}/environments/{environmentId}/deployment — configure deployment spec.
group.MapPut("{appId:guid}/environments/{environmentId:guid}/deployment", async (
Guid appId,
Guid environmentId,
DeploymentSpecProxy spec,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyPutAsync(httpClientFactory, $"/api/apps/{appId}/environments/{environmentId}/deployment", spec, ct);
});
// PUT /api/apps/{appId}/environments/{environmentId}/helm — configure helm release spec.
group.MapPut("{appId:guid}/environments/{environmentId:guid}/helm", async (
Guid appId,
Guid environmentId,
HelmReleaseSpecProxy spec,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyPutAsync(httpClientFactory, $"/api/apps/{appId}/environments/{environmentId}/helm", spec, ct);
});
// POST /api/apps/{appId}/environments/{environmentId}/secrets — add a secret reference.
group.MapPost("{appId:guid}/environments/{environmentId:guid}/secrets", async (
Guid appId,
Guid environmentId,
AddSecretProxyRequest request,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyPostAsync(httpClientFactory, $"/api/apps/{appId}/environments/{environmentId}/secrets", request, ct);
});
// DELETE /api/apps/{appId} — delete an app and all its environments.
group.MapDelete("{appId:guid}", async (
Guid appId,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyDeleteAsync(httpClientFactory, $"/api/apps/{appId}", ct);
});
// DELETE /api/apps/{appId}/environments/{environmentId} — remove an environment from an app.
group.MapDelete("{appId:guid}/environments/{environmentId:guid}", async (
Guid appId,
Guid environmentId,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyDeleteAsync(httpClientFactory, $"/api/apps/{appId}/environments/{environmentId}", ct);
});
// DELETE /api/apps/{appId}/environments/{environmentId}/secrets/{secretName} — remove a secret.
group.MapDelete("{appId:guid}/environments/{environmentId:guid}/secrets/{secretName}", async (
Guid appId,
Guid environmentId,
string secretName,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyDeleteAsync(httpClientFactory, $"/api/apps/{appId}/environments/{environmentId}/secrets/{secretName}", ct);
});
// POST /api/apps/{appId}/suspend — suspend an app.
group.MapPost("{appId:guid}/suspend", async (
Guid appId,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyPostEmptyAsync(httpClientFactory, $"/api/apps/{appId}/suspend", ct);
});
// POST /api/apps/{appId}/activate — re-activate a suspended app.
group.MapPost("{appId:guid}/activate", async (
Guid appId,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyPostEmptyAsync(httpClientFactory, $"/api/apps/{appId}/activate", ct);
});
// POST /api/apps/{appId}/environments/{environmentId}/sync — trigger immediate sync.
group.MapPost("{appId:guid}/environments/{environmentId:guid}/sync", async (
Guid appId,
Guid environmentId,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyPostEmptyAsync(httpClientFactory, $"/api/apps/{appId}/environments/{environmentId}/sync", ct);
});
// ─── Cluster Resource Lookups ────────────────────────────────────
// These proxy to the Provisioning service which talks to the cluster.
// GET /api/apps/clusters/{clusterId}/namespaces/{ns}/service-accounts — list service accounts.
group.MapGet("clusters/{clusterId:guid}/namespaces/{ns}/service-accounts", async (
Guid clusterId,
string ns,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyGetAsync(httpClientFactory, $"/api/clusters/{clusterId}/namespaces/{ns}/service-accounts", ct);
});
// GET /api/apps/clusters/{clusterId}/namespaces/{ns}/pvcs — list PVCs.
group.MapGet("clusters/{clusterId:guid}/namespaces/{ns}/pvcs", async (
Guid clusterId,
string ns,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyGetAsync(httpClientFactory, $"/api/clusters/{clusterId}/namespaces/{ns}/pvcs", ct);
});
// POST /api/apps/clusters/{clusterId}/namespaces/{ns}/service-accounts — create a service account.
group.MapPost("clusters/{clusterId:guid}/namespaces/{ns}/service-accounts", async (
Guid clusterId,
string ns,
CreateResourceRequest request,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyPostAsync(httpClientFactory, $"/api/clusters/{clusterId}/namespaces/{ns}/service-accounts", request, ct);
});
// POST /api/apps/clusters/{clusterId}/namespaces/{ns}/pvcs — create a PVC.
group.MapPost("clusters/{clusterId:guid}/namespaces/{ns}/pvcs", async (
Guid clusterId,
string ns,
CreatePvcRequest request,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
return await ProxyPostAsync(httpClientFactory, $"/api/clusters/{clusterId}/namespaces/{ns}/pvcs", request, ct);
});
}
// ─── Shared proxy helpers ─────────────────────────────────────────────
private static async Task<IResult> ProxyGetAsync(
IHttpClientFactory httpClientFactory, string path, CancellationToken ct)
{
try
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync(path, ct);
if (!response.IsSuccessStatusCode)
{
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Json(
new ApiError(false, $"Provisioning service returned {(int)response.StatusCode}: {body}"),
statusCode: (int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
}
catch (HttpRequestException)
{
return Results.Json(
new ApiError(false, "Provisioning service is unreachable. Make sure it is running on the configured port."),
statusCode: 502);
}
}
private static async Task<IResult> ProxyPostAsync<T>(
IHttpClientFactory httpClientFactory, string path, T payload, CancellationToken ct)
{
try
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PostAsJsonAsync(path, payload, ct);
if (!response.IsSuccessStatusCode)
{
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Json(
new ApiError(false, $"Provisioning service returned {(int)response.StatusCode}: {body}"),
statusCode: (int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
}
catch (HttpRequestException)
{
return Results.Json(
new ApiError(false, "Provisioning service is unreachable. Make sure it is running on the configured port."),
statusCode: 502);
}
}
private static async Task<IResult> ProxyPostEmptyAsync(
IHttpClientFactory httpClientFactory, string path, CancellationToken ct)
{
try
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PostAsync(path, null, ct);
if (!response.IsSuccessStatusCode)
{
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Json(
new ApiError(false, $"Provisioning service returned {(int)response.StatusCode}: {body}"),
statusCode: (int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
}
catch (HttpRequestException)
{
return Results.Json(
new ApiError(false, "Provisioning service is unreachable. Make sure it is running on the configured port."),
statusCode: 502);
}
}
private static async Task<IResult> ProxyPutAsync<T>(
IHttpClientFactory httpClientFactory, string path, T payload, CancellationToken ct)
{
try
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PutAsJsonAsync(path, payload, ct);
if (!response.IsSuccessStatusCode)
{
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Json(
new ApiError(false, $"Provisioning service returned {(int)response.StatusCode}: {body}"),
statusCode: (int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
}
catch (HttpRequestException)
{
return Results.Json(
new ApiError(false, "Provisioning service is unreachable. Make sure it is running on the configured port."),
statusCode: 502);
}
}
private static async Task<IResult> ProxyDeleteAsync(
IHttpClientFactory httpClientFactory, string path, CancellationToken ct)
{
try
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.DeleteAsync(path, ct);
if (!response.IsSuccessStatusCode)
{
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Json(
new ApiError(false, $"Provisioning service returned {(int)response.StatusCode}: {body}"),
statusCode: (int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
}
catch (HttpRequestException)
{
return Results.Json(
new ApiError(false, "Provisioning service is unreachable. Make sure it is running on the configured port."),
statusCode: 502);
}
}
private record ApiError(bool Success, string Error);
}
// ─── Proxy DTOs ──────────────────────────────────────────────────────────
public record CreateAppProxyRequest(Guid TenantId, string Name, string Slug, string Type);
public record AddEnvironmentProxyRequest(Guid EnvironmentId, Guid ClusterId, string Namespace);
public record AddSecretProxyRequest(string Name, string VaultKey);
public record DeploymentSpecProxy(
string Image,
string Tag,
int Replicas,
int ContainerPort,
int ServicePort,
string? HostName,
string? PathPrefix,
Dictionary<string, string>? EnvironmentVariables,
ResourceSpecProxy? Resources);
public record ResourceSpecProxy(
string? CpuRequest,
string? CpuLimit,
string? MemoryRequest,
string? MemoryLimit);
public record HelmReleaseSpecProxy(
string RepoUrl,
string ChartName,
string ChartVersion,
string? ValuesYaml);
public record CreateResourceRequest(string Name);
public record CreatePvcRequest(
string Name,
string StorageSize,
string AccessMode = "ReadWriteOnce",
string? StorageClass = null);

View File

@@ -1,31 +0,0 @@
namespace EntKube.Web.Components.Provisioning;
/// <summary>
/// BFF proxy endpoints for Gitea operations. The Blazor frontend calls these
/// to fetch Gitea instance info and runner statuses. Requests are forwarded
/// to the Clusters microservice which has direct K8s API access.
/// </summary>
public static class GiteaProxyEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/gitea")
.RequireAuthorization()
.DisableAntiforgery();
// GET /api/gitea/{clusterId}/info — Gitea instance info + runners.
group.MapGet("{clusterId:guid}/info", async (
Guid clusterId,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.GetAsync(
$"/api/clusters/{clusterId}/gitea/info", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Content(body, "application/json", statusCode: (int)response.StatusCode);
});
}
}

View File

@@ -1,153 +0,0 @@
using System.Net.Http.Json;
namespace EntKube.Web.Components.Provisioning;
/// <summary>
/// BFF proxy endpoints for Grafana instance management. Forwards requests from
/// the Blazor frontend to the Provisioning microservice's Grafana endpoints.
/// </summary>
public static class GrafanaProxyEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/grafana")
.RequireAuthorization()
.DisableAntiforgery();
// GET /api/grafana — list all Grafana instances
group.MapGet("", async (IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync("/api/grafana", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// GET /api/grafana/environment/{environmentId}
group.MapGet("environment/{environmentId:guid}", async (Guid environmentId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync($"/api/grafana/environment/{environmentId}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// GET /api/grafana/{id}
group.MapGet("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync($"/api/grafana/{id}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// POST /api/grafana — create a new Grafana instance
group.MapPost("", async (CreateGrafanaInstanceProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PostAsJsonAsync("/api/grafana", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// DELETE /api/grafana/{id}
group.MapDelete("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.DeleteAsync($"/api/grafana/{id}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// PUT /api/grafana/{id}/persistence
group.MapPut("{id:guid}/persistence", async (Guid id, ConfigurePersistenceProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PutAsJsonAsync($"/api/grafana/{id}/persistence", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// PUT /api/grafana/{id}/oidc
group.MapPut("{id:guid}/oidc", async (Guid id, ConfigureOidcProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PutAsJsonAsync($"/api/grafana/{id}/oidc", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// DELETE /api/grafana/{id}/oidc
group.MapDelete("{id:guid}/oidc", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.DeleteAsync($"/api/grafana/{id}/oidc", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// PUT /api/grafana/{id}/ingress
group.MapPut("{id:guid}/ingress", async (Guid id, ConfigureGrafanaIngressProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PutAsJsonAsync($"/api/grafana/{id}/ingress", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// DELETE /api/grafana/{id}/ingress
group.MapDelete("{id:guid}/ingress", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.DeleteAsync($"/api/grafana/{id}/ingress", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// GET /api/grafana/{id}/dashboards
group.MapGet("{id:guid}/dashboards", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync($"/api/grafana/{id}/dashboards", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// POST /api/grafana/{id}/dashboards
group.MapPost("{id:guid}/dashboards", async (Guid id, AddDashboardProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PostAsJsonAsync($"/api/grafana/{id}/dashboards", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// DELETE /api/grafana/{id}/dashboards/{name}
group.MapDelete("{id:guid}/dashboards/{name}", async (Guid id, string name, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.DeleteAsync($"/api/grafana/{id}/dashboards/{name}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
}
}
// ─── Proxy Request DTOs ─────────────────────────────────────────────
public record CreateGrafanaInstanceProxyRequest(Guid EnvironmentId, Guid ClusterId, string Name, string Namespace);
public record ConfigurePersistenceProxyRequest(bool Enabled, string? Size);
public record ConfigureOidcProxyRequest(
string Provider, string ClientId, string ClientSecretRef,
string AuthUrl, string TokenUrl, string ApiUrl,
string Scopes, string? RoleAttributePath, bool AutoLogin);
public record ConfigureGrafanaIngressProxyRequest(
bool Enabled, string Provider,
string? Hostname, bool TlsEnabled,
string TlsCertificateMode = "Manual",
string? TlsSecretName = null, string? ClusterIssuer = null);
public record AddDashboardProxyRequest(string Name, string DisplayName, string Category, string JsonContent);

View File

@@ -1,54 +0,0 @@
using System.Net.Http.Json;
namespace EntKube.Web.Components.Provisioning;
/// <summary>
/// BFF proxy endpoints for Harbor operations. The Blazor frontend calls these
/// endpoints to list Harbor projects and create pull secrets. Requests are
/// forwarded to the Clusters microservice which has direct Harbor API access.
/// </summary>
public static class HarborProxyEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/harbor")
.RequireAuthorization()
.DisableAntiforgery();
// GET /api/harbor/{clusterId}/projects — list Harbor projects on a cluster.
group.MapGet("{clusterId:guid}/projects", async (
Guid clusterId,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.GetAsync(
$"/api/clusters/{clusterId}/harbor/projects", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Content(body, "application/json", statusCode: (int)response.StatusCode);
});
// POST /api/harbor/{clusterId}/pull-secret — create a Harbor pull secret.
group.MapPost("{clusterId:guid}/pull-secret", async (
Guid clusterId,
CreateHarborPullSecretProxyRequest request,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage response = await client.PostAsJsonAsync(
$"/api/clusters/{clusterId}/harbor/pull-secret", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Content(body, "application/json", statusCode: (int)response.StatusCode);
});
}
}
public record CreateHarborPullSecretProxyRequest(
string ProjectName,
string AppSlug,
string TargetNamespace);

View File

@@ -1,135 +0,0 @@
namespace EntKube.Web.Components.Provisioning;
/// <summary>
/// BFF proxy endpoints for Keycloak realm management. The Blazor frontend calls these
/// endpoints, and the BFF forwards requests to the Provisioning microservice.
/// </summary>
public static class KeycloakRealmProxyEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/keycloak-realms")
.RequireAuthorization()
.DisableAntiforgery();
// GET /api/keycloak-realms/cluster/{clusterId} — list realms for a cluster.
group.MapGet("cluster/{clusterId:guid}", async (Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync($"/api/keycloak-realms/cluster/{clusterId}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// GET /api/keycloak-realms/{id} — get a single realm.
group.MapGet("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync($"/api/keycloak-realms/{id}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// POST /api/keycloak-realms — create a new realm.
group.MapPost("", async (HttpContext httpContext, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
StreamContent content = new(httpContext.Request.Body);
content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(
httpContext.Request.ContentType ?? "application/json");
HttpResponseMessage response = await client.PostAsync("/api/keycloak-realms", content, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// DELETE /api/keycloak-realms/{id} — delete a realm.
group.MapDelete("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.DeleteAsync($"/api/keycloak-realms/{id}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// PUT /api/keycloak-realms/{id}/branding — update realm branding.
group.MapPut("{id:guid}/branding", async (Guid id, HttpContext httpContext, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
StreamContent content = new(httpContext.Request.Body);
content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(
httpContext.Request.ContentType ?? "application/json");
HttpResponseMessage response = await client.PutAsync($"/api/keycloak-realms/{id}/branding", content, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// PUT /api/keycloak-realms/{id}/pages — update realm pages config.
group.MapPut("{id:guid}/pages", async (Guid id, HttpContext httpContext, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
StreamContent content = new(httpContext.Request.Body);
content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(
httpContext.Request.ContentType ?? "application/json");
HttpResponseMessage response = await client.PutAsync($"/api/keycloak-realms/{id}/pages", content, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// POST /api/keycloak-realms/{id}/identity-providers — add identity provider.
group.MapPost("{id:guid}/identity-providers", async (Guid id, HttpContext httpContext, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
StreamContent content = new(httpContext.Request.Body);
content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(
httpContext.Request.ContentType ?? "application/json");
HttpResponseMessage response = await client.PostAsync($"/api/keycloak-realms/{id}/identity-providers", content, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// DELETE /api/keycloak-realms/{id}/identity-providers/{alias} — remove identity provider.
group.MapDelete("{id:guid}/identity-providers/{alias}", async (Guid id, string alias, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.DeleteAsync($"/api/keycloak-realms/{id}/identity-providers/{alias}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// POST /api/keycloak-realms/{id}/organizations — add organization.
group.MapPost("{id:guid}/organizations", async (Guid id, HttpContext httpContext, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
StreamContent content = new(httpContext.Request.Body);
content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(
httpContext.Request.ContentType ?? "application/json");
HttpResponseMessage response = await client.PostAsync($"/api/keycloak-realms/{id}/organizations", content, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// DELETE /api/keycloak-realms/{id}/organizations/{name} — remove organization.
group.MapDelete("{id:guid}/organizations/{name}", async (Guid id, string name, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.DeleteAsync($"/api/keycloak-realms/{id}/organizations/{name}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
}
}

View File

@@ -1,339 +0,0 @@
using System.Net.Http.Json;
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc;
namespace EntKube.Web.Components.Provisioning;
/// <summary>
/// BFF proxy endpoints for MinIO tenant management. The Blazor frontend calls these
/// endpoints, and the BFF forwards requests to the Provisioning microservice.
///
/// The BFF enriches requests with the cluster's kubeconfig (fetched from the Clusters
/// service) so the frontend never needs to handle sensitive credentials.
/// </summary>
public static class MinioTenantProxyEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/minio-tenants")
.RequireAuthorization()
.DisableAntiforgery();
// GET /api/minio-tenants — list all MinIO tenants.
group.MapGet("", async (IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync("/api/minio-tenants", ct);
response.EnsureSuccessStatusCode();
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// GET /api/minio-tenants/cluster/{clusterId} — list tenants for a specific cluster.
group.MapGet("cluster/{clusterId:guid}", async (Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync($"/api/minio-tenants/cluster/{clusterId}", ct);
response.EnsureSuccessStatusCode();
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// GET /api/minio-tenants/{id} — get a specific tenant.
group.MapGet("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync($"/api/minio-tenants/{id}", ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// POST /api/minio-tenants — create a new MinIO tenant.
// The BFF fetches the kubeconfig from the Clusters service and injects it.
group.MapPost("", async (CreateMinioTenantFrontendRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
// Fetch cluster credentials from the Clusters service.
ClusterCredentialsResponse? creds = await GetClusterCredentials(httpClientFactory, request.ClusterId, ct);
if (creds is null)
{
return Results.BadRequest(new { error = "Could not fetch cluster credentials." });
}
// Forward to Provisioning with credentials injected.
object provisioningRequest = new
{
request.ClusterId,
creds.KubeConfig,
creds.ContextName,
request.Name,
request.Namespace,
request.Pools
};
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PostAsJsonAsync("/api/minio-tenants", provisioningRequest, ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// PUT /api/minio-tenants/{id}/configure — update tenant pool configuration.
group.MapPut("{id:guid}/configure", async (Guid id, ConfigureMinioTenantFrontendRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
// Fetch cluster credentials.
ClusterCredentialsResponse? creds = await GetClusterCredentials(httpClientFactory, request.ClusterId, ct);
if (creds is null)
{
return Results.BadRequest(new { error = "Could not fetch cluster credentials." });
}
object provisioningRequest = new
{
creds.KubeConfig,
creds.ContextName,
request.Pools
};
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PutAsJsonAsync($"/api/minio-tenants/{id}/configure", provisioningRequest, ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// DELETE /api/minio-tenants/{id} — delete a tenant from the cluster.
group.MapDelete("{id:guid}", async (Guid id, [FromBody] DeleteMinioTenantFrontendRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
// Fetch cluster credentials.
ClusterCredentialsResponse? creds = await GetClusterCredentials(httpClientFactory, request.ClusterId, ct);
if (creds is null)
{
return Results.BadRequest(new { error = "Could not fetch cluster credentials." });
}
object provisioningRequest = new
{
creds.KubeConfig,
creds.ContextName
};
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpRequestMessage deleteRequest = new(HttpMethod.Delete, $"/api/minio-tenants/{id}")
{
Content = JsonContent.Create(provisioningRequest)
};
HttpResponseMessage response = await client.SendAsync(deleteRequest, ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// POST /api/minio-tenants/adopt — discover and adopt existing MinIO tenants.
// The frontend sends clusterId, the BFF injects kubeConfig/contextName.
group.MapPost("adopt", async (HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, "/api/minio-tenants/adopt", "POST", ct);
});
// POST /api/minio-tenants/{id}/health — live health from K8s.
// BFF resolves credentials and forwards via POST (kubeConfig is too large for query strings).
group.MapGet("{id:guid}/health", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, $"/api/minio-tenants/{id}/health", ct);
});
// GET /api/minio-tenants/{id}/buckets — list buckets (forwarded as POST).
group.MapGet("{id:guid}/buckets", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, $"/api/minio-tenants/{id}/buckets", ct);
});
// POST /api/minio-tenants/{id}/buckets/create — create a bucket.
group.MapPost("{id:guid}/buckets/create", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/minio-tenants/{id}/buckets/create", "POST", ct);
});
// POST /api/minio-tenants/{id}/buckets/delete — delete a bucket.
group.MapPost("{id:guid}/buckets/delete", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/minio-tenants/{id}/buckets/delete", "POST", ct);
});
}
/// <summary>
/// Fetches the kubeconfig and context name for a cluster from the Clusters service.
/// This is an internal BFF-to-service call — credentials never reach the frontend.
/// </summary>
private static async Task<ClusterCredentialsResponse?> GetClusterCredentials(
IHttpClientFactory httpClientFactory, Guid clusterId, CancellationToken ct)
{
HttpClient clustersClient = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage credsResponse = await clustersClient.GetAsync($"/api/clusters/{clusterId}/credentials", ct);
if (!credsResponse.IsSuccessStatusCode)
{
return null;
}
ApiResponseWrapper<ClusterCredentialsResponse>? result =
await credsResponse.Content.ReadFromJsonAsync<ApiResponseWrapper<ClusterCredentialsResponse>>(ct);
return result?.Data;
}
/// <summary>
/// Reads the request body, resolves kubeConfig from the Clusters service using
/// the clusterId in the body, injects the credentials, and forwards to Provisioning.
/// </summary>
private static async Task<IResult> ForwardWithCredentials(
HttpRequest request, IHttpClientFactory httpClientFactory,
string provisioningPath, string httpMethod, CancellationToken ct)
{
JsonNode? body = await JsonNode.ParseAsync(request.Body, cancellationToken: ct);
if (body is null)
{
return Results.BadRequest("Request body is required.");
}
Guid? clusterId = body["clusterId"]?.GetValue<Guid>();
if (clusterId is null)
{
return Results.BadRequest("clusterId is required.");
}
ClusterCredentialsResponse? creds = await GetClusterCredentials(httpClientFactory, clusterId.Value, ct);
if (creds is null)
{
return Results.BadRequest("Could not resolve cluster credentials.");
}
body["kubeConfig"] = creds.KubeConfig;
body["contextName"] = creds.ContextName;
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpContent content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
HttpResponseMessage response = httpMethod == "PUT"
? await client.PutAsync(provisioningPath, content, ct)
: await client.PostAsync(provisioningPath, content, ct);
if (!response.IsSuccessStatusCode)
{
string errorBody = await response.Content.ReadAsStringAsync(ct);
return Results.Problem(errorBody, statusCode: (int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
}
/// <summary>
/// Resolves kubeConfig for a cluster and forwards a GET-style request as a POST
/// with credentials in the body. Used for read operations where the kubeConfig
/// payload is too large for query strings.
/// </summary>
private static async Task<IResult> ForwardGetWithCredentials(
IHttpClientFactory httpClientFactory, Guid clusterId,
string provisioningPath, CancellationToken ct)
{
ClusterCredentialsResponse? creds = await GetClusterCredentials(httpClientFactory, clusterId, ct);
if (creds is null)
{
return Results.BadRequest("Could not resolve cluster credentials.");
}
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
JsonNode body = new JsonObject
{
["kubeConfig"] = creds.KubeConfig,
["contextName"] = creds.ContextName
};
HttpContent content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
HttpResponseMessage response = await client.PostAsync(provisioningPath, content, ct);
if (!response.IsSuccessStatusCode)
{
string error = await response.Content.ReadAsStringAsync(ct);
return Results.Problem(error, statusCode: (int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
}
}
// ─── Frontend DTOs (no kubeconfig — BFF injects it) ─────────────────────────
public record CreateMinioTenantFrontendRequest(
Guid ClusterId,
string Name,
string Namespace,
List<PoolSpecProxy> Pools);
public record ConfigureMinioTenantFrontendRequest(
Guid ClusterId,
List<PoolSpecProxy> Pools);
public record DeleteMinioTenantFrontendRequest(
Guid ClusterId);
public record PoolSpecProxy(
int Servers,
int VolumesPerServer,
string StorageSize,
string StorageClass,
string? CpuRequest = null,
string? CpuLimit = null,
string? MemoryRequest = null,
string? MemoryLimit = null);
// ─── Internal response types ─────────────────────────────────────────────────
internal record ClusterCredentialsResponse(string KubeConfig, string ContextName);
internal record ApiResponseWrapper<T>
{
public bool Success { get; init; }
public T? Data { get; init; }
public string? Error { get; init; }
}

View File

@@ -1,329 +0,0 @@
using System.Text;
using System.Text.Json.Nodes;
namespace EntKube.Web.Components.Provisioning;
/// <summary>
/// BFF proxy endpoints for MongoDB cluster management. The Blazor frontend calls
/// these endpoints, and the BFF forwards requests to the Provisioning
/// microservice's /api/mongo-clusters routes.
///
/// The frontend never sends kubeConfig — the BFF resolves it server-side by
/// calling the Clusters service's /api/clusters/{id}/credentials endpoint,
/// then injects it into the request before forwarding to Provisioning.
/// </summary>
public static class MongoClusterProxyEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/mongo-clusters")
.RequireAuthorization()
.DisableAntiforgery();
// GET /api/mongo-clusters — list clusters, optionally filtered by clusterId.
group.MapGet("", async (Guid? clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
string url = clusterId.HasValue
? $"/api/mongo-clusters?clusterId={clusterId.Value}"
: "/api/mongo-clusters";
HttpResponseMessage response = await client.GetAsync(url, ct);
response.EnsureSuccessStatusCode();
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// GET /api/mongo-clusters/{id} — get a single cluster.
group.MapGet("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync($"/api/mongo-clusters/{id}", ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// POST /api/mongo-clusters — create a new MongoDB cluster.
// BFF enriches with bucket credentials so Provisioning can create the K8s
// secret directly without depending on a pre-existing copy in cnpg-system.
group.MapPost("", async (HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, "/api/mongo-clusters", "POST", ct,
enrichBody: async (body, factory, cancellation) =>
{
await EnrichWithBucketCredentials(body, factory, cancellation);
});
});
// POST /api/mongo-clusters/adopt — discover and adopt existing clusters.
group.MapPost("adopt", async (HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, "/api/mongo-clusters/adopt", "POST", ct);
});
// POST /api/mongo-clusters/{id}/upgrade — initiate version upgrade.
group.MapPost("{id:guid}/upgrade", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/mongo-clusters/{id}/upgrade", "POST", ct);
});
// PUT /api/mongo-clusters/{id}/configure — reconfigure settings.
group.MapPut("{id:guid}/configure", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/mongo-clusters/{id}/configure", "PUT", ct);
});
// GET /api/mongo-clusters/{id}/health — live health from K8s.
group.MapGet("{id:guid}/health", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, $"/api/mongo-clusters/{id}/health", ct);
});
// POST /api/mongo-clusters/{id}/restart — rolling restart.
group.MapPost("{id:guid}/restart", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/mongo-clusters/{id}/restart", "POST", ct);
});
// POST /api/mongo-clusters/{id}/stepdown — primary stepdown.
group.MapPost("{id:guid}/stepdown", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/mongo-clusters/{id}/stepdown", "POST", ct);
});
// DELETE /api/mongo-clusters/{id} — delete a MongoDB cluster.
group.MapDelete("{id:guid}", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, $"/api/mongo-clusters/{id}", ct, "DELETE");
});
// DELETE /api/mongo-clusters/{id}/databases/{dbName} — drop a database.
group.MapDelete("{id:guid}/databases/{dbName}", async (Guid id, string dbName, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, $"/api/mongo-clusters/{id}/databases/{dbName}", ct, "DELETE");
});
// GET /api/mongo-clusters/available-versions — list supported MongoDB versions.
group.MapGet("available-versions", async (Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, "/api/mongo-clusters/available-versions", ct);
});
}
/// <summary>
/// Forwards a request to Provisioning by injecting kubeConfig+contextName into
/// the body as a POST. This avoids putting large kubeConfig data into query
/// strings, which can exceed URL length limits.
/// </summary>
private static async Task<IResult> ForwardGetWithCredentials(
IHttpClientFactory httpClientFactory, Guid clusterId,
string provisioningPath, CancellationToken ct, string httpMethod = "POST")
{
ClusterCreds? creds = await GetClusterCredentials(httpClientFactory, clusterId, ct);
if (creds is null)
{
return Results.BadRequest("Could not resolve cluster credentials.");
}
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
JsonNode body = new JsonObject
{
["kubeConfig"] = creds.KubeConfig,
["contextName"] = creds.ContextName
};
HttpContent content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
HttpResponseMessage response = httpMethod == "DELETE"
? await client.SendAsync(new HttpRequestMessage(HttpMethod.Delete, provisioningPath) { Content = content }, ct)
: await client.PostAsync(provisioningPath, content, ct);
if (!response.IsSuccessStatusCode)
{
string error = await response.Content.ReadAsStringAsync(ct);
return Results.Problem(error, statusCode: (int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
}
/// <summary>
/// Reads the request body, resolves kubeConfig from the Clusters service using
/// the clusterId in the body, injects the credentials, and forwards to Provisioning.
/// An optional enrichBody callback can modify the body before forwarding.
/// </summary>
private static async Task<IResult> ForwardWithCredentials(
HttpRequest request, IHttpClientFactory httpClientFactory,
string provisioningPath, string httpMethod, CancellationToken ct,
Func<JsonNode, IHttpClientFactory, CancellationToken, Task>? enrichBody = null)
{
JsonNode? body = await JsonNode.ParseAsync(request.Body, cancellationToken: ct);
if (body is null)
{
return Results.BadRequest("Request body is required.");
}
Guid? clusterId = body["clusterId"]?.GetValue<Guid>();
if (clusterId is null)
{
return Results.BadRequest("clusterId is required.");
}
ClusterCreds? creds = await GetClusterCredentials(httpClientFactory, clusterId.Value, ct);
if (creds is null)
{
return Results.BadRequest("Could not resolve cluster credentials.");
}
body["kubeConfig"] = creds.KubeConfig;
body["contextName"] = creds.ContextName;
if (enrichBody is not null)
{
await enrichBody(body, httpClientFactory, ct);
}
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpContent content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
HttpResponseMessage response = httpMethod == "PUT"
? await client.PutAsync(provisioningPath, content, ct)
: await client.PostAsync(provisioningPath, content, ct);
if (!response.IsSuccessStatusCode)
{
string errorBody = await response.Content.ReadAsStringAsync(ct);
return Results.Problem(errorBody, statusCode: (int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
}
private static async Task<ClusterCreds?> GetClusterCredentials(
IHttpClientFactory httpClientFactory, Guid clusterId, CancellationToken ct)
{
HttpClient clustersClient = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage credsResponse = await clustersClient.GetAsync($"/api/clusters/{clusterId}/credentials", ct);
if (!credsResponse.IsSuccessStatusCode)
{
return null;
}
JsonNode? credsBody = await JsonNode.ParseAsync(
await credsResponse.Content.ReadAsStreamAsync(ct), cancellationToken: ct);
string? kubeConfig = credsBody?["data"]?["kubeConfig"]?.GetValue<string>();
string? contextName = credsBody?["data"]?["contextName"]?.GetValue<string>();
if (string.IsNullOrEmpty(kubeConfig) || string.IsNullOrEmpty(contextName))
{
return null;
}
return new ClusterCreds(kubeConfig, contextName);
}
private record ClusterCreds(string KubeConfig, string ContextName);
/// <summary>
/// Fetches bucket credentials from the Clusters service and injects them into the
/// request body so the Provisioning service can create the secret directly.
/// </summary>
private static async Task EnrichWithBucketCredentials(
JsonNode body, IHttpClientFactory httpClientFactory, CancellationToken ct)
{
Guid? clusterId = body["clusterId"]?.GetValue<Guid>();
string? endpoint = body["minioEndpoint"]?.GetValue<string>();
if (clusterId is null || string.IsNullOrWhiteSpace(endpoint))
{
return;
}
HttpClient clustersClient = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage bucketsResponse = await clustersClient.GetAsync(
$"/api/clusters/{clusterId}/storage/buckets", ct);
if (!bucketsResponse.IsSuccessStatusCode)
{
return;
}
JsonNode? bucketsBody = await JsonNode.ParseAsync(
await bucketsResponse.Content.ReadAsStreamAsync(ct), cancellationToken: ct);
JsonArray? buckets = bucketsBody?["data"]?.AsArray();
if (buckets is null)
{
return;
}
foreach (JsonNode? bucket in buckets)
{
if (bucket is null)
{
continue;
}
string? bucketEndpoint = bucket["endpoint"]?.GetValue<string>();
if (string.Equals(bucketEndpoint, endpoint, StringComparison.OrdinalIgnoreCase))
{
Guid bucketId = bucket["id"]!.GetValue<Guid>();
HttpResponseMessage detailResponse = await clustersClient.GetAsync(
$"/api/clusters/{clusterId}/storage/buckets/{bucketId}", ct);
if (!detailResponse.IsSuccessStatusCode)
{
return;
}
JsonNode? detailBody = await JsonNode.ParseAsync(
await detailResponse.Content.ReadAsStreamAsync(ct), cancellationToken: ct);
string? accessKey = detailBody?["data"]?["accessKey"]?.GetValue<string>();
string? secretKey = detailBody?["data"]?["secretKey"]?.GetValue<string>();
string? region = detailBody?["data"]?["region"]?.GetValue<string>();
if (!string.IsNullOrWhiteSpace(accessKey) && !string.IsNullOrWhiteSpace(secretKey))
{
body["s3AccessKey"] = accessKey;
body["s3SecretKey"] = secretKey;
if (!string.IsNullOrWhiteSpace(region))
{
body["s3Region"] = region;
}
}
return;
}
}
}
}

View File

@@ -1,398 +0,0 @@
using System.Text;
using System.Text.Json.Nodes;
namespace EntKube.Web.Components.Provisioning;
/// <summary>
/// BFF proxy endpoints for PostgreSQL cluster management. The Blazor frontend
/// calls these endpoints, and the BFF forwards requests to the Provisioning
/// microservice's /api/postgres-clusters routes.
///
/// The frontend never sends kubeConfig — the BFF resolves it server-side by
/// calling the Clusters service's /api/clusters/{id}/credentials endpoint,
/// then injects it into the request before forwarding to Provisioning.
/// </summary>
public static class PostgresClusterProxyEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/postgres-clusters")
.RequireAuthorization()
.DisableAntiforgery();
// GET /api/postgres-clusters — list clusters, optionally filtered by clusterId.
group.MapGet("", async (Guid? clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
string url = clusterId.HasValue
? $"/api/postgres-clusters?clusterId={clusterId.Value}"
: "/api/postgres-clusters";
HttpResponseMessage response = await client.GetAsync(url, ct);
response.EnsureSuccessStatusCode();
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// GET /api/postgres-clusters/{id} — get a single cluster.
group.MapGet("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync($"/api/postgres-clusters/{id}", ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// POST /api/postgres-clusters — create a new PG cluster.
// Frontend sends clusterId + config. BFF injects kubeConfig/contextName
// and bucket credentials so the Provisioning service can create the K8s
// secret directly without depending on a pre-existing copy in cnpg-system.
group.MapPost("", async (HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, "/api/postgres-clusters", "POST", ct,
enrichBody: async (body, factory, cancellation) =>
{
await EnrichWithBucketCredentials(body, factory, cancellation);
});
});
// POST /api/postgres-clusters/adopt — discover and adopt existing clusters.
group.MapPost("adopt", async (HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, "/api/postgres-clusters/adopt", "POST", ct);
});
// POST /api/postgres-clusters/{id}/databases — add a database.
group.MapPost("{id:guid}/databases", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/postgres-clusters/{id}/databases", "POST", ct);
});
// POST /api/postgres-clusters/{id}/upgrade — initiate version upgrade.
group.MapPost("{id:guid}/upgrade", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/postgres-clusters/{id}/upgrade", "POST", ct);
});
// PUT /api/postgres-clusters/{id}/configure — reconfigure settings.
group.MapPut("{id:guid}/configure", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/postgres-clusters/{id}/configure", "PUT", ct);
});
// GET /api/postgres-clusters/{id}/health — live health from K8s.
// The BFF resolves credentials server-side and forwards to Provisioning
// via POST (kubeConfig is too large for query strings).
group.MapGet("{id:guid}/health", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, $"/api/postgres-clusters/{id}/health", ct);
});
// GET /api/postgres-clusters/{id}/backups — list backups.
group.MapGet("{id:guid}/backups", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, $"/api/postgres-clusters/{id}/backups", ct);
});
// GET /api/postgres-clusters/{id}/backups/diagnose — raw backup diagnostics.
group.MapGet("{id:guid}/backups/diagnose", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, $"/api/postgres-clusters/{id}/backups/diagnose", ct);
});
// POST /api/postgres-clusters/{id}/backup — trigger on-demand backup.
group.MapPost("{id:guid}/backup", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/postgres-clusters/{id}/backup", "POST", ct);
});
// POST /api/postgres-clusters/{id}/restart — rolling restart.
group.MapPost("{id:guid}/restart", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/postgres-clusters/{id}/restart", "POST", ct);
});
// POST /api/postgres-clusters/{id}/failover — controlled switchover.
group.MapPost("{id:guid}/failover", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/postgres-clusters/{id}/failover", "POST", ct);
});
// DELETE /api/postgres-clusters/{id}/databases/{dbName} — drop a database.
group.MapDelete("{id:guid}/databases/{dbName}", async (Guid id, string dbName, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, $"/api/postgres-clusters/{id}/databases/{dbName}", ct, "DELETE");
});
// DELETE /api/postgres-clusters/{id} — delete the entire cluster.
group.MapDelete("{id:guid}", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, $"/api/postgres-clusters/{id}", ct, "DELETE");
});
// POST /api/postgres-clusters/{id}/restore — restore from backup.
group.MapPost("{id:guid}/restore", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/postgres-clusters/{id}/restore", "POST", ct);
});
// POST /api/postgres-clusters/{id}/restore/preview — dry-run restore preview.
group.MapPost("{id:guid}/restore/preview", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/postgres-clusters/{id}/restore/preview", "POST", ct);
});
// GET /api/postgres-clusters/available-versions — list supported PG versions.
group.MapGet("available-versions", async (Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, "/api/postgres-clusters/available-versions", ct);
});
// GET /api/postgres-clusters/{id}/databases/live — live database listing.
group.MapGet("{id:guid}/databases/live", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, $"/api/postgres-clusters/{id}/databases/live", ct);
});
}
/// <summary>
/// Forwards a request to Provisioning by injecting kubeConfig+contextName into
/// the body as a POST. This avoids putting large kubeConfig data into query
/// strings, which can exceed URL length limits.
/// </summary>
private static async Task<IResult> ForwardGetWithCredentials(
IHttpClientFactory httpClientFactory, Guid clusterId,
string provisioningPath, CancellationToken ct, string httpMethod = "POST")
{
ClusterCreds? creds = await GetClusterCredentials(httpClientFactory, clusterId, ct);
if (creds is null)
{
return Results.BadRequest("Could not resolve cluster credentials.");
}
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
JsonNode body = new JsonObject
{
["kubeConfig"] = creds.KubeConfig,
["contextName"] = creds.ContextName
};
HttpContent content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
HttpResponseMessage response = httpMethod == "DELETE"
? await client.SendAsync(new HttpRequestMessage(HttpMethod.Delete, provisioningPath) { Content = content }, ct)
: await client.PostAsync(provisioningPath, content, ct);
if (!response.IsSuccessStatusCode)
{
string error = await response.Content.ReadAsStringAsync(ct);
return Results.Problem(error, statusCode: (int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
}
/// <summary>
/// Reads the request body, resolves kubeConfig from the Clusters service using
/// the clusterId in the body, injects the credentials, and forwards to Provisioning.
/// An optional enrichBody callback can modify the body before forwarding.
/// </summary>
private static async Task<IResult> ForwardWithCredentials(
HttpRequest request, IHttpClientFactory httpClientFactory,
string provisioningPath, string httpMethod, CancellationToken ct,
Func<JsonNode, IHttpClientFactory, CancellationToken, Task>? enrichBody = null)
{
JsonNode? body = await JsonNode.ParseAsync(request.Body, cancellationToken: ct);
if (body is null)
{
return Results.BadRequest("Request body is required.");
}
// Resolve kubeConfig from the Clusters service.
Guid? clusterId = body["clusterId"]?.GetValue<Guid>();
if (clusterId is null)
{
return Results.BadRequest("clusterId is required.");
}
ClusterCreds? creds = await GetClusterCredentials(httpClientFactory, clusterId.Value, ct);
if (creds is null)
{
return Results.BadRequest("Could not resolve cluster credentials.");
}
body["kubeConfig"] = creds.KubeConfig;
body["contextName"] = creds.ContextName;
// Allow callers to enrich the body with additional data before forwarding.
if (enrichBody is not null)
{
await enrichBody(body, httpClientFactory, ct);
}
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpContent content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
HttpResponseMessage response = httpMethod == "PUT"
? await client.PutAsync(provisioningPath, content, ct)
: await client.PostAsync(provisioningPath, content, ct);
if (!response.IsSuccessStatusCode)
{
string errorBody = await response.Content.ReadAsStringAsync(ct);
return Results.Problem(errorBody, statusCode: (int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
}
/// <summary>
/// Fetches kubeConfig and contextName from the Clusters service for a given
/// Kubernetes cluster ID. This is a server-to-server call — the frontend
/// never sees the kubeConfig.
/// </summary>
private static async Task<ClusterCreds?> GetClusterCredentials(
IHttpClientFactory httpClientFactory, Guid clusterId, CancellationToken ct)
{
HttpClient clustersClient = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage credsResponse = await clustersClient.GetAsync($"/api/clusters/{clusterId}/credentials", ct);
if (!credsResponse.IsSuccessStatusCode)
{
return null;
}
JsonNode? credsBody = await JsonNode.ParseAsync(
await credsResponse.Content.ReadAsStreamAsync(ct), cancellationToken: ct);
string? kubeConfig = credsBody?["data"]?["kubeConfig"]?.GetValue<string>();
string? contextName = credsBody?["data"]?["contextName"]?.GetValue<string>();
if (string.IsNullOrEmpty(kubeConfig) || string.IsNullOrEmpty(contextName))
{
return null;
}
return new ClusterCreds(kubeConfig, contextName);
}
private record ClusterCreds(string KubeConfig, string ContextName);
/// <summary>
/// When the frontend selected a storage bucket for backups, fetch the bucket's
/// access key and secret key from the Clusters service and inject them into the
/// request body. This allows the Provisioning service to create the K8s secret
/// directly in the target namespace without depending on a copy in cnpg-system.
/// </summary>
private static async Task EnrichWithBucketCredentials(
JsonNode body, IHttpClientFactory httpClientFactory, CancellationToken ct)
{
// The frontend sends minioCredentialsSecret = "cnpg-backup-creds" and
// clusterId. We need to find which bucket was selected to get its credentials.
// The frontend doesn't send bucketId to the provisioning path, but we can
// look up the bucket by matching the endpoint that was sent.
Guid? clusterId = body["clusterId"]?.GetValue<Guid>();
string? endpoint = body["minioEndpoint"]?.GetValue<string>();
if (clusterId is null || string.IsNullOrWhiteSpace(endpoint))
{
return;
}
HttpClient clustersClient = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage bucketsResponse = await clustersClient.GetAsync(
$"/api/clusters/{clusterId}/storage/buckets", ct);
if (!bucketsResponse.IsSuccessStatusCode)
{
return;
}
JsonNode? bucketsBody = await JsonNode.ParseAsync(
await bucketsResponse.Content.ReadAsStreamAsync(ct), cancellationToken: ct);
JsonArray? buckets = bucketsBody?["data"]?.AsArray();
if (buckets is null)
{
return;
}
// Find the bucket that matches the endpoint the frontend sent.
foreach (JsonNode? bucket in buckets)
{
if (bucket is null)
{
continue;
}
string? bucketEndpoint = bucket["endpoint"]?.GetValue<string>();
if (string.Equals(bucketEndpoint, endpoint, StringComparison.OrdinalIgnoreCase))
{
// Found the matching bucket — get its full details (including secrets).
Guid bucketId = bucket["id"]!.GetValue<Guid>();
HttpResponseMessage detailResponse = await clustersClient.GetAsync(
$"/api/clusters/{clusterId}/storage/buckets/{bucketId}", ct);
if (!detailResponse.IsSuccessStatusCode)
{
return;
}
JsonNode? detailBody = await JsonNode.ParseAsync(
await detailResponse.Content.ReadAsStreamAsync(ct), cancellationToken: ct);
string? accessKey = detailBody?["data"]?["accessKey"]?.GetValue<string>();
string? secretKey = detailBody?["data"]?["secretKey"]?.GetValue<string>();
if (!string.IsNullOrWhiteSpace(accessKey) && !string.IsNullOrWhiteSpace(secretKey))
{
body["s3AccessKey"] = accessKey;
body["s3SecretKey"] = secretKey;
}
return;
}
}
}
}

View File

@@ -1,121 +0,0 @@
using System.Net.Http.Json;
namespace EntKube.Web.Components.Provisioning;
/// <summary>
/// BFF proxy endpoints for Prometheus stack management. Forwards requests from
/// the Blazor frontend to the Provisioning microservice's Prometheus endpoints.
/// </summary>
public static class PrometheusProxyEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/prometheus")
.RequireAuthorization()
.DisableAntiforgery();
// GET /api/prometheus — list all Prometheus stacks
group.MapGet("", async (IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync("/api/prometheus", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// GET /api/prometheus/cluster/{clusterId}
group.MapGet("cluster/{clusterId:guid}", async (Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync($"/api/prometheus/cluster/{clusterId}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// GET /api/prometheus/environment/{environmentId}
group.MapGet("environment/{environmentId:guid}", async (Guid environmentId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync($"/api/prometheus/environment/{environmentId}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// GET /api/prometheus/{id}
group.MapGet("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync($"/api/prometheus/{id}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// POST /api/prometheus — create a new Prometheus stack
group.MapPost("", async (CreatePrometheusStackProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PostAsJsonAsync("/api/prometheus", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// DELETE /api/prometheus/{id}
group.MapDelete("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.DeleteAsync($"/api/prometheus/{id}", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// PUT /api/prometheus/{id}/config
group.MapPut("{id:guid}/config", async (Guid id, ConfigurePrometheusProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PutAsJsonAsync($"/api/prometheus/{id}/config", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// PUT /api/prometheus/{id}/alertmanager
group.MapPut("{id:guid}/alertmanager", async (Guid id, ConfigureAlertmanagerProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PutAsJsonAsync($"/api/prometheus/{id}/alertmanager", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// PUT /api/prometheus/{id}/ingress
group.MapPut("{id:guid}/ingress", async (Guid id, ConfigureIngressProxyRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PutAsJsonAsync($"/api/prometheus/{id}/ingress", request, ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
// DELETE /api/prometheus/{id}/ingress
group.MapDelete("{id:guid}/ingress", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.DeleteAsync($"/api/prometheus/{id}/ingress", ct);
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
});
}
}
// ─── Proxy Request DTOs ─────────────────────────────────────────────
public record CreatePrometheusStackProxyRequest(Guid ClusterId, Guid EnvironmentId, string Name, string Namespace);
public record ConfigurePrometheusProxyRequest(string? Retention, int? Replicas, string? StorageSize, string? RetentionSize);
public record ConfigureAlertmanagerProxyRequest(bool? Enabled, int? Replicas);
public record ConfigureIngressProxyRequest(
bool Enabled, string Provider,
string? PrometheusHostname, string? AlertmanagerHostname,
bool TlsEnabled, string TlsCertificateMode = "Manual",
string? TlsSecretName = null, string? ClusterIssuer = null);

View File

@@ -1,241 +0,0 @@
using System.Text;
using System.Text.Json.Nodes;
namespace EntKube.Web.Components.Provisioning;
/// <summary>
/// BFF proxy endpoints for Redis cluster management. The Blazor frontend calls
/// these endpoints, and the BFF forwards requests to the Provisioning
/// microservice's /api/redis-clusters routes.
///
/// The frontend never sends kubeConfig — the BFF resolves it server-side by
/// calling the Clusters service's /api/clusters/{id}/credentials endpoint,
/// then injects it into the request before forwarding to Provisioning.
/// </summary>
public static class RedisClusterProxyEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/redis-clusters")
.RequireAuthorization()
.DisableAntiforgery();
// GET /api/redis-clusters — list clusters, optionally filtered by clusterId.
group.MapGet("", async (Guid? clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
string url = clusterId.HasValue
? $"/api/redis-clusters?clusterId={clusterId.Value}"
: "/api/redis-clusters";
HttpResponseMessage response = await client.GetAsync(url, ct);
response.EnsureSuccessStatusCode();
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// GET /api/redis-clusters/{id} — get a single cluster.
group.MapGet("{id:guid}", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.GetAsync($"/api/redis-clusters/{id}", ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// POST /api/redis-clusters — create a new Redis instance.
// Frontend sends clusterId + config. BFF injects kubeConfig/contextName.
group.MapPost("", async (HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, "/api/redis-clusters", "POST", ct);
});
// POST /api/redis-clusters/adopt — discover and adopt existing instances.
group.MapPost("adopt", async (HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, "/api/redis-clusters/adopt", "POST", ct);
});
// POST /api/redis-clusters/{id}/upgrade — initiate version upgrade.
group.MapPost("{id:guid}/upgrade", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/redis-clusters/{id}/upgrade", "POST", ct);
});
// PUT /api/redis-clusters/{id}/configure — reconfigure settings.
group.MapPut("{id:guid}/configure", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/redis-clusters/{id}/configure", "PUT", ct);
});
// GET /api/redis-clusters/{id}/health — live health from K8s.
// The BFF resolves credentials server-side and forwards to Provisioning
// via POST (kubeConfig is too large for query strings).
group.MapGet("{id:guid}/health", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, $"/api/redis-clusters/{id}/health", ct);
});
// POST /api/redis-clusters/{id}/restart — rolling restart.
group.MapPost("{id:guid}/restart", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/redis-clusters/{id}/restart", "POST", ct);
});
// POST /api/redis-clusters/{id}/failover — controlled switchover.
group.MapPost("{id:guid}/failover", async (Guid id, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardWithCredentials(request, httpClientFactory, $"/api/redis-clusters/{id}/failover", "POST", ct);
});
// DELETE /api/redis-clusters/{id} — delete a Redis instance.
group.MapDelete("{id:guid}", async (Guid id, Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, $"/api/redis-clusters/{id}", ct, "DELETE");
});
// GET /api/redis-clusters/available-versions — list supported Redis versions.
group.MapGet("available-versions", async (Guid clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await ForwardGetWithCredentials(
httpClientFactory, clusterId, "/api/redis-clusters/available-versions", ct);
});
}
/// <summary>
/// Forwards a request to Provisioning by injecting kubeConfig+contextName into
/// the body as a POST. This avoids putting large kubeConfig data into query
/// strings, which can exceed URL length limits.
/// </summary>
private static async Task<IResult> ForwardGetWithCredentials(
IHttpClientFactory httpClientFactory, Guid clusterId,
string provisioningPath, CancellationToken ct, string httpMethod = "POST")
{
ClusterCreds? creds = await GetClusterCredentials(httpClientFactory, clusterId, ct);
if (creds is null)
{
return Results.BadRequest("Could not resolve cluster credentials.");
}
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
JsonNode body = new JsonObject
{
["kubeConfig"] = creds.KubeConfig,
["contextName"] = creds.ContextName
};
HttpContent content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
HttpResponseMessage response = httpMethod == "DELETE"
? await client.SendAsync(new HttpRequestMessage(HttpMethod.Delete, provisioningPath) { Content = content }, ct)
: await client.PostAsync(provisioningPath, content, ct);
if (!response.IsSuccessStatusCode)
{
string error = await response.Content.ReadAsStringAsync(ct);
return Results.Problem(error, statusCode: (int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
}
/// <summary>
/// Reads the request body, resolves kubeConfig from the Clusters service using
/// the clusterId in the body, injects the credentials, and forwards to Provisioning.
/// </summary>
private static async Task<IResult> ForwardWithCredentials(
HttpRequest request, IHttpClientFactory httpClientFactory,
string provisioningPath, string httpMethod, CancellationToken ct)
{
JsonNode? body = await JsonNode.ParseAsync(request.Body, cancellationToken: ct);
if (body is null)
{
return Results.BadRequest("Request body is required.");
}
// Resolve kubeConfig from the Clusters service.
Guid? clusterId = body["clusterId"]?.GetValue<Guid>();
if (clusterId is null)
{
return Results.BadRequest("clusterId is required.");
}
ClusterCreds? creds = await GetClusterCredentials(httpClientFactory, clusterId.Value, ct);
if (creds is null)
{
return Results.BadRequest("Could not resolve cluster credentials.");
}
body["kubeConfig"] = creds.KubeConfig;
body["contextName"] = creds.ContextName;
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpContent content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
HttpResponseMessage response = httpMethod == "PUT"
? await client.PutAsync(provisioningPath, content, ct)
: await client.PostAsync(provisioningPath, content, ct);
if (!response.IsSuccessStatusCode)
{
string errorBody = await response.Content.ReadAsStringAsync(ct);
return Results.Problem(errorBody, statusCode: (int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
}
/// <summary>
/// Fetches kubeConfig and contextName from the Clusters service for a given
/// Kubernetes cluster ID. This is a server-to-server call — the frontend
/// never sees the kubeConfig.
/// </summary>
private static async Task<ClusterCreds?> GetClusterCredentials(
IHttpClientFactory httpClientFactory, Guid clusterId, CancellationToken ct)
{
HttpClient clustersClient = httpClientFactory.CreateClient("ClustersApi");
HttpResponseMessage credsResponse = await clustersClient.GetAsync($"/api/clusters/{clusterId}/credentials", ct);
if (!credsResponse.IsSuccessStatusCode)
{
return null;
}
JsonNode? credsBody = await JsonNode.ParseAsync(
await credsResponse.Content.ReadAsStreamAsync(ct), cancellationToken: ct);
string? kubeConfig = credsBody?["data"]?["kubeConfig"]?.GetValue<string>();
string? contextName = credsBody?["data"]?["contextName"]?.GetValue<string>();
if (string.IsNullOrEmpty(kubeConfig) || string.IsNullOrEmpty(contextName))
{
return null;
}
return new ClusterCreds(kubeConfig, contextName);
}
private record ClusterCreds(string KubeConfig, string ContextName);
}

View File

@@ -1,60 +0,0 @@
using System.Net.Http.Json;
namespace EntKube.Web.Components.Provisioning;
/// <summary>
/// BFF proxy endpoints for service provisioning. The Blazor frontend calls these
/// endpoints, and the BFF forwards requests to the Provisioning microservice.
/// </summary>
public static class ServicesProxyEndpoints
{
public static void Map(IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/services")
.RequireAuthorization()
.DisableAntiforgery();
// GET /api/services — list all services (optionally filtered by clusterId)
group.MapGet("", async (Guid? clusterId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
string url = clusterId.HasValue
? $"/api/services?clusterId={clusterId.Value}"
: "/api/services";
HttpResponseMessage response = await client.GetAsync(url, ct);
response.EnsureSuccessStatusCode();
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// POST /api/services — provision a new service
group.MapPost("", async (ProvisionServiceRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PostAsJsonAsync("/api/services", request, ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
// POST /api/services/{id}/decommission — request decommissioning
group.MapPost("{id:guid}/decommission", async (Guid id, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
HttpClient client = httpClientFactory.CreateClient("ProvisioningApi");
HttpResponseMessage response = await client.PostAsync($"/api/services/{id}/decommission", null, ct);
if (!response.IsSuccessStatusCode)
{
return Results.StatusCode((int)response.StatusCode);
}
return Results.Stream(await response.Content.ReadAsStreamAsync(ct), "application/json");
});
}
}
public record ProvisionServiceRequest(Guid ClusterId, string ServiceType, string Name, string Namespace);

View File

@@ -0,0 +1,10 @@
<Router AppAssembly="typeof(Program).Assembly" AdditionalAssemblies="new[] { typeof(Client._Imports).Assembly }" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
<NotAuthorized>
<RedirectToLogin />
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>

View File

@@ -1,180 +0,0 @@
namespace EntKube.Web.Components.Secrets;
/// <summary>
/// BFF proxy endpoints for the Secrets microservice. All vault operations
/// are tenant-scoped — the frontend passes the tenantId in the URL path.
/// </summary>
public static class SecretsProxyEndpoints
{
private static async Task<IResult> ForwardResponseAsync(HttpResponseMessage response, CancellationToken ct)
{
string body = await response.Content.ReadAsStringAsync(ct);
return Results.Text(body, "application/json", statusCode: (int)response.StatusCode);
}
private static async Task<IResult> SafeProxyAsync(Func<Task<IResult>> action)
{
try
{
return await action();
}
catch (HttpRequestException)
{
return Results.Json(
new { Success = false, Error = "Secrets service is unreachable. Make sure it is running on the configured port." },
statusCode: 502);
}
}
public static void Map(IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/vault")
.RequireAuthorization()
.DisableAntiforgery();
// GET /api/vault/{tenantId}/status — tenant vault status
group.MapGet("{tenantId:guid}/status", async (Guid tenantId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await SafeProxyAsync(async () =>
{
HttpClient client = httpClientFactory.CreateClient("SecretsApi");
HttpResponseMessage response = await client.GetAsync($"/api/vault/{tenantId}/status", ct);
return await ForwardResponseAsync(response, ct);
});
});
// POST /api/vault/{tenantId}/init — initialize tenant vault
group.MapPost("{tenantId:guid}/init", async (Guid tenantId, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await SafeProxyAsync(async () =>
{
HttpClient client = httpClientFactory.CreateClient("SecretsApi");
StreamContent content = new(request.Body);
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
HttpResponseMessage response = await client.PostAsync($"/api/vault/{tenantId}/init", content, ct);
return await ForwardResponseAsync(response, ct);
});
});
// POST /api/vault/{tenantId}/seal — seal tenant vault
group.MapPost("{tenantId:guid}/seal", async (Guid tenantId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await SafeProxyAsync(async () =>
{
HttpClient client = httpClientFactory.CreateClient("SecretsApi");
HttpResponseMessage response = await client.PostAsync($"/api/vault/{tenantId}/seal", null, ct);
return await ForwardResponseAsync(response, ct);
});
});
// POST /api/vault/seal — seal all tenants
group.MapPost("seal", async (IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await SafeProxyAsync(async () =>
{
HttpClient client = httpClientFactory.CreateClient("SecretsApi");
HttpResponseMessage response = await client.PostAsync("/api/vault/seal", null, ct);
return await ForwardResponseAsync(response, ct);
});
});
// POST /api/vault/{tenantId}/unseal — provide unseal share
group.MapPost("{tenantId:guid}/unseal", async (Guid tenantId, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await SafeProxyAsync(async () =>
{
HttpClient client = httpClientFactory.CreateClient("SecretsApi");
StreamContent content = new(request.Body);
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
HttpResponseMessage response = await client.PostAsync($"/api/vault/{tenantId}/unseal", content, ct);
return await ForwardResponseAsync(response, ct);
});
});
// GET /api/vault/{tenantId}/secrets — list secret paths
group.MapGet("{tenantId:guid}/secrets", async (Guid tenantId, string? prefix, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await SafeProxyAsync(async () =>
{
HttpClient client = httpClientFactory.CreateClient("SecretsApi");
string url = string.IsNullOrEmpty(prefix)
? $"/api/vault/{tenantId}/secrets"
: $"/api/vault/{tenantId}/secrets?prefix={Uri.EscapeDataString(prefix)}";
HttpResponseMessage response = await client.GetAsync(url, ct);
return await ForwardResponseAsync(response, ct);
});
});
// GET /api/vault/{tenantId}/secrets/{path} — get a secret
group.MapGet("{tenantId:guid}/secrets/{*path}", async (Guid tenantId, string path, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await SafeProxyAsync(async () =>
{
HttpClient client = httpClientFactory.CreateClient("SecretsApi");
HttpResponseMessage response = await client.GetAsync($"/api/vault/{tenantId}/secrets/{path}", ct);
return await ForwardResponseAsync(response, ct);
});
});
// POST /api/vault/{tenantId}/secrets/{path} — put a secret
group.MapPost("{tenantId:guid}/secrets/{*path}", async (Guid tenantId, string path, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await SafeProxyAsync(async () =>
{
HttpClient client = httpClientFactory.CreateClient("SecretsApi");
StreamContent content = new(request.Body);
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
HttpResponseMessage response = await client.PostAsync($"/api/vault/{tenantId}/secrets/{path}", content, ct);
return await ForwardResponseAsync(response, ct);
});
});
// DELETE /api/vault/{tenantId}/secrets/{path} — delete a secret
group.MapDelete("{tenantId:guid}/secrets/{*path}", async (Guid tenantId, string path, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await SafeProxyAsync(async () =>
{
HttpClient client = httpClientFactory.CreateClient("SecretsApi");
HttpResponseMessage response = await client.DeleteAsync($"/api/vault/{tenantId}/secrets/{path}", ct);
return await ForwardResponseAsync(response, ct);
});
});
// POST /api/vault/{tenantId}/tokens — create a service token
group.MapPost("{tenantId:guid}/tokens", async (Guid tenantId, HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await SafeProxyAsync(async () =>
{
HttpClient client = httpClientFactory.CreateClient("SecretsApi");
StreamContent content = new(request.Body);
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
HttpResponseMessage response = await client.PostAsync($"/api/vault/{tenantId}/tokens", content, ct);
return await ForwardResponseAsync(response, ct);
});
});
// GET /api/vault/{tenantId}/tokens — list service tokens
group.MapGet("{tenantId:guid}/tokens", async (Guid tenantId, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await SafeProxyAsync(async () =>
{
HttpClient client = httpClientFactory.CreateClient("SecretsApi");
HttpResponseMessage response = await client.GetAsync($"/api/vault/{tenantId}/tokens", ct);
return await ForwardResponseAsync(response, ct);
});
});
// POST /api/vault/tokens/revoke — revoke a token (global, token hash is unique)
group.MapPost("tokens/revoke", async (HttpRequest request, IHttpClientFactory httpClientFactory, CancellationToken ct) =>
{
return await SafeProxyAsync(async () =>
{
HttpClient client = httpClientFactory.CreateClient("SecretsApi");
StreamContent content = new(request.Body);
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
HttpResponseMessage response = await client.PostAsync("/api/vault/tokens/revoke", content, ct);
return await ForwardResponseAsync(response, ct);
});
});
}
}

View File

@@ -9,5 +9,5 @@
@using Microsoft.JSInterop
@using EntKube.Web
@using EntKube.Web.Client
@using EntKube.Web.Client.Layout
@using EntKube.Web.Components
@using EntKube.Web.Components.Layout