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

View File

@@ -0,0 +1,25 @@
namespace EntKube.Web.Data;
/// <summary>
/// An app represents a software application owned by a customer. Apps can be
/// deployed to one or many environments via the AppEnvironment join entity.
/// </summary>
public class App
{
public Guid Id { get; set; }
public Guid CustomerId { get; set; }
/// <summary>
/// The application name. Must be unique within a customer.
/// </summary>
public required string Name { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation
public Customer Customer { get; set; } = null!;
public ICollection<AppEnvironment> AppEnvironments { get; set; } = [];
public ICollection<VaultSecret> Secrets { get; set; } = [];
public ICollection<AppDeployment> Deployments { get; set; } = [];
}

View File

@@ -0,0 +1,89 @@
namespace EntKube.Web.Data;
/// <summary>
/// An app deployment represents a deployable unit targeting a specific cluster
/// and namespace. Think of it like an ArgoCD Application — it describes what
/// should be deployed, where, and tracks whether the live state matches.
///
/// A deployment can be defined three ways:
/// - Manual: structured form (containers, services, PVCs) → generated manifests
/// - Yaml: raw K8s YAML pasted or uploaded
/// - HelmChart: a Helm chart with repo, name, version, and dynamic values
///
/// Regardless of type, the platform tracks the deployment's sync and health
/// status by comparing desired state to live cluster state.
/// </summary>
public class AppDeployment
{
public Guid Id { get; set; }
public Guid AppId { get; set; }
/// <summary>
/// A human-friendly name for this deployment (e.g. "billing-api-prod").
/// Must be unique within an app.
/// </summary>
public required string Name { get; set; }
/// <summary>
/// How this deployment is defined — Manual form, raw YAML, or Helm chart.
/// </summary>
public DeploymentType Type { get; set; }
// ── Target ──
/// <summary>
/// The environment this deployment targets (e.g. Production).
/// </summary>
public Guid EnvironmentId { get; set; }
/// <summary>
/// The cluster within that environment where resources will be applied.
/// </summary>
public Guid ClusterId { get; set; }
/// <summary>
/// The Kubernetes namespace for all resources in this deployment.
/// </summary>
public required string Namespace { get; set; }
// ── Status (ArgoCD-style) ──
public SyncStatus SyncStatus { get; set; } = SyncStatus.Unknown;
public HealthStatus HealthStatus { get; set; } = HealthStatus.Unknown;
public string? StatusMessage { get; set; }
public DateTime? LastSyncedAt { get; set; }
// ── Helm-specific fields (only used when Type == HelmChart) ──
/// <summary>
/// The Helm chart repository URL (e.g. "https://charts.bitnami.com/bitnami").
/// </summary>
public string? HelmRepoUrl { get; set; }
/// <summary>
/// The chart name within the repository (e.g. "postgresql").
/// </summary>
public string? HelmChartName { get; set; }
/// <summary>
/// The chart version to deploy (e.g. "15.5.0").
/// </summary>
public string? HelmChartVersion { get; set; }
/// <summary>
/// The Helm values as YAML text. These override the chart's default values.
/// Stored as free-form YAML so any chart's values can be represented.
/// </summary>
public string? HelmValues { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation
public App App { get; set; } = null!;
public Environment Environment { get; set; } = null!;
public KubernetesCluster Cluster { get; set; } = null!;
public ICollection<DeploymentManifest> Manifests { get; set; } = [];
public ICollection<DeploymentResource> Resources { get; set; } = [];
public ICollection<StorageBinding> StorageBindings { get; set; } = [];
}

View File

@@ -0,0 +1,19 @@
namespace EntKube.Web.Data;
/// <summary>
/// Join entity linking an app to an environment. This represents the
/// many-to-many relationship: an app can be deployed to multiple environments,
/// and an environment can host multiple apps.
/// </summary>
public class AppEnvironment
{
public Guid AppId { get; set; }
public Guid EnvironmentId { get; set; }
public DateTime LinkedAt { get; set; } = DateTime.UtcNow;
// Navigation
public App App { get; set; } = null!;
public Environment Environment { get; set; } = null!;
}

View File

@@ -1,8 +1,498 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace EntKube.Web.Data;
public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : IdentityDbContext<ApplicationUser>(options)
/// <summary>
/// The main database context for EntKube, holding Identity tables and all
/// application entities. This is the base context — provider-specific
/// subclasses exist for PostgreSQL and SQL Server so EF Core can generate
/// provider-appropriate migrations independently.
/// </summary>
public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<ApplicationUser>(options)
{
public DbSet<Tenant> Tenants => Set<Tenant>();
public DbSet<TenantRole> TenantRoles => Set<TenantRole>();
public DbSet<TenantMembership> TenantMemberships => Set<TenantMembership>();
public DbSet<Group> Groups => Set<Group>();
public DbSet<GroupMembership> GroupMemberships => Set<GroupMembership>();
public DbSet<Environment> Environments => Set<Environment>();
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<App> Apps => Set<App>();
public DbSet<AppEnvironment> AppEnvironments => Set<AppEnvironment>();
public DbSet<KubernetesCluster> KubernetesClusters => Set<KubernetesCluster>();
public DbSet<SecretVault> SecretVaults => Set<SecretVault>();
public DbSet<VaultSecret> VaultSecrets => Set<VaultSecret>();
public DbSet<ClusterComponent> ClusterComponents => Set<ClusterComponent>();
public DbSet<ExternalRoute> ExternalRoutes => Set<ExternalRoute>();
public DbSet<StorageLink> StorageLinks => Set<StorageLink>();
public DbSet<OpenStackConnection> OpenStackConnections => Set<OpenStackConnection>();
public DbSet<StorageBinding> StorageBindings => Set<StorageBinding>();
public DbSet<AppDeployment> AppDeployments => Set<AppDeployment>();
public DbSet<DeploymentManifest> DeploymentManifests => Set<DeploymentManifest>();
public DbSet<DeploymentResource> DeploymentResources => Set<DeploymentResource>();
public DbSet<CustomerAccess> CustomerAccesses => Set<CustomerAccess>();
public DbSet<CnpgCluster> CnpgClusters => Set<CnpgCluster>();
public DbSet<CnpgDatabase> CnpgDatabases => Set<CnpgDatabase>();
public DbSet<CnpgBackup> CnpgBackups => Set<CnpgBackup>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
// Suppress the PendingModelChangesWarning during development.
// The model may temporarily drift from the snapshot while iterating
// on entity design — this prevents the app from failing to start.
optionsBuilder.ConfigureWarnings(w =>
w.Ignore(RelationalEventId.PendingModelChangesWarning));
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
// Tenant — the slug must be unique across all tenants since it's
// used as the URL-safe identifier in routes and API calls.
builder.Entity<Tenant>(entity =>
{
entity.HasKey(t => t.Id);
entity.HasIndex(t => t.Slug).IsUnique();
entity.Property(t => t.Name).HasMaxLength(200).IsRequired();
entity.Property(t => t.Slug).HasMaxLength(100).IsRequired();
});
// TenantRole — each role name must be unique within its tenant.
// A tenant owns its roles; deleting a tenant cascades to its roles.
builder.Entity<TenantRole>(entity =>
{
entity.HasKey(r => r.Id);
entity.HasIndex(r => new { r.TenantId, r.Name }).IsUnique();
entity.Property(r => r.Name).HasMaxLength(100).IsRequired();
entity.HasOne(r => r.Tenant)
.WithMany(t => t.Roles)
.HasForeignKey(r => r.TenantId)
.OnDelete(DeleteBehavior.Cascade);
});
// TenantMembership — composite key on (UserId, TenantId) ensures
// a user can only have one membership per tenant. The role FK tells
// us what they can do in that tenant.
builder.Entity<TenantMembership>(entity =>
{
entity.HasKey(m => new { m.UserId, m.TenantId });
entity.HasOne(m => m.User)
.WithMany()
.HasForeignKey(m => m.UserId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(m => m.Tenant)
.WithMany(t => t.Memberships)
.HasForeignKey(m => m.TenantId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(m => m.Role)
.WithMany(r => r.Memberships)
.HasForeignKey(m => m.RoleId)
.OnDelete(DeleteBehavior.Restrict);
});
// Group — belongs to a tenant. Name should be unique within a tenant.
builder.Entity<Group>(entity =>
{
entity.HasKey(g => g.Id);
entity.HasIndex(g => new { g.TenantId, g.Name }).IsUnique();
entity.Property(g => g.Name).HasMaxLength(200).IsRequired();
entity.HasOne(g => g.Tenant)
.WithMany(t => t.Groups)
.HasForeignKey(g => g.TenantId)
.OnDelete(DeleteBehavior.Cascade);
});
// GroupMembership — composite key on (UserId, GroupId) prevents
// a user from being added to the same group twice.
builder.Entity<GroupMembership>(entity =>
{
entity.HasKey(gm => new { gm.UserId, gm.GroupId });
entity.HasOne(gm => gm.User)
.WithMany()
.HasForeignKey(gm => gm.UserId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(gm => gm.Group)
.WithMany(g => g.Memberships)
.HasForeignKey(gm => gm.GroupId)
.OnDelete(DeleteBehavior.Cascade);
});
// Environment — belongs to a tenant. Name must be unique within a tenant.
builder.Entity<Environment>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => new { e.TenantId, e.Name }).IsUnique();
entity.Property(e => e.Name).HasMaxLength(100).IsRequired();
entity.HasOne(e => e.Tenant)
.WithMany(t => t.Environments)
.HasForeignKey(e => e.TenantId)
.OnDelete(DeleteBehavior.Cascade);
});
// Customer — belongs to a tenant. Name must be unique within a tenant.
builder.Entity<Customer>(entity =>
{
entity.HasKey(c => c.Id);
entity.HasIndex(c => new { c.TenantId, c.Name }).IsUnique();
entity.Property(c => c.Name).HasMaxLength(200).IsRequired();
entity.HasOne(c => c.Tenant)
.WithMany(t => t.Customers)
.HasForeignKey(c => c.TenantId)
.OnDelete(DeleteBehavior.Cascade);
});
// App — belongs to a customer. Name must be unique within a customer.
builder.Entity<App>(entity =>
{
entity.HasKey(a => a.Id);
entity.HasIndex(a => new { a.CustomerId, a.Name }).IsUnique();
entity.Property(a => a.Name).HasMaxLength(200).IsRequired();
entity.HasOne(a => a.Customer)
.WithMany(c => c.Apps)
.HasForeignKey(a => a.CustomerId)
.OnDelete(DeleteBehavior.Cascade);
});
// AppEnvironment — many-to-many join between App and Environment.
// Composite key prevents duplicate links.
builder.Entity<AppEnvironment>(entity =>
{
entity.HasKey(ae => new { ae.AppId, ae.EnvironmentId });
entity.HasOne(ae => ae.App)
.WithMany(a => a.AppEnvironments)
.HasForeignKey(ae => ae.AppId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(ae => ae.Environment)
.WithMany(e => e.AppEnvironments)
.HasForeignKey(ae => ae.EnvironmentId)
.OnDelete(DeleteBehavior.Restrict);
});
// KubernetesCluster — belongs to a tenant and an environment.
// Name must be unique within a tenant.
builder.Entity<KubernetesCluster>(entity =>
{
entity.HasKey(c => c.Id);
entity.HasIndex(c => new { c.TenantId, c.Name }).IsUnique();
entity.Property(c => c.Name).HasMaxLength(200).IsRequired();
entity.Property(c => c.ApiServerUrl).HasMaxLength(500).IsRequired();
entity.HasOne(c => c.Tenant)
.WithMany(t => t.KubernetesClusters)
.HasForeignKey(c => c.TenantId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(c => c.Environment)
.WithMany(e => e.KubernetesClusters)
.HasForeignKey(c => c.EnvironmentId)
.OnDelete(DeleteBehavior.Restrict);
});
// SecretVault — one vault per tenant. Stores the sealed Data Encryption Key.
// A tenant can have at most one vault (1:1 relationship).
builder.Entity<SecretVault>(entity =>
{
entity.HasKey(v => v.Id);
entity.HasIndex(v => v.TenantId).IsUnique();
entity.HasOne(v => v.Tenant)
.WithOne(t => t.Vault)
.HasForeignKey<SecretVault>(v => v.TenantId)
.OnDelete(DeleteBehavior.Cascade);
});
// VaultSecret — an individual encrypted secret. Scoped to either an App
// or a ClusterComponent. Name must be unique within its scope.
builder.Entity<VaultSecret>(entity =>
{
entity.HasKey(s => s.Id);
entity.Property(s => s.Name).HasMaxLength(200).IsRequired();
entity.Property(s => s.KubernetesSecretName).HasMaxLength(253);
entity.Property(s => s.KubernetesNamespace).HasMaxLength(63);
entity.HasIndex(s => new { s.VaultId, s.AppId, s.Name })
.IsUnique()
.HasFilter(null);
entity.HasIndex(s => new { s.VaultId, s.ComponentId, s.Name })
.IsUnique()
.HasFilter(null);
entity.HasOne(s => s.Vault)
.WithMany(v => v.Secrets)
.HasForeignKey(s => s.VaultId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(s => s.App)
.WithMany(a => a.Secrets)
.HasForeignKey(s => s.AppId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(s => s.Component)
.WithMany(c => c.Secrets)
.HasForeignKey(s => s.ComponentId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(s => s.CnpgDatabase)
.WithMany()
.HasForeignKey(s => s.CnpgDatabaseId)
.OnDelete(DeleteBehavior.Cascade);
});
// ClusterComponent — a deployable unit (Helm chart, operator, etc.) on a cluster.
// Name must be unique within a cluster.
builder.Entity<ClusterComponent>(entity =>
{
entity.HasKey(c => c.Id);
entity.HasIndex(c => new { c.ClusterId, c.Name }).IsUnique();
entity.Property(c => c.Name).HasMaxLength(200).IsRequired();
entity.Property(c => c.ComponentType).HasMaxLength(50).IsRequired();
entity.HasOne(c => c.Cluster)
.WithMany(k => k.Components)
.HasForeignKey(c => c.ClusterId)
.OnDelete(DeleteBehavior.Cascade);
});
// ExternalRoute — exposes a component via Gateway API HTTPRoute.
// Hostname must be unique within a cluster (enforced via component→cluster).
builder.Entity<ExternalRoute>(entity =>
{
entity.HasKey(r => r.Id);
entity.Property(r => r.Hostname).HasMaxLength(253).IsRequired();
entity.Property(r => r.ServiceName).HasMaxLength(200);
entity.Property(r => r.PathPrefix).HasMaxLength(200);
entity.Property(r => r.ClusterIssuerName).HasMaxLength(200);
entity.Property(r => r.GatewayName).HasMaxLength(200);
entity.Property(r => r.GatewayNamespace).HasMaxLength(63);
entity.Property(r => r.TlsMode).HasConversion<string>().HasMaxLength(20);
entity.HasOne(r => r.Component)
.WithMany(c => c.ExternalRoutes)
.HasForeignKey(r => r.ComponentId)
.OnDelete(DeleteBehavior.Cascade);
});
// AppDeployment — a deployable unit targeting a specific cluster and namespace.
// Name must be unique within an app. Stores sync/health status like ArgoCD.
builder.Entity<AppDeployment>(entity =>
{
entity.HasKey(d => d.Id);
entity.HasIndex(d => new { d.AppId, d.Name }).IsUnique();
entity.Property(d => d.Name).HasMaxLength(200).IsRequired();
entity.Property(d => d.Namespace).HasMaxLength(63).IsRequired();
entity.Property(d => d.Type).HasConversion<string>().HasMaxLength(20);
entity.Property(d => d.SyncStatus).HasConversion<string>().HasMaxLength(20);
entity.Property(d => d.HealthStatus).HasConversion<string>().HasMaxLength(20);
entity.Property(d => d.HelmRepoUrl).HasMaxLength(500);
entity.Property(d => d.HelmChartName).HasMaxLength(200);
entity.Property(d => d.HelmChartVersion).HasMaxLength(50);
entity.HasOne(d => d.App)
.WithMany(a => a.Deployments)
.HasForeignKey(d => d.AppId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(d => d.Environment)
.WithMany()
.HasForeignKey(d => d.EnvironmentId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(d => d.Cluster)
.WithMany()
.HasForeignKey(d => d.ClusterId)
.OnDelete(DeleteBehavior.Restrict);
});
// DeploymentManifest — an individual K8s YAML document within a deployment.
// Applied in SortOrder sequence. Name is informational, not necessarily unique.
builder.Entity<DeploymentManifest>(entity =>
{
entity.HasKey(m => m.Id);
entity.Property(m => m.Kind).HasMaxLength(100).IsRequired();
entity.Property(m => m.Name).HasMaxLength(253).IsRequired();
entity.HasOne(m => m.Deployment)
.WithMany(d => d.Manifests)
.HasForeignKey(m => m.DeploymentId)
.OnDelete(DeleteBehavior.Cascade);
});
// DeploymentResource — a tracked live resource in the cluster (ArgoCD-style
// resource tree). Resources form a parent-child hierarchy for tree rendering.
builder.Entity<DeploymentResource>(entity =>
{
entity.HasKey(r => r.Id);
entity.Property(r => r.Group).HasMaxLength(100).IsRequired();
entity.Property(r => r.Version).HasMaxLength(20).IsRequired();
entity.Property(r => r.Kind).HasMaxLength(100).IsRequired();
entity.Property(r => r.Name).HasMaxLength(253).IsRequired();
entity.Property(r => r.Namespace).HasMaxLength(63);
entity.Property(r => r.SyncStatus).HasConversion<string>().HasMaxLength(20);
entity.Property(r => r.HealthStatus).HasConversion<string>().HasMaxLength(20);
entity.HasOne(r => r.Deployment)
.WithMany(d => d.Resources)
.HasForeignKey(r => r.DeploymentId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(r => r.ParentResource)
.WithMany(r => r.ChildResources)
.HasForeignKey(r => r.ParentResourceId)
.OnDelete(DeleteBehavior.Restrict);
});
// CustomerAccess — composite key on (UserId, CustomerId) ensures
// a user gets exactly one access entry per customer. The Role enum
// controls what they can do (Viewer, Operator, Admin).
builder.Entity<CustomerAccess>(entity =>
{
entity.HasKey(ca => new { ca.UserId, ca.CustomerId });
entity.Property(ca => ca.Role)
.HasConversion<string>()
.HasMaxLength(20);
entity.HasOne(ca => ca.User)
.WithMany()
.HasForeignKey(ca => ca.UserId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(ca => ca.Customer)
.WithMany()
.HasForeignKey(ca => ca.CustomerId)
.OnDelete(DeleteBehavior.Cascade);
});
// StorageBinding — connects a StorageLink to a workload (AppDeployment or
// ClusterComponent). The platform syncs storage credentials to a K8s Secret
// in the workload's namespace so pods can consume them.
builder.Entity<StorageBinding>(entity =>
{
entity.HasKey(b => b.Id);
entity.Property(b => b.KubernetesSecretName).HasMaxLength(253).IsRequired();
entity.HasOne(b => b.StorageLink)
.WithMany(s => s.StorageBindings)
.HasForeignKey(b => b.StorageLinkId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(b => b.AppDeployment)
.WithMany(d => d.StorageBindings)
.HasForeignKey(b => b.AppDeploymentId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(b => b.Component)
.WithMany(c => c.StorageBindings)
.HasForeignKey(b => b.ComponentId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<CnpgCluster>(entity =>
{
entity.HasKey(c => c.Id);
entity.Property(c => c.Name).HasMaxLength(63).IsRequired();
entity.Property(c => c.Namespace).HasMaxLength(63).IsRequired();
entity.Property(c => c.PostgresVersion).HasMaxLength(10).IsRequired();
entity.Property(c => c.StorageSize).HasMaxLength(20).IsRequired();
entity.Property(c => c.BackupSchedule).HasMaxLength(100);
entity.HasIndex(c => new { c.KubernetesClusterId, c.Name, c.Namespace }).IsUnique();
entity.HasOne(c => c.Tenant)
.WithMany()
.HasForeignKey(c => c.TenantId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(c => c.KubernetesCluster)
.WithMany()
.HasForeignKey(c => c.KubernetesClusterId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(c => c.StorageLink)
.WithMany()
.HasForeignKey(c => c.StorageLinkId)
.OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<CnpgDatabase>(entity =>
{
entity.HasKey(d => d.Id);
entity.Property(d => d.Name).HasMaxLength(63).IsRequired();
entity.Property(d => d.Owner).HasMaxLength(63).IsRequired();
entity.HasIndex(d => new { d.CnpgClusterId, d.Name }).IsUnique();
entity.HasOne(d => d.CnpgCluster)
.WithMany(c => c.Databases)
.HasForeignKey(d => d.CnpgClusterId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<CnpgBackup>(entity =>
{
entity.HasKey(b => b.Id);
entity.Property(b => b.Name).HasMaxLength(253).IsRequired();
entity.HasIndex(b => new { b.CnpgClusterId, b.Name }).IsUnique();
entity.HasOne(b => b.CnpgCluster)
.WithMany(c => c.Backups)
.HasForeignKey(b => b.CnpgClusterId)
.OnDelete(DeleteBehavior.Cascade);
});
}
}
/// <summary>
/// PostgreSQL-specific context. Used only for generating and applying
/// PostgreSQL migrations. Shares the same model as the base context.
/// </summary>
public class PostgresApplicationDbContext(DbContextOptions<PostgresApplicationDbContext> options)
: ApplicationDbContext(options)
{
}
/// <summary>
/// SQL Server-specific context. Used only for generating and applying
/// SQL Server migrations. Shares the same model as the base context.
/// </summary>
public class SqlServerApplicationDbContext(DbContextOptions<SqlServerApplicationDbContext> options)
: ApplicationDbContext(options)
{
}

View File

@@ -0,0 +1,87 @@
namespace EntKube.Web.Data;
/// <summary>
/// Lifecycle status of a cluster component. Tracks whether the component
/// has been installed to the target cluster and its current operational state.
/// </summary>
public enum ComponentStatus
{
/// <summary>Component is registered but not yet installed on the cluster.</summary>
NotInstalled,
/// <summary>Helm install/upgrade is in progress.</summary>
Installing,
/// <summary>Component is installed and running on the cluster.</summary>
Installed,
/// <summary>Last install/upgrade attempt failed.</summary>
Failed,
/// <summary>Helm uninstall is in progress.</summary>
Uninstalling
}
/// <summary>
/// A component deployed to a Kubernetes cluster. Components represent Helm charts,
/// deployments, or other installable units managed by the platform. Each component
/// can have secrets in the vault that are integrated with its Helm values or
/// environment configuration during deployment.
/// </summary>
public class ClusterComponent
{
public Guid Id { get; set; }
public Guid ClusterId { get; set; }
/// <summary>
/// Human-friendly name (e.g. "minio", "cnpg-cluster", "keycloak").
/// Must be unique within a cluster.
/// </summary>
public required string Name { get; set; }
/// <summary>
/// The type of component. Will be refined later with a proper enum/value set.
/// Examples: "HelmChart", "Deployment", "StatefulSet", "Operator".
/// </summary>
public required string ComponentType { get; set; }
/// <summary>
/// Optional JSON configuration specific to this component type.
/// For kube-prometheus-stack: {"namespace","serviceName","servicePort"}.
/// </summary>
public string? Configuration { get; set; }
// ── Lifecycle fields ──
/// <summary>Current lifecycle status of this component on the cluster.</summary>
public ComponentStatus Status { get; set; } = ComponentStatus.NotInstalled;
/// <summary>Target namespace where the component is installed.</summary>
public string? Namespace { get; set; }
/// <summary>Helm repository URL (e.g. "https://prometheus-community.github.io/helm-charts").</summary>
public string? HelmRepoUrl { get; set; }
/// <summary>Helm chart name (e.g. "kube-prometheus-stack").</summary>
public string? HelmChartName { get; set; }
/// <summary>Helm chart version (e.g. "65.1.0"). Null means latest.</summary>
public string? HelmChartVersion { get; set; }
/// <summary>Helm release name on the cluster. Defaults to component Name if not set.</summary>
public string? ReleaseName { get; set; }
/// <summary>Custom Helm values in YAML format, applied with --values during install/upgrade.</summary>
public string? HelmValues { get; set; }
/// <summary>Error message from the last failed operation, if any.</summary>
public string? LastError { get; set; }
/// <summary>When the component was last installed or upgraded.</summary>
public DateTime? InstalledAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation
public KubernetesCluster Cluster { get; set; } = null!;
public ICollection<VaultSecret> Secrets { get; set; } = [];
public ICollection<ExternalRoute> ExternalRoutes { get; set; } = [];
public ICollection<StorageBinding> StorageBindings { get; set; } = [];
}

View File

@@ -0,0 +1,74 @@
namespace EntKube.Web.Data;
/// <summary>
/// Type of CNPG backup — on-demand (user-triggered) or scheduled (cron).
/// </summary>
public enum CnpgBackupType
{
OnDemand,
Scheduled
}
/// <summary>
/// Status of a CNPG backup operation.
/// </summary>
public enum CnpgBackupStatus
{
Running,
Completed,
Failed
}
/// <summary>
/// A backup record for a managed CNPG cluster. Represents a Backup CR
/// in Kubernetes that triggers Barman to take a base backup to the
/// configured S3 bucket. Combined with continuous WAL archiving, this
/// enables point-in-time recovery (PITR) to any moment between backups.
/// </summary>
public class CnpgBackup
{
public Guid Id { get; set; }
/// <summary>
/// The CNPG cluster this backup belongs to.
/// </summary>
public Guid CnpgClusterId { get; set; }
/// <summary>
/// The Kubernetes Backup resource name (e.g. "my-cluster-20260517-120000").
/// </summary>
public required string Name { get; set; }
/// <summary>
/// Whether this was triggered manually or by a schedule.
/// </summary>
public CnpgBackupType Type { get; set; } = CnpgBackupType.OnDemand;
/// <summary>
/// Current status of the backup.
/// </summary>
public CnpgBackupStatus Status { get; set; } = CnpgBackupStatus.Running;
/// <summary>
/// When the backup started.
/// </summary>
public DateTime StartedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// When the backup completed (null if still running or failed before completion).
/// </summary>
public DateTime? CompletedAt { get; set; }
/// <summary>
/// Backup size in bytes (reported by Barman after completion).
/// </summary>
public long? SizeBytes { get; set; }
/// <summary>
/// Error message if the backup failed.
/// </summary>
public string? LastError { get; set; }
// Navigation
public CnpgCluster CnpgCluster { get; set; } = null!;
}

View File

@@ -0,0 +1,99 @@
namespace EntKube.Web.Data;
/// <summary>
/// The lifecycle status of a managed CNPG cluster. Tracks where the cluster
/// is in the provisioning/operational/teardown cycle.
/// </summary>
public enum CnpgClusterStatus
{
Creating,
Running,
Upgrading,
Restoring,
Failed,
Deleting
}
/// <summary>
/// A managed CloudNativePG cluster that EntKube provisions and controls.
/// Each cluster lives on a Kubernetes cluster where the CNPG operator is installed.
/// Optionally backed by a StorageLink (S3 bucket) for Barman backups and PITR.
///
/// The cluster is represented as a CNPG Cluster CRD in Kubernetes.
/// EntKube owns the full lifecycle: create, upgrade, backup, restore, delete.
/// </summary>
public class CnpgCluster
{
public Guid Id { get; set; }
/// <summary>
/// The tenant that owns this cluster.
/// </summary>
public Guid TenantId { get; set; }
/// <summary>
/// The Kubernetes cluster where this CNPG cluster runs.
/// Must have the cloudnative-pg operator installed.
/// </summary>
public Guid KubernetesClusterId { get; set; }
/// <summary>
/// The name of the CNPG Cluster resource in Kubernetes (metadata.name).
/// Lowercase, DNS-safe, max 63 chars.
/// </summary>
public required string Name { get; set; }
/// <summary>
/// The Kubernetes namespace where the cluster lives.
/// </summary>
public required string Namespace { get; set; }
/// <summary>
/// The major PostgreSQL version (e.g. "18", "17", "16").
/// Maps to the CNPG container image tag.
/// </summary>
public required string PostgresVersion { get; set; }
/// <summary>
/// Number of PostgreSQL instances (1 = standalone, 3 = HA with streaming replication).
/// </summary>
public int Instances { get; set; } = 3;
/// <summary>
/// PVC storage size for each instance (e.g. "10Gi", "50Gi").
/// </summary>
public required string StorageSize { get; set; }
/// <summary>
/// Optional link to an S3 bucket for Barman backup/restore.
/// When set, the cluster is configured with continuous WAL archiving
/// and on-demand/scheduled base backups.
/// </summary>
public Guid? StorageLinkId { get; set; }
/// <summary>
/// Cron schedule for automated backups (e.g. "0 0 2 * * *" for daily at 2 AM).
/// Null means no scheduled backups — only on-demand.
/// </summary>
public string? BackupSchedule { get; set; }
/// <summary>
/// Current lifecycle status of the cluster.
/// </summary>
public CnpgClusterStatus Status { get; set; } = CnpgClusterStatus.Creating;
/// <summary>
/// The last error encountered during a lifecycle operation.
/// Cleared on successful operations.
/// </summary>
public string? LastError { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation
public Tenant Tenant { get; set; } = null!;
public KubernetesCluster KubernetesCluster { get; set; } = null!;
public StorageLink? StorageLink { get; set; }
public ICollection<CnpgDatabase> Databases { get; set; } = [];
public ICollection<CnpgBackup> Backups { get; set; } = [];
}

View File

@@ -0,0 +1,91 @@
namespace EntKube.Web.Data;
/// <summary>
/// A snapshot of a CNPG cluster's live state including pod information,
/// overall health, and timeline details queried from Kubernetes.
/// Used by the UI to show real-time status beyond what's stored in the database.
/// </summary>
public class CnpgClusterDetail
{
/// <summary>
/// The managed cluster record from the database.
/// </summary>
public required CnpgCluster Cluster { get; set; }
/// <summary>
/// Live pod information from Kubernetes.
/// </summary>
public List<CnpgPodInfo> Pods { get; set; } = [];
/// <summary>
/// The CNPG cluster phase as reported by the Cluster status (e.g. "Cluster in healthy state").
/// </summary>
public string Phase { get; set; } = "Unknown";
/// <summary>
/// Number of instances that are currently ready.
/// </summary>
public int ReadyInstances { get; set; }
/// <summary>
/// Current primary pod name.
/// </summary>
public string? CurrentPrimary { get; set; }
/// <summary>
/// Current WAL timeline (indicates how many failovers/restores have occurred).
/// </summary>
public int? CurrentTimeline { get; set; }
/// <summary>
/// The current write LAG in bytes across replicas (0 = fully caught up).
/// </summary>
public long ReplicationLagBytes { get; set; }
}
/// <summary>
/// Information about a single pod in a CNPG cluster, including its role
/// (primary or replica), status, and resource usage.
/// </summary>
public class CnpgPodInfo
{
/// <summary>
/// The pod name (e.g. "my-cluster-1", "my-cluster-2").
/// </summary>
public required string Name { get; set; }
/// <summary>
/// The role of this pod: "primary" or "replica".
/// </summary>
public required string Role { get; set; }
/// <summary>
/// The Kubernetes pod phase (Running, Pending, Succeeded, Failed, Unknown).
/// </summary>
public required string Status { get; set; }
/// <summary>
/// Whether all containers in the pod are ready.
/// </summary>
public bool Ready { get; set; }
/// <summary>
/// The node this pod is scheduled on.
/// </summary>
public string? Node { get; set; }
/// <summary>
/// When the pod started.
/// </summary>
public DateTime? StartTime { get; set; }
/// <summary>
/// For replicas: replication lag in bytes behind the primary.
/// </summary>
public long? ReplicationLagBytes { get; set; }
/// <summary>
/// Pod restart count (sum of all container restarts).
/// </summary>
public int Restarts { get; set; }
}

View File

@@ -0,0 +1,54 @@
namespace EntKube.Web.Data;
/// <summary>
/// Status of an individual database within a CNPG cluster.
/// </summary>
public enum CnpgDatabaseStatus
{
Creating,
Ready,
Deleting,
Failed
}
/// <summary>
/// A logical PostgreSQL database within a managed CNPG cluster.
/// Each database has its own owner role and a set of vault secrets
/// (host, port, database name, username, password) that can be synced
/// to Kubernetes for application consumption.
///
/// When a database is created, EntKube runs SQL against the primary
/// to CREATE DATABASE and CREATE ROLE, then stores the credentials
/// in the tenant's vault tagged for K8s sync.
/// </summary>
public class CnpgDatabase
{
public Guid Id { get; set; }
/// <summary>
/// The CNPG cluster this database belongs to.
/// </summary>
public Guid CnpgClusterId { get; set; }
/// <summary>
/// The PostgreSQL database name (e.g. "myapp", "analytics").
/// Must be valid PostgreSQL identifier — lowercase, no spaces.
/// </summary>
public required string Name { get; set; }
/// <summary>
/// The PostgreSQL role that owns this database (e.g. "myapp_owner").
/// Created automatically when the database is provisioned.
/// </summary>
public required string Owner { get; set; }
/// <summary>
/// Current status of the database.
/// </summary>
public CnpgDatabaseStatus Status { get; set; } = CnpgDatabaseStatus.Creating;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation
public CnpgCluster CnpgCluster { get; set; } = null!;
}

View File

@@ -0,0 +1,23 @@
namespace EntKube.Web.Data;
/// <summary>
/// A customer represents an end-client or account within a tenant. Tenants
/// may serve multiple customers, and this entity tracks that relationship.
/// </summary>
public class Customer
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
/// <summary>
/// The customer's display name. Must be unique within a tenant.
/// </summary>
public required string Name { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation
public Tenant Tenant { get; set; } = null!;
public ICollection<App> Apps { get; set; } = [];
}

View File

@@ -0,0 +1,56 @@
namespace EntKube.Web.Data;
/// <summary>
/// Links an application user to a specific customer, granting them access
/// to view that customer's apps, deployments, and cluster operations.
///
/// A tenant admin assigns customer access to users. Once linked, the user
/// can visit the customer portal and see only their customer's apps — they
/// won't see other customers, tenants, or admin-level features.
///
/// A user can have access to multiple customers (across tenants), and a
/// customer can have multiple users with access.
/// </summary>
public class CustomerAccess
{
public string UserId { get; set; } = null!;
public Guid CustomerId { get; set; }
/// <summary>
/// The role this user holds for the customer. Controls what operations
/// they can perform — a Viewer can browse and see logs, an Operator
/// can also restart pods and redeploy, an Admin can manage deployments.
/// </summary>
public CustomerAccessRole Role { get; set; } = CustomerAccessRole.Viewer;
public DateTime GrantedAt { get; set; } = DateTime.UtcNow;
// Navigation
public ApplicationUser User { get; set; } = null!;
public Customer Customer { get; set; } = null!;
}
/// <summary>
/// What a user can do when accessing a customer's resources through the portal.
/// </summary>
public enum CustomerAccessRole
{
/// <summary>
/// Can view apps, deployments, resource tree, and pod logs.
/// Cannot modify anything.
/// </summary>
Viewer,
/// <summary>
/// Everything a Viewer can do, plus restart pods, redeploy,
/// and trigger sync operations.
/// </summary>
Operator,
/// <summary>
/// Everything an Operator can do, plus create/delete deployments
/// and manage manifests.
/// </summary>
Admin
}

View File

@@ -0,0 +1,53 @@
namespace EntKube.Web.Data;
/// <summary>
/// How a deployment is defined — whether through manual form entry,
/// raw YAML manifests, or a Helm chart with dynamic values.
/// </summary>
public enum DeploymentType
{
/// <summary>
/// Built via form: containers, services, PVCs, env vars, volume mounts.
/// The platform generates K8s manifests from the structured definition.
/// </summary>
Manual,
/// <summary>
/// Raw YAML manifests pasted or uploaded by the user.
/// Supports Deployment, Service, PVC, ConfigMap, etc.
/// </summary>
Yaml,
/// <summary>
/// A Helm chart from any repository. The user provides the repo URL,
/// chart name, version, and dynamic values.
/// </summary>
HelmChart
}
/// <summary>
/// The synchronization status of a deployment, modeled after ArgoCD.
/// Tells us whether the desired state matches the live cluster state.
/// </summary>
public enum SyncStatus
{
Unknown,
Synced,
OutOfSync,
Syncing,
Failed
}
/// <summary>
/// The health status of a deployment's resources, modeled after ArgoCD.
/// Tells us whether the workload is actually running correctly.
/// </summary>
public enum HealthStatus
{
Unknown,
Healthy,
Progressing,
Degraded,
Missing,
Suspended
}

View File

@@ -0,0 +1,43 @@
namespace EntKube.Web.Data;
/// <summary>
/// An individual Kubernetes manifest within a deployment. For Manual deployments,
/// these are generated from the structured form (containers, services, PVCs).
/// For Yaml deployments, these are the raw YAML documents the user pasted/uploaded.
///
/// Each manifest represents a single K8s resource (Deployment, Service, PVC,
/// ConfigMap, Secret, etc.). They are applied in SortOrder sequence.
/// </summary>
public class DeploymentManifest
{
public Guid Id { get; set; }
public Guid DeploymentId { get; set; }
/// <summary>
/// The Kubernetes resource kind (e.g. "Deployment", "Service", "PersistentVolumeClaim").
/// </summary>
public required string Kind { get; set; }
/// <summary>
/// The resource name as it appears in metadata.name.
/// </summary>
public required string Name { get; set; }
/// <summary>
/// Controls the order manifests are applied. Lower numbers go first.
/// Namespaces and PVCs before Deployments, Deployments before Services, etc.
/// </summary>
public int SortOrder { get; set; }
/// <summary>
/// The full YAML content of this manifest. This is what gets applied to the cluster.
/// </summary>
public required string YamlContent { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
// Navigation
public AppDeployment Deployment { get; set; } = null!;
}

View File

@@ -0,0 +1,60 @@
namespace EntKube.Web.Data;
/// <summary>
/// A tracked Kubernetes resource in the live cluster, modeled after ArgoCD's
/// resource tree. Each resource has its own sync and health status, and resources
/// form a parent-child tree (e.g. Deployment → ReplicaSet → Pod).
///
/// The platform populates and refreshes these by watching the cluster. Users
/// see the full resource tree with per-resource health indicators, just like
/// the ArgoCD application detail view.
/// </summary>
public class DeploymentResource
{
public Guid Id { get; set; }
public Guid DeploymentId { get; set; }
/// <summary>
/// Kubernetes API group (e.g. "apps", "" for core, "networking.k8s.io").
/// </summary>
public required string Group { get; set; }
/// <summary>
/// Kubernetes API version (e.g. "v1", "v1beta1").
/// </summary>
public required string Version { get; set; }
/// <summary>
/// The resource kind (e.g. "Deployment", "ReplicaSet", "Pod", "Service").
/// </summary>
public required string Kind { get; set; }
/// <summary>
/// The resource name from metadata.name.
/// </summary>
public required string Name { get; set; }
/// <summary>
/// The namespace the resource lives in. Null for cluster-scoped resources.
/// </summary>
public string? Namespace { get; set; }
public SyncStatus SyncStatus { get; set; } = SyncStatus.Unknown;
public HealthStatus HealthStatus { get; set; } = HealthStatus.Unknown;
public string? StatusMessage { get; set; }
/// <summary>
/// Parent resource for tree rendering. A Pod's parent is a ReplicaSet,
/// a ReplicaSet's parent is a Deployment, etc. Null for root resources.
/// </summary>
public Guid? ParentResourceId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime LastUpdatedAt { get; set; } = DateTime.UtcNow;
// Navigation
public AppDeployment Deployment { get; set; } = null!;
public DeploymentResource? ParentResource { get; set; }
public ICollection<DeploymentResource> ChildResources { get; set; } = [];
}

View File

@@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace EntKube.Web.Data;
/// <summary>
/// Design-time factory for the SQLite context. EF Core tooling uses this when
/// running `dotnet ef migrations add` targeting the default (SQLite) context.
/// It creates a throwaway context with a dummy connection string so the tooling
/// can inspect the model and generate migration code.
/// </summary>
public class SqliteDesignTimeDbContextFactory : IDesignTimeDbContextFactory<ApplicationDbContext>
{
public ApplicationDbContext CreateDbContext(string[] args)
{
DbContextOptionsBuilder<ApplicationDbContext> optionsBuilder = new();
optionsBuilder.UseSqlite("DataSource=:memory:");
return new ApplicationDbContext(optionsBuilder.Options);
}
}
/// <summary>
/// Design-time factory for the PostgreSQL context. Used when generating
/// PostgreSQL-specific migrations via:
/// dotnet ef migrations add <Name> --context PostgresApplicationDbContext --output-dir Data/Migrations/Postgres
/// </summary>
public class PostgresDesignTimeDbContextFactory : IDesignTimeDbContextFactory<PostgresApplicationDbContext>
{
public PostgresApplicationDbContext CreateDbContext(string[] args)
{
DbContextOptionsBuilder<PostgresApplicationDbContext> optionsBuilder = new();
optionsBuilder.UseNpgsql("Host=localhost;Database=entkube_design;Username=postgres;Password=postgres");
return new PostgresApplicationDbContext(optionsBuilder.Options);
}
}
/// <summary>
/// Design-time factory for the SQL Server context. Used when generating
/// SQL Server-specific migrations via:
/// dotnet ef migrations add <Name> --context SqlServerApplicationDbContext --output-dir Data/Migrations/SqlServer
/// </summary>
public class SqlServerDesignTimeDbContextFactory : IDesignTimeDbContextFactory<SqlServerApplicationDbContext>
{
public SqlServerApplicationDbContext CreateDbContext(string[] args)
{
DbContextOptionsBuilder<SqlServerApplicationDbContext> optionsBuilder = new();
optionsBuilder.UseSqlServer("Server=localhost;Database=entkube_design;Trusted_Connection=True;TrustServerCertificate=True");
return new SqlServerApplicationDbContext(optionsBuilder.Options);
}
}

View File

@@ -0,0 +1,26 @@
namespace EntKube.Web.Data;
/// <summary>
/// An environment represents a deployment stage within a tenant (e.g. "Development",
/// "Staging", "Production"). Clusters, services, and other resources are scoped
/// to an environment within a tenant.
/// </summary>
public class Environment
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
/// <summary>
/// Human-friendly name for the environment (e.g. "Production").
/// Must be unique within a tenant.
/// </summary>
public required string Name { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation
public Tenant Tenant { get; set; } = null!;
public ICollection<AppEnvironment> AppEnvironments { get; set; } = [];
public ICollection<KubernetesCluster> KubernetesClusters { get; set; } = [];
}

View File

@@ -0,0 +1,88 @@
namespace EntKube.Web.Data;
/// <summary>
/// How TLS is handled for an external route.
/// </summary>
public enum TlsMode
{
/// <summary>Automatic TLS via a cert-manager ClusterIssuer (e.g. Let's Encrypt).</summary>
ClusterIssuer,
/// <summary>Manual TLS — operator uploads a certificate (and optionally a private key).</summary>
Manual
}
/// <summary>
/// An external route exposes a cluster component to the outside world via
/// a Gateway API HTTPRoute. Each route maps a hostname to a backend service
/// with TLS termination handled either automatically (ClusterIssuer) or
/// manually (uploaded certificate).
///
/// This is the simple abstraction over Gateway API — operators just specify
/// the hostname and TLS strategy, the platform generates the resources.
/// </summary>
public class ExternalRoute
{
public Guid Id { get; set; }
/// <summary>The component this route exposes.</summary>
public Guid ComponentId { get; set; }
/// <summary>
/// The hostname for external access (e.g. "grafana.example.com").
/// Must be unique within a cluster — two routes can't serve the same hostname.
/// </summary>
public required string Hostname { get; set; }
/// <summary>
/// The Kubernetes service name to route traffic to.
/// Defaults to the component's release name if not specified.
/// </summary>
public string? ServiceName { get; set; }
/// <summary>
/// The port on the service to route traffic to (e.g. 80, 3000, 9090).
/// </summary>
public int ServicePort { get; set; } = 80;
/// <summary>
/// Optional path prefix for the route (e.g. "/grafana"). Defaults to "/".
/// </summary>
public string PathPrefix { get; set; } = "/";
/// <summary>How TLS is handled — automatic via ClusterIssuer or manual cert upload.</summary>
public TlsMode TlsMode { get; set; } = TlsMode.ClusterIssuer;
/// <summary>
/// Name of the ClusterIssuer to use when TlsMode is ClusterIssuer.
/// Typically "letsencrypt-prod" or "letsencrypt-staging".
/// </summary>
public string? ClusterIssuerName { get; set; }
/// <summary>
/// PEM-encoded TLS certificate for manual TLS mode.
/// Stored encrypted at rest via the vault.
/// </summary>
public string? TlsCertificate { get; set; }
/// <summary>
/// PEM-encoded private key for manual TLS mode.
/// Stored encrypted at rest via the vault. Optional — some scenarios use
/// certificates without keys (e.g. intermediates managed elsewhere).
/// </summary>
public string? TlsPrivateKey { get; set; }
/// <summary>
/// The name of the Kubernetes Gateway resource to attach this route to.
/// Determined by the installed ingress controller (e.g. "traefik-gateway").
/// </summary>
public string? GatewayName { get; set; }
/// <summary>The namespace where the Gateway resource lives.</summary>
public string? GatewayNamespace { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation
public ClusterComponent Component { get; set; } = null!;
}

View File

@@ -0,0 +1,21 @@
namespace EntKube.Web.Data;
/// <summary>
/// A group organizes users within a tenant. Groups enable bulk permission
/// assignment and logical team structure (e.g. "Engineering", "On-Call").
/// A group belongs to exactly one tenant.
/// </summary>
public class Group
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public required string Name { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation
public Tenant Tenant { get; set; } = null!;
public ICollection<GroupMembership> Memberships { get; set; } = [];
}

View File

@@ -0,0 +1,18 @@
namespace EntKube.Web.Data;
/// <summary>
/// Join entity between a user and a group. A user can belong to multiple
/// groups within a tenant, and a group can contain multiple users.
/// </summary>
public class GroupMembership
{
public string UserId { get; set; } = null!;
public Guid GroupId { get; set; }
public DateTime JoinedAt { get; set; } = DateTime.UtcNow;
// Navigation
public ApplicationUser User { get; set; } = null!;
public Group Group { get; set; } = null!;
}

View File

@@ -0,0 +1,45 @@
namespace EntKube.Web.Data;
/// <summary>
/// A Kubernetes cluster registered in EntKube. Belongs to a tenant and is
/// placed into an environment. One environment can host many clusters
/// (e.g. for regional distribution or capacity scaling).
/// </summary>
public class KubernetesCluster
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public Guid EnvironmentId { get; set; }
/// <summary>
/// Human-friendly cluster name (e.g. "prod-eu-west-1").
/// Must be unique within a tenant.
/// </summary>
public required string Name { get; set; }
/// <summary>
/// The Kubernetes API server URL used to connect to this cluster.
/// </summary>
public required string ApiServerUrl { get; set; }
/// <summary>
/// The kubeconfig context name that was selected when registering this cluster.
/// </summary>
public string? ContextName { get; set; }
/// <summary>
/// The raw kubeconfig YAML content for connecting to this cluster.
/// Stored so the platform can authenticate against the K8s API later.
/// In production this should be encrypted via the tenant vault.
/// </summary>
public string? Kubeconfig { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation
public Tenant Tenant { get; set; } = null!;
public Environment Environment { get; set; } = null!;
public ICollection<ClusterComponent> Components { get; set; } = [];
}

View File

@@ -0,0 +1,277 @@
// <auto-generated />
using System;
using EntKube.Web.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace EntKube.Web.Data.Migrations.Postgres
{
[DbContext(typeof(PostgresApplicationDbContext))]
[Migration("20260516154204_CreateIdentitySchema")]
partial class CreateIdentitySchema
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,223 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace EntKube.Web.Data.Migrations.Postgres
{
/// <inheritdoc />
public partial class CreateIdentitySchema : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AspNetRoles",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetUsers",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
UserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false),
PasswordHash = table.Column<string>(type: "text", nullable: true),
SecurityStamp = table.Column<string>(type: "text", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
PhoneNumber = table.Column<string>(type: "text", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
AccessFailedCount = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
RoleId = table.Column<string>(type: "text", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserClaims",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<string>(type: "text", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserLogins",
columns: table => new
{
LoginProvider = table.Column<string>(type: "text", nullable: false),
ProviderKey = table.Column<string>(type: "text", nullable: false),
ProviderDisplayName = table.Column<string>(type: "text", nullable: true),
UserId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
table.ForeignKey(
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserRoles",
columns: table => new
{
UserId = table.Column<string>(type: "text", nullable: false),
RoleId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserTokens",
columns: table => new
{
UserId = table.Column<string>(type: "text", nullable: false),
LoginProvider = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Value = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
table.ForeignKey(
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
table: "AspNetRoles",
column: "NormalizedName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AspNetUserClaims_UserId",
table: "AspNetUserClaims",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserLogins_UserId",
table: "AspNetUserLogins",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserRoles_RoleId",
table: "AspNetUserRoles",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "EmailIndex",
table: "AspNetUsers",
column: "NormalizedEmail");
migrationBuilder.CreateIndex(
name: "UserNameIndex",
table: "AspNetUsers",
column: "NormalizedUserName",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AspNetRoleClaims");
migrationBuilder.DropTable(
name: "AspNetUserClaims");
migrationBuilder.DropTable(
name: "AspNetUserLogins");
migrationBuilder.DropTable(
name: "AspNetUserRoles");
migrationBuilder.DropTable(
name: "AspNetUserTokens");
migrationBuilder.DropTable(
name: "AspNetRoles");
migrationBuilder.DropTable(
name: "AspNetUsers");
}
}
}

View File

@@ -0,0 +1,479 @@
// <auto-generated />
using System;
using EntKube.Web.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace EntKube.Web.Data.Migrations.Postgres
{
[DbContext(typeof(PostgresApplicationDbContext))]
[Migration("20260516171924_AddTenantsAndGroups")]
partial class AddTenantsAndGroups
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("EntKube.Web.Data.Group", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TenantId", "Name")
.IsUnique();
b.ToTable("Groups");
});
modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<Guid>("GroupId")
.HasColumnType("uuid");
b.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("UserId", "GroupId");
b.HasIndex("GroupId");
b.ToTable("GroupMemberships");
});
modelBuilder.Entity("EntKube.Web.Data.Tenant", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("Tenants");
});
modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "TenantId");
b.HasIndex("RoleId");
b.HasIndex("TenantId");
b.ToTable("TenantMemberships");
});
modelBuilder.Entity("EntKube.Web.Data.TenantRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TenantId", "Name")
.IsUnique();
b.ToTable("TenantRoles");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("EntKube.Web.Data.Group", b =>
{
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("Groups")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b =>
{
b.HasOne("EntKube.Web.Data.Group", "Group")
.WithMany("Memberships")
.HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Group");
b.Navigation("User");
});
modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b =>
{
b.HasOne("EntKube.Web.Data.TenantRole", "Role")
.WithMany("Memberships")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("Memberships")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("Tenant");
b.Navigation("User");
});
modelBuilder.Entity("EntKube.Web.Data.TenantRole", b =>
{
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("Roles")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Tenant");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("EntKube.Web.Data.Group", b =>
{
b.Navigation("Memberships");
});
modelBuilder.Entity("EntKube.Web.Data.Tenant", b =>
{
b.Navigation("Groups");
b.Navigation("Memberships");
b.Navigation("Roles");
});
modelBuilder.Entity("EntKube.Web.Data.TenantRole", b =>
{
b.Navigation("Memberships");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,177 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.Postgres
{
/// <inheritdoc />
public partial class AddTenantsAndGroups : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Tenants",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Slug = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tenants", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Groups",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Groups", x => x.Id);
table.ForeignKey(
name: "FK_Groups_Tenants_TenantId",
column: x => x.TenantId,
principalTable: "Tenants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "TenantRoles",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TenantRoles", x => x.Id);
table.ForeignKey(
name: "FK_TenantRoles_Tenants_TenantId",
column: x => x.TenantId,
principalTable: "Tenants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "GroupMemberships",
columns: table => new
{
UserId = table.Column<string>(type: "text", nullable: false),
GroupId = table.Column<Guid>(type: "uuid", nullable: false),
JoinedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GroupMemberships", x => new { x.UserId, x.GroupId });
table.ForeignKey(
name: "FK_GroupMemberships_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_GroupMemberships_Groups_GroupId",
column: x => x.GroupId,
principalTable: "Groups",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "TenantMemberships",
columns: table => new
{
UserId = table.Column<string>(type: "text", nullable: false),
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
RoleId = table.Column<Guid>(type: "uuid", nullable: false),
JoinedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TenantMemberships", x => new { x.UserId, x.TenantId });
table.ForeignKey(
name: "FK_TenantMemberships_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_TenantMemberships_TenantRoles_RoleId",
column: x => x.RoleId,
principalTable: "TenantRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_TenantMemberships_Tenants_TenantId",
column: x => x.TenantId,
principalTable: "Tenants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_GroupMemberships_GroupId",
table: "GroupMemberships",
column: "GroupId");
migrationBuilder.CreateIndex(
name: "IX_Groups_TenantId_Name",
table: "Groups",
columns: new[] { "TenantId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_TenantMemberships_RoleId",
table: "TenantMemberships",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "IX_TenantMemberships_TenantId",
table: "TenantMemberships",
column: "TenantId");
migrationBuilder.CreateIndex(
name: "IX_TenantRoles_TenantId_Name",
table: "TenantRoles",
columns: new[] { "TenantId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Tenants_Slug",
table: "Tenants",
column: "Slug",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "GroupMemberships");
migrationBuilder.DropTable(
name: "TenantMemberships");
migrationBuilder.DropTable(
name: "Groups");
migrationBuilder.DropTable(
name: "TenantRoles");
migrationBuilder.DropTable(
name: "Tenants");
}
}
}

View File

@@ -0,0 +1,555 @@
// <auto-generated />
using System;
using EntKube.Web.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace EntKube.Web.Data.Migrations.Postgres
{
[DbContext(typeof(PostgresApplicationDbContext))]
[Migration("20260516172614_AddEnvironmentsAndCustomers")]
partial class AddEnvironmentsAndCustomers
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("EntKube.Web.Data.Customer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TenantId", "Name")
.IsUnique();
b.ToTable("Customers");
});
modelBuilder.Entity("EntKube.Web.Data.Environment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TenantId", "Name")
.IsUnique();
b.ToTable("Environments");
});
modelBuilder.Entity("EntKube.Web.Data.Group", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TenantId", "Name")
.IsUnique();
b.ToTable("Groups");
});
modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<Guid>("GroupId")
.HasColumnType("uuid");
b.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("UserId", "GroupId");
b.HasIndex("GroupId");
b.ToTable("GroupMemberships");
});
modelBuilder.Entity("EntKube.Web.Data.Tenant", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("Tenants");
});
modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "TenantId");
b.HasIndex("RoleId");
b.HasIndex("TenantId");
b.ToTable("TenantMemberships");
});
modelBuilder.Entity("EntKube.Web.Data.TenantRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TenantId", "Name")
.IsUnique();
b.ToTable("TenantRoles");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("EntKube.Web.Data.Customer", b =>
{
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("Customers")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.Environment", b =>
{
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("Environments")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.Group", b =>
{
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("Groups")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b =>
{
b.HasOne("EntKube.Web.Data.Group", "Group")
.WithMany("Memberships")
.HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Group");
b.Navigation("User");
});
modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b =>
{
b.HasOne("EntKube.Web.Data.TenantRole", "Role")
.WithMany("Memberships")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("Memberships")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("Tenant");
b.Navigation("User");
});
modelBuilder.Entity("EntKube.Web.Data.TenantRole", b =>
{
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("Roles")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Tenant");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("EntKube.Web.Data.Group", b =>
{
b.Navigation("Memberships");
});
modelBuilder.Entity("EntKube.Web.Data.Tenant", b =>
{
b.Navigation("Customers");
b.Navigation("Environments");
b.Navigation("Groups");
b.Navigation("Memberships");
b.Navigation("Roles");
});
modelBuilder.Entity("EntKube.Web.Data.TenantRole", b =>
{
b.Navigation("Memberships");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,77 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.Postgres
{
/// <inheritdoc />
public partial class AddEnvironmentsAndCustomers : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Customers",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Customers", x => x.Id);
table.ForeignKey(
name: "FK_Customers_Tenants_TenantId",
column: x => x.TenantId,
principalTable: "Tenants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Environments",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Environments", x => x.Id);
table.ForeignKey(
name: "FK_Environments_Tenants_TenantId",
column: x => x.TenantId,
principalTable: "Tenants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Customers_TenantId_Name",
table: "Customers",
columns: new[] { "TenantId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Environments_TenantId_Name",
table: "Environments",
columns: new[] { "TenantId", "Name" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Customers");
migrationBuilder.DropTable(
name: "Environments");
}
}
}

View File

@@ -0,0 +1,643 @@
// <auto-generated />
using System;
using EntKube.Web.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace EntKube.Web.Data.Migrations.Postgres
{
[DbContext(typeof(PostgresApplicationDbContext))]
[Migration("20260516173036_AddAppsAndAppEnvironments")]
partial class AddAppsAndAppEnvironments
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("EntKube.Web.Data.App", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.HasKey("Id");
b.HasIndex("CustomerId", "Name")
.IsUnique();
b.ToTable("Apps");
});
modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b =>
{
b.Property<Guid>("AppId")
.HasColumnType("uuid");
b.Property<Guid>("EnvironmentId")
.HasColumnType("uuid");
b.Property<DateTime>("LinkedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("AppId", "EnvironmentId");
b.HasIndex("EnvironmentId");
b.ToTable("AppEnvironments");
});
modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("EntKube.Web.Data.Customer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TenantId", "Name")
.IsUnique();
b.ToTable("Customers");
});
modelBuilder.Entity("EntKube.Web.Data.Environment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TenantId", "Name")
.IsUnique();
b.ToTable("Environments");
});
modelBuilder.Entity("EntKube.Web.Data.Group", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TenantId", "Name")
.IsUnique();
b.ToTable("Groups");
});
modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<Guid>("GroupId")
.HasColumnType("uuid");
b.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("UserId", "GroupId");
b.HasIndex("GroupId");
b.ToTable("GroupMemberships");
});
modelBuilder.Entity("EntKube.Web.Data.Tenant", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("Tenants");
});
modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "TenantId");
b.HasIndex("RoleId");
b.HasIndex("TenantId");
b.ToTable("TenantMemberships");
});
modelBuilder.Entity("EntKube.Web.Data.TenantRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TenantId", "Name")
.IsUnique();
b.ToTable("TenantRoles");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("EntKube.Web.Data.App", b =>
{
b.HasOne("EntKube.Web.Data.Customer", "Customer")
.WithMany("Apps")
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Customer");
});
modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b =>
{
b.HasOne("EntKube.Web.Data.App", "App")
.WithMany("AppEnvironments")
.HasForeignKey("AppId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.Environment", "Environment")
.WithMany("AppEnvironments")
.HasForeignKey("EnvironmentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("App");
b.Navigation("Environment");
});
modelBuilder.Entity("EntKube.Web.Data.Customer", b =>
{
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("Customers")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.Environment", b =>
{
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("Environments")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.Group", b =>
{
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("Groups")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b =>
{
b.HasOne("EntKube.Web.Data.Group", "Group")
.WithMany("Memberships")
.HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Group");
b.Navigation("User");
});
modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b =>
{
b.HasOne("EntKube.Web.Data.TenantRole", "Role")
.WithMany("Memberships")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("Memberships")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("Tenant");
b.Navigation("User");
});
modelBuilder.Entity("EntKube.Web.Data.TenantRole", b =>
{
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("Roles")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Tenant");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("EntKube.Web.Data.App", b =>
{
b.Navigation("AppEnvironments");
});
modelBuilder.Entity("EntKube.Web.Data.Customer", b =>
{
b.Navigation("Apps");
});
modelBuilder.Entity("EntKube.Web.Data.Environment", b =>
{
b.Navigation("AppEnvironments");
});
modelBuilder.Entity("EntKube.Web.Data.Group", b =>
{
b.Navigation("Memberships");
});
modelBuilder.Entity("EntKube.Web.Data.Tenant", b =>
{
b.Navigation("Customers");
b.Navigation("Environments");
b.Navigation("Groups");
b.Navigation("Memberships");
b.Navigation("Roles");
});
modelBuilder.Entity("EntKube.Web.Data.TenantRole", b =>
{
b.Navigation("Memberships");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,81 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.Postgres
{
/// <inheritdoc />
public partial class AddAppsAndAppEnvironments : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Apps",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CustomerId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Apps", x => x.Id);
table.ForeignKey(
name: "FK_Apps_Customers_CustomerId",
column: x => x.CustomerId,
principalTable: "Customers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AppEnvironments",
columns: table => new
{
AppId = table.Column<Guid>(type: "uuid", nullable: false),
EnvironmentId = table.Column<Guid>(type: "uuid", nullable: false),
LinkedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AppEnvironments", x => new { x.AppId, x.EnvironmentId });
table.ForeignKey(
name: "FK_AppEnvironments_Apps_AppId",
column: x => x.AppId,
principalTable: "Apps",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AppEnvironments_Environments_EnvironmentId",
column: x => x.EnvironmentId,
principalTable: "Environments",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_AppEnvironments_EnvironmentId",
table: "AppEnvironments",
column: "EnvironmentId");
migrationBuilder.CreateIndex(
name: "IX_Apps_CustomerId_Name",
table: "Apps",
columns: new[] { "CustomerId", "Name" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AppEnvironments");
migrationBuilder.DropTable(
name: "Apps");
}
}
}

View File

@@ -0,0 +1,701 @@
// <auto-generated />
using System;
using EntKube.Web.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace EntKube.Web.Data.Migrations.Postgres
{
[DbContext(typeof(PostgresApplicationDbContext))]
[Migration("20260516173329_AddKubernetesClusters")]
partial class AddKubernetesClusters
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("EntKube.Web.Data.App", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.HasKey("Id");
b.HasIndex("CustomerId", "Name")
.IsUnique();
b.ToTable("Apps");
});
modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b =>
{
b.Property<Guid>("AppId")
.HasColumnType("uuid");
b.Property<Guid>("EnvironmentId")
.HasColumnType("uuid");
b.Property<DateTime>("LinkedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("AppId", "EnvironmentId");
b.HasIndex("EnvironmentId");
b.ToTable("AppEnvironments");
});
modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("EntKube.Web.Data.Customer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TenantId", "Name")
.IsUnique();
b.ToTable("Customers");
});
modelBuilder.Entity("EntKube.Web.Data.Environment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TenantId", "Name")
.IsUnique();
b.ToTable("Environments");
});
modelBuilder.Entity("EntKube.Web.Data.Group", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TenantId", "Name")
.IsUnique();
b.ToTable("Groups");
});
modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<Guid>("GroupId")
.HasColumnType("uuid");
b.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("UserId", "GroupId");
b.HasIndex("GroupId");
b.ToTable("GroupMemberships");
});
modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ApiServerUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("EnvironmentId")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("EnvironmentId");
b.HasIndex("TenantId", "Name")
.IsUnique();
b.ToTable("KubernetesClusters");
});
modelBuilder.Entity("EntKube.Web.Data.Tenant", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("Tenants");
});
modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "TenantId");
b.HasIndex("RoleId");
b.HasIndex("TenantId");
b.ToTable("TenantMemberships");
});
modelBuilder.Entity("EntKube.Web.Data.TenantRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TenantId", "Name")
.IsUnique();
b.ToTable("TenantRoles");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("EntKube.Web.Data.App", b =>
{
b.HasOne("EntKube.Web.Data.Customer", "Customer")
.WithMany("Apps")
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Customer");
});
modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b =>
{
b.HasOne("EntKube.Web.Data.App", "App")
.WithMany("AppEnvironments")
.HasForeignKey("AppId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.Environment", "Environment")
.WithMany("AppEnvironments")
.HasForeignKey("EnvironmentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("App");
b.Navigation("Environment");
});
modelBuilder.Entity("EntKube.Web.Data.Customer", b =>
{
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("Customers")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.Environment", b =>
{
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("Environments")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.Group", b =>
{
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("Groups")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b =>
{
b.HasOne("EntKube.Web.Data.Group", "Group")
.WithMany("Memberships")
.HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Group");
b.Navigation("User");
});
modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b =>
{
b.HasOne("EntKube.Web.Data.Environment", "Environment")
.WithMany("KubernetesClusters")
.HasForeignKey("EnvironmentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("KubernetesClusters")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Environment");
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b =>
{
b.HasOne("EntKube.Web.Data.TenantRole", "Role")
.WithMany("Memberships")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("Memberships")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("Tenant");
b.Navigation("User");
});
modelBuilder.Entity("EntKube.Web.Data.TenantRole", b =>
{
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("Roles")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Tenant");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("EntKube.Web.Data.App", b =>
{
b.Navigation("AppEnvironments");
});
modelBuilder.Entity("EntKube.Web.Data.Customer", b =>
{
b.Navigation("Apps");
});
modelBuilder.Entity("EntKube.Web.Data.Environment", b =>
{
b.Navigation("AppEnvironments");
b.Navigation("KubernetesClusters");
});
modelBuilder.Entity("EntKube.Web.Data.Group", b =>
{
b.Navigation("Memberships");
});
modelBuilder.Entity("EntKube.Web.Data.Tenant", b =>
{
b.Navigation("Customers");
b.Navigation("Environments");
b.Navigation("Groups");
b.Navigation("KubernetesClusters");
b.Navigation("Memberships");
b.Navigation("Roles");
});
modelBuilder.Entity("EntKube.Web.Data.TenantRole", b =>
{
b.Navigation("Memberships");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,61 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.Postgres
{
/// <inheritdoc />
public partial class AddKubernetesClusters : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "KubernetesClusters",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
EnvironmentId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
ApiServerUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_KubernetesClusters", x => x.Id);
table.ForeignKey(
name: "FK_KubernetesClusters_Environments_EnvironmentId",
column: x => x.EnvironmentId,
principalTable: "Environments",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_KubernetesClusters_Tenants_TenantId",
column: x => x.TenantId,
principalTable: "Tenants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_KubernetesClusters_EnvironmentId",
table: "KubernetesClusters",
column: "EnvironmentId");
migrationBuilder.CreateIndex(
name: "IX_KubernetesClusters_TenantId_Name",
table: "KubernetesClusters",
columns: new[] { "TenantId", "Name" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "KubernetesClusters");
}
}
}

View File

@@ -0,0 +1,885 @@
// <auto-generated />
using System;
using EntKube.Web.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace EntKube.Web.Data.Migrations.Postgres
{
[DbContext(typeof(PostgresApplicationDbContext))]
[Migration("20260516180045_AddVaultAndComponents")]
partial class AddVaultAndComponents
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("EntKube.Web.Data.App", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.HasKey("Id");
b.HasIndex("CustomerId", "Name")
.IsUnique();
b.ToTable("Apps");
});
modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b =>
{
b.Property<Guid>("AppId")
.HasColumnType("uuid");
b.Property<Guid>("EnvironmentId")
.HasColumnType("uuid");
b.Property<DateTime>("LinkedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("AppId", "EnvironmentId");
b.HasIndex("EnvironmentId");
b.ToTable("AppEnvironments");
});
modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ClusterId")
.HasColumnType("uuid");
b.Property<string>("ComponentType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.HasKey("Id");
b.HasIndex("ClusterId", "Name")
.IsUnique();
b.ToTable("ClusterComponents");
});
modelBuilder.Entity("EntKube.Web.Data.Customer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TenantId", "Name")
.IsUnique();
b.ToTable("Customers");
});
modelBuilder.Entity("EntKube.Web.Data.Environment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TenantId", "Name")
.IsUnique();
b.ToTable("Environments");
});
modelBuilder.Entity("EntKube.Web.Data.Group", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TenantId", "Name")
.IsUnique();
b.ToTable("Groups");
});
modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<Guid>("GroupId")
.HasColumnType("uuid");
b.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("UserId", "GroupId");
b.HasIndex("GroupId");
b.ToTable("GroupMemberships");
});
modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ApiServerUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("EnvironmentId")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("EnvironmentId");
b.HasIndex("TenantId", "Name")
.IsUnique();
b.ToTable("KubernetesClusters");
});
modelBuilder.Entity("EntKube.Web.Data.SecretVault", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<byte[]>("EncryptedDataKey")
.IsRequired()
.HasColumnType("bytea");
b.Property<byte[]>("Nonce")
.IsRequired()
.HasColumnType("bytea");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TenantId")
.IsUnique();
b.ToTable("SecretVaults");
});
modelBuilder.Entity("EntKube.Web.Data.Tenant", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("Tenants");
});
modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "TenantId");
b.HasIndex("RoleId");
b.HasIndex("TenantId");
b.ToTable("TenantMemberships");
});
modelBuilder.Entity("EntKube.Web.Data.TenantRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TenantId", "Name")
.IsUnique();
b.ToTable("TenantRoles");
});
modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AppId")
.HasColumnType("uuid");
b.Property<Guid?>("ComponentId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<byte[]>("EncryptedValue")
.IsRequired()
.HasColumnType("bytea");
b.Property<string>("KubernetesNamespace")
.HasMaxLength(63)
.HasColumnType("character varying(63)");
b.Property<string>("KubernetesSecretName")
.HasMaxLength(253)
.HasColumnType("character varying(253)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<byte[]>("Nonce")
.IsRequired()
.HasColumnType("bytea");
b.Property<bool>("SyncToKubernetes")
.HasColumnType("boolean");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("VaultId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AppId");
b.HasIndex("ComponentId");
b.HasIndex("VaultId", "AppId", "Name")
.IsUnique();
b.HasIndex("VaultId", "ComponentId", "Name")
.IsUnique();
b.ToTable("VaultSecrets");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("EntKube.Web.Data.App", b =>
{
b.HasOne("EntKube.Web.Data.Customer", "Customer")
.WithMany("Apps")
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Customer");
});
modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b =>
{
b.HasOne("EntKube.Web.Data.App", "App")
.WithMany("AppEnvironments")
.HasForeignKey("AppId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.Environment", "Environment")
.WithMany("AppEnvironments")
.HasForeignKey("EnvironmentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("App");
b.Navigation("Environment");
});
modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b =>
{
b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster")
.WithMany("Components")
.HasForeignKey("ClusterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Cluster");
});
modelBuilder.Entity("EntKube.Web.Data.Customer", b =>
{
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("Customers")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.Environment", b =>
{
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("Environments")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.Group", b =>
{
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("Groups")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b =>
{
b.HasOne("EntKube.Web.Data.Group", "Group")
.WithMany("Memberships")
.HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Group");
b.Navigation("User");
});
modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b =>
{
b.HasOne("EntKube.Web.Data.Environment", "Environment")
.WithMany("KubernetesClusters")
.HasForeignKey("EnvironmentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("KubernetesClusters")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Environment");
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.SecretVault", b =>
{
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithOne("Vault")
.HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b =>
{
b.HasOne("EntKube.Web.Data.TenantRole", "Role")
.WithMany("Memberships")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("Memberships")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("Tenant");
b.Navigation("User");
});
modelBuilder.Entity("EntKube.Web.Data.TenantRole", b =>
{
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("Roles")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b =>
{
b.HasOne("EntKube.Web.Data.App", "App")
.WithMany("Secrets")
.HasForeignKey("AppId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("EntKube.Web.Data.ClusterComponent", "Component")
.WithMany("Secrets")
.HasForeignKey("ComponentId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("EntKube.Web.Data.SecretVault", "Vault")
.WithMany("Secrets")
.HasForeignKey("VaultId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("App");
b.Navigation("Component");
b.Navigation("Vault");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("EntKube.Web.Data.App", b =>
{
b.Navigation("AppEnvironments");
b.Navigation("Secrets");
});
modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b =>
{
b.Navigation("Secrets");
});
modelBuilder.Entity("EntKube.Web.Data.Customer", b =>
{
b.Navigation("Apps");
});
modelBuilder.Entity("EntKube.Web.Data.Environment", b =>
{
b.Navigation("AppEnvironments");
b.Navigation("KubernetesClusters");
});
modelBuilder.Entity("EntKube.Web.Data.Group", b =>
{
b.Navigation("Memberships");
});
modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b =>
{
b.Navigation("Components");
});
modelBuilder.Entity("EntKube.Web.Data.SecretVault", b =>
{
b.Navigation("Secrets");
});
modelBuilder.Entity("EntKube.Web.Data.Tenant", b =>
{
b.Navigation("Customers");
b.Navigation("Environments");
b.Navigation("Groups");
b.Navigation("KubernetesClusters");
b.Navigation("Memberships");
b.Navigation("Roles");
b.Navigation("Vault");
});
modelBuilder.Entity("EntKube.Web.Data.TenantRole", b =>
{
b.Navigation("Memberships");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,144 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.Postgres
{
/// <inheritdoc />
public partial class AddVaultAndComponents : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ClusterComponents",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ClusterId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
ComponentType = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ClusterComponents", x => x.Id);
table.ForeignKey(
name: "FK_ClusterComponents_KubernetesClusters_ClusterId",
column: x => x.ClusterId,
principalTable: "KubernetesClusters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "SecretVaults",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
EncryptedDataKey = table.Column<byte[]>(type: "bytea", nullable: false),
Nonce = table.Column<byte[]>(type: "bytea", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SecretVaults", x => x.Id);
table.ForeignKey(
name: "FK_SecretVaults_Tenants_TenantId",
column: x => x.TenantId,
principalTable: "Tenants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "VaultSecrets",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
VaultId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
EncryptedValue = table.Column<byte[]>(type: "bytea", nullable: false),
Nonce = table.Column<byte[]>(type: "bytea", nullable: false),
AppId = table.Column<Guid>(type: "uuid", nullable: true),
ComponentId = table.Column<Guid>(type: "uuid", nullable: true),
SyncToKubernetes = table.Column<bool>(type: "boolean", nullable: false),
KubernetesSecretName = table.Column<string>(type: "character varying(253)", maxLength: 253, nullable: true),
KubernetesNamespace = table.Column<string>(type: "character varying(63)", maxLength: 63, nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VaultSecrets", x => x.Id);
table.ForeignKey(
name: "FK_VaultSecrets_Apps_AppId",
column: x => x.AppId,
principalTable: "Apps",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_VaultSecrets_ClusterComponents_ComponentId",
column: x => x.ComponentId,
principalTable: "ClusterComponents",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_VaultSecrets_SecretVaults_VaultId",
column: x => x.VaultId,
principalTable: "SecretVaults",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ClusterComponents_ClusterId_Name",
table: "ClusterComponents",
columns: new[] { "ClusterId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_SecretVaults_TenantId",
table: "SecretVaults",
column: "TenantId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_VaultSecrets_AppId",
table: "VaultSecrets",
column: "AppId");
migrationBuilder.CreateIndex(
name: "IX_VaultSecrets_ComponentId",
table: "VaultSecrets",
column: "ComponentId");
migrationBuilder.CreateIndex(
name: "IX_VaultSecrets_VaultId_AppId_Name",
table: "VaultSecrets",
columns: new[] { "VaultId", "AppId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_VaultSecrets_VaultId_ComponentId_Name",
table: "VaultSecrets",
columns: new[] { "VaultId", "ComponentId", "Name" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "VaultSecrets");
migrationBuilder.DropTable(
name: "ClusterComponents");
migrationBuilder.DropTable(
name: "SecretVaults");
}
}
}

View File

@@ -0,0 +1,891 @@
// <auto-generated />
using System;
using EntKube.Web.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace EntKube.Web.Data.Migrations.Postgres
{
[DbContext(typeof(PostgresApplicationDbContext))]
[Migration("20260516183618_AddKubeconfigToCluster")]
partial class AddKubeconfigToCluster
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("EntKube.Web.Data.App", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CustomerId")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.HasKey("Id");
b.HasIndex("CustomerId", "Name")
.IsUnique();
b.ToTable("Apps");
});
modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b =>
{
b.Property<Guid>("AppId")
.HasColumnType("uuid");
b.Property<Guid>("EnvironmentId")
.HasColumnType("uuid");
b.Property<DateTime>("LinkedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("AppId", "EnvironmentId");
b.HasIndex("EnvironmentId");
b.ToTable("AppEnvironments");
});
modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ClusterId")
.HasColumnType("uuid");
b.Property<string>("ComponentType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.HasKey("Id");
b.HasIndex("ClusterId", "Name")
.IsUnique();
b.ToTable("ClusterComponents");
});
modelBuilder.Entity("EntKube.Web.Data.Customer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TenantId", "Name")
.IsUnique();
b.ToTable("Customers");
});
modelBuilder.Entity("EntKube.Web.Data.Environment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TenantId", "Name")
.IsUnique();
b.ToTable("Environments");
});
modelBuilder.Entity("EntKube.Web.Data.Group", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TenantId", "Name")
.IsUnique();
b.ToTable("Groups");
});
modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<Guid>("GroupId")
.HasColumnType("uuid");
b.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("UserId", "GroupId");
b.HasIndex("GroupId");
b.ToTable("GroupMemberships");
});
modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ApiServerUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("ContextName")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("EnvironmentId")
.HasColumnType("uuid");
b.Property<string>("Kubeconfig")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("EnvironmentId");
b.HasIndex("TenantId", "Name")
.IsUnique();
b.ToTable("KubernetesClusters");
});
modelBuilder.Entity("EntKube.Web.Data.SecretVault", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<byte[]>("EncryptedDataKey")
.IsRequired()
.HasColumnType("bytea");
b.Property<byte[]>("Nonce")
.IsRequired()
.HasColumnType("bytea");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TenantId")
.IsUnique();
b.ToTable("SecretVaults");
});
modelBuilder.Entity("EntKube.Web.Data.Tenant", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("Slug")
.IsUnique();
b.ToTable("Tenants");
});
modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "TenantId");
b.HasIndex("RoleId");
b.HasIndex("TenantId");
b.ToTable("TenantMemberships");
});
modelBuilder.Entity("EntKube.Web.Data.TenantRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TenantId", "Name")
.IsUnique();
b.ToTable("TenantRoles");
});
modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AppId")
.HasColumnType("uuid");
b.Property<Guid?>("ComponentId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<byte[]>("EncryptedValue")
.IsRequired()
.HasColumnType("bytea");
b.Property<string>("KubernetesNamespace")
.HasMaxLength(63)
.HasColumnType("character varying(63)");
b.Property<string>("KubernetesSecretName")
.HasMaxLength(253)
.HasColumnType("character varying(253)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<byte[]>("Nonce")
.IsRequired()
.HasColumnType("bytea");
b.Property<bool>("SyncToKubernetes")
.HasColumnType("boolean");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("VaultId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AppId");
b.HasIndex("ComponentId");
b.HasIndex("VaultId", "AppId", "Name")
.IsUnique();
b.HasIndex("VaultId", "ComponentId", "Name")
.IsUnique();
b.ToTable("VaultSecrets");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("EntKube.Web.Data.App", b =>
{
b.HasOne("EntKube.Web.Data.Customer", "Customer")
.WithMany("Apps")
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Customer");
});
modelBuilder.Entity("EntKube.Web.Data.AppEnvironment", b =>
{
b.HasOne("EntKube.Web.Data.App", "App")
.WithMany("AppEnvironments")
.HasForeignKey("AppId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.Environment", "Environment")
.WithMany("AppEnvironments")
.HasForeignKey("EnvironmentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("App");
b.Navigation("Environment");
});
modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b =>
{
b.HasOne("EntKube.Web.Data.KubernetesCluster", "Cluster")
.WithMany("Components")
.HasForeignKey("ClusterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Cluster");
});
modelBuilder.Entity("EntKube.Web.Data.Customer", b =>
{
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("Customers")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.Environment", b =>
{
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("Environments")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.Group", b =>
{
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("Groups")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.GroupMembership", b =>
{
b.HasOne("EntKube.Web.Data.Group", "Group")
.WithMany("Memberships")
.HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Group");
b.Navigation("User");
});
modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b =>
{
b.HasOne("EntKube.Web.Data.Environment", "Environment")
.WithMany("KubernetesClusters")
.HasForeignKey("EnvironmentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("KubernetesClusters")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Environment");
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.SecretVault", b =>
{
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithOne("Vault")
.HasForeignKey("EntKube.Web.Data.SecretVault", "TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.TenantMembership", b =>
{
b.HasOne("EntKube.Web.Data.TenantRole", "Role")
.WithMany("Memberships")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("Memberships")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("Tenant");
b.Navigation("User");
});
modelBuilder.Entity("EntKube.Web.Data.TenantRole", b =>
{
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
.WithMany("Roles")
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Tenant");
});
modelBuilder.Entity("EntKube.Web.Data.VaultSecret", b =>
{
b.HasOne("EntKube.Web.Data.App", "App")
.WithMany("Secrets")
.HasForeignKey("AppId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("EntKube.Web.Data.ClusterComponent", "Component")
.WithMany("Secrets")
.HasForeignKey("ComponentId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("EntKube.Web.Data.SecretVault", "Vault")
.WithMany("Secrets")
.HasForeignKey("VaultId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("App");
b.Navigation("Component");
b.Navigation("Vault");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("EntKube.Web.Data.App", b =>
{
b.Navigation("AppEnvironments");
b.Navigation("Secrets");
});
modelBuilder.Entity("EntKube.Web.Data.ClusterComponent", b =>
{
b.Navigation("Secrets");
});
modelBuilder.Entity("EntKube.Web.Data.Customer", b =>
{
b.Navigation("Apps");
});
modelBuilder.Entity("EntKube.Web.Data.Environment", b =>
{
b.Navigation("AppEnvironments");
b.Navigation("KubernetesClusters");
});
modelBuilder.Entity("EntKube.Web.Data.Group", b =>
{
b.Navigation("Memberships");
});
modelBuilder.Entity("EntKube.Web.Data.KubernetesCluster", b =>
{
b.Navigation("Components");
});
modelBuilder.Entity("EntKube.Web.Data.SecretVault", b =>
{
b.Navigation("Secrets");
});
modelBuilder.Entity("EntKube.Web.Data.Tenant", b =>
{
b.Navigation("Customers");
b.Navigation("Environments");
b.Navigation("Groups");
b.Navigation("KubernetesClusters");
b.Navigation("Memberships");
b.Navigation("Roles");
b.Navigation("Vault");
});
modelBuilder.Entity("EntKube.Web.Data.TenantRole", b =>
{
b.Navigation("Memberships");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.Postgres
{
/// <inheritdoc />
public partial class AddKubeconfigToCluster : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ContextName",
table: "KubernetesClusters",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Kubeconfig",
table: "KubernetesClusters",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ContextName",
table: "KubernetesClusters");
migrationBuilder.DropColumn(
name: "Kubeconfig",
table: "KubernetesClusters");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,162 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.Postgres
{
/// <inheritdoc />
public partial class AddAppDeployments : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AppDeployments",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
AppId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Type = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
EnvironmentId = table.Column<Guid>(type: "uuid", nullable: false),
ClusterId = table.Column<Guid>(type: "uuid", nullable: false),
Namespace = table.Column<string>(type: "character varying(63)", maxLength: 63, nullable: false),
SyncStatus = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
HealthStatus = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
StatusMessage = table.Column<string>(type: "text", nullable: true),
LastSyncedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
HelmRepoUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
HelmChartName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
HelmChartVersion = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
HelmValues = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AppDeployments", x => x.Id);
table.ForeignKey(
name: "FK_AppDeployments_Apps_AppId",
column: x => x.AppId,
principalTable: "Apps",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AppDeployments_Environments_EnvironmentId",
column: x => x.EnvironmentId,
principalTable: "Environments",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_AppDeployments_KubernetesClusters_ClusterId",
column: x => x.ClusterId,
principalTable: "KubernetesClusters",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "DeploymentManifests",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
DeploymentId = table.Column<Guid>(type: "uuid", nullable: false),
Kind = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Name = table.Column<string>(type: "character varying(253)", maxLength: 253, nullable: false),
SortOrder = table.Column<int>(type: "integer", nullable: false),
YamlContent = table.Column<string>(type: "text", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DeploymentManifests", x => x.Id);
table.ForeignKey(
name: "FK_DeploymentManifests_AppDeployments_DeploymentId",
column: x => x.DeploymentId,
principalTable: "AppDeployments",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "DeploymentResources",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
DeploymentId = table.Column<Guid>(type: "uuid", nullable: false),
Group = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Version = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
Kind = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Name = table.Column<string>(type: "character varying(253)", maxLength: 253, nullable: false),
Namespace = table.Column<string>(type: "character varying(63)", maxLength: 63, nullable: true),
SyncStatus = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
HealthStatus = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
StatusMessage = table.Column<string>(type: "text", nullable: true),
ParentResourceId = table.Column<Guid>(type: "uuid", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
LastUpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DeploymentResources", x => x.Id);
table.ForeignKey(
name: "FK_DeploymentResources_AppDeployments_DeploymentId",
column: x => x.DeploymentId,
principalTable: "AppDeployments",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_DeploymentResources_DeploymentResources_ParentResourceId",
column: x => x.ParentResourceId,
principalTable: "DeploymentResources",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_AppDeployments_AppId_Name",
table: "AppDeployments",
columns: new[] { "AppId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AppDeployments_ClusterId",
table: "AppDeployments",
column: "ClusterId");
migrationBuilder.CreateIndex(
name: "IX_AppDeployments_EnvironmentId",
table: "AppDeployments",
column: "EnvironmentId");
migrationBuilder.CreateIndex(
name: "IX_DeploymentManifests_DeploymentId",
table: "DeploymentManifests",
column: "DeploymentId");
migrationBuilder.CreateIndex(
name: "IX_DeploymentResources_DeploymentId",
table: "DeploymentResources",
column: "DeploymentId");
migrationBuilder.CreateIndex(
name: "IX_DeploymentResources_ParentResourceId",
table: "DeploymentResources",
column: "ParentResourceId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DeploymentManifests");
migrationBuilder.DropTable(
name: "DeploymentResources");
migrationBuilder.DropTable(
name: "AppDeployments");
}
}
}

View File

@@ -0,0 +1,53 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.Postgres
{
/// <inheritdoc />
public partial class AddCustomerAccessAndDeployments : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CustomerAccesses",
columns: table => new
{
UserId = table.Column<string>(type: "text", nullable: false),
CustomerId = table.Column<Guid>(type: "uuid", nullable: false),
Role = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
GrantedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CustomerAccesses", x => new { x.UserId, x.CustomerId });
table.ForeignKey(
name: "FK_CustomerAccesses_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CustomerAccesses_Customers_CustomerId",
column: x => x.CustomerId,
principalTable: "Customers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_CustomerAccesses_CustomerId",
table: "CustomerAccesses",
column: "CustomerId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CustomerAccesses");
}
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.Postgres
{
/// <inheritdoc />
public partial class AddClusterComponentConfiguration : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Configuration",
table: "ClusterComponents",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Configuration",
table: "ClusterComponents");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,110 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.Postgres
{
/// <inheritdoc />
public partial class AddComponentLifecycleFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "HelmChartName",
table: "ClusterComponents",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "HelmChartVersion",
table: "ClusterComponents",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "HelmRepoUrl",
table: "ClusterComponents",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "HelmValues",
table: "ClusterComponents",
type: "text",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "InstalledAt",
table: "ClusterComponents",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "LastError",
table: "ClusterComponents",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Namespace",
table: "ClusterComponents",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ReleaseName",
table: "ClusterComponents",
type: "text",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "Status",
table: "ClusterComponents",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "HelmChartName",
table: "ClusterComponents");
migrationBuilder.DropColumn(
name: "HelmChartVersion",
table: "ClusterComponents");
migrationBuilder.DropColumn(
name: "HelmRepoUrl",
table: "ClusterComponents");
migrationBuilder.DropColumn(
name: "HelmValues",
table: "ClusterComponents");
migrationBuilder.DropColumn(
name: "InstalledAt",
table: "ClusterComponents");
migrationBuilder.DropColumn(
name: "LastError",
table: "ClusterComponents");
migrationBuilder.DropColumn(
name: "Namespace",
table: "ClusterComponents");
migrationBuilder.DropColumn(
name: "ReleaseName",
table: "ClusterComponents");
migrationBuilder.DropColumn(
name: "Status",
table: "ClusterComponents");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.Postgres
{
/// <inheritdoc />
public partial class AddExternalRoutes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ExternalRoutes",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ComponentId = table.Column<Guid>(type: "uuid", nullable: false),
Hostname = table.Column<string>(type: "character varying(253)", maxLength: 253, nullable: false),
ServiceName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
ServicePort = table.Column<int>(type: "integer", nullable: false),
PathPrefix = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
TlsMode = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
ClusterIssuerName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
TlsCertificate = table.Column<string>(type: "text", nullable: true),
TlsPrivateKey = table.Column<string>(type: "text", nullable: true),
GatewayName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
GatewayNamespace = table.Column<string>(type: "character varying(63)", maxLength: 63, nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExternalRoutes", x => x.Id);
table.ForeignKey(
name: "FK_ExternalRoutes_ClusterComponents_ComponentId",
column: x => x.ComponentId,
principalTable: "ClusterComponents",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ExternalRoutes_ComponentId",
table: "ExternalRoutes",
column: "ComponentId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ExternalRoutes");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,161 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.Postgres
{
/// <inheritdoc />
public partial class AddStorageAndOpenStack : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "OpenStackConnectionId",
table: "VaultSecrets",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "StorageLinkId",
table: "VaultSecrets",
type: "uuid",
nullable: true);
migrationBuilder.CreateTable(
name: "OpenStackConnections",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
AuthUrl = table.Column<string>(type: "text", nullable: false),
Region = table.Column<string>(type: "text", nullable: true),
ProjectName = table.Column<string>(type: "text", nullable: true),
ProjectId = table.Column<string>(type: "text", nullable: true),
UserDomainName = table.Column<string>(type: "text", nullable: true),
ProjectDomainName = table.Column<string>(type: "text", nullable: true),
Username = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OpenStackConnections", x => x.Id);
table.ForeignKey(
name: "FK_OpenStackConnections_Tenants_TenantId",
column: x => x.TenantId,
principalTable: "Tenants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "StorageLinks",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
EnvironmentId = table.Column<Guid>(type: "uuid", nullable: false),
Provider = table.Column<int>(type: "integer", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Endpoint = table.Column<string>(type: "text", nullable: true),
BucketName = table.Column<string>(type: "text", nullable: true),
Region = table.Column<string>(type: "text", nullable: true),
ComponentId = table.Column<Guid>(type: "uuid", nullable: true),
OpenStackConnectionId = table.Column<Guid>(type: "uuid", nullable: true),
Notes = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_StorageLinks", x => x.Id);
table.ForeignKey(
name: "FK_StorageLinks_ClusterComponents_ComponentId",
column: x => x.ComponentId,
principalTable: "ClusterComponents",
principalColumn: "Id");
table.ForeignKey(
name: "FK_StorageLinks_Environments_EnvironmentId",
column: x => x.EnvironmentId,
principalTable: "Environments",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_StorageLinks_OpenStackConnections_OpenStackConnectionId",
column: x => x.OpenStackConnectionId,
principalTable: "OpenStackConnections",
principalColumn: "Id");
table.ForeignKey(
name: "FK_StorageLinks_Tenants_TenantId",
column: x => x.TenantId,
principalTable: "Tenants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_VaultSecrets_StorageLinkId",
table: "VaultSecrets",
column: "StorageLinkId");
migrationBuilder.CreateIndex(
name: "IX_OpenStackConnections_TenantId",
table: "OpenStackConnections",
column: "TenantId");
migrationBuilder.CreateIndex(
name: "IX_StorageLinks_ComponentId",
table: "StorageLinks",
column: "ComponentId");
migrationBuilder.CreateIndex(
name: "IX_StorageLinks_EnvironmentId",
table: "StorageLinks",
column: "EnvironmentId");
migrationBuilder.CreateIndex(
name: "IX_StorageLinks_OpenStackConnectionId",
table: "StorageLinks",
column: "OpenStackConnectionId");
migrationBuilder.CreateIndex(
name: "IX_StorageLinks_TenantId",
table: "StorageLinks",
column: "TenantId");
migrationBuilder.AddForeignKey(
name: "FK_VaultSecrets_StorageLinks_StorageLinkId",
table: "VaultSecrets",
column: "StorageLinkId",
principalTable: "StorageLinks",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_VaultSecrets_StorageLinks_StorageLinkId",
table: "VaultSecrets");
migrationBuilder.DropTable(
name: "StorageLinks");
migrationBuilder.DropTable(
name: "OpenStackConnections");
migrationBuilder.DropIndex(
name: "IX_VaultSecrets_StorageLinkId",
table: "VaultSecrets");
migrationBuilder.DropColumn(
name: "OpenStackConnectionId",
table: "VaultSecrets");
migrationBuilder.DropColumn(
name: "StorageLinkId",
table: "VaultSecrets");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,73 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.Postgres
{
/// <inheritdoc />
public partial class AddStorageBindings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "StorageBindings",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
StorageLinkId = table.Column<Guid>(type: "uuid", nullable: false),
AppDeploymentId = table.Column<Guid>(type: "uuid", nullable: true),
ComponentId = table.Column<Guid>(type: "uuid", nullable: true),
KubernetesSecretName = table.Column<string>(type: "character varying(253)", maxLength: 253, nullable: false),
SyncEnabled = table.Column<bool>(type: "boolean", nullable: false),
LastSyncedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_StorageBindings", x => x.Id);
table.ForeignKey(
name: "FK_StorageBindings_AppDeployments_AppDeploymentId",
column: x => x.AppDeploymentId,
principalTable: "AppDeployments",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_StorageBindings_ClusterComponents_ComponentId",
column: x => x.ComponentId,
principalTable: "ClusterComponents",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_StorageBindings_StorageLinks_StorageLinkId",
column: x => x.StorageLinkId,
principalTable: "StorageLinks",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_StorageBindings_AppDeploymentId",
table: "StorageBindings",
column: "AppDeploymentId");
migrationBuilder.CreateIndex(
name: "IX_StorageBindings_ComponentId",
table: "StorageBindings",
column: "ComponentId");
migrationBuilder.CreateIndex(
name: "IX_StorageBindings_StorageLinkId",
table: "StorageBindings",
column: "StorageLinkId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "StorageBindings");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,175 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.Postgres
{
/// <inheritdoc />
public partial class AddCnpgManagement : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "CnpgDatabaseId",
table: "VaultSecrets",
type: "uuid",
nullable: true);
migrationBuilder.CreateTable(
name: "CnpgClusters",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
KubernetesClusterId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(63)", maxLength: 63, nullable: false),
Namespace = table.Column<string>(type: "character varying(63)", maxLength: 63, nullable: false),
PostgresVersion = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
Instances = table.Column<int>(type: "integer", nullable: false),
StorageSize = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
StorageLinkId = table.Column<Guid>(type: "uuid", nullable: true),
BackupSchedule = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
Status = table.Column<int>(type: "integer", nullable: false),
LastError = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CnpgClusters", x => x.Id);
table.ForeignKey(
name: "FK_CnpgClusters_KubernetesClusters_KubernetesClusterId",
column: x => x.KubernetesClusterId,
principalTable: "KubernetesClusters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CnpgClusters_StorageLinks_StorageLinkId",
column: x => x.StorageLinkId,
principalTable: "StorageLinks",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_CnpgClusters_Tenants_TenantId",
column: x => x.TenantId,
principalTable: "Tenants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "CnpgBackups",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CnpgClusterId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(253)", maxLength: 253, nullable: false),
Type = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
StartedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CompletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
SizeBytes = table.Column<long>(type: "bigint", nullable: true),
LastError = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_CnpgBackups", x => x.Id);
table.ForeignKey(
name: "FK_CnpgBackups_CnpgClusters_CnpgClusterId",
column: x => x.CnpgClusterId,
principalTable: "CnpgClusters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "CnpgDatabases",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CnpgClusterId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(63)", maxLength: 63, nullable: false),
Owner = table.Column<string>(type: "character varying(63)", maxLength: 63, nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CnpgDatabases", x => x.Id);
table.ForeignKey(
name: "FK_CnpgDatabases_CnpgClusters_CnpgClusterId",
column: x => x.CnpgClusterId,
principalTable: "CnpgClusters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_VaultSecrets_CnpgDatabaseId",
table: "VaultSecrets",
column: "CnpgDatabaseId");
migrationBuilder.CreateIndex(
name: "IX_CnpgBackups_CnpgClusterId_Name",
table: "CnpgBackups",
columns: new[] { "CnpgClusterId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CnpgClusters_KubernetesClusterId_Name_Namespace",
table: "CnpgClusters",
columns: new[] { "KubernetesClusterId", "Name", "Namespace" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CnpgClusters_StorageLinkId",
table: "CnpgClusters",
column: "StorageLinkId");
migrationBuilder.CreateIndex(
name: "IX_CnpgClusters_TenantId",
table: "CnpgClusters",
column: "TenantId");
migrationBuilder.CreateIndex(
name: "IX_CnpgDatabases_CnpgClusterId_Name",
table: "CnpgDatabases",
columns: new[] { "CnpgClusterId", "Name" },
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_VaultSecrets_CnpgDatabases_CnpgDatabaseId",
table: "VaultSecrets",
column: "CnpgDatabaseId",
principalTable: "CnpgDatabases",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_VaultSecrets_CnpgDatabases_CnpgDatabaseId",
table: "VaultSecrets");
migrationBuilder.DropTable(
name: "CnpgBackups");
migrationBuilder.DropTable(
name: "CnpgDatabases");
migrationBuilder.DropTable(
name: "CnpgClusters");
migrationBuilder.DropIndex(
name: "IX_VaultSecrets_CnpgDatabaseId",
table: "VaultSecrets");
migrationBuilder.DropColumn(
name: "CnpgDatabaseId",
table: "VaultSecrets");
}
}
}

View File

@@ -0,0 +1,279 @@
// <auto-generated />
using System;
using EntKube.Web.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace EntKube.Web.Data.Migrations.SqlServer
{
[DbContext(typeof(SqlServerApplicationDbContext))]
[Migration("20260516154213_CreateIdentitySchema")]
partial class CreateIdentitySchema
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("EntKube.Web.Data.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("bit");
b.Property<bool>("LockoutEnabled")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("datetimeoffset");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("bit");
b.Property<string>("SecurityStamp")
.HasColumnType("nvarchar(max)");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("bit");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex")
.HasFilter("[NormalizedUserName] IS NOT NULL");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex")
.HasFilter("[NormalizedName] IS NOT NULL");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderKey")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderDisplayName")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("nvarchar(450)");
b.Property<string>("RoleId")
.HasColumnType("nvarchar(450)");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("nvarchar(450)");
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<string>("Value")
.HasColumnType("nvarchar(max)");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("EntKube.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,224 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EntKube.Web.Data.Migrations.SqlServer
{
/// <inheritdoc />
public partial class CreateIdentitySchema : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AspNetRoles",
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(450)", nullable: false),
Name = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetUsers",
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(450)", nullable: false),
UserName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "bit", nullable: false),
PasswordHash = table.Column<string>(type: "nvarchar(max)", nullable: true),
SecurityStamp = table.Column<string>(type: "nvarchar(max)", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "nvarchar(max)", nullable: true),
PhoneNumber = table.Column<string>(type: "nvarchar(max)", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "bit", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "bit", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
LockoutEnabled = table.Column<bool>(type: "bit", nullable: false),
AccessFailedCount = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
RoleId = table.Column<string>(type: "nvarchar(450)", nullable: false),
ClaimType = table.Column<string>(type: "nvarchar(max)", nullable: true),
ClaimValue = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserClaims",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
UserId = table.Column<string>(type: "nvarchar(450)", nullable: false),
ClaimType = table.Column<string>(type: "nvarchar(max)", nullable: true),
ClaimValue = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserLogins",
columns: table => new
{
LoginProvider = table.Column<string>(type: "nvarchar(450)", nullable: false),
ProviderKey = table.Column<string>(type: "nvarchar(450)", nullable: false),
ProviderDisplayName = table.Column<string>(type: "nvarchar(max)", nullable: true),
UserId = table.Column<string>(type: "nvarchar(450)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
table.ForeignKey(
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserRoles",
columns: table => new
{
UserId = table.Column<string>(type: "nvarchar(450)", nullable: false),
RoleId = table.Column<string>(type: "nvarchar(450)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserTokens",
columns: table => new
{
UserId = table.Column<string>(type: "nvarchar(450)", nullable: false),
LoginProvider = table.Column<string>(type: "nvarchar(450)", nullable: false),
Name = table.Column<string>(type: "nvarchar(450)", nullable: false),
Value = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
table.ForeignKey(
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
table: "AspNetRoles",
column: "NormalizedName",
unique: true,
filter: "[NormalizedName] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserClaims_UserId",
table: "AspNetUserClaims",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserLogins_UserId",
table: "AspNetUserLogins",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserRoles_RoleId",
table: "AspNetUserRoles",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "EmailIndex",
table: "AspNetUsers",
column: "NormalizedEmail");
migrationBuilder.CreateIndex(
name: "UserNameIndex",
table: "AspNetUsers",
column: "NormalizedUserName",
unique: true,
filter: "[NormalizedUserName] IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AspNetRoleClaims");
migrationBuilder.DropTable(
name: "AspNetUserClaims");
migrationBuilder.DropTable(
name: "AspNetUserLogins");
migrationBuilder.DropTable(
name: "AspNetUserRoles");
migrationBuilder.DropTable(
name: "AspNetUserTokens");
migrationBuilder.DropTable(
name: "AspNetRoles");
migrationBuilder.DropTable(
name: "AspNetUsers");
}
}
}

Some files were not shown because too many files have changed in this diff Show More