database and gitsync issues fixed
This commit is contained in:
7
.claude/settings.json
Normal file
7
.claude/settings.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(grep -E \"\\\\.\\(cs|razor\\)$\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -422,3 +422,6 @@ FodyWeavers.xsd
|
|||||||
*.db
|
*.db
|
||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
|
|
||||||
|
# Local reference infrastructure (not part of the project)
|
||||||
|
CMKS - Infra/
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
@@ -6,8 +7,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<div class="top-row px-4">
|
<div class="top-row px-4 d-flex align-items-center justify-content-between">
|
||||||
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
|
<span class="text-muted small">
|
||||||
|
<i class="bi bi-server me-1"></i>EntKube Platform
|
||||||
|
</span>
|
||||||
|
<AuthorizeView>
|
||||||
|
<Authorized>
|
||||||
|
<span class="text-muted small">
|
||||||
|
<i class="bi bi-person-circle me-1"></i>@context.User.Identity?.Name
|
||||||
|
</span>
|
||||||
|
</Authorized>
|
||||||
|
</AuthorizeView>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<article class="content px-4">
|
<article class="content px-4">
|
||||||
|
|||||||
@@ -1,30 +1,25 @@
|
|||||||
@page "/Error"
|
@page "/Error"
|
||||||
@using System.Diagnostics
|
@using System.Diagnostics
|
||||||
|
|
||||||
<PageTitle>Error</PageTitle>
|
<PageTitle>Error — EntKube</PageTitle>
|
||||||
|
|
||||||
<h1 class="text-danger">Error.</h1>
|
<div class="text-center py-5">
|
||||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
<i class="bi bi-exclamation-triangle text-danger" style="font-size: 4rem;"></i>
|
||||||
|
<h2 class="mt-3 mb-1">Something went wrong</h2>
|
||||||
|
<p class="text-muted mb-1">An unexpected error occurred while processing your request.</p>
|
||||||
|
@if (ShowRequestId)
|
||||||
|
{
|
||||||
|
<p class="text-muted small mb-4">Request ID: <code>@RequestId</code></p>
|
||||||
|
}
|
||||||
|
<a href="/" class="btn btn-primary me-2">
|
||||||
|
<i class="bi bi-house me-1"></i>Go Home
|
||||||
|
</a>
|
||||||
|
<a href="javascript:location.reload()" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-clockwise me-1"></i>Retry
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (ShowRequestId)
|
@code {
|
||||||
{
|
|
||||||
<p>
|
|
||||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
<h3>Development Mode</h3>
|
|
||||||
<p>
|
|
||||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
|
||||||
It can result in displaying sensitive information from exceptions to end users.
|
|
||||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
|
||||||
and restarting the app.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
@code{
|
|
||||||
[CascadingParameter]
|
[CascadingParameter]
|
||||||
private HttpContext? HttpContext { get; set; }
|
private HttpContext? HttpContext { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,111 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
|
||||||
<PageTitle>EntKube</PageTitle>
|
<PageTitle>EntKube</PageTitle>
|
||||||
|
|
||||||
<h1>EntKube</h1>
|
<div class="mb-4">
|
||||||
<p class="lead">Multi-tenant platform for managing shared Kubernetes applications and infrastructure services.</p>
|
<h1 class="mb-1"><i class="bi bi-server me-2 text-primary"></i>EntKube</h1>
|
||||||
|
<p class="text-muted mb-0">Multi-tenant platform for managing shared Kubernetes applications and infrastructure services.</p>
|
||||||
<div class="mt-4 d-flex gap-2">
|
|
||||||
<AuthorizeView Policy="HasTenantAccess">
|
|
||||||
<Authorized>
|
|
||||||
<a href="/tenants" class="btn btn-primary btn-lg">Manage Tenants</a>
|
|
||||||
</Authorized>
|
|
||||||
</AuthorizeView>
|
|
||||||
<AuthorizeView>
|
|
||||||
<Authorized>
|
|
||||||
<a href="/portal" class="btn btn-outline-secondary btn-lg">Customer Portal</a>
|
|
||||||
</Authorized>
|
|
||||||
</AuthorizeView>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AuthorizeView>
|
||||||
|
<Authorized>
|
||||||
|
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
|
||||||
|
|
||||||
|
<AuthorizeView Policy="HasTenantAccess" Context="tenantCtx">
|
||||||
|
<Authorized>
|
||||||
|
<div class="col">
|
||||||
|
<a href="/tenants" class="text-decoration-none">
|
||||||
|
<div class="card h-100 shadow-sm border-0 home-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<div class="home-card-icon bg-primary bg-opacity-10 text-primary rounded me-3">
|
||||||
|
<i class="bi bi-building fs-4"></i>
|
||||||
|
</div>
|
||||||
|
<h5 class="mb-0">Tenants</h5>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small mb-0">Manage tenant organisations, infrastructure, services, and deployments.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-transparent border-0 pt-0">
|
||||||
|
<span class="text-primary small fw-semibold">Manage Tenants <i class="bi bi-arrow-right ms-1"></i></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</Authorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<a href="/portal" class="text-decoration-none">
|
||||||
|
<div class="card h-100 shadow-sm border-0 home-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<div class="home-card-icon bg-success bg-opacity-10 text-success rounded me-3">
|
||||||
|
<i class="bi bi-grid fs-4"></i>
|
||||||
|
</div>
|
||||||
|
<h5 class="mb-0">Customer Portal</h5>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small mb-0">Browse your customers, manage deployments, and access application resources.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-transparent border-0 pt-0">
|
||||||
|
<span class="text-success small fw-semibold">Open Portal <i class="bi bi-arrow-right ms-1"></i></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<a href="/portal/status" class="text-decoration-none">
|
||||||
|
<div class="card h-100 shadow-sm border-0 home-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<div class="home-card-icon bg-info bg-opacity-10 text-info rounded me-3">
|
||||||
|
<i class="bi bi-activity fs-4"></i>
|
||||||
|
</div>
|
||||||
|
<h5 class="mb-0">Status Overview</h5>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small mb-0">Cross-customer health, SLA targets, uptime, and active alerts at a glance.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-transparent border-0 pt-0">
|
||||||
|
<span class="text-info small fw-semibold">View Status <i class="bi bi-arrow-right ms-1"></i></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AuthorizeView Roles="Admin" Context="adminCtx">
|
||||||
|
<Authorized>
|
||||||
|
<div class="col">
|
||||||
|
<a href="/admin/backup" class="text-decoration-none">
|
||||||
|
<div class="card h-100 shadow-sm border-0 home-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<div class="home-card-icon bg-warning bg-opacity-10 text-warning rounded me-3">
|
||||||
|
<i class="bi bi-arrow-repeat fs-4"></i>
|
||||||
|
</div>
|
||||||
|
<h5 class="mb-0">Backup & Restore</h5>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small mb-0">Export and restore platform state for migration or disaster recovery.</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-transparent border-0 pt-0">
|
||||||
|
<span class="text-warning small fw-semibold">Open Backup <i class="bi bi-arrow-right ms-1"></i></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</Authorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</Authorized>
|
||||||
|
<NotAuthorized>
|
||||||
|
<div class="d-flex gap-2 mt-3">
|
||||||
|
<a href="/Account/Login" class="btn btn-primary btn-lg">
|
||||||
|
<i class="bi bi-box-arrow-in-right me-2"></i>Sign In
|
||||||
|
</a>
|
||||||
|
<a href="/Account/Register" class="btn btn-outline-secondary btn-lg">
|
||||||
|
<i class="bi bi-person-plus me-2"></i>Register
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</NotAuthorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
@page "/not-found"
|
@page "/not-found"
|
||||||
@layout MainLayout
|
@layout MainLayout
|
||||||
|
|
||||||
<h3>Not Found</h3>
|
<PageTitle>Not Found — EntKube</PageTitle>
|
||||||
<p>Sorry, the content you are looking for does not exist.</p>
|
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-map text-muted" style="font-size: 4rem;"></i>
|
||||||
|
<h2 class="mt-3 mb-1">Page not found</h2>
|
||||||
|
<p class="text-muted mb-4">Sorry, the page you are looking for does not exist or has been moved.</p>
|
||||||
|
<a href="/" class="btn btn-primary me-2">
|
||||||
|
<i class="bi bi-house me-1"></i>Go Home
|
||||||
|
</a>
|
||||||
|
<a href="javascript:history.back()" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Go Back
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
@inject PrometheusService PrometheusService
|
@inject PrometheusService PrometheusService
|
||||||
@inject AuditService AuditService
|
@inject AuditService AuditService
|
||||||
@inject IncidentService IncidentService
|
@inject IncidentService IncidentService
|
||||||
|
@inject CustomerGitService CustomerGitService
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
@@ -37,11 +38,9 @@
|
|||||||
}
|
}
|
||||||
else if (customers is null || customers.Count == 0)
|
else if (customers is null || customers.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="text-center py-5">
|
<EmptyState Icon="bi-shield-lock"
|
||||||
<i class="bi bi-shield-lock text-muted" style="font-size: 3rem;"></i>
|
Title="No access granted"
|
||||||
<h4 class="mt-3">No access granted</h4>
|
Message="You don't have access to any customers yet. Ask a tenant administrator to grant you access." />
|
||||||
<p class="text-muted">You don't have access to any customers yet. Ask a tenant administrator to grant you access.</p>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -49,7 +48,7 @@ else
|
|||||||
<nav aria-label="breadcrumb" class="mb-3">
|
<nav aria-label="breadcrumb" class="mb-3">
|
||||||
<ol class="breadcrumb small">
|
<ol class="breadcrumb small">
|
||||||
<li class="breadcrumb-item">
|
<li class="breadcrumb-item">
|
||||||
@if (selectedDeployment is not null || selectedIdentityRealm is not null || selectedSecretsApp is not null || selectedRegistryProject is not null || selectedGovernanceApp is not null)
|
@if (selectedDeployment is not null || selectedIdentityRealm is not null || selectedSecretsApp is not null || selectedRegistryProject is not null || selectedGovernanceApp is not null || selectedResourcesApp is not null || showGitPolicies)
|
||||||
{
|
{
|
||||||
<a href="javascript:void(0)" class="text-decoration-none" @onclick="BackToApps">
|
<a href="javascript:void(0)" class="text-decoration-none" @onclick="BackToApps">
|
||||||
<i class="bi bi-grid me-1"></i>Portal
|
<i class="bi bi-grid me-1"></i>Portal
|
||||||
@@ -70,7 +69,7 @@ else
|
|||||||
@if (selectedCustomer is not null)
|
@if (selectedCustomer is not null)
|
||||||
{
|
{
|
||||||
<li class="breadcrumb-item">
|
<li class="breadcrumb-item">
|
||||||
@if (selectedDeployment is not null || selectedIdentityRealm is not null || selectedSecretsApp is not null || selectedRegistryProject is not null || selectedGovernanceApp is not null)
|
@if (selectedDeployment is not null || selectedIdentityRealm is not null || selectedSecretsApp is not null || selectedRegistryProject is not null || selectedGovernanceApp is not null || selectedResourcesApp is not null || showGitPolicies)
|
||||||
{
|
{
|
||||||
<a href="javascript:void(0)" class="text-decoration-none" @onclick="BackToApps">@selectedCustomer.Name</a>
|
<a href="javascript:void(0)" class="text-decoration-none" @onclick="BackToApps">@selectedCustomer.Name</a>
|
||||||
}
|
}
|
||||||
@@ -109,6 +108,18 @@ else
|
|||||||
<i class="bi bi-shield-check me-1"></i>@selectedGovernanceApp.Name — Policy
|
<i class="bi bi-shield-check me-1"></i>@selectedGovernanceApp.Name — Policy
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
|
else if (selectedResourcesApp is not null)
|
||||||
|
{
|
||||||
|
<li class="breadcrumb-item active">
|
||||||
|
<i class="bi bi-diagram-3 me-1"></i>@selectedResourcesApp.Name — Resources
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
else if (showGitPolicies)
|
||||||
|
{
|
||||||
|
<li class="breadcrumb-item active">
|
||||||
|
<i class="bi bi-git me-1"></i>Git Policies
|
||||||
|
</li>
|
||||||
|
}
|
||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -144,10 +155,46 @@ else
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@* ════════════════════════════════════════════════════════════════
|
||||||
|
Level 3: Git URL policies (read-only)
|
||||||
|
════════════════════════════════════════════════════════════════ *@
|
||||||
|
else if (showGitPolicies)
|
||||||
|
{
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-white py-2 d-flex align-items-center gap-2">
|
||||||
|
<span class="bi bi-git text-primary"></span>
|
||||||
|
<strong>Git URL Policies — @selectedCustomer!.Name</strong>
|
||||||
|
<button class="btn-close btn-sm ms-auto" @onclick="() => showGitPolicies = false"></button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
These URL patterns define which git repositories you may use in deployments.
|
||||||
|
Contact your administrator to change them.
|
||||||
|
</p>
|
||||||
|
@if (gitPolicies.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="text-muted small">No git URL policies defined.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
@foreach (CustomerGitRepoPolicy policy in gitPolicies)
|
||||||
|
{
|
||||||
|
<li class="list-group-item d-flex align-items-center gap-2 px-0">
|
||||||
|
<i class="bi bi-check-circle text-success"></i>
|
||||||
|
<code class="small">@policy.UrlPattern</code>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@* ════════════════════════════════════════════════════════════════
|
@* ════════════════════════════════════════════════════════════════
|
||||||
Level 2: Apps and deployments for a selected customer
|
Level 2: Apps and deployments for a selected customer
|
||||||
════════════════════════════════════════════════════════════════ *@
|
════════════════════════════════════════════════════════════════ *@
|
||||||
else if (selectedDeployment is null && selectedIdentityRealm is null && selectedSecretsApp is null && selectedRegistryProject is null)
|
else if (selectedDeployment is null && selectedIdentityRealm is null && selectedSecretsApp is null && selectedRegistryProject is null && !showGitPolicies)
|
||||||
{
|
{
|
||||||
<div class="d-flex align-items-center mb-3">
|
<div class="d-flex align-items-center mb-3">
|
||||||
<i class="bi bi-people fs-3 me-2 text-primary"></i>
|
<i class="bi bi-people fs-3 me-2 text-primary"></i>
|
||||||
@@ -160,6 +207,12 @@ else
|
|||||||
@onclick="() => showMonitoringPanel = !showMonitoringPanel">
|
@onclick="() => showMonitoringPanel = !showMonitoringPanel">
|
||||||
<span class="bi bi-heart-pulse me-1"></span>Monitoring
|
<span class="bi bi-heart-pulse me-1"></span>Monitoring
|
||||||
</button>
|
</button>
|
||||||
|
@if (gitPolicies.Count > 0)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showGitPolicies = true" title="Git URL Policies">
|
||||||
|
<span class="bi bi-git me-1"></span>Git Policies
|
||||||
|
</button>
|
||||||
|
}
|
||||||
<a class="btn btn-sm btn-outline-secondary" href="/portal/status">
|
<a class="btn btn-sm btn-outline-secondary" href="/portal/status">
|
||||||
<span class="bi bi-activity me-1"></span>Status Overview
|
<span class="bi bi-activity me-1"></span>Status Overview
|
||||||
</a>
|
</a>
|
||||||
@@ -173,7 +226,7 @@ else
|
|||||||
@if (showMonitoringPanel)
|
@if (showMonitoringPanel)
|
||||||
{
|
{
|
||||||
<div class="card shadow-sm mb-4">
|
<div class="card shadow-sm mb-4">
|
||||||
<div class="card-header d-flex align-items-center gap-2 py-2">
|
<div class="card-header bg-white d-flex align-items-center gap-2 py-2">
|
||||||
<span class="bi bi-heart-pulse text-primary"></span>
|
<span class="bi bi-heart-pulse text-primary"></span>
|
||||||
<strong>Monitoring — @selectedCustomer.Name</strong>
|
<strong>Monitoring — @selectedCustomer.Name</strong>
|
||||||
<button class="btn-close btn-sm ms-auto" @onclick="() => showMonitoringPanel = false"></button>
|
<button class="btn-close btn-sm ms-auto" @onclick="() => showMonitoringPanel = false"></button>
|
||||||
@@ -204,10 +257,8 @@ else
|
|||||||
|
|
||||||
@if (selectedCustomer.Apps.Count == 0)
|
@if (selectedCustomer.Apps.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="text-center py-4">
|
<EmptyState Icon="bi-app-indicator"
|
||||||
<i class="bi bi-app-indicator text-muted" style="font-size: 2rem;"></i>
|
Message="No apps configured for this customer yet." />
|
||||||
<p class="text-muted mt-2">No apps configured for this customer yet.</p>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -223,7 +274,7 @@ else
|
|||||||
<i class="bi bi-app-indicator me-2 text-primary"></i>
|
<i class="bi bi-app-indicator me-2 text-primary"></i>
|
||||||
<strong>@app.Name</strong>
|
<strong>@app.Name</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2 align-items-center">
|
||||||
@if (currentAccessRole >= CustomerAccessRole.Admin)
|
@if (currentAccessRole >= CustomerAccessRole.Admin)
|
||||||
{
|
{
|
||||||
<button class="btn btn-sm btn-outline-primary"
|
<button class="btn btn-sm btn-outline-primary"
|
||||||
@@ -231,28 +282,45 @@ else
|
|||||||
<i class="bi bi-plus me-1"></i>New Deployment
|
<i class="bi bi-plus me-1"></i>New Deployment
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (hasIdentity)
|
<div class="dropdown">
|
||||||
{
|
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button"
|
||||||
<button class="btn btn-sm btn-outline-primary"
|
data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
@onclick="() => SelectIdentityRealm(app.Id)">
|
<i class="bi bi-three-dots me-1"></i>More
|
||||||
<i class="bi bi-shield-lock me-1"></i>Identity
|
|
||||||
</button>
|
</button>
|
||||||
}
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
@if (hasRegistry)
|
@if (hasIdentity)
|
||||||
{
|
{
|
||||||
<button class="btn btn-sm btn-outline-primary"
|
<li>
|
||||||
@onclick="() => SelectRegistryProject(app.Id)">
|
<button class="dropdown-item" @onclick="() => SelectIdentityRealm(app.Id)">
|
||||||
<i class="bi bi-archive me-1"></i>Registry
|
<i class="bi bi-shield-lock me-2 text-primary"></i>Identity
|
||||||
</button>
|
</button>
|
||||||
}
|
</li>
|
||||||
<button class="btn btn-sm btn-outline-secondary"
|
}
|
||||||
@onclick="() => SelectSecretsApp(app)">
|
@if (hasRegistry)
|
||||||
<i class="bi bi-key me-1"></i>Secrets
|
{
|
||||||
</button>
|
<li>
|
||||||
<button class="btn btn-sm btn-outline-secondary"
|
<button class="dropdown-item" @onclick="() => SelectRegistryProject(app.Id)">
|
||||||
@onclick="() => SelectGovernanceApp(app)">
|
<i class="bi bi-archive me-2 text-primary"></i>Registry
|
||||||
<i class="bi bi-shield-check me-1"></i>Policy
|
</button>
|
||||||
</button>
|
</li>
|
||||||
|
}
|
||||||
|
<li>
|
||||||
|
<button class="dropdown-item" @onclick="() => SelectSecretsApp(app)">
|
||||||
|
<i class="bi bi-key me-2"></i>Secrets
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button class="dropdown-item" @onclick="() => SelectGovernanceApp(app)">
|
||||||
|
<i class="bi bi-shield-check me-2"></i>Policy
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button class="dropdown-item" @onclick="() => SelectResourcesApp(app)">
|
||||||
|
<i class="bi bi-diagram-3 me-2"></i>Resources
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -277,6 +345,12 @@ else
|
|||||||
<option value="@DeploymentType.Manual">Manual</option>
|
<option value="@DeploymentType.Manual">Manual</option>
|
||||||
<option value="@DeploymentType.Yaml">YAML</option>
|
<option value="@DeploymentType.Yaml">YAML</option>
|
||||||
<option value="@DeploymentType.HelmChart">Helm Chart</option>
|
<option value="@DeploymentType.HelmChart">Helm Chart</option>
|
||||||
|
@if (gitPolicies.Count > 0)
|
||||||
|
{
|
||||||
|
<option value="@DeploymentType.GitYaml">Git — YAML</option>
|
||||||
|
<option value="@DeploymentType.GitHelm">Git — Helm</option>
|
||||||
|
<option value="@DeploymentType.GitAppOfApps">Git — App of Apps</option>
|
||||||
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
@@ -301,7 +375,15 @@ else
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label small">Namespace</label>
|
<label class="form-label small">Namespace</label>
|
||||||
<input class="form-control form-control-sm" @bind="newDeployNamespace" placeholder="e.g. my-app" />
|
@if (!string.IsNullOrWhiteSpace(app.Namespace))
|
||||||
|
{
|
||||||
|
<input class="form-control form-control-sm font-monospace" value="@app.Namespace" disabled />
|
||||||
|
<div class="form-text small text-warning"><i class="bi bi-lock me-1"></i>Locked by governance policy.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<input class="form-control form-control-sm" @bind="newDeployNamespace" placeholder="e.g. my-app" />
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (newDeployType == DeploymentType.HelmChart)
|
@if (newDeployType == DeploymentType.HelmChart)
|
||||||
{
|
{
|
||||||
@@ -318,10 +400,52 @@ else
|
|||||||
<input class="form-control form-control-sm" @bind="newHelmChartVersion" placeholder="e.g. 1.0.0" />
|
<input class="form-control form-control-sm" @bind="newHelmChartVersion" placeholder="e.g. 1.0.0" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (newDeployType is DeploymentType.GitYaml or DeploymentType.GitHelm or DeploymentType.GitAppOfApps)
|
||||||
|
{
|
||||||
|
<div class="col-12">
|
||||||
|
@if (customerGitCredentials.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-info py-2 small mb-2">
|
||||||
|
<i class="bi bi-key-fill me-1"></i>
|
||||||
|
<strong>@customerGitCredentials.Count stored credential@(customerGitCredentials.Count != 1 ? "s" : "") on file</strong>
|
||||||
|
(@string.Join(", ", customerGitCredentials.Select(c => c.Name))) — no need to enter credentials.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (gitPolicies.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-secondary py-2 small mb-2">
|
||||||
|
<i class="bi bi-shield-check me-1"></i>
|
||||||
|
Repository URL must match one of your allowed patterns:
|
||||||
|
@foreach (CustomerGitRepoPolicy p in gitPolicies)
|
||||||
|
{
|
||||||
|
<code class="ms-2">@p.UrlPattern</code>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label small">Repository URL</label>
|
||||||
|
<input class="form-control form-control-sm font-monospace @(newGitUrlError is not null ? "is-invalid" : "")"
|
||||||
|
@bind="newGitUrl" @bind:event="oninput" @onchange="ValidateNewGitUrl"
|
||||||
|
placeholder="https://github.com/org/repo" />
|
||||||
|
@if (newGitUrlError is not null)
|
||||||
|
{
|
||||||
|
<div class="invalid-feedback">@newGitUrlError</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Path <span class="text-muted">(leave blank for repo root)</span></label>
|
||||||
|
<input class="form-control form-control-sm font-monospace" @bind="newGitPath" placeholder="." />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Branch / Revision <span class="text-muted">(leave blank for default)</span></label>
|
||||||
|
<input class="form-control form-control-sm font-monospace" @bind="newGitRevision" placeholder="main" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-1">
|
<div class="d-flex gap-1">
|
||||||
<button class="btn btn-sm btn-primary" @onclick="() => CreateDeployment(app.Id)"
|
<button class="btn btn-sm btn-primary" @onclick="() => CreateDeployment(app.Id)"
|
||||||
disabled="@(string.IsNullOrWhiteSpace(newDeployName) || newDeployEnvId == Guid.Empty || newDeployClusterId == Guid.Empty || string.IsNullOrWhiteSpace(newDeployNamespace) || creatingDeployment)">
|
disabled="@(string.IsNullOrWhiteSpace(newDeployName) || newDeployEnvId == Guid.Empty || newDeployClusterId == Guid.Empty || string.IsNullOrWhiteSpace(newDeployNamespace) || creatingDeployment || newGitUrlError is not null || (IsGitType(newDeployType) && string.IsNullOrWhiteSpace(newGitUrl)))">
|
||||||
@if (creatingDeployment) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
@if (creatingDeployment) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
Create
|
Create
|
||||||
</button>
|
</button>
|
||||||
@@ -363,7 +487,8 @@ else
|
|||||||
}
|
}
|
||||||
else if (!showingCreateForm)
|
else if (!showingCreateForm)
|
||||||
{
|
{
|
||||||
<p class="text-muted small mb-0">No deployments configured.</p>
|
<EmptyState Icon="bi-rocket-takeoff"
|
||||||
|
Message="No deployments configured for this app yet." />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -383,6 +508,9 @@ else
|
|||||||
DeploymentService="DeploymentService"
|
DeploymentService="DeploymentService"
|
||||||
PrometheusService="PrometheusService"
|
PrometheusService="PrometheusService"
|
||||||
AuditService="AuditService"
|
AuditService="AuditService"
|
||||||
|
CustomerId="selectedCustomer!.Id"
|
||||||
|
TenantId="selectedCustomer!.TenantId"
|
||||||
|
GovernanceNamespace="@(selectedCustomer?.Apps.FirstOrDefault(a => a.Id == selectedDeployment.AppId)?.Namespace)"
|
||||||
OnBack="BackToApps"
|
OnBack="BackToApps"
|
||||||
OnDeleted="OnDeploymentDeleted" />
|
OnDeleted="OnDeploymentDeleted" />
|
||||||
}
|
}
|
||||||
@@ -422,6 +550,10 @@ else
|
|||||||
{
|
{
|
||||||
<AppGovernancePanel AppId="selectedGovernanceApp.Id" AppName="selectedGovernanceApp.Name" />
|
<AppGovernancePanel AppId="selectedGovernanceApp.Id" AppName="selectedGovernanceApp.Name" />
|
||||||
}
|
}
|
||||||
|
else if (selectedResourcesApp is not null)
|
||||||
|
{
|
||||||
|
<AppResourcesPanel AppId="selectedResourcesApp.Id" />
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
@@ -436,6 +568,7 @@ else
|
|||||||
private Data.App? selectedSecretsApp;
|
private Data.App? selectedSecretsApp;
|
||||||
private HarborProject? selectedRegistryProject;
|
private HarborProject? selectedRegistryProject;
|
||||||
private Data.App? selectedGovernanceApp;
|
private Data.App? selectedGovernanceApp;
|
||||||
|
private Data.App? selectedResourcesApp;
|
||||||
|
|
||||||
// Deployments indexed by app ID for the selected customer.
|
// Deployments indexed by app ID for the selected customer.
|
||||||
private Dictionary<Guid, List<AppDeployment>> appDeployments = new();
|
private Dictionary<Guid, List<AppDeployment>> appDeployments = new();
|
||||||
@@ -459,12 +592,19 @@ else
|
|||||||
private HashSet<Guid> AllCustomerClusterIds =>
|
private HashSet<Guid> AllCustomerClusterIds =>
|
||||||
AllCustomerDeployments.Select(d => d.ClusterId).ToHashSet();
|
AllCustomerDeployments.Select(d => d.ClusterId).ToHashSet();
|
||||||
|
|
||||||
|
// Git policies and credentials for the selected customer.
|
||||||
|
private List<CustomerGitRepoPolicy> gitPolicies = [];
|
||||||
|
private List<CustomerGitCredential> customerGitCredentials = [];
|
||||||
|
private bool showGitPolicies;
|
||||||
|
|
||||||
// Create deployment form (portal Admin).
|
// Create deployment form (portal Admin).
|
||||||
private Guid? createDeployAppId;
|
private Guid? createDeployAppId;
|
||||||
private string newDeployName = "", newDeployNamespace = "";
|
private string newDeployName = "", newDeployNamespace = "";
|
||||||
private DeploymentType newDeployType = DeploymentType.Manual;
|
private DeploymentType newDeployType = DeploymentType.Manual;
|
||||||
private Guid newDeployEnvId, newDeployClusterId;
|
private Guid newDeployEnvId, newDeployClusterId;
|
||||||
private string newHelmRepoUrl = "", newHelmChartName = "", newHelmChartVersion = "";
|
private string newHelmRepoUrl = "", newHelmChartName = "", newHelmChartVersion = "";
|
||||||
|
private string newGitUrl = "", newGitPath = "", newGitRevision = "";
|
||||||
|
private string? newGitUrlError;
|
||||||
private bool creatingDeployment;
|
private bool creatingDeployment;
|
||||||
private string? createDeployError;
|
private string? createDeployError;
|
||||||
|
|
||||||
@@ -489,6 +629,7 @@ else
|
|||||||
{
|
{
|
||||||
selectedCustomer = customer;
|
selectedCustomer = customer;
|
||||||
showMonitoringPanel = false;
|
showMonitoringPanel = false;
|
||||||
|
showGitPolicies = false;
|
||||||
|
|
||||||
// Look up the user's role for this customer.
|
// Look up the user's role for this customer.
|
||||||
CustomerAccess? access = await CustomerAccessService.GetAccessAsync(currentUserId!, customer.Id);
|
CustomerAccess? access = await CustomerAccessService.GetAccessAsync(currentUserId!, customer.Id);
|
||||||
@@ -500,6 +641,10 @@ else
|
|||||||
appHarborProjects.Clear();
|
appHarborProjects.Clear();
|
||||||
createDeployAppId = null;
|
createDeployAppId = null;
|
||||||
|
|
||||||
|
// Load git policies and credentials for this customer (all roles need policies to validate URLs).
|
||||||
|
gitPolicies = await CustomerGitService.GetRepoPoliciesAsync(customer.Id);
|
||||||
|
customerGitCredentials = await CustomerGitService.GetCredentialsAsync(customer.Id);
|
||||||
|
|
||||||
foreach (Data.App app in customer.Apps)
|
foreach (Data.App app in customer.Apps)
|
||||||
{
|
{
|
||||||
List<AppDeployment> deploys = await DeploymentService.GetDeploymentsAsync(app.Id);
|
List<AppDeployment> deploys = await DeploymentService.GetDeploymentsAsync(app.Id);
|
||||||
@@ -546,6 +691,9 @@ else
|
|||||||
selectedIdentityRealm = null;
|
selectedIdentityRealm = null;
|
||||||
selectedSecretsApp = null;
|
selectedSecretsApp = null;
|
||||||
selectedRegistryProject = null;
|
selectedRegistryProject = null;
|
||||||
|
gitPolicies.Clear();
|
||||||
|
customerGitCredentials.Clear();
|
||||||
|
showGitPolicies = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SelectSecretsApp(Data.App app)
|
private void SelectSecretsApp(Data.App app)
|
||||||
@@ -558,6 +706,11 @@ else
|
|||||||
selectedGovernanceApp = app;
|
selectedGovernanceApp = app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SelectResourcesApp(Data.App app)
|
||||||
|
{
|
||||||
|
selectedResourcesApp = app;
|
||||||
|
}
|
||||||
|
|
||||||
private void BackToApps()
|
private void BackToApps()
|
||||||
{
|
{
|
||||||
selectedDeployment = null;
|
selectedDeployment = null;
|
||||||
@@ -565,6 +718,8 @@ else
|
|||||||
selectedSecretsApp = null;
|
selectedSecretsApp = null;
|
||||||
selectedRegistryProject = null;
|
selectedRegistryProject = null;
|
||||||
selectedGovernanceApp = null;
|
selectedGovernanceApp = null;
|
||||||
|
selectedResourcesApp = null;
|
||||||
|
showGitPolicies = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnDeploymentDeleted()
|
private async Task OnDeploymentDeleted()
|
||||||
@@ -587,7 +742,11 @@ else
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
createDeployAppId = appId;
|
createDeployAppId = appId;
|
||||||
newDeployName = newDeployNamespace = newHelmRepoUrl = newHelmChartName = newHelmChartVersion = "";
|
Data.App? app = selectedCustomer?.Apps.FirstOrDefault(a => a.Id == appId);
|
||||||
|
newDeployNamespace = app?.Namespace ?? "";
|
||||||
|
newDeployName = newHelmRepoUrl = newHelmChartName = newHelmChartVersion = "";
|
||||||
|
newGitUrl = newGitPath = newGitRevision = "";
|
||||||
|
newGitUrlError = null;
|
||||||
newDeployType = DeploymentType.Manual;
|
newDeployType = DeploymentType.Manual;
|
||||||
newDeployEnvId = newDeployClusterId = Guid.Empty;
|
newDeployEnvId = newDeployClusterId = Guid.Empty;
|
||||||
createDeployError = null;
|
createDeployError = null;
|
||||||
@@ -601,12 +760,36 @@ else
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
Guid? gitRepoId = null;
|
||||||
|
|
||||||
|
if (IsGitType(newDeployType) && !string.IsNullOrWhiteSpace(newGitUrl))
|
||||||
|
{
|
||||||
|
// Validate URL against policy one more time before creating.
|
||||||
|
if (!CustomerGitService.MatchesAnyPolicy(newGitUrl, gitPolicies))
|
||||||
|
{
|
||||||
|
createDeployError = "The repository URL does not match any allowed policy pattern.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find or create a GitRepository backed by the first available credential.
|
||||||
|
CustomerGitCredential? cred = customerGitCredentials.FirstOrDefault();
|
||||||
|
if (cred is not null && selectedCustomer is not null)
|
||||||
|
{
|
||||||
|
GitRepository repo = await CustomerGitService.FindOrCreateRepositoryForCustomerAsync(
|
||||||
|
selectedCustomer.TenantId, selectedCustomer.Id, newGitUrl, cred.Id);
|
||||||
|
gitRepoId = repo.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await DeploymentService.CreateDeploymentAsync(
|
await DeploymentService.CreateDeploymentAsync(
|
||||||
appId, newDeployName, newDeployType,
|
appId, newDeployName, newDeployType,
|
||||||
newDeployEnvId, newDeployClusterId, newDeployNamespace,
|
newDeployEnvId, newDeployClusterId, newDeployNamespace,
|
||||||
newDeployType == DeploymentType.HelmChart ? newHelmRepoUrl : null,
|
newDeployType == DeploymentType.HelmChart ? newHelmRepoUrl : null,
|
||||||
newDeployType == DeploymentType.HelmChart ? newHelmChartName : null,
|
newDeployType == DeploymentType.HelmChart ? newHelmChartName : null,
|
||||||
newDeployType == DeploymentType.HelmChart ? newHelmChartVersion : null);
|
newDeployType == DeploymentType.HelmChart ? newHelmChartVersion : null,
|
||||||
|
gitRepositoryId: gitRepoId,
|
||||||
|
gitPath: string.IsNullOrWhiteSpace(newGitPath) ? null : newGitPath,
|
||||||
|
gitRevision: string.IsNullOrWhiteSpace(newGitRevision) ? null : newGitRevision);
|
||||||
|
|
||||||
createDeployAppId = null;
|
createDeployAppId = null;
|
||||||
appDeployments[appId] = await DeploymentService.GetDeploymentsAsync(appId);
|
appDeployments[appId] = await DeploymentService.GetDeploymentsAsync(appId);
|
||||||
@@ -621,6 +804,22 @@ else
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ValidateNewGitUrl(ChangeEventArgs e)
|
||||||
|
{
|
||||||
|
newGitUrl = e.Value?.ToString() ?? "";
|
||||||
|
if (string.IsNullOrWhiteSpace(newGitUrl))
|
||||||
|
{
|
||||||
|
newGitUrlError = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
newGitUrlError = CustomerGitService.MatchesAnyPolicy(newGitUrl, gitPolicies)
|
||||||
|
? null
|
||||||
|
: "URL does not match any allowed policy pattern.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsGitType(DeploymentType type)
|
||||||
|
=> type is DeploymentType.GitYaml or DeploymentType.GitHelm or DeploymentType.GitAppOfApps;
|
||||||
|
|
||||||
// ──────── Badge helpers ────────
|
// ──────── Badge helpers ────────
|
||||||
|
|
||||||
private RenderFragment TypeBadge(DeploymentType type) => type switch
|
private RenderFragment TypeBadge(DeploymentType type) => type switch
|
||||||
@@ -628,6 +827,9 @@ else
|
|||||||
DeploymentType.Manual => @<span class="badge bg-info">Manual</span>,
|
DeploymentType.Manual => @<span class="badge bg-info">Manual</span>,
|
||||||
DeploymentType.Yaml => @<span class="badge bg-warning text-dark">YAML</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>,
|
DeploymentType.HelmChart => @<span class="badge bg-purple text-white" style="background-color: #6f42c1 !important;">Helm</span>,
|
||||||
|
DeploymentType.GitYaml => @<span class="badge bg-success"><i class="bi bi-git me-1"></i>Git YAML</span>,
|
||||||
|
DeploymentType.GitHelm => @<span class="badge bg-success"><i class="bi bi-git me-1"></i>Git Helm</span>,
|
||||||
|
DeploymentType.GitAppOfApps => @<span class="badge bg-success"><i class="bi bi-git me-1"></i>App of Apps</span>,
|
||||||
_ => @<span class="badge bg-secondary">Unknown</span>
|
_ => @<span class="badge bg-secondary">Unknown</span>
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -24,19 +24,12 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (isLoading)
|
<LoadingPanel Loading="@isLoading" LoadingText="Loading status…">
|
||||||
|
@if (!isLoading && rows.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="text-center py-5">
|
<EmptyState Icon="bi-shield-lock"
|
||||||
<div class="spinner-border text-primary" role="status"></div>
|
Title="No applications to show"
|
||||||
<p class="text-muted mt-2">Loading status…</p>
|
Message="You may not have access to any customer yet." />
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else if (rows.Count == 0)
|
|
||||||
{
|
|
||||||
<div class="text-center py-5 text-muted">
|
|
||||||
<i class="bi bi-shield-lock display-4 d-block mb-2"></i>
|
|
||||||
No applications to show. You may not have access to any customer yet.
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -119,6 +112,7 @@ else
|
|||||||
Refreshed @lastRefreshed.ToLocalTime().ToString("HH:mm:ss") · Health snapshots every 5 minutes
|
Refreshed @lastRefreshed.ToLocalTime().ToString("HH:mm:ss") · Health snapshots every 5 minutes
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<StatusRow> rows = [];
|
private List<StatusRow> rows = [];
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
@inject IncidentService IncidentService
|
@inject IncidentService IncidentService
|
||||||
@inject LokiService LokiService
|
@inject LokiService LokiService
|
||||||
@inject GitSyncService GitSyncService
|
@inject GitSyncService GitSyncService
|
||||||
|
@inject CustomerGitService CustomerGitService
|
||||||
@inject IDbContextFactory<ApplicationDbContext> DbFactory
|
@inject IDbContextFactory<ApplicationDbContext> DbFactory
|
||||||
|
|
||||||
@* ═══════════════════════════════════════════════════════════════════
|
@* ═══════════════════════════════════════════════════════════════════
|
||||||
@@ -63,7 +64,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label small">Namespace</label>
|
<label class="form-label small">Namespace</label>
|
||||||
<input class="form-control form-control-sm" @bind="editDeployNamespace" />
|
@if (!string.IsNullOrWhiteSpace(GovernanceNamespace))
|
||||||
|
{
|
||||||
|
<input class="form-control form-control-sm font-monospace" value="@GovernanceNamespace" disabled />
|
||||||
|
<div class="form-text small text-warning"><i class="bi bi-lock me-1"></i>Locked by governance policy.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<input class="form-control form-control-sm" @bind="editDeployNamespace" />
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (Deployment.Type == DeploymentType.HelmChart)
|
@if (Deployment.Type == DeploymentType.HelmChart)
|
||||||
{
|
{
|
||||||
@@ -94,17 +103,25 @@
|
|||||||
|
|
||||||
@if (AccessRole >= CustomerAccessRole.Admin && confirmDeleteDeployment)
|
@if (AccessRole >= CustomerAccessRole.Admin && confirmDeleteDeployment)
|
||||||
{
|
{
|
||||||
<div class="mt-3 p-2 bg-danger bg-opacity-10 rounded d-flex align-items-center justify-content-between">
|
<div class="mt-3 p-3 bg-danger bg-opacity-10 rounded">
|
||||||
<span class="text-danger small">
|
<div class="text-danger small mb-2">
|
||||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||||
Permanently delete <strong>@Deployment.Name</strong> and all its manifests?
|
What would you like to do with <strong>@Deployment.Name</strong>?
|
||||||
</span>
|
</div>
|
||||||
<div class="d-flex gap-1">
|
@if (deleteError is not null)
|
||||||
<button class="btn btn-sm btn-danger" @onclick="DeleteDeployment" disabled="@deletingDeployment">
|
{
|
||||||
@if (deletingDeployment) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
<div class="alert alert-danger py-1 small mb-2">@deleteError</div>
|
||||||
Yes, delete
|
}
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<button class="btn btn-sm btn-danger" @onclick="() => DeleteDeployment(true)" disabled="@deletingDeployment">
|
||||||
|
@if (deletingDeployment && deleteFromCluster) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
<i class="bi bi-trash me-1"></i>Delete (remove cluster resources + DB)
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmDeleteDeployment = false">Cancel</button>
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteDeployment(false)" disabled="@deletingDeployment">
|
||||||
|
@if (deletingDeployment && !deleteFromCluster) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
<i class="bi bi-database-x me-1"></i>Unregister (DB only, keep cluster resources)
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => { confirmDeleteDeployment = false; deleteError = null; }">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -221,6 +238,11 @@
|
|||||||
<i class="bi bi-journal-text me-1"></i>Logs
|
<i class="bi bi-journal-text me-1"></i>Logs
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link @(activeTab == "git" ? "active" : "")" @onclick="LoadGitTab">
|
||||||
|
<i class="bi bi-git me-1"></i>Git
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@* ══════════════════════════════════════════════════════════════
|
@* ══════════════════════════════════════════════════════════════
|
||||||
@@ -246,27 +268,13 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (podsLoading)
|
<LoadingPanel Loading="@podsLoading" LoadingText="Loading pods from cluster…" Error="@podsError">
|
||||||
|
@if (!podsLoading && string.IsNullOrEmpty(podsError) && (pods is null || pods.Count == 0))
|
||||||
{
|
{
|
||||||
<div class="text-center py-4">
|
<EmptyState Icon="bi-cpu"
|
||||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
Message="@($"No pods found in namespace {Deployment.Namespace}.")" />
|
||||||
<p class="text-muted small mt-2">Loading pods from cluster...</p>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
else if (!string.IsNullOrEmpty(podsError))
|
else if (!podsLoading && string.IsNullOrEmpty(podsError) && pods is not null && pods.Count > 0)
|
||||||
{
|
|
||||||
<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) --- *@
|
@* --- Pod log viewer (shown above pods when active) --- *@
|
||||||
@if (showLogViewer)
|
@if (showLogViewer)
|
||||||
@@ -409,6 +417,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
}
|
}
|
||||||
|
|
||||||
@* ══════════════════════════════════════════════════════════════
|
@* ══════════════════════════════════════════════════════════════
|
||||||
@@ -475,20 +484,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (manifests is null)
|
<LoadingPanel Loading="@(manifests is null)">
|
||||||
|
@if (manifests is not null && manifests.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="text-center py-3">
|
<EmptyState Icon="bi-file-earmark-code" Message="No manifests defined for this deployment." />
|
||||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
else if (manifests.Count == 0)
|
else if (manifests is not null)
|
||||||
{
|
|
||||||
<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)
|
@foreach (DeploymentManifest manifest in manifests)
|
||||||
{
|
{
|
||||||
@@ -536,6 +537,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
|
|
||||||
@if (yamlApplyOutput is not null)
|
@if (yamlApplyOutput is not null)
|
||||||
{
|
{
|
||||||
@@ -836,6 +838,163 @@
|
|||||||
PresetNamespace="@Deployment.Namespace" />
|
PresetNamespace="@Deployment.Namespace" />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@* ══════════════════════════════════════════════════════════════
|
||||||
|
Git Tab — per-deployment git source configuration
|
||||||
|
══════════════════════════════════════════════════════════════ *@
|
||||||
|
@if (activeTab == "git")
|
||||||
|
{
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-header bg-white py-2 d-flex align-items-center gap-2">
|
||||||
|
<span class="bi bi-git text-primary"></span>
|
||||||
|
<strong>Git Source</strong>
|
||||||
|
@if (AccessRole >= CustomerAccessRole.Admin && !showGitEdit)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-secondary ms-auto" @onclick="StartGitEdit">
|
||||||
|
<i class="bi bi-pencil"></i> Edit
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
|
||||||
|
@* ── Credential notice ── *@
|
||||||
|
@if (gitTabCredentials.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-info py-2 small mb-3">
|
||||||
|
<i class="bi bi-key-fill me-1"></i>
|
||||||
|
<strong>Stored credentials on file:</strong>
|
||||||
|
@string.Join(", ", gitTabCredentials.Select(c => c.Name)) —
|
||||||
|
no need to enter credentials when setting a repository URL.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ── Policies (read-only) ── *@
|
||||||
|
@if (gitTabPolicies.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="text-muted small mb-1"><i class="bi bi-shield-check me-1"></i>Allowed repository URL patterns:</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
@foreach (CustomerGitRepoPolicy p in gitTabPolicies)
|
||||||
|
{
|
||||||
|
<code class="small bg-light px-2 py-1 rounded">@p.UrlPattern</code>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showGitEdit && AccessRole >= CustomerAccessRole.Admin)
|
||||||
|
{
|
||||||
|
@* ── Edit form ── *@
|
||||||
|
@if (gitEditError is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger py-1 small mb-2">@gitEditError</div>
|
||||||
|
}
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label small">Repository URL</label>
|
||||||
|
<input class="form-control form-control-sm font-monospace @(gitEditUrlError is not null ? "is-invalid" : "")"
|
||||||
|
@bind="gitEditUrl" @bind:event="oninput" @onchange="ValidateGitEditUrl"
|
||||||
|
placeholder="https://github.com/org/repo" />
|
||||||
|
@if (gitEditUrlError is not null)
|
||||||
|
{
|
||||||
|
<div class="invalid-feedback">@gitEditUrlError</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Path <span class="text-muted">(leave blank for root)</span></label>
|
||||||
|
<input class="form-control form-control-sm font-monospace" @bind="gitEditPath" placeholder="." />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Branch / Revision <span class="text-muted">(leave blank for default)</span></label>
|
||||||
|
<input class="form-control form-control-sm font-monospace" @bind="gitEditRevision" placeholder="main" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="gitAutoSync" @bind="gitEditAutoSync" />
|
||||||
|
<label class="form-check-label small" for="gitAutoSync">Auto-sync (poll for changes every 3 minutes)</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="SaveGitSettings"
|
||||||
|
disabled="@(savingGitSettings || gitEditUrlError is not null || string.IsNullOrWhiteSpace(gitEditUrl))">
|
||||||
|
@if (savingGitSettings) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showGitEdit = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (Deployment.GitRepositoryId is not null)
|
||||||
|
{
|
||||||
|
@* ── Current settings (read) ── *@
|
||||||
|
<dl class="row small mb-0">
|
||||||
|
<dt class="col-sm-3">Repository</dt>
|
||||||
|
<dd class="col-sm-9 font-monospace">@(Deployment.GitRepository?.Url ?? "—")</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">Path</dt>
|
||||||
|
<dd class="col-sm-9 font-monospace">@(Deployment.GitPath ?? ".")</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">Revision</dt>
|
||||||
|
<dd class="col-sm-9 font-monospace">@(Deployment.GitRevision ?? Deployment.GitRepository?.DefaultBranch ?? "main")</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">Auto-sync</dt>
|
||||||
|
<dd class="col-sm-9">
|
||||||
|
@if (Deployment.GitAutoSync)
|
||||||
|
{
|
||||||
|
<span class="badge bg-success">Enabled</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="badge bg-secondary">Disabled</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
@if (Deployment.GitLastSyncedCommit is not null)
|
||||||
|
{
|
||||||
|
<dt class="col-sm-3">Last synced</dt>
|
||||||
|
<dd class="col-sm-9">
|
||||||
|
<code>@Deployment.GitLastSyncedCommit[..Math.Min(7, Deployment.GitLastSyncedCommit.Length)]</code>
|
||||||
|
@if (Deployment.GitLastSyncedAt.HasValue)
|
||||||
|
{
|
||||||
|
<span class="text-muted ms-2">@Deployment.GitLastSyncedAt.Value.ToString("yyyy-MM-dd HH:mm")</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
@if (AccessRole >= CustomerAccessRole.Operator)
|
||||||
|
{
|
||||||
|
<div class="mt-3">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" @onclick="TriggerGitSync" disabled="@syncingGit">
|
||||||
|
@if (syncingGit) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
else { <i class="bi bi-arrow-repeat me-1"></i> }
|
||||||
|
Sync now
|
||||||
|
</button>
|
||||||
|
@if (gitSyncMessage is not null)
|
||||||
|
{
|
||||||
|
<span class="ms-2 small @(gitSyncSuccess ? "text-success" : "text-danger")">
|
||||||
|
<i class="bi @(gitSyncSuccess ? "bi-check-circle" : "bi-exclamation-triangle") me-1"></i>@gitSyncMessage
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@* ── Not configured ── *@
|
||||||
|
<div class="text-muted small">
|
||||||
|
No git source configured for this deployment.
|
||||||
|
@if (AccessRole >= CustomerAccessRole.Admin)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-primary ms-2" @onclick="StartGitEdit">
|
||||||
|
<i class="bi bi-plus me-1"></i>Configure Git
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public required AppDeployment Deployment { get; set; }
|
[Parameter] public required AppDeployment Deployment { get; set; }
|
||||||
[Parameter] public CustomerAccessRole AccessRole { get; set; }
|
[Parameter] public CustomerAccessRole AccessRole { get; set; }
|
||||||
@@ -843,11 +1002,24 @@
|
|||||||
[Parameter] public required DeploymentService DeploymentService { get; set; }
|
[Parameter] public required DeploymentService DeploymentService { get; set; }
|
||||||
[Parameter] public required PrometheusService PrometheusService { get; set; }
|
[Parameter] public required PrometheusService PrometheusService { get; set; }
|
||||||
[Parameter] public required AuditService AuditService { get; set; }
|
[Parameter] public required AuditService AuditService { get; set; }
|
||||||
|
[Parameter] public Guid CustomerId { get; set; }
|
||||||
|
[Parameter] public Guid TenantId { get; set; }
|
||||||
|
[Parameter] public string? GovernanceNamespace { get; set; }
|
||||||
[Parameter] public EventCallback OnBack { get; set; }
|
[Parameter] public EventCallback OnBack { get; set; }
|
||||||
[Parameter] public EventCallback OnDeleted { get; set; }
|
[Parameter] public EventCallback OnDeleted { get; set; }
|
||||||
|
|
||||||
private string activeTab = "pods";
|
private string activeTab = "pods";
|
||||||
|
|
||||||
|
// Git tab
|
||||||
|
private List<CustomerGitRepoPolicy> gitTabPolicies = [];
|
||||||
|
private List<CustomerGitCredential> gitTabCredentials = [];
|
||||||
|
private bool showGitEdit;
|
||||||
|
private bool savingGitSettings;
|
||||||
|
private string gitEditUrl = "", gitEditPath = "", gitEditRevision = "";
|
||||||
|
private bool gitEditAutoSync = true;
|
||||||
|
private string? gitEditUrlError;
|
||||||
|
private string? gitEditError;
|
||||||
|
|
||||||
// Git sync
|
// Git sync
|
||||||
private bool syncingGit;
|
private bool syncingGit;
|
||||||
private string? gitSyncMessage;
|
private string? gitSyncMessage;
|
||||||
@@ -877,6 +1049,107 @@
|
|||||||
finally { syncingGit = false; }
|
finally { syncingGit = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────── Git tab ────────
|
||||||
|
|
||||||
|
private async Task LoadGitTab()
|
||||||
|
{
|
||||||
|
activeTab = "git";
|
||||||
|
if (gitTabPolicies.Count == 0 && CustomerId != Guid.Empty)
|
||||||
|
{
|
||||||
|
gitTabPolicies = await CustomerGitService.GetRepoPoliciesAsync(CustomerId);
|
||||||
|
gitTabCredentials = await CustomerGitService.GetCredentialsAsync(CustomerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartGitEdit()
|
||||||
|
{
|
||||||
|
gitEditUrl = Deployment.GitRepository?.Url ?? "";
|
||||||
|
gitEditPath = Deployment.GitPath ?? "";
|
||||||
|
gitEditRevision = Deployment.GitRevision ?? "";
|
||||||
|
gitEditAutoSync = Deployment.GitAutoSync;
|
||||||
|
gitEditUrlError = null;
|
||||||
|
gitEditError = null;
|
||||||
|
showGitEdit = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ValidateGitEditUrl(ChangeEventArgs e)
|
||||||
|
{
|
||||||
|
gitEditUrl = e.Value?.ToString() ?? "";
|
||||||
|
if (string.IsNullOrWhiteSpace(gitEditUrl))
|
||||||
|
{
|
||||||
|
gitEditUrlError = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
gitEditUrlError = gitTabPolicies.Count == 0 || CustomerGitService.MatchesAnyPolicy(gitEditUrl, gitTabPolicies)
|
||||||
|
? null
|
||||||
|
: "URL does not match any allowed policy pattern.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveGitSettings()
|
||||||
|
{
|
||||||
|
savingGitSettings = true;
|
||||||
|
gitEditError = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (gitTabPolicies.Count > 0 && !CustomerGitService.MatchesAnyPolicy(gitEditUrl, gitTabPolicies))
|
||||||
|
{
|
||||||
|
gitEditError = "The repository URL does not match any allowed policy pattern.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Guid? repoId = null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(gitEditUrl))
|
||||||
|
{
|
||||||
|
CustomerGitCredential? cred = gitTabCredentials.FirstOrDefault();
|
||||||
|
if (cred is not null)
|
||||||
|
{
|
||||||
|
GitRepository repo = await CustomerGitService.FindOrCreateRepositoryForCustomerAsync(
|
||||||
|
TenantId, CustomerId, gitEditUrl, cred.Id);
|
||||||
|
repoId = repo.Id;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// No customer credential — check if existing repo already has this URL.
|
||||||
|
using ApplicationDbContext db = DbFactory.CreateDbContext();
|
||||||
|
GitRepository? existing = await db.GitRepositories
|
||||||
|
.FirstOrDefaultAsync(r => r.TenantId == TenantId && r.Url == gitEditUrl);
|
||||||
|
repoId = existing?.Id;
|
||||||
|
if (repoId is null)
|
||||||
|
{
|
||||||
|
gitEditError = "No stored credential found. Ask your administrator to add a git credential for your account.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await DeploymentService.UpdateGitSettingsAsync(
|
||||||
|
Deployment.Id, repoId,
|
||||||
|
string.IsNullOrWhiteSpace(gitEditPath) ? null : gitEditPath,
|
||||||
|
string.IsNullOrWhiteSpace(gitEditRevision) ? null : gitEditRevision,
|
||||||
|
gitEditAutoSync);
|
||||||
|
|
||||||
|
// Update local state so the UI reflects the change without reload.
|
||||||
|
Deployment.GitRepositoryId = repoId;
|
||||||
|
Deployment.GitPath = string.IsNullOrWhiteSpace(gitEditPath) ? null : gitEditPath;
|
||||||
|
Deployment.GitRevision = string.IsNullOrWhiteSpace(gitEditRevision) ? null : gitEditRevision;
|
||||||
|
Deployment.GitAutoSync = gitEditAutoSync;
|
||||||
|
Deployment.GitLastSyncedCommit = null;
|
||||||
|
Deployment.GitLastSyncedAt = null;
|
||||||
|
|
||||||
|
showGitEdit = false;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
gitEditError = ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
savingGitSettings = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Pods
|
// Pods
|
||||||
private List<PodInfo>? pods;
|
private List<PodInfo>? pods;
|
||||||
private bool podsLoading;
|
private bool podsLoading;
|
||||||
@@ -934,6 +1207,8 @@
|
|||||||
// Delete deployment
|
// Delete deployment
|
||||||
private bool confirmDeleteDeployment;
|
private bool confirmDeleteDeployment;
|
||||||
private bool deletingDeployment;
|
private bool deletingDeployment;
|
||||||
|
private bool deleteFromCluster;
|
||||||
|
private string? deleteError;
|
||||||
|
|
||||||
// Resources
|
// Resources
|
||||||
private List<DeploymentResource>? resourceTree;
|
private List<DeploymentResource>? resourceTree;
|
||||||
@@ -1125,7 +1400,7 @@
|
|||||||
private void StartEditDeployment()
|
private void StartEditDeployment()
|
||||||
{
|
{
|
||||||
editDeployName = Deployment.Name;
|
editDeployName = Deployment.Name;
|
||||||
editDeployNamespace = Deployment.Namespace;
|
editDeployNamespace = !string.IsNullOrWhiteSpace(GovernanceNamespace) ? GovernanceNamespace : Deployment.Namespace;
|
||||||
editHelmRepoUrl = Deployment.HelmRepoUrl ?? "";
|
editHelmRepoUrl = Deployment.HelmRepoUrl ?? "";
|
||||||
editHelmChartName = Deployment.HelmChartName ?? "";
|
editHelmChartName = Deployment.HelmChartName ?? "";
|
||||||
editHelmChartVersion = Deployment.HelmChartVersion ?? "";
|
editHelmChartVersion = Deployment.HelmChartVersion ?? "";
|
||||||
@@ -1140,14 +1415,16 @@
|
|||||||
editDeployError = null;
|
editDeployError = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
string ns = !string.IsNullOrWhiteSpace(GovernanceNamespace) ? GovernanceNamespace : editDeployNamespace;
|
||||||
|
|
||||||
await DeploymentService.UpdateDeploymentAsync(
|
await DeploymentService.UpdateDeploymentAsync(
|
||||||
Deployment.Id, editDeployName, editDeployNamespace,
|
Deployment.Id, editDeployName, ns,
|
||||||
Deployment.Type == DeploymentType.HelmChart ? editHelmRepoUrl : null,
|
Deployment.Type == DeploymentType.HelmChart ? editHelmRepoUrl : null,
|
||||||
Deployment.Type == DeploymentType.HelmChart ? editHelmChartName : null,
|
Deployment.Type == DeploymentType.HelmChart ? editHelmChartName : null,
|
||||||
Deployment.Type == DeploymentType.HelmChart ? editHelmChartVersion : null);
|
Deployment.Type == DeploymentType.HelmChart ? editHelmChartVersion : null);
|
||||||
|
|
||||||
Deployment.Name = editDeployName;
|
Deployment.Name = editDeployName;
|
||||||
Deployment.Namespace = editDeployNamespace;
|
Deployment.Namespace = ns;
|
||||||
if (Deployment.Type == DeploymentType.HelmChart)
|
if (Deployment.Type == DeploymentType.HelmChart)
|
||||||
{
|
{
|
||||||
Deployment.HelmRepoUrl = editHelmRepoUrl;
|
Deployment.HelmRepoUrl = editHelmRepoUrl;
|
||||||
@@ -1161,15 +1438,36 @@
|
|||||||
finally { savingEdit = false; }
|
finally { savingEdit = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteDeployment()
|
private async Task DeleteDeployment(bool fromCluster)
|
||||||
{
|
{
|
||||||
deletingDeployment = true;
|
deletingDeployment = true;
|
||||||
|
deleteFromCluster = fromCluster;
|
||||||
|
deleteError = null;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
if (fromCluster)
|
||||||
|
{
|
||||||
|
KubernetesOperationResult<string> clusterResult =
|
||||||
|
await K8sOps.DeleteDeploymentFromClusterAsync(Deployment.Id);
|
||||||
|
|
||||||
|
if (!clusterResult.IsSuccess)
|
||||||
|
{
|
||||||
|
deleteError = clusterResult.Error;
|
||||||
|
deletingDeployment = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await DeploymentService.DeleteDeploymentAsync(Deployment.Id);
|
await DeploymentService.DeleteDeploymentAsync(Deployment.Id);
|
||||||
await OnDeleted.InvokeAsync();
|
await OnDeleted.InvokeAsync();
|
||||||
}
|
}
|
||||||
catch { deletingDeployment = false; confirmDeleteDeployment = false; }
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
deleteError = ex.Message;
|
||||||
|
deletingDeployment = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────── Manifests ────────
|
// ──────── Manifests ────────
|
||||||
|
|||||||
@@ -36,11 +36,8 @@
|
|||||||
@* ── Users ── *@
|
@* ── Users ── *@
|
||||||
@if (activeTab == "users")
|
@if (activeTab == "users")
|
||||||
{
|
{
|
||||||
@if (loadingUsers)
|
<LoadingPanel Loading="@loadingUsers">
|
||||||
{
|
@if (!loadingUsers)
|
||||||
<div class="spinner-border spinner-border-sm text-primary"></div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<span class="text-muted small">@(users?.Count ?? 0) users</span>
|
<span class="text-muted small">@(users?.Count ?? 0) users</span>
|
||||||
@@ -126,16 +123,14 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
}
|
}
|
||||||
|
|
||||||
@* ── Identity Providers ── *@
|
@* ── Identity Providers ── *@
|
||||||
@if (activeTab == "idp")
|
@if (activeTab == "idp")
|
||||||
{
|
{
|
||||||
@if (loadingIdps)
|
<LoadingPanel Loading="@loadingIdps">
|
||||||
{
|
@if (!loadingIdps)
|
||||||
<div class="spinner-border spinner-border-sm text-primary"></div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<span class="text-muted small">@(idps?.Count ?? 0) providers</span>
|
<span class="text-muted small">@(idps?.Count ?? 0) providers</span>
|
||||||
@@ -207,16 +202,14 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
}
|
}
|
||||||
|
|
||||||
@* ── Groups ── *@
|
@* ── Groups ── *@
|
||||||
@if (activeTab == "groups")
|
@if (activeTab == "groups")
|
||||||
{
|
{
|
||||||
@if (loadingGroups)
|
<LoadingPanel Loading="@loadingGroups">
|
||||||
{
|
@if (!loadingGroups)
|
||||||
<div class="spinner-border spinner-border-sm text-primary"></div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<span class="text-muted small">@(groups?.Count ?? 0) groups</span>
|
<span class="text-muted small">@(groups?.Count ?? 0) groups</span>
|
||||||
@@ -257,16 +250,14 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
}
|
}
|
||||||
|
|
||||||
@* ── Organizations ── *@
|
@* ── Organizations ── *@
|
||||||
@if (activeTab == "orgs")
|
@if (activeTab == "orgs")
|
||||||
{
|
{
|
||||||
@if (loadingOrgs)
|
<LoadingPanel Loading="@loadingOrgs">
|
||||||
{
|
@if (!loadingOrgs)
|
||||||
<div class="spinner-border spinner-border-sm text-primary"></div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<span class="text-muted small">@(orgs?.Count ?? 0) organizations (Keycloak 26+)</span>
|
<span class="text-muted small">@(orgs?.Count ?? 0) organizations (Keycloak 26+)</span>
|
||||||
@@ -322,6 +313,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
|||||||
@@ -2,20 +2,8 @@
|
|||||||
@using EntKube.Web.Services
|
@using EntKube.Web.Services
|
||||||
@inject HarborService HarborService
|
@inject HarborService HarborService
|
||||||
|
|
||||||
@if (loading)
|
<LoadingPanel Loading="@loading" LoadingText="Loading registry…" Error="@loadError">
|
||||||
{
|
@if (!loading && loadError is null)
|
||||||
<div class="text-center py-5">
|
|
||||||
<div class="spinner-border text-primary" role="status"></div>
|
|
||||||
<p class="text-muted mt-2">Loading registry...</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else if (loadError is not null)
|
|
||||||
{
|
|
||||||
<div class="alert alert-danger">
|
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>@loadError
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
<div class="d-flex align-items-center gap-2 mb-3">
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
<i class="bi bi-archive fs-4 text-primary"></i>
|
<i class="bi bi-archive fs-4 text-primary"></i>
|
||||||
@@ -329,6 +317,7 @@ else
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public required HarborProject Project { get; set; }
|
[Parameter] public required HarborProject Project { get; set; }
|
||||||
|
|||||||
@@ -102,24 +102,13 @@ else
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
@if (secrets is null)
|
<LoadingPanel Loading="@(secrets is null)">
|
||||||
|
@if (secrets is not null && secrets.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="text-center py-4">
|
<EmptyState Icon="bi-lock"
|
||||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
Message="@(AccessRole >= CustomerAccessRole.Admin ? "No secrets stored yet. Use the form above to add one." : "No secrets stored for this app yet.")" />
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
else if (secrets.Count == 0)
|
else if (secrets is not null)
|
||||||
{
|
|
||||||
<div class="text-center py-5">
|
|
||||||
<i class="bi bi-lock text-muted" style="font-size: 2rem;"></i>
|
|
||||||
<p class="text-muted small mt-2 mb-0">No secrets stored for this app yet.</p>
|
|
||||||
@if (AccessRole >= CustomerAccessRole.Admin)
|
|
||||||
{
|
|
||||||
<p class="text-muted small mb-0">Use the form above to add one.</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover align-middle mb-0">
|
<table class="table table-hover align-middle mb-0">
|
||||||
@@ -271,6 +260,7 @@ else
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
@if (syncOutput is not null)
|
@if (syncOutput is not null)
|
||||||
{
|
{
|
||||||
<div class="m-3 p-2 bg-dark text-white rounded font-monospace small" style="white-space: pre-wrap; max-height: 200px; overflow-y: auto;">@syncOutput</div>
|
<div class="m-3 p-2 bg-dark text-white rounded font-monospace small" style="white-space: pre-wrap; max-height: 200px; overflow-y: auto;">@syncOutput</div>
|
||||||
|
|||||||
25
src/EntKube.Web/Components/Pages/Shared/EmptyState.razor
Normal file
25
src/EntKube.Web/Components/Pages/Shared/EmptyState.razor
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<div class="text-center py-4">
|
||||||
|
<i class="bi @Icon text-muted" style="font-size: 2.5rem;"></i>
|
||||||
|
@if (!string.IsNullOrEmpty(Title))
|
||||||
|
{
|
||||||
|
<h6 class="mt-3 mb-1 text-muted fw-semibold">@Title</h6>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrEmpty(Message))
|
||||||
|
{
|
||||||
|
<p class="text-muted small mb-@(OnAction.HasDelegate ? 3 : 0)">@Message</p>
|
||||||
|
}
|
||||||
|
@if (OnAction.HasDelegate && !string.IsNullOrEmpty(ActionLabel))
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="OnAction">
|
||||||
|
<i class="bi bi-plus me-1"></i>@ActionLabel
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string Icon { get; set; } = "bi-inbox";
|
||||||
|
[Parameter] public string? Title { get; set; }
|
||||||
|
[Parameter] public string? Message { get; set; }
|
||||||
|
[Parameter] public string? ActionLabel { get; set; }
|
||||||
|
[Parameter] public EventCallback OnAction { get; set; }
|
||||||
|
}
|
||||||
27
src/EntKube.Web/Components/Pages/Shared/LoadingPanel.razor
Normal file
27
src/EntKube.Web/Components/Pages/Shared/LoadingPanel.razor
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
@if (Loading)
|
||||||
|
{
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||||
|
@if (!string.IsNullOrEmpty(LoadingText))
|
||||||
|
{
|
||||||
|
<p class="text-muted small mt-2 mb-0">@LoadingText</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrEmpty(Error))
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning small py-2 mb-0">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>@Error
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@ChildContent
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public bool Loading { get; set; }
|
||||||
|
[Parameter] public string? Error { get; set; }
|
||||||
|
[Parameter] public string? LoadingText { get; set; }
|
||||||
|
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -30,27 +30,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
@if (loading)
|
<LoadingPanel Loading="@loading" Error="@errorMessage">
|
||||||
{
|
@if (!loading && string.IsNullOrEmpty(errorMessage) && view == "alerts")
|
||||||
<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 ── *@
|
@* ── Active Alerts ── *@
|
||||||
@if (alerts is null || alerts.Count == 0)
|
@if (alerts is null || alerts.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="text-center py-4">
|
<EmptyState Icon="bi-check-circle"
|
||||||
<i class="bi bi-check-circle text-success" style="font-size: 2rem;"></i>
|
Title="All clear!"
|
||||||
<p class="text-muted mt-2 mb-0">No active alerts. All clear!</p>
|
Message="No active alerts right now." />
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -93,15 +81,12 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (view == "silences")
|
else if (!loading && string.IsNullOrEmpty(errorMessage) && view == "silences")
|
||||||
{
|
{
|
||||||
@* ── Silences ── *@
|
@* ── Silences ── *@
|
||||||
@if (silences is null || silences.Count == 0)
|
@if (silences is null || silences.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="text-center py-4">
|
<EmptyState Icon="bi-bell-slash" Message="No silences configured." />
|
||||||
<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
|
else
|
||||||
{
|
{
|
||||||
@@ -134,6 +119,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
</LoadingPanel>
|
||||||
|
|
||||||
@* ── Create Silence Form ── *@
|
@* ── Create Silence Form ── *@
|
||||||
@if (showCreateSilence)
|
@if (showCreateSilence)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
@inject TenantService TenantService
|
@inject TenantService TenantService
|
||||||
@inject DeploymentService DeploymentService
|
@inject DeploymentService DeploymentService
|
||||||
@inject CnpgService CnpgService
|
@inject CnpgService CnpgService
|
||||||
|
@inject MongoService MongoService
|
||||||
@inject KubernetesOperationsService K8sOps
|
@inject KubernetesOperationsService K8sOps
|
||||||
@inject PrometheusService PrometheusService
|
@inject PrometheusService PrometheusService
|
||||||
@inject AuditService AuditService
|
@inject AuditService AuditService
|
||||||
@@ -110,20 +111,13 @@
|
|||||||
<small class="text-muted">Select which environments this app is deployed to.</small>
|
<small class="text-muted">Select which environments this app is deployed to.</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@if (environments is null)
|
<LoadingPanel Loading="@(environments is null)">
|
||||||
|
@if (environments is not null && environments.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="text-center py-3">
|
<EmptyState Icon="bi-layers"
|
||||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
Message="No environments exist for this tenant yet. Create environments first." />
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
else if (environments.Count == 0)
|
else if (environments is not null)
|
||||||
{
|
|
||||||
<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">
|
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
|
||||||
@foreach (Data.Environment env in environments)
|
@foreach (Data.Environment env in environments)
|
||||||
@@ -149,6 +143,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -183,8 +178,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-items-start gap-1">
|
<div class="d-flex align-items-start gap-1">
|
||||||
@GetSyncBadge(selectedDeployment.SyncStatus)
|
<button class="btn btn-link p-0 border-0 bg-transparent" title="View resources"
|
||||||
@GetHealthBadge(selectedDeployment.HealthStatus)
|
@onclick="OpenResourcesTab">
|
||||||
|
@GetSyncBadge(selectedDeployment.SyncStatus)
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-link p-0 border-0 bg-transparent" title="View activity"
|
||||||
|
@onclick="OpenActivityTab">
|
||||||
|
@GetHealthBadge(selectedDeployment.HealthStatus)
|
||||||
|
</button>
|
||||||
|
|
||||||
<button class="btn btn-sm btn-outline-secondary ms-2"
|
<button class="btn btn-sm btn-outline-secondary ms-2"
|
||||||
@onclick="StartEditDeployment" title="Edit deployment">
|
@onclick="StartEditDeployment" title="Edit deployment">
|
||||||
@@ -436,20 +437,13 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (manifests is null)
|
<LoadingPanel Loading="@(manifests is null)">
|
||||||
|
@if (manifests is not null && manifests.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="text-center py-3">
|
<EmptyState Icon="bi-file-earmark-code"
|
||||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
Message="No manifests yet. Add YAML manifests to define what gets deployed." />
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
else if (manifests.Count == 0)
|
else if (manifests is not null)
|
||||||
{
|
|
||||||
<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)
|
@foreach (DeploymentManifest manifest in manifests)
|
||||||
{
|
{
|
||||||
@@ -469,12 +463,19 @@
|
|||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => editingManifestId = null" disabled="@savingManifest">Cancel</button>
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => editingManifestId = null" disabled="@savingManifest">Cancel</button>
|
||||||
}
|
}
|
||||||
|
else if (confirmDeleteManifestId == manifest.Id)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-danger" @onclick="() => DeleteManifest(manifest.Id)">
|
||||||
|
<i class="bi bi-trash me-1"></i>Delete
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmDeleteManifestId = null">Cancel</button>
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<button class="btn btn-sm btn-outline-primary" @onclick="() => StartEditManifest(manifest)">
|
<button class="btn btn-sm btn-outline-primary" @onclick="() => StartEditManifest(manifest)">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteManifest(manifest.Id)">
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => confirmDeleteManifestId = manifest.Id">
|
||||||
<i class="bi bi-trash"></i>
|
<i class="bi bi-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -497,6 +498,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
|
|
||||||
@if (yamlApplyOutput is not null)
|
@if (yamlApplyOutput is not null)
|
||||||
{
|
{
|
||||||
@@ -621,18 +623,29 @@
|
|||||||
{
|
{
|
||||||
<div class="card border-primary mb-3">
|
<div class="card border-primary mb-3">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h6 class="mb-3"><i class="bi bi-plus-circle me-1"></i>Attach a PostgreSQL Database</h6>
|
<h6 class="mb-3"><i class="bi bi-plus-circle me-1"></i>Attach a Database</h6>
|
||||||
<div class="row g-2 mb-2">
|
<div class="row g-2 mb-2">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label small">Database</label>
|
<label class="form-label small">Database</label>
|
||||||
<select class="form-select form-select-sm" @bind="newBindingDatabaseId">
|
<select class="form-select form-select-sm" @bind="newBindingDatabaseId">
|
||||||
<option value="">Select database...</option>
|
<option value="">Select database...</option>
|
||||||
@if (availableCnpgDatabases is not null)
|
@if (availableCnpgDatabases is not null && availableCnpgDatabases.Count > 0)
|
||||||
{
|
{
|
||||||
@foreach ((CnpgCluster cluster, CnpgDatabase database) in availableCnpgDatabases)
|
<optgroup label="PostgreSQL (CNPG)">
|
||||||
{
|
@foreach ((CnpgCluster cluster, CnpgDatabase database) in availableCnpgDatabases)
|
||||||
<option value="@database.Id">@cluster.Name / @database.Name</option>
|
{
|
||||||
}
|
<option value="@database.Id">@cluster.Name / @database.Name</option>
|
||||||
|
}
|
||||||
|
</optgroup>
|
||||||
|
}
|
||||||
|
@if (availableMongoDatabases is not null && availableMongoDatabases.Count > 0)
|
||||||
|
{
|
||||||
|
<optgroup label="MongoDB">
|
||||||
|
@foreach ((MongoCluster cluster, MongoDatabase database) in availableMongoDatabases)
|
||||||
|
{
|
||||||
|
<option value="@database.Id">@cluster.Name / @database.Name</option>
|
||||||
|
}
|
||||||
|
</optgroup>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -655,21 +668,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@* --- Binding list --- *@
|
@* --- Binding list --- *@
|
||||||
@if (databaseBindings is null)
|
<LoadingPanel Loading="@(databaseBindings is null)">
|
||||||
|
@if (databaseBindings is not null && databaseBindings.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="text-center py-3">
|
<EmptyState Icon="bi-database"
|
||||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
Message="No databases attached. Attach a database to have its credentials automatically synced to this deployment's namespace." />
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
else if (databaseBindings.Count == 0)
|
else if (databaseBindings is not null)
|
||||||
{
|
|
||||||
<div class="text-center py-4">
|
|
||||||
<i class="bi bi-database text-muted" style="font-size: 2rem;"></i>
|
|
||||||
<p class="text-muted small mt-2 mb-0">No databases attached. Attach a database to have its credentials automatically synced to this deployment's namespace.</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
|
<div class="table-responsive">
|
||||||
<table class="table table-sm mb-0">
|
<table class="table table-sm mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -700,16 +707,26 @@
|
|||||||
@(binding.LastSyncedAt.HasValue ? binding.LastSyncedAt.Value.ToString("yyyy-MM-dd HH:mm") : "Never")
|
@(binding.LastSyncedAt.HasValue ? binding.LastSyncedAt.Value.ToString("yyyy-MM-dd HH:mm") : "Never")
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-link btn-sm text-danger p-0" title="Detach"
|
@if (confirmRemoveBindingId == binding.Id)
|
||||||
@onclick="() => RemoveDatabaseBinding(binding.Id)">
|
{
|
||||||
<i class="bi bi-x-circle" style="font-size:0.85rem;"></i>
|
<button class="btn btn-sm btn-danger me-1" @onclick="() => RemoveDatabaseBinding(binding.Id)">Detach</button>
|
||||||
</button>
|
<button class="btn btn-sm btn-link p-0" @onclick="() => confirmRemoveBindingId = null">Cancel</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="btn btn-link btn-sm text-danger p-0" title="Detach"
|
||||||
|
@onclick="() => confirmRemoveBindingId = binding.Id">
|
||||||
|
<i class="bi bi-x-circle" style="font-size:0.85rem;"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -770,21 +787,15 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (cacheBindings is null)
|
<LoadingPanel Loading="@(cacheBindings is null)">
|
||||||
|
@if (cacheBindings is not null && cacheBindings.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="text-center py-3">
|
<EmptyState Icon="bi-lightning-charge"
|
||||||
<div class="spinner-border spinner-border-sm text-warning" role="status"></div>
|
Message="No Redis clusters attached. Attach one to have REDIS_HOST, REDIS_PORT, and REDIS_PASSWORD synced into this deployment's namespace." />
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
else if (cacheBindings.Count == 0)
|
else if (cacheBindings is not null)
|
||||||
{
|
|
||||||
<div class="text-center py-4">
|
|
||||||
<i class="bi bi-lightning-charge text-muted" style="font-size: 2rem;"></i>
|
|
||||||
<p class="text-muted small mt-2 mb-0">No Redis clusters attached. Attach one to have REDIS_HOST, REDIS_PORT, and REDIS_PASSWORD synced into this deployment's namespace.</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
|
<div class="table-responsive">
|
||||||
<table class="table table-sm mb-0">
|
<table class="table table-sm mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -808,16 +819,26 @@
|
|||||||
@(binding.LastSyncedAt.HasValue ? binding.LastSyncedAt.Value.ToString("yyyy-MM-dd HH:mm") : "Never")
|
@(binding.LastSyncedAt.HasValue ? binding.LastSyncedAt.Value.ToString("yyyy-MM-dd HH:mm") : "Never")
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-link btn-sm text-danger p-0" title="Detach"
|
@if (confirmRemoveCacheBindingId == binding.Id)
|
||||||
@onclick="() => RemoveCacheBinding(binding.Id)">
|
{
|
||||||
<i class="bi bi-x-circle" style="font-size:0.85rem;"></i>
|
<button class="btn btn-sm btn-danger me-1" @onclick="() => RemoveCacheBinding(binding.Id)">Detach</button>
|
||||||
</button>
|
<button class="btn btn-sm btn-link p-0" @onclick="() => confirmRemoveCacheBindingId = null">Cancel</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="btn btn-link btn-sm text-danger p-0" title="Detach"
|
||||||
|
@onclick="() => confirmRemoveCacheBindingId = binding.Id">
|
||||||
|
<i class="bi bi-x-circle" style="font-size:0.85rem;"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1081,20 +1102,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@* --- Deployment list --- *@
|
@* --- Deployment list --- *@
|
||||||
@if (deployments is null)
|
<LoadingPanel Loading="@(deployments is null)">
|
||||||
|
@if (deployments is not null && deployments.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="text-center py-3">
|
<EmptyState Icon="bi-rocket-takeoff"
|
||||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
Message="No deployments yet. Create one to start deploying to Kubernetes." />
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
else if (deployments.Count == 0)
|
else if (deployments is not null)
|
||||||
{
|
|
||||||
<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">
|
<div class="row row-cols-1 row-cols-lg-2 g-2">
|
||||||
@foreach (AppDeployment deploy in deployments)
|
@foreach (AppDeployment deploy in deployments)
|
||||||
@@ -1129,6 +1143,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1320,10 +1335,12 @@
|
|||||||
// Database bindings
|
// Database bindings
|
||||||
private List<DatabaseBinding>? databaseBindings;
|
private List<DatabaseBinding>? databaseBindings;
|
||||||
private List<(CnpgCluster Cluster, CnpgDatabase Database)>? availableCnpgDatabases;
|
private List<(CnpgCluster Cluster, CnpgDatabase Database)>? availableCnpgDatabases;
|
||||||
|
private List<(MongoCluster Cluster, MongoDatabase Database)>? availableMongoDatabases;
|
||||||
private bool showAddBinding;
|
private bool showAddBinding;
|
||||||
private Guid newBindingDatabaseId;
|
private Guid newBindingDatabaseId;
|
||||||
private string newBindingSecretName = "";
|
private string newBindingSecretName = "";
|
||||||
private string? bindingError;
|
private string? bindingError;
|
||||||
|
private Guid? confirmRemoveBindingId;
|
||||||
|
|
||||||
// Cache bindings
|
// Cache bindings
|
||||||
private List<CacheBinding>? cacheBindings;
|
private List<CacheBinding>? cacheBindings;
|
||||||
@@ -1332,6 +1349,10 @@
|
|||||||
private Guid newCacheBindingClusterId;
|
private Guid newCacheBindingClusterId;
|
||||||
private string newCacheBindingSecretName = "redis-cache";
|
private string newCacheBindingSecretName = "redis-cache";
|
||||||
private string? cacheBindingError;
|
private string? cacheBindingError;
|
||||||
|
private Guid? confirmRemoveCacheBindingId;
|
||||||
|
|
||||||
|
// Manifest confirm delete
|
||||||
|
private Guid? confirmDeleteManifestId;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -1629,6 +1650,7 @@
|
|||||||
|
|
||||||
private async Task DeleteManifest(Guid manifestId)
|
private async Task DeleteManifest(Guid manifestId)
|
||||||
{
|
{
|
||||||
|
confirmDeleteManifestId = null;
|
||||||
await DeploymentService.DeleteManifestAsync(manifestId);
|
await DeploymentService.DeleteManifestAsync(manifestId);
|
||||||
await LoadManifests();
|
await LoadManifests();
|
||||||
}
|
}
|
||||||
@@ -1761,6 +1783,15 @@
|
|||||||
.SelectMany(c => c.Databases.Select(d => (c, d)))
|
.SelectMany(c => c.Databases.Select(d => (c, d)))
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (availableMongoDatabases is null)
|
||||||
|
{
|
||||||
|
List<MongoCluster> mongoClusters = await MongoService.GetClustersAsync(TenantId);
|
||||||
|
availableMongoDatabases = mongoClusters
|
||||||
|
.Where(c => !c.IsExternal)
|
||||||
|
.SelectMany(c => c.Databases.Select(d => (c, d)))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1828,17 +1859,29 @@
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
DatabaseBinding binding = await CnpgService.AddCnpgDatabaseBindingAsync(
|
(MongoCluster cluster, MongoDatabase _)? mongoMatch = availableMongoDatabases?
|
||||||
selectedDeployment.Id, newBindingDatabaseId, newBindingSecretName);
|
|
||||||
|
|
||||||
// Find the cluster that owns this database so we can call sync.
|
|
||||||
(CnpgCluster cluster, CnpgDatabase _)? match = availableCnpgDatabases?
|
|
||||||
.FirstOrDefault(x => x.Database.Id == newBindingDatabaseId);
|
.FirstOrDefault(x => x.Database.Id == newBindingDatabaseId);
|
||||||
|
|
||||||
if (match.HasValue)
|
if (mongoMatch.HasValue)
|
||||||
{
|
{
|
||||||
await CnpgService.SyncDatabaseCredentialsToK8sAsync(
|
await MongoService.AddMongoDatabaseBindingAsync(
|
||||||
TenantId, match.Value.cluster.Id, newBindingDatabaseId);
|
selectedDeployment.Id, newBindingDatabaseId, newBindingSecretName);
|
||||||
|
await MongoService.SyncDatabaseCredentialsToK8sAsync(
|
||||||
|
TenantId, mongoMatch.Value.cluster.Id, newBindingDatabaseId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await CnpgService.AddCnpgDatabaseBindingAsync(
|
||||||
|
selectedDeployment.Id, newBindingDatabaseId, newBindingSecretName);
|
||||||
|
|
||||||
|
(CnpgCluster cluster, CnpgDatabase _)? cnpgMatch = availableCnpgDatabases?
|
||||||
|
.FirstOrDefault(x => x.Database.Id == newBindingDatabaseId);
|
||||||
|
|
||||||
|
if (cnpgMatch.HasValue)
|
||||||
|
{
|
||||||
|
await CnpgService.SyncDatabaseCredentialsToK8sAsync(
|
||||||
|
TenantId, cnpgMatch.Value.cluster.Id, newBindingDatabaseId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showAddBinding = false;
|
showAddBinding = false;
|
||||||
@@ -1854,6 +1897,7 @@
|
|||||||
|
|
||||||
private async Task RemoveDatabaseBinding(Guid bindingId)
|
private async Task RemoveDatabaseBinding(Guid bindingId)
|
||||||
{
|
{
|
||||||
|
confirmRemoveBindingId = null;
|
||||||
bindingError = null;
|
bindingError = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -1909,6 +1953,7 @@
|
|||||||
|
|
||||||
private async Task RemoveCacheBinding(Guid bindingId)
|
private async Task RemoveCacheBinding(Guid bindingId)
|
||||||
{
|
{
|
||||||
|
confirmRemoveCacheBindingId = null;
|
||||||
cacheBindingError = null;
|
cacheBindingError = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -4,284 +4,215 @@
|
|||||||
@inject KubernetesOperationsService K8sOps
|
@inject KubernetesOperationsService K8sOps
|
||||||
|
|
||||||
@* ═══════════════════════════════════════════════════════════════════
|
@* ═══════════════════════════════════════════════════════════════════
|
||||||
AppResourcesPanel — app-level resource overview.
|
AppResourcesPanel — two-level ArgoCD-style navigation.
|
||||||
|
|
||||||
Shows each deployment as an ArgoCD-style Application card. Clicking
|
Level 1 (grid): All deployments as Application cards showing
|
||||||
a card expands its resource tree inline. Multiple cards can be open
|
name, type, environment, health and sync status.
|
||||||
simultaneously.
|
Clicking a card drills into level 2.
|
||||||
|
|
||||||
Card layout mirrors the ArgoCD Application grid:
|
Level 2 (graph): Single deployment — full ResourceTreePanel graph
|
||||||
┌──────────────────────────────────────┐
|
with a back button to return to the grid.
|
||||||
│ [icon] deployment-name │
|
|
||||||
│ [type] env · cluster · ns │
|
This matches ArgoCD's App List → App Detail flow.
|
||||||
│ ● Healthy ○ Synced │
|
|
||||||
└──────────────────────────────────────┘
|
|
||||||
═══════════════════════════════════════════════════════════════════ *@
|
═══════════════════════════════════════════════════════════════════ *@
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.app-argo-grid {
|
.app-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-card {
|
.app-app-card {
|
||||||
border: 1px solid #e9ecef;
|
border: 1px solid #dee2e6;
|
||||||
border-top: 3px solid var(--hc, #adb5bd);
|
border-top: 3px solid var(--hc, #adb5bd);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: white;
|
background: white;
|
||||||
box-shadow: 0 1px 4px rgba(0,0,0,.07);
|
box-shadow: 0 1px 4px rgba(0,0,0,.07);
|
||||||
|
padding: 12px 14px 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: box-shadow .15s, border-color .15s;
|
|
||||||
padding: 12px 14px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
transition: box-shadow .14s, transform .1s;
|
||||||
}
|
}
|
||||||
.app-card:hover { box-shadow: 0 3px 10px rgba(0,0,0,.12); }
|
.app-app-card:hover {
|
||||||
.app-card.expanded {
|
box-shadow: 0 4px 14px rgba(0,0,0,.13);
|
||||||
border-color: #6ea8fe;
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 0 0 2px rgba(13,110,253,.15), 0 2px 8px rgba(0,0,0,.08);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-card-header {
|
.app-card-name {
|
||||||
display: flex;
|
font-weight: 700;
|
||||||
align-items: flex-start;
|
font-size: .9rem;
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-card-icon {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #f0f4ff;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-card-title {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: .88rem;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
max-width: 180px;
|
margin-bottom: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-card-meta {
|
.app-card-meta {
|
||||||
font-size: .73rem;
|
font-size: .71rem;
|
||||||
color: #6c757d;
|
color: #6c757d;
|
||||||
|
margin-bottom: 7px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
.app-card-badges { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||||
.app-card-status {
|
.app-badge {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.argo-badge-health, .argo-badge-sync {
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
font-size: .7rem;
|
font-size: .67rem;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
padding: 2px 7px;
|
padding: 2px 7px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
border: 1px solid;
|
border: 1px solid;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-section {
|
|
||||||
background: #f8f9fb;
|
|
||||||
border: 1px solid #e9ecef;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
.tree-section-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="app-argo-grid">
|
@if (selectedDeployment is null)
|
||||||
@if (deployments is null)
|
|
||||||
{
|
|
||||||
@for (int i = 0; i < 3; i++)
|
|
||||||
{
|
|
||||||
<div class="app-card" style="opacity:.5;pointer-events:none;">
|
|
||||||
<div class="app-card-header">
|
|
||||||
<div class="app-card-icon"><i class="bi bi-rocket-takeoff text-primary"></i></div>
|
|
||||||
<div style="flex:1;">
|
|
||||||
<div class="app-card-title text-muted">Loading…</div>
|
|
||||||
<div class="app-card-meta">—</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (deployments.Count == 0)
|
|
||||||
{
|
|
||||||
<div style="grid-column: 1/-1;" class="text-center py-5">
|
|
||||||
<i class="bi bi-rocket-takeoff text-muted" style="font-size: 2.5rem;"></i>
|
|
||||||
<p class="text-muted small mt-2 mb-0">No deployments yet. Create one in the Deployments tab.</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
@foreach (AppDeployment d in deployments)
|
|
||||||
{
|
|
||||||
string hc = HealthColor(d.HealthStatus);
|
|
||||||
bool open = expandedIds.Contains(d.Id);
|
|
||||||
|
|
||||||
<div class="app-card @(open ? "expanded" : "")"
|
|
||||||
style="--hc: @hc;"
|
|
||||||
@onclick="() => ToggleDeployment(d.Id)">
|
|
||||||
|
|
||||||
<div class="app-card-header">
|
|
||||||
<div class="app-card-icon">
|
|
||||||
<i class="bi @TypeIcon(d.Type) text-primary"></i>
|
|
||||||
</div>
|
|
||||||
<div style="flex: 1; min-width: 0;">
|
|
||||||
<div class="app-card-title" title="@d.Name">@d.Name</div>
|
|
||||||
<div class="app-card-meta">
|
|
||||||
@TypeLabel(d.Type) · @d.Environment?.Name · @d.Namespace
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<i class="bi @(open ? "bi-chevron-up" : "bi-chevron-down") text-muted small"></i>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="app-card-status">
|
|
||||||
@{
|
|
||||||
string hcol = HealthColor(d.HealthStatus);
|
|
||||||
string hlbl = HealthLabel(d.HealthStatus);
|
|
||||||
var (scol, sico, slbl) = SyncInfo(d.SyncStatus);
|
|
||||||
}
|
|
||||||
<span class="argo-badge-health" style="color:@hcol;border-color:@hcol;">
|
|
||||||
● @hlbl
|
|
||||||
</span>
|
|
||||||
<span class="argo-badge-sync" style="color:@scol;border-color:@scol;">
|
|
||||||
<i class="bi @sico"></i> @slbl
|
|
||||||
</span>
|
|
||||||
@if (d.GitRepositoryId is not null && d.GitLastSyncedCommit is not null)
|
|
||||||
{
|
|
||||||
<span class="badge bg-secondary" style="font-size:.65rem;">
|
|
||||||
@d.GitLastSyncedCommit[..7]
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (d.Cluster is not null)
|
|
||||||
{
|
|
||||||
<div style="font-size:.72rem; color:#6c757d;">
|
|
||||||
<i class="bi bi-hdd-network me-1"></i>@d.Cluster.Name
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@* ── Expanded resource trees ─────────────────────────────────────────────── *@
|
|
||||||
@if (deployments is not null)
|
|
||||||
{
|
{
|
||||||
@foreach (AppDeployment d in deployments.Where(d => expandedIds.Contains(d.Id)))
|
@* ── Level 1: deployment card grid ── *@
|
||||||
|
<LoadingPanel Loading="@(deployments is null)" LoadingText="Loading deployments…">
|
||||||
|
@if (deployments is not null && deployments.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="tree-section">
|
<EmptyState Icon="bi-rocket-takeoff" Message="No deployments yet." />
|
||||||
<div class="tree-section-header">
|
}
|
||||||
<div class="d-flex align-items-center gap-2">
|
else if (deployments is not null)
|
||||||
<i class="bi bi-diagram-3 text-primary"></i>
|
{
|
||||||
<strong class="small">@d.Name</strong>
|
<div class="app-grid">
|
||||||
<span class="text-muted small">/ @d.Namespace</span>
|
@foreach (AppDeployment d in deployments)
|
||||||
@if (loadingIds.Contains(d.Id))
|
{
|
||||||
|
string hc = HealthColor(d.HealthStatus);
|
||||||
|
string hlabel = HealthLabel(d.HealthStatus);
|
||||||
|
var (scol, sico, slabel) = SyncInfo(d.SyncStatus);
|
||||||
|
|
||||||
|
<div class="app-app-card" style="--hc:@hc;" @onclick="() => SelectDeployment(d)">
|
||||||
|
<div class="d-flex align-items-start gap-1 mb-1">
|
||||||
|
<div class="app-card-name" title="@d.Name">
|
||||||
|
<i class="bi @TypeIcon(d.Type) text-primary me-1" style="font-size:.78rem;"></i>@d.Name
|
||||||
|
</div>
|
||||||
|
<i class="bi bi-chevron-right text-muted ms-auto flex-shrink-0 mt-1 small"></i>
|
||||||
|
</div>
|
||||||
|
<div class="app-card-meta">
|
||||||
|
@TypeLabel(d.Type) · @d.Environment?.Name · <code style="font-size:.67rem;">@d.Namespace</code>
|
||||||
|
</div>
|
||||||
|
<div class="app-card-badges">
|
||||||
|
<span class="app-badge" style="color:@hc;border-color:@hc;">● @hlabel</span>
|
||||||
|
<span class="app-badge" style="color:@scol;border-color:@scol;">
|
||||||
|
<i class="bi @sico"></i> @slabel
|
||||||
|
</span>
|
||||||
|
@if (d.GitLastSyncedCommit is not null)
|
||||||
|
{
|
||||||
|
<span class="badge bg-secondary" style="font-size:.62rem;">
|
||||||
|
@d.GitLastSyncedCommit[..7]
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (d.Cluster is not null)
|
||||||
{
|
{
|
||||||
<div class="spinner-border spinner-border-sm text-primary" style="width:.9rem;height:.9rem;"></div>
|
<div class="mt-1" style="font-size:.69rem;color:#6c757d;">
|
||||||
|
<i class="bi bi-hdd-network me-1"></i>@d.Cluster.Name
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-sm btn-link text-muted p-0" @onclick="() => expandedIds.Remove(d.Id)" @onclick:stopPropagation>
|
|
||||||
<i class="bi bi-x-lg"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (treeErrors.TryGetValue(d.Id, out string? err))
|
|
||||||
{
|
|
||||||
<div class="alert alert-warning py-1 small mb-0">
|
|
||||||
<i class="bi bi-exclamation-triangle me-1"></i>@err
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<ResourceTreePanel Loading="@loadingIds.Contains(d.Id)"
|
|
||||||
Error="@(treeErrors.TryGetValue(d.Id, out var e2) ? e2 : null)"
|
|
||||||
Resources="@(trees.TryGetValue(d.Id, out var t) ? t : null)"
|
|
||||||
Namespace="@d.Namespace"
|
|
||||||
DeploymentId="@d.Id"
|
|
||||||
AccessRole="@null" />
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@* ── Level 2: single deployment resource graph ── *@
|
||||||
|
<nav class="mb-3">
|
||||||
|
<button class="btn btn-sm btn-link text-decoration-none p-0 text-secondary"
|
||||||
|
@onclick="BackToGrid">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>All deployments
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center gap-3 mb-3 pb-2 border-bottom">
|
||||||
|
<div>
|
||||||
|
<span class="fw-semibold">@selectedDeployment.Name</span>
|
||||||
|
<span class="text-muted small ms-2">
|
||||||
|
@TypeLabel(selectedDeployment.Type) · @selectedDeployment.Environment?.Name · <code style="font-size:.75rem;">@selectedDeployment.Namespace</code>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@{
|
||||||
|
string shc = HealthColor(selectedDeployment.HealthStatus);
|
||||||
|
string shlabel = HealthLabel(selectedDeployment.HealthStatus);
|
||||||
|
var (sscol, ssico, sslabel) = SyncInfo(selectedDeployment.SyncStatus);
|
||||||
|
}
|
||||||
|
<span class="app-badge" style="color:@shc;border-color:@shc;">● @shlabel</span>
|
||||||
|
<span class="app-badge" style="color:@sscol;border-color:@sscol;">
|
||||||
|
<i class="bi @ssico"></i> @sslabel
|
||||||
|
</span>
|
||||||
|
@if (selectedDeployment.Cluster is not null)
|
||||||
|
{
|
||||||
|
<span class="text-muted small ms-auto">
|
||||||
|
<i class="bi bi-hdd-network me-1"></i>@selectedDeployment.Cluster.Name
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResourceTreePanel Loading="@treeLoading"
|
||||||
|
Error="@treeError"
|
||||||
|
Resources="@tree"
|
||||||
|
Namespace="@selectedDeployment.Namespace"
|
||||||
|
DeploymentId="@selectedDeployment.Id"
|
||||||
|
AccessRole="@null" />
|
||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter, EditorRequired] public Guid AppId { get; set; }
|
[Parameter, EditorRequired] public Guid AppId { get; set; }
|
||||||
|
|
||||||
private List<AppDeployment>? deployments;
|
private List<AppDeployment>? deployments;
|
||||||
private HashSet<Guid> expandedIds = [];
|
|
||||||
private HashSet<Guid> loadingIds = [];
|
// Level 2 state
|
||||||
private Dictionary<Guid, List<DeploymentResource>> trees = [];
|
private AppDeployment? selectedDeployment;
|
||||||
private Dictionary<Guid, string> treeErrors = [];
|
private bool treeLoading;
|
||||||
|
private string? treeError;
|
||||||
|
private List<DeploymentResource>? tree;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
deployments = await DeploymentService.GetDeploymentsAsync(AppId);
|
deployments = await DeploymentService.GetDeploymentsAsync(AppId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ToggleDeployment(Guid id)
|
private async Task SelectDeployment(AppDeployment d)
|
||||||
{
|
{
|
||||||
if (expandedIds.Contains(id))
|
selectedDeployment = d;
|
||||||
{
|
treeLoading = true;
|
||||||
expandedIds.Remove(id);
|
treeError = null;
|
||||||
return;
|
tree = null;
|
||||||
}
|
|
||||||
|
|
||||||
expandedIds.Add(id);
|
|
||||||
|
|
||||||
if (trees.ContainsKey(id)) return;
|
|
||||||
|
|
||||||
loadingIds.Add(id);
|
|
||||||
treeErrors.Remove(id);
|
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
|
|
||||||
KubernetesOperationResult<List<DeploymentResource>> result =
|
KubernetesOperationResult<List<DeploymentResource>> result =
|
||||||
await K8sOps.GetLiveResourcesAsync(id);
|
await K8sOps.GetLiveResourcesAsync(d.Id);
|
||||||
|
|
||||||
loadingIds.Remove(id);
|
treeLoading = false;
|
||||||
|
|
||||||
if (result.IsSuccess && result.Data is not null)
|
if (result.IsSuccess && result.Data is not null)
|
||||||
{
|
{
|
||||||
trees[id] = result.Data;
|
tree = result.Data;
|
||||||
(SyncStatus sync, HealthStatus health) = KubernetesOperationsService.ComputeStatusFromResources(result.Data);
|
(SyncStatus sync, HealthStatus health) =
|
||||||
AppDeployment? d = deployments?.FirstOrDefault(x => x.Id == id);
|
KubernetesOperationsService.ComputeStatusFromResources(result.Data);
|
||||||
if (d is not null) { d.SyncStatus = sync; d.HealthStatus = health; }
|
d.SyncStatus = sync;
|
||||||
|
d.HealthStatus = health;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
treeErrors[id] = result.Error ?? "Failed to fetch resources.";
|
treeError = result.Error ?? "Failed to fetch resources.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Badge / label helpers ─────────────────────────────────────────────────
|
private void BackToGrid()
|
||||||
|
{
|
||||||
|
selectedDeployment = null;
|
||||||
|
tree = null;
|
||||||
|
treeError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Visual helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static string HealthColor(HealthStatus h) => h switch
|
private static string HealthColor(HealthStatus h) => h switch
|
||||||
{
|
{
|
||||||
@@ -305,30 +236,28 @@
|
|||||||
|
|
||||||
private static (string color, string icon, string label) SyncInfo(SyncStatus s) => s switch
|
private static (string color, string icon, string label) SyncInfo(SyncStatus s) => s switch
|
||||||
{
|
{
|
||||||
SyncStatus.Synced => ("#198754", "bi-check-circle", "Synced"),
|
SyncStatus.Synced => ("#198754", "bi-check-circle", "Synced"),
|
||||||
SyncStatus.OutOfSync => ("#fd7e14", "bi-arrow-left-right", "OutOfSync"),
|
SyncStatus.OutOfSync => ("#fd7e14", "bi-arrow-left-right", "OutOfSync"),
|
||||||
SyncStatus.Syncing => ("#0d6efd", "bi-arrow-repeat", "Syncing"),
|
SyncStatus.Syncing => ("#0d6efd", "bi-arrow-repeat", "Syncing"),
|
||||||
SyncStatus.Failed => ("#dc3545", "bi-x-circle", "Failed"),
|
SyncStatus.Failed => ("#dc3545", "bi-x-circle", "Failed"),
|
||||||
_ => ("#adb5bd", "bi-question-circle", "Unknown"),
|
_ => ("#adb5bd", "bi-question-circle", "Unknown"),
|
||||||
};
|
};
|
||||||
|
|
||||||
private static string TypeIcon(DeploymentType t) => t switch
|
private static string TypeIcon(DeploymentType t) => t switch
|
||||||
{
|
{
|
||||||
DeploymentType.HelmChart => "bi-box-seam",
|
DeploymentType.HelmChart => "bi-box-seam",
|
||||||
DeploymentType.GitYaml => "bi-git",
|
DeploymentType.GitYaml or DeploymentType.GitHelm or DeploymentType.GitAppOfApps => "bi-git",
|
||||||
DeploymentType.GitHelm => "bi-git",
|
_ => "bi-rocket-takeoff",
|
||||||
DeploymentType.GitAppOfApps => "bi-diagram-2",
|
|
||||||
_ => "bi-rocket-takeoff",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private static string TypeLabel(DeploymentType t) => t switch
|
private static string TypeLabel(DeploymentType t) => t switch
|
||||||
{
|
{
|
||||||
DeploymentType.Manual => "Manual",
|
DeploymentType.Manual => "Manual",
|
||||||
DeploymentType.Yaml => "YAML",
|
DeploymentType.Yaml => "YAML",
|
||||||
DeploymentType.HelmChart => "Helm",
|
DeploymentType.HelmChart => "Helm",
|
||||||
DeploymentType.GitYaml => "Git/YAML",
|
DeploymentType.GitYaml => "Git/YAML",
|
||||||
DeploymentType.GitHelm => "Git/Helm",
|
DeploymentType.GitHelm => "Git/Helm",
|
||||||
DeploymentType.GitAppOfApps => "App of Apps",
|
DeploymentType.GitAppOfApps => "App of Apps",
|
||||||
_ => t.ToString(),
|
_ => t.ToString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,8 @@
|
|||||||
the RedisCluster manifest. Credentials are surfaced from the tenant vault.
|
the RedisCluster manifest. Credentials are surfaced from the tenant vault.
|
||||||
=========================================================================== *@
|
=========================================================================== *@
|
||||||
|
|
||||||
@if (loading)
|
<LoadingPanel Loading="@loading" LoadingText="Loading Redis clusters…">
|
||||||
{
|
@if (!loading)
|
||||||
<div class="text-center py-5">
|
|
||||||
<div class="spinner-border text-primary" role="status"></div>
|
|
||||||
<p class="text-muted mt-2">Loading Redis clusters...</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
@* ── Operator availability badge ── *@
|
@* ── Operator availability badge ── *@
|
||||||
<div class="d-flex gap-2 mb-4 flex-wrap">
|
<div class="d-flex gap-2 mb-4 flex-wrap">
|
||||||
@@ -412,6 +406,7 @@ else
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public Guid TenantId { get; set; }
|
[Parameter] public Guid TenantId { get; set; }
|
||||||
|
|||||||
@@ -1788,6 +1788,16 @@ else if (section == "components")
|
|||||||
editFormFieldValues["admin-username"] = harborConfig.AdminUsername;
|
editFormFieldValues["admin-username"] = harborConfig.AdminUsername;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load Loki storage link ID from the component's Configuration JSON.
|
||||||
|
if (catalogMatch?.Key == "loki")
|
||||||
|
{
|
||||||
|
Guid? lokiStorageLinkId = await LokiService.GetStorageLinkIdForComponentAsync(TenantId, componentId);
|
||||||
|
if (lokiStorageLinkId.HasValue)
|
||||||
|
{
|
||||||
|
editFormFieldValues["storage-link"] = lokiStorageLinkId.Value.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Pre-populate hostname and TLS fields from the existing external route.
|
// Pre-populate hostname and TLS fields from the existing external route.
|
||||||
List<ExternalRoute> routes = await RouteService.GetRoutesAsync(componentId);
|
List<ExternalRoute> routes = await RouteService.GetRoutesAsync(componentId);
|
||||||
ExternalRoute? route = routes.FirstOrDefault();
|
ExternalRoute? route = routes.FirstOrDefault();
|
||||||
@@ -2639,10 +2649,11 @@ else if (section == "components")
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// cnpg:/harbor: fields are not in YAML — loaded from component configs in ToggleComponentDetail.
|
// cnpg:/harbor:/loki: fields are not in YAML — loaded from component configs in ToggleComponentDetail.
|
||||||
|
|
||||||
if (field.YamlPath.StartsWith("cnpg:", StringComparison.Ordinal)
|
if (field.YamlPath.StartsWith("cnpg:", StringComparison.Ordinal)
|
||||||
|| field.YamlPath.StartsWith("harbor:", StringComparison.Ordinal))
|
|| field.YamlPath.StartsWith("harbor:", StringComparison.Ordinal)
|
||||||
|
|| field.YamlPath.StartsWith("loki:", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
@inject TenantService TenantService
|
@inject TenantService TenantService
|
||||||
@inject CustomerAccessService CustomerAccessService
|
@inject CustomerAccessService CustomerAccessService
|
||||||
@inject PrometheusService PrometheusService
|
@inject PrometheusService PrometheusService
|
||||||
|
@inject CustomerGitService CustomerGitService
|
||||||
|
|
||||||
@* ===========================================================================
|
@* ===========================================================================
|
||||||
Three-level drill-down: Customers ▸ Apps ▸ App Detail
|
Three-level drill-down: Customers ▸ Apps ▸ App Detail
|
||||||
@@ -57,17 +58,8 @@ else if (selectedCustomer is not null)
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@if (customerMetricsLoading)
|
<LoadingPanel Loading="@customerMetricsLoading" Error="@customerMetricsError">
|
||||||
{
|
@if (!customerMetricsLoading && customerMetrics is not null)
|
||||||
<div class="text-center py-4"><div class="spinner-border spinner-border-sm text-primary"></div></div>
|
|
||||||
}
|
|
||||||
else if (customerMetricsError is not null)
|
|
||||||
{
|
|
||||||
<div class="alert alert-warning small py-2 mb-0">
|
|
||||||
<i class="bi bi-exclamation-triangle me-1"></i>@customerMetricsError
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else if (customerMetrics is not null)
|
|
||||||
{
|
{
|
||||||
<div class="row g-3 mb-2">
|
<div class="row g-3 mb-2">
|
||||||
<div class="col-sm-6 col-lg-3">
|
<div class="col-sm-6 col-lg-3">
|
||||||
@@ -101,6 +93,7 @@ else if (selectedCustomer is not null)
|
|||||||
<i class="bi bi-clock me-1"></i>Queried @customerMetrics.QueriedAt.ToLocalTime().ToString("HH:mm:ss")
|
<i class="bi bi-clock me-1"></i>Queried @customerMetrics.QueriedAt.ToLocalTime().ToString("HH:mm:ss")
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -125,20 +118,14 @@ else if (selectedCustomer is not null)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* --- App cards --- *@
|
@* --- App cards --- *@
|
||||||
@if (apps is null)
|
<LoadingPanel Loading="@(apps is null)">
|
||||||
|
@if (apps is not null && apps.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="text-center py-3">
|
<EmptyState Icon="bi-app-indicator"
|
||||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
Title="No apps yet"
|
||||||
</div>
|
Message="Create one above to get started." />
|
||||||
}
|
}
|
||||||
else if (apps.Count == 0)
|
else if (apps is not null)
|
||||||
{
|
|
||||||
<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">
|
<div class="row row-cols-1 row-cols-md-2 g-3">
|
||||||
@foreach (Data.App app in apps)
|
@foreach (Data.App app in apps)
|
||||||
@@ -173,6 +160,204 @@ else if (selectedCustomer is not null)
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
|
|
||||||
|
@* --- Git Repo URL Policies --- *@
|
||||||
|
<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-git me-2 text-primary"></i>
|
||||||
|
<strong>Git Repo URL Policies</strong>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">Allowed git repository URL patterns for this customer. Supports <code>*</code> (within a path segment) and <code>**</code> (across segments).</small>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<input type="text" class="form-control form-control-sm font-monospace"
|
||||||
|
placeholder="e.g. https://github.com/acme/* or git@github.com:acme/**"
|
||||||
|
@bind="newRepoPolicyPattern" @bind:event="oninput"
|
||||||
|
@onkeydown="@(async (e) => { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newRepoPolicyPattern)) await AddRepoPolicy(); })" />
|
||||||
|
<button class="btn btn-sm btn-outline-primary" @onclick="AddRepoPolicy"
|
||||||
|
disabled="@string.IsNullOrWhiteSpace(newRepoPolicyPattern)">
|
||||||
|
<i class="bi bi-plus me-1"></i>Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrEmpty(repoPolicyError))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger py-1 small mb-2">@repoPolicyError</div>
|
||||||
|
}
|
||||||
|
<LoadingPanel Loading="@(gitRepoPolicies is null)">
|
||||||
|
@if (gitRepoPolicies is not null && gitRepoPolicies.Count == 0)
|
||||||
|
{
|
||||||
|
<EmptyState Icon="bi-slash-circle"
|
||||||
|
Message="No URL patterns defined. All repo URLs are implicitly denied for this customer." />
|
||||||
|
}
|
||||||
|
else if (gitRepoPolicies is not null)
|
||||||
|
{
|
||||||
|
@foreach (CustomerGitRepoPolicy policy in gitRepoPolicies)
|
||||||
|
{
|
||||||
|
<div class="d-flex align-items-center justify-content-between py-1 border-bottom">
|
||||||
|
<code class="small">@policy.UrlPattern</code>
|
||||||
|
@if (confirmDeletePolicyId == policy.Id)
|
||||||
|
{
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
<button class="btn btn-sm btn-danger" @onclick="() => DeleteRepoPolicy(policy.Id)">Remove</button>
|
||||||
|
<button class="btn btn-sm btn-link p-0" @onclick="() => confirmDeletePolicyId = null">Cancel</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-danger" title="Remove pattern"
|
||||||
|
@onclick="() => confirmDeletePolicyId = policy.Id">
|
||||||
|
<i class="bi bi-x"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</LoadingPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* --- Git Credentials --- *@
|
||||||
|
<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-key me-2 text-primary"></i>
|
||||||
|
<strong>Git Credentials</strong>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">Reusable credential sets for this customer's git repositories.</small>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
@* --- Add credential form --- *@
|
||||||
|
<div class="row g-2 align-items-end mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small mb-1">Name</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" placeholder="e.g. GitHub PAT"
|
||||||
|
@bind="newCredName" @bind:event="oninput" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small mb-1">Auth type</label>
|
||||||
|
<select class="form-select form-select-sm" @bind="newCredAuthType">
|
||||||
|
<option value="@GitAuthType.None">None (public)</option>
|
||||||
|
<option value="@GitAuthType.HttpsPat">HTTPS – PAT</option>
|
||||||
|
<option value="@GitAuthType.HttpsPassword">HTTPS – Password</option>
|
||||||
|
<option value="@GitAuthType.SshKey">SSH Key</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
@if (newCredAuthType == GitAuthType.HttpsPassword)
|
||||||
|
{
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small mb-1">Username</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" placeholder="e.g. myuser"
|
||||||
|
@bind="newCredUsername" @bind:event="oninput" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="col-auto">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" @onclick="AddCredential"
|
||||||
|
disabled="@string.IsNullOrWhiteSpace(newCredName)">
|
||||||
|
<i class="bi bi-plus me-1"></i>Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrEmpty(credentialError))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger py-1 small mb-2">@credentialError</div>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrEmpty(credentialSuccess))
|
||||||
|
{
|
||||||
|
<div class="alert alert-success py-1 small mb-2">@credentialSuccess</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<LoadingPanel Loading="@(gitCredentials is null)">
|
||||||
|
@if (gitCredentials is not null && gitCredentials.Count == 0)
|
||||||
|
{
|
||||||
|
<EmptyState Icon="bi-key" Message="No credentials configured yet." />
|
||||||
|
}
|
||||||
|
else if (gitCredentials is not null)
|
||||||
|
{
|
||||||
|
@foreach (CustomerGitCredential cred in gitCredentials)
|
||||||
|
{
|
||||||
|
<div class="border rounded p-3 mb-2">
|
||||||
|
<div class="d-flex align-items-start justify-content-between">
|
||||||
|
<div>
|
||||||
|
<span class="fw-semibold"><i class="bi bi-key me-1 text-primary"></i>@cred.Name</span>
|
||||||
|
<span class="badge bg-secondary ms-2">@cred.AuthType</span>
|
||||||
|
@if (cred.AuthType == GitAuthType.HttpsPassword && cred.Username is not null)
|
||||||
|
{
|
||||||
|
<span class="text-muted small ms-2">(@cred.Username)</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (confirmDeleteCredentialId == cred.Id)
|
||||||
|
{
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
<button class="btn btn-sm btn-danger" @onclick="() => DeleteCredential(cred.Id)">Delete</button>
|
||||||
|
<button class="btn btn-sm btn-link p-0" @onclick="() => confirmDeleteCredentialId = null">Cancel</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-danger" title="Delete credential"
|
||||||
|
@onclick="() => confirmDeleteCredentialId = cred.Id">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* --- Secret value form --- *@
|
||||||
|
@if (cred.AuthType != GitAuthType.None)
|
||||||
|
{
|
||||||
|
<div class="mt-2">
|
||||||
|
@if (cred.AuthType == GitAuthType.HttpsPat)
|
||||||
|
{
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<span class="input-group-text">PAT</span>
|
||||||
|
<input type="password" class="form-control form-control-sm"
|
||||||
|
placeholder="Personal Access Token"
|
||||||
|
@bind="credSecretValues[cred.Id]" @bind:event="oninput" />
|
||||||
|
<button class="btn btn-sm btn-outline-secondary"
|
||||||
|
@onclick="() => SaveCredentialSecret(cred)">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (cred.AuthType == GitAuthType.HttpsPassword)
|
||||||
|
{
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<span class="input-group-text">Password</span>
|
||||||
|
<input type="password" class="form-control form-control-sm"
|
||||||
|
placeholder="Password"
|
||||||
|
@bind="credSecretValues[cred.Id]" @bind:event="oninput" />
|
||||||
|
<button class="btn btn-sm btn-outline-secondary"
|
||||||
|
@onclick="() => SaveCredentialSecret(cred)">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (cred.AuthType == GitAuthType.SshKey)
|
||||||
|
{
|
||||||
|
<textarea class="form-control form-control-sm font-monospace mb-1"
|
||||||
|
rows="3" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||||
|
@bind="credSecretValues[cred.Id]" @bind:event="oninput"></textarea>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary"
|
||||||
|
@onclick="() => SaveCredentialSecret(cred)">
|
||||||
|
Save SSH Key
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (credSaveMessages.TryGetValue(cred.Id, out string? msg) && msg is not null)
|
||||||
|
{
|
||||||
|
<div class="small mt-1 @(credSaveOk.GetValueOrDefault(cred.Id) ? "text-success" : "text-danger")">
|
||||||
|
<i class="bi @(credSaveOk.GetValueOrDefault(cred.Id) ? "bi-check-circle" : "bi-x-circle") me-1"></i>@msg
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</LoadingPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@* --- Portal Access Management --- *@
|
@* --- Portal Access Management --- *@
|
||||||
<div class="card shadow-sm mt-4">
|
<div class="card shadow-sm mt-4">
|
||||||
@@ -210,17 +395,13 @@ else if (selectedCustomer is not null)
|
|||||||
}
|
}
|
||||||
|
|
||||||
@* --- Current access list --- *@
|
@* --- Current access list --- *@
|
||||||
@if (customerAccesses is null)
|
<LoadingPanel Loading="@(customerAccesses is null)">
|
||||||
|
@if (customerAccesses is not null && customerAccesses.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="text-center py-2">
|
<EmptyState Icon="bi-person-x"
|
||||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
Message="No users have portal access to this customer yet." />
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
else if (customerAccesses.Count == 0)
|
else if (customerAccesses is not null)
|
||||||
{
|
|
||||||
<p class="text-muted small mb-0">No users have portal access to this customer yet.</p>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
@foreach (CustomerAccess access in customerAccesses)
|
@foreach (CustomerAccess access in customerAccesses)
|
||||||
{
|
{
|
||||||
@@ -230,13 +411,24 @@ else if (selectedCustomer is not null)
|
|||||||
<span class="fw-medium">@access.User.Email</span>
|
<span class="fw-medium">@access.User.Email</span>
|
||||||
<span class="badge @GetRoleBadgeClass(access.Role) ms-1">@access.Role</span>
|
<span class="badge @GetRoleBadgeClass(access.Role) ms-1">@access.Role</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-sm btn-outline-danger" title="Revoke access"
|
@if (confirmRevokeUserId == access.UserId)
|
||||||
@onclick="() => RevokeAccess(access.UserId)">
|
{
|
||||||
<i class="bi bi-x"></i>
|
<div class="d-flex gap-1">
|
||||||
</button>
|
<button class="btn btn-sm btn-danger" @onclick="() => RevokeAccess(access.UserId)">Revoke</button>
|
||||||
|
<button class="btn btn-sm btn-link p-0" @onclick="() => confirmRevokeUserId = null">Cancel</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-danger" title="Revoke access"
|
||||||
|
@onclick="() => confirmRevokeUserId = access.UserId">
|
||||||
|
<i class="bi bi-x"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -272,21 +464,14 @@ else
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (customers is null)
|
<LoadingPanel Loading="@(customers is null)">
|
||||||
|
@if (customers is not null && customers.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="text-center py-4">
|
<EmptyState Icon="bi-people"
|
||||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
Title="No customers yet"
|
||||||
<span class="ms-2 text-muted">Loading customers...</span>
|
Message="Create one above to get started." />
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
else if (customers.Count == 0)
|
else if (customers is not null)
|
||||||
{
|
|
||||||
<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">
|
<div class="list-group shadow-sm">
|
||||||
@foreach (Customer customer in customers)
|
@foreach (Customer customer in customers)
|
||||||
@@ -326,6 +511,7 @@ else
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
@@ -360,6 +546,27 @@ else
|
|||||||
private string? accessError;
|
private string? accessError;
|
||||||
private string? accessSuccess;
|
private string? accessSuccess;
|
||||||
|
|
||||||
|
// --- Git repo policies ---
|
||||||
|
private List<CustomerGitRepoPolicy>? gitRepoPolicies;
|
||||||
|
private string newRepoPolicyPattern = "";
|
||||||
|
private string? repoPolicyError;
|
||||||
|
private Guid? confirmDeletePolicyId;
|
||||||
|
|
||||||
|
// --- Git credentials ---
|
||||||
|
private List<CustomerGitCredential>? gitCredentials;
|
||||||
|
private string newCredName = "";
|
||||||
|
private GitAuthType newCredAuthType = GitAuthType.HttpsPat;
|
||||||
|
private string newCredUsername = "";
|
||||||
|
private string? credentialError;
|
||||||
|
private string? credentialSuccess;
|
||||||
|
private Dictionary<Guid, string> credSecretValues = new();
|
||||||
|
private Dictionary<Guid, string?> credSaveMessages = new();
|
||||||
|
private Dictionary<Guid, bool> credSaveOk = new();
|
||||||
|
private Guid? confirmDeleteCredentialId;
|
||||||
|
|
||||||
|
// --- Portal access ---
|
||||||
|
private string? confirmRevokeUserId;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
await LoadCustomers();
|
await LoadCustomers();
|
||||||
@@ -414,8 +621,20 @@ else
|
|||||||
accessSuccess = null;
|
accessSuccess = null;
|
||||||
customerView = "apps";
|
customerView = "apps";
|
||||||
customerMetrics = null;
|
customerMetrics = null;
|
||||||
|
newRepoPolicyPattern = "";
|
||||||
|
repoPolicyError = null;
|
||||||
|
newCredName = "";
|
||||||
|
newCredAuthType = GitAuthType.HttpsPat;
|
||||||
|
newCredUsername = "";
|
||||||
|
credentialError = null;
|
||||||
|
credentialSuccess = null;
|
||||||
|
credSecretValues = new();
|
||||||
|
credSaveMessages = new();
|
||||||
|
credSaveOk = new();
|
||||||
await LoadApps();
|
await LoadApps();
|
||||||
await LoadAccesses();
|
await LoadAccesses();
|
||||||
|
await LoadGitRepoPolicies();
|
||||||
|
await LoadGitCredentials();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OpenCustomerMetrics()
|
private async Task OpenCustomerMetrics()
|
||||||
@@ -521,10 +740,120 @@ else
|
|||||||
|
|
||||||
private async Task RevokeAccess(string userId)
|
private async Task RevokeAccess(string userId)
|
||||||
{
|
{
|
||||||
|
confirmRevokeUserId = null;
|
||||||
await CustomerAccessService.RevokeAccessAsync(userId, selectedCustomer!.Id);
|
await CustomerAccessService.RevokeAccessAsync(userId, selectedCustomer!.Id);
|
||||||
await LoadAccesses();
|
await LoadAccesses();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────── Git Repo Policies ────────────────
|
||||||
|
|
||||||
|
private async Task LoadGitRepoPolicies()
|
||||||
|
{
|
||||||
|
gitRepoPolicies = await CustomerGitService.GetRepoPoliciesAsync(selectedCustomer!.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddRepoPolicy()
|
||||||
|
{
|
||||||
|
repoPolicyError = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await CustomerGitService.AddRepoPolicyAsync(selectedCustomer!.Id, newRepoPolicyPattern.Trim());
|
||||||
|
newRepoPolicyPattern = "";
|
||||||
|
await LoadGitRepoPolicies();
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
repoPolicyError = "That URL pattern already exists for this customer.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteRepoPolicy(Guid policyId)
|
||||||
|
{
|
||||||
|
confirmDeletePolicyId = null;
|
||||||
|
await CustomerGitService.DeleteRepoPolicyAsync(selectedCustomer!.Id, policyId);
|
||||||
|
await LoadGitRepoPolicies();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────── Git Credentials ────────────────
|
||||||
|
|
||||||
|
private async Task LoadGitCredentials()
|
||||||
|
{
|
||||||
|
gitCredentials = await CustomerGitService.GetCredentialsAsync(selectedCustomer!.Id);
|
||||||
|
foreach (CustomerGitCredential cred in gitCredentials)
|
||||||
|
credSecretValues.TryAdd(cred.Id, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddCredential()
|
||||||
|
{
|
||||||
|
credentialError = null;
|
||||||
|
credentialSuccess = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await CustomerGitService.CreateCredentialAsync(
|
||||||
|
selectedCustomer!.Id, TenantId,
|
||||||
|
newCredName.Trim(), newCredAuthType,
|
||||||
|
newCredAuthType == GitAuthType.HttpsPassword ? newCredUsername : null);
|
||||||
|
newCredName = "";
|
||||||
|
newCredAuthType = GitAuthType.HttpsPat;
|
||||||
|
newCredUsername = "";
|
||||||
|
await LoadGitCredentials();
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
credentialError = "A credential with that name already exists for this customer.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteCredential(Guid credentialId)
|
||||||
|
{
|
||||||
|
confirmDeleteCredentialId = null;
|
||||||
|
credentialError = null;
|
||||||
|
await CustomerGitService.DeleteCredentialAsync(selectedCustomer!.Id, credentialId);
|
||||||
|
credSecretValues.Remove(credentialId);
|
||||||
|
credSaveMessages.Remove(credentialId);
|
||||||
|
credSaveOk.Remove(credentialId);
|
||||||
|
await LoadGitCredentials();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveCredentialSecret(CustomerGitCredential cred)
|
||||||
|
{
|
||||||
|
credSaveMessages[cred.Id] = null;
|
||||||
|
|
||||||
|
if (!credSecretValues.TryGetValue(cred.Id, out string? value) || string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
credSaveMessages[cred.Id] = "Value cannot be empty.";
|
||||||
|
credSaveOk[cred.Id] = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
switch (cred.AuthType)
|
||||||
|
{
|
||||||
|
case GitAuthType.HttpsPat:
|
||||||
|
await CustomerGitService.SetPatAsync(TenantId, cred.Id, value);
|
||||||
|
break;
|
||||||
|
case GitAuthType.HttpsPassword:
|
||||||
|
await CustomerGitService.SetPasswordAsync(TenantId, cred.Id, value);
|
||||||
|
break;
|
||||||
|
case GitAuthType.SshKey:
|
||||||
|
await CustomerGitService.SetSshKeyAsync(TenantId, cred.Id, value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
credSecretValues[cred.Id] = "";
|
||||||
|
credSaveMessages[cred.Id] = "Saved.";
|
||||||
|
credSaveOk[cred.Id] = true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
credSaveMessages[cred.Id] = ex.Message;
|
||||||
|
credSaveOk[cred.Id] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static string GetRoleBadgeClass(CustomerAccessRole role) => role switch
|
private static string GetRoleBadgeClass(CustomerAccessRole role) => role switch
|
||||||
{
|
{
|
||||||
CustomerAccessRole.Admin => "bg-danger",
|
CustomerAccessRole.Admin => "bg-danger",
|
||||||
|
|||||||
@@ -13,14 +13,8 @@
|
|||||||
to be installed on at least one cluster.
|
to be installed on at least one cluster.
|
||||||
=========================================================================== *@
|
=========================================================================== *@
|
||||||
|
|
||||||
@if (loading)
|
<LoadingPanel Loading="@loading" LoadingText="Discovering database clusters…">
|
||||||
{
|
@if (!loading)
|
||||||
<div class="text-center py-5">
|
|
||||||
<div class="spinner-border text-primary" role="status"></div>
|
|
||||||
<p class="text-muted mt-2">Discovering database clusters...</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
@* ── Operator availability badges ── *@
|
@* ── Operator availability badges ── *@
|
||||||
<div class="d-flex gap-2 mb-4">
|
<div class="d-flex gap-2 mb-4">
|
||||||
@@ -2190,6 +2184,19 @@ else
|
|||||||
@onclick="() => ShowMongoResize(mc)">
|
@onclick="() => ShowMongoResize(mc)">
|
||||||
<i class="bi bi-sliders"></i>
|
<i class="bi bi-sliders"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary border-0"
|
||||||
|
title="Fetch the MongoDB admin password from the Kubernetes secret and store it in vault"
|
||||||
|
disabled="@(mongoAdminFetchingId == mc.Id)"
|
||||||
|
@onclick="() => FetchMongoAdminPassword(mc)">
|
||||||
|
@if (mongoAdminFetchingId == mc.Id)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm"></span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<i class="bi bi-key"></i>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
<button class="btn btn-sm btn-outline-danger border-0" title="Delete Cluster"
|
<button class="btn btn-sm btn-outline-danger border-0" title="Delete Cluster"
|
||||||
@onclick="() => InitiateDeleteMongoCluster(mc)" disabled="@(mongoDeletingId == mc.Id)">
|
@onclick="() => InitiateDeleteMongoCluster(mc)" disabled="@(mongoDeletingId == mc.Id)">
|
||||||
@@ -2573,11 +2580,17 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
@* ── Databases ── *@
|
@* ── Databases ── *@
|
||||||
|
@if (!string.IsNullOrEmpty(mongoClusterDetail.SyncError))
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning small py-1 mb-2">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>Database sync failed: @mongoClusterDetail.SyncError
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<h6 class="small text-muted mb-2 d-flex align-items-center">
|
<h6 class="small text-muted mb-2 d-flex align-items-center">
|
||||||
<i class="bi bi-table me-1"></i>Databases
|
<i class="bi bi-table me-1"></i>Databases
|
||||||
<span class="badge bg-success rounded-pill ms-2">@mc.Databases.Count</span>
|
<span class="badge bg-success rounded-pill ms-2">@mongoClusterDetail.Cluster.Databases.Count</span>
|
||||||
</h6>
|
</h6>
|
||||||
@if (mc.Databases.Count > 0)
|
@if (mongoClusterDetail.Cluster.Databases.Count > 0)
|
||||||
{
|
{
|
||||||
<div class="table-responsive mb-3">
|
<div class="table-responsive mb-3">
|
||||||
<table class="table table-sm small mb-0">
|
<table class="table table-sm small mb-0">
|
||||||
@@ -2590,7 +2603,7 @@ else
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (MongoDatabase mdb in mc.Databases)
|
@foreach (MongoDatabase mdb in mongoClusterDetail.Cluster.Databases)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td class="font-monospace">@mdb.Name</td>
|
<td class="font-monospace">@mdb.Name</td>
|
||||||
@@ -2656,17 +2669,27 @@ else
|
|||||||
<tr>
|
<tr>
|
||||||
<td colspan="4" class="bg-light p-0">
|
<td colspan="4" class="bg-light p-0">
|
||||||
<div class="p-2">
|
<div class="p-2">
|
||||||
<table class="table table-sm table-borderless mb-1" style="font-size:0.75rem;">
|
@if (mongoDbCredentials.Count == 0)
|
||||||
<tbody>
|
{
|
||||||
@foreach (KeyValuePair<string, string> cred in mongoDbCredentials)
|
<div class="text-muted small">No credentials found in vault for this database.</div>
|
||||||
{
|
}
|
||||||
<tr>
|
else
|
||||||
<td class="text-muted fw-bold" style="width:140px;">@cred.Key</td>
|
{
|
||||||
<td><code class="user-select-all">@cred.Value</code></td>
|
<table class="table table-sm table-borderless mb-1" style="font-size:0.75rem;">
|
||||||
</tr>
|
<tbody>
|
||||||
}
|
@foreach (KeyValuePair<string, string> cred in mongoDbCredentials)
|
||||||
</tbody>
|
{
|
||||||
</table>
|
<tr>
|
||||||
|
<td class="text-muted fw-bold" style="width:140px;">@cred.Key</td>
|
||||||
|
<td><code class="user-select-all">@cred.Value</code></td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="text-muted" style="font-size:0.7rem;">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>K8s Secret: <code>@($"{mc.Name}-{mdb.Name}-credentials")</code> in namespace <code>@mc.Namespace</code>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -3253,6 +3276,7 @@ else
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public Guid TenantId { get; set; }
|
[Parameter] public Guid TenantId { get; set; }
|
||||||
@@ -3451,6 +3475,7 @@ else
|
|||||||
private string? mongoScheduleSuccess;
|
private string? mongoScheduleSuccess;
|
||||||
private Guid? mongoCleaningUpBackupsId;
|
private Guid? mongoCleaningUpBackupsId;
|
||||||
private Dictionary<Guid, string> mongoClusterMessages = [];
|
private Dictionary<Guid, string> mongoClusterMessages = [];
|
||||||
|
private Guid? mongoAdminFetchingId;
|
||||||
|
|
||||||
// Mongo cluster detail panel
|
// Mongo cluster detail panel
|
||||||
private Guid? expandedMongoClusterId;
|
private Guid? expandedMongoClusterId;
|
||||||
@@ -4320,6 +4345,28 @@ else
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task FetchMongoAdminPassword(MongoCluster cluster)
|
||||||
|
{
|
||||||
|
mongoAdminFetchingId = cluster.Id;
|
||||||
|
mongoClusterMessages.Remove(cluster.Id);
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await MongoService.FetchAdminPasswordAsync(TenantId, cluster.Id);
|
||||||
|
mongoClusterMessages[cluster.Id] = "Admin password fetched from Kubernetes and stored in vault.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
mongoClusterMessages[cluster.Id] = $"Error: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
mongoAdminFetchingId = null;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task FetchClusterSuperuserPassword(CnpgCluster cluster)
|
private async Task FetchClusterSuperuserPassword(CnpgCluster cluster)
|
||||||
{
|
{
|
||||||
clusterSuperuserFetchingId = cluster.Id;
|
clusterSuperuserFetchingId = cluster.Id;
|
||||||
@@ -5238,6 +5285,9 @@ else
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
mongoClusterDetail = await MongoService.GetClusterDetailAsync(TenantId, cluster.Id);
|
mongoClusterDetail = await MongoService.GetClusterDetailAsync(TenantId, cluster.Id);
|
||||||
|
// Sync the in-memory cluster object so the header badge shows the live count.
|
||||||
|
if (mongoClusterDetail is not null)
|
||||||
|
cluster.Databases = mongoClusterDetail.Cluster.Databases;
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -125,20 +125,14 @@ else if (selectedEnv is not null)
|
|||||||
}
|
}
|
||||||
|
|
||||||
@* --- Cluster Cards --- *@
|
@* --- Cluster Cards --- *@
|
||||||
@if (envClusters is null)
|
<LoadingPanel Loading="@(envClusters is null)">
|
||||||
|
@if (envClusters is not null && envClusters.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="text-center py-3">
|
<EmptyState Icon="bi-hdd-rack"
|
||||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
Title="No clusters yet"
|
||||||
</div>
|
Message="Register a cluster above to start deploying." />
|
||||||
}
|
}
|
||||||
else if (envClusters.Count == 0)
|
else if (envClusters is not null)
|
||||||
{
|
|
||||||
<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">
|
<div class="row row-cols-1 row-cols-md-2 g-3">
|
||||||
@foreach (KubernetesCluster cluster in envClusters)
|
@foreach (KubernetesCluster cluster in envClusters)
|
||||||
@@ -168,6 +162,7 @@ else if (selectedEnv is not null)
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -201,21 +196,14 @@ else
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (environments is null)
|
<LoadingPanel Loading="@(environments is null)">
|
||||||
|
@if (environments is not null && environments.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="text-center py-4">
|
<EmptyState Icon="bi-layers"
|
||||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
Title="No environments yet"
|
||||||
<span class="ms-2 text-muted">Loading environments...</span>
|
Message="Create one above to get started." />
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
else if (environments.Count == 0)
|
else if (environments is not null)
|
||||||
{
|
|
||||||
<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">
|
<div class="list-group shadow-sm">
|
||||||
@foreach (Data.Environment env in environments)
|
@foreach (Data.Environment env in environments)
|
||||||
@@ -255,6 +243,7 @@ else
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
|||||||
@@ -77,21 +77,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@* ── Repo list ── *@
|
@* ── Repo list ── *@
|
||||||
@if (repos is null)
|
<LoadingPanel Loading="@(repos is null)">
|
||||||
|
@if (repos is not null && repos.Count == 0 && !showAddForm)
|
||||||
{
|
{
|
||||||
<div class="text-center py-3">
|
<EmptyState Icon="bi-git"
|
||||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
Message="No repositories registered. Add one to enable Git-sourced deployments." />
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
else if (repos.Count == 0 && !showAddForm)
|
else if (repos is not null && (repos.Count > 0 || showAddForm))
|
||||||
{
|
|
||||||
<div class="text-center py-4">
|
|
||||||
<i class="bi bi-git text-muted" style="font-size: 2rem;"></i>
|
|
||||||
<p class="text-muted small mt-2 mb-0">No repositories registered. Add one to enable Git-sourced deployments.</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
|
<div class="table-responsive">
|
||||||
<table class="table table-sm mb-0">
|
<table class="table table-sm mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -248,7 +242,9 @@
|
|||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -31,21 +31,14 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (groups is null)
|
<LoadingPanel Loading="@(groups is null)" LoadingText="Loading groups…">
|
||||||
|
@if (groups is not null && groups.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="text-center py-4">
|
<EmptyState Icon="bi-diagram-3"
|
||||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
Title="No groups yet"
|
||||||
<span class="ms-2 text-muted">Loading groups...</span>
|
Message="Create one above to get started." />
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
else if (groups.Count == 0)
|
else if (groups is not null)
|
||||||
{
|
|
||||||
<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">
|
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
|
||||||
@foreach (Group group in groups)
|
@foreach (Group group in groups)
|
||||||
@@ -90,6 +83,7 @@ else
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public Guid TenantId { get; set; }
|
[Parameter] public Guid TenantId { get; set; }
|
||||||
|
|||||||
@@ -5,14 +5,8 @@
|
|||||||
@inject ComponentLifecycleService LifecycleService
|
@inject ComponentLifecycleService LifecycleService
|
||||||
@inject RegisteredPostgresService RegisteredPostgresService
|
@inject RegisteredPostgresService RegisteredPostgresService
|
||||||
|
|
||||||
@if (loading)
|
<LoadingPanel Loading="@loading" LoadingText="Scanning for Keycloak instances…">
|
||||||
{
|
@if (selectedRealm is not null && selectedKeycloak is not null)
|
||||||
<div class="text-center py-5">
|
|
||||||
<div class="spinner-border text-primary" role="status"></div>
|
|
||||||
<p class="text-muted mt-2">Scanning for Keycloak instances...</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else if (selectedRealm is not null && selectedKeycloak is not null)
|
|
||||||
{
|
{
|
||||||
@* ── Level 3: Realm detail ── *@
|
@* ── Level 3: Realm detail ── *@
|
||||||
<nav class="mb-3">
|
<nav class="mb-3">
|
||||||
@@ -1340,6 +1334,7 @@ else
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public Guid TenantId { get; set; }
|
[Parameter] public Guid TenantId { get; set; }
|
||||||
|
|||||||
@@ -93,18 +93,14 @@
|
|||||||
<span class="ms-auto text-muted small">@incidents.Count incident@(incidents.Count == 1 ? "" : "s")</span>
|
<span class="ms-auto text-muted small">@incidents.Count incident@(incidents.Count == 1 ? "" : "s")</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (isLoading)
|
<LoadingPanel Loading="@isLoading" LoadingText="Loading incidents…">
|
||||||
|
@if (!isLoading && incidents.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="text-center py-4 text-muted"><span class="bi bi-hourglass-split"></span> Loading incidents…</div>
|
<EmptyState Icon="bi-check-circle"
|
||||||
|
Title="No incidents found"
|
||||||
|
Message="No incidents match the selected filters." />
|
||||||
}
|
}
|
||||||
else if (incidents.Count == 0)
|
else if (!isLoading)
|
||||||
{
|
|
||||||
<div class="text-center py-5 text-muted">
|
|
||||||
<span class="bi bi-check-circle display-4 d-block mb-2"></span>
|
|
||||||
No incidents found for the selected filters.
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover align-middle">
|
<table class="table table-hover align-middle">
|
||||||
@@ -197,6 +193,7 @@ else
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public required Guid TenantId { get; set; }
|
[Parameter] public required Guid TenantId { get; set; }
|
||||||
|
|||||||
@@ -29,10 +29,9 @@
|
|||||||
|
|
||||||
@if (windows.Count == 0)
|
@if (windows.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="text-center py-4 text-muted">
|
<EmptyState Icon="bi-calendar-x"
|
||||||
<span class="bi bi-calendar-x display-4 d-block mb-2"></span>
|
Title="No maintenance windows"
|
||||||
No maintenance windows scheduled.
|
Message="No windows scheduled." />
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,14 +11,8 @@
|
|||||||
App linkage syncs AMQP connection details into app namespace K8s Secrets.
|
App linkage syncs AMQP connection details into app namespace K8s Secrets.
|
||||||
=========================================================================== *@
|
=========================================================================== *@
|
||||||
|
|
||||||
@if (loading)
|
<LoadingPanel Loading="@loading" LoadingText="Loading RabbitMQ clusters…">
|
||||||
{
|
@if (!loading)
|
||||||
<div class="text-center py-5">
|
|
||||||
<div class="spinner-border text-primary" role="status"></div>
|
|
||||||
<p class="text-muted mt-2">Loading RabbitMQ clusters...</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
@* ── Operator availability badges ── *@
|
@* ── Operator availability badges ── *@
|
||||||
<div class="d-flex gap-2 mb-4 flex-wrap">
|
<div class="d-flex gap-2 mb-4 flex-wrap">
|
||||||
@@ -908,6 +902,7 @@ else
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public Guid TenantId { get; set; }
|
[Parameter] public Guid TenantId { get; set; }
|
||||||
|
|||||||
@@ -10,14 +10,8 @@
|
|||||||
Helm component flow — no separate ops process needed.
|
Helm component flow — no separate ops process needed.
|
||||||
============================================================================ *@
|
============================================================================ *@
|
||||||
|
|
||||||
@if (loading)
|
<LoadingPanel Loading="@loading" LoadingText="Loading VPN tunnels…">
|
||||||
{
|
@if (!loading)
|
||||||
<div class="text-center py-5">
|
|
||||||
<div class="spinner-border text-primary" role="status"></div>
|
|
||||||
<p class="text-muted mt-2">Loading VPN tunnels...</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
@* ── Header ── *@
|
@* ── Header ── *@
|
||||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||||
@@ -753,6 +747,7 @@ else
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public Guid TenantId { get; set; }
|
[Parameter] public Guid TenantId { get; set; }
|
||||||
|
|||||||
@@ -13,10 +13,9 @@
|
|||||||
|
|
||||||
@if (channels.Count == 0)
|
@if (channels.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="text-center py-5 text-muted">
|
<EmptyState Icon="bi-send"
|
||||||
<span class="bi bi-send display-4 d-block mb-2"></span>
|
Title="No notification channels"
|
||||||
No notification channels configured. Add a channel to get alerted via Slack, Teams, email, or webhook.
|
Message="Add a channel to get alerted via Slack, Teams, email, or webhook." />
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,14 +3,8 @@
|
|||||||
@inject HarborService HarborService
|
@inject HarborService HarborService
|
||||||
@inject TenantService TenantService
|
@inject TenantService TenantService
|
||||||
|
|
||||||
@if (loading)
|
<LoadingPanel Loading="@loading" LoadingText="Scanning for Harbor instances…">
|
||||||
{
|
@if (selectedProject is not null && selectedHarbor is not null)
|
||||||
<div class="text-center py-5">
|
|
||||||
<div class="spinner-border text-primary" role="status"></div>
|
|
||||||
<p class="text-muted mt-2">Scanning for Harbor instances...</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else if (selectedProject is not null && selectedHarbor is not null)
|
|
||||||
{
|
{
|
||||||
@* ── Level 3: Project repositories ── *@
|
@* ── Level 3: Project repositories ── *@
|
||||||
<nav class="mb-3">
|
<nav class="mb-3">
|
||||||
@@ -646,6 +640,7 @@ else
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public Guid TenantId { get; set; }
|
[Parameter] public Guid TenantId { get; set; }
|
||||||
|
|||||||
@@ -17,10 +17,9 @@
|
|||||||
|
|
||||||
@if (targets.Count == 0)
|
@if (targets.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="text-center py-4 text-muted">
|
<EmptyState Icon="bi-shield"
|
||||||
<span class="bi bi-shield display-4 d-block mb-2"></span>
|
Title="No SLA targets"
|
||||||
No SLA targets configured. Add one to track uptime compliance.
|
Message="Add one to track uptime compliance." />
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,14 +10,8 @@
|
|||||||
External providers are managed as vault-backed links.
|
External providers are managed as vault-backed links.
|
||||||
=========================================================================== *@
|
=========================================================================== *@
|
||||||
|
|
||||||
@if (loading)
|
<LoadingPanel Loading="@loading" LoadingText="Discovering storage resources…">
|
||||||
{
|
@if (!loading)
|
||||||
<div class="text-center py-5">
|
|
||||||
<div class="spinner-border text-primary" role="status"></div>
|
|
||||||
<p class="text-muted mt-2">Discovering storage resources...</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
@* ── MinIO Section ── *@
|
@* ── MinIO Section ── *@
|
||||||
<div class="mb-5">
|
<div class="mb-5">
|
||||||
@@ -834,6 +828,7 @@ else
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public Guid TenantId { get; set; }
|
[Parameter] public Guid TenantId { get; set; }
|
||||||
|
|||||||
@@ -34,94 +34,131 @@ else
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="nav nav-pills mb-4 gap-1">
|
@* ── Category (top-level) nav ── *@
|
||||||
|
<ul class="nav nav-pills mb-2 gap-1 tenant-category-nav">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link @(activeTab == "environments" ? "active" : "")" @onclick='() => activeTab = "environments"'>
|
<button class="nav-link @(activeCategory == "apps" ? "active" : "")" @onclick='() => SetCategory("apps")'>
|
||||||
<i class="bi bi-layers me-1"></i>Environments
|
<i class="bi bi-people me-1"></i>Applications
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link @(activeTab == "customers" ? "active" : "")" @onclick='() => activeTab = "customers"'>
|
<button class="nav-link @(activeCategory == "infra" ? "active" : "")" @onclick='() => SetCategory("infra")'>
|
||||||
<i class="bi bi-people me-1"></i>Customers
|
<i class="bi bi-layers me-1"></i>Infrastructure
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link @(activeTab == "vault" ? "active" : "")" @onclick='() => activeTab = "vault"'>
|
<button class="nav-link @(activeCategory == "services" ? "active" : "")" @onclick='() => SetCategory("services")'>
|
||||||
<i class="bi bi-shield-lock me-1"></i>Vault
|
<i class="bi bi-box-seam me-1"></i>Services
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link @(activeTab == "groups" ? "active" : "")" @onclick='() => activeTab = "groups"'>
|
<button class="nav-link @(activeCategory == "ops" ? "active" : "")" @onclick='() => SetCategory("ops")'>
|
||||||
<i class="bi bi-diagram-3 me-1"></i>Groups
|
<i class="bi bi-speedometer2 me-1"></i>Operations
|
||||||
</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>
|
|
||||||
<li class="nav-item">
|
|
||||||
<button class="nav-link @(activeTab == "identity" ? "active" : "")" @onclick='() => activeTab = "identity"'>
|
|
||||||
<i class="bi bi-person-badge me-1"></i>Identity
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<button class="nav-link @(activeTab == "registry" ? "active" : "")" @onclick='() => activeTab = "registry"'>
|
|
||||||
<i class="bi bi-archive me-1"></i>Registry
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<button class="nav-link @(activeTab == "messaging" ? "active" : "")" @onclick='() => activeTab = "messaging"'>
|
|
||||||
<i class="bi bi-collection me-1"></i>Messaging
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<button class="nav-link @(activeTab == "cache" ? "active" : "")" @onclick='() => activeTab = "cache"'>
|
|
||||||
<i class="bi bi-lightning-charge me-1"></i>Cache
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<button class="nav-link @(activeTab == "overview" ? "active" : "")" @onclick='() => activeTab = "overview"'>
|
|
||||||
<i class="bi bi-speedometer2 me-1"></i>Overview
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<button class="nav-link @(activeTab == "incidents" ? "active" : "")" @onclick='() => activeTab = "incidents"'>
|
|
||||||
<i class="bi bi-bell-fill me-1"></i>Incidents
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<button class="nav-link @(activeTab == "notifications" ? "active" : "")" @onclick='() => activeTab = "notifications"'>
|
|
||||||
<i class="bi bi-send-fill me-1"></i>Notifications
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<button class="nav-link @(activeTab == "maintenance" ? "active" : "")" @onclick='() => activeTab = "maintenance"'>
|
|
||||||
<i class="bi bi-tools me-1"></i>Maintenance
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<button class="nav-link @(activeTab == "sla" ? "active" : "")" @onclick='() => activeTab = "sla"'>
|
|
||||||
<i class="bi bi-shield-check me-1"></i>SLA
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<button class="nav-link @(activeTab == "networking" ? "active" : "")" @onclick='() => activeTab = "networking"'>
|
|
||||||
<i class="bi bi-shield-lock me-1"></i>Networking
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<button class="nav-link @(activeTab == "git" ? "active" : "")" @onclick='() => activeTab = "git"'>
|
|
||||||
<i class="bi bi-git me-1"></i>Git
|
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
@* ── Sub-tab strip for the active category ── *@
|
||||||
|
<ul class="nav nav-tabs mb-4">
|
||||||
|
@if (activeCategory == "apps")
|
||||||
|
{
|
||||||
|
<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 == "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 == "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 == "git" ? "active" : "")" @onclick='() => activeTab = "git"'>
|
||||||
|
<i class="bi bi-git me-1"></i>Git
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
else if (activeCategory == "infra")
|
||||||
|
{
|
||||||
|
<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 == "networking" ? "active" : "")" @onclick='() => activeTab = "networking"'>
|
||||||
|
<i class="bi bi-diagram-2 me-1"></i>Networking
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
else if (activeCategory == "services")
|
||||||
|
{
|
||||||
|
<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>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link @(activeTab == "messaging" ? "active" : "")" @onclick='() => activeTab = "messaging"'>
|
||||||
|
<i class="bi bi-collection me-1"></i>Messaging
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link @(activeTab == "cache" ? "active" : "")" @onclick='() => activeTab = "cache"'>
|
||||||
|
<i class="bi bi-lightning-charge me-1"></i>Cache
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link @(activeTab == "identity" ? "active" : "")" @onclick='() => activeTab = "identity"'>
|
||||||
|
<i class="bi bi-person-badge me-1"></i>Identity
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link @(activeTab == "registry" ? "active" : "")" @onclick='() => activeTab = "registry"'>
|
||||||
|
<i class="bi bi-archive me-1"></i>Registry
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
else if (activeCategory == "ops")
|
||||||
|
{
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link @(activeTab == "overview" ? "active" : "")" @onclick='() => activeTab = "overview"'>
|
||||||
|
<i class="bi bi-speedometer2 me-1"></i>Overview
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link @(activeTab == "incidents" ? "active" : "")" @onclick='() => activeTab = "incidents"'>
|
||||||
|
<i class="bi bi-bell-fill me-1"></i>Incidents
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link @(activeTab == "notifications" ? "active" : "")" @onclick='() => activeTab = "notifications"'>
|
||||||
|
<i class="bi bi-send-fill me-1"></i>Notifications
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link @(activeTab == "maintenance" ? "active" : "")" @onclick='() => activeTab = "maintenance"'>
|
||||||
|
<i class="bi bi-tools me-1"></i>Maintenance
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link @(activeTab == "sla" ? "active" : "")" @onclick='() => activeTab = "sla"'>
|
||||||
|
<i class="bi bi-shield-check me-1"></i>SLA
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
@switch (activeTab)
|
@switch (activeTab)
|
||||||
{
|
{
|
||||||
@@ -184,7 +221,22 @@ else
|
|||||||
[Parameter] public string Slug { get; set; } = "";
|
[Parameter] public string Slug { get; set; } = "";
|
||||||
|
|
||||||
private Tenant? tenant;
|
private Tenant? tenant;
|
||||||
private string activeTab = "environments";
|
private string activeCategory = "apps";
|
||||||
|
private string activeTab = "customers";
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, string> CategoryDefaults = new()
|
||||||
|
{
|
||||||
|
["apps"] = "customers",
|
||||||
|
["infra"] = "environments",
|
||||||
|
["services"] = "databases",
|
||||||
|
["ops"] = "overview",
|
||||||
|
};
|
||||||
|
|
||||||
|
private void SetCategory(string category)
|
||||||
|
{
|
||||||
|
activeCategory = category;
|
||||||
|
activeTab = CategoryDefaults[category];
|
||||||
|
}
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,14 +10,8 @@
|
|||||||
|
|
||||||
<PageTitle>Tenants</PageTitle>
|
<PageTitle>Tenants</PageTitle>
|
||||||
|
|
||||||
@if (!loaded)
|
<LoadingPanel Loading="@(!loaded)" LoadingText="Loading tenants…">
|
||||||
{
|
@if (loaded)
|
||||||
<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
|
|
||||||
{
|
{
|
||||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -49,18 +43,9 @@ else
|
|||||||
|
|
||||||
@if (tenants is null || tenants.Count == 0)
|
@if (tenants is null || tenants.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="text-center py-5">
|
<EmptyState Icon="bi-building"
|
||||||
<i class="bi bi-building text-muted" style="font-size: 4rem;"></i>
|
Title="No tenants yet"
|
||||||
<h5 class="text-muted mt-3">No tenants yet</h5>
|
Message="@(isAdmin ? "Create your first tenant above to get started." : "You have not been added to any tenants yet.")" />
|
||||||
@if (isAdmin)
|
|
||||||
{
|
|
||||||
<p class="text-muted">Create your first tenant above to get started.</p>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<p class="text-muted">You have not been added to any tenants yet.</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -113,6 +98,7 @@ else
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<Tenant>? tenants;
|
private List<Tenant>? tenants;
|
||||||
|
|||||||
@@ -72,10 +72,8 @@ else
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="text-center py-4">
|
<EmptyState Icon="bi-app"
|
||||||
<i class="bi bi-app text-muted" style="font-size: 2rem;"></i>
|
Message="Create a customer and app first to store app secrets." />
|
||||||
<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")
|
else if (scope == "component")
|
||||||
@@ -99,10 +97,8 @@ else
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="text-center py-4">
|
<EmptyState Icon="bi-puzzle"
|
||||||
<i class="bi bi-puzzle text-muted" style="font-size: 2rem;"></i>
|
Message="Create an environment, cluster, and component first to store component secrets." />
|
||||||
<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")
|
else if (scope == "storage")
|
||||||
@@ -126,10 +122,8 @@ else
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="text-center py-4">
|
<EmptyState Icon="bi-hdd"
|
||||||
<i class="bi bi-hdd text-muted" style="font-size: 2rem;"></i>
|
Message="Create a storage link first to view its secrets." />
|
||||||
<p class="text-muted small mt-2 mb-0">Create a storage link first to view its secrets.</p>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,10 +148,8 @@ else
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="text-center py-4">
|
<EmptyState Icon="bi-database"
|
||||||
<i class="bi bi-database text-muted" style="font-size: 2rem;"></i>
|
Message="No CNPG clusters found for this tenant." />
|
||||||
<p class="text-muted small mt-2 mb-0">No CNPG clusters found for this tenant.</p>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,20 +218,13 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
@* Secrets table *@
|
@* Secrets table *@
|
||||||
@if (secrets is null)
|
<LoadingPanel Loading="@(secrets is null)">
|
||||||
|
@if (secrets is not null && secrets.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="text-center py-3">
|
<EmptyState Icon="bi-lock"
|
||||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
Message="No secrets stored yet. Add one above." />
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
else if (secrets.Count == 0)
|
else if (secrets is not null)
|
||||||
{
|
|
||||||
<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">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover align-middle mb-0">
|
<table class="table table-hover align-middle mb-0">
|
||||||
@@ -385,6 +370,7 @@ else
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
</LoadingPanel>
|
||||||
@if (syncOutput is not null)
|
@if (syncOutput is not null)
|
||||||
{
|
{
|
||||||
<div class="mt-3 p-2 bg-dark text-white rounded font-monospace small" style="white-space: pre-wrap; max-height: 200px; overflow-y: auto;">@syncOutput</div>
|
<div class="mt-3 p-2 bg-dark text-white rounded font-monospace small" style="white-space: pre-wrap; max-height: 200px; overflow-y: auto;">@syncOutput</div>
|
||||||
|
|||||||
@@ -12,3 +12,4 @@
|
|||||||
@using EntKube.Web.Components
|
@using EntKube.Web.Components
|
||||||
@using EntKube.Web.Components.Layout
|
@using EntKube.Web.Components.Layout
|
||||||
@using EntKube.Web.Components.Pages.Shared
|
@using EntKube.Web.Components.Pages.Shared
|
||||||
|
@using EntKube.Web.Components.Pages.Tenants
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
|
|||||||
public DbSet<CacheBinding> CacheBindings => Set<CacheBinding>();
|
public DbSet<CacheBinding> CacheBindings => Set<CacheBinding>();
|
||||||
public DbSet<GitRepository> GitRepositories => Set<GitRepository>();
|
public DbSet<GitRepository> GitRepositories => Set<GitRepository>();
|
||||||
public DbSet<GitKnownHost> GitKnownHosts => Set<GitKnownHost>();
|
public DbSet<GitKnownHost> GitKnownHosts => Set<GitKnownHost>();
|
||||||
|
public DbSet<CustomerGitRepoPolicy> CustomerGitRepoPolicies => Set<CustomerGitRepoPolicy>();
|
||||||
|
public DbSet<CustomerGitCredential> CustomerGitCredentials => Set<CustomerGitCredential>();
|
||||||
public DbSet<AppQuota> AppQuotas => Set<AppQuota>();
|
public DbSet<AppQuota> AppQuotas => Set<AppQuota>();
|
||||||
public DbSet<AppNetworkPolicy> AppNetworkPolicies => Set<AppNetworkPolicy>();
|
public DbSet<AppNetworkPolicy> AppNetworkPolicies => Set<AppNetworkPolicy>();
|
||||||
public DbSet<AppRbacPolicy> AppRbacPolicies => Set<AppRbacPolicy>();
|
public DbSet<AppRbacPolicy> AppRbacPolicies => Set<AppRbacPolicy>();
|
||||||
@@ -1208,6 +1210,46 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
|
|||||||
.HasForeignKey(s => s.GitRepositoryId)
|
.HasForeignKey(s => s.GitRepositoryId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
// CustomerGitRepoPolicy — URL allowlist per customer (wildcard patterns).
|
||||||
|
builder.Entity<CustomerGitRepoPolicy>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(p => p.Id);
|
||||||
|
entity.HasIndex(p => new { p.CustomerId, p.UrlPattern }).IsUnique();
|
||||||
|
entity.Property(p => p.UrlPattern).HasMaxLength(2000).IsRequired();
|
||||||
|
|
||||||
|
entity.HasOne(p => p.Customer)
|
||||||
|
.WithMany(c => c.GitRepoPolicies)
|
||||||
|
.HasForeignKey(p => p.CustomerId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
// CustomerGitCredential — reusable credential sets per customer.
|
||||||
|
builder.Entity<CustomerGitCredential>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(c => c.Id);
|
||||||
|
entity.HasIndex(c => new { c.CustomerId, c.Name }).IsUnique();
|
||||||
|
entity.Property(c => c.Name).HasMaxLength(200).IsRequired();
|
||||||
|
entity.Property(c => c.AuthType).HasConversion<string>().HasMaxLength(20);
|
||||||
|
entity.Property(c => c.Username).HasMaxLength(300);
|
||||||
|
|
||||||
|
entity.HasOne(c => c.Customer)
|
||||||
|
.WithMany(cu => cu.GitCredentials)
|
||||||
|
.HasForeignKey(c => c.CustomerId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasOne(c => c.Tenant)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.TenantId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
// VaultSecret — CustomerGitCredential scoping (PAT / SSH key / password for a customer credential).
|
||||||
|
builder.Entity<VaultSecret>()
|
||||||
|
.HasOne(s => s.CustomerGitCredential)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(s => s.CustomerGitCredentialId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
// App — add Namespace field constraint.
|
// App — add Namespace field constraint.
|
||||||
builder.Entity<App>(entity =>
|
builder.Entity<App>(entity =>
|
||||||
{
|
{
|
||||||
@@ -1288,6 +1330,11 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
|
|||||||
.WithMany(t => t.GitRepositories)
|
.WithMany(t => t.GitRepositories)
|
||||||
.HasForeignKey(r => r.TenantId)
|
.HasForeignKey(r => r.TenantId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasOne(r => r.CustomerGitCredential)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(r => r.CustomerGitCredentialId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
// GitKnownHost — trusted SSH host fingerprints, unique per (TenantId, Hostname).
|
// GitKnownHost — trusted SSH host fingerprints, unique per (TenantId, Hostname).
|
||||||
|
|||||||
@@ -20,4 +20,6 @@ public class Customer
|
|||||||
// Navigation
|
// Navigation
|
||||||
public Tenant Tenant { get; set; } = null!;
|
public Tenant Tenant { get; set; } = null!;
|
||||||
public ICollection<App> Apps { get; set; } = [];
|
public ICollection<App> Apps { get; set; } = [];
|
||||||
|
public ICollection<CustomerGitRepoPolicy> GitRepoPolicies { get; set; } = [];
|
||||||
|
public ICollection<CustomerGitCredential> GitCredentials { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
39
src/EntKube.Web/Data/CustomerGitCredential.cs
Normal file
39
src/EntKube.Web/Data/CustomerGitCredential.cs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
namespace EntKube.Web.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A reusable git credential set scoped to a customer. One credential can cover
|
||||||
|
/// any number of repositories that match the customer's GitRepoPolicies.
|
||||||
|
/// Actual secret values (PAT, SSH key, password) are encrypted in the tenant
|
||||||
|
/// vault as VaultSecret rows with CustomerGitCredentialId set.
|
||||||
|
///
|
||||||
|
/// Supported auth types (same as GitRepository):
|
||||||
|
/// None — public repos, no credentials needed
|
||||||
|
/// HttpsPat — HTTPS with a Personal Access Token (vault: "PAT")
|
||||||
|
/// HttpsPassword — HTTPS with username + password (vault: "PASSWORD"; username stored here)
|
||||||
|
/// SshKey — SSH private key (vault: "SSH_PRIVATE_KEY")
|
||||||
|
/// </summary>
|
||||||
|
public class CustomerGitCredential
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
public Guid CustomerId { get; set; }
|
||||||
|
|
||||||
|
public Guid TenantId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Human-friendly label, e.g. "GitHub PAT". Unique within a customer.</summary>
|
||||||
|
public required string Name { get; set; }
|
||||||
|
|
||||||
|
public GitAuthType AuthType { get; set; } = GitAuthType.None;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// For HttpsPassword auth: the username paired with the vault-stored password.
|
||||||
|
/// Ignored for other auth types.
|
||||||
|
/// </summary>
|
||||||
|
public string? Username { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
public Customer Customer { get; set; } = null!;
|
||||||
|
public Tenant Tenant { get; set; } = null!;
|
||||||
|
}
|
||||||
27
src/EntKube.Web/Data/CustomerGitRepoPolicy.cs
Normal file
27
src/EntKube.Web/Data/CustomerGitRepoPolicy.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
namespace EntKube.Web.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An allowed git repository URL pattern for a customer. Supports wildcards
|
||||||
|
/// using '*' (matches any sequence of characters within a segment) and '**'
|
||||||
|
/// (matches across path separators). For example:
|
||||||
|
/// https://github.com/acme/* — any repo under the acme org
|
||||||
|
/// git@github.com:acme/* — SSH variant of the same
|
||||||
|
/// https://dev.azure.com/contoso/** — any repo anywhere under contoso's org
|
||||||
|
/// </summary>
|
||||||
|
public class CustomerGitRepoPolicy
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
public Guid CustomerId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The URL pattern. May contain '*' or '**' wildcards.
|
||||||
|
/// Must be unique within a customer.
|
||||||
|
/// </summary>
|
||||||
|
public required string UrlPattern { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
public Customer Customer { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -45,8 +45,15 @@ public class GitRepository
|
|||||||
|
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When set, this repository was created on behalf of a customer and uses the
|
||||||
|
/// customer's stored credential for authentication rather than its own vault entries.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? CustomerGitCredentialId { get; set; }
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
public Tenant Tenant { get; set; } = null!;
|
public Tenant Tenant { get; set; } = null!;
|
||||||
|
public CustomerGitCredential? CustomerGitCredential { get; set; }
|
||||||
public ICollection<AppDeployment> Deployments { get; set; } = [];
|
public ICollection<AppDeployment> Deployments { get; set; } = [];
|
||||||
public ICollection<GitKnownHost> KnownHosts { get; set; } = [];
|
public ICollection<GitKnownHost> KnownHosts { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
4436
src/EntKube.Web/Data/Migrations/Postgres/20260604152714_AddCustomerGitPoliciesAndCredentials.Designer.cs
generated
Normal file
4436
src/EntKube.Web/Data/Migrations/Postgres/20260604152714_AddCustomerGitPoliciesAndCredentials.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.Postgres
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddCustomerGitPoliciesAndCredentials : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "CustomerGitCredentialId",
|
||||||
|
table: "VaultSecrets",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CustomerGitCredentials",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
CustomerId = 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),
|
||||||
|
AuthType = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
Username = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_CustomerGitCredentials", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_CustomerGitCredentials_Customers_CustomerId",
|
||||||
|
column: x => x.CustomerId,
|
||||||
|
principalTable: "Customers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_CustomerGitCredentials_Tenants_TenantId",
|
||||||
|
column: x => x.TenantId,
|
||||||
|
principalTable: "Tenants",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CustomerGitRepoPolicies",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
CustomerId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
UrlPattern = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_CustomerGitRepoPolicies", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_CustomerGitRepoPolicies_Customers_CustomerId",
|
||||||
|
column: x => x.CustomerId,
|
||||||
|
principalTable: "Customers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_VaultSecrets_CustomerGitCredentialId",
|
||||||
|
table: "VaultSecrets",
|
||||||
|
column: "CustomerGitCredentialId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CustomerGitCredentials_CustomerId_Name",
|
||||||
|
table: "CustomerGitCredentials",
|
||||||
|
columns: new[] { "CustomerId", "Name" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CustomerGitCredentials_TenantId",
|
||||||
|
table: "CustomerGitCredentials",
|
||||||
|
column: "TenantId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CustomerGitRepoPolicies_CustomerId_UrlPattern",
|
||||||
|
table: "CustomerGitRepoPolicies",
|
||||||
|
columns: new[] { "CustomerId", "UrlPattern" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_VaultSecrets_CustomerGitCredentials_CustomerGitCredentialId",
|
||||||
|
table: "VaultSecrets",
|
||||||
|
column: "CustomerGitCredentialId",
|
||||||
|
principalTable: "CustomerGitCredentials",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_VaultSecrets_CustomerGitCredentials_CustomerGitCredentialId",
|
||||||
|
table: "VaultSecrets");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CustomerGitCredentials");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CustomerGitRepoPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_VaultSecrets_CustomerGitCredentialId",
|
||||||
|
table: "VaultSecrets");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CustomerGitCredentialId",
|
||||||
|
table: "VaultSecrets");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4448
src/EntKube.Web/Data/Migrations/Postgres/20260605075348_AddCustomerGitCredentialToGitRepository.Designer.cs
generated
Normal file
4448
src/EntKube.Web/Data/Migrations/Postgres/20260605075348_AddCustomerGitCredentialToGitRepository.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.Postgres
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddCustomerGitCredentialToGitRepository : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "CustomerGitCredentialId",
|
||||||
|
table: "GitRepositories",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_GitRepositories_CustomerGitCredentialId",
|
||||||
|
table: "GitRepositories",
|
||||||
|
column: "CustomerGitCredentialId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_GitRepositories_CustomerGitCredentials_CustomerGitCredentia~",
|
||||||
|
table: "GitRepositories",
|
||||||
|
column: "CustomerGitCredentialId",
|
||||||
|
principalTable: "CustomerGitCredentials",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_GitRepositories_CustomerGitCredentials_CustomerGitCredentia~",
|
||||||
|
table: "GitRepositories");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_GitRepositories_CustomerGitCredentialId",
|
||||||
|
table: "GitRepositories");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CustomerGitCredentialId",
|
||||||
|
table: "GitRepositories");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -782,6 +782,70 @@ namespace EntKube.Web.Data.Migrations.Postgres
|
|||||||
b.ToTable("CustomerAccesses");
|
b.ToTable("CustomerAccesses");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EntKube.Web.Data.CustomerGitCredential", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AuthType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
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.Property<Guid>("TenantId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.HasMaxLength(300)
|
||||||
|
.HasColumnType("character varying(300)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TenantId");
|
||||||
|
|
||||||
|
b.HasIndex("CustomerId", "Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("CustomerGitCredentials");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EntKube.Web.Data.CustomerGitRepoPolicy", 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>("UrlPattern")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("character varying(2000)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CustomerId", "UrlPattern")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("CustomerGitRepoPolicies");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("EntKube.Web.Data.DatabaseBinding", b =>
|
modelBuilder.Entity("EntKube.Web.Data.DatabaseBinding", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1213,6 +1277,9 @@ namespace EntKube.Web.Data.Migrations.Postgres
|
|||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CustomerGitCredentialId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<string>("DefaultBranch")
|
b.Property<string>("DefaultBranch")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -1239,6 +1306,8 @@ namespace EntKube.Web.Data.Migrations.Postgres
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CustomerGitCredentialId");
|
||||||
|
|
||||||
b.HasIndex("TenantId", "Name")
|
b.HasIndex("TenantId", "Name")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
@@ -2550,6 +2619,9 @@ namespace EntKube.Web.Data.Migrations.Postgres
|
|||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CustomerGitCredentialId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<byte[]>("EncryptedValue")
|
b.Property<byte[]>("EncryptedValue")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("bytea");
|
.HasColumnType("bytea");
|
||||||
@@ -2620,6 +2692,8 @@ namespace EntKube.Web.Data.Migrations.Postgres
|
|||||||
|
|
||||||
b.HasIndex("ComponentId");
|
b.HasIndex("ComponentId");
|
||||||
|
|
||||||
|
b.HasIndex("CustomerGitCredentialId");
|
||||||
|
|
||||||
b.HasIndex("GitRepositoryId");
|
b.HasIndex("GitRepositoryId");
|
||||||
|
|
||||||
b.HasIndex("KubernetesClusterId");
|
b.HasIndex("KubernetesClusterId");
|
||||||
@@ -3192,6 +3266,36 @@ namespace EntKube.Web.Data.Migrations.Postgres
|
|||||||
b.Navigation("User");
|
b.Navigation("User");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EntKube.Web.Data.CustomerGitCredential", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EntKube.Web.Data.Customer", "Customer")
|
||||||
|
.WithMany("GitCredentials")
|
||||||
|
.HasForeignKey("CustomerId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TenantId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Customer");
|
||||||
|
|
||||||
|
b.Navigation("Tenant");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EntKube.Web.Data.CustomerGitRepoPolicy", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EntKube.Web.Data.Customer", "Customer")
|
||||||
|
.WithMany("GitRepoPolicies")
|
||||||
|
.HasForeignKey("CustomerId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Customer");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("EntKube.Web.Data.DatabaseBinding", b =>
|
modelBuilder.Entity("EntKube.Web.Data.DatabaseBinding", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment")
|
b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment")
|
||||||
@@ -3339,12 +3443,19 @@ namespace EntKube.Web.Data.Migrations.Postgres
|
|||||||
|
|
||||||
modelBuilder.Entity("EntKube.Web.Data.GitRepository", b =>
|
modelBuilder.Entity("EntKube.Web.Data.GitRepository", b =>
|
||||||
{
|
{
|
||||||
|
b.HasOne("EntKube.Web.Data.CustomerGitCredential", "CustomerGitCredential")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CustomerGitCredentialId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
|
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
|
||||||
.WithMany("GitRepositories")
|
.WithMany("GitRepositories")
|
||||||
.HasForeignKey("TenantId")
|
.HasForeignKey("TenantId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("CustomerGitCredential");
|
||||||
|
|
||||||
b.Navigation("Tenant");
|
b.Navigation("Tenant");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3943,6 +4054,11 @@ namespace EntKube.Web.Data.Migrations.Postgres
|
|||||||
.HasForeignKey("ComponentId")
|
.HasForeignKey("ComponentId")
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("EntKube.Web.Data.CustomerGitCredential", "CustomerGitCredential")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CustomerGitCredentialId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
b.HasOne("EntKube.Web.Data.GitRepository", "GitRepository")
|
b.HasOne("EntKube.Web.Data.GitRepository", "GitRepository")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("GitRepositoryId")
|
.HasForeignKey("GitRepositoryId")
|
||||||
@@ -4001,6 +4117,8 @@ namespace EntKube.Web.Data.Migrations.Postgres
|
|||||||
|
|
||||||
b.Navigation("Component");
|
b.Navigation("Component");
|
||||||
|
|
||||||
|
b.Navigation("CustomerGitCredential");
|
||||||
|
|
||||||
b.Navigation("GitRepository");
|
b.Navigation("GitRepository");
|
||||||
|
|
||||||
b.Navigation("KubernetesCluster");
|
b.Navigation("KubernetesCluster");
|
||||||
@@ -4187,6 +4305,10 @@ namespace EntKube.Web.Data.Migrations.Postgres
|
|||||||
modelBuilder.Entity("EntKube.Web.Data.Customer", b =>
|
modelBuilder.Entity("EntKube.Web.Data.Customer", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Apps");
|
b.Navigation("Apps");
|
||||||
|
|
||||||
|
b.Navigation("GitCredentials");
|
||||||
|
|
||||||
|
b.Navigation("GitRepoPolicies");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b =>
|
modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b =>
|
||||||
|
|||||||
4438
src/EntKube.Web/Data/Migrations/SqlServer/20260604152727_AddCustomerGitPoliciesAndCredentials.Designer.cs
generated
Normal file
4438
src/EntKube.Web/Data/Migrations/SqlServer/20260604152727_AddCustomerGitPoliciesAndCredentials.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.SqlServer
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddCustomerGitPoliciesAndCredentials : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "CustomerGitCredentialId",
|
||||||
|
table: "VaultSecrets",
|
||||||
|
type: "uniqueidentifier",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CustomerGitCredentials",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
CustomerId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
TenantId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||||
|
AuthType = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
||||||
|
Username = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_CustomerGitCredentials", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_CustomerGitCredentials_Customers_CustomerId",
|
||||||
|
column: x => x.CustomerId,
|
||||||
|
principalTable: "Customers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_CustomerGitCredentials_Tenants_TenantId",
|
||||||
|
column: x => x.TenantId,
|
||||||
|
principalTable: "Tenants",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CustomerGitRepoPolicies",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
CustomerId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
UrlPattern = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_CustomerGitRepoPolicies", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_CustomerGitRepoPolicies_Customers_CustomerId",
|
||||||
|
column: x => x.CustomerId,
|
||||||
|
principalTable: "Customers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_VaultSecrets_CustomerGitCredentialId",
|
||||||
|
table: "VaultSecrets",
|
||||||
|
column: "CustomerGitCredentialId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CustomerGitCredentials_CustomerId_Name",
|
||||||
|
table: "CustomerGitCredentials",
|
||||||
|
columns: new[] { "CustomerId", "Name" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CustomerGitCredentials_TenantId",
|
||||||
|
table: "CustomerGitCredentials",
|
||||||
|
column: "TenantId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CustomerGitRepoPolicies_CustomerId_UrlPattern",
|
||||||
|
table: "CustomerGitRepoPolicies",
|
||||||
|
columns: new[] { "CustomerId", "UrlPattern" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_VaultSecrets_CustomerGitCredentials_CustomerGitCredentialId",
|
||||||
|
table: "VaultSecrets",
|
||||||
|
column: "CustomerGitCredentialId",
|
||||||
|
principalTable: "CustomerGitCredentials",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_VaultSecrets_CustomerGitCredentials_CustomerGitCredentialId",
|
||||||
|
table: "VaultSecrets");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CustomerGitCredentials");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CustomerGitRepoPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_VaultSecrets_CustomerGitCredentialId",
|
||||||
|
table: "VaultSecrets");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CustomerGitCredentialId",
|
||||||
|
table: "VaultSecrets");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4450
src/EntKube.Web/Data/Migrations/SqlServer/20260605075405_AddCustomerGitCredentialToGitRepository.Designer.cs
generated
Normal file
4450
src/EntKube.Web/Data/Migrations/SqlServer/20260605075405_AddCustomerGitCredentialToGitRepository.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.SqlServer
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddCustomerGitCredentialToGitRepository : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "CustomerGitCredentialId",
|
||||||
|
table: "GitRepositories",
|
||||||
|
type: "uniqueidentifier",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_GitRepositories_CustomerGitCredentialId",
|
||||||
|
table: "GitRepositories",
|
||||||
|
column: "CustomerGitCredentialId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_GitRepositories_CustomerGitCredentials_CustomerGitCredentialId",
|
||||||
|
table: "GitRepositories",
|
||||||
|
column: "CustomerGitCredentialId",
|
||||||
|
principalTable: "CustomerGitCredentials",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_GitRepositories_CustomerGitCredentials_CustomerGitCredentialId",
|
||||||
|
table: "GitRepositories");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_GitRepositories_CustomerGitCredentialId",
|
||||||
|
table: "GitRepositories");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CustomerGitCredentialId",
|
||||||
|
table: "GitRepositories");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -783,6 +783,70 @@ namespace EntKube.Web.Data.Migrations.SqlServer
|
|||||||
b.ToTable("CustomerAccesses");
|
b.ToTable("CustomerAccesses");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EntKube.Web.Data.CustomerGitCredential", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("AuthType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("nvarchar(20)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid>("CustomerId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.Property<Guid>("TenantId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.HasMaxLength(300)
|
||||||
|
.HasColumnType("nvarchar(300)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TenantId");
|
||||||
|
|
||||||
|
b.HasIndex("CustomerId", "Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("CustomerGitCredentials");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EntKube.Web.Data.CustomerGitRepoPolicy", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid>("CustomerId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("UrlPattern")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("nvarchar(2000)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CustomerId", "UrlPattern")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("CustomerGitRepoPolicies");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("EntKube.Web.Data.DatabaseBinding", b =>
|
modelBuilder.Entity("EntKube.Web.Data.DatabaseBinding", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1214,6 +1278,9 @@ namespace EntKube.Web.Data.Migrations.SqlServer
|
|||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CustomerGitCredentialId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
b.Property<string>("DefaultBranch")
|
b.Property<string>("DefaultBranch")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -1240,6 +1307,8 @@ namespace EntKube.Web.Data.Migrations.SqlServer
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CustomerGitCredentialId");
|
||||||
|
|
||||||
b.HasIndex("TenantId", "Name")
|
b.HasIndex("TenantId", "Name")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
@@ -2551,6 +2620,9 @@ namespace EntKube.Web.Data.Migrations.SqlServer
|
|||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CustomerGitCredentialId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
b.Property<byte[]>("EncryptedValue")
|
b.Property<byte[]>("EncryptedValue")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("varbinary(max)");
|
.HasColumnType("varbinary(max)");
|
||||||
@@ -2621,6 +2693,8 @@ namespace EntKube.Web.Data.Migrations.SqlServer
|
|||||||
|
|
||||||
b.HasIndex("ComponentId");
|
b.HasIndex("ComponentId");
|
||||||
|
|
||||||
|
b.HasIndex("CustomerGitCredentialId");
|
||||||
|
|
||||||
b.HasIndex("GitRepositoryId");
|
b.HasIndex("GitRepositoryId");
|
||||||
|
|
||||||
b.HasIndex("KubernetesClusterId");
|
b.HasIndex("KubernetesClusterId");
|
||||||
@@ -3194,6 +3268,36 @@ namespace EntKube.Web.Data.Migrations.SqlServer
|
|||||||
b.Navigation("User");
|
b.Navigation("User");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EntKube.Web.Data.CustomerGitCredential", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EntKube.Web.Data.Customer", "Customer")
|
||||||
|
.WithMany("GitCredentials")
|
||||||
|
.HasForeignKey("CustomerId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TenantId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Customer");
|
||||||
|
|
||||||
|
b.Navigation("Tenant");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EntKube.Web.Data.CustomerGitRepoPolicy", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EntKube.Web.Data.Customer", "Customer")
|
||||||
|
.WithMany("GitRepoPolicies")
|
||||||
|
.HasForeignKey("CustomerId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Customer");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("EntKube.Web.Data.DatabaseBinding", b =>
|
modelBuilder.Entity("EntKube.Web.Data.DatabaseBinding", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment")
|
b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment")
|
||||||
@@ -3341,12 +3445,19 @@ namespace EntKube.Web.Data.Migrations.SqlServer
|
|||||||
|
|
||||||
modelBuilder.Entity("EntKube.Web.Data.GitRepository", b =>
|
modelBuilder.Entity("EntKube.Web.Data.GitRepository", b =>
|
||||||
{
|
{
|
||||||
|
b.HasOne("EntKube.Web.Data.CustomerGitCredential", "CustomerGitCredential")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CustomerGitCredentialId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
|
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
|
||||||
.WithMany("GitRepositories")
|
.WithMany("GitRepositories")
|
||||||
.HasForeignKey("TenantId")
|
.HasForeignKey("TenantId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("CustomerGitCredential");
|
||||||
|
|
||||||
b.Navigation("Tenant");
|
b.Navigation("Tenant");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3945,6 +4056,11 @@ namespace EntKube.Web.Data.Migrations.SqlServer
|
|||||||
.HasForeignKey("ComponentId")
|
.HasForeignKey("ComponentId")
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("EntKube.Web.Data.CustomerGitCredential", "CustomerGitCredential")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CustomerGitCredentialId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
b.HasOne("EntKube.Web.Data.GitRepository", "GitRepository")
|
b.HasOne("EntKube.Web.Data.GitRepository", "GitRepository")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("GitRepositoryId")
|
.HasForeignKey("GitRepositoryId")
|
||||||
@@ -4003,6 +4119,8 @@ namespace EntKube.Web.Data.Migrations.SqlServer
|
|||||||
|
|
||||||
b.Navigation("Component");
|
b.Navigation("Component");
|
||||||
|
|
||||||
|
b.Navigation("CustomerGitCredential");
|
||||||
|
|
||||||
b.Navigation("GitRepository");
|
b.Navigation("GitRepository");
|
||||||
|
|
||||||
b.Navigation("KubernetesCluster");
|
b.Navigation("KubernetesCluster");
|
||||||
@@ -4189,6 +4307,10 @@ namespace EntKube.Web.Data.Migrations.SqlServer
|
|||||||
modelBuilder.Entity("EntKube.Web.Data.Customer", b =>
|
modelBuilder.Entity("EntKube.Web.Data.Customer", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Apps");
|
b.Navigation("Apps");
|
||||||
|
|
||||||
|
b.Navigation("GitCredentials");
|
||||||
|
|
||||||
|
b.Navigation("GitRepoPolicies");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b =>
|
modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b =>
|
||||||
|
|||||||
4427
src/EntKube.Web/Data/Migrations/Sqlite/20260604152747_AddCustomerGitPoliciesAndCredentials.Designer.cs
generated
Normal file
4427
src/EntKube.Web/Data/Migrations/Sqlite/20260604152747_AddCustomerGitPoliciesAndCredentials.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.Sqlite
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddCustomerGitPoliciesAndCredentials : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "CustomerGitCredentialId",
|
||||||
|
table: "VaultSecrets",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CustomerGitCredentials",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
CustomerId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
TenantId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||||
|
AuthType = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
|
||||||
|
Username = table.Column<string>(type: "TEXT", maxLength: 300, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_CustomerGitCredentials", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_CustomerGitCredentials_Customers_CustomerId",
|
||||||
|
column: x => x.CustomerId,
|
||||||
|
principalTable: "Customers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_CustomerGitCredentials_Tenants_TenantId",
|
||||||
|
column: x => x.TenantId,
|
||||||
|
principalTable: "Tenants",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CustomerGitRepoPolicies",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
CustomerId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
UrlPattern = table.Column<string>(type: "TEXT", maxLength: 2000, nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_CustomerGitRepoPolicies", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_CustomerGitRepoPolicies_Customers_CustomerId",
|
||||||
|
column: x => x.CustomerId,
|
||||||
|
principalTable: "Customers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_VaultSecrets_CustomerGitCredentialId",
|
||||||
|
table: "VaultSecrets",
|
||||||
|
column: "CustomerGitCredentialId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CustomerGitCredentials_CustomerId_Name",
|
||||||
|
table: "CustomerGitCredentials",
|
||||||
|
columns: new[] { "CustomerId", "Name" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CustomerGitCredentials_TenantId",
|
||||||
|
table: "CustomerGitCredentials",
|
||||||
|
column: "TenantId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CustomerGitRepoPolicies_CustomerId_UrlPattern",
|
||||||
|
table: "CustomerGitRepoPolicies",
|
||||||
|
columns: new[] { "CustomerId", "UrlPattern" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_VaultSecrets_CustomerGitCredentials_CustomerGitCredentialId",
|
||||||
|
table: "VaultSecrets",
|
||||||
|
column: "CustomerGitCredentialId",
|
||||||
|
principalTable: "CustomerGitCredentials",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_VaultSecrets_CustomerGitCredentials_CustomerGitCredentialId",
|
||||||
|
table: "VaultSecrets");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CustomerGitCredentials");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CustomerGitRepoPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_VaultSecrets_CustomerGitCredentialId",
|
||||||
|
table: "VaultSecrets");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CustomerGitCredentialId",
|
||||||
|
table: "VaultSecrets");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4439
src/EntKube.Web/Data/Migrations/Sqlite/20260605072428_AddCustomerGitCredentialToGitRepository.Designer.cs
generated
Normal file
4439
src/EntKube.Web/Data/Migrations/Sqlite/20260605072428_AddCustomerGitCredentialToGitRepository.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.Sqlite
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddCustomerGitCredentialToGitRepository : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "CustomerGitCredentialId",
|
||||||
|
table: "GitRepositories",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_GitRepositories_CustomerGitCredentialId",
|
||||||
|
table: "GitRepositories",
|
||||||
|
column: "CustomerGitCredentialId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_GitRepositories_CustomerGitCredentials_CustomerGitCredentialId",
|
||||||
|
table: "GitRepositories",
|
||||||
|
column: "CustomerGitCredentialId",
|
||||||
|
principalTable: "CustomerGitCredentials",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_GitRepositories_CustomerGitCredentials_CustomerGitCredentialId",
|
||||||
|
table: "GitRepositories");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_GitRepositories_CustomerGitCredentialId",
|
||||||
|
table: "GitRepositories");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CustomerGitCredentialId",
|
||||||
|
table: "GitRepositories");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -777,6 +777,70 @@ namespace EntKube.Web.Data.Migrations.Sqlite
|
|||||||
b.ToTable("CustomerAccesses");
|
b.ToTable("CustomerAccesses");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EntKube.Web.Data.CustomerGitCredential", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AuthType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("CustomerId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("TenantId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.HasMaxLength(300)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TenantId");
|
||||||
|
|
||||||
|
b.HasIndex("CustomerId", "Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("CustomerGitCredentials");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EntKube.Web.Data.CustomerGitRepoPolicy", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("CustomerId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("UrlPattern")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CustomerId", "UrlPattern")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("CustomerGitRepoPolicies");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("EntKube.Web.Data.DatabaseBinding", b =>
|
modelBuilder.Entity("EntKube.Web.Data.DatabaseBinding", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1208,6 +1272,9 @@ namespace EntKube.Web.Data.Migrations.Sqlite
|
|||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CustomerGitCredentialId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("DefaultBranch")
|
b.Property<string>("DefaultBranch")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -1234,6 +1301,8 @@ namespace EntKube.Web.Data.Migrations.Sqlite
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CustomerGitCredentialId");
|
||||||
|
|
||||||
b.HasIndex("TenantId", "Name")
|
b.HasIndex("TenantId", "Name")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
@@ -2545,6 +2614,9 @@ namespace EntKube.Web.Data.Migrations.Sqlite
|
|||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CustomerGitCredentialId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<byte[]>("EncryptedValue")
|
b.Property<byte[]>("EncryptedValue")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("BLOB");
|
.HasColumnType("BLOB");
|
||||||
@@ -2615,6 +2687,8 @@ namespace EntKube.Web.Data.Migrations.Sqlite
|
|||||||
|
|
||||||
b.HasIndex("ComponentId");
|
b.HasIndex("ComponentId");
|
||||||
|
|
||||||
|
b.HasIndex("CustomerGitCredentialId");
|
||||||
|
|
||||||
b.HasIndex("GitRepositoryId");
|
b.HasIndex("GitRepositoryId");
|
||||||
|
|
||||||
b.HasIndex("KubernetesClusterId");
|
b.HasIndex("KubernetesClusterId");
|
||||||
@@ -3183,6 +3257,36 @@ namespace EntKube.Web.Data.Migrations.Sqlite
|
|||||||
b.Navigation("User");
|
b.Navigation("User");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EntKube.Web.Data.CustomerGitCredential", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EntKube.Web.Data.Customer", "Customer")
|
||||||
|
.WithMany("GitCredentials")
|
||||||
|
.HasForeignKey("CustomerId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TenantId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Customer");
|
||||||
|
|
||||||
|
b.Navigation("Tenant");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EntKube.Web.Data.CustomerGitRepoPolicy", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EntKube.Web.Data.Customer", "Customer")
|
||||||
|
.WithMany("GitRepoPolicies")
|
||||||
|
.HasForeignKey("CustomerId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Customer");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("EntKube.Web.Data.DatabaseBinding", b =>
|
modelBuilder.Entity("EntKube.Web.Data.DatabaseBinding", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment")
|
b.HasOne("EntKube.Web.Data.AppDeployment", "AppDeployment")
|
||||||
@@ -3330,12 +3434,19 @@ namespace EntKube.Web.Data.Migrations.Sqlite
|
|||||||
|
|
||||||
modelBuilder.Entity("EntKube.Web.Data.GitRepository", b =>
|
modelBuilder.Entity("EntKube.Web.Data.GitRepository", b =>
|
||||||
{
|
{
|
||||||
|
b.HasOne("EntKube.Web.Data.CustomerGitCredential", "CustomerGitCredential")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CustomerGitCredentialId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
|
b.HasOne("EntKube.Web.Data.Tenant", "Tenant")
|
||||||
.WithMany("GitRepositories")
|
.WithMany("GitRepositories")
|
||||||
.HasForeignKey("TenantId")
|
.HasForeignKey("TenantId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("CustomerGitCredential");
|
||||||
|
|
||||||
b.Navigation("Tenant");
|
b.Navigation("Tenant");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3934,6 +4045,11 @@ namespace EntKube.Web.Data.Migrations.Sqlite
|
|||||||
.HasForeignKey("ComponentId")
|
.HasForeignKey("ComponentId")
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("EntKube.Web.Data.CustomerGitCredential", "CustomerGitCredential")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CustomerGitCredentialId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
b.HasOne("EntKube.Web.Data.GitRepository", "GitRepository")
|
b.HasOne("EntKube.Web.Data.GitRepository", "GitRepository")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("GitRepositoryId")
|
.HasForeignKey("GitRepositoryId")
|
||||||
@@ -3992,6 +4108,8 @@ namespace EntKube.Web.Data.Migrations.Sqlite
|
|||||||
|
|
||||||
b.Navigation("Component");
|
b.Navigation("Component");
|
||||||
|
|
||||||
|
b.Navigation("CustomerGitCredential");
|
||||||
|
|
||||||
b.Navigation("GitRepository");
|
b.Navigation("GitRepository");
|
||||||
|
|
||||||
b.Navigation("KubernetesCluster");
|
b.Navigation("KubernetesCluster");
|
||||||
@@ -4178,6 +4296,10 @@ namespace EntKube.Web.Data.Migrations.Sqlite
|
|||||||
modelBuilder.Entity("EntKube.Web.Data.Customer", b =>
|
modelBuilder.Entity("EntKube.Web.Data.Customer", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Apps");
|
b.Navigation("Apps");
|
||||||
|
|
||||||
|
b.Navigation("GitCredentials");
|
||||||
|
|
||||||
|
b.Navigation("GitRepoPolicies");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b =>
|
modelBuilder.Entity("EntKube.Web.Data.DeploymentResource", b =>
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ public class VaultSecret
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid? GitRepositoryId { get; set; }
|
public Guid? GitRepositoryId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// If set, this secret belongs to a customer-level git credential.
|
||||||
|
/// Secret names follow the same convention as GitRepositoryId: "PAT", "SSH_PRIVATE_KEY", "PASSWORD".
|
||||||
|
/// </summary>
|
||||||
|
public Guid? CustomerGitCredentialId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// When true, this secret will be synced to Kubernetes as a Secret resource.
|
/// When true, this secret will be synced to Kubernetes as a Secret resource.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -139,4 +145,5 @@ public class VaultSecret
|
|||||||
public RedisCluster? RedisCluster { get; set; }
|
public RedisCluster? RedisCluster { get; set; }
|
||||||
public VpnRemoteEndpoint? VpnRemoteEndpoint { get; set; }
|
public VpnRemoteEndpoint? VpnRemoteEndpoint { get; set; }
|
||||||
public GitRepository? GitRepository { get; set; }
|
public GitRepository? GitRepository { get; set; }
|
||||||
|
public CustomerGitCredential? CustomerGitCredential { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<AppGovernanceService>();
|
builder.Services.AddScoped<AppGovernanceService>();
|
||||||
builder.Services.AddScoped<GitOperationsService>();
|
builder.Services.AddScoped<GitOperationsService>();
|
||||||
builder.Services.AddScoped<GitRepositoryService>();
|
builder.Services.AddScoped<GitRepositoryService>();
|
||||||
|
builder.Services.AddScoped<CustomerGitService>();
|
||||||
builder.Services.AddScoped<AppOfAppsService>();
|
builder.Services.AddScoped<AppOfAppsService>();
|
||||||
builder.Services.AddSingleton<GitSyncService>();
|
builder.Services.AddSingleton<GitSyncService>();
|
||||||
builder.Services.AddScoped<GitWebhookService>();
|
builder.Services.AddScoped<GitWebhookService>();
|
||||||
|
|||||||
258
src/EntKube.Web/Services/CustomerGitService.cs
Normal file
258
src/EntKube.Web/Services/CustomerGitService.cs
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using EntKube.Web.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace EntKube.Web.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages per-customer git repo URL allowlist policies and reusable git
|
||||||
|
/// credential sets. Credential secrets are stored encrypted via VaultService.
|
||||||
|
/// </summary>
|
||||||
|
public class CustomerGitService(
|
||||||
|
IDbContextFactory<ApplicationDbContext> dbFactory,
|
||||||
|
VaultService vault)
|
||||||
|
{
|
||||||
|
// ── Repo policies ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<List<CustomerGitRepoPolicy>> GetRepoPoliciesAsync(
|
||||||
|
Guid customerId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using ApplicationDbContext db = dbFactory.CreateDbContext();
|
||||||
|
|
||||||
|
return await db.CustomerGitRepoPolicies
|
||||||
|
.Where(p => p.CustomerId == customerId)
|
||||||
|
.OrderBy(p => p.UrlPattern)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CustomerGitRepoPolicy> AddRepoPolicyAsync(
|
||||||
|
Guid customerId, string urlPattern, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using ApplicationDbContext db = dbFactory.CreateDbContext();
|
||||||
|
|
||||||
|
CustomerGitRepoPolicy policy = new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
CustomerId = customerId,
|
||||||
|
UrlPattern = urlPattern.Trim()
|
||||||
|
};
|
||||||
|
|
||||||
|
db.CustomerGitRepoPolicies.Add(policy);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return policy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteRepoPolicyAsync(
|
||||||
|
Guid customerId, Guid policyId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using ApplicationDbContext db = dbFactory.CreateDbContext();
|
||||||
|
|
||||||
|
CustomerGitRepoPolicy? policy = await db.CustomerGitRepoPolicies
|
||||||
|
.FirstOrDefaultAsync(p => p.CustomerId == customerId && p.Id == policyId, ct);
|
||||||
|
|
||||||
|
if (policy is null) return false;
|
||||||
|
|
||||||
|
db.CustomerGitRepoPolicies.Remove(policy);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if <paramref name="url"/> matches at least one policy pattern.
|
||||||
|
/// '*' matches any characters except '/'; '**' matches across '/'.
|
||||||
|
/// </summary>
|
||||||
|
public static bool MatchesAnyPolicy(string url, IEnumerable<CustomerGitRepoPolicy> policies)
|
||||||
|
=> policies.Any(p => MatchesPattern(url, p.UrlPattern));
|
||||||
|
|
||||||
|
private static bool MatchesPattern(string url, string pattern)
|
||||||
|
{
|
||||||
|
// Convert the glob pattern to a regex so matching is handled by the
|
||||||
|
// battle-tested .NET regex engine rather than a hand-rolled walker.
|
||||||
|
// '**' → matches any sequence of characters, including '/'
|
||||||
|
// '*' → matches any sequence of characters within a single path segment (no '/')
|
||||||
|
// other → escaped and matched literally
|
||||||
|
StringBuilder rx = new("^");
|
||||||
|
int i = 0;
|
||||||
|
while (i < pattern.Length)
|
||||||
|
{
|
||||||
|
if (i + 1 < pattern.Length && pattern[i] == '*' && pattern[i + 1] == '*')
|
||||||
|
{
|
||||||
|
rx.Append(".*");
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
else if (pattern[i] == '*')
|
||||||
|
{
|
||||||
|
rx.Append("[^/]*");
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
rx.Append(Regex.Escape(pattern[i].ToString()));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rx.Append('$');
|
||||||
|
return Regex.IsMatch(url, rx.ToString(), RegexOptions.IgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Repository auto-provisioning ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds an existing <see cref="GitRepository"/> that was provisioned for the given
|
||||||
|
/// customer credential and URL, or creates one backed by that credential.
|
||||||
|
/// The repository reuses the credential's auth type and fetches secrets from the
|
||||||
|
/// customer credential vault entries at sync time — no secret copying needed.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<GitRepository> FindOrCreateRepositoryForCustomerAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid customerId,
|
||||||
|
string url,
|
||||||
|
Guid credentialId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using ApplicationDbContext db = dbFactory.CreateDbContext();
|
||||||
|
|
||||||
|
GitRepository? existing = await db.GitRepositories
|
||||||
|
.FirstOrDefaultAsync(r =>
|
||||||
|
r.TenantId == tenantId &&
|
||||||
|
r.CustomerGitCredentialId == credentialId &&
|
||||||
|
r.Url == url, ct);
|
||||||
|
|
||||||
|
if (existing is not null) return existing;
|
||||||
|
|
||||||
|
CustomerGitCredential? cred = await db.CustomerGitCredentials
|
||||||
|
.FirstOrDefaultAsync(c => c.CustomerId == customerId && c.Id == credentialId, ct);
|
||||||
|
|
||||||
|
if (cred is null)
|
||||||
|
throw new InvalidOperationException("Customer git credential not found.");
|
||||||
|
|
||||||
|
// Derive a short name from the URL path (e.g. "github.com/org/repo").
|
||||||
|
string name = DeriveRepoName(tenantId, url, db);
|
||||||
|
|
||||||
|
GitRepository repo = new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
TenantId = tenantId,
|
||||||
|
Name = name,
|
||||||
|
Url = url,
|
||||||
|
AuthType = cred.AuthType,
|
||||||
|
Username = cred.Username,
|
||||||
|
CustomerGitCredentialId = credentialId
|
||||||
|
};
|
||||||
|
|
||||||
|
db.GitRepositories.Add(repo);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DeriveRepoName(Guid tenantId, string url, ApplicationDbContext db)
|
||||||
|
{
|
||||||
|
// Strip scheme and trailing .git to get a human-readable label.
|
||||||
|
string path = url
|
||||||
|
.TrimEnd('/')
|
||||||
|
.Replace("https://", "")
|
||||||
|
.Replace("http://", "")
|
||||||
|
.Replace("git@", "")
|
||||||
|
.TrimEnd(".git".ToCharArray());
|
||||||
|
|
||||||
|
// Keep it short and unique within the tenant.
|
||||||
|
string candidate = path.Length > 120 ? path[^120..] : path;
|
||||||
|
string name = candidate;
|
||||||
|
int suffix = 2;
|
||||||
|
while (db.GitRepositories.Any(r => r.TenantId == tenantId && r.Name == name))
|
||||||
|
name = $"{candidate} ({suffix++})";
|
||||||
|
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Credentials ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<List<CustomerGitCredential>> GetCredentialsAsync(
|
||||||
|
Guid customerId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using ApplicationDbContext db = dbFactory.CreateDbContext();
|
||||||
|
|
||||||
|
return await db.CustomerGitCredentials
|
||||||
|
.Where(c => c.CustomerId == customerId)
|
||||||
|
.OrderBy(c => c.Name)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CustomerGitCredential> CreateCredentialAsync(
|
||||||
|
Guid customerId, Guid tenantId, string name,
|
||||||
|
GitAuthType authType, string? username = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using ApplicationDbContext db = dbFactory.CreateDbContext();
|
||||||
|
|
||||||
|
CustomerGitCredential credential = new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
CustomerId = customerId,
|
||||||
|
TenantId = tenantId,
|
||||||
|
Name = name.Trim(),
|
||||||
|
AuthType = authType,
|
||||||
|
Username = string.IsNullOrWhiteSpace(username) ? null : username.Trim()
|
||||||
|
};
|
||||||
|
|
||||||
|
db.CustomerGitCredentials.Add(credential);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return credential;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> UpdateCredentialAsync(
|
||||||
|
Guid customerId, Guid credentialId,
|
||||||
|
string name, GitAuthType authType, string? username,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using ApplicationDbContext db = dbFactory.CreateDbContext();
|
||||||
|
|
||||||
|
CustomerGitCredential? credential = await db.CustomerGitCredentials
|
||||||
|
.FirstOrDefaultAsync(c => c.CustomerId == customerId && c.Id == credentialId, ct);
|
||||||
|
|
||||||
|
if (credential is null) return false;
|
||||||
|
|
||||||
|
credential.Name = name.Trim();
|
||||||
|
credential.AuthType = authType;
|
||||||
|
credential.Username = string.IsNullOrWhiteSpace(username) ? null : username.Trim();
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteCredentialAsync(
|
||||||
|
Guid customerId, Guid credentialId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using ApplicationDbContext db = dbFactory.CreateDbContext();
|
||||||
|
|
||||||
|
CustomerGitCredential? credential = await db.CustomerGitCredentials
|
||||||
|
.FirstOrDefaultAsync(c => c.CustomerId == customerId && c.Id == credentialId, ct);
|
||||||
|
|
||||||
|
if (credential is null) return false;
|
||||||
|
|
||||||
|
db.CustomerGitCredentials.Remove(credential);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Credential secrets (stored encrypted in vault) ───────────────────────────
|
||||||
|
|
||||||
|
public Task SetPatAsync(Guid tenantId, Guid credentialId, string pat, CancellationToken ct = default)
|
||||||
|
=> vault.SetCustomerGitCredentialSecretAsync(tenantId, credentialId, "PAT", pat, ct);
|
||||||
|
|
||||||
|
public Task SetPasswordAsync(Guid tenantId, Guid credentialId, string password, CancellationToken ct = default)
|
||||||
|
=> vault.SetCustomerGitCredentialSecretAsync(tenantId, credentialId, "PASSWORD", password, ct);
|
||||||
|
|
||||||
|
public Task SetSshKeyAsync(Guid tenantId, Guid credentialId, string privateKeyPem, CancellationToken ct = default)
|
||||||
|
=> vault.SetCustomerGitCredentialSecretAsync(tenantId, credentialId, "SSH_PRIVATE_KEY", privateKeyPem, ct);
|
||||||
|
|
||||||
|
public Task<string?> GetPatAsync(Guid tenantId, Guid credentialId, CancellationToken ct = default)
|
||||||
|
=> vault.GetCustomerGitCredentialSecretValueAsync(tenantId, credentialId, "PAT", ct);
|
||||||
|
|
||||||
|
public Task<string?> GetPasswordAsync(Guid tenantId, Guid credentialId, CancellationToken ct = default)
|
||||||
|
=> vault.GetCustomerGitCredentialSecretValueAsync(tenantId, credentialId, "PASSWORD", ct);
|
||||||
|
|
||||||
|
public Task<string?> GetSshKeyAsync(Guid tenantId, Guid credentialId, CancellationToken ct = default)
|
||||||
|
=> vault.GetCustomerGitCredentialSecretValueAsync(tenantId, credentialId, "SSH_PRIVATE_KEY", ct);
|
||||||
|
}
|
||||||
@@ -122,6 +122,35 @@ public class DeploymentService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the git source settings for a deployment. Resets sync state so the
|
||||||
|
/// next sync cycle picks up the new configuration from scratch.
|
||||||
|
/// </summary>
|
||||||
|
public async Task UpdateGitSettingsAsync(
|
||||||
|
Guid deploymentId,
|
||||||
|
Guid? gitRepositoryId,
|
||||||
|
string? gitPath,
|
||||||
|
string? gitRevision,
|
||||||
|
bool gitAutoSync,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using ApplicationDbContext db = dbFactory.CreateDbContext();
|
||||||
|
|
||||||
|
AppDeployment? deployment = await db.AppDeployments.FindAsync([deploymentId], ct);
|
||||||
|
|
||||||
|
if (deployment is not null)
|
||||||
|
{
|
||||||
|
deployment.GitRepositoryId = gitRepositoryId;
|
||||||
|
deployment.GitPath = string.IsNullOrWhiteSpace(gitPath) ? null : gitPath.Trim();
|
||||||
|
deployment.GitRevision = string.IsNullOrWhiteSpace(gitRevision) ? null : gitRevision.Trim();
|
||||||
|
deployment.GitAutoSync = gitAutoSync;
|
||||||
|
// Reset sync state so the next cycle re-syncs from new settings.
|
||||||
|
deployment.GitLastSyncedCommit = null;
|
||||||
|
deployment.GitLastSyncedAt = null;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deletes a deployment and all its manifests and tracked resources.
|
/// Deletes a deployment and all its manifests and tracked resources.
|
||||||
/// EF cascade delete handles the child entities.
|
/// EF cascade delete handles the child entities.
|
||||||
|
|||||||
@@ -140,8 +140,9 @@ public class GitOperationsService(
|
|||||||
private async Task<CredentialsHandler> BuildPatCredentialsAsync(
|
private async Task<CredentialsHandler> BuildPatCredentialsAsync(
|
||||||
GitRepository repo, CancellationToken ct)
|
GitRepository repo, CancellationToken ct)
|
||||||
{
|
{
|
||||||
string? pat = await vault.GetGitRepositorySecretValueAsync(
|
string? pat = repo.CustomerGitCredentialId.HasValue
|
||||||
repo.TenantId, repo.Id, "PAT", ct);
|
? await vault.GetCustomerGitCredentialSecretValueAsync(repo.TenantId, repo.CustomerGitCredentialId.Value, "PAT", ct)
|
||||||
|
: await vault.GetGitRepositorySecretValueAsync(repo.TenantId, repo.Id, "PAT", ct);
|
||||||
|
|
||||||
if (pat is null)
|
if (pat is null)
|
||||||
throw new InvalidOperationException($"Git repo '{repo.Name}' has no PAT secret in vault.");
|
throw new InvalidOperationException($"Git repo '{repo.Name}' has no PAT secret in vault.");
|
||||||
@@ -156,8 +157,9 @@ public class GitOperationsService(
|
|||||||
private async Task<CredentialsHandler> BuildPasswordCredentialsAsync(
|
private async Task<CredentialsHandler> BuildPasswordCredentialsAsync(
|
||||||
GitRepository repo, CancellationToken ct)
|
GitRepository repo, CancellationToken ct)
|
||||||
{
|
{
|
||||||
string? password = await vault.GetGitRepositorySecretValueAsync(
|
string? password = repo.CustomerGitCredentialId.HasValue
|
||||||
repo.TenantId, repo.Id, "PASSWORD", ct);
|
? await vault.GetCustomerGitCredentialSecretValueAsync(repo.TenantId, repo.CustomerGitCredentialId.Value, "PASSWORD", ct)
|
||||||
|
: await vault.GetGitRepositorySecretValueAsync(repo.TenantId, repo.Id, "PASSWORD", ct);
|
||||||
|
|
||||||
if (password is null)
|
if (password is null)
|
||||||
throw new InvalidOperationException($"Git repo '{repo.Name}' has no PASSWORD secret in vault.");
|
throw new InvalidOperationException($"Git repo '{repo.Name}' has no PASSWORD secret in vault.");
|
||||||
@@ -175,8 +177,9 @@ public class GitOperationsService(
|
|||||||
private async Task EnsureClonedViaSshAsync(
|
private async Task EnsureClonedViaSshAsync(
|
||||||
GitRepository repo, string workDir, string revision, CancellationToken ct)
|
GitRepository repo, string workDir, string revision, CancellationToken ct)
|
||||||
{
|
{
|
||||||
string? privateKey = await vault.GetGitRepositorySecretValueAsync(
|
string? privateKey = repo.CustomerGitCredentialId.HasValue
|
||||||
repo.TenantId, repo.Id, "SSH_PRIVATE_KEY", ct);
|
? await vault.GetCustomerGitCredentialSecretValueAsync(repo.TenantId, repo.CustomerGitCredentialId.Value, "SSH_PRIVATE_KEY", ct)
|
||||||
|
: await vault.GetGitRepositorySecretValueAsync(repo.TenantId, repo.Id, "SSH_PRIVATE_KEY", ct);
|
||||||
|
|
||||||
if (privateKey is null)
|
if (privateKey is null)
|
||||||
throw new InvalidOperationException($"Git repo '{repo.Name}' has no SSH_PRIVATE_KEY secret in vault.");
|
throw new InvalidOperationException($"Git repo '{repo.Name}' has no SSH_PRIVATE_KEY secret in vault.");
|
||||||
@@ -266,19 +269,41 @@ public class GitOperationsService(
|
|||||||
Dictionary<string, string> result = [];
|
Dictionary<string, string> result = [];
|
||||||
string normalizedPath = path.Trim('/');
|
string normalizedPath = path.Trim('/');
|
||||||
|
|
||||||
Tree tree = commit.Tree;
|
// "." and "" both mean the repository root.
|
||||||
|
if (normalizedPath == ".") normalizedPath = string.Empty;
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(normalizedPath))
|
Tree tree = string.IsNullOrEmpty(normalizedPath)
|
||||||
{
|
? commit.Tree
|
||||||
TreeEntry? entry = commit.Tree[normalizedPath];
|
: NavigateToSubtree(commit.Tree, normalizedPath);
|
||||||
if (entry?.Target is Tree subtree)
|
|
||||||
tree = subtree;
|
|
||||||
}
|
|
||||||
|
|
||||||
ReadTreeRecursive(tree, string.Empty, result);
|
ReadTreeRecursive(tree, string.Empty, result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Walk the tree level-by-level for each path segment instead of relying on
|
||||||
|
// LibGit2Sharp's multi-level indexer, which returns null for bare repos.
|
||||||
|
private static Tree NavigateToSubtree(Tree root, string path)
|
||||||
|
{
|
||||||
|
Tree current = root;
|
||||||
|
foreach (string segment in path.Split('/'))
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(segment)) continue;
|
||||||
|
|
||||||
|
TreeEntry? entry = current.FirstOrDefault(e => e.Name == segment);
|
||||||
|
|
||||||
|
if (entry is null)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Git path '{path}': directory '{segment}' not found.");
|
||||||
|
|
||||||
|
if (entry.Target is not Tree subtree)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Git path '{path}': '{segment}' exists but is not a directory.");
|
||||||
|
|
||||||
|
current = subtree;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
private static void ReadTreeRecursive(Tree tree, string prefix, Dictionary<string, string> result)
|
private static void ReadTreeRecursive(Tree tree, string prefix, Dictionary<string, string> result)
|
||||||
{
|
{
|
||||||
foreach (TreeEntry entry in tree)
|
foreach (TreeEntry entry in tree)
|
||||||
|
|||||||
@@ -594,6 +594,96 @@ public class KubernetesOperationsService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────── Deployment-level cluster deletion ────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes a deployment's resources from the cluster without touching the database.
|
||||||
|
/// For HelmChart/GitHelm deployments this runs "helm uninstall"; for Yaml/Manual/GitSync
|
||||||
|
/// deployments it runs "kubectl delete -f" against the stored manifests. Callers should
|
||||||
|
/// delete the DB record separately once this succeeds (or ignore the cluster result for
|
||||||
|
/// an unregister-only flow).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<KubernetesOperationResult<string>> DeleteDeploymentFromClusterAsync(
|
||||||
|
Guid deploymentId, string? performedBy = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
AppDeployment? deployment = await GetDeploymentWithClusterAsync(deploymentId, ct);
|
||||||
|
|
||||||
|
if (deployment is null)
|
||||||
|
return KubernetesOperationResult<string>.Failure("Deployment not found.");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(deployment.Cluster?.Kubeconfig))
|
||||||
|
return KubernetesOperationResult<string>.Failure(
|
||||||
|
"Cluster has no kubeconfig configured. Upload a kubeconfig to enable cluster operations.");
|
||||||
|
|
||||||
|
string tempKubeconfig = Path.Combine(Path.GetTempPath(), $"entkube-{Guid.NewGuid()}.kubeconfig");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await File.WriteAllTextAsync(tempKubeconfig, deployment.Cluster.Kubeconfig, ct);
|
||||||
|
|
||||||
|
HelmExecutionResult result;
|
||||||
|
|
||||||
|
if (deployment.Type is DeploymentType.HelmChart or DeploymentType.GitHelm)
|
||||||
|
{
|
||||||
|
string releaseName = ToHelmReleaseName(deployment.Name);
|
||||||
|
result = await RunCliAsync("helm",
|
||||||
|
$"uninstall {releaseName} --namespace {deployment.Namespace} --kubeconfig {tempKubeconfig}",
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Load stored manifests ordered by sort order.
|
||||||
|
using ApplicationDbContext db = dbFactory.CreateDbContext();
|
||||||
|
List<DeploymentManifest> manifests = await db.DeploymentManifests
|
||||||
|
.Where(m => m.DeploymentId == deploymentId)
|
||||||
|
.OrderBy(m => m.SortOrder)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
if (manifests.Count == 0)
|
||||||
|
return KubernetesOperationResult<string>.Failure(
|
||||||
|
"No manifests found. Resources may have been applied outside of EntKube and must be removed manually.");
|
||||||
|
|
||||||
|
string combined = string.Join("\n---\n", manifests.Select(m => m.YamlContent));
|
||||||
|
string tempManifest = Path.Combine(Path.GetTempPath(), $"entkube-manifest-{Guid.NewGuid()}.yaml");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await File.WriteAllTextAsync(tempManifest, combined, ct);
|
||||||
|
result = await RunCliAsync("kubectl",
|
||||||
|
$"delete -f {tempManifest} --kubeconfig {tempKubeconfig} --ignore-not-found",
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(tempManifest)) File.Delete(tempManifest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.Success)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Deployment {Name} ({DeploymentId}) cluster resources deleted by {User}",
|
||||||
|
deployment.Name, deploymentId, performedBy ?? "system");
|
||||||
|
await auditService.RecordAsync(deploymentId, "DeleteFromCluster", "Deployment",
|
||||||
|
deployment.Name, performedBy: performedBy, ct: ct);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Cluster deletion failed for deployment {DeploymentId}: {Output}",
|
||||||
|
deploymentId, result.Output);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Success
|
||||||
|
? KubernetesOperationResult<string>.Success(result.Output)
|
||||||
|
: KubernetesOperationResult<string>.Failure(result.Output);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(tempKubeconfig)) File.Delete(tempKubeconfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ──────── YAML / Manual apply ────────
|
// ──────── YAML / Manual apply ────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -717,14 +807,22 @@ public class KubernetesOperationsService(
|
|||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(deployment.HelmRepoUrl))
|
if (!string.IsNullOrWhiteSpace(deployment.HelmRepoUrl))
|
||||||
{
|
{
|
||||||
string repoAlias = $"entkube-{releaseName}";
|
if (deployment.HelmRepoUrl.StartsWith("oci://", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// OCI registries cannot be added with "helm repo add" — use the full URI directly.
|
||||||
|
chartRef = $"{deployment.HelmRepoUrl.TrimEnd('/')}/{deployment.HelmChartName}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
string repoAlias = $"entkube-{releaseName}";
|
||||||
|
|
||||||
await RunCliAsync("helm",
|
await RunCliAsync("helm",
|
||||||
$"repo add {repoAlias} {deployment.HelmRepoUrl} --force-update --kubeconfig {tempKubeconfig}", ct);
|
$"repo add {repoAlias} {deployment.HelmRepoUrl} --force-update --kubeconfig {tempKubeconfig}", ct);
|
||||||
await RunCliAsync("helm",
|
await RunCliAsync("helm",
|
||||||
$"repo update {repoAlias} --kubeconfig {tempKubeconfig}", ct);
|
$"repo update {repoAlias} --kubeconfig {tempKubeconfig}", ct);
|
||||||
|
|
||||||
chartRef = $"{repoAlias}/{deployment.HelmChartName}";
|
chartRef = $"{repoAlias}/{deployment.HelmChartName}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the main helm upgrade --install command.
|
// Build the main helm upgrade --install command.
|
||||||
@@ -1041,13 +1139,37 @@ public class KubernetesOperationsService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Return root nodes sorted by display order then name ───────
|
// ── Build full root list ──────────────────────────────────────
|
||||||
List<DeploymentResource> roots = byUid.Values
|
List<DeploymentResource> roots = byUid.Values
|
||||||
.Where(r => r.ParentResourceId == null)
|
.Where(r => r.ParentResourceId == null)
|
||||||
.OrderBy(r => KindDisplayOrder(r.Kind))
|
.OrderBy(r => KindDisplayOrder(r.Kind))
|
||||||
.ThenBy(r => r.Name)
|
.ThenBy(r => r.Name)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
// ── Filter roots to this specific deployment ──────────────────
|
||||||
|
// If the deployment has manifests (Yaml/Manual/Git types), use
|
||||||
|
// (Kind, Name) pairs from those manifests as an allow-list so that
|
||||||
|
// resources from other workloads sharing the same namespace are excluded.
|
||||||
|
// For HelmChart/GitHelm deployments (no manifest rows), fall back to
|
||||||
|
// release-name prefix matching since Helm names resources after the release.
|
||||||
|
HashSet<(string Kind, string Name)>? manifestFilter =
|
||||||
|
await LoadManifestFilterAsync(deploymentId, ct);
|
||||||
|
|
||||||
|
if (manifestFilter is { Count: > 0 })
|
||||||
|
{
|
||||||
|
roots = roots.Where(r => manifestFilter.Contains((r.Kind, r.Name))).ToList();
|
||||||
|
}
|
||||||
|
else if (deployment.Type is DeploymentType.HelmChart or DeploymentType.GitHelm)
|
||||||
|
{
|
||||||
|
string release = deployment.Name;
|
||||||
|
roots = roots
|
||||||
|
.Where(r => r.Name == release
|
||||||
|
|| r.Name.StartsWith(release + "-", StringComparison.Ordinal))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
// else: no manifests and not Helm — show all namespace resources (e.g. a
|
||||||
|
// brand-new Manual deployment whose manifests haven't been applied yet).
|
||||||
|
|
||||||
SortChildResources(roots);
|
SortChildResources(roots);
|
||||||
|
|
||||||
return KubernetesOperationResult<List<DeploymentResource>>.Success(roots);
|
return KubernetesOperationResult<List<DeploymentResource>>.Success(roots);
|
||||||
@@ -1132,6 +1254,25 @@ public class KubernetesOperationsService(
|
|||||||
return (SyncStatus.Synced, health);
|
return (SyncStatus.Synced, health);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a set of (Kind, Name) pairs from the deployment's stored manifests,
|
||||||
|
/// used to filter live namespace resources to just those belonging to this deployment.
|
||||||
|
/// Returns null if the deployment has no manifests (Helm or new Manual deployment).
|
||||||
|
/// </summary>
|
||||||
|
private async Task<HashSet<(string Kind, string Name)>?> LoadManifestFilterAsync(
|
||||||
|
Guid deploymentId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using ApplicationDbContext db = dbFactory.CreateDbContext();
|
||||||
|
|
||||||
|
List<(string Kind, string Name)> pairs = await db.DeploymentManifests
|
||||||
|
.Where(m => m.DeploymentId == deploymentId)
|
||||||
|
.Select(m => new { m.Kind, m.Name })
|
||||||
|
.ToListAsync(ct)
|
||||||
|
.ContinueWith(t => t.Result.Select(m => (m.Kind, m.Name)).ToList(), ct);
|
||||||
|
|
||||||
|
return pairs.Count == 0 ? null : pairs.ToHashSet();
|
||||||
|
}
|
||||||
|
|
||||||
private static void SortChildResources(IEnumerable<DeploymentResource> nodes)
|
private static void SortChildResources(IEnumerable<DeploymentResource> nodes)
|
||||||
{
|
{
|
||||||
foreach (DeploymentResource node in nodes)
|
foreach (DeploymentResource node in nodes)
|
||||||
|
|||||||
@@ -7,21 +7,17 @@ using k8s.Models;
|
|||||||
namespace EntKube.Web.Services;
|
namespace EntKube.Web.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Routes Amazon S3 SDK requests through the Kubernetes API server pod-proxy so that
|
/// Routes Amazon S3 SDK requests through the Kubernetes API server service proxy so that
|
||||||
/// cluster-internal MinIO pods are reachable from EntKube.Web.
|
/// cluster-internal MinIO pods are reachable from EntKube.Web (same mechanism as kubectl proxy).
|
||||||
///
|
///
|
||||||
/// The core challenge: AWS Signature V4 includes the Host header. The K8s pod proxy
|
/// AWS Signature V4 includes the Host header. The proxy handler signs requests for
|
||||||
/// rewrites Host to podIP:port when forwarding to the pod, causing a signature mismatch
|
/// podIP:podPort (what MinIO actually receives) and rewrites the URL to the K8s service-proxy
|
||||||
/// when the SDK signs for the API server hostname.
|
/// path before forwarding. The signature stays valid because K8s forwards Host=podIP:podPort
|
||||||
|
/// to the backend pod.
|
||||||
///
|
///
|
||||||
/// Solution: sign with Host = podIP:port (what MinIO actually receives), then intercept
|
/// Multiple service candidates are tried in priority order (headless → ClusterIP).
|
||||||
/// the request in KubernetesProxyHandler and rewrite the URL to the K8s pod-proxy path.
|
/// On a 5xx from K8s the handler advances to the next candidate transparently, so the
|
||||||
/// The signature remains valid; K8s routes based on the URL path, not the Host header.
|
/// caller never needs to know which service/port ultimately worked.
|
||||||
///
|
|
||||||
/// SDK signs request for: http://podIP:9000/bucket?cors
|
|
||||||
/// Handler rewrites to: https://k8s-api/api/v1/namespaces/{ns}/pods/{pod}:9000/proxy/bucket?cors
|
|
||||||
/// K8s forwards to pod: http://podIP:9000/bucket?cors (Host = podIP:9000)
|
|
||||||
/// MinIO validates: Host matches signed value ✓
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class KubernetesS3Proxy
|
public static class KubernetesS3Proxy
|
||||||
{
|
{
|
||||||
@@ -48,8 +44,12 @@ public static class KubernetesS3Proxy
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves a ready MinIO pod, builds a correctly-signed S3 client that routes
|
/// Resolves a ready MinIO pod, builds a correctly-signed S3 client that routes
|
||||||
/// through the Kubernetes API server pod proxy, and returns both as a single
|
/// through the Kubernetes API server service proxy, and returns both as a single
|
||||||
/// disposable. Returns null if the endpoint URL cannot be parsed.
|
/// disposable. Returns null if the endpoint URL cannot be parsed as a cluster-internal URL.
|
||||||
|
///
|
||||||
|
/// The underlying handler tries multiple K8s service candidates in priority order
|
||||||
|
/// (headless service first, then ClusterIP). On a 5xx from K8s it automatically
|
||||||
|
/// advances to the next candidate so callers do not need retry logic.
|
||||||
///
|
///
|
||||||
/// Usage:
|
/// Usage:
|
||||||
/// using var proxied = await KubernetesS3Proxy.OpenAsync(kubeconfig, endpoint, key, secret);
|
/// using var proxied = await KubernetesS3Proxy.OpenAsync(kubeconfig, endpoint, key, secret);
|
||||||
@@ -74,41 +74,33 @@ public static class KubernetesS3Proxy
|
|||||||
(string podName, string podIP) = await FindReadyPodAsync(
|
(string podName, string podIP) = await FindReadyPodAsync(
|
||||||
k8s, parsed.Value.Namespace, parsed.Value.Service, ct);
|
k8s, parsed.Value.Namespace, parsed.Value.Service, ct);
|
||||||
|
|
||||||
// Use the K8s SERVICE proxy instead of the pod proxy.
|
|
||||||
// Pod proxy requires the API server to make a direct TCP connection to the pod IP,
|
|
||||||
// which fails with 503 in clusters where the control plane cannot reach pod IPs
|
|
||||||
// (common in managed/cloud K8s environments).
|
|
||||||
// Service proxy routes through kube-proxy/iptables — the same path as normal
|
|
||||||
// in-cluster traffic — and works regardless of control-plane ↔ pod connectivity.
|
|
||||||
//
|
|
||||||
// Look up the real service port (e.g. 443 for TLS MinIO, 9000 for plain HTTP).
|
|
||||||
// The service port in the URL must match a port the service actually exposes.
|
|
||||||
bool endpointTls = internalEndpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase);
|
bool endpointTls = internalEndpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase);
|
||||||
(int svcPort, bool svcTls) = await GetServicePortAsync(
|
string svcSchemePrefix = endpointTls ? "https:" : "";
|
||||||
k8s, parsed.Value.Namespace, parsed.Value.Service, parsed.Value.Port, endpointTls, ct);
|
string k8sBase = k8s.BaseUri.ToString().TrimEnd('/');
|
||||||
|
string ns = parsed.Value.Namespace;
|
||||||
|
|
||||||
string svcSchemePrefix = svcTls ? "https:" : "";
|
// Build an ordered list of proxy-base URLs to try. For MinIO operator the
|
||||||
|
// headless service ({tenant}-hl) directly exposes the pod port without
|
||||||
|
// ClusterIP translation; the main service ({tenant}) maps 443→9000.
|
||||||
|
// The retrying handler will try them in order and skip 5xx responses.
|
||||||
|
IReadOnlyList<(string SvcName, int SvcPort)> candidates =
|
||||||
|
await ResolveServiceCandidatesAsync(k8s, ns, parsed.Value.Service, parsed.Value.Port, ct);
|
||||||
|
|
||||||
// K8s service proxy URL: the scheme prefix tells K8s whether to use TLS when
|
List<string> proxyCandidates = [..candidates
|
||||||
// connecting to the backend pod (InsecureSkipVerify=true for self-signed certs).
|
.Select(c => $"{k8sBase}/api/v1/namespaces/{ns}/services/{svcSchemePrefix}{c.SvcName}:{c.SvcPort}/proxy")];
|
||||||
string proxyBase = k8s.BaseUri.ToString().TrimEnd('/')
|
|
||||||
+ $"/api/v1/namespaces/{parsed.Value.Namespace}/services/{svcSchemePrefix}{parsed.Value.Service}:{svcPort}/proxy";
|
|
||||||
|
|
||||||
// AWS Signature V4 is signed for the host MinIO actually receives.
|
// Sign requests for the host MinIO actually sees: podIP:podPort.
|
||||||
// K8s service proxy creates the backend request to podIP:podPort (from the Endpoints
|
string signingScheme = endpointTls ? "https" : "http";
|
||||||
// resource), so MinIO sees Host = podIP:podPort — same as pod proxy.
|
|
||||||
string signingScheme = svcTls ? "https" : "http";
|
|
||||||
string signingBaseUrl = $"{signingScheme}://{podIP}:{parsed.Value.Port}";
|
string signingBaseUrl = $"{signingScheme}://{podIP}:{parsed.Value.Port}";
|
||||||
|
|
||||||
// Custom handler intercepts SDK requests (addressed to podIP:port) and reroutes to K8s.
|
HttpClient proxyHttpClient = new(new RetryingKubernetesProxyHandler(k8s.HttpClient, proxyCandidates));
|
||||||
HttpClient proxyHttpClient = new(new KubernetesProxyHandler(k8s.HttpClient, proxyBase));
|
|
||||||
|
|
||||||
AmazonS3Config config = new()
|
AmazonS3Config config = new()
|
||||||
{
|
{
|
||||||
ServiceURL = signingBaseUrl,
|
ServiceURL = signingBaseUrl,
|
||||||
ForcePathStyle = true,
|
ForcePathStyle = true,
|
||||||
AuthenticationRegion = region,
|
AuthenticationRegion = region,
|
||||||
UseHttp = !svcTls, // must match signingBaseUrl scheme
|
UseHttp = !endpointTls,
|
||||||
Timeout = TimeSpan.FromSeconds(30),
|
Timeout = TimeSpan.FromSeconds(30),
|
||||||
MaxErrorRetry = 0,
|
MaxErrorRetry = 0,
|
||||||
HttpClientFactory = new SingletonHttpClientFactory(proxyHttpClient)
|
HttpClientFactory = new SingletonHttpClientFactory(proxyHttpClient)
|
||||||
@@ -118,39 +110,68 @@ public static class KubernetesS3Proxy
|
|||||||
return new ProxiedS3Client(k8s, s3, proxyHttpClient);
|
return new ProxiedS3Client(k8s, s3, proxyHttpClient);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Service Port Detection ──
|
// ── Service Candidate Resolution ──
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Looks up the K8s service to find the port that maps to the given pod port.
|
/// Returns an ordered list of (serviceName, servicePort) candidates to try as
|
||||||
/// Returns (servicePort=443, tls=true) for TLS MinIO (operator default),
|
/// K8s service-proxy targets for the given pod port.
|
||||||
/// or (servicePort=podPort, tls=false) if no 443 mapping is found.
|
///
|
||||||
|
/// Priority:
|
||||||
|
/// 1. Headless service ({svc}-hl) with a port that directly equals podPort
|
||||||
|
/// 2. Headless service ({svc}-hl) with a port whose targetPort resolves to podPort
|
||||||
|
/// 3. Main service ({svc}) with a port whose targetPort resolves to podPort
|
||||||
|
/// 4. Main service ({svc}) with a port that directly equals podPort
|
||||||
|
/// 5. Main service ({svc}) first port as last resort
|
||||||
|
/// 6. Hard fallback: ({svc}, endpointTls ? 443 : podPort)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="endpointTls">Whether the stored endpoint uses https:// — determines the
|
private static async Task<IReadOnlyList<(string SvcName, int SvcPort)>> ResolveServiceCandidatesAsync(
|
||||||
/// service proxy scheme prefix (https: tells K8s to use TLS for the backend connection).
|
Kubernetes k8s, string ns, string svc, int podPort, CancellationToken ct)
|
||||||
/// Service port 443 does NOT imply TLS; the endpoint scheme is the source of truth.</param>
|
|
||||||
private static async Task<(int ServicePort, bool Tls)> GetServicePortAsync(
|
|
||||||
Kubernetes k8s, string ns, string svc, int podPort, bool endpointTls, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
|
List<(string, int)> results = [];
|
||||||
|
|
||||||
|
// 1 & 2: Headless service.
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
V1Service service = await k8s.CoreV1.ReadNamespacedServiceAsync(svc, ns, cancellationToken: ct);
|
V1Service hl = await k8s.CoreV1.ReadNamespacedServiceAsync($"{svc}-hl", ns, cancellationToken: ct);
|
||||||
|
IList<V1ServicePort>? hlPorts = hl.Spec?.Ports;
|
||||||
|
|
||||||
// Find the service port that targets our pod port.
|
V1ServicePort? direct = hlPorts?.FirstOrDefault(p => p.Port == podPort);
|
||||||
V1ServicePort? match = service.Spec?.Ports?.FirstOrDefault(p =>
|
if (direct is not null)
|
||||||
p.TargetPort?.Value == podPort.ToString()
|
results.Add(($"{svc}-hl", direct.Port));
|
||||||
|| (p.TargetPort?.Value == null && p.Port == podPort));
|
else
|
||||||
|
{
|
||||||
if (match is not null)
|
V1ServicePort? byTarget = hlPorts?.FirstOrDefault(p => p.TargetPort?.Value == podPort.ToString());
|
||||||
return (match.Port, endpointTls); // TLS from endpoint scheme, not service port
|
if (byTarget is not null)
|
||||||
|
results.Add(($"{svc}-hl", byTarget.Port));
|
||||||
// Fallback: use the first exposed port.
|
}
|
||||||
int first = service.Spec?.Ports?.FirstOrDefault()?.Port ?? podPort;
|
|
||||||
return (first, endpointTls);
|
|
||||||
}
|
}
|
||||||
catch
|
catch { }
|
||||||
|
|
||||||
|
// 3, 4 & 5: Main ClusterIP service.
|
||||||
|
try
|
||||||
{
|
{
|
||||||
return (podPort, endpointTls);
|
V1Service main = await k8s.CoreV1.ReadNamespacedServiceAsync(svc, ns, cancellationToken: ct);
|
||||||
|
IList<V1ServicePort>? ports = main.Spec?.Ports;
|
||||||
|
|
||||||
|
V1ServicePort? byTarget = ports?.FirstOrDefault(p => p.TargetPort?.Value == podPort.ToString());
|
||||||
|
if (byTarget is not null)
|
||||||
|
{
|
||||||
|
results.Add((svc, byTarget.Port));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
V1ServicePort? direct = ports?.FirstOrDefault(p => p.Port == podPort);
|
||||||
|
int fallbackPort = direct?.Port ?? ports?.FirstOrDefault()?.Port ?? podPort;
|
||||||
|
results.Add((svc, fallbackPort));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
// Hard fallback so there is always at least one candidate.
|
||||||
|
if (results.Count == 0)
|
||||||
|
results.Add((svc, podPort));
|
||||||
|
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Pod Discovery ──
|
// ── Pod Discovery ──
|
||||||
@@ -187,63 +208,79 @@ public static class KubernetesS3Proxy
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Proxy Handler ──
|
// ── Retrying Proxy Handler ──
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Intercepts Amazon SDK requests addressed to http://podIP:port/path and
|
/// Intercepts Amazon SDK requests and rewrites the URL to successive K8s service-proxy
|
||||||
/// rewrites the URL to the K8s pod-proxy endpoint before forwarding via the
|
/// candidates until one succeeds (non-5xx) or all are exhausted.
|
||||||
/// K8s-authenticated HttpClient. All headers (including Authorization) are
|
///
|
||||||
/// preserved so AWS Signature V4 reaches MinIO intact.
|
/// - Never throws: on total failure the last 5xx response is returned so the AWS SDK
|
||||||
|
/// can produce a proper AmazonServiceException that callers can catch.
|
||||||
|
/// - Content bodies are buffered before the first attempt so the stream can be
|
||||||
|
/// replayed on each retry.
|
||||||
|
/// - Host is stripped from forwarded headers so the K8s API server does not reject
|
||||||
|
/// the connection for a hostname mismatch.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed class KubernetesProxyHandler(HttpClient k8sClient, string proxyBase) : HttpMessageHandler
|
private sealed class RetryingKubernetesProxyHandler(
|
||||||
|
HttpClient k8sClient,
|
||||||
|
IReadOnlyList<string> proxyCandidates) : HttpMessageHandler
|
||||||
{
|
{
|
||||||
private readonly string _proxyBase = proxyBase.TrimEnd('/');
|
|
||||||
|
|
||||||
protected override async Task<HttpResponseMessage> SendAsync(
|
protected override async Task<HttpResponseMessage> SendAsync(
|
||||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
string pathAndQuery = request.RequestUri?.PathAndQuery ?? "/";
|
// Buffer body upfront so it can be resent on each candidate retry.
|
||||||
string targetUrl = _proxyBase + pathAndQuery;
|
byte[]? bodyBytes = null;
|
||||||
|
IEnumerable<KeyValuePair<string, IEnumerable<string>>>? contentHeaders = null;
|
||||||
HttpRequestMessage proxied = new(request.Method, targetUrl);
|
|
||||||
|
|
||||||
// Copy all headers except Host.
|
|
||||||
// - Authorization (AWS Signature V4), X-Amz-Date, etc. must reach MinIO unchanged.
|
|
||||||
// - Host must NOT be copied: the AWS SDK sets Host=podIP:port for signing, but
|
|
||||||
// the K8s API server validates Host against its own hostname and closes the
|
|
||||||
// connection if it doesn't match ("unexpected EOF" / SSL error).
|
|
||||||
// K8s will set Host=podIP:port itself when forwarding to the pod, which is
|
|
||||||
// exactly what MinIO needs to validate the signature.
|
|
||||||
foreach (KeyValuePair<string, IEnumerable<string>> h in request.Headers)
|
|
||||||
{
|
|
||||||
if (string.Equals(h.Key, "Host", StringComparison.OrdinalIgnoreCase))
|
|
||||||
continue;
|
|
||||||
proxied.Headers.TryAddWithoutValidation(h.Key, h.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.Content is not null)
|
if (request.Content is not null)
|
||||||
proxied.Content = request.Content;
|
|
||||||
|
|
||||||
HttpResponseMessage response = await k8sClient.SendAsync(proxied, cancellationToken);
|
|
||||||
|
|
||||||
// For 5xx errors from K8s, read the body so we can surface the real reason
|
|
||||||
// (e.g. "error trying to reach service: ...", certificate errors, RBAC, etc.)
|
|
||||||
// rather than a generic "ServiceUnavailable" from the Amazon SDK.
|
|
||||||
if ((int)response.StatusCode >= 500)
|
|
||||||
{
|
{
|
||||||
string body = await response.Content.ReadAsStringAsync(cancellationToken);
|
bodyBytes = await request.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||||
string snippet = body.Length > 600 ? body[..600] : body;
|
contentHeaders = [..request.Content.Headers];
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"K8s proxy returned {(int)response.StatusCode} for {targetUrl} — {snippet}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
HttpResponseMessage? lastResponse = null;
|
||||||
|
|
||||||
|
foreach (string proxyBase in proxyCandidates)
|
||||||
|
{
|
||||||
|
string pathAndQuery = request.RequestUri?.PathAndQuery ?? "/";
|
||||||
|
string targetUrl = proxyBase.TrimEnd('/') + pathAndQuery;
|
||||||
|
|
||||||
|
HttpRequestMessage proxied = new(request.Method, targetUrl);
|
||||||
|
|
||||||
|
// Copy all headers except Host (K8s API validates Host against its own name).
|
||||||
|
foreach (KeyValuePair<string, IEnumerable<string>> h in request.Headers)
|
||||||
|
{
|
||||||
|
if (string.Equals(h.Key, "Host", StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
proxied.Headers.TryAddWithoutValidation(h.Key, h.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bodyBytes is not null)
|
||||||
|
{
|
||||||
|
ByteArrayContent body = new(bodyBytes);
|
||||||
|
foreach (KeyValuePair<string, IEnumerable<string>> ch in contentHeaders!)
|
||||||
|
body.Headers.TryAddWithoutValidation(ch.Key, ch.Value);
|
||||||
|
proxied.Content = body;
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpResponseMessage response = await k8sClient.SendAsync(proxied, cancellationToken);
|
||||||
|
|
||||||
|
if ((int)response.StatusCode < 500)
|
||||||
|
return response;
|
||||||
|
|
||||||
|
// Dispose the previous failing response before moving to the next candidate.
|
||||||
|
lastResponse?.Dispose();
|
||||||
|
lastResponse = response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All candidates exhausted — return the last 5xx response so the AWS SDK
|
||||||
|
// raises a proper AmazonServiceException (catchable by callers).
|
||||||
|
return lastResponse!;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Amazon SDK HttpClientFactory injection ──
|
// ── Amazon SDK HttpClientFactory injection ──
|
||||||
|
|
||||||
private sealed class SingletonHttpClientFactory(HttpClient client) : Amazon.Runtime.HttpClientFactory
|
private sealed class SingletonHttpClientFactory(HttpClient client) : HttpClientFactory
|
||||||
{
|
{
|
||||||
public override HttpClient CreateHttpClient(IClientConfig clientConfig) => client;
|
public override HttpClient CreateHttpClient(IClientConfig clientConfig) => client;
|
||||||
public override bool UseSDKHttpClientCaching(IClientConfig clientConfig) => false;
|
public override bool UseSDKHttpClientCaching(IClientConfig clientConfig) => false;
|
||||||
@@ -252,8 +289,7 @@ public static class KubernetesS3Proxy
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A paired K8s client + proxied S3 client. Dispose to release all three resources
|
/// A paired K8s client + proxied S3 client. Dispose to release all resources.
|
||||||
/// in the correct order (S3 → proxyHttpClient → K8s).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ProxiedS3Client(Kubernetes k8s, AmazonS3Client s3, HttpClient proxyHttpClient) : IDisposable
|
public sealed class ProxiedS3Client(Kubernetes k8s, AmazonS3Client s3, HttpClient proxyHttpClient) : IDisposable
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public class LokiConfig
|
|||||||
public string Namespace { get; set; } = "monitoring";
|
public string Namespace { get; set; } = "monitoring";
|
||||||
public string ServiceName { get; set; } = "loki";
|
public string ServiceName { get; set; } = "loki";
|
||||||
public int ServicePort { get; set; } = 3100;
|
public int ServicePort { get; set; } = 3100;
|
||||||
|
public Guid? StorageLinkId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class LokiLogStream
|
public class LokiLogStream
|
||||||
@@ -158,6 +159,12 @@ public class LokiService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
component.HelmValues = YamlFormMerger.MergeFormValues(component.HelmValues ?? "", s3Values);
|
component.HelmValues = YamlFormMerger.MergeFormValues(component.HelmValues ?? "", s3Values);
|
||||||
|
|
||||||
|
// Persist the storage link ID in Configuration so the UI can re-populate the dropdown.
|
||||||
|
LokiConfig storedConfig = TryDeserializeConfig(component.Configuration) ?? new LokiConfig();
|
||||||
|
storedConfig.StorageLinkId = storageLinkId;
|
||||||
|
component.Configuration = JsonSerializer.Serialize(storedConfig);
|
||||||
|
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
// Store credentials as vault secrets — injected at install time via hidden catalog fields
|
// Store credentials as vault secrets — injected at install time via hidden catalog fields
|
||||||
@@ -177,6 +184,28 @@ public class LokiService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Guid?> GetStorageLinkIdForComponentAsync(
|
||||||
|
Guid tenantId, Guid clusterComponentId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using ApplicationDbContext db = dbFactory.CreateDbContext();
|
||||||
|
ClusterComponent? component = await db.ClusterComponents
|
||||||
|
.Include(c => c.Cluster)
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == clusterComponentId && c.Cluster.TenantId == tenantId, ct);
|
||||||
|
if (component is null) return null;
|
||||||
|
return TryDeserializeConfig(component.Configuration)?.StorageLinkId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LokiConfig? TryDeserializeConfig(string? json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<LokiConfig>(json,
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
// ──────── Internal ────────
|
// ──────── Internal ────────
|
||||||
|
|
||||||
private static string BuildLogQL(string ns, string? pod, string? container, string? text)
|
private static string BuildLogQL(string ns, string? pod, string? container, string? text)
|
||||||
@@ -305,17 +334,11 @@ public class LokiService(
|
|||||||
? releaseName : $"{releaseName}-loki"
|
? releaseName : $"{releaseName}-loki"
|
||||||
};
|
};
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(component.Configuration)) return derived;
|
LokiConfig? explicit_ = TryDeserializeConfig(component.Configuration);
|
||||||
try
|
if (explicit_ is null) return derived;
|
||||||
{
|
if (!string.IsNullOrWhiteSpace(explicit_.Namespace)) derived.Namespace = explicit_.Namespace;
|
||||||
LokiConfig? explicit_ = JsonSerializer.Deserialize<LokiConfig>(component.Configuration,
|
if (!string.IsNullOrWhiteSpace(explicit_.ServiceName)) derived.ServiceName = explicit_.ServiceName;
|
||||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
if (explicit_.ServicePort != 3100) derived.ServicePort = explicit_.ServicePort;
|
||||||
if (explicit_ is null) return derived;
|
|
||||||
if (!string.IsNullOrWhiteSpace(explicit_.Namespace)) derived.Namespace = explicit_.Namespace;
|
|
||||||
if (!string.IsNullOrWhiteSpace(explicit_.ServiceName)) derived.ServiceName = explicit_.ServiceName;
|
|
||||||
if (explicit_.ServicePort != 3100) derived.ServicePort = explicit_.ServicePort;
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
return derived;
|
return derived;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1110,6 +1110,37 @@ public class MongoService(
|
|||||||
List<VaultSecret> vaultSecrets = await vaultService.GetMongoDatabaseSecretsAsync(tenantId, databaseId, ct);
|
List<VaultSecret> vaultSecrets = await vaultService.GetMongoDatabaseSecretsAsync(tenantId, databaseId, ct);
|
||||||
foreach (VaultSecret secret in vaultSecrets)
|
foreach (VaultSecret secret in vaultSecrets)
|
||||||
await vaultService.ConfigureKubernetesSyncAsync(secret.Id, true, secretName, mongo.Namespace, ct);
|
await vaultService.ConfigureKubernetesSyncAsync(secret.Id, true, secretName, mongo.Namespace, ct);
|
||||||
|
|
||||||
|
// Propagate to every app deployment bound to this database.
|
||||||
|
List<DatabaseBinding> bindings = await db.DatabaseBindings
|
||||||
|
.Include(b => b.AppDeployment)
|
||||||
|
.ThenInclude(d => d.Cluster)
|
||||||
|
.Where(b => b.MongoDatabaseId == databaseId && b.SyncEnabled)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
foreach (DatabaseBinding binding in bindings)
|
||||||
|
{
|
||||||
|
string bindingKubeconfig = binding.AppDeployment.Cluster.Kubeconfig!;
|
||||||
|
string ns = binding.AppDeployment.Namespace;
|
||||||
|
|
||||||
|
StringBuilder bsb = new();
|
||||||
|
bsb.AppendLine("apiVersion: v1");
|
||||||
|
bsb.AppendLine("kind: Secret");
|
||||||
|
bsb.AppendLine("metadata:");
|
||||||
|
bsb.AppendLine($" name: {binding.KubernetesSecretName}");
|
||||||
|
bsb.AppendLine($" namespace: {ns}");
|
||||||
|
bsb.AppendLine("type: Opaque");
|
||||||
|
bsb.AppendLine("stringData:");
|
||||||
|
foreach (KeyValuePair<string, string> kv in credentials)
|
||||||
|
bsb.AppendLine($" {kv.Key}: \"{kv.Value.Replace("\"", "\\\"")}\"");
|
||||||
|
|
||||||
|
await k8sFactory.EnsureNamespaceAsync(ns, bindingKubeconfig, ct);
|
||||||
|
await k8sFactory.ApplyManifestAsync(bsb.ToString(), bindingKubeconfig, ct);
|
||||||
|
|
||||||
|
binding.LastSyncedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1131,7 +1162,7 @@ public class MongoService(
|
|||||||
?? throw new InvalidOperationException("Database not found.");
|
?? throw new InvalidOperationException("Database not found.");
|
||||||
|
|
||||||
string? adminPassword = await vaultService.GetMongoClusterSecretValueAsync(
|
string? adminPassword = await vaultService.GetMongoClusterSecretValueAsync(
|
||||||
tenantId, mongo.Id, "admin-password", ct)
|
tenantId, mongo.Id, "ADMIN_PASSWORD", ct)
|
||||||
?? throw new InvalidOperationException("Admin password not found in vault.");
|
?? throw new InvalidOperationException("Admin password not found in vault.");
|
||||||
|
|
||||||
string newPassword = GeneratePassword();
|
string newPassword = GeneratePassword();
|
||||||
@@ -1304,13 +1335,13 @@ public class MongoService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Discovers databases on an external MongoDB cluster by executing a listDatabases command
|
/// Discovers databases on a MongoDB cluster (managed or external) by executing a
|
||||||
/// against the primary pod via kubectl exec. Reads the admin password from the
|
/// listDatabases command against the primary pod via kubectl exec. Reads the admin
|
||||||
/// {name}-admin-password K8s Secret. Any databases not yet tracked in EntKube are added
|
/// password from the {name}-admin-password K8s Secret. Any databases not yet tracked
|
||||||
/// automatically. System databases (admin, local, config) are skipped.
|
/// in EntKube are added automatically. System databases (admin, local, config) are
|
||||||
/// Fails silently if the pod is unreachable or credentials are missing.
|
/// skipped. Fails silently if the pod is unreachable or credentials are missing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task SyncExternalDatabasesAsync(
|
public async Task<string> SyncLiveDatabasesAsync(
|
||||||
Guid tenantId, Guid mongoClusterId, CancellationToken ct = default)
|
Guid tenantId, Guid mongoClusterId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
using ApplicationDbContext db = dbFactory.CreateDbContext();
|
using ApplicationDbContext db = dbFactory.CreateDbContext();
|
||||||
@@ -1318,9 +1349,9 @@ public class MongoService(
|
|||||||
MongoCluster? cluster = await db.MongoClusters
|
MongoCluster? cluster = await db.MongoClusters
|
||||||
.Include(c => c.KubernetesCluster)
|
.Include(c => c.KubernetesCluster)
|
||||||
.Include(c => c.Databases)
|
.Include(c => c.Databases)
|
||||||
.FirstOrDefaultAsync(c => c.Id == mongoClusterId && c.TenantId == tenantId && c.IsExternal, ct);
|
.FirstOrDefaultAsync(c => c.Id == mongoClusterId && c.TenantId == tenantId, ct);
|
||||||
|
|
||||||
if (cluster is null) return;
|
if (cluster is null) return string.Empty;
|
||||||
|
|
||||||
string kubeconfig = cluster.KubernetesCluster.Kubeconfig!;
|
string kubeconfig = cluster.KubernetesCluster.Kubeconfig!;
|
||||||
|
|
||||||
@@ -1328,45 +1359,62 @@ public class MongoService(
|
|||||||
string? adminPw = await k8sFactory.GetSecretValueAsync(
|
string? adminPw = await k8sFactory.GetSecretValueAsync(
|
||||||
$"{cluster.Name}-admin-password", "password", cluster.Namespace, kubeconfig, ct);
|
$"{cluster.Name}-admin-password", "password", cluster.Namespace, kubeconfig, ct);
|
||||||
|
|
||||||
// Run listDatabases on the primary pod, outputting one name per line.
|
// Prefix each name with ENTK_DB: so we can pick out exactly our output lines
|
||||||
// We use EJSON-free output: each line is a plain database name.
|
// and ignore all mongosh REPL echoes, banners, and warnings.
|
||||||
const string script = """
|
const string script = "db.adminCommand({listDatabases:1}).databases.filter(d=>!['admin','local','config'].includes(d.name)).forEach(d=>print('ENTK_DB:'+d.name));";
|
||||||
db.adminCommand({listDatabases:1}).databases
|
|
||||||
.filter(d => !['admin','local','config'].includes(d.name))
|
|
||||||
.forEach(d => print(d.name))
|
|
||||||
""";
|
|
||||||
|
|
||||||
string output;
|
string output;
|
||||||
try
|
// Hard timeout: if mongosh hangs (auth prompt, slow pod, etc.) we must not
|
||||||
{
|
// block the cluster detail page. 12 seconds is generous for a local exec.
|
||||||
// Hard timeout: if mongosh hangs (auth prompt, slow pod, etc.) we must not
|
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
// block the cluster detail page. 12 seconds is generous for a local exec.
|
timeout.CancelAfter(TimeSpan.FromSeconds(12));
|
||||||
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
||||||
timeout.CancelAfter(TimeSpan.FromSeconds(12));
|
|
||||||
|
|
||||||
output = await k8sFactory.ExecuteMongoWithOutputAsync(
|
output = await k8sFactory.ExecuteMongoWithOutputAsync(
|
||||||
cluster.Name, cluster.Namespace, script, kubeconfig,
|
cluster.Name, cluster.Namespace, script, kubeconfig,
|
||||||
username: string.IsNullOrEmpty(adminPw) ? null : "admin",
|
username: string.IsNullOrEmpty(adminPw) ? null : "admin",
|
||||||
password: adminPw,
|
password: adminPw,
|
||||||
ct: timeout.Token);
|
ct: timeout.Token);
|
||||||
}
|
|
||||||
catch
|
// Re-query existing names right before inserting — PersistDiscoveredMetadataAsync
|
||||||
|
// may have added rows from spec.users in a separate context after we loaded above.
|
||||||
|
List<MongoDatabase> existing = await db.Set<MongoDatabase>()
|
||||||
|
.Where(d => d.MongoClusterId == cluster.Id)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
// Remove any records whose names were inserted as garbage from earlier buggy
|
||||||
|
// mongosh output parsing (e.g. "]" from the echoed array closing bracket).
|
||||||
|
bool changed = false;
|
||||||
|
foreach (MongoDatabase bad in existing.Where(d => !IsValidMongoDbName(d.Name)))
|
||||||
{
|
{
|
||||||
// Pod unreachable, auth failed, timeout, mongosh not available — skip silently.
|
db.Set<MongoDatabase>().Remove(bad);
|
||||||
return;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
HashSet<string> existingNames = cluster.Databases
|
HashSet<string> existingNames = existing
|
||||||
|
.Where(d => IsValidMongoDbName(d.Name))
|
||||||
.Select(d => d.Name)
|
.Select(d => d.Name)
|
||||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
// The REPL prompt is written to the same stdout stream as print(), so ENTK_DB:name
|
||||||
bool changed = false;
|
// may appear mid-line (e.g. "test> ENTK_DB:kp test>"). Search the whole output
|
||||||
foreach (string line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
// for every occurrence of the prefix instead of matching line-starts.
|
||||||
|
const string prefix = "ENTK_DB:";
|
||||||
|
int searchFrom = 0;
|
||||||
|
while (true)
|
||||||
{
|
{
|
||||||
string dbName = line.Trim();
|
int idx = output.IndexOf(prefix, searchFrom, StringComparison.Ordinal);
|
||||||
|
if (idx < 0) break;
|
||||||
|
|
||||||
|
int nameStart = idx + prefix.Length;
|
||||||
|
int nameEnd = output.IndexOfAny([' ', '\r', '\n', '\t'], nameStart);
|
||||||
|
string dbName = (nameEnd < 0 ? output[nameStart..] : output[nameStart..nameEnd]).Trim();
|
||||||
|
searchFrom = nameEnd < 0 ? output.Length : nameEnd;
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(dbName) || existingNames.Contains(dbName))
|
if (string.IsNullOrEmpty(dbName) || existingNames.Contains(dbName))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
if (!IsValidMongoDbName(dbName))
|
||||||
|
continue;
|
||||||
|
|
||||||
db.Set<MongoDatabase>().Add(new MongoDatabase
|
db.Set<MongoDatabase>().Add(new MongoDatabase
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
@@ -1381,6 +1429,8 @@ public class MongoService(
|
|||||||
|
|
||||||
if (changed)
|
if (changed)
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1490,26 +1540,28 @@ public class MongoService(
|
|||||||
detail.Phase = "Unable to reach cluster";
|
detail.Phase = "Unable to reach cluster";
|
||||||
}
|
}
|
||||||
|
|
||||||
// For external clusters: supplement CRD-based discovery with a live listDatabases
|
// Supplement CRD-based discovery with a live listDatabases call via kubectl exec.
|
||||||
// call via kubectl exec. Runs after the CRD query so it catches databases that
|
// Runs for both managed and external clusters so databases restored directly via
|
||||||
// exist in MongoDB but are not declared in spec.users. Must never throw — any
|
// mongorestore (bypassing spec.users) are picked up automatically.
|
||||||
// failure here would kill the detail view that already loaded successfully above.
|
// Must never throw — any failure here would kill the detail view loaded above.
|
||||||
if (cluster.IsExternal)
|
try
|
||||||
{
|
{
|
||||||
try
|
string rawOutput = await SyncLiveDatabasesAsync(cluster.TenantId, cluster.Id, ct);
|
||||||
{
|
|
||||||
await SyncExternalDatabasesAsync(cluster.TenantId, cluster.Id, ct);
|
|
||||||
|
|
||||||
// Reload databases so the detail reflects any newly synced ones.
|
// Reload databases so the detail reflects any newly synced ones.
|
||||||
using ApplicationDbContext refreshDb = dbFactory.CreateDbContext();
|
using ApplicationDbContext refreshDb = dbFactory.CreateDbContext();
|
||||||
cluster.Databases = await refreshDb.Set<MongoDatabase>()
|
cluster.Databases = await refreshDb.Set<MongoDatabase>()
|
||||||
.Where(d => d.MongoClusterId == cluster.Id)
|
.Where(d => d.MongoClusterId == cluster.Id)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
}
|
|
||||||
catch
|
// Surface raw output when no databases found so the operator can diagnose
|
||||||
{
|
// mongosh connectivity or auth issues without reading container logs.
|
||||||
// Sync failures are non-fatal — cluster status was already loaded above.
|
if (cluster.Databases.Count == 0)
|
||||||
}
|
detail.SyncError = $"No databases found. Raw mongosh output: {rawOutput.Trim()}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
detail.SyncError = ex.InnerException?.Message ?? ex.Message;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch backup job status independently — has its own try/catch so a cluster-query
|
// Fetch backup job status independently — has its own try/catch so a cluster-query
|
||||||
@@ -1530,11 +1582,16 @@ public class MongoService(
|
|||||||
// A backup still Running after 25 h is certainly finished (TTL is 24 h).
|
// A backup still Running after 25 h is certainly finished (TTL is 24 h).
|
||||||
await MarkStaleRunningBackupsAsync(cluster.Id, ct);
|
await MarkStaleRunningBackupsAsync(cluster.Id, ct);
|
||||||
|
|
||||||
|
// Remove completed backup records that have aged past the retention window.
|
||||||
|
await PruneExpiredBackupsAsync(cluster, ct);
|
||||||
|
|
||||||
// Always reload from DB after persisting so backup objects carry real GUIDs.
|
// Always reload from DB after persisting so backup objects carry real GUIDs.
|
||||||
// K8s-fetched records have Id=Guid.Empty; DB records are what restore methods need.
|
// K8s-fetched records have Id=Guid.Empty; DB records are what restore methods need.
|
||||||
using ApplicationDbContext backupsDb = dbFactory.CreateDbContext();
|
using ApplicationDbContext backupsDb = dbFactory.CreateDbContext();
|
||||||
|
DateTime retentionCutoff = DateTime.UtcNow.AddDays(-cluster.RetentionDays);
|
||||||
List<MongoBackup> dbBackups = await backupsDb.MongoBackups
|
List<MongoBackup> dbBackups = await backupsDb.MongoBackups
|
||||||
.Where(b => b.MongoClusterId == cluster.Id)
|
.Where(b => b.MongoClusterId == cluster.Id
|
||||||
|
&& (b.Status == MongoBackupStatus.Running || b.StartedAt >= retentionCutoff))
|
||||||
.OrderByDescending(b => b.StartedAt)
|
.OrderByDescending(b => b.StartedAt)
|
||||||
.Take(20)
|
.Take(20)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
@@ -1629,9 +1686,11 @@ public class MongoService(
|
|||||||
|
|
||||||
if (meta.TryGetProperty("labels", out System.Text.Json.JsonElement labels))
|
if (meta.TryGetProperty("labels", out System.Text.Json.JsonElement labels))
|
||||||
{
|
{
|
||||||
// Skip restore jobs.
|
// Skip restore and s3-prune jobs.
|
||||||
if (labels.TryGetProperty("entkube.io/restore-source", out _)
|
if (labels.TryGetProperty("entkube.io/restore-source", out _)
|
||||||
|| labels.TryGetProperty("entkube.io/restore-type", out _))
|
|| labels.TryGetProperty("entkube.io/restore-type", out _)
|
||||||
|
|| (labels.TryGetProperty("entkube.io/job-type", out System.Text.Json.JsonElement jobTypeEl)
|
||||||
|
&& jobTypeEl.GetString() == "s3-prune"))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (labels.TryGetProperty("entkube.io/mongo-cluster", out System.Text.Json.JsonElement clusterLabel)
|
if (labels.TryGetProperty("entkube.io/mongo-cluster", out System.Text.Json.JsonElement clusterLabel)
|
||||||
@@ -1725,7 +1784,11 @@ public class MongoService(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return [.. result.OrderByDescending(b => b.StartedAt).Take(20)];
|
DateTime cutoff = DateTime.UtcNow.AddDays(-cluster.RetentionDays);
|
||||||
|
return [.. result
|
||||||
|
.Where(b => b.Status == MongoBackupStatus.Running || b.StartedAt >= cutoff)
|
||||||
|
.OrderByDescending(b => b.StartedAt)
|
||||||
|
.Take(20)];
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -1809,6 +1872,90 @@ public class MongoService(
|
|||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes completed/failed backup records older than RetentionDays from both the DB and S3.
|
||||||
|
/// Fires a K8s Job to delete the S3 archives first, then removes the DB rows.
|
||||||
|
/// Running backups are never pruned. Safe to call on every detail page load — no-ops when
|
||||||
|
/// nothing has expired.
|
||||||
|
/// </summary>
|
||||||
|
private async Task PruneExpiredBackupsAsync(MongoCluster cluster, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using ApplicationDbContext db = dbFactory.CreateDbContext();
|
||||||
|
DateTime cutoff = DateTime.UtcNow.AddDays(-cluster.RetentionDays);
|
||||||
|
|
||||||
|
List<MongoBackup> expired = await db.MongoBackups
|
||||||
|
.Where(b => b.MongoClusterId == cluster.Id
|
||||||
|
&& b.Status != MongoBackupStatus.Running
|
||||||
|
&& b.StartedAt < cutoff)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
if (expired.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Delete the S3 archives for each expired backup before removing DB records.
|
||||||
|
if (cluster.StorageLink is not null)
|
||||||
|
{
|
||||||
|
string s3SecretName = $"{cluster.Name}-s3-credentials";
|
||||||
|
string deleteCommands = string.Join(" && ", expired.Select(b =>
|
||||||
|
$"aws s3 rm \"s3://{cluster.StorageLink.BucketName}/{cluster.Name}/{b.Name}.archive\" --endpoint-url \"{cluster.StorageLink.Endpoint}\" || true"));
|
||||||
|
|
||||||
|
string jobName = $"{cluster.Name}-s3-prune-{DateTime.UtcNow:yyyyMMddHHmmss}";
|
||||||
|
string jobManifest = BuildS3PruneJobManifest(jobName, cluster, s3SecretName, deleteCommands);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await k8sFactory.ApplyManifestAsync(jobManifest, cluster.KubernetesCluster.Kubeconfig!, ct);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
db.MongoBackups.RemoveRange(expired);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildS3PruneJobManifest(
|
||||||
|
string jobName, MongoCluster cluster, string s3SecretName, string deleteCommands)
|
||||||
|
{
|
||||||
|
StringBuilder sb = new();
|
||||||
|
sb.AppendLine("apiVersion: batch/v1");
|
||||||
|
sb.AppendLine("kind: Job");
|
||||||
|
sb.AppendLine("metadata:");
|
||||||
|
sb.AppendLine($" name: {jobName}");
|
||||||
|
sb.AppendLine($" namespace: {cluster.Namespace}");
|
||||||
|
sb.AppendLine(" labels:");
|
||||||
|
sb.AppendLine($" entkube.io/mongo-cluster: {cluster.Name}");
|
||||||
|
sb.AppendLine(" entkube.io/job-type: s3-prune");
|
||||||
|
sb.AppendLine("spec:");
|
||||||
|
sb.AppendLine(" backoffLimit: 0");
|
||||||
|
sb.AppendLine(" ttlSecondsAfterFinished: 300");
|
||||||
|
sb.AppendLine(" template:");
|
||||||
|
sb.AppendLine(" spec:");
|
||||||
|
sb.AppendLine(" restartPolicy: Never");
|
||||||
|
sb.AppendLine(" containers:");
|
||||||
|
sb.AppendLine(" - name: s3-prune");
|
||||||
|
sb.AppendLine(" image: amazon/aws-cli");
|
||||||
|
sb.AppendLine(" command: [\"/bin/sh\", \"-c\"]");
|
||||||
|
sb.AppendLine($" args: [\"{deleteCommands}\"]");
|
||||||
|
sb.AppendLine(" env:");
|
||||||
|
sb.AppendLine(" - name: AWS_ACCESS_KEY_ID");
|
||||||
|
sb.AppendLine(" valueFrom:");
|
||||||
|
sb.AppendLine(" secretKeyRef:");
|
||||||
|
sb.AppendLine($" name: {s3SecretName}");
|
||||||
|
sb.AppendLine(" key: ACCESS_KEY");
|
||||||
|
sb.AppendLine(" - name: AWS_SECRET_ACCESS_KEY");
|
||||||
|
sb.AppendLine(" valueFrom:");
|
||||||
|
sb.AppendLine(" secretKeyRef:");
|
||||||
|
sb.AppendLine($" name: {s3SecretName}");
|
||||||
|
sb.AppendLine(" key: SECRET_KEY");
|
||||||
|
sb.AppendLine(" - name: AWS_DEFAULT_REGION");
|
||||||
|
sb.AppendLine($" value: \"{cluster.StorageLink!.Region ?? "us-east-1"}\"");
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reconciles DB backup records still marked Running using two sources:
|
/// Reconciles DB backup records still marked Running using two sources:
|
||||||
/// 1. The already-fetched pods JSON — K8s automatically labels job pods with
|
/// 1. The already-fetched pods JSON — K8s automatically labels job pods with
|
||||||
@@ -2096,6 +2243,67 @@ public class MongoService(
|
|||||||
await secretDb.SaveChangesAsync(ct);
|
await secretDb.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────── Admin Credentials ────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the MongoDB admin password from the Kubernetes secret created by the
|
||||||
|
/// Community Operator ({name}-admin-password) and stores it in the vault so
|
||||||
|
/// EntKube can use it for database operations without re-reading from K8s each time.
|
||||||
|
/// </summary>
|
||||||
|
public async Task FetchAdminPasswordAsync(
|
||||||
|
Guid tenantId, Guid mongoClusterId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using ApplicationDbContext db = dbFactory.CreateDbContext();
|
||||||
|
|
||||||
|
MongoCluster mongo = await db.MongoClusters
|
||||||
|
.Include(c => c.KubernetesCluster)
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == mongoClusterId && c.TenantId == tenantId, ct)
|
||||||
|
?? throw new InvalidOperationException("MongoDB cluster not found.");
|
||||||
|
|
||||||
|
string secretName = $"{mongo.Name}-admin-password";
|
||||||
|
string kubeconfig = mongo.KubernetesCluster.Kubeconfig!;
|
||||||
|
|
||||||
|
string? password = await k8sFactory.GetSecretValueAsync(
|
||||||
|
secretName, "password", mongo.Namespace, kubeconfig, ct);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(password))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Could not read the MongoDB admin secret '{secretName}' in namespace '{mongo.Namespace}'. " +
|
||||||
|
"Ensure the cluster is running and the kubeconfig has read access to Secrets.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await vaultService.InitializeVaultAsync(tenantId, ct);
|
||||||
|
await vaultService.SetMongoClusterSecretAsync(
|
||||||
|
tenantId, mongoClusterId, "ADMIN_PASSWORD", password,
|
||||||
|
secretName, mongo.Namespace, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────── DatabaseBinding management ────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a binding between a MongoDB database and an app deployment. Call
|
||||||
|
/// SyncDatabaseCredentialsToK8sAsync afterwards to push credentials to the app namespace.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<DatabaseBinding> AddMongoDatabaseBindingAsync(
|
||||||
|
Guid appDeploymentId, Guid mongoDatabaseId, string kubernetesSecretName,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using ApplicationDbContext db = dbFactory.CreateDbContext();
|
||||||
|
|
||||||
|
DatabaseBinding binding = new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
AppDeploymentId = appDeploymentId,
|
||||||
|
MongoDatabaseId = mongoDatabaseId,
|
||||||
|
KubernetesSecretName = kubernetesSecretName
|
||||||
|
};
|
||||||
|
|
||||||
|
db.DatabaseBindings.Add(binding);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return binding;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generates a secure random password for database users.
|
/// Generates a secure random password for database users.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -2165,6 +2373,11 @@ public class MongoService(
|
|||||||
/// Must be applied before the MongoDBCommunity CRD — the operator reads this
|
/// Must be applied before the MongoDBCommunity CRD — the operator reads this
|
||||||
/// Secret during startup to configure SCRAM credentials.
|
/// Secret during startup to configure SCRAM credentials.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
private static bool IsValidMongoDbName(string name) =>
|
||||||
|
!string.IsNullOrEmpty(name)
|
||||||
|
&& name.Length <= 63
|
||||||
|
&& name.IndexOfAny([' ', '/', '\\', '.', '"', '$', '*', '<', '>', ':', '|', '?', '[', ']', '\'']) < 0;
|
||||||
|
|
||||||
private static string BuildAdminSecretManifest(string name, string ns, string password)
|
private static string BuildAdminSecretManifest(string name, string ns, string password)
|
||||||
{
|
{
|
||||||
StringBuilder sb = new();
|
StringBuilder sb = new();
|
||||||
@@ -3004,6 +3217,7 @@ public class MongoClusterDetail
|
|||||||
public List<(string DbName, string Owner)> DiscoveredDatabases { get; set; } = [];
|
public List<(string DbName, string Owner)> DiscoveredDatabases { get; set; } = [];
|
||||||
public List<MongoPodInfo> Pods { get; set; } = [];
|
public List<MongoPodInfo> Pods { get; set; } = [];
|
||||||
public List<MongoBackup> Backups { get; set; } = [];
|
public List<MongoBackup> Backups { get; set; } = [];
|
||||||
|
public string? SyncError { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -72,9 +72,9 @@ public class StorageBrowserService(VaultService vaultService, IDbContextFactory<
|
|||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(kubeconfig))
|
if (!string.IsNullOrWhiteSpace(kubeconfig))
|
||||||
{
|
{
|
||||||
ProxiedS3Client proxied = await KubernetesS3Proxy.OpenAsync(kubeconfig, link.Endpoint, accessKey, secretKey, ct: ct)
|
ProxiedS3Client? proxied = await KubernetesS3Proxy.OpenAsync(kubeconfig, link.Endpoint, accessKey, secretKey, ct: ct);
|
||||||
?? throw new InvalidOperationException($"Cannot parse internal MinIO endpoint: {link.Endpoint}");
|
if (proxied is not null)
|
||||||
return (proxied.S3, proxied);
|
return (proxied.S3, proxied);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +116,10 @@ public class StorageBrowserService(VaultService vaultService, IDbContextFactory<
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<string?> ResolveKubeconfigAsync(StorageLink link, CancellationToken ct)
|
private async Task<string?> ResolveKubeconfigAsync(StorageLink link, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
// Only proxy through K8s for internal cluster-local endpoints.
|
||||||
|
if (KubernetesS3Proxy.ParseInternalServiceUrl(link.Endpoint ?? "") is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
using ApplicationDbContext db = dbFactory.CreateDbContext();
|
using ApplicationDbContext db = dbFactory.CreateDbContext();
|
||||||
|
|
||||||
// Fast path: ComponentId set at registration.
|
// Fast path: ComponentId set at registration.
|
||||||
|
|||||||
@@ -587,19 +587,22 @@ public class StorageService(IDbContextFactory<ApplicationDbContext> dbFactory, V
|
|||||||
|
|
||||||
if (kubeconfig is not null && link.Endpoint is not null)
|
if (kubeconfig is not null && link.Endpoint is not null)
|
||||||
{
|
{
|
||||||
using ProxiedS3Client proxied = await KubernetesS3Proxy.OpenAsync(kubeconfig, link.Endpoint, accessKey, secretKey, ct: ct)
|
ProxiedS3Client? proxied = await KubernetesS3Proxy.OpenAsync(kubeconfig, link.Endpoint, accessKey, secretKey, ct: ct);
|
||||||
?? throw new InvalidOperationException($"Cannot parse internal MinIO endpoint: {link.Endpoint}");
|
if (proxied is not null)
|
||||||
try
|
|
||||||
{
|
{
|
||||||
Amazon.S3.Model.ListBucketsResponse resp = await proxied.S3.ListBucketsAsync(ct);
|
using var _ = proxied;
|
||||||
return (resp.Buckets ?? [])
|
try
|
||||||
.Select(b => new S3BucketInfo { Name = b.BucketName ?? "", CreatedAt = b.CreationDate.GetValueOrDefault() })
|
{
|
||||||
.ToList();
|
Amazon.S3.Model.ListBucketsResponse resp = await proxied.S3.ListBucketsAsync(ct);
|
||||||
}
|
return (resp.Buckets ?? [])
|
||||||
catch (Amazon.S3.AmazonS3Exception s3ex)
|
.Select(b => new S3BucketInfo { Name = b.BucketName ?? "", CreatedAt = b.CreationDate.GetValueOrDefault() })
|
||||||
{
|
.ToList();
|
||||||
throw new InvalidOperationException(
|
}
|
||||||
$"MinIO S3 error [{(int)s3ex.StatusCode} {s3ex.ErrorCode}]: {s3ex.Message}", s3ex);
|
catch (Amazon.S3.AmazonS3Exception s3ex)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"MinIO S3 error [{(int)s3ex.StatusCode} {s3ex.ErrorCode}]: {s3ex.Message}", s3ex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,9 +623,12 @@ public class StorageService(IDbContextFactory<ApplicationDbContext> dbFactory, V
|
|||||||
|
|
||||||
if (kubeconfig is not null && link.Endpoint is not null)
|
if (kubeconfig is not null && link.Endpoint is not null)
|
||||||
{
|
{
|
||||||
using ProxiedS3Client proxied = await KubernetesS3Proxy.OpenAsync(kubeconfig, link.Endpoint, accessKey, secretKey, ct: ct)
|
ProxiedS3Client? proxied = await KubernetesS3Proxy.OpenAsync(kubeconfig, link.Endpoint, accessKey, secretKey, ct: ct);
|
||||||
?? throw new InvalidOperationException($"Cannot parse internal MinIO endpoint: {link.Endpoint}");
|
if (proxied is not null)
|
||||||
return await openStackS3.GetBucketCorsAsync(proxied.S3, link.BucketName!, ct);
|
{
|
||||||
|
using var _ = proxied;
|
||||||
|
return await openStackS3.GetBucketCorsAsync(proxied.S3, link.BucketName!, ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return await openStackS3.GetBucketCorsAsync(
|
return await openStackS3.GetBucketCorsAsync(
|
||||||
@@ -642,10 +648,13 @@ public class StorageService(IDbContextFactory<ApplicationDbContext> dbFactory, V
|
|||||||
|
|
||||||
if (kubeconfig is not null && link.Endpoint is not null)
|
if (kubeconfig is not null && link.Endpoint is not null)
|
||||||
{
|
{
|
||||||
using ProxiedS3Client proxied = await KubernetesS3Proxy.OpenAsync(kubeconfig, link.Endpoint, accessKey, secretKey, ct: ct)
|
ProxiedS3Client? proxied = await KubernetesS3Proxy.OpenAsync(kubeconfig, link.Endpoint, accessKey, secretKey, ct: ct);
|
||||||
?? throw new InvalidOperationException($"Cannot parse internal MinIO endpoint: {link.Endpoint}");
|
if (proxied is not null)
|
||||||
await openStackS3.SetBucketCorsAsync(proxied.S3, link.BucketName!, rules, ct);
|
{
|
||||||
return;
|
using var _ = proxied;
|
||||||
|
await openStackS3.SetBucketCorsAsync(proxied.S3, link.BucketName!, rules, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await openStackS3.SetBucketCorsAsync(
|
await openStackS3.SetBucketCorsAsync(
|
||||||
@@ -665,9 +674,12 @@ public class StorageService(IDbContextFactory<ApplicationDbContext> dbFactory, V
|
|||||||
|
|
||||||
if (kubeconfig is not null && link.Endpoint is not null)
|
if (kubeconfig is not null && link.Endpoint is not null)
|
||||||
{
|
{
|
||||||
using ProxiedS3Client proxied = await KubernetesS3Proxy.OpenAsync(kubeconfig, link.Endpoint, accessKey, secretKey, ct: ct)
|
ProxiedS3Client? proxied = await KubernetesS3Proxy.OpenAsync(kubeconfig, link.Endpoint, accessKey, secretKey, ct: ct);
|
||||||
?? throw new InvalidOperationException($"Cannot parse internal MinIO endpoint: {link.Endpoint}");
|
if (proxied is not null)
|
||||||
return await openStackS3.GetBucketPolicyAsync(proxied.S3, link.BucketName!, ct);
|
{
|
||||||
|
using var _ = proxied;
|
||||||
|
return await openStackS3.GetBucketPolicyAsync(proxied.S3, link.BucketName!, ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return await openStackS3.GetBucketPolicyAsync(
|
return await openStackS3.GetBucketPolicyAsync(
|
||||||
@@ -687,10 +699,13 @@ public class StorageService(IDbContextFactory<ApplicationDbContext> dbFactory, V
|
|||||||
|
|
||||||
if (kubeconfig is not null && link.Endpoint is not null)
|
if (kubeconfig is not null && link.Endpoint is not null)
|
||||||
{
|
{
|
||||||
using ProxiedS3Client proxied = await KubernetesS3Proxy.OpenAsync(kubeconfig, link.Endpoint, accessKey, secretKey, ct: ct)
|
ProxiedS3Client? proxied = await KubernetesS3Proxy.OpenAsync(kubeconfig, link.Endpoint, accessKey, secretKey, ct: ct);
|
||||||
?? throw new InvalidOperationException($"Cannot parse internal MinIO endpoint: {link.Endpoint}");
|
if (proxied is not null)
|
||||||
await openStackS3.SetBucketPolicyAsync(proxied.S3, link.BucketName!, policyJson, ct);
|
{
|
||||||
return;
|
using var _ = proxied;
|
||||||
|
await openStackS3.SetBucketPolicyAsync(proxied.S3, link.BucketName!, policyJson, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await openStackS3.SetBucketPolicyAsync(
|
await openStackS3.SetBucketPolicyAsync(
|
||||||
@@ -770,6 +785,11 @@ public class StorageService(IDbContextFactory<ApplicationDbContext> dbFactory, V
|
|||||||
if (link.Provider != StorageProvider.MinIO)
|
if (link.Provider != StorageProvider.MinIO)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
// Only proxy through K8s for internal cluster-local endpoints.
|
||||||
|
// External endpoints are reached directly — no proxy needed.
|
||||||
|
if (KubernetesS3Proxy.ParseInternalServiceUrl(link.Endpoint ?? "") is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
// Fast path: ComponentId was set at registration time.
|
// Fast path: ComponentId was set at registration time.
|
||||||
string? kubeconfig = link.Component?.Cluster?.Kubeconfig;
|
string? kubeconfig = link.Component?.Cluster?.Kubeconfig;
|
||||||
if (!string.IsNullOrWhiteSpace(kubeconfig))
|
if (!string.IsNullOrWhiteSpace(kubeconfig))
|
||||||
|
|||||||
@@ -1290,6 +1290,72 @@ public class VaultService(IDbContextFactory<ApplicationDbContext> dbFactory, Vau
|
|||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Customer git credential secrets ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stores or updates a secret scoped to a customer git credential.
|
||||||
|
/// Conventional key names: "PAT", "PASSWORD", "SSH_PRIVATE_KEY".
|
||||||
|
/// </summary>
|
||||||
|
public async Task<VaultSecret> SetCustomerGitCredentialSecretAsync(
|
||||||
|
Guid tenantId, Guid credentialId, string name, string value, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using ApplicationDbContext db = dbFactory.CreateDbContext();
|
||||||
|
|
||||||
|
byte[] dataKey = await UnsealVaultAsync(tenantId, ct);
|
||||||
|
|
||||||
|
VaultSecret? existing = await db.Set<VaultSecret>()
|
||||||
|
.FirstOrDefaultAsync(s => s.Vault.TenantId == tenantId
|
||||||
|
&& s.CustomerGitCredentialId == credentialId
|
||||||
|
&& s.Name == name, ct);
|
||||||
|
|
||||||
|
(byte[] ciphertext, byte[] nonce) = encryption.Encrypt(dataKey, value);
|
||||||
|
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
existing.EncryptedValue = ciphertext;
|
||||||
|
existing.Nonce = nonce;
|
||||||
|
existing.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
SecretVault vault = (await GetVaultAsync(tenantId, ct))!;
|
||||||
|
|
||||||
|
VaultSecret secret = new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
VaultId = vault.Id,
|
||||||
|
Name = name,
|
||||||
|
EncryptedValue = ciphertext,
|
||||||
|
Nonce = nonce,
|
||||||
|
CustomerGitCredentialId = credentialId
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Set<VaultSecret>().Add(secret);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decrypts and returns a specific customer git credential secret value.
|
||||||
|
/// Returns null if not found.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string?> GetCustomerGitCredentialSecretValueAsync(
|
||||||
|
Guid tenantId, Guid credentialId, string name, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using ApplicationDbContext db = dbFactory.CreateDbContext();
|
||||||
|
|
||||||
|
VaultSecret? secret = await db.Set<VaultSecret>()
|
||||||
|
.FirstOrDefaultAsync(s => s.Vault.TenantId == tenantId
|
||||||
|
&& s.CustomerGitCredentialId == credentialId
|
||||||
|
&& s.Name == name, ct);
|
||||||
|
|
||||||
|
if (secret is null) return null;
|
||||||
|
|
||||||
|
byte[] dataKey = await UnsealVaultAsync(tenantId, ct);
|
||||||
|
return encryption.Decrypt(dataKey, secret.EncryptedValue, secret.Nonce);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Private Helpers ---
|
// --- Private Helpers ---
|
||||||
|
|
||||||
private static async Task<HelmExecutionResult> RunProcessAsync(
|
private static async Task<HelmExecutionResult> RunProcessAsync(
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"System.Net.Http.HttpClient.RouteHealth": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*"
|
||||||
|
|||||||
@@ -67,4 +67,38 @@ h1:focus {
|
|||||||
.spin {
|
.spin {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Home page feature cards */
|
||||||
|
.home-card {
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 .5rem 1rem rgba(0,0,0,.15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-card-icon {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tenant detail category/sub-tab hierarchy */
|
||||||
|
.tenant-category-nav .nav-link {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tenant-category-nav .nav-link:not(.active) {
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Consistent card header style */
|
||||||
|
.card-header.bg-white {
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, .075);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user