Mark subproject as dirty in CMKS - Infra

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

View File

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