5840 lines
317 KiB
Plaintext
5840 lines
317 KiB
Plaintext
@using EntKube.Web.Data
|
||
@using EntKube.Web.Services
|
||
@inject DatabaseService DatabaseService
|
||
@inject CnpgService CnpgService
|
||
@inject MongoService MongoService
|
||
@inject StorageService StorageService
|
||
@inject RegisteredPostgresService RegisteredPostgresService
|
||
|
||
@* ===========================================================================
|
||
Databases Tab — shows CNPG PostgreSQL clusters and MongoDB clusters
|
||
discovered across the tenant's Kubernetes clusters.
|
||
Requires the respective operators (cloudnative-pg, mongodb-operator)
|
||
to be installed on at least one cluster.
|
||
=========================================================================== *@
|
||
|
||
@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 ── *@
|
||
<div class="d-flex gap-2 mb-4">
|
||
@if (operatorStatus!.CnpgAvailable)
|
||
{
|
||
<span class="badge bg-success-subtle text-success border border-success-subtle px-3 py-2">
|
||
<i class="bi bi-database me-1"></i>PostgreSQL (CNPG)
|
||
<small class="ms-1 opacity-75">on @operatorStatus.CnpgClusterName</small>
|
||
</span>
|
||
}
|
||
else
|
||
{
|
||
<span class="badge bg-secondary-subtle text-secondary border px-3 py-2">
|
||
<i class="bi bi-database me-1"></i>PostgreSQL (CNPG) — not installed
|
||
</span>
|
||
}
|
||
|
||
@if (operatorStatus.MongoDbAvailable)
|
||
{
|
||
<span class="badge bg-success-subtle text-success border border-success-subtle px-3 py-2">
|
||
<i class="bi bi-database-gear me-1"></i>MongoDB
|
||
<small class="ms-1 opacity-75">on @operatorStatus.MongoDbClusterName</small>
|
||
</span>
|
||
}
|
||
else
|
||
{
|
||
<span class="badge bg-secondary-subtle text-secondary border px-3 py-2">
|
||
<i class="bi bi-database-gear me-1"></i>MongoDB — not installed
|
||
</span>
|
||
}
|
||
</div>
|
||
|
||
@* ── Environment Tabs ── *@
|
||
@if (AllEnvironments.Count > 0)
|
||
{
|
||
<ul class="nav nav-pills mb-4 gap-1 flex-wrap">
|
||
<li class="nav-item">
|
||
<button class="nav-link @(selectedEnvironment is null ? "active" : "")"
|
||
@onclick="() => selectedEnvironment = null">
|
||
<i class="bi bi-layers me-1"></i>All
|
||
</button>
|
||
</li>
|
||
@foreach (string env in AllEnvironments)
|
||
{
|
||
<li class="nav-item">
|
||
<button class="nav-link @(selectedEnvironment == env ? "active" : "")"
|
||
@onclick="() => selectedEnvironment = env">
|
||
@env
|
||
</button>
|
||
</li>
|
||
}
|
||
</ul>
|
||
}
|
||
|
||
@* ── Registered PostgreSQL Instances ── *@
|
||
<div class="mb-4">
|
||
<h5 class="d-flex align-items-center gap-2 mb-3">
|
||
<i class="bi bi-database text-primary"></i>Postgres
|
||
@if (FilteredRegisteredPgInstances is not null)
|
||
{
|
||
<span class="badge bg-secondary rounded-pill">@FilteredRegisteredPgInstances.Count</span>
|
||
}
|
||
<button class="btn btn-sm btn-secondary ms-auto" @onclick="ShowRegisterPgForm" disabled="@showRegisterPgForm">
|
||
<i class="bi bi-plus-lg me-1"></i>Register Instance
|
||
</button>
|
||
</h5>
|
||
|
||
@* ── Register Form ── *@
|
||
@if (showRegisterPgForm)
|
||
{
|
||
<div class="card shadow-sm mb-3 border-secondary">
|
||
<div class="card-header bg-secondary bg-opacity-10 d-flex align-items-center justify-content-between">
|
||
<h6 class="mb-0"><i class="bi bi-database-gear me-2"></i>Register Existing PostgreSQL Instance</h6>
|
||
<button class="btn btn-sm btn-outline-secondary border-0" @onclick="CancelRegisterPgForm"><i class="bi bi-x-lg"></i></button>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row g-3">
|
||
<div class="col-md-6">
|
||
<label class="form-label small">Display Name</label>
|
||
<input class="form-control form-control-sm" @bind="regPgName" placeholder="Keycloak DB – Old cluster" />
|
||
</div>
|
||
<div class="col-md-6">
|
||
<label class="form-label small">Kubernetes Cluster</label>
|
||
<select class="form-select form-select-sm" @bind="regPgClusterId">
|
||
<option value="@Guid.Empty">Select cluster...</option>
|
||
@if (allClusters is not null)
|
||
{
|
||
@foreach (KubernetesCluster k in allClusters)
|
||
{
|
||
<option value="@k.Id">@k.Name (@k.Environment.Name)</option>
|
||
}
|
||
}
|
||
</select>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Namespace</label>
|
||
<input class="form-control form-control-sm" @bind="regPgNamespace" placeholder="keycloak" />
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">K8s Service Name</label>
|
||
<input class="form-control form-control-sm" @bind="regPgServiceName" placeholder="postgres" />
|
||
<div class="form-text small">Used as HOST in credentials (resolves as service.ns.svc.cluster.local)</div>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Port</label>
|
||
<input type="number" class="form-control form-control-sm" @bind="regPgPort" min="1" max="65535" />
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Admin Pod Name</label>
|
||
<input class="form-control form-control-sm" @bind="regPgAdminPodName" placeholder="postgres-0" />
|
||
<div class="form-text small">Pod to kubectl exec into for admin SQL</div>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Admin Username</label>
|
||
<input class="form-control form-control-sm" @bind="regPgAdminUsername" placeholder="postgres" />
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Admin Password</label>
|
||
<input type="password" class="form-control form-control-sm" @bind="regPgAdminPassword" />
|
||
</div>
|
||
<div class="col-12">
|
||
<label class="form-label small">Notes (optional)</label>
|
||
<input class="form-control form-control-sm" @bind="regPgNotes" placeholder="e.g. Provisioned by Helm in 2023, holds keycloak database" />
|
||
</div>
|
||
</div>
|
||
<div class="mt-3 d-flex align-items-center gap-2">
|
||
<button class="btn btn-sm btn-secondary" @onclick="RegisterPgInstance" disabled="@registeringPg">
|
||
@if (registeringPg)
|
||
{
|
||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||
<text>Registering...</text>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-plus-lg me-1"></i>
|
||
<text>Register Instance</text>
|
||
}
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelRegisterPgForm">Cancel</button>
|
||
</div>
|
||
@if (!string.IsNullOrEmpty(registerPgError))
|
||
{
|
||
<div class="alert alert-danger small mt-2 mb-0 py-2">
|
||
<i class="bi bi-exclamation-triangle me-1"></i>@registerPgError
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
@* ── Instance Cards ── *@
|
||
@if (registeredPgInstances is null)
|
||
{
|
||
<div class="text-center py-3">
|
||
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
|
||
</div>
|
||
}
|
||
else if (FilteredRegisteredPgInstances!.Count == 0 && !showRegisterPgForm)
|
||
{
|
||
<div class="alert alert-light border">
|
||
<i class="bi bi-inbox me-2"></i>
|
||
No registered PostgreSQL instances yet. Click "Register Instance" to add a non-CNPG Postgres server.
|
||
</div>
|
||
}
|
||
else
|
||
{
|
||
@foreach (RegisteredPostgresInstance rpi in FilteredRegisteredPgInstances!)
|
||
{
|
||
<div class="card shadow-sm mb-3">
|
||
<div class="card-header bg-white d-flex align-items-center justify-content-between py-2">
|
||
<div class="d-flex align-items-center gap-2 cursor-pointer" @onclick="() => ToggleRegPgDetail(rpi.Id)">
|
||
<i class="bi @(expandedRegPgId == rpi.Id ? "bi-chevron-down" : "bi-chevron-right") text-muted"></i>
|
||
<i class="bi bi-database text-secondary"></i>
|
||
<strong>@rpi.Name</strong>
|
||
<span class="badge bg-light text-dark border small">@rpi.ServiceName:@rpi.Port</span>
|
||
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle small">
|
||
<i class="bi bi-layers me-1"></i>@rpi.KubernetesCluster.Environment.Name
|
||
</span>
|
||
@if (rpi.Databases.Count > 0)
|
||
{
|
||
<span class="badge bg-secondary-subtle text-secondary">@rpi.Databases.Count db(s)</span>
|
||
}
|
||
</div>
|
||
<div class="d-flex gap-1">
|
||
<button class="btn btn-sm btn-outline-secondary border-0" title="Test Connection"
|
||
@onclick="() => TestRegPgConnection(rpi.Id)" disabled="@(testingRegPgId == rpi.Id)">
|
||
@if (testingRegPgId == rpi.Id)
|
||
{
|
||
<span class="spinner-border spinner-border-sm"></span>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-plug"></i>
|
||
}
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-danger border-0" title="Delete Instance"
|
||
@onclick="() => DeleteRegPgInstance(rpi.Id)" disabled="@(deletingRegPgId == rpi.Id)">
|
||
@if (deletingRegPgId == rpi.Id)
|
||
{
|
||
<span class="spinner-border spinner-border-sm"></span>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-trash"></i>
|
||
}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
@if (testRegPgResultId == rpi.Id && testRegPgResult is not null)
|
||
{
|
||
<div class="px-3 pt-2">
|
||
<div class="alert @(testRegPgResult.StartsWith("OK") ? "alert-success" : "alert-danger") small py-1 mb-0">
|
||
@testRegPgResult
|
||
</div>
|
||
</div>
|
||
}
|
||
@if (deleteRegPgError is not null && deletingRegPgId == rpi.Id)
|
||
{
|
||
<div class="px-3 pt-2">
|
||
<div class="alert alert-danger small py-1 mb-0">@deleteRegPgError</div>
|
||
</div>
|
||
}
|
||
|
||
@* ── Expanded Detail Panel ── *@
|
||
@if (expandedRegPgId == rpi.Id)
|
||
{
|
||
<div class="card-body pt-2 pb-3">
|
||
@if (!string.IsNullOrEmpty(rpi.Notes))
|
||
{
|
||
<p class="text-muted small mb-3"><i class="bi bi-info-circle me-1"></i>@rpi.Notes</p>
|
||
}
|
||
|
||
@* ── Pod Health ── *@
|
||
<h6 class="small text-muted mb-2"><i class="bi bi-hdd-stack me-1"></i>Pod</h6>
|
||
@if (regPgPodsLoading.GetValueOrDefault(rpi.Id))
|
||
{
|
||
<div class="text-center py-2 mb-3">
|
||
<div class="spinner-border spinner-border-sm text-secondary"></div>
|
||
</div>
|
||
}
|
||
else if (regPgPods.TryGetValue(rpi.Id, out List<CnpgPodInfo>? pods) && pods.Count > 0)
|
||
{
|
||
<div class="table-responsive mb-3">
|
||
<table class="table table-sm table-hover small mb-0">
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th>Pod</th>
|
||
<th>Status</th>
|
||
<th>Ready</th>
|
||
<th>Node</th>
|
||
<th>Age</th>
|
||
<th>Restarts</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
@foreach (CnpgPodInfo pod in pods)
|
||
{
|
||
<tr>
|
||
<td class="font-monospace">@pod.Name</td>
|
||
<td>
|
||
@if (pod.Status == "Running")
|
||
{
|
||
<span class="badge bg-success-subtle text-success">@pod.Status</span>
|
||
}
|
||
else if (pod.Status is "Pending" or "ContainerCreating")
|
||
{
|
||
<span class="badge bg-warning-subtle text-warning">@pod.Status</span>
|
||
}
|
||
else
|
||
{
|
||
<span class="badge bg-danger-subtle text-danger">@pod.Status</span>
|
||
}
|
||
</td>
|
||
<td>
|
||
@if (pod.Ready)
|
||
{
|
||
<i class="bi bi-check-circle-fill text-success"></i>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-x-circle text-danger"></i>
|
||
}
|
||
</td>
|
||
<td class="text-muted small">@(pod.Node ?? "—")</td>
|
||
<td class="text-muted small">
|
||
@if (pod.StartTime.HasValue)
|
||
{
|
||
@((DateTime.UtcNow - pod.StartTime.Value) switch
|
||
{
|
||
var d when d.TotalDays >= 1 => $"{(int)d.TotalDays}d",
|
||
var d when d.TotalHours >= 1 => $"{(int)d.TotalHours}h",
|
||
var d => $"{(int)d.TotalMinutes}m"
|
||
})
|
||
}
|
||
else { <text>—</text> }
|
||
</td>
|
||
<td class="@(pod.Restarts > 0 ? "text-warning" : "text-muted") small">@pod.Restarts</td>
|
||
</tr>
|
||
}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
}
|
||
else
|
||
{
|
||
<p class="small text-muted mb-3">Pod info unavailable.</p>
|
||
}
|
||
|
||
<div class="d-flex align-items-center gap-2 mb-2">
|
||
<h6 class="mb-0 small fw-semibold text-muted text-uppercase">Databases</h6>
|
||
<button class="btn btn-sm btn-outline-secondary btn-xs" @onclick="() => ShowAddRegPgDatabase(rpi)">
|
||
<i class="bi bi-plus-circle me-1"></i>Create
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary btn-xs" @onclick="() => ShowImportRegPgDatabase(rpi)">
|
||
<i class="bi bi-box-arrow-in-down me-1"></i>Import Existing
|
||
</button>
|
||
</div>
|
||
|
||
@* ── Create Database Form ── *@
|
||
@if (addDbRegPgInstance?.Id == rpi.Id)
|
||
{
|
||
<div class="border rounded p-3 mb-3 bg-light">
|
||
<div class="row g-2">
|
||
<div class="col-md-6">
|
||
<label class="form-label small">Database Name</label>
|
||
<input class="form-control form-control-sm" @bind="newRegPgDbName" placeholder="myapp" />
|
||
</div>
|
||
</div>
|
||
<div class="mt-2 d-flex gap-2">
|
||
<button class="btn btn-sm btn-secondary" @onclick="CreateRegPgDatabase" disabled="@addingRegPgDb">
|
||
@if (addingRegPgDb) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||
Create Database
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => addDbRegPgInstance = null">Cancel</button>
|
||
</div>
|
||
@if (!string.IsNullOrEmpty(addRegPgDbError))
|
||
{
|
||
<div class="alert alert-danger small mt-2 mb-0 py-1">@addRegPgDbError</div>
|
||
}
|
||
</div>
|
||
}
|
||
|
||
@* ── Import Database Form ── *@
|
||
@if (importRegPgInstance?.Id == rpi.Id)
|
||
{
|
||
<div class="border rounded p-3 mb-3 bg-light">
|
||
<p class="small text-muted mb-2">Register an existing database without creating it on the server. Stores the provided credentials in the vault.</p>
|
||
<div class="row g-2">
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Database Name</label>
|
||
<input class="form-control form-control-sm" @bind="importRegPgDbName" placeholder="keycloak" />
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Owner Role</label>
|
||
<input class="form-control form-control-sm" @bind="importRegPgOwner" placeholder="keycloak" />
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Owner Password</label>
|
||
<input type="password" class="form-control form-control-sm" @bind="importRegPgPassword" />
|
||
</div>
|
||
</div>
|
||
<div class="mt-2 d-flex gap-2">
|
||
<button class="btn btn-sm btn-secondary" @onclick="ImportRegPgDatabase" disabled="@importingRegPgDb">
|
||
@if (importingRegPgDb) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||
Import
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => importRegPgInstance = null">Cancel</button>
|
||
</div>
|
||
@if (!string.IsNullOrEmpty(importRegPgError))
|
||
{
|
||
<div class="alert alert-danger small mt-2 mb-0 py-1">@importRegPgError</div>
|
||
}
|
||
</div>
|
||
}
|
||
|
||
@* ── Databases Table ── *@
|
||
@if (rpi.Databases.Count == 0)
|
||
{
|
||
<p class="text-muted small mb-0"><i class="bi bi-inbox me-1"></i>No databases managed yet.</p>
|
||
}
|
||
else
|
||
{
|
||
<div class="table-responsive">
|
||
<table class="table table-sm table-hover mb-0">
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th class="small">Database</th>
|
||
<th class="small">Owner Role</th>
|
||
<th class="small">Status</th>
|
||
<th class="small text-end">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
@foreach (RegisteredPostgresDatabase db in rpi.Databases)
|
||
{
|
||
<tr>
|
||
<td class="small align-middle"><i class="bi bi-database me-1 text-muted"></i>@db.Name</td>
|
||
<td class="small align-middle text-muted">@db.Owner</td>
|
||
<td class="small align-middle">
|
||
@switch (db.Status)
|
||
{
|
||
case RegisteredPostgresDatabaseStatus.Ready:
|
||
<span class="badge bg-success-subtle text-success border border-success-subtle">Ready</span>
|
||
break;
|
||
case RegisteredPostgresDatabaseStatus.Creating:
|
||
<span class="badge bg-warning-subtle text-warning border border-warning-subtle">Creating</span>
|
||
break;
|
||
case RegisteredPostgresDatabaseStatus.Deleting:
|
||
<span class="badge bg-danger-subtle text-danger border border-danger-subtle">Deleting</span>
|
||
break;
|
||
default:
|
||
<span class="badge bg-danger-subtle text-danger border border-danger-subtle">Failed</span>
|
||
break;
|
||
}
|
||
</td>
|
||
<td class="small align-middle text-end">
|
||
<div class="d-flex gap-1 justify-content-end">
|
||
<button class="btn btn-xs btn-outline-secondary border-0" title="View Credentials"
|
||
@onclick="() => ViewRegPgCredentials(rpi, db.Id)">
|
||
<i class="bi bi-key"></i>
|
||
</button>
|
||
<button class="btn btn-xs btn-outline-info border-0" title="Sync to Kubernetes"
|
||
@onclick="() => SyncRegPgCredentials(rpi.Id, db.Id)"
|
||
disabled="@(syncingRegPgDbId == db.Id)">
|
||
@if (syncingRegPgDbId == db.Id)
|
||
{
|
||
<span class="spinner-border spinner-border-sm"></span>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-arrow-repeat"></i>
|
||
}
|
||
</button>
|
||
<button class="btn btn-xs btn-outline-warning border-0" title="Rotate Password"
|
||
@onclick="() => RotateRegPgPassword(rpi.Id, db.Id)"
|
||
disabled="@(rotatingRegPgDbId == db.Id)">
|
||
@if (rotatingRegPgDbId == db.Id)
|
||
{
|
||
<span class="spinner-border spinner-border-sm"></span>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-arrow-clockwise"></i>
|
||
}
|
||
</button>
|
||
<button class="btn btn-xs btn-outline-secondary border-0" title="Dump to S3"
|
||
@onclick="() => ShowRegPgDumpForm(rpi, db)">
|
||
<i class="bi bi-cloud-arrow-up"></i>
|
||
</button>
|
||
<button class="btn btn-xs btn-outline-primary border-0" title="Migrate to CNPG"
|
||
@onclick="() => ShowRegPgMigrateForm(rpi, db)">
|
||
<i class="bi bi-arrow-right-circle"></i>
|
||
</button>
|
||
<button class="btn btn-xs btn-outline-danger border-0" title="Drop Database"
|
||
@onclick="() => regPgDbDeleteConfirmId = db.Id"
|
||
disabled="@(droppingRegPgDbId == db.Id)">
|
||
@if (droppingRegPgDbId == db.Id)
|
||
{
|
||
<span class="spinner-border spinner-border-sm"></span>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-trash"></i>
|
||
}
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
@if (regPgDbDeleteConfirmId == db.Id)
|
||
{
|
||
<tr class="table-warning">
|
||
<td colspan="4" class="py-2 px-2">
|
||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||
<i class="bi bi-exclamation-triangle text-danger small"></i>
|
||
<span class="small">Remove <strong class="font-monospace">@db.Name</strong>?</span>
|
||
<div class="ms-auto d-flex gap-2">
|
||
<button class="btn btn-xs btn-outline-secondary" @onclick="() => regPgDbDeleteConfirmId = null">Cancel</button>
|
||
<button class="btn btn-xs btn-outline-secondary" title="Remove from EntKube only — database and role are left on the server"
|
||
@onclick="() => UnregisterRegPgDatabase(rpi.Id, db.Id)"
|
||
disabled="@(droppingRegPgDbId == db.Id)">
|
||
@if (droppingRegPgDbId == db.Id) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||
Unregister
|
||
</button>
|
||
<button class="btn btn-xs btn-danger" title="Drop database and role from the Postgres server, then remove from EntKube"
|
||
@onclick="() => DropRegPgDatabase(rpi.Id, db.Id)"
|
||
disabled="@(droppingRegPgDbId == db.Id)">
|
||
@if (droppingRegPgDbId == db.Id) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||
Drop Database
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
}
|
||
@if (showDumpFormForDbId == db.Id)
|
||
{
|
||
<tr class="table-light">
|
||
<td colspan="4" class="p-2">
|
||
<div class="row g-2 align-items-end">
|
||
<div class="col-md-6">
|
||
<label class="form-label small mb-1">S3 Storage Bucket</label>
|
||
<select class="form-select form-select-sm" @bind="dumpFormStorageLinkId">
|
||
<option value="@Guid.Empty">Select bucket...</option>
|
||
@if (storageLinks is not null)
|
||
{
|
||
@foreach (StorageLink sl in storageLinks)
|
||
{
|
||
<option value="@sl.Id">@sl.Name (@sl.BucketName)</option>
|
||
}
|
||
}
|
||
</select>
|
||
</div>
|
||
<div class="col-auto d-flex gap-2">
|
||
<button class="btn btn-sm btn-secondary" @onclick="() => CreateRegPgDump(rpi, db)"
|
||
disabled="@(dumpingRegPgDbId == db.Id || dumpFormStorageLinkId == Guid.Empty)">
|
||
@if (dumpingRegPgDbId == db.Id)
|
||
{
|
||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||
<text>Dumping...</text>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-cloud-arrow-up me-1"></i>
|
||
<text>Start Dump</text>
|
||
}
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showDumpFormForDbId = null">Cancel</button>
|
||
</div>
|
||
</div>
|
||
@if (!string.IsNullOrEmpty(dumpError) && dumpErrorDbId == db.Id)
|
||
{
|
||
<div class="alert alert-danger small py-1 mt-2 mb-0">@dumpError</div>
|
||
}
|
||
@if (!string.IsNullOrEmpty(dumpSuccess) && dumpErrorDbId == db.Id)
|
||
{
|
||
<div class="alert alert-success small py-1 mt-2 mb-0">@dumpSuccess</div>
|
||
}
|
||
</td>
|
||
</tr>
|
||
}
|
||
@if (showMigrateFormForDbId == db.Id)
|
||
{
|
||
<tr class="table-light">
|
||
<td colspan="4" class="p-2">
|
||
<div class="row g-2 align-items-end">
|
||
<div class="col-md-5">
|
||
<label class="form-label small mb-1">Target CNPG Cluster</label>
|
||
<select class="form-select form-select-sm" @bind="migrateFormCnpgClusterId">
|
||
<option value="@Guid.Empty">Select cluster...</option>
|
||
@if (managedClusters is not null)
|
||
{
|
||
@foreach (CnpgCluster c in managedClusters)
|
||
{
|
||
<option value="@c.Id">@c.Name (@c.KubernetesCluster.Environment.Name)</option>
|
||
}
|
||
}
|
||
</select>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small mb-1">New Database Name</label>
|
||
<input class="form-control form-control-sm" @bind="migrateFormNewDbName" placeholder="@db.Name" />
|
||
</div>
|
||
<div class="col-auto d-flex gap-2">
|
||
<button class="btn btn-sm btn-primary" @onclick="() => MigrateRegPgToCnpg(rpi, db)"
|
||
disabled="@(migratingRegPgDbId == db.Id || migrateFormCnpgClusterId == Guid.Empty || string.IsNullOrWhiteSpace(migrateFormNewDbName))">
|
||
@if (migratingRegPgDbId == db.Id)
|
||
{
|
||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||
<text>Migrating...</text>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-arrow-right-circle me-1"></i>
|
||
<text>Migrate</text>
|
||
}
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showMigrateFormForDbId = null">Cancel</button>
|
||
</div>
|
||
</div>
|
||
@if (!string.IsNullOrEmpty(migrateError) && migrateErrorDbId == db.Id)
|
||
{
|
||
<div class="alert alert-danger small py-1 mt-2 mb-0">@migrateError</div>
|
||
}
|
||
@if (!string.IsNullOrEmpty(migrateSuccess) && migrateErrorDbId == db.Id)
|
||
{
|
||
<div class="alert alert-success small py-1 mt-2 mb-0">@migrateSuccess</div>
|
||
}
|
||
</td>
|
||
</tr>
|
||
}
|
||
@if (regPgCredDbId == db.Id && regPgCredentials is not null)
|
||
{
|
||
<tr class="table-light">
|
||
<td colspan="4" class="p-2">
|
||
<div class="d-flex flex-wrap gap-2">
|
||
@foreach (KeyValuePair<string, string> cred in regPgCredentials)
|
||
{
|
||
<div class="font-monospace small border rounded px-2 py-1 bg-white">
|
||
<span class="text-muted">@cred.Key:</span> @cred.Value
|
||
</div>
|
||
}
|
||
</div>
|
||
<button class="btn btn-xs btn-outline-secondary mt-2" @onclick="() => regPgCredDbId = null">
|
||
<i class="bi bi-eye-slash me-1"></i>Hide
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
}
|
||
@if (!string.IsNullOrEmpty(regPgDbActionError) && regPgDbActionErrorId == db.Id)
|
||
{
|
||
<tr>
|
||
<td colspan="4" class="p-1">
|
||
<div class="alert alert-danger small py-1 mb-0">@regPgDbActionError</div>
|
||
</td>
|
||
</tr>
|
||
}
|
||
}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
}
|
||
|
||
@* ── Dump History ── *@
|
||
@if (loadingDumps)
|
||
{
|
||
<div class="text-center py-2 mt-2">
|
||
<span class="spinner-border spinner-border-sm text-secondary me-1"></span>
|
||
<small class="text-muted">Loading dump history...</small>
|
||
</div>
|
||
}
|
||
else if (dumpsByDatabase is not null)
|
||
{
|
||
@foreach (RegisteredPostgresDatabase dumpDb in rpi.Databases)
|
||
{
|
||
@if (dumpsByDatabase.TryGetValue(dumpDb.Id, out List<RegisteredPostgresDump>? dumps) && dumps.Count > 0)
|
||
{
|
||
<div class="mt-3">
|
||
<h6 class="small fw-semibold text-muted text-uppercase mb-2">
|
||
<i class="bi bi-clock-history me-1"></i>Dumps — @dumpDb.Name
|
||
</h6>
|
||
<div class="table-responsive">
|
||
<table class="table table-sm table-hover mb-0">
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th class="small">Created</th>
|
||
<th class="small">Size</th>
|
||
<th class="small">Bucket</th>
|
||
<th class="small text-end">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
@foreach (RegisteredPostgresDump dump in dumps)
|
||
{
|
||
<tr>
|
||
<td class="small align-middle">@dump.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td>
|
||
<td class="small align-middle text-muted">@FormatBytes(dump.SizeBytes)</td>
|
||
<td class="small align-middle text-muted">@dump.StorageLink.Name</td>
|
||
<td class="small align-middle text-end">
|
||
<div class="d-flex gap-1 justify-content-end">
|
||
<button class="btn btn-xs btn-outline-primary border-0" title="Restore to CNPG"
|
||
@onclick="() => ShowDumpRestoreForm(dump)">
|
||
<i class="bi bi-arrow-right-circle"></i>
|
||
</button>
|
||
<button class="btn btn-xs btn-outline-danger border-0" title="Delete dump"
|
||
@onclick="() => DeleteRegPgDump(dump, rpi.Id)"
|
||
disabled="@(deletingDumpId == dump.Id)">
|
||
@if (deletingDumpId == dump.Id)
|
||
{
|
||
<span class="spinner-border spinner-border-sm"></span>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-trash"></i>
|
||
}
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
@if (showRestoreFormForDumpId == dump.Id)
|
||
{
|
||
<tr class="table-light">
|
||
<td colspan="4" class="p-2">
|
||
<div class="row g-2 align-items-end">
|
||
<div class="col-md-5">
|
||
<label class="form-label small mb-1">Target CNPG Cluster</label>
|
||
<select class="form-select form-select-sm" @bind="restoreFormCnpgClusterId">
|
||
<option value="@Guid.Empty">Select cluster...</option>
|
||
@if (managedClusters is not null)
|
||
{
|
||
@foreach (CnpgCluster c in managedClusters)
|
||
{
|
||
<option value="@c.Id">@c.Name (@c.KubernetesCluster.Environment.Name)</option>
|
||
}
|
||
}
|
||
</select>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small mb-1">New Database Name</label>
|
||
<input class="form-control form-control-sm" @bind="restoreFormNewDbName" placeholder="@dumpDb.Name" />
|
||
</div>
|
||
<div class="col-auto d-flex gap-2">
|
||
<button class="btn btn-sm btn-primary" @onclick="() => RestoreRegPgDump(dump)"
|
||
disabled="@(restoringDumpId == dump.Id || restoreFormCnpgClusterId == Guid.Empty || string.IsNullOrWhiteSpace(restoreFormNewDbName))">
|
||
@if (restoringDumpId == dump.Id)
|
||
{
|
||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||
<text>Restoring...</text>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-arrow-right-circle me-1"></i>
|
||
<text>Restore to CNPG</text>
|
||
}
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showRestoreFormForDumpId = null">Cancel</button>
|
||
</div>
|
||
</div>
|
||
@if (!string.IsNullOrEmpty(dumpRestoreError) && dumpRestoreErrorDumpId == dump.Id)
|
||
{
|
||
<div class="alert alert-danger small py-1 mt-2 mb-0">@dumpRestoreError</div>
|
||
}
|
||
@if (!string.IsNullOrEmpty(dumpRestoreSuccess) && dumpRestoreErrorDumpId == dump.Id)
|
||
{
|
||
<div class="alert alert-success small py-1 mt-2 mb-0">@dumpRestoreSuccess</div>
|
||
}
|
||
</td>
|
||
</tr>
|
||
}
|
||
}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
}
|
||
}
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
}
|
||
</div>
|
||
|
||
@* ── Managed CNPG Clusters ── *@
|
||
@if (operatorStatus!.CnpgAvailable)
|
||
{
|
||
<div class="mb-4">
|
||
<h5 class="d-flex align-items-center gap-2 mb-3">
|
||
<i class="bi bi-database-add text-primary"></i>Managed PostgreSQL Clusters
|
||
@if (FilteredManagedClusters is not null)
|
||
{
|
||
<span class="badge bg-primary rounded-pill">@FilteredManagedClusters.Count</span>
|
||
}
|
||
<button class="btn btn-sm btn-primary ms-auto" @onclick="ShowCreateForm" disabled="@(showCreate || showImportCnpg)">
|
||
<i class="bi bi-plus-lg me-1"></i>Create Cluster
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-primary" @onclick="ShowImportCnpgForm" disabled="@(showCreate || showImportCnpg)">
|
||
<i class="bi bi-box-arrow-in-down me-1"></i>Import Existing
|
||
</button>
|
||
</h5>
|
||
|
||
@* ── Create Cluster Form ── *@
|
||
@if (showCreate)
|
||
{
|
||
<div class="card shadow-sm mb-3 border-primary">
|
||
<div class="card-header bg-primary bg-opacity-10 d-flex align-items-center justify-content-between">
|
||
<h6 class="mb-0"><i class="bi bi-database-add me-2"></i>New CNPG Cluster</h6>
|
||
<button class="btn btn-sm btn-outline-secondary border-0" @onclick="CancelCreate"><i class="bi bi-x-lg"></i></button>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row g-3">
|
||
<div class="col-md-6">
|
||
<label class="form-label small">Cluster Name</label>
|
||
<input class="form-control form-control-sm" @bind="createName" placeholder="my-postgres" />
|
||
</div>
|
||
<div class="col-md-6">
|
||
<label class="form-label small">Namespace</label>
|
||
<input class="form-control form-control-sm" @bind="createNamespace" placeholder="databases" />
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Target Cluster</label>
|
||
<select class="form-select form-select-sm" @bind="createClusterId">
|
||
<option value="@Guid.Empty">Select cluster...</option>
|
||
@if (availableClusters is not null)
|
||
{
|
||
@foreach (KubernetesCluster k in availableClusters)
|
||
{
|
||
<option value="@k.Id">@k.Name</option>
|
||
}
|
||
}
|
||
</select>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">PostgreSQL Version</label>
|
||
<select class="form-select form-select-sm" @bind="createPostgresVersion">
|
||
<option value="18">PostgreSQL 18</option>
|
||
<option value="17">PostgreSQL 17</option>
|
||
<option value="16">PostgreSQL 16</option>
|
||
<option value="15">PostgreSQL 15</option>
|
||
<option value="14">PostgreSQL 14</option>
|
||
</select>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Instances (HA)</label>
|
||
<select class="form-select form-select-sm" @bind="createInstances">
|
||
<option value="1">1 (Standalone)</option>
|
||
<option value="2">2 (Primary + Replica)</option>
|
||
<option value="3">3 (HA)</option>
|
||
</select>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Storage Size</label>
|
||
<input class="form-control form-control-sm" @bind="createStorageSize" placeholder="10Gi" />
|
||
</div>
|
||
<div class="col-md-6">
|
||
<label class="form-label small">Backup S3 Bucket (optional)</label>
|
||
<select class="form-select form-select-sm" @bind="createStorageLinkId">
|
||
<option value="@Guid.Empty">No backup</option>
|
||
@if (storageLinks is not null)
|
||
{
|
||
@foreach (StorageLink sl in storageLinks)
|
||
{
|
||
<option value="@sl.Id">@sl.Name (@sl.BucketName)</option>
|
||
}
|
||
}
|
||
</select>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Backup Schedule (cron, optional)</label>
|
||
<input class="form-control form-control-sm" @bind="createSchedule" placeholder="0 0 2 * * *" />
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Backup Retention (days)</label>
|
||
<input type="number" class="form-control form-control-sm" @bind="createRetentionDays" min="1" max="365" />
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Max backups</label>
|
||
<input type="number" class="form-control form-control-sm" @bind="createMaxBackups" min="1" max="200" />
|
||
</div>
|
||
</div>
|
||
<div class="mt-3 d-flex align-items-center gap-2">
|
||
<button class="btn btn-sm btn-primary" @onclick="CreateCluster" disabled="@creating">
|
||
@if (creating)
|
||
{
|
||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||
<text>Creating...</text>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-plus-lg me-1"></i>
|
||
<text>Create PostgreSQL @createPostgresVersion Cluster</text>
|
||
}
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelCreate">Cancel</button>
|
||
</div>
|
||
@if (!string.IsNullOrEmpty(createError))
|
||
{
|
||
<div class="alert alert-danger small mt-2 mb-0 py-2">
|
||
<i class="bi bi-exclamation-triangle me-1"></i>@createError
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
@* ── Import Existing CNPG Cluster Form ── *@
|
||
@if (showImportCnpg)
|
||
{
|
||
<div class="card shadow-sm mb-3 border-primary">
|
||
<div class="card-header bg-primary bg-opacity-10 d-flex align-items-center justify-content-between">
|
||
<h6 class="mb-0"><i class="bi bi-box-arrow-in-down me-2"></i>Import Existing CNPG Cluster</h6>
|
||
<button class="btn btn-sm btn-outline-secondary border-0" @onclick="CancelImportCnpg"><i class="bi bi-x-lg"></i></button>
|
||
</div>
|
||
<div class="card-body">
|
||
<p class="text-muted small mb-3">Register a CloudNativePG cluster that is already running in Kubernetes. EntKube will read its configuration from the Cluster CRD and store the superuser credentials in vault. No Kubernetes resources are created or modified.</p>
|
||
<div class="row g-3">
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Kubernetes Cluster</label>
|
||
<select class="form-select form-select-sm" @bind="importCnpgK8sClusterId">
|
||
<option value="@Guid.Empty">Select cluster...</option>
|
||
@if (availableClusters is not null)
|
||
{
|
||
@foreach (KubernetesCluster k in availableClusters)
|
||
{
|
||
<option value="@k.Id">@k.Name</option>
|
||
}
|
||
}
|
||
</select>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">CNPG Cluster Name</label>
|
||
<input class="form-control form-control-sm" @bind="importCnpgName" placeholder="my-postgres" />
|
||
<div class="form-text small">Kubernetes resource name (metadata.name)</div>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Namespace</label>
|
||
<input class="form-control form-control-sm" @bind="importCnpgNamespace" placeholder="databases" />
|
||
</div>
|
||
</div>
|
||
<div class="mt-3 d-flex align-items-center gap-2">
|
||
<button class="btn btn-sm btn-primary" @onclick="ImportCnpgCluster" disabled="@importingCnpg">
|
||
@if (importingCnpg)
|
||
{
|
||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||
<text>Importing...</text>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-box-arrow-in-down me-1"></i>
|
||
<text>Import</text>
|
||
}
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelImportCnpg">Cancel</button>
|
||
</div>
|
||
@if (!string.IsNullOrEmpty(importCnpgError))
|
||
{
|
||
<div class="alert alert-danger small mt-2 mb-0 py-2">
|
||
<i class="bi bi-exclamation-triangle me-1"></i>@importCnpgError
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
@* ── Managed Cluster Cards ── *@
|
||
@if (FilteredManagedClusters is null)
|
||
{
|
||
<div class="text-center py-3">
|
||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||
</div>
|
||
}
|
||
else if (FilteredManagedClusters.Count == 0 && !showCreate && !showImportCnpg)
|
||
{
|
||
<div class="alert alert-light border">
|
||
<i class="bi bi-inbox me-2"></i>
|
||
No managed clusters yet. Click "Create Cluster" to provision a new PostgreSQL 18 cluster.
|
||
</div>
|
||
}
|
||
else
|
||
{
|
||
@foreach (CnpgCluster mc in FilteredManagedClusters)
|
||
{
|
||
<div class="card shadow-sm mb-3">
|
||
<div class="card-header bg-white d-flex align-items-center justify-content-between py-2">
|
||
<div class="d-flex align-items-center gap-2 cursor-pointer" @onclick="() => ToggleClusterDetail(mc)">
|
||
<i class="bi @(expandedClusterId == mc.Id ? "bi-chevron-down" : "bi-chevron-right") text-muted"></i>
|
||
<i class="bi bi-database text-primary"></i>
|
||
<strong>@mc.Name</strong>
|
||
<span class="badge bg-light text-dark border small">PG @mc.PostgresVersion</span>
|
||
@(GetManagedStatusBadge(mc.Status))
|
||
<span class="badge bg-primary-subtle text-primary border border-primary-subtle small">
|
||
<i class="bi bi-layers me-1"></i>@mc.KubernetesCluster.Environment.Name
|
||
</span>
|
||
@if (mc.IsExternal)
|
||
{
|
||
<span class="badge bg-info-subtle text-info border border-info-subtle small" title="Registered from existing deployment — not provisioned by EntKube">
|
||
<i class="bi bi-box-arrow-in-down me-1"></i>External
|
||
</span>
|
||
}
|
||
@if (mc.Databases.Count > 0)
|
||
{
|
||
<span class="badge bg-secondary-subtle text-secondary">@mc.Databases.Count db(s)</span>
|
||
}
|
||
</div>
|
||
<div class="d-flex gap-1">
|
||
<button class="btn btn-sm btn-outline-primary border-0" title="Add Database"
|
||
@onclick="() => ShowAddDatabase(mc)">
|
||
<i class="bi bi-plus-circle"></i>
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary border-0" title="Import Existing Database"
|
||
@onclick="() => ShowImportDatabase(mc)">
|
||
<i class="bi bi-box-arrow-in-down"></i>
|
||
</button>
|
||
@if (mc.StorageLinkId.HasValue)
|
||
{
|
||
<button class="btn btn-sm btn-outline-info border-0" title="Backup Now"
|
||
@onclick="() => TriggerBackup(mc.Id)" disabled="@(backingUpId == mc.Id)">
|
||
@if (backingUpId == mc.Id)
|
||
{
|
||
<span class="spinner-border spinner-border-sm"></span>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-cloud-arrow-up"></i>
|
||
}
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-info border-0"
|
||
title="@(string.IsNullOrWhiteSpace(mc.BackupSchedule) ? "Configure Scheduled Backup" : $"Apply Scheduled Backup ({mc.BackupSchedule})")"
|
||
@onclick="() => ToggleScheduleForm(mc)" disabled="@(applyingScheduledBackupId == mc.Id)">
|
||
@if (applyingScheduledBackupId == mc.Id)
|
||
{
|
||
<span class="spinner-border spinner-border-sm"></span>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-calendar-check"></i>
|
||
}
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-warning border-0" title="Point-in-Time Restore"
|
||
@onclick="() => ShowRestore(mc)">
|
||
<i class="bi bi-clock-history"></i>
|
||
</button>
|
||
}
|
||
<button class="btn btn-sm btn-outline-secondary border-0" title="Upgrade"
|
||
@onclick="() => ShowUpgrade(mc)">
|
||
<i class="bi bi-arrow-up-circle"></i>
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary border-0"
|
||
title="Fetch the PostgreSQL superuser credentials from the CNPG Kubernetes secret and store them in vault"
|
||
disabled="@(clusterSuperuserFetchingId == mc.Id)"
|
||
@onclick="() => FetchClusterSuperuserPassword(mc)">
|
||
@if (clusterSuperuserFetchingId == 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"
|
||
@onclick="() => cnpgDeleteConfirmId = mc.Id" disabled="@(deletingId == mc.Id)">
|
||
@if (deletingId == mc.Id)
|
||
{
|
||
<span class="spinner-border spinner-border-sm"></span>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-trash"></i>
|
||
}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
@* ── CNPG Delete Confirmation ── *@
|
||
@if (cnpgDeleteConfirmId == mc.Id)
|
||
{
|
||
<div class="card-body border-top py-2 bg-danger-subtle">
|
||
<div class="d-flex align-items-center gap-3 flex-wrap">
|
||
<i class="bi bi-exclamation-triangle text-danger"></i>
|
||
<div>
|
||
<span class="small fw-semibold">Delete <strong>@mc.Name</strong>?</span>
|
||
@if (mc.IsExternal)
|
||
{
|
||
<span class="small text-muted ms-2">This is an externally registered cluster.</span>
|
||
}
|
||
else
|
||
{
|
||
<span class="small text-muted ms-2">This will remove all Kubernetes resources and cannot be undone.</span>
|
||
}
|
||
</div>
|
||
<div class="ms-auto d-flex gap-2">
|
||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => cnpgDeleteConfirmId = null">Cancel</button>
|
||
@if (mc.IsExternal)
|
||
{
|
||
<button class="btn btn-sm btn-outline-secondary" title="Remove from EntKube only — Kubernetes resources are left untouched"
|
||
@onclick="() => UnregisterCluster(mc.Id)" disabled="@(deletingId == mc.Id)">
|
||
@if (deletingId == mc.Id) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||
<i class="bi bi-box-arrow-up me-1"></i>Unregister
|
||
</button>
|
||
}
|
||
<button class="btn btn-sm btn-danger" @onclick="() => DeleteCluster(mc.Id)" disabled="@(deletingId == mc.Id)">
|
||
@if (deletingId == mc.Id) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||
<i class="bi bi-trash me-1"></i>Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
@* ── Cluster-level operation feedback ── *@
|
||
@if (clusterMessages.TryGetValue(mc.Id, out string? clusterMsg))
|
||
{
|
||
<div class="px-3 py-1 border-top">
|
||
<div class="alert @(clusterMsg.StartsWith("Error") ? "alert-danger" : "alert-success") py-1 px-2 small mb-0">
|
||
@clusterMsg
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
@* ── Expanded Detail Panel ── *@
|
||
@if (expandedClusterId == mc.Id)
|
||
{
|
||
<div class="card-body pt-2 pb-3">
|
||
@if (loadingDetail)
|
||
{
|
||
<div class="text-center py-3">
|
||
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||
<small class="text-muted ms-2">Querying cluster status...</small>
|
||
</div>
|
||
}
|
||
else if (clusterDetail is not null)
|
||
{
|
||
@* ── Cluster Overview ── *@
|
||
<div class="row g-2 mb-3">
|
||
<div class="col-md-3">
|
||
<div class="border rounded p-2 text-center small">
|
||
<div class="text-muted">Phase</div>
|
||
<div class="fw-semibold">
|
||
@if (clusterDetail.Phase.Contains("healthy", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
<span class="text-success"><i class="bi bi-check-circle me-1"></i>@clusterDetail.Phase</span>
|
||
}
|
||
else if (clusterDetail.Phase.Contains("Creating", StringComparison.OrdinalIgnoreCase)
|
||
|| clusterDetail.Phase.Contains("Setting", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
<span class="text-warning"><i class="bi bi-hourglass me-1"></i>@clusterDetail.Phase</span>
|
||
}
|
||
else
|
||
{
|
||
<span>@clusterDetail.Phase</span>
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="border rounded p-2 text-center small">
|
||
<div class="text-muted">Ready</div>
|
||
<div class="fw-semibold">@clusterDetail.ReadyInstances / @mc.Instances</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="border rounded p-2 text-center small">
|
||
<div class="text-muted">Primary</div>
|
||
<div class="fw-semibold text-truncate">@(clusterDetail.CurrentPrimary ?? "—")</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="border rounded p-2 text-center small">
|
||
<div class="text-muted">Info</div>
|
||
<div class="fw-semibold">
|
||
<span class="me-2"><i class="bi bi-server me-1"></i>@mc.KubernetesCluster.Name</span>
|
||
<span><i class="bi bi-box me-1"></i>@mc.Namespace</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
@* ── Pod Table ── *@
|
||
<h6 class="small text-muted mb-2"><i class="bi bi-hdd-stack me-1"></i>Pods</h6>
|
||
@if (clusterDetail.Pods.Count > 0)
|
||
{
|
||
<div class="table-responsive mb-3">
|
||
<table class="table table-sm table-hover small mb-0">
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th>Pod</th>
|
||
<th>Role</th>
|
||
<th>Status</th>
|
||
<th>Ready</th>
|
||
<th>Node</th>
|
||
<th>Age</th>
|
||
<th>Restarts</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
@foreach (CnpgPodInfo pod in clusterDetail.Pods)
|
||
{
|
||
<tr>
|
||
<td class="font-monospace">@pod.Name</td>
|
||
<td>
|
||
@if (pod.Role == "primary")
|
||
{
|
||
<span class="badge bg-primary">
|
||
<i class="bi bi-star-fill me-1"></i>Primary
|
||
</span>
|
||
}
|
||
else
|
||
{
|
||
<span class="badge bg-secondary-subtle text-secondary">
|
||
<i class="bi bi-arrow-repeat me-1"></i>Replica
|
||
</span>
|
||
}
|
||
</td>
|
||
<td>
|
||
@if (pod.Status == "Running")
|
||
{
|
||
<span class="text-success"><i class="bi bi-circle-fill me-1" style="font-size:0.5rem;"></i>Running</span>
|
||
}
|
||
else if (pod.Status == "Pending")
|
||
{
|
||
<span class="text-warning"><i class="bi bi-circle-fill me-1" style="font-size:0.5rem;"></i>Pending</span>
|
||
}
|
||
else
|
||
{
|
||
<span class="text-danger"><i class="bi bi-circle-fill me-1" style="font-size:0.5rem;"></i>@pod.Status</span>
|
||
}
|
||
</td>
|
||
<td>
|
||
@if (pod.Ready)
|
||
{
|
||
<i class="bi bi-check-circle text-success"></i>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-x-circle text-danger"></i>
|
||
}
|
||
</td>
|
||
<td class="text-truncate" style="max-width: 150px;">@(pod.Node ?? "—")</td>
|
||
<td>@FormatAge(pod.StartTime)</td>
|
||
<td>
|
||
@if (pod.Restarts > 0)
|
||
{
|
||
<span class="text-warning">@pod.Restarts</span>
|
||
}
|
||
else
|
||
{
|
||
<span class="text-muted">0</span>
|
||
}
|
||
</td>
|
||
</tr>
|
||
}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
}
|
||
else
|
||
{
|
||
<div class="alert alert-warning small py-2 mb-3">
|
||
<i class="bi bi-exclamation-triangle me-1"></i>No pods found. Cluster may still be initializing.
|
||
</div>
|
||
}
|
||
|
||
@* ── Databases ── *@
|
||
<h6 class="small text-muted mb-2 d-flex align-items-center">
|
||
<i class="bi bi-table me-1"></i>Databases
|
||
<span class="badge bg-primary rounded-pill ms-2">@mc.Databases.Count</span>
|
||
</h6>
|
||
@if (mc.Databases.Count > 0)
|
||
{
|
||
<div class="table-responsive mb-3">
|
||
<table class="table table-sm small mb-0">
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th>Database</th>
|
||
<th>Owner</th>
|
||
<th>Status</th>
|
||
<th>Created</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
@foreach (CnpgDatabase cdb in mc.Databases)
|
||
{
|
||
<tr>
|
||
<td class="font-monospace">@cdb.Name</td>
|
||
<td class="font-monospace text-muted">@cdb.Owner</td>
|
||
<td>
|
||
@if (cdb.Status == CnpgDatabaseStatus.Ready)
|
||
{
|
||
<span class="badge bg-success-subtle text-success">Ready</span>
|
||
}
|
||
else if (cdb.Status == CnpgDatabaseStatus.Creating)
|
||
{
|
||
<span class="badge bg-warning-subtle text-warning">Creating</span>
|
||
}
|
||
else
|
||
{
|
||
<span class="badge bg-danger-subtle text-danger">@cdb.Status</span>
|
||
}
|
||
</td>
|
||
<td class="text-muted">@cdb.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td>
|
||
<td>
|
||
<button class="btn btn-link btn-sm text-info p-0 me-2" title="View credentials"
|
||
@onclick="() => ToggleDatabaseCredentials(mc.Id, cdb.Id)">
|
||
<i class="bi bi-key" style="font-size:0.8rem;"></i>
|
||
</button>
|
||
<button class="btn btn-link btn-sm text-primary p-0 me-2" title="Sync credentials to K8s Secret"
|
||
@onclick="() => SyncDatabaseCredentialsToK8s(mc.Id, cdb.Id)">
|
||
<i class="bi bi-cloud-arrow-up" style="font-size:0.8rem;"></i>
|
||
</button>
|
||
<button class="btn btn-link btn-sm text-warning p-0 me-2" title="Rotate password"
|
||
disabled="@(rotatingDatabaseId == cdb.Id)"
|
||
@onclick="() => RotateDatabasePassword(mc.Id, cdb.Id)">
|
||
@if (rotatingDatabaseId == cdb.Id)
|
||
{
|
||
<span class="spinner-border spinner-border-sm" role="status" style="width:0.7rem;height:0.7rem;"></span>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-arrow-clockwise" style="font-size:0.8rem;"></i>
|
||
}
|
||
</button>
|
||
<button class="btn btn-link btn-sm text-danger p-0 me-2" title="Drop database"
|
||
@onclick="() => cnpgDbDeleteConfirmId = cdb.Id">
|
||
<i class="bi bi-trash" style="font-size:0.8rem;"></i>
|
||
</button>
|
||
@if (dbOwnershipOk.TryGetValue(cdb.Id, out bool ownerOk) && ownerOk)
|
||
{
|
||
<i class="bi bi-shield-check text-success me-2" style="font-size:0.8rem;" title="Database ownership is correct"></i>
|
||
}
|
||
else
|
||
{
|
||
<button class="btn btn-link btn-sm text-warning p-0 me-2"
|
||
title="Grant all schema privileges and transfer ownership to the database owner (fixes 'permission denied' after a pg_restore)"
|
||
disabled="@(dbFixBusyId == cdb.Id)"
|
||
@onclick="() => FixDatabaseOwnerPermissions(mc, cdb)">
|
||
@if (dbFixBusyId == cdb.Id)
|
||
{
|
||
<span class="spinner-border spinner-border-sm" style="width:0.7rem;height:0.7rem;"></span>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-shield-check" style="font-size:0.8rem;"></i>
|
||
}
|
||
</button>
|
||
}
|
||
<button class="btn btn-link btn-sm text-danger p-0"
|
||
title="Release a stuck Liquibase changelog lock (fixes Keycloak hanging at startup with empty 500 responses)"
|
||
disabled="@(dbFixBusyId == cdb.Id)"
|
||
@onclick="() => ReleaseLiquibaseLock(mc, cdb)">
|
||
<i class="bi bi-unlock" style="font-size:0.8rem;"></i>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
@if (cnpgDbDeleteConfirmId == cdb.Id)
|
||
{
|
||
<tr class="table-danger">
|
||
<td colspan="5" class="py-2 px-2">
|
||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||
<i class="bi bi-exclamation-triangle text-danger small"></i>
|
||
<span class="small">Drop <strong class="font-monospace">@cdb.Name</strong>? This drops the PostgreSQL database and removes all credentials.</span>
|
||
<div class="ms-auto d-flex gap-2">
|
||
<button class="btn btn-xs btn-outline-secondary" @onclick="() => cnpgDbDeleteConfirmId = null">Cancel</button>
|
||
<button class="btn btn-xs btn-danger" @onclick="() => DeleteDatabase(mc.Id, cdb.Id)">Drop</button>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
}
|
||
@if (dbFixMessageId == cdb.Id && dbFixMessage is not null)
|
||
{
|
||
<tr>
|
||
<td colspan="5" class="p-0">
|
||
<div class="alert @(dbFixMessage.StartsWith("Error") ? "alert-danger" : "alert-success") py-1 px-2 mb-0 small rounded-0">
|
||
@dbFixMessage
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
}
|
||
@if (credentialsDatabaseId == cdb.Id && databaseCredentials is not null)
|
||
{
|
||
<tr>
|
||
<td colspan="5" class="bg-light p-0">
|
||
<div class="p-2">
|
||
<table class="table table-sm table-borderless mb-1" style="font-size:0.75rem;">
|
||
<tbody>
|
||
@foreach (KeyValuePair<string, string> cred in databaseCredentials)
|
||
{
|
||
<tr>
|
||
<td class="text-muted fw-bold" style="width:100px;">@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}-{cdb.Name}-credentials")</code> in namespace <code>@mc.Namespace</code>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
}
|
||
}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
}
|
||
else
|
||
{
|
||
<p class="small text-muted mb-3">No databases created yet. Use the <i class="bi bi-plus-circle"></i> button to add one.</p>
|
||
}
|
||
|
||
@* ── Backups ── *@
|
||
<h6 class="small text-muted mb-2 d-flex align-items-center gap-2">
|
||
<i class="bi bi-cloud-arrow-up me-1"></i>Backups
|
||
<span class="badge bg-info rounded-pill">@clusterDetail.Backups.Count</span>
|
||
@if (mc.StorageLinkId.HasValue)
|
||
{
|
||
<span class="text-muted fw-normal ms-1">
|
||
@if (!string.IsNullOrWhiteSpace(mc.BackupSchedule))
|
||
{
|
||
<span class="me-2"><i class="bi bi-clock me-1"></i>@mc.BackupSchedule</span>
|
||
}
|
||
<span class="me-2"><i class="bi bi-calendar-x me-1"></i>@mc.RetentionDays d</span>
|
||
<span><i class="bi bi-stack me-1"></i>max @mc.MaxBackups</span>
|
||
</span>
|
||
<button class="btn btn-xs btn-outline-danger border-0 ms-auto" title="Remove oldest Backup CRs from Kubernetes beyond the max limit. Does NOT delete backup data from S3 — Barman handles that via the retention policy."
|
||
@onclick="() => CleanupBackups(mc)" disabled="@(cleaningUpBackupsId == mc.Id)">
|
||
@if (cleaningUpBackupsId == mc.Id)
|
||
{
|
||
<span class="spinner-border spinner-border-sm"></span>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-trash3"></i>
|
||
}
|
||
</button>
|
||
}
|
||
</h6>
|
||
@if (clusterDetail.Backups.Count > 0)
|
||
{
|
||
<div class="table-responsive">
|
||
<table class="table table-sm small mb-0">
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th>Name</th>
|
||
<th>Type</th>
|
||
<th>Status</th>
|
||
<th>Started</th>
|
||
<th>Completed</th>
|
||
<th>Size</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
@foreach (CnpgBackup bk in clusterDetail.Backups)
|
||
{
|
||
<tr class="@(selectedCnpgBackup?.Name == bk.Name && selectedCnpgBackupCluster?.Id == mc.Id ? "table-warning" : "")"
|
||
style="cursor:pointer" title="Click to restore from this backup"
|
||
@onclick="() => SelectCnpgBackup(mc, bk)">
|
||
<td class="font-monospace text-truncate" style="max-width: 200px;">@bk.Name</td>
|
||
<td>
|
||
@if (bk.Type == CnpgBackupType.Scheduled)
|
||
{
|
||
<span class="badge bg-light text-dark border"><i class="bi bi-clock me-1"></i>Scheduled</span>
|
||
}
|
||
else
|
||
{
|
||
<span class="badge bg-light text-dark border"><i class="bi bi-hand-index me-1"></i>On-Demand</span>
|
||
}
|
||
</td>
|
||
<td>
|
||
@if (bk.Status == CnpgBackupStatus.Completed)
|
||
{
|
||
<span class="badge bg-success-subtle text-success"><i class="bi bi-check-circle me-1"></i>Completed</span>
|
||
}
|
||
else if (bk.Status == CnpgBackupStatus.Running)
|
||
{
|
||
<span class="badge bg-warning-subtle text-warning"><i class="bi bi-hourglass-split me-1"></i>Running</span>
|
||
}
|
||
else
|
||
{
|
||
<span class="badge bg-danger-subtle text-danger"><i class="bi bi-x-circle me-1"></i>Failed</span>
|
||
}
|
||
</td>
|
||
<td class="text-muted">@bk.StartedAt.ToString("yyyy-MM-dd HH:mm")</td>
|
||
<td class="text-muted">@(bk.CompletedAt?.ToString("HH:mm") ?? "—")</td>
|
||
<td class="text-muted">@(bk.SizeBytes.HasValue ? FormatBytes(bk.SizeBytes.Value) : "—")</td>
|
||
</tr>
|
||
}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
}
|
||
else if (mc.StorageLinkId.HasValue)
|
||
{
|
||
<p class="small text-muted mb-2">No backups yet. Click <i class="bi bi-cloud-arrow-up"></i> to create one.</p>
|
||
}
|
||
else
|
||
{
|
||
<p class="small text-muted">Backups are not configured. Assign an S3 bucket to enable backups.</p>
|
||
}
|
||
|
||
@* ── Backup Restore Panel (CNPG) ── *@
|
||
@if (selectedCnpgBackup != null && selectedCnpgBackupCluster?.Id == mc.Id)
|
||
{
|
||
<div class="card border-warning mt-2">
|
||
<div class="card-header bg-warning bg-opacity-10 d-flex align-items-center justify-content-between py-2">
|
||
<h6 class="mb-0 small">
|
||
<i class="bi bi-clock-history me-2"></i>Restore from <strong>@selectedCnpgBackup.Name</strong>
|
||
<span class="text-muted ms-2">(@(selectedCnpgBackup.CompletedAt?.ToString("yyyy-MM-dd HH:mm") ?? selectedCnpgBackup.StartedAt.ToString("yyyy-MM-dd HH:mm")))</span>
|
||
</h6>
|
||
<button class="btn btn-sm btn-outline-secondary border-0"
|
||
@onclick="() => { selectedCnpgBackup = null; selectedCnpgBackupCluster = null; backupRestoreError = null; }">
|
||
<i class="bi bi-x-lg"></i>
|
||
</button>
|
||
</div>
|
||
<div class="card-body py-2">
|
||
<div class="d-flex gap-4 mb-3">
|
||
<div class="form-check">
|
||
<input class="form-check-input" type="radio" name="cnpg-restore-target-@mc.Id"
|
||
id="cnpg-rt-current-@mc.Id" checked="@(backupRestoreTarget == "current")"
|
||
@onchange="@(_ => backupRestoreTarget = "current")" />
|
||
<label class="form-check-label small" for="cnpg-rt-current-@mc.Id">
|
||
<i class="bi bi-arrow-counterclockwise me-1"></i>Current cluster
|
||
</label>
|
||
</div>
|
||
<div class="form-check">
|
||
<input class="form-check-input" type="radio" name="cnpg-restore-target-@mc.Id"
|
||
id="cnpg-rt-new-@mc.Id" checked="@(backupRestoreTarget == "new")"
|
||
@onchange="@(_ => backupRestoreTarget = "new")" />
|
||
<label class="form-check-label small" for="cnpg-rt-new-@mc.Id">
|
||
<i class="bi bi-plus-circle me-1"></i>New cluster
|
||
</label>
|
||
</div>
|
||
<div class="form-check">
|
||
<input class="form-check-input" type="radio" name="cnpg-restore-target-@mc.Id"
|
||
id="cnpg-rt-external-@mc.Id" checked="@(backupRestoreTarget == "external")"
|
||
@onchange="@(_ => backupRestoreTarget = "external")" />
|
||
<label class="form-check-label small" for="cnpg-rt-external-@mc.Id">
|
||
<i class="bi bi-cloud me-1"></i>External connection
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
@if (backupRestoreTarget == "current")
|
||
{
|
||
<div class="alert alert-warning small py-2 mb-2">
|
||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||
<strong>Destructive:</strong> Deletes the running cluster and recreates it from this backup. All data after the backup point is lost.
|
||
</div>
|
||
}
|
||
else if (backupRestoreTarget == "new")
|
||
{
|
||
<div class="row g-2 mb-2">
|
||
<div class="col-md-6">
|
||
<label class="form-label small">New Cluster Name</label>
|
||
<input class="form-control form-control-sm" @bind="backupRestoreNewName" placeholder="@(mc.Name + "-restored")" />
|
||
</div>
|
||
</div>
|
||
}
|
||
else if (backupRestoreTarget == "external")
|
||
{
|
||
<div class="alert alert-info small py-2 mb-2">
|
||
<i class="bi bi-info-circle me-1"></i>
|
||
CNPG uses physical (Barman) backups that cannot be directly restored to an external PostgreSQL server.
|
||
Use <strong>New cluster</strong> to restore within Kubernetes, then use <code>pg_dump</code> to export specific databases.
|
||
</div>
|
||
}
|
||
|
||
<div class="d-flex gap-2">
|
||
<button class="btn btn-sm btn-warning" @onclick="ExecuteBackupRestore"
|
||
disabled="@(backupRestoring || backupRestoreTarget == "external")">
|
||
@if (backupRestoring) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||
Restore
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary"
|
||
@onclick="() => { selectedCnpgBackup = null; selectedCnpgBackupCluster = null; backupRestoreError = null; }">
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
|
||
@if (!string.IsNullOrEmpty(backupRestoreError))
|
||
{
|
||
<div class="alert alert-danger small py-1 mt-2 mb-0">@backupRestoreError</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
@if (configuringScheduleId == mc.Id)
|
||
{
|
||
<div class="card border-secondary mt-2">
|
||
<div class="card-header bg-secondary bg-opacity-10 py-2 d-flex align-items-center justify-content-between">
|
||
<span class="small fw-semibold"><i class="bi bi-gear me-1"></i>Backup Settings</span>
|
||
<button class="btn btn-sm btn-outline-secondary border-0" @onclick="CancelScheduleForm"><i class="bi bi-x-lg"></i></button>
|
||
</div>
|
||
<div class="card-body py-2">
|
||
<div class="row g-2 align-items-end">
|
||
<div class="col-md-5">
|
||
<label class="form-label small mb-1">Schedule (6-field cron)</label>
|
||
<input class="form-control form-control-sm" @bind="scheduleInput"
|
||
placeholder="0 0 2 * * * (daily at 02:00)" />
|
||
<div class="form-text" style="font-size:0.7rem;">Leave blank to disable scheduled backups.</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<label class="form-label small mb-1">Retention (days)</label>
|
||
<input type="number" class="form-control form-control-sm"
|
||
@bind="scheduleRetentionDays" min="1" max="365" />
|
||
<div class="form-text" style="font-size:0.7rem;">Barman WAL/data retention window.</div>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label small mb-1">Max backups</label>
|
||
<input type="number" class="form-control form-control-sm"
|
||
@bind="scheduleMaxBackups" min="1" max="200" />
|
||
<div class="form-text" style="font-size:0.7rem;">Backup CRs to keep.</div>
|
||
</div>
|
||
<div class="col-md-2 d-flex gap-2">
|
||
<button class="btn btn-sm btn-primary w-100"
|
||
@onclick="() => SaveBackupSettings(mc.Id)"
|
||
disabled="@(applyingScheduledBackupId == mc.Id)">
|
||
@if (applyingScheduledBackupId == mc.Id)
|
||
{
|
||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||
}
|
||
Save
|
||
</button>
|
||
</div>
|
||
</div>
|
||
@if (scheduleError is not null)
|
||
{
|
||
<div class="alert alert-danger small py-1 mt-2 mb-0">@scheduleError</div>
|
||
}
|
||
@if (scheduleSuccess is not null)
|
||
{
|
||
<div class="alert alert-success small py-1 mt-2 mb-0">@scheduleSuccess</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
}
|
||
|
||
@if (!string.IsNullOrEmpty(mc.LastError))
|
||
{
|
||
<div class="alert alert-danger small py-1 mt-2 mb-0">
|
||
<i class="bi bi-exclamation-triangle me-1"></i>@mc.LastError
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
else
|
||
{
|
||
@* ── Collapsed Summary Row ── *@
|
||
<div class="card-body py-2">
|
||
<div class="row small text-muted">
|
||
<div class="col-auto">
|
||
<i class="bi bi-server me-1"></i>@mc.KubernetesCluster.Name
|
||
</div>
|
||
<div class="col-auto">
|
||
<i class="bi bi-box me-1"></i>@mc.Namespace
|
||
</div>
|
||
<div class="col-auto">
|
||
<i class="bi bi-hdd-stack me-1"></i>@mc.Instances instance(s)
|
||
</div>
|
||
<div class="col-auto">
|
||
<i class="bi bi-device-hdd me-1"></i>@mc.StorageSize
|
||
</div>
|
||
@if (mc.StorageLink is not null)
|
||
{
|
||
<div class="col-auto">
|
||
<i class="bi bi-cloud me-1"></i>@mc.StorageLink.BucketName
|
||
</div>
|
||
}
|
||
@if (mc.Databases.Count > 0)
|
||
{
|
||
<div class="col-auto">
|
||
<i class="bi bi-table me-1"></i>@string.Join(", ", mc.Databases.Select(d => d.Name))
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
}
|
||
|
||
@* ── Add Database Form ── *@
|
||
@if (addDbCluster is not null)
|
||
{
|
||
<div class="card shadow-sm mb-3 border-success">
|
||
<div class="card-header bg-success bg-opacity-10">
|
||
<h6 class="mb-0"><i class="bi bi-plus-circle me-2"></i>Add Database to <strong>@addDbCluster.Name</strong></h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row g-3 align-items-end">
|
||
<div class="col-md-6">
|
||
<label class="form-label small">Database Name</label>
|
||
<input class="form-control form-control-sm" @bind="newDbName" placeholder="myapp" />
|
||
</div>
|
||
<div class="col-md-6 d-flex gap-2">
|
||
<button class="btn btn-sm btn-success" @onclick="AddDatabase" disabled="@addingDb">
|
||
@if (addingDb) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||
Create
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => addDbCluster = null">Cancel</button>
|
||
</div>
|
||
</div>
|
||
@if (!string.IsNullOrEmpty(dbError))
|
||
{
|
||
<div class="alert alert-danger small py-1 mt-2 mb-0">@dbError</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
@* ── Import Existing Database Form ── *@
|
||
@if (importDbCluster is not null)
|
||
{
|
||
<div class="card shadow-sm mb-3 border-secondary">
|
||
<div class="card-header bg-secondary bg-opacity-10 d-flex align-items-center justify-content-between">
|
||
<h6 class="mb-0"><i class="bi bi-box-arrow-in-down me-2"></i>Import Existing Database into <strong>@importDbCluster.Name</strong></h6>
|
||
<button class="btn btn-sm btn-outline-secondary border-0" @onclick="() => importDbCluster = null"><i class="bi bi-x-lg"></i></button>
|
||
</div>
|
||
<div class="card-body">
|
||
<p class="text-muted small mb-3">Register a database that already exists in this CNPG cluster. Click Discover to load the list of databases, or fill in the fields manually. Credentials are stored in vault — no SQL is executed.</p>
|
||
<div class="d-flex align-items-center gap-2 mb-3">
|
||
<button class="btn btn-sm btn-outline-secondary" @onclick="DiscoverCnpgDatabases" disabled="@discoveringDbs">
|
||
@if (discoveringDbs)
|
||
{
|
||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||
<text>Discovering...</text>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-search me-1"></i>
|
||
<text>Discover Databases</text>
|
||
}
|
||
</button>
|
||
@if (discoveredDbs is not null)
|
||
{
|
||
<span class="text-muted small">@discoveredDbs.Count database(s) found</span>
|
||
}
|
||
@if (!string.IsNullOrEmpty(discoverDbError))
|
||
{
|
||
<span class="text-danger small"><i class="bi bi-exclamation-triangle me-1"></i>@discoverDbError</span>
|
||
}
|
||
</div>
|
||
<div class="row g-3">
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Database Name</label>
|
||
@if (discoveredDbs is { Count: > 0 })
|
||
{
|
||
<select class="form-select form-select-sm" @bind="importDbName" @bind:after="OnImportDbNameChanged">
|
||
<option value="">Select database...</option>
|
||
@foreach ((string name, string owner) in discoveredDbs)
|
||
{
|
||
<option value="@name">@name</option>
|
||
}
|
||
</select>
|
||
}
|
||
else
|
||
{
|
||
<input class="form-control form-control-sm" @bind="importDbName" placeholder="myapp" />
|
||
}
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Owner Role</label>
|
||
<input class="form-control form-control-sm" @bind="importDbOwner" placeholder="myapp_owner" />
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Password</label>
|
||
<input type="password" class="form-control form-control-sm" @bind="importDbPassword" />
|
||
</div>
|
||
</div>
|
||
<div class="mt-3 d-flex gap-2">
|
||
<button class="btn btn-sm btn-secondary" @onclick="ImportCnpgDatabase" disabled="@importingDb">
|
||
@if (importingDb)
|
||
{
|
||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||
<text>Importing...</text>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-box-arrow-in-down me-1"></i>
|
||
<text>Import</text>
|
||
}
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => importDbCluster = null">Cancel</button>
|
||
</div>
|
||
@if (!string.IsNullOrEmpty(importDbError))
|
||
{
|
||
<div class="alert alert-danger small mt-2 mb-0 py-2">
|
||
<i class="bi bi-exclamation-triangle me-1"></i>@importDbError
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
@* ── Restore Form ── *@
|
||
@if (restoreSource is not null)
|
||
{
|
||
<div class="card shadow-sm mb-3 border-warning">
|
||
<div class="card-header bg-warning bg-opacity-10">
|
||
<h6 class="mb-0"><i class="bi bi-clock-history me-2"></i>Point-in-Time Restore from <strong>@restoreSource.Name</strong></h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row g-3 align-items-end">
|
||
<div class="col-md-4">
|
||
<label class="form-label small">New Cluster Name</label>
|
||
<input class="form-control form-control-sm" @bind="restoreName" placeholder="restored-cluster" />
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Target Time (UTC)</label>
|
||
<input type="datetime-local" class="form-control form-control-sm" @bind="restoreTargetTime" />
|
||
</div>
|
||
<div class="col-md-4 d-flex gap-2">
|
||
<button class="btn btn-sm btn-warning" @onclick="ExecuteRestore" disabled="@restoring">
|
||
@if (restoring) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||
Restore
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => restoreSource = null">Cancel</button>
|
||
</div>
|
||
</div>
|
||
@if (!string.IsNullOrEmpty(restoreError))
|
||
{
|
||
<div class="alert alert-danger small py-1 mt-2 mb-0">@restoreError</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
@* ── Upgrade Form ── *@
|
||
@if (upgradeCluster is not null)
|
||
{
|
||
<div class="card shadow-sm mb-3 border-secondary">
|
||
<div class="card-header bg-secondary bg-opacity-10">
|
||
<h6 class="mb-0"><i class="bi bi-arrow-up-circle me-2"></i>Upgrade <strong>@upgradeCluster.Name</strong> (currently PG @upgradeCluster.PostgresVersion)</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row g-3 align-items-end">
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Target Version</label>
|
||
<select class="form-select form-select-sm" @bind="upgradeVersion">
|
||
<option value="18">PostgreSQL 18</option>
|
||
<option value="17">PostgreSQL 17</option>
|
||
<option value="16">PostgreSQL 16</option>
|
||
</select>
|
||
</div>
|
||
<div class="col-md-4 d-flex gap-2">
|
||
<button class="btn btn-sm btn-secondary" @onclick="ExecuteUpgrade" disabled="@upgrading">
|
||
@if (upgrading) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||
Upgrade
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => upgradeCluster = null">Cancel</button>
|
||
</div>
|
||
</div>
|
||
@if (!string.IsNullOrEmpty(upgradeError))
|
||
{
|
||
<div class="alert alert-danger small py-1 mt-2 mb-0">@upgradeError</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
|
||
@* ── Prometheus Metrics for expanded CNPG cluster ── *@
|
||
@if (expandedClusterId.HasValue && managedClusters is not null)
|
||
{
|
||
CnpgCluster? expandedCnpg = managedClusters.FirstOrDefault(c => c.Id == expandedClusterId.Value);
|
||
@if (expandedCnpg is not null)
|
||
{
|
||
<div class="card mb-3">
|
||
<div class="card-header py-2">
|
||
<h6 class="mb-0"><i class="bi bi-bar-chart-line me-1"></i>Prometheus Metrics — @expandedCnpg.Name</h6>
|
||
</div>
|
||
<div class="card-body py-2">
|
||
<DatabaseMonitoringPanel ClusterId="expandedCnpg.KubernetesClusterId" />
|
||
</div>
|
||
</div>
|
||
}
|
||
}
|
||
|
||
@* ── Managed MongoDB Clusters ── *@
|
||
@if (operatorStatus!.MongoDbAvailable)
|
||
{
|
||
<div class="mb-4">
|
||
<h5 class="d-flex align-items-center gap-2 mb-3">
|
||
<i class="bi bi-database-add text-success"></i>Managed MongoDB Clusters
|
||
@if (FilteredManagedMongoClusters is not null)
|
||
{
|
||
<span class="badge bg-success rounded-pill">@FilteredManagedMongoClusters.Count</span>
|
||
}
|
||
<div class="ms-auto d-flex gap-2">
|
||
<button class="btn btn-sm btn-outline-success" @onclick="ShowMongoRegisterForm" disabled="@showMongoRegister">
|
||
<i class="bi bi-box-arrow-in-down me-1"></i>Register Existing
|
||
</button>
|
||
<button class="btn btn-sm btn-success" @onclick="ShowMongoCreateForm" disabled="@showMongoCreate">
|
||
<i class="bi bi-plus-lg me-1"></i>Create Cluster
|
||
</button>
|
||
</div>
|
||
</h5>
|
||
|
||
@* ── Register Existing MongoDB Cluster Form ── *@
|
||
@if (showMongoRegister)
|
||
{
|
||
<div class="card shadow-sm mb-3 border-success">
|
||
<div class="card-header bg-success bg-opacity-10 d-flex align-items-center justify-content-between">
|
||
<h6 class="mb-0"><i class="bi bi-box-arrow-in-down me-2"></i>Register Existing MongoDB Cluster</h6>
|
||
<button class="btn btn-sm btn-outline-secondary border-0" @onclick="CancelMongoRegister"><i class="bi bi-x-lg"></i></button>
|
||
</div>
|
||
<div class="card-body">
|
||
<p class="text-muted small mb-3">Register a MongoDB cluster that was deployed outside EntKube. This creates a tracking record without modifying the live cluster.</p>
|
||
<div class="row g-3">
|
||
<div class="col-md-4">
|
||
<label class="form-label small">MongoDB Resource Name</label>
|
||
<input class="form-control form-control-sm" @bind="mongoRegName" placeholder="my-mongodb" />
|
||
<div class="form-text small">Must match the MongoDBCommunity CR name in Kubernetes (if using community operator)</div>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Namespace</label>
|
||
<input class="form-control form-control-sm" @bind="mongoRegNamespace" placeholder="database" />
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Kubernetes Cluster</label>
|
||
<select class="form-select form-select-sm" @bind="mongoRegClusterId">
|
||
<option value="@Guid.Empty">Select cluster...</option>
|
||
@if (mongoAvailableClusters is not null)
|
||
{
|
||
@foreach (KubernetesCluster k in mongoAvailableClusters)
|
||
{
|
||
<option value="@k.Id">@k.Name (@k.Environment.Name)</option>
|
||
}
|
||
}
|
||
</select>
|
||
</div>
|
||
<div class="col-12">
|
||
<div class="alert alert-info py-2 mb-0 small">
|
||
<i class="bi bi-info-circle me-1"></i>
|
||
After registering, use the <i class="bi bi-gear"></i> settings button to store the admin password as a K8s Secret — this enables S3 backup and migration.
|
||
Version and databases are discovered automatically for community-operator clusters.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mt-3 d-flex align-items-center gap-2">
|
||
<button class="btn btn-sm btn-success" @onclick="RegisterMongoCluster" disabled="@mongoRegistering">
|
||
@if (mongoRegistering)
|
||
{
|
||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||
<text>Registering...</text>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-box-arrow-in-down me-1"></i>
|
||
<text>Register</text>
|
||
}
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelMongoRegister">Cancel</button>
|
||
</div>
|
||
@if (!string.IsNullOrEmpty(mongoRegisterError))
|
||
{
|
||
<div class="alert alert-danger small mt-2 mb-0 py-2">
|
||
<i class="bi bi-exclamation-triangle me-1"></i>@mongoRegisterError
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
@* ── Create MongoDB Cluster Form ── *@
|
||
@if (showMongoCreate)
|
||
{
|
||
<div class="card shadow-sm mb-3 border-success">
|
||
<div class="card-header bg-success bg-opacity-10 d-flex align-items-center justify-content-between">
|
||
<h6 class="mb-0"><i class="bi bi-database-add me-2"></i>New MongoDB Cluster</h6>
|
||
<button class="btn btn-sm btn-outline-secondary border-0" @onclick="CancelMongoCreate"><i class="bi bi-x-lg"></i></button>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row g-3">
|
||
<div class="col-md-6">
|
||
<label class="form-label small">Cluster Name</label>
|
||
<input class="form-control form-control-sm" @bind="mongoCreateName" placeholder="my-mongo" />
|
||
</div>
|
||
<div class="col-md-6">
|
||
<label class="form-label small">Namespace</label>
|
||
<input class="form-control form-control-sm" @bind="mongoCreateNamespace" placeholder="databases" />
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Target Cluster</label>
|
||
<select class="form-select form-select-sm" @bind="mongoCreateClusterId">
|
||
<option value="@Guid.Empty">Select cluster...</option>
|
||
@if (mongoAvailableClusters is not null)
|
||
{
|
||
@foreach (KubernetesCluster k in mongoAvailableClusters)
|
||
{
|
||
<option value="@k.Id">@k.Name</option>
|
||
}
|
||
}
|
||
</select>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">MongoDB Version</label>
|
||
<input class="form-control form-control-sm font-monospace" list="mongo-create-versions"
|
||
@bind="mongoCreateVersion" placeholder="8.3.2" />
|
||
<datalist id="mongo-create-versions">
|
||
<option value="8.3.2" />
|
||
<option value="8.2.9" />
|
||
<option value="8.0.11" />
|
||
<option value="8.0.4" />
|
||
<option value="7.0.21" />
|
||
<option value="7.0.9" />
|
||
<option value="6.0.21" />
|
||
<option value="6.0.9" />
|
||
</datalist>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Members (Replica Set)</label>
|
||
<select class="form-select form-select-sm" @bind="mongoCreateMembers">
|
||
<option value="1">1 (Standalone)</option>
|
||
<option value="3">3 (HA)</option>
|
||
<option value="5">5 (Large HA)</option>
|
||
</select>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Storage Size</label>
|
||
<input class="form-control form-control-sm" @bind="mongoCreateStorageSize" placeholder="10Gi" />
|
||
</div>
|
||
<div class="col-md-6">
|
||
<label class="form-label small">Backup S3 Bucket (optional)</label>
|
||
<select class="form-select form-select-sm" @bind="mongoCreateStorageLinkId">
|
||
<option value="@Guid.Empty">No backup</option>
|
||
@if (storageLinks is not null)
|
||
{
|
||
@foreach (StorageLink sl in storageLinks)
|
||
{
|
||
<option value="@sl.Id">@sl.Name (@sl.BucketName)</option>
|
||
}
|
||
}
|
||
</select>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Backup Schedule (cron, optional)</label>
|
||
<input class="form-control form-control-sm" @bind="mongoCreateSchedule" placeholder="0 2 * * *" />
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Backup Retention (days)</label>
|
||
<input type="number" class="form-control form-control-sm" @bind="mongoCreateRetentionDays" min="1" max="365" />
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Max backups</label>
|
||
<input type="number" class="form-control form-control-sm" @bind="mongoCreateMaxBackups" min="1" max="200" />
|
||
</div>
|
||
</div>
|
||
<div class="mt-3 d-flex align-items-center gap-2">
|
||
<button class="btn btn-sm btn-success" @onclick="CreateMongoCluster" disabled="@mongoCreating">
|
||
@if (mongoCreating)
|
||
{
|
||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||
<text>Creating...</text>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-plus-lg me-1"></i>
|
||
<text>Create MongoDB Cluster</text>
|
||
}
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelMongoCreate">Cancel</button>
|
||
</div>
|
||
@if (!string.IsNullOrEmpty(mongoCreateError))
|
||
{
|
||
<div class="alert alert-danger small mt-2 mb-0 py-2">
|
||
<i class="bi bi-exclamation-triangle me-1"></i>@mongoCreateError
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
@* ── Managed MongoDB Cluster Cards ── *@
|
||
@if (FilteredManagedMongoClusters is null)
|
||
{
|
||
<div class="text-center py-3">
|
||
<div class="spinner-border spinner-border-sm text-success" role="status"></div>
|
||
</div>
|
||
}
|
||
else if (FilteredManagedMongoClusters.Count == 0 && !showMongoCreate && !showMongoRegister)
|
||
{
|
||
<div class="alert alert-light border">
|
||
<i class="bi bi-inbox me-2"></i>
|
||
No managed MongoDB clusters yet. Click "Create Cluster" to provision a new one, or "Register Existing" to track a cluster deployed outside EntKube.
|
||
</div>
|
||
}
|
||
else
|
||
{
|
||
@foreach (MongoCluster mc in FilteredManagedMongoClusters)
|
||
{
|
||
<div class="card shadow-sm mb-3">
|
||
<div class="card-header bg-white d-flex align-items-center justify-content-between py-2">
|
||
<div class="d-flex align-items-center gap-2 cursor-pointer" @onclick="() => ToggleMongoClusterDetail(mc)">
|
||
<i class="bi @(expandedMongoClusterId == mc.Id ? "bi-chevron-down" : "bi-chevron-right") text-muted"></i>
|
||
<i class="bi bi-database-gear text-success"></i>
|
||
<strong>@mc.Name</strong>
|
||
<span class="badge bg-light text-dark border small">Mongo @mc.MongoVersion</span>
|
||
@(GetMongoManagedStatusBadge(mc.Status))
|
||
<span class="badge bg-success-subtle text-success border border-success-subtle small">
|
||
<i class="bi bi-layers me-1"></i>@mc.KubernetesCluster.Environment.Name
|
||
</span>
|
||
@if (mc.IsExternal)
|
||
{
|
||
<span class="badge bg-info-subtle text-info border border-info-subtle small" title="Registered from existing deployment — not provisioned by EntKube">
|
||
<i class="bi bi-box-arrow-in-down me-1"></i>External
|
||
</span>
|
||
@if (mc.StorageLinkId.HasValue)
|
||
{
|
||
<span class="badge bg-success-subtle text-success border border-success-subtle small" title="S3 backup configured">
|
||
<i class="bi bi-cloud-arrow-up me-1"></i>S3
|
||
</span>
|
||
}
|
||
}
|
||
@if (mc.Databases.Count > 0)
|
||
{
|
||
<span class="badge bg-secondary-subtle text-secondary">@mc.Databases.Count db(s)</span>
|
||
}
|
||
</div>
|
||
<div class="d-flex gap-1">
|
||
@if (mc.IsExternal)
|
||
{
|
||
<button class="btn btn-sm btn-outline-success border-0" title="Track Existing Database"
|
||
@onclick="() => ShowMongoTrackDb(mc)">
|
||
<i class="bi bi-plus-circle"></i>
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-info border-0" title="Connection & Backup Settings"
|
||
@onclick="() => ShowMongoExtSettings(mc)">
|
||
<i class="bi bi-gear"></i>
|
||
</button>
|
||
@if (mc.StorageLinkId.HasValue)
|
||
{
|
||
<button class="btn btn-sm btn-outline-info border-0" title="Backup Now"
|
||
@onclick="() => TriggerMongoBackup(mc.Id)" disabled="@(mongoBackingUpId == mc.Id)">
|
||
@if (mongoBackingUpId == mc.Id)
|
||
{
|
||
<span class="spinner-border spinner-border-sm"></span>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-cloud-arrow-up"></i>
|
||
}
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-warning border-0" title="Restore from Backup"
|
||
@onclick="() => ShowMongoRestore(mc)">
|
||
<i class="bi bi-clock-history"></i>
|
||
</button>
|
||
}
|
||
<button class="btn btn-sm btn-outline-primary border-0" title="Migrate to Managed Cluster"
|
||
@onclick="() => ShowMongoMigrate(mc)">
|
||
<i class="bi bi-arrow-right-circle"></i>
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary border-0" title="Upgrade (patches MongoDBCommunity CRD version)"
|
||
@onclick="() => ShowMongoUpgrade(mc)">
|
||
<i class="bi bi-arrow-up-circle"></i>
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary border-0" title="Resize (storage, CPU, memory)"
|
||
@onclick="() => ShowMongoResize(mc)">
|
||
<i class="bi bi-sliders"></i>
|
||
</button>
|
||
}
|
||
else
|
||
{
|
||
<button class="btn btn-sm btn-outline-success border-0" title="Add Database"
|
||
@onclick="() => ShowMongoAddDatabase(mc)">
|
||
<i class="bi bi-plus-circle"></i>
|
||
</button>
|
||
@if (mc.StorageLinkId.HasValue)
|
||
{
|
||
<button class="btn btn-sm btn-outline-secondary border-0"
|
||
title="Backup Schedule & Settings"
|
||
@onclick="() => ToggleMongoScheduleForm(mc)">
|
||
<i class="bi bi-calendar-event"></i>
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-info border-0" title="Backup Now"
|
||
@onclick="() => TriggerMongoBackup(mc.Id)" disabled="@(mongoBackingUpId == mc.Id)">
|
||
@if (mongoBackingUpId == mc.Id)
|
||
{
|
||
<span class="spinner-border spinner-border-sm"></span>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-cloud-arrow-up"></i>
|
||
}
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-warning border-0" title="Restore"
|
||
@onclick="() => ShowMongoRestore(mc)">
|
||
<i class="bi bi-clock-history"></i>
|
||
</button>
|
||
}
|
||
<button class="btn btn-sm btn-outline-secondary border-0" title="Restore from Atlas"
|
||
@onclick="() => ShowAtlasRestore(mc)">
|
||
<i class="bi bi-cloud-download"></i>
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary border-0" title="Upgrade"
|
||
@onclick="() => ShowMongoUpgrade(mc)">
|
||
<i class="bi bi-arrow-up-circle"></i>
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary border-0" title="Resize (storage, CPU, memory)"
|
||
@onclick="() => ShowMongoResize(mc)">
|
||
<i class="bi bi-sliders"></i>
|
||
</button>
|
||
}
|
||
<button class="btn btn-sm btn-outline-danger border-0" title="Delete Cluster"
|
||
@onclick="() => InitiateDeleteMongoCluster(mc)" disabled="@(mongoDeletingId == mc.Id)">
|
||
@if (mongoDeletingId == mc.Id)
|
||
{
|
||
<span class="spinner-border spinner-border-sm"></span>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-trash"></i>
|
||
}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
@* ── Delete / Unregister Confirmation ── *@
|
||
@if (mongoDeleteConfirmId == mc.Id)
|
||
{
|
||
<div class="card-body border-top py-2 bg-danger-subtle">
|
||
<div class="d-flex align-items-center gap-3 flex-wrap">
|
||
<i class="bi bi-exclamation-triangle text-danger"></i>
|
||
<div>
|
||
<span class="small fw-semibold">Delete <strong>@mc.Name</strong>?</span>
|
||
@if (mc.IsExternal)
|
||
{
|
||
<span class="small text-muted ms-2">This is an externally registered cluster.</span>
|
||
}
|
||
else
|
||
{
|
||
<span class="small text-muted ms-2">This will delete all Kubernetes resources for this cluster. This cannot be undone.</span>
|
||
}
|
||
</div>
|
||
<div class="ms-auto d-flex gap-2">
|
||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => mongoDeleteConfirmId = null">Cancel</button>
|
||
@if (mc.IsExternal)
|
||
{
|
||
<button class="btn btn-sm btn-outline-secondary" title="Remove from EntKube only — Kubernetes resources are left untouched"
|
||
@onclick="() => UnregisterMongoCluster(mc.Id)" disabled="@(mongoDeletingId == mc.Id)">
|
||
@if (mongoDeletingId == mc.Id) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||
<i class="bi bi-box-arrow-up me-1"></i>Unregister
|
||
</button>
|
||
}
|
||
<button class="btn btn-sm btn-danger" title="Delete from Kubernetes and remove from EntKube"
|
||
@onclick="() => DeleteMongoCluster(mc.Id)" disabled="@(mongoDeletingId == mc.Id)">
|
||
@if (mongoDeletingId == mc.Id) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||
<i class="bi bi-trash me-1"></i>Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
@if (!string.IsNullOrEmpty(mongoCreateError) && mongoDeletingId == null)
|
||
{
|
||
<div class="alert alert-danger small py-1 mt-2 mb-0">@mongoCreateError</div>
|
||
}
|
||
</div>
|
||
}
|
||
|
||
@* ── External Cluster Settings Panel ── *@
|
||
@if (mc.IsExternal && mongoExtSettingsClusterId == mc.Id)
|
||
{
|
||
<div class="card-body border-top py-3">
|
||
<h6 class="small fw-semibold mb-3"><i class="bi bi-gear me-2"></i>External Cluster — Credentials & Backup Settings</h6>
|
||
<div class="row g-3">
|
||
<div class="col-12">
|
||
<label class="form-label small">Admin password <span class="text-muted fw-normal">(stored as <code>@(mongoExtSettingsClusterId.HasValue ? managedMongoClusters?.FirstOrDefault(c => c.Id == mongoExtSettingsClusterId)?.Name + "-admin-password" : "…-admin-password")</code> K8s Secret)</span></label>
|
||
<input type="password" class="form-control form-control-sm" @bind="mongoExtUri"
|
||
placeholder="Leave blank to keep existing secret" autocomplete="new-password" />
|
||
<div class="form-text small">
|
||
Stored as a K8s Secret on the cluster — never in EntKube's database.
|
||
The connection endpoint is derived automatically from the cluster name and namespace, exactly like managed clusters.
|
||
</div>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">S3 Backup Storage</label>
|
||
<select class="form-select form-select-sm" @bind="mongoExtStorageLinkId">
|
||
<option value="@Guid.Empty">None (disable backups)</option>
|
||
@if (storageLinks is not null)
|
||
{
|
||
@foreach (StorageLink sl in storageLinks)
|
||
{
|
||
<option value="@sl.Id">@sl.Name</option>
|
||
}
|
||
}
|
||
</select>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Backup Schedule (cron)</label>
|
||
<input class="form-control form-control-sm font-monospace" @bind="mongoExtSchedule" placeholder="0 2 * * *" />
|
||
<div class="form-text small">Leave blank for on-demand only</div>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label small">Retention (days)</label>
|
||
<input type="number" class="form-control form-control-sm" @bind="mongoExtRetentionDays" min="1" />
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label small">Max Backups</label>
|
||
<input type="number" class="form-control form-control-sm" @bind="mongoExtMaxBackups" min="1" />
|
||
</div>
|
||
</div>
|
||
<div class="mt-3 d-flex gap-2 align-items-center">
|
||
<button class="btn btn-sm btn-primary" @onclick="() => SaveMongoExtSettings(mc.Id)" disabled="@mongoExtSaving">
|
||
@if (mongoExtSaving) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||
Save
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => mongoExtSettingsClusterId = null">Cancel</button>
|
||
</div>
|
||
@if (!string.IsNullOrEmpty(mongoExtError) && mongoExtSettingsClusterId == mc.Id)
|
||
{
|
||
<div class="alert alert-danger small py-1 mt-2 mb-0">@mongoExtError</div>
|
||
}
|
||
@if (!string.IsNullOrEmpty(mongoExtSuccess) && mongoExtSettingsClusterId == mc.Id)
|
||
{
|
||
<div class="alert alert-success small py-1 mt-2 mb-0">@mongoExtSuccess</div>
|
||
}
|
||
</div>
|
||
}
|
||
|
||
@* ── Track External Database Form ── *@
|
||
@if (mc.IsExternal && mongoTrackDbClusterId == mc.Id)
|
||
{
|
||
<div class="card-body border-top py-3">
|
||
<h6 class="small fw-semibold mb-3"><i class="bi bi-plus-circle me-2"></i>Track Existing Database</h6>
|
||
<p class="small text-muted mb-3">Register a database that already exists in this external cluster. No MongoDB commands are run — this only creates a tracking record.</p>
|
||
<div class="row g-3 align-items-end">
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Database Name</label>
|
||
<input class="form-control form-control-sm" @bind="mongoTrackDbName" placeholder="myapp" />
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Owner / Username <span class="text-muted fw-normal">(optional)</span></label>
|
||
<input class="form-control form-control-sm" @bind="mongoTrackDbOwner" placeholder="defaults to db name" />
|
||
</div>
|
||
<div class="col-md-4 d-flex gap-2">
|
||
<button class="btn btn-sm btn-success" @onclick="TrackExternalDatabase" disabled="@mongoTrackingDb">
|
||
@if (mongoTrackingDb) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||
Track
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => mongoTrackDbClusterId = null">Cancel</button>
|
||
</div>
|
||
</div>
|
||
@if (!string.IsNullOrEmpty(mongoTrackDbError) && mongoTrackDbClusterId == mc.Id)
|
||
{
|
||
<div class="alert alert-danger small py-1 mt-2 mb-0">@mongoTrackDbError</div>
|
||
}
|
||
</div>
|
||
}
|
||
|
||
@* ── Migrate External → Managed Form ── *@
|
||
@if (mc.IsExternal && mongoMigrateSourceClusterId == mc.Id)
|
||
{
|
||
<div class="card-body border-top py-3 bg-primary-subtle">
|
||
<h6 class="small fw-semibold mb-3"><i class="bi bi-arrow-right-circle me-2"></i>Migrate to Managed Cluster</h6>
|
||
<p class="small text-muted mb-3">
|
||
Copies all data from <strong>@mc.Name</strong> (via connection URI) to a managed Community Operator cluster using a direct dump-restore Job.
|
||
The migration runs on the target cluster's Kubernetes environment. Existing data in the target is overwritten (mongorestore --drop).
|
||
</p>
|
||
@{
|
||
var managedMongoTargets = managedMongoClusters?.Where(c => !c.IsExternal).ToList() ?? [];
|
||
}
|
||
@if (managedMongoTargets.Count == 0)
|
||
{
|
||
<div class="alert alert-warning small py-2 mb-2">No managed MongoDB clusters available. Create one first.</div>
|
||
}
|
||
else
|
||
{
|
||
<div class="row g-3 align-items-end">
|
||
<div class="col-md-5">
|
||
<label class="form-label small">Target Managed Cluster</label>
|
||
<select class="form-select form-select-sm" @bind="mongoMigrateTargetClusterId">
|
||
<option value="@Guid.Empty">Select target...</option>
|
||
@foreach (MongoCluster mc2 in managedMongoTargets)
|
||
{
|
||
<option value="@mc2.Id">@mc2.Name — @mc2.KubernetesCluster.Name (@mc2.MongoVersion)</option>
|
||
}
|
||
</select>
|
||
</div>
|
||
<div class="col-md-4 d-flex gap-2">
|
||
<button class="btn btn-sm btn-primary" @onclick="ExecuteMongoMigrate"
|
||
disabled="@(mongoMigrating || mongoMigrateTargetClusterId == Guid.Empty)">
|
||
@if (mongoMigrating) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||
<i class="bi bi-arrow-right-circle me-1"></i>Start Migration
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => mongoMigrateSourceClusterId = null">Cancel</button>
|
||
</div>
|
||
</div>
|
||
}
|
||
@if (!string.IsNullOrEmpty(mongoMigrateError) && mongoMigrateSourceClusterId == mc.Id)
|
||
{
|
||
<div class="alert alert-danger small py-1 mt-2 mb-0">@mongoMigrateError</div>
|
||
}
|
||
@if (!string.IsNullOrEmpty(mongoMigrateSuccess) && mongoMigrateSourceClusterId == mc.Id)
|
||
{
|
||
<div class="alert alert-success small py-1 mt-2 mb-0">@mongoMigrateSuccess</div>
|
||
}
|
||
</div>
|
||
}
|
||
|
||
@* ── Atlas Restore Form ── *@
|
||
@if (atlasRestoreClusterId == mc.Id)
|
||
{
|
||
<div class="card-body border-top pt-3 pb-3">
|
||
<h6 class="mb-3 small fw-semibold"><i class="bi bi-cloud-download me-2 text-secondary"></i>Restore from MongoDB Atlas</h6>
|
||
<div class="row g-3">
|
||
<div class="col-12">
|
||
<label class="form-label small">Atlas Connection String</label>
|
||
<input type="password" class="form-control form-control-sm font-monospace"
|
||
@bind="atlasUri" placeholder="mongodb+srv://user:password@cluster.mongodb.net/" />
|
||
<div class="form-text small">Include credentials in the URI. The string is stored as a short-lived K8s Secret and never persisted in EntKube.</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<label class="form-label small">Source Database (in Atlas)</label>
|
||
<input class="form-control form-control-sm" @bind="atlasSourceDb" placeholder="myapp" />
|
||
</div>
|
||
<div class="col-md-6">
|
||
<label class="form-label small">Target Database (in this cluster)</label>
|
||
<input class="form-control form-control-sm" @bind="atlasTargetDb" placeholder="myapp" />
|
||
<div class="form-text small">Defaults to the same name as source. Existing data in the target database will be dropped.</div>
|
||
</div>
|
||
</div>
|
||
<div class="mt-3 d-flex align-items-center gap-2">
|
||
<button class="btn btn-sm btn-secondary" @onclick="() => StartAtlasRestore(mc)" disabled="@atlasRestoring">
|
||
@if (atlasRestoring)
|
||
{
|
||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||
<text>Starting restore job...</text>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-play-circle me-1"></i>
|
||
<text>Start Restore</text>
|
||
}
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelAtlasRestore">Cancel</button>
|
||
</div>
|
||
@if (!string.IsNullOrEmpty(atlasRestoreError))
|
||
{
|
||
<div class="alert alert-danger small mt-2 mb-0 py-2">
|
||
<i class="bi bi-exclamation-triangle me-1"></i>@atlasRestoreError
|
||
</div>
|
||
}
|
||
@if (!string.IsNullOrEmpty(atlasRestoreSuccess))
|
||
{
|
||
<div class="alert alert-success small mt-2 mb-0 py-2">
|
||
<i class="bi bi-check-circle me-1"></i>@atlasRestoreSuccess
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
|
||
@* ── Expanded Mongo Detail Panel ── *@
|
||
@if (expandedMongoClusterId == mc.Id)
|
||
{
|
||
<div class="card-body pt-2 pb-3">
|
||
@if (mongoLoadingDetail)
|
||
{
|
||
<div class="text-center py-3">
|
||
<div class="spinner-border spinner-border-sm text-success"></div>
|
||
<small class="text-muted ms-2">Querying cluster status...</small>
|
||
</div>
|
||
}
|
||
else if (mongoClusterDetail is not null)
|
||
{
|
||
@* ── Cluster Overview ── *@
|
||
<div class="row g-2 mb-3">
|
||
<div class="col-md-3">
|
||
<div class="border rounded p-2 text-center small">
|
||
<div class="text-muted">State</div>
|
||
<div class="fw-semibold">
|
||
@if (mongoClusterDetail.Phase.Contains("ready", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
<span class="text-success"><i class="bi bi-check-circle me-1"></i>@mongoClusterDetail.Phase</span>
|
||
}
|
||
else if (mongoClusterDetail.Phase.Contains("initializing", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
<span class="text-warning"><i class="bi bi-hourglass me-1"></i>@mongoClusterDetail.Phase</span>
|
||
}
|
||
else
|
||
{
|
||
<span>@mongoClusterDetail.Phase</span>
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="border rounded p-2 text-center small">
|
||
<div class="text-muted">Ready</div>
|
||
<div class="fw-semibold">@mongoClusterDetail.ReadyMembers / @mc.Members</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="border rounded p-2 text-center small">
|
||
<div class="text-muted">Endpoint</div>
|
||
<div class="fw-semibold text-truncate font-monospace small">@(mongoClusterDetail.Endpoint ?? "—")</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="border rounded p-2 text-center small">
|
||
<div class="text-muted">Info</div>
|
||
<div class="fw-semibold">
|
||
<span class="me-2"><i class="bi bi-server me-1"></i>@mc.KubernetesCluster.Name</span>
|
||
<span><i class="bi bi-box me-1"></i>@mc.Namespace</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
@if (!string.IsNullOrEmpty(mongoClusterDetail.Message))
|
||
{
|
||
<div class="alert alert-warning py-1 px-2 small mb-3">
|
||
<i class="bi bi-info-circle me-1"></i>@mongoClusterDetail.Message
|
||
</div>
|
||
}
|
||
|
||
@* ── Pod Table ── *@
|
||
<h6 class="small text-muted mb-2"><i class="bi bi-hdd-stack me-1"></i>Pods</h6>
|
||
@if (mongoClusterDetail.Pods.Count > 0)
|
||
{
|
||
<div class="table-responsive mb-3">
|
||
<table class="table table-sm table-hover small mb-0">
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th>Pod</th>
|
||
<th>Status</th>
|
||
<th>Ready</th>
|
||
<th>Node</th>
|
||
<th>Age</th>
|
||
<th>Restarts</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
@foreach (MongoPodInfo pod in mongoClusterDetail.Pods)
|
||
{
|
||
<tr>
|
||
<td class="font-monospace">@pod.Name</td>
|
||
<td>
|
||
@if (pod.Status == "Running")
|
||
{
|
||
<span class="text-success"><i class="bi bi-circle-fill me-1" style="font-size:0.5rem;"></i>Running</span>
|
||
}
|
||
else if (pod.Status == "Pending")
|
||
{
|
||
<span class="text-warning"><i class="bi bi-circle-fill me-1" style="font-size:0.5rem;"></i>Pending</span>
|
||
}
|
||
else
|
||
{
|
||
<span class="text-danger"><i class="bi bi-circle-fill me-1" style="font-size:0.5rem;"></i>@pod.Status</span>
|
||
}
|
||
</td>
|
||
<td>
|
||
@if (pod.Ready)
|
||
{
|
||
<i class="bi bi-check-circle text-success"></i>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-x-circle text-danger"></i>
|
||
}
|
||
</td>
|
||
<td class="text-truncate" style="max-width: 150px;">@(pod.Node ?? "—")</td>
|
||
<td>@FormatAge(pod.StartTime)</td>
|
||
<td>
|
||
@if (pod.Restarts > 0)
|
||
{
|
||
<span class="text-warning">@pod.Restarts</span>
|
||
}
|
||
else
|
||
{
|
||
<span class="text-muted">0</span>
|
||
}
|
||
</td>
|
||
</tr>
|
||
}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
}
|
||
else
|
||
{
|
||
<div class="alert alert-warning small py-2 mb-3">
|
||
<i class="bi bi-exclamation-triangle me-1"></i>No pods found. Cluster may still be initializing.
|
||
</div>
|
||
}
|
||
|
||
@* ── Databases ── *@
|
||
<h6 class="small text-muted mb-2 d-flex align-items-center">
|
||
<i class="bi bi-table me-1"></i>Databases
|
||
<span class="badge bg-success rounded-pill ms-2">@mc.Databases.Count</span>
|
||
</h6>
|
||
@if (mc.Databases.Count > 0)
|
||
{
|
||
<div class="table-responsive mb-3">
|
||
<table class="table table-sm small mb-0">
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th>Database</th>
|
||
<th>Status</th>
|
||
<th>Created</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
@foreach (MongoDatabase mdb in mc.Databases)
|
||
{
|
||
<tr>
|
||
<td class="font-monospace">@mdb.Name</td>
|
||
<td>
|
||
@if (mdb.Status == MongoDatabaseStatus.Ready)
|
||
{
|
||
<span class="badge bg-success-subtle text-success">Ready</span>
|
||
}
|
||
else if (mdb.Status == MongoDatabaseStatus.Creating)
|
||
{
|
||
<span class="badge bg-warning-subtle text-warning">Creating</span>
|
||
}
|
||
else
|
||
{
|
||
<span class="badge bg-danger-subtle text-danger">@mdb.Status</span>
|
||
}
|
||
</td>
|
||
<td class="text-muted">@mdb.CreatedAt.ToString("yyyy-MM-dd HH:mm")</td>
|
||
<td class="text-nowrap">
|
||
@if (!mc.IsExternal)
|
||
{
|
||
<button class="btn btn-link btn-sm text-info p-0 me-2" title="View credentials"
|
||
@onclick="() => ToggleMongoDatabaseCredentials(mc.Id, mdb.Id)">
|
||
<i class="bi bi-key" style="font-size:0.8rem;"></i>
|
||
</button>
|
||
<button class="btn btn-link btn-sm text-primary p-0 me-2" title="Sync credentials to K8s Secret"
|
||
disabled="@(mongoSyncingDbId == mdb.Id)"
|
||
@onclick="() => SyncMongoDatabaseCredentials(mc.Id, mdb.Id)">
|
||
@if (mongoSyncingDbId == mdb.Id)
|
||
{
|
||
<span class="spinner-border spinner-border-sm" style="width:0.7rem;height:0.7rem;"></span>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-cloud-arrow-up" style="font-size:0.8rem;"></i>
|
||
}
|
||
</button>
|
||
<button class="btn btn-link btn-sm text-warning p-0 me-2" title="Rotate password"
|
||
disabled="@(mongoRotatingDbId == mdb.Id)"
|
||
@onclick="() => RotateMongoDatabasePassword(mc.Id, mdb.Id)">
|
||
@if (mongoRotatingDbId == mdb.Id)
|
||
{
|
||
<span class="spinner-border spinner-border-sm" style="width:0.7rem;height:0.7rem;"></span>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-arrow-clockwise" style="font-size:0.8rem;"></i>
|
||
}
|
||
</button>
|
||
}
|
||
<button class="btn btn-link btn-sm text-primary p-0 me-2" title="Migrate to another cluster"
|
||
@onclick="() => ShowMongoDatabaseMigrate(mc, mdb)">
|
||
<i class="bi bi-arrow-right-circle" style="font-size:0.8rem;"></i>
|
||
</button>
|
||
<button class="btn btn-link btn-sm text-danger p-0" title="@(mc.IsExternal ? "Unregister / Drop database" : "Drop database")"
|
||
@onclick="() => InitiateDeleteMongoDatabase(mc, mdb.Id)">
|
||
<i class="bi bi-trash" style="font-size:0.8rem;"></i>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
@if (mongoCredDbId == mdb.Id && mongoDbCredentials is not null)
|
||
{
|
||
<tr>
|
||
<td colspan="4" class="bg-light p-0">
|
||
<div class="p-2">
|
||
<table class="table table-sm table-borderless mb-1" style="font-size:0.75rem;">
|
||
<tbody>
|
||
@foreach (KeyValuePair<string, string> cred in mongoDbCredentials)
|
||
{
|
||
<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>
|
||
</td>
|
||
</tr>
|
||
}
|
||
@if (mongoDbActionErrorId == mdb.Id && !string.IsNullOrEmpty(mongoDbActionError))
|
||
{
|
||
<tr>
|
||
<td colspan="4" class="p-0">
|
||
<div class="alert alert-danger py-1 px-2 mb-0 small rounded-0">@mongoDbActionError</div>
|
||
</td>
|
||
</tr>
|
||
}
|
||
@if (mongoMigrateDbId == mdb.Id)
|
||
{
|
||
<tr class="table-primary">
|
||
<td colspan="4" class="py-2 px-3">
|
||
<div class="d-flex align-items-end gap-2 flex-wrap">
|
||
<div>
|
||
<label class="form-label small mb-1">Migrate <strong class="font-monospace">@mdb.Name</strong> to cluster</label>
|
||
<select class="form-select form-select-sm" style="min-width:220px;" @bind="mongoMigrateDbTargetClusterId">
|
||
<option value="@Guid.Empty">Select target cluster...</option>
|
||
@if (managedMongoClusters is not null)
|
||
{
|
||
@foreach (MongoCluster t in managedMongoClusters.Where(c => !c.IsExternal && c.Id != mc.Id))
|
||
{
|
||
<option value="@t.Id">@t.Name (@t.KubernetesCluster.Name — @t.MongoVersion)</option>
|
||
}
|
||
}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="form-label small mb-1">Target database name</label>
|
||
<input class="form-control form-control-sm font-monospace" style="min-width:160px;"
|
||
@bind="mongoMigrateDbTargetName" placeholder="@mdb.Name" />
|
||
</div>
|
||
<button class="btn btn-sm btn-primary" @onclick="ExecuteMongoDatabaseMigrate"
|
||
disabled="@(mongoMigratingDb || mongoMigrateDbTargetClusterId == Guid.Empty)">
|
||
@if (mongoMigratingDb) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||
<i class="bi bi-arrow-right-circle me-1"></i>Migrate
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => { mongoMigrateDbId = null; mongoMigrateDbError = null; mongoMigrateDbSuccess = null; }">Cancel</button>
|
||
</div>
|
||
@if (!string.IsNullOrEmpty(mongoMigrateDbError) && mongoMigrateDbId == mdb.Id)
|
||
{
|
||
<div class="alert alert-danger small py-1 mt-2 mb-0">@mongoMigrateDbError</div>
|
||
}
|
||
@if (!string.IsNullOrEmpty(mongoMigrateDbSuccess) && mongoMigrateDbId == mdb.Id)
|
||
{
|
||
<div class="alert alert-success small py-1 mt-2 mb-0">@mongoMigrateDbSuccess</div>
|
||
}
|
||
</td>
|
||
</tr>
|
||
}
|
||
@if (mongoDbDeleteConfirmId == mdb.Id)
|
||
{
|
||
<tr class="table-danger">
|
||
<td colspan="4" class="py-2 px-2">
|
||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||
<i class="bi bi-exclamation-triangle text-danger small"></i>
|
||
<span class="small">Delete <strong class="font-monospace">@mdb.Name</strong>?</span>
|
||
<div class="ms-auto d-flex gap-2">
|
||
<button class="btn btn-xs btn-outline-secondary" @onclick="() => mongoDbDeleteConfirmId = null">Cancel</button>
|
||
@if (mc.IsExternal)
|
||
{
|
||
<button class="btn btn-xs btn-outline-secondary" title="Remove from EntKube only — user is left in MongoDB"
|
||
@onclick="() => UnregisterMongoDatabase(mc.Id, mdb.Id)"
|
||
disabled="@(mongoDroppingDbId == mdb.Id)">
|
||
@if (mongoDroppingDbId == mdb.Id) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||
Unregister
|
||
</button>
|
||
}
|
||
<button class="btn btn-xs btn-danger"
|
||
title="@(mc.IsExternal ? "Drop user in MongoDB and remove from EntKube" : "Drop database and remove from EntKube")"
|
||
@onclick="() => DeleteMongoDatabase(mc.Id, mdb.Id)"
|
||
disabled="@(mongoDroppingDbId == mdb.Id)">
|
||
@if (mongoDroppingDbId == mdb.Id) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||
Drop
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
}
|
||
}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
}
|
||
else if (mc.IsExternal)
|
||
{
|
||
<p class="small text-muted mb-3">No databases tracked yet. Use the <i class="bi bi-plus-circle"></i> button to track an existing database, or databases are discovered automatically if this cluster uses the MongoDB Community Operator.</p>
|
||
}
|
||
else
|
||
{
|
||
<p class="small text-muted mb-3">No databases created yet. Use the <i class="bi bi-plus-circle"></i> button to add one.</p>
|
||
}
|
||
|
||
@* ── Backups ── *@
|
||
<h6 class="small text-muted mb-2 d-flex align-items-center gap-2">
|
||
<i class="bi bi-cloud-arrow-up me-1"></i>Backups
|
||
<span class="badge bg-info rounded-pill">@mongoClusterDetail.Backups.Count</span>
|
||
@if (mc.StorageLinkId.HasValue)
|
||
{
|
||
<span class="text-muted fw-normal ms-1">
|
||
@if (!string.IsNullOrWhiteSpace(mc.BackupSchedule)) { <span class="me-2">@mc.BackupSchedule</span> }
|
||
<span class="me-2">@mc.RetentionDays d</span>
|
||
<span>max @mc.MaxBackups</span>
|
||
</span>
|
||
<button class="btn btn-sm btn-outline-secondary border-0 p-0 px-1 ms-auto"
|
||
title="Remove oldest backup Jobs from Kubernetes beyond the max limit. Does NOT delete backup data from S3."
|
||
disabled="@(mongoCleaningUpBackupsId == mc.Id)"
|
||
@onclick="() => CleanupMongoBackups(mc)">
|
||
@if (mongoCleaningUpBackupsId == mc.Id)
|
||
{
|
||
<span class="spinner-border spinner-border-sm"></span>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-trash3"></i>
|
||
}
|
||
</button>
|
||
}
|
||
</h6>
|
||
@if (mongoClusterDetail.Backups.Count > 0)
|
||
{
|
||
<div class="table-responsive">
|
||
<table class="table table-sm small mb-0">
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th>Name</th>
|
||
<th>Type</th>
|
||
<th>Status</th>
|
||
<th>Started</th>
|
||
<th>Completed</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
@foreach (MongoBackup bk in mongoClusterDetail.Backups)
|
||
{
|
||
<tr class="@(selectedMongoBackup?.Name == bk.Name && selectedMongoBackupCluster?.Id == mc.Id ? "table-warning" : "")"
|
||
style="cursor:pointer" title="Click to restore from this backup"
|
||
@onclick="() => SelectMongoBackup(mc, bk)">
|
||
<td class="font-monospace text-truncate" style="max-width: 200px;">@bk.Name</td>
|
||
<td>
|
||
@if (bk.Type == MongoBackupType.Scheduled)
|
||
{
|
||
<span class="badge bg-light text-dark border"><i class="bi bi-clock me-1"></i>Scheduled</span>
|
||
}
|
||
else
|
||
{
|
||
<span class="badge bg-light text-dark border"><i class="bi bi-hand-index me-1"></i>On-Demand</span>
|
||
}
|
||
</td>
|
||
<td>
|
||
@if (bk.Status == MongoBackupStatus.Completed)
|
||
{
|
||
<span class="badge bg-success-subtle text-success"><i class="bi bi-check-circle me-1"></i>Completed</span>
|
||
}
|
||
else if (bk.Status == MongoBackupStatus.Running)
|
||
{
|
||
<span class="badge bg-warning-subtle text-warning"><i class="bi bi-hourglass-split me-1"></i>Running</span>
|
||
}
|
||
else
|
||
{
|
||
<span class="badge bg-danger-subtle text-danger"><i class="bi bi-x-circle me-1"></i>Failed</span>
|
||
}
|
||
</td>
|
||
<td class="text-muted">@bk.StartedAt.ToString("yyyy-MM-dd HH:mm")</td>
|
||
<td class="text-muted">@(bk.CompletedAt?.ToString("HH:mm") ?? "—")</td>
|
||
</tr>
|
||
}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
}
|
||
else if (mc.StorageLinkId.HasValue)
|
||
{
|
||
<p class="small text-muted">No backups yet. Click <i class="bi bi-cloud-arrow-up"></i> to create one.</p>
|
||
}
|
||
else if (mc.IsExternal)
|
||
{
|
||
<p class="small text-muted">
|
||
Backups are not configured. Set an <strong>admin password</strong> and assign an <strong>S3 bucket</strong> via the <i class="bi bi-gear"></i> settings button to enable backups.
|
||
</p>
|
||
}
|
||
else
|
||
{
|
||
<p class="small text-muted">Backups are not configured. Assign an S3 bucket to enable backups.</p>
|
||
}
|
||
|
||
@* ── Backup Restore Panel (MongoDB) ── *@
|
||
@if (selectedMongoBackup != null && selectedMongoBackupCluster?.Id == mc.Id)
|
||
{
|
||
<div class="card border-warning mt-2">
|
||
<div class="card-header bg-warning bg-opacity-10 d-flex align-items-center justify-content-between py-2">
|
||
<h6 class="mb-0 small">
|
||
<i class="bi bi-clock-history me-2"></i>Restore from <strong>@selectedMongoBackup.Name</strong>
|
||
<span class="text-muted ms-2">(@(selectedMongoBackup.CompletedAt?.ToString("yyyy-MM-dd HH:mm") ?? selectedMongoBackup.StartedAt.ToString("yyyy-MM-dd HH:mm")))</span>
|
||
</h6>
|
||
<button class="btn btn-sm btn-outline-secondary border-0"
|
||
@onclick="() => { selectedMongoBackup = null; selectedMongoBackupCluster = null; backupRestoreError = null; }">
|
||
<i class="bi bi-x-lg"></i>
|
||
</button>
|
||
</div>
|
||
<div class="card-body py-2">
|
||
<div class="d-flex gap-4 mb-3">
|
||
<div class="form-check">
|
||
<input class="form-check-input" type="radio" name="mongo-restore-target-@mc.Id"
|
||
id="mongo-rt-current-@mc.Id" checked="@(backupRestoreTarget == "current")"
|
||
@onchange="@(_ => backupRestoreTarget = "current")" />
|
||
<label class="form-check-label small" for="mongo-rt-current-@mc.Id">
|
||
<i class="bi bi-arrow-counterclockwise me-1"></i>Current cluster
|
||
</label>
|
||
</div>
|
||
<div class="form-check">
|
||
<input class="form-check-input" type="radio" name="mongo-restore-target-@mc.Id"
|
||
id="mongo-rt-new-@mc.Id" checked="@(backupRestoreTarget == "new")"
|
||
@onchange="@(_ => backupRestoreTarget = "new")" />
|
||
<label class="form-check-label small" for="mongo-rt-new-@mc.Id">
|
||
<i class="bi bi-plus-circle me-1"></i>New cluster
|
||
</label>
|
||
</div>
|
||
<div class="form-check">
|
||
<input class="form-check-input" type="radio" name="mongo-restore-target-@mc.Id"
|
||
id="mongo-rt-external-@mc.Id" checked="@(backupRestoreTarget == "external")"
|
||
@onchange="@(_ => backupRestoreTarget = "external")" />
|
||
<label class="form-check-label small" for="mongo-rt-external-@mc.Id">
|
||
<i class="bi bi-cloud me-1"></i>External connection
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
@if (backupRestoreTarget == "current")
|
||
{
|
||
<div class="alert alert-warning small py-2 mb-2">
|
||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||
<strong>Destructive:</strong> Runs mongorestore --drop against the running cluster. All collections are replaced with backup data.
|
||
</div>
|
||
}
|
||
else if (backupRestoreTarget == "new")
|
||
{
|
||
<div class="row g-2 mb-2">
|
||
<div class="col-md-6">
|
||
<label class="form-label small">New Cluster Name</label>
|
||
<input class="form-control form-control-sm" @bind="backupRestoreNewName" placeholder="@(mc.Name + "-restored")" />
|
||
</div>
|
||
</div>
|
||
}
|
||
else if (backupRestoreTarget == "external")
|
||
{
|
||
<div class="row g-2 mb-2">
|
||
<div class="col-12">
|
||
<label class="form-label small">MongoDB Connection URI</label>
|
||
<input class="form-control form-control-sm font-monospace" @bind="backupRestoreExternalUri"
|
||
placeholder="mongodb://user:pass@host:27017/admin?authSource=admin" />
|
||
<div class="form-text small text-muted">The backup will be restored (mongorestore --drop) to this external MongoDB instance.</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
<div class="d-flex gap-2">
|
||
<button class="btn btn-sm btn-warning" @onclick="ExecuteBackupRestore" disabled="@backupRestoring">
|
||
@if (backupRestoring) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||
Restore
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary"
|
||
@onclick="() => { selectedMongoBackup = null; selectedMongoBackupCluster = null; backupRestoreError = null; }">
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
|
||
@if (!string.IsNullOrEmpty(backupRestoreError))
|
||
{
|
||
<div class="alert alert-danger small py-1 mt-2 mb-0">@backupRestoreError</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
@* ── Backup Settings Panel ── *@
|
||
@if (mongoConfiguringScheduleId == mc.Id)
|
||
{
|
||
<div class="card border-secondary mt-2">
|
||
<div class="card-header bg-light d-flex align-items-center justify-content-between py-2">
|
||
<span class="small fw-semibold"><i class="bi bi-calendar-event me-1"></i>Backup Settings</span>
|
||
<button class="btn btn-sm btn-outline-secondary border-0" @onclick="CancelMongoScheduleForm">
|
||
<i class="bi bi-x-lg"></i>
|
||
</button>
|
||
</div>
|
||
<div class="card-body py-2">
|
||
<div class="row g-2 align-items-end">
|
||
<div class="col-md-5">
|
||
<label class="form-label small mb-1">Schedule (cron, 5-field)</label>
|
||
<input type="text" class="form-control form-control-sm font-monospace"
|
||
placeholder="e.g. 0 2 * * *"
|
||
@bind="mongoScheduleInput" @bind:event="oninput" />
|
||
</div>
|
||
<div class="col-md-3">
|
||
<label class="form-label small mb-1">Retention (days)</label>
|
||
<input type="number" class="form-control form-control-sm"
|
||
min="1" max="3650"
|
||
@bind="mongoScheduleRetentionDays" @bind:event="onchange" />
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label small mb-1">Max backups</label>
|
||
<input type="number" class="form-control form-control-sm"
|
||
min="1" max="200"
|
||
@bind="mongoScheduleMaxBackups" @bind:event="onchange" />
|
||
</div>
|
||
<div class="col-md-2">
|
||
<button class="btn btn-sm btn-success w-100"
|
||
disabled="@(mongoApplyingScheduleId == mc.Id)"
|
||
@onclick="() => SaveMongoBackupSettings(mc.Id)">
|
||
@if (mongoApplyingScheduleId == mc.Id)
|
||
{
|
||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||
}
|
||
Save
|
||
</button>
|
||
</div>
|
||
</div>
|
||
@if (!string.IsNullOrEmpty(mongoScheduleError))
|
||
{
|
||
<div class="alert alert-danger small py-1 mt-2 mb-0">@mongoScheduleError</div>
|
||
}
|
||
@if (!string.IsNullOrEmpty(mongoScheduleSuccess))
|
||
{
|
||
<div class="alert alert-success small py-1 mt-2 mb-0">@mongoScheduleSuccess</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
}
|
||
|
||
@if (mongoClusterMessages.TryGetValue(mc.Id, out string? mongoMsg))
|
||
{
|
||
<div class="alert alert-info small py-1 mt-2 mb-0">@mongoMsg</div>
|
||
}
|
||
@if (!string.IsNullOrEmpty(mc.LastError))
|
||
{
|
||
<div class="alert alert-danger small py-1 mt-2 mb-0">
|
||
<i class="bi bi-exclamation-triangle me-1"></i>@mc.LastError
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
else
|
||
{
|
||
@* ── Collapsed Summary Row ── *@
|
||
<div class="card-body py-2">
|
||
<div class="row small text-muted">
|
||
<div class="col-auto">
|
||
<i class="bi bi-server me-1"></i>@mc.KubernetesCluster.Name
|
||
</div>
|
||
<div class="col-auto">
|
||
<i class="bi bi-box me-1"></i>@mc.Namespace
|
||
</div>
|
||
<div class="col-auto">
|
||
<i class="bi bi-hdd-stack me-1"></i>@mc.Members member(s)
|
||
</div>
|
||
<div class="col-auto">
|
||
<i class="bi bi-device-hdd me-1"></i>@mc.StorageSize
|
||
</div>
|
||
@if (mc.StorageLink is not null)
|
||
{
|
||
<div class="col-auto">
|
||
<i class="bi bi-cloud me-1"></i>@mc.StorageLink.BucketName
|
||
</div>
|
||
}
|
||
@if (mc.Databases.Count > 0)
|
||
{
|
||
<div class="col-auto">
|
||
<i class="bi bi-table me-1"></i>@string.Join(", ", mc.Databases.Select(d => d.Name))
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
}
|
||
|
||
@* ── Add MongoDB Database Form ── *@
|
||
@if (mongoAddDbCluster is not null)
|
||
{
|
||
<div class="card shadow-sm mb-3 border-success">
|
||
<div class="card-header bg-success bg-opacity-10">
|
||
<h6 class="mb-0"><i class="bi bi-plus-circle me-2"></i>Add Database to <strong>@mongoAddDbCluster.Name</strong></h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row g-3 align-items-end">
|
||
<div class="col-md-6">
|
||
<label class="form-label small">Database Name</label>
|
||
<input class="form-control form-control-sm" @bind="mongoNewDbName" placeholder="myapp" />
|
||
</div>
|
||
<div class="col-md-6 d-flex gap-2">
|
||
<button class="btn btn-sm btn-success" @onclick="AddMongoDatabase" disabled="@mongoAddingDb">
|
||
@if (mongoAddingDb) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||
Create
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => mongoAddDbCluster = null">Cancel</button>
|
||
</div>
|
||
</div>
|
||
@if (!string.IsNullOrEmpty(mongoDbError))
|
||
{
|
||
<div class="alert alert-danger small py-1 mt-2 mb-0">@mongoDbError</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
@* ── MongoDB Restore Form ── *@
|
||
@if (mongoRestoreSource is not null)
|
||
{
|
||
<div class="card shadow-sm mb-3 border-warning">
|
||
<div class="card-header bg-warning bg-opacity-10">
|
||
<h6 class="mb-0"><i class="bi bi-clock-history me-2"></i>Restore from <strong>@mongoRestoreSource.Name</strong></h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row g-3 align-items-end">
|
||
<div class="col-md-5">
|
||
<label class="form-label small">Source Backup</label>
|
||
@{
|
||
var completedBackups = mongoRestoreSource.Backups
|
||
.Where(b => b.Status == MongoBackupStatus.Completed)
|
||
.OrderByDescending(b => b.StartedAt)
|
||
.ToList();
|
||
}
|
||
<select class="form-select form-select-sm" @bind="mongoRestoreBackupId">
|
||
<option value="">— select backup —</option>
|
||
@foreach (var b in completedBackups)
|
||
{
|
||
<option value="@b.Id">@b.Name (@b.StartedAt.ToString("yyyy-MM-dd HH:mm") UTC)</option>
|
||
}
|
||
</select>
|
||
@if (!completedBackups.Any())
|
||
{
|
||
<div class="form-text text-warning">No completed backups available.</div>
|
||
}
|
||
</div>
|
||
<div class="col-md-4 d-flex gap-2 align-items-end">
|
||
<button class="btn btn-sm btn-warning" @onclick="ExecuteMongoRestore" disabled="@(mongoRestoring || mongoRestoreBackupId == Guid.Empty)">
|
||
@if (mongoRestoring) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||
Restore
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => mongoRestoreSource = null">Cancel</button>
|
||
</div>
|
||
</div>
|
||
@if (!string.IsNullOrEmpty(mongoRestoreError))
|
||
{
|
||
<div class="alert alert-danger small py-1 mt-2 mb-0">@mongoRestoreError</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
@* ── MongoDB Resize Form ── *@
|
||
@if (mongoResizeCluster is not null)
|
||
{
|
||
<div class="card shadow-sm mb-3 border-secondary">
|
||
<div class="card-header bg-secondary bg-opacity-10">
|
||
<h6 class="mb-0"><i class="bi bi-sliders me-2"></i>Resize <strong>@mongoResizeCluster.Name</strong></h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="alert alert-info small py-2 mb-3">
|
||
<i class="bi bi-info-circle me-1"></i>
|
||
<strong>Storage</strong> — patches each PVC directly. Requires <code>allowVolumeExpansion: true</code> on the StorageClass. Can only grow, not shrink.<br />
|
||
<strong>CPU / Memory</strong> — re-applies the manifest; the operator performs a rolling restart.
|
||
</div>
|
||
<div class="row g-3 align-items-end">
|
||
<div class="col-md-3">
|
||
<label class="form-label small">Storage per member</label>
|
||
<input class="form-control form-control-sm font-monospace"
|
||
@bind="mongoResizeStorageSize" placeholder="@mongoResizeCluster.StorageSize" />
|
||
<div class="form-text small">Leave blank to keep current (@mongoResizeCluster.StorageSize)</div>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label small">CPU Request</label>
|
||
<input class="form-control form-control-sm font-monospace"
|
||
@bind="mongoResizeCpuRequest" placeholder="e.g. 500m" />
|
||
<div class="form-text small">Current: @(mongoResizeCluster.CpuRequest ?? "—")</div>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label small">CPU Limit</label>
|
||
<input class="form-control form-control-sm font-monospace"
|
||
@bind="mongoResizeCpuLimit" placeholder="e.g. 4" />
|
||
<div class="form-text small">Current: @(mongoResizeCluster.CpuLimit ?? "—")</div>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label small">Memory Request</label>
|
||
<input class="form-control form-control-sm font-monospace"
|
||
@bind="mongoResizeMemoryRequest" placeholder="e.g. 4Gi" />
|
||
<div class="form-text small">Current: @(mongoResizeCluster.MemoryRequest ?? "—")</div>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<label class="form-label small">Memory Limit</label>
|
||
<input class="form-control form-control-sm font-monospace"
|
||
@bind="mongoResizeMemoryLimit" placeholder="e.g. 16Gi" />
|
||
<div class="form-text small">Current: @(mongoResizeCluster.MemoryLimit ?? "—")</div>
|
||
</div>
|
||
<div class="col-md-3 d-flex gap-2">
|
||
<button class="btn btn-sm btn-secondary" @onclick="ExecuteMongoResize" disabled="@mongoResizing">
|
||
@if (mongoResizing) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||
Apply
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => mongoResizeCluster = null">Cancel</button>
|
||
</div>
|
||
</div>
|
||
@if (!string.IsNullOrEmpty(mongoResizeError))
|
||
{
|
||
<div class="alert alert-danger small py-1 mt-2 mb-0">@mongoResizeError</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
@* ── MongoDB Upgrade Form ── *@
|
||
@if (mongoUpgradeCluster is not null)
|
||
{
|
||
<div class="card shadow-sm mb-3 border-secondary">
|
||
<div class="card-header bg-secondary bg-opacity-10">
|
||
<h6 class="mb-0"><i class="bi bi-arrow-up-circle me-2"></i>Upgrade <strong>@mongoUpgradeCluster.Name</strong> (currently @mongoUpgradeCluster.MongoVersion)</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
@if (mongoUpgradeCluster.IsExternal)
|
||
{
|
||
<div class="alert alert-info small py-2 mb-3">
|
||
<i class="bi bi-info-circle me-1"></i>
|
||
This patches <code>spec.version</code> in the existing <strong>MongoDBCommunity</strong> CRD. The Community Operator performs a rolling upgrade of replica set members.
|
||
Existing users, auth config, and StatefulSet settings are preserved.
|
||
</div>
|
||
}
|
||
<div class="row g-3 align-items-end">
|
||
<div class="col-md-4">
|
||
<label class="form-label small">Target Version</label>
|
||
<input class="form-control form-control-sm font-monospace" list="mongo-versions"
|
||
@bind="mongoUpgradeVersion" placeholder="e.g. 8.2.9" />
|
||
<datalist id="mongo-versions">
|
||
<option value="8.2.9" />
|
||
<option value="8.2.0" />
|
||
<option value="8.0.11" />
|
||
<option value="8.0.9" />
|
||
<option value="8.0.4" />
|
||
<option value="7.0.21" />
|
||
<option value="7.0.15" />
|
||
<option value="7.0.9" />
|
||
<option value="6.0.21" />
|
||
<option value="6.0.15" />
|
||
<option value="6.0.9" />
|
||
<option value="5.0.31" />
|
||
</datalist>
|
||
<div class="form-text small">Enter any X.Y.Z version supported by the MongoDB Community Operator</div>
|
||
</div>
|
||
<div class="col-md-4 d-flex gap-2">
|
||
<button class="btn btn-sm btn-secondary" @onclick="ExecuteMongoUpgrade" disabled="@mongoUpgrading">
|
||
@if (mongoUpgrading) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||
Upgrade
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => mongoUpgradeCluster = null">Cancel</button>
|
||
</div>
|
||
</div>
|
||
@if (!string.IsNullOrEmpty(mongoUpgradeError))
|
||
{
|
||
<div class="alert alert-danger small py-1 mt-2 mb-0">@mongoUpgradeError</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
}
|
||
|
||
@* ── Refresh button ── *@
|
||
<div class="mt-3">
|
||
<button class="btn btn-sm btn-outline-secondary" @onclick="RefreshAll" disabled="@refreshing">
|
||
@if (refreshing)
|
||
{
|
||
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||
}
|
||
else
|
||
{
|
||
<i class="bi bi-arrow-clockwise me-1"></i>
|
||
}
|
||
Refresh
|
||
</button>
|
||
</div>
|
||
}
|
||
|
||
@code {
|
||
[Parameter] public Guid TenantId { get; set; }
|
||
|
||
private bool loading = true;
|
||
private bool refreshing;
|
||
private DatabaseOperatorStatus? operatorStatus;
|
||
// Environment tab filter (null = All)
|
||
private string? selectedEnvironment;
|
||
|
||
private IReadOnlyList<string> AllEnvironments
|
||
{
|
||
get
|
||
{
|
||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||
if (managedClusters is not null)
|
||
foreach (var c in managedClusters) names.Add(c.KubernetesCluster.Environment.Name);
|
||
if (managedMongoClusters is not null)
|
||
foreach (var c in managedMongoClusters) names.Add(c.KubernetesCluster.Environment.Name);
|
||
if (registeredPgInstances is not null)
|
||
foreach (var i in registeredPgInstances) names.Add(i.KubernetesCluster.Environment.Name);
|
||
return names.OrderBy(n => n).ToList();
|
||
}
|
||
}
|
||
|
||
private List<CnpgCluster>? FilteredManagedClusters =>
|
||
managedClusters?.Where(c => selectedEnvironment is null || c.KubernetesCluster.Environment.Name == selectedEnvironment).ToList();
|
||
|
||
private List<MongoCluster>? FilteredManagedMongoClusters =>
|
||
managedMongoClusters?.Where(c => selectedEnvironment is null || c.KubernetesCluster.Environment.Name == selectedEnvironment).ToList();
|
||
|
||
// Managed CNPG cluster state
|
||
private List<CnpgCluster>? managedClusters;
|
||
private List<KubernetesCluster>? availableClusters;
|
||
private List<StorageLink>? storageLinks;
|
||
|
||
// Managed MongoDB cluster state
|
||
private List<MongoCluster>? managedMongoClusters;
|
||
private List<KubernetesCluster>? mongoAvailableClusters;
|
||
|
||
// Create cluster form
|
||
private bool showCreate;
|
||
private bool creating;
|
||
private string? createError;
|
||
|
||
// Import existing CNPG cluster form
|
||
private bool showImportCnpg;
|
||
private bool importingCnpg;
|
||
private string? importCnpgError;
|
||
private Guid importCnpgK8sClusterId;
|
||
private string importCnpgName = "";
|
||
private string importCnpgNamespace = "";
|
||
private string createName = "";
|
||
private string createNamespace = "databases";
|
||
private Guid createClusterId;
|
||
private string createPostgresVersion = "18";
|
||
private int createInstances = 3;
|
||
private string createStorageSize = "10Gi";
|
||
private Guid createStorageLinkId;
|
||
private string createSchedule = "";
|
||
private int createRetentionDays = 30;
|
||
private int createMaxBackups = 20;
|
||
|
||
// Cluster detail panel
|
||
private Guid? expandedClusterId;
|
||
private CnpgClusterDetail? clusterDetail;
|
||
private bool loadingDetail;
|
||
|
||
// Delete cluster
|
||
private Guid? deletingId;
|
||
private Guid? cnpgDeleteConfirmId;
|
||
private Guid? cnpgDbDeleteConfirmId;
|
||
|
||
// Backup
|
||
private Guid? backingUpId;
|
||
private Guid? applyingScheduledBackupId;
|
||
private Guid? configuringScheduleId;
|
||
private string scheduleInput = "";
|
||
private int scheduleRetentionDays = 30;
|
||
private int scheduleMaxBackups = 20;
|
||
private string? scheduleError;
|
||
private string? scheduleSuccess;
|
||
private Guid? cleaningUpBackupsId;
|
||
|
||
// Restore form
|
||
private CnpgCluster? restoreSource;
|
||
private string restoreName = "";
|
||
private DateTime restoreTargetTime = DateTime.UtcNow.AddHours(-1);
|
||
private bool restoring;
|
||
private string? restoreError;
|
||
|
||
// Upgrade form
|
||
private CnpgCluster? upgradeCluster;
|
||
private string upgradeVersion = "18";
|
||
private bool upgrading;
|
||
private string? upgradeError;
|
||
|
||
// Add database form
|
||
private CnpgCluster? addDbCluster;
|
||
private string newDbName = "";
|
||
private bool addingDb;
|
||
private string? dbError;
|
||
|
||
// Import existing database form
|
||
private CnpgCluster? importDbCluster;
|
||
private string importDbName = "";
|
||
private string importDbOwner = "";
|
||
private string importDbPassword = "";
|
||
private bool importingDb;
|
||
private string? importDbError;
|
||
private bool discoveringDbs;
|
||
private string? discoverDbError;
|
||
private List<(string Name, string Owner)>? discoveredDbs;
|
||
|
||
// Database credentials
|
||
private Guid? credentialsDatabaseId;
|
||
private Dictionary<string, string>? databaseCredentials;
|
||
private Guid? rotatingDatabaseId;
|
||
|
||
// Database permission fix — ownership check results keyed by CnpgDatabase.Id
|
||
private Guid? dbFixBusyId;
|
||
private Guid? dbFixMessageId;
|
||
private string? dbFixMessage;
|
||
private Dictionary<Guid, bool> dbOwnershipOk = [];
|
||
|
||
// Cluster-level superuser fetch
|
||
private Guid? clusterSuperuserFetchingId;
|
||
private Dictionary<Guid, string> clusterMessages = [];
|
||
|
||
// ── MongoDB State ──
|
||
// Atlas restore form
|
||
private Guid? atlasRestoreClusterId;
|
||
private bool atlasRestoring;
|
||
private string atlasUri = "";
|
||
private string atlasSourceDb = "";
|
||
private string atlasTargetDb = "";
|
||
private string? atlasRestoreError;
|
||
private string? atlasRestoreSuccess;
|
||
|
||
// Register existing MongoDB cluster form
|
||
private bool showMongoRegister;
|
||
private bool mongoRegistering;
|
||
private string? mongoRegisterError;
|
||
private string mongoRegName = "";
|
||
private string mongoRegNamespace = "database";
|
||
private Guid mongoRegClusterId;
|
||
private string mongoRegExternalUri = "";
|
||
|
||
// External cluster settings panel
|
||
private Guid? mongoExtSettingsClusterId;
|
||
private string mongoExtUri = "";
|
||
private Guid mongoExtStorageLinkId;
|
||
private string mongoExtSchedule = "";
|
||
private int mongoExtRetentionDays = 30;
|
||
private int mongoExtMaxBackups = 20;
|
||
private bool mongoExtSaving;
|
||
private string? mongoExtError;
|
||
private string? mongoExtSuccess;
|
||
|
||
// Track external database form
|
||
private Guid? mongoTrackDbClusterId;
|
||
private string mongoTrackDbName = "";
|
||
private string mongoTrackDbOwner = "";
|
||
private bool mongoTrackingDb;
|
||
private string? mongoTrackDbError;
|
||
|
||
// Migrate external → managed form
|
||
private Guid? mongoMigrateSourceClusterId;
|
||
private Guid mongoMigrateTargetClusterId;
|
||
private bool mongoMigrating;
|
||
private string? mongoMigrateError;
|
||
private string? mongoMigrateSuccess;
|
||
|
||
// Create MongoDB cluster form
|
||
private bool showMongoCreate;
|
||
private bool mongoCreating;
|
||
private string? mongoCreateError;
|
||
private string mongoCreateName = "";
|
||
private string mongoCreateNamespace = "databases";
|
||
private Guid mongoCreateClusterId;
|
||
private string mongoCreateVersion = "8.3.2";
|
||
private int mongoCreateMembers = 3;
|
||
private string mongoCreateStorageSize = "10Gi";
|
||
private Guid mongoCreateStorageLinkId;
|
||
private string mongoCreateSchedule = "";
|
||
private int mongoCreateRetentionDays = 30;
|
||
private int mongoCreateMaxBackups = 20;
|
||
|
||
// Mongo backup settings
|
||
private Guid? mongoConfiguringScheduleId;
|
||
private Guid? mongoApplyingScheduleId;
|
||
private string mongoScheduleInput = "";
|
||
private int mongoScheduleRetentionDays = 30;
|
||
private int mongoScheduleMaxBackups = 20;
|
||
private string? mongoScheduleError;
|
||
private string? mongoScheduleSuccess;
|
||
private Guid? mongoCleaningUpBackupsId;
|
||
private Dictionary<Guid, string> mongoClusterMessages = [];
|
||
|
||
// Mongo cluster detail panel
|
||
private Guid? expandedMongoClusterId;
|
||
private MongoClusterDetail? mongoClusterDetail;
|
||
private bool mongoLoadingDetail;
|
||
|
||
// Mongo delete / unregister confirm
|
||
private Guid? mongoDeletingId;
|
||
private Guid? mongoDeleteConfirmId;
|
||
private Guid? mongoDbDeleteConfirmId;
|
||
private Guid? mongoDroppingDbId;
|
||
|
||
// Mongo database credentials
|
||
private Guid? mongoCredDbId;
|
||
private Dictionary<string, string>? mongoDbCredentials;
|
||
private Guid? mongoSyncingDbId;
|
||
private Guid? mongoRotatingDbId;
|
||
private Guid? mongoDbActionErrorId;
|
||
private string? mongoDbActionError;
|
||
|
||
// Per-database migrate
|
||
private Guid? mongoMigrateDbId; // the MongoDatabase.Id being migrated
|
||
private Guid? mongoMigrateDbClusterId; // its source MongoCluster.Id
|
||
private Guid mongoMigrateDbTargetClusterId;
|
||
private string mongoMigrateDbTargetName = "";
|
||
private bool mongoMigratingDb;
|
||
private string? mongoMigrateDbError;
|
||
private string? mongoMigrateDbSuccess;
|
||
|
||
// Mongo backup
|
||
private Guid? mongoBackingUpId;
|
||
|
||
// Mongo restore form
|
||
private MongoCluster? mongoRestoreSource;
|
||
private Guid mongoRestoreBackupId;
|
||
private bool mongoRestoring;
|
||
private string? mongoRestoreError;
|
||
|
||
// Mongo upgrade form
|
||
private MongoCluster? mongoUpgradeCluster;
|
||
private string mongoUpgradeVersion = "8.2.9";
|
||
private bool mongoUpgrading;
|
||
private string? mongoUpgradeError;
|
||
|
||
// Mongo resize form
|
||
private MongoCluster? mongoResizeCluster;
|
||
private string mongoResizeStorageSize = "";
|
||
private string mongoResizeCpuRequest = "";
|
||
private string mongoResizeCpuLimit = "";
|
||
private string mongoResizeMemoryRequest = "";
|
||
private string mongoResizeMemoryLimit = "";
|
||
private bool mongoResizing;
|
||
private string? mongoResizeError;
|
||
|
||
// Mongo add database form
|
||
private MongoCluster? mongoAddDbCluster;
|
||
private string mongoNewDbName = "";
|
||
private bool mongoAddingDb;
|
||
private string? mongoDbError;
|
||
|
||
// ── Registered Postgres State ──
|
||
private List<RegisteredPostgresInstance>? registeredPgInstances;
|
||
private List<KubernetesCluster>? allClusters;
|
||
|
||
private List<RegisteredPostgresInstance>? FilteredRegisteredPgInstances =>
|
||
registeredPgInstances?.Where(i => selectedEnvironment is null ||
|
||
i.KubernetesCluster.Environment.Name == selectedEnvironment).ToList();
|
||
|
||
// Register form
|
||
private bool showRegisterPgForm;
|
||
private string regPgName = "";
|
||
private Guid regPgClusterId = Guid.Empty;
|
||
private string regPgNamespace = "";
|
||
private string regPgServiceName = "";
|
||
private int regPgPort = 5432;
|
||
private string regPgAdminPodName = "";
|
||
private string regPgAdminUsername = "postgres";
|
||
private string regPgAdminPassword = "";
|
||
private string regPgNotes = "";
|
||
private bool registeringPg;
|
||
private string? registerPgError;
|
||
|
||
// Per-instance UI
|
||
private Guid? expandedRegPgId;
|
||
private Dictionary<Guid, List<CnpgPodInfo>> regPgPods = [];
|
||
private Dictionary<Guid, bool> regPgPodsLoading = [];
|
||
private Guid? testingRegPgId;
|
||
private Guid? testRegPgResultId;
|
||
private string? testRegPgResult;
|
||
private Guid? deletingRegPgId;
|
||
private string? deleteRegPgError;
|
||
|
||
// Create database form
|
||
private RegisteredPostgresInstance? addDbRegPgInstance;
|
||
private string newRegPgDbName = "";
|
||
private bool addingRegPgDb;
|
||
private string? addRegPgDbError;
|
||
|
||
// Import database form
|
||
private RegisteredPostgresInstance? importRegPgInstance;
|
||
private string importRegPgDbName = "";
|
||
private string importRegPgOwner = "";
|
||
private string importRegPgPassword = "";
|
||
private bool importingRegPgDb;
|
||
private string? importRegPgError;
|
||
|
||
// Database actions
|
||
private Guid? regPgCredDbId;
|
||
private Dictionary<string, string>? regPgCredentials;
|
||
private Guid? syncingRegPgDbId;
|
||
private Guid? rotatingRegPgDbId;
|
||
private Guid? droppingRegPgDbId;
|
||
private Guid? regPgDbDeleteConfirmId;
|
||
private Guid? regPgDbActionErrorId;
|
||
private string? regPgDbActionError;
|
||
|
||
// Backup-driven restore (CNPG and MongoDB)
|
||
private CnpgBackup? selectedCnpgBackup;
|
||
private CnpgCluster? selectedCnpgBackupCluster;
|
||
private MongoBackup? selectedMongoBackup;
|
||
private MongoCluster? selectedMongoBackupCluster;
|
||
private string backupRestoreTarget = "new";
|
||
private string backupRestoreNewName = "";
|
||
private string backupRestoreExternalUri = "";
|
||
private bool backupRestoring;
|
||
private string? backupRestoreError;
|
||
|
||
protected override async Task OnInitializedAsync()
|
||
{
|
||
await LoadData();
|
||
}
|
||
|
||
private async Task LoadData()
|
||
{
|
||
loading = true;
|
||
StateHasChanged();
|
||
|
||
operatorStatus = await DatabaseService.GetOperatorStatusAsync(TenantId);
|
||
|
||
if (operatorStatus.CnpgAvailable || operatorStatus.MongoDbAvailable)
|
||
{
|
||
// Load managed clusters in parallel.
|
||
|
||
Task<List<CnpgCluster>> managedTask = operatorStatus.CnpgAvailable
|
||
? CnpgService.GetClustersAsync(TenantId)
|
||
: Task.FromResult(new List<CnpgCluster>());
|
||
|
||
Task<List<MongoCluster>> managedMongoTask = operatorStatus.MongoDbAvailable
|
||
? MongoService.GetClustersAsync(TenantId)
|
||
: Task.FromResult(new List<MongoCluster>());
|
||
|
||
await Task.WhenAll(managedTask, managedMongoTask);
|
||
managedClusters = managedTask.Result;
|
||
managedMongoClusters = managedMongoTask.Result;
|
||
}
|
||
|
||
var regPgTask = RegisteredPostgresService.GetInstancesAsync(TenantId);
|
||
var allClustersTask = DatabaseService.GetAllClustersAsync(TenantId);
|
||
await Task.WhenAll(regPgTask, allClustersTask);
|
||
registeredPgInstances = regPgTask.Result;
|
||
allClusters = allClustersTask.Result;
|
||
|
||
loading = false;
|
||
}
|
||
|
||
private async Task RefreshAll()
|
||
{
|
||
refreshing = true;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
await LoadData();
|
||
await RefreshOpenDetailPanels();
|
||
}
|
||
finally
|
||
{
|
||
refreshing = false;
|
||
}
|
||
}
|
||
|
||
private async Task RefreshOpenDetailPanels()
|
||
{
|
||
if (expandedClusterId.HasValue)
|
||
{
|
||
try
|
||
{
|
||
clusterDetail = await CnpgService.GetClusterDetailAsync(TenantId, expandedClusterId.Value);
|
||
}
|
||
catch { }
|
||
}
|
||
|
||
if (expandedMongoClusterId.HasValue)
|
||
{
|
||
try
|
||
{
|
||
mongoClusterDetail = await MongoService.GetClusterDetailAsync(TenantId, expandedMongoClusterId.Value);
|
||
}
|
||
catch { }
|
||
}
|
||
}
|
||
|
||
// ──────── Cluster Detail Panel ────────
|
||
|
||
private async Task ToggleClusterDetail(CnpgCluster cluster)
|
||
{
|
||
// If already expanded, collapse it.
|
||
|
||
if (expandedClusterId == cluster.Id)
|
||
{
|
||
expandedClusterId = null;
|
||
clusterDetail = null;
|
||
return;
|
||
}
|
||
|
||
// Expand and fetch live pod/status info from K8s.
|
||
|
||
expandedClusterId = cluster.Id;
|
||
clusterDetail = null;
|
||
loadingDetail = true;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
clusterDetail = await CnpgService.GetClusterDetailAsync(TenantId, cluster.Id);
|
||
}
|
||
catch (Exception)
|
||
{
|
||
clusterDetail = new CnpgClusterDetail
|
||
{
|
||
Cluster = cluster,
|
||
Phase = "Error fetching status"
|
||
};
|
||
}
|
||
finally
|
||
{
|
||
loadingDetail = false;
|
||
}
|
||
|
||
// Check ownership for each database in the background so we can hide the fix button when not needed.
|
||
foreach (CnpgDatabase cdb in cluster.Databases)
|
||
{
|
||
_ = Task.Run(async () =>
|
||
{
|
||
try
|
||
{
|
||
bool ok = await CnpgService.IsDatabaseOwnershipCorrectAsync(TenantId, cluster.Id, cdb.Id);
|
||
dbOwnershipOk[cdb.Id] = ok;
|
||
await InvokeAsync(StateHasChanged);
|
||
}
|
||
catch
|
||
{
|
||
// Leave button visible if check fails.
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
private static string FormatAge(DateTime? startTime)
|
||
{
|
||
if (startTime is null)
|
||
{
|
||
return "—";
|
||
}
|
||
|
||
TimeSpan age = DateTime.UtcNow - startTime.Value.ToUniversalTime();
|
||
|
||
if (age.TotalDays >= 1)
|
||
{
|
||
return $"{(int)age.TotalDays}d {age.Hours}h";
|
||
}
|
||
|
||
if (age.TotalHours >= 1)
|
||
{
|
||
return $"{(int)age.TotalHours}h {age.Minutes}m";
|
||
}
|
||
|
||
return $"{(int)age.TotalMinutes}m";
|
||
}
|
||
|
||
private static string FormatBytes(long bytes)
|
||
{
|
||
if (bytes >= 1_073_741_824)
|
||
{
|
||
return $"{bytes / 1_073_741_824.0:F1} GB";
|
||
}
|
||
|
||
if (bytes >= 1_048_576)
|
||
{
|
||
return $"{bytes / 1_048_576.0:F1} MB";
|
||
}
|
||
|
||
if (bytes >= 1024)
|
||
{
|
||
return $"{bytes / 1024.0:F0} KB";
|
||
}
|
||
|
||
return $"{bytes} B";
|
||
}
|
||
|
||
// ──────── Create Cluster ────────
|
||
|
||
private async Task ShowCreateForm()
|
||
{
|
||
showCreate = true;
|
||
createError = null;
|
||
|
||
// Load available K8s clusters (those with CNPG installed) and S3 storage links.
|
||
|
||
availableClusters = await DatabaseService.GetCnpgEnabledClustersAsync(TenantId);
|
||
storageLinks = await StorageService.GetStorageLinksAsync(TenantId);
|
||
}
|
||
|
||
private void CancelCreate()
|
||
{
|
||
showCreate = false;
|
||
createError = null;
|
||
createName = "";
|
||
createNamespace = "databases";
|
||
createClusterId = Guid.Empty;
|
||
createPostgresVersion = "18";
|
||
createInstances = 3;
|
||
createStorageSize = "10Gi";
|
||
createStorageLinkId = Guid.Empty;
|
||
createSchedule = "";
|
||
createRetentionDays = 30;
|
||
createMaxBackups = 20;
|
||
}
|
||
|
||
private async Task CreateCluster()
|
||
{
|
||
if (string.IsNullOrWhiteSpace(createName) || createClusterId == Guid.Empty)
|
||
{
|
||
createError = "Name and target cluster are required.";
|
||
return;
|
||
}
|
||
|
||
creating = true;
|
||
createError = null;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
Guid? storageLinkId = createStorageLinkId != Guid.Empty ? createStorageLinkId : null;
|
||
string? schedule = !string.IsNullOrWhiteSpace(createSchedule) ? createSchedule : null;
|
||
|
||
await CnpgService.CreateClusterAsync(
|
||
TenantId, createClusterId, createName.Trim(), createNamespace.Trim(),
|
||
createInstances, createStorageSize.Trim(), storageLinkId, schedule,
|
||
createRetentionDays, createMaxBackups, createPostgresVersion.Trim());
|
||
|
||
CancelCreate();
|
||
managedClusters = await CnpgService.GetClustersAsync(TenantId);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
createError = ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
creating = false;
|
||
}
|
||
}
|
||
|
||
// ──────── Import Existing CNPG Cluster ────────
|
||
|
||
private async Task ShowImportCnpgForm()
|
||
{
|
||
showImportCnpg = true;
|
||
importCnpgError = null;
|
||
availableClusters = await DatabaseService.GetCnpgEnabledClustersAsync(TenantId);
|
||
}
|
||
|
||
private void CancelImportCnpg()
|
||
{
|
||
showImportCnpg = false;
|
||
importCnpgError = null;
|
||
importCnpgK8sClusterId = Guid.Empty;
|
||
importCnpgName = "";
|
||
importCnpgNamespace = "";
|
||
}
|
||
|
||
private async Task ImportCnpgCluster()
|
||
{
|
||
if (string.IsNullOrWhiteSpace(importCnpgName) || importCnpgK8sClusterId == Guid.Empty || string.IsNullOrWhiteSpace(importCnpgNamespace))
|
||
{
|
||
importCnpgError = "Kubernetes cluster, cluster name, and namespace are required.";
|
||
return;
|
||
}
|
||
|
||
importingCnpg = true;
|
||
importCnpgError = null;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
await CnpgService.ImportClusterAsync(
|
||
TenantId, importCnpgK8sClusterId, importCnpgName.Trim(), importCnpgNamespace.Trim());
|
||
|
||
CancelImportCnpg();
|
||
managedClusters = await CnpgService.GetClustersAsync(TenantId);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
importCnpgError = ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
importingCnpg = false;
|
||
}
|
||
}
|
||
|
||
// ──────── Delete Cluster ────────
|
||
|
||
private async Task DeleteCluster(Guid cnpgClusterId)
|
||
{
|
||
deletingId = cnpgClusterId;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
await CnpgService.DeleteClusterAsync(TenantId, cnpgClusterId);
|
||
managedClusters = await CnpgService.GetClustersAsync(TenantId);
|
||
cnpgDeleteConfirmId = null;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
createError = $"Delete failed: {ex.Message}";
|
||
}
|
||
finally
|
||
{
|
||
deletingId = null;
|
||
}
|
||
}
|
||
|
||
private async Task UnregisterCluster(Guid cnpgClusterId)
|
||
{
|
||
deletingId = cnpgClusterId;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
await CnpgService.UnregisterClusterAsync(TenantId, cnpgClusterId);
|
||
managedClusters = await CnpgService.GetClustersAsync(TenantId);
|
||
cnpgDeleteConfirmId = null;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
createError = $"Unregister failed: {ex.Message}";
|
||
}
|
||
finally
|
||
{
|
||
deletingId = null;
|
||
}
|
||
}
|
||
|
||
// ──────── Backup ────────
|
||
|
||
private async Task TriggerBackup(Guid cnpgClusterId)
|
||
{
|
||
backingUpId = cnpgClusterId;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
await CnpgService.BackupAsync(TenantId, cnpgClusterId);
|
||
managedClusters = await CnpgService.GetClustersAsync(TenantId);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
createError = $"Backup failed: {ex.Message}";
|
||
}
|
||
finally
|
||
{
|
||
backingUpId = null;
|
||
}
|
||
}
|
||
|
||
private void ToggleScheduleForm(CnpgCluster cluster)
|
||
{
|
||
if (configuringScheduleId == cluster.Id)
|
||
{
|
||
CancelScheduleForm();
|
||
return;
|
||
}
|
||
|
||
expandedClusterId = cluster.Id;
|
||
configuringScheduleId = cluster.Id;
|
||
scheduleInput = cluster.BackupSchedule ?? "";
|
||
scheduleRetentionDays = cluster.RetentionDays;
|
||
scheduleMaxBackups = cluster.MaxBackups;
|
||
scheduleError = null;
|
||
scheduleSuccess = null;
|
||
}
|
||
|
||
private void CancelScheduleForm()
|
||
{
|
||
configuringScheduleId = null;
|
||
scheduleInput = "";
|
||
scheduleError = null;
|
||
scheduleSuccess = null;
|
||
}
|
||
|
||
private async Task SaveBackupSettings(Guid cnpgClusterId)
|
||
{
|
||
applyingScheduledBackupId = cnpgClusterId;
|
||
scheduleError = null;
|
||
scheduleSuccess = null;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
await CnpgService.UpdateBackupSettingsAsync(
|
||
TenantId, cnpgClusterId,
|
||
scheduleInput,
|
||
scheduleRetentionDays,
|
||
scheduleMaxBackups);
|
||
|
||
scheduleSuccess = "Backup settings saved and applied to Kubernetes.";
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
scheduleError = ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
applyingScheduledBackupId = null;
|
||
// Always reload so the header reflects the saved values even if K8s apply failed
|
||
// (the DB is committed before K8s operations, so the data is always up-to-date).
|
||
managedClusters = await CnpgService.GetClustersAsync(TenantId);
|
||
}
|
||
}
|
||
|
||
private async Task CleanupBackups(CnpgCluster cluster)
|
||
{
|
||
cleaningUpBackupsId = cluster.Id;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
int deleted = await CnpgService.CleanupOldBackupsAsync(TenantId, cluster.Id);
|
||
string msg = deleted == 0
|
||
? $"No cleanup needed — {cluster.MaxBackups} or fewer completed backups."
|
||
: $"Removed {deleted} Backup CR(s) from Kubernetes. Keeping the {cluster.MaxBackups} most recent. Note: backup data in S3 is managed separately by Barman via the retention policy.";
|
||
clusterMessages[cluster.Id] = msg;
|
||
|
||
// Reload detail so the backup list reflects the deletions.
|
||
if (expandedClusterId == cluster.Id)
|
||
clusterDetail = await CnpgService.GetClusterDetailAsync(TenantId, cluster.Id);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
clusterMessages[cluster.Id] = $"Error: {ex.Message}";
|
||
}
|
||
finally
|
||
{
|
||
cleaningUpBackupsId = null;
|
||
}
|
||
}
|
||
|
||
private async Task ApplyScheduledBackup(Guid cnpgClusterId, string? schedule)
|
||
{
|
||
applyingScheduledBackupId = cnpgClusterId;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
await CnpgService.ApplyScheduledBackupAsync(TenantId, cnpgClusterId, schedule);
|
||
managedClusters = await CnpgService.GetClustersAsync(TenantId);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
createError = $"Apply scheduled backup failed: {ex.Message}";
|
||
}
|
||
finally
|
||
{
|
||
applyingScheduledBackupId = null;
|
||
}
|
||
}
|
||
|
||
// ──────── Restore ────────
|
||
|
||
private void ShowRestore(CnpgCluster source)
|
||
{
|
||
restoreSource = source;
|
||
restoreName = $"{source.Name}-restored";
|
||
restoreTargetTime = DateTime.UtcNow.AddHours(-1);
|
||
restoreError = null;
|
||
}
|
||
|
||
private async Task ExecuteRestore()
|
||
{
|
||
if (string.IsNullOrWhiteSpace(restoreName))
|
||
{
|
||
restoreError = "New cluster name is required.";
|
||
return;
|
||
}
|
||
|
||
restoring = true;
|
||
restoreError = null;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
await CnpgService.RestoreAsync(
|
||
TenantId, restoreSource!.Id, restoreName.Trim(), restoreTargetTime);
|
||
|
||
restoreSource = null;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
restoreError = ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
managedClusters = await CnpgService.GetClustersAsync(TenantId);
|
||
restoring = false;
|
||
}
|
||
}
|
||
|
||
// ──────── Upgrade ────────
|
||
|
||
private void ShowUpgrade(CnpgCluster cluster)
|
||
{
|
||
upgradeCluster = cluster;
|
||
upgradeVersion = "18";
|
||
upgradeError = null;
|
||
}
|
||
|
||
private async Task ExecuteUpgrade()
|
||
{
|
||
upgrading = true;
|
||
upgradeError = null;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
// Determine whether this is a minor (rolling) or major (restore-based) upgrade.
|
||
// Minor: same major version, zero-downtime rolling update.
|
||
// Major: restore to new cluster, swap name, remove old (~10-30s downtime).
|
||
|
||
int currentMajor = int.Parse(upgradeCluster!.PostgresVersion.Split('.')[0].Split('-')[0]);
|
||
int targetMajor = int.Parse(upgradeVersion.Split('.')[0].Split('-')[0]);
|
||
|
||
if (targetMajor != currentMajor)
|
||
{
|
||
await CnpgService.MajorUpgradeAsync(TenantId, upgradeCluster.Id, upgradeVersion);
|
||
}
|
||
else
|
||
{
|
||
await CnpgService.UpgradeClusterAsync(TenantId, upgradeCluster.Id, upgradeVersion);
|
||
}
|
||
|
||
upgradeCluster = null;
|
||
managedClusters = await CnpgService.GetClustersAsync(TenantId);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
upgradeError = ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
upgrading = false;
|
||
}
|
||
}
|
||
|
||
// ──────── Database Management ────────
|
||
|
||
private void ShowAddDatabase(CnpgCluster cluster)
|
||
{
|
||
addDbCluster = cluster;
|
||
newDbName = "";
|
||
dbError = null;
|
||
}
|
||
|
||
private async Task AddDatabase()
|
||
{
|
||
if (string.IsNullOrWhiteSpace(newDbName))
|
||
{
|
||
dbError = "Database name is required.";
|
||
return;
|
||
}
|
||
|
||
addingDb = true;
|
||
dbError = null;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
await CnpgService.CreateDatabaseAsync(TenantId, addDbCluster!.Id, newDbName.Trim());
|
||
addDbCluster = null;
|
||
managedClusters = await CnpgService.GetClustersAsync(TenantId);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
dbError = ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
addingDb = false;
|
||
}
|
||
}
|
||
|
||
private void ShowImportDatabase(CnpgCluster cluster)
|
||
{
|
||
importDbCluster = cluster;
|
||
importDbName = "";
|
||
importDbOwner = "";
|
||
importDbPassword = "";
|
||
importDbError = null;
|
||
discoverDbError = null;
|
||
discoveredDbs = null;
|
||
}
|
||
|
||
private async Task DiscoverCnpgDatabases()
|
||
{
|
||
discoveringDbs = true;
|
||
discoverDbError = null;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
discoveredDbs = await CnpgService.DiscoverDatabasesAsync(TenantId, importDbCluster!.Id);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
discoverDbError = ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
discoveringDbs = false;
|
||
}
|
||
}
|
||
|
||
private void OnImportDbNameChanged()
|
||
{
|
||
if (discoveredDbs is null) return;
|
||
(string Name, string Owner) match = discoveredDbs.FirstOrDefault(d => d.Name == importDbName);
|
||
if (match != default && !string.IsNullOrEmpty(match.Owner))
|
||
importDbOwner = match.Owner;
|
||
}
|
||
|
||
private async Task ImportCnpgDatabase()
|
||
{
|
||
if (string.IsNullOrWhiteSpace(importDbName) || string.IsNullOrWhiteSpace(importDbOwner) || string.IsNullOrWhiteSpace(importDbPassword))
|
||
{
|
||
importDbError = "Database name, owner role, and password are required.";
|
||
return;
|
||
}
|
||
|
||
importingDb = true;
|
||
importDbError = null;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
await CnpgService.ImportDatabaseAsync(
|
||
TenantId, importDbCluster!.Id,
|
||
importDbName.Trim(), importDbOwner.Trim(), importDbPassword);
|
||
|
||
importDbCluster = null;
|
||
managedClusters = await CnpgService.GetClustersAsync(TenantId);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
importDbError = ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
importingDb = false;
|
||
}
|
||
}
|
||
|
||
private async Task DeleteDatabase(Guid cnpgClusterId, Guid databaseId)
|
||
{
|
||
try
|
||
{
|
||
await CnpgService.DeleteDatabaseAsync(TenantId, cnpgClusterId, databaseId);
|
||
managedClusters = await CnpgService.GetClustersAsync(TenantId);
|
||
cnpgDbDeleteConfirmId = null;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
dbError = $"Failed to delete database: {ex.Message}";
|
||
}
|
||
}
|
||
|
||
private async Task ToggleDatabaseCredentials(Guid cnpgClusterId, Guid databaseId)
|
||
{
|
||
// Toggle off if already showing this database's credentials.
|
||
|
||
if (credentialsDatabaseId == databaseId)
|
||
{
|
||
credentialsDatabaseId = null;
|
||
databaseCredentials = null;
|
||
return;
|
||
}
|
||
|
||
// Fetch credentials from the vault and display them.
|
||
|
||
try
|
||
{
|
||
databaseCredentials = await CnpgService.GetDatabaseCredentialsAsync(TenantId, databaseId);
|
||
credentialsDatabaseId = databaseId;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
dbError = $"Failed to load credentials: {ex.Message}";
|
||
}
|
||
}
|
||
|
||
private async Task SyncDatabaseCredentialsToK8s(Guid cnpgClusterId, Guid databaseId)
|
||
{
|
||
try
|
||
{
|
||
await CnpgService.SyncDatabaseCredentialsToK8sAsync(TenantId, cnpgClusterId, databaseId);
|
||
dbError = null;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
dbError = $"Failed to sync credentials to K8s: {ex.Message}";
|
||
}
|
||
}
|
||
|
||
private async Task RotateDatabasePassword(Guid cnpgClusterId, Guid databaseId)
|
||
{
|
||
rotatingDatabaseId = databaseId;
|
||
dbError = null;
|
||
try
|
||
{
|
||
await CnpgService.RotateDatabasePasswordAsync(TenantId, cnpgClusterId, databaseId);
|
||
// Refresh credentials panel if it was open for this database.
|
||
if (credentialsDatabaseId == databaseId)
|
||
{
|
||
databaseCredentials = await CnpgService.GetDatabaseCredentialsAsync(TenantId, databaseId);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
dbError = $"Failed to rotate password: {ex.Message}";
|
||
}
|
||
finally
|
||
{
|
||
rotatingDatabaseId = null;
|
||
}
|
||
}
|
||
|
||
private async Task FixDatabaseOwnerPermissions(CnpgCluster cluster, CnpgDatabase database)
|
||
{
|
||
dbFixBusyId = database.Id;
|
||
dbFixMessageId = database.Id;
|
||
dbFixMessage = null;
|
||
|
||
try
|
||
{
|
||
await CnpgService.GrantDatabaseOwnerPermissionsAsync(TenantId, cluster.Id, database.Id);
|
||
dbOwnershipOk[database.Id] = true;
|
||
dbFixMessage = $"Permissions granted to '{database.Owner}' on all objects in schema public.";
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
dbFixMessage = $"Error: {ex.Message}";
|
||
}
|
||
finally
|
||
{
|
||
dbFixBusyId = null;
|
||
}
|
||
}
|
||
|
||
private async Task FetchClusterSuperuserPassword(CnpgCluster cluster)
|
||
{
|
||
clusterSuperuserFetchingId = cluster.Id;
|
||
clusterMessages.Remove(cluster.Id);
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
await CnpgService.FetchSuperuserCredentialsAsync(TenantId, cluster.Id);
|
||
(string? user, _) = await CnpgService.GetSuperuserCredentialsAsync(TenantId, cluster.Id);
|
||
clusterMessages[cluster.Id] = $"Superuser credentials stored (user: {user ?? "postgres"}).";
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
clusterMessages[cluster.Id] = $"Error: {ex.Message}";
|
||
}
|
||
finally
|
||
{
|
||
clusterSuperuserFetchingId = null;
|
||
StateHasChanged();
|
||
}
|
||
}
|
||
|
||
private async Task ReleaseLiquibaseLock(CnpgCluster cluster, CnpgDatabase database)
|
||
{
|
||
dbFixBusyId = database.Id;
|
||
dbFixMessageId = database.Id;
|
||
dbFixMessage = null;
|
||
|
||
try
|
||
{
|
||
await CnpgService.ReleaseLiquibaseLockAsync(TenantId, cluster.Id, database.Id);
|
||
dbFixMessage = "Liquibase lock released — restart Keycloak pods to resume startup.";
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
dbFixMessage = $"Error: {ex.Message}";
|
||
}
|
||
finally
|
||
{
|
||
dbFixBusyId = null;
|
||
}
|
||
}
|
||
|
||
// ──────── Status Helpers ────────
|
||
|
||
// ──────── Managed MongoDB Methods ────────
|
||
|
||
private void ShowAtlasRestore(MongoCluster mc)
|
||
{
|
||
atlasRestoreClusterId = mc.Id;
|
||
atlasUri = "";
|
||
atlasSourceDb = "";
|
||
atlasTargetDb = "";
|
||
atlasRestoreError = null;
|
||
atlasRestoreSuccess = null;
|
||
}
|
||
|
||
private void CancelAtlasRestore()
|
||
{
|
||
atlasRestoreClusterId = null;
|
||
atlasRestoreError = null;
|
||
atlasRestoreSuccess = null;
|
||
}
|
||
|
||
private async Task StartAtlasRestore(MongoCluster mc)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(atlasUri) || string.IsNullOrWhiteSpace(atlasSourceDb))
|
||
{
|
||
atlasRestoreError = "Connection string and source database are required.";
|
||
return;
|
||
}
|
||
|
||
string targetDb = string.IsNullOrWhiteSpace(atlasTargetDb) ? atlasSourceDb.Trim() : atlasTargetDb.Trim();
|
||
|
||
atlasRestoring = true;
|
||
atlasRestoreError = null;
|
||
atlasRestoreSuccess = null;
|
||
|
||
try
|
||
{
|
||
await MongoService.RestoreFromAtlasAsync(
|
||
TenantId, mc.Id, atlasUri.Trim(), atlasSourceDb.Trim(), targetDb);
|
||
atlasRestoreSuccess = $"Restore job started. The Job is now running in namespace '{mc.Namespace}' — check your cluster for progress.";
|
||
atlasUri = "";
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
atlasRestoreError = ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
atlasRestoring = false;
|
||
}
|
||
}
|
||
|
||
private async Task ShowMongoRegisterForm()
|
||
{
|
||
showMongoRegister = true;
|
||
mongoRegisterError = null;
|
||
mongoAvailableClusters = await DatabaseService.GetMongoEnabledClustersAsync(TenantId);
|
||
}
|
||
|
||
private void CancelMongoRegister()
|
||
{
|
||
showMongoRegister = false;
|
||
mongoRegisterError = null;
|
||
mongoRegName = "";
|
||
mongoRegNamespace = "database";
|
||
mongoRegClusterId = Guid.Empty;
|
||
mongoRegExternalUri = "";
|
||
}
|
||
|
||
private async Task RegisterMongoCluster()
|
||
{
|
||
if (string.IsNullOrWhiteSpace(mongoRegName) || mongoRegClusterId == Guid.Empty
|
||
|| string.IsNullOrWhiteSpace(mongoRegNamespace))
|
||
{
|
||
mongoRegisterError = "Name, namespace, and target cluster are required.";
|
||
return;
|
||
}
|
||
|
||
mongoRegistering = true;
|
||
mongoRegisterError = null;
|
||
|
||
try
|
||
{
|
||
await MongoService.RegisterExistingClusterAsync(
|
||
TenantId, mongoRegClusterId, mongoRegName.Trim(), mongoRegNamespace.Trim(),
|
||
string.IsNullOrWhiteSpace(mongoRegExternalUri) ? null : mongoRegExternalUri.Trim());
|
||
managedMongoClusters = await MongoService.GetClustersAsync(TenantId);
|
||
CancelMongoRegister();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
mongoRegisterError = ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
mongoRegistering = false;
|
||
}
|
||
}
|
||
|
||
private async Task ShowMongoCreateForm()
|
||
{
|
||
showMongoCreate = true;
|
||
mongoCreateError = null;
|
||
mongoAvailableClusters = await DatabaseService.GetMongoEnabledClustersAsync(TenantId);
|
||
storageLinks ??= await StorageService.GetStorageLinksAsync(TenantId);
|
||
}
|
||
|
||
private void CancelMongoCreate()
|
||
{
|
||
showMongoCreate = false;
|
||
mongoCreateError = null;
|
||
mongoCreateName = "";
|
||
mongoCreateNamespace = "databases";
|
||
mongoCreateClusterId = Guid.Empty;
|
||
mongoCreateVersion = "8.3.2";
|
||
mongoCreateMembers = 3;
|
||
mongoCreateStorageSize = "10Gi";
|
||
mongoCreateStorageLinkId = Guid.Empty;
|
||
mongoCreateSchedule = "";
|
||
mongoCreateRetentionDays = 30;
|
||
mongoCreateMaxBackups = 20;
|
||
}
|
||
|
||
private async Task CreateMongoCluster()
|
||
{
|
||
if (string.IsNullOrWhiteSpace(mongoCreateName) || mongoCreateClusterId == Guid.Empty)
|
||
{
|
||
mongoCreateError = "Name and target cluster are required.";
|
||
return;
|
||
}
|
||
|
||
mongoCreating = true;
|
||
mongoCreateError = null;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
Guid? storageLinkId = mongoCreateStorageLinkId != Guid.Empty ? mongoCreateStorageLinkId : null;
|
||
string? schedule = !string.IsNullOrWhiteSpace(mongoCreateSchedule) ? mongoCreateSchedule : null;
|
||
|
||
await MongoService.CreateClusterAsync(
|
||
TenantId, mongoCreateClusterId, mongoCreateName.Trim(), mongoCreateNamespace.Trim(),
|
||
mongoCreateMembers, mongoCreateStorageSize.Trim(), storageLinkId, schedule,
|
||
mongoCreateRetentionDays, mongoCreateMaxBackups, mongoCreateVersion.Trim());
|
||
|
||
CancelMongoCreate();
|
||
managedMongoClusters = await MongoService.GetClustersAsync(TenantId);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
mongoCreateError = ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
mongoCreating = false;
|
||
}
|
||
}
|
||
|
||
private void InitiateDeleteMongoCluster(MongoCluster cluster)
|
||
{
|
||
mongoDeleteConfirmId = cluster.Id;
|
||
}
|
||
|
||
private async Task DeleteMongoCluster(Guid mongoClusterId)
|
||
{
|
||
mongoDeletingId = mongoClusterId;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
await MongoService.DeleteClusterAsync(TenantId, mongoClusterId);
|
||
managedMongoClusters = await MongoService.GetClustersAsync(TenantId);
|
||
mongoDeleteConfirmId = null;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
mongoCreateError = $"Delete failed: {ex.Message}";
|
||
}
|
||
finally
|
||
{
|
||
mongoDeletingId = null;
|
||
}
|
||
}
|
||
|
||
private async Task UnregisterMongoCluster(Guid mongoClusterId)
|
||
{
|
||
mongoDeletingId = mongoClusterId;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
await MongoService.UnregisterClusterAsync(TenantId, mongoClusterId);
|
||
managedMongoClusters = await MongoService.GetClustersAsync(TenantId);
|
||
mongoDeleteConfirmId = null;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
mongoCreateError = $"Unregister failed: {ex.Message}";
|
||
}
|
||
finally
|
||
{
|
||
mongoDeletingId = null;
|
||
}
|
||
}
|
||
|
||
private void ToggleMongoScheduleForm(MongoCluster cluster)
|
||
{
|
||
if (mongoConfiguringScheduleId == cluster.Id) { CancelMongoScheduleForm(); return; }
|
||
expandedMongoClusterId = cluster.Id;
|
||
mongoConfiguringScheduleId = cluster.Id;
|
||
mongoScheduleInput = cluster.BackupSchedule ?? "";
|
||
mongoScheduleRetentionDays = cluster.RetentionDays;
|
||
mongoScheduleMaxBackups = cluster.MaxBackups;
|
||
mongoScheduleError = null;
|
||
mongoScheduleSuccess = null;
|
||
}
|
||
|
||
private void CancelMongoScheduleForm()
|
||
{
|
||
mongoConfiguringScheduleId = null;
|
||
mongoScheduleError = null;
|
||
mongoScheduleSuccess = null;
|
||
}
|
||
|
||
private async Task SaveMongoBackupSettings(Guid mongoClusterId)
|
||
{
|
||
mongoApplyingScheduleId = mongoClusterId;
|
||
try
|
||
{
|
||
await MongoService.UpdateBackupSettingsAsync(TenantId, mongoClusterId, mongoScheduleInput, mongoScheduleRetentionDays, mongoScheduleMaxBackups);
|
||
mongoScheduleSuccess = "Backup settings saved and applied to Kubernetes.";
|
||
}
|
||
catch (Exception ex) { mongoScheduleError = ex.Message; }
|
||
finally
|
||
{
|
||
mongoApplyingScheduleId = null;
|
||
managedMongoClusters = await MongoService.GetClustersAsync(TenantId);
|
||
}
|
||
}
|
||
|
||
private async Task CleanupMongoBackups(MongoCluster cluster)
|
||
{
|
||
mongoCleaningUpBackupsId = cluster.Id;
|
||
try
|
||
{
|
||
int deleted = await MongoService.CleanupOldBackupsAsync(TenantId, cluster.Id);
|
||
string msg = deleted == 0
|
||
? $"No cleanup needed — {cluster.MaxBackups} or fewer completed backups."
|
||
: $"Removed {deleted} backup Job(s) from Kubernetes. Keeping the {cluster.MaxBackups} most recent. Note: backup data in S3 is managed separately via S3 lifecycle rules.";
|
||
mongoClusterMessages[cluster.Id] = msg;
|
||
if (expandedMongoClusterId == cluster.Id)
|
||
mongoClusterDetail = await MongoService.GetClusterDetailAsync(TenantId, cluster.Id);
|
||
}
|
||
catch (Exception ex) { mongoClusterMessages[cluster.Id] = $"Error: {ex.Message}"; }
|
||
finally { mongoCleaningUpBackupsId = null; }
|
||
}
|
||
|
||
private async Task TriggerMongoBackup(Guid mongoClusterId)
|
||
{
|
||
mongoBackingUpId = mongoClusterId;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
await MongoService.BackupAsync(TenantId, mongoClusterId);
|
||
managedMongoClusters = await MongoService.GetClustersAsync(TenantId);
|
||
await RefreshOpenDetailPanels();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
mongoCreateError = $"Backup failed: {ex.Message}";
|
||
}
|
||
finally
|
||
{
|
||
mongoBackingUpId = null;
|
||
}
|
||
}
|
||
|
||
private void ShowMongoRestore(MongoCluster source)
|
||
{
|
||
mongoRestoreSource = source;
|
||
mongoRestoreBackupId = source.Backups
|
||
.Where(b => b.Status == MongoBackupStatus.Completed)
|
||
.OrderByDescending(b => b.StartedAt)
|
||
.Select(b => b.Id)
|
||
.FirstOrDefault();
|
||
mongoRestoreError = null;
|
||
}
|
||
|
||
private async Task ExecuteMongoRestore()
|
||
{
|
||
if (mongoRestoreBackupId == Guid.Empty)
|
||
{
|
||
mongoRestoreError = "Select a backup to restore from.";
|
||
return;
|
||
}
|
||
|
||
mongoRestoring = true;
|
||
mongoRestoreError = null;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
await MongoService.RestoreAsync(
|
||
TenantId, mongoRestoreSource!.Id, mongoRestoreBackupId);
|
||
|
||
mongoRestoreSource = null;
|
||
managedMongoClusters = await MongoService.GetClustersAsync(TenantId);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
mongoRestoreError = ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
mongoRestoring = false;
|
||
}
|
||
}
|
||
|
||
// ──────── Backup-Driven Restore ────────
|
||
|
||
private void SelectCnpgBackup(CnpgCluster cluster, CnpgBackup backup)
|
||
{
|
||
if (selectedCnpgBackup?.Name == backup.Name && selectedCnpgBackupCluster?.Id == cluster.Id)
|
||
{
|
||
selectedCnpgBackup = null;
|
||
selectedCnpgBackupCluster = null;
|
||
backupRestoreError = null;
|
||
return;
|
||
}
|
||
selectedCnpgBackup = backup;
|
||
selectedCnpgBackupCluster = cluster;
|
||
selectedMongoBackup = null;
|
||
selectedMongoBackupCluster = null;
|
||
backupRestoreTarget = "new";
|
||
backupRestoreNewName = $"{cluster.Name}-restored";
|
||
backupRestoreExternalUri = "";
|
||
backupRestoreError = null;
|
||
}
|
||
|
||
private void SelectMongoBackup(MongoCluster cluster, MongoBackup backup)
|
||
{
|
||
if (selectedMongoBackup?.Name == backup.Name && selectedMongoBackupCluster?.Id == cluster.Id)
|
||
{
|
||
selectedMongoBackup = null;
|
||
selectedMongoBackupCluster = null;
|
||
backupRestoreError = null;
|
||
return;
|
||
}
|
||
selectedMongoBackup = backup;
|
||
selectedMongoBackupCluster = cluster;
|
||
selectedCnpgBackup = null;
|
||
selectedCnpgBackupCluster = null;
|
||
backupRestoreTarget = "new";
|
||
backupRestoreNewName = $"{cluster.Name}-restored";
|
||
backupRestoreExternalUri = "";
|
||
backupRestoreError = null;
|
||
}
|
||
|
||
private async Task ExecuteBackupRestore()
|
||
{
|
||
backupRestoring = true;
|
||
backupRestoreError = null;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
if (selectedCnpgBackup != null)
|
||
{
|
||
switch (backupRestoreTarget)
|
||
{
|
||
case "current":
|
||
await CnpgService.RestoreInPlaceAsync(
|
||
TenantId, selectedCnpgBackupCluster!.Id,
|
||
selectedCnpgBackup.CompletedAt ?? DateTime.UtcNow);
|
||
break;
|
||
case "new":
|
||
if (string.IsNullOrWhiteSpace(backupRestoreNewName))
|
||
{
|
||
backupRestoreError = "New cluster name is required.";
|
||
return;
|
||
}
|
||
await CnpgService.RestoreAsync(
|
||
TenantId, selectedCnpgBackupCluster!.Id,
|
||
backupRestoreNewName.Trim(),
|
||
barmanBackupId: selectedCnpgBackup.BarmanId);
|
||
break;
|
||
default:
|
||
return;
|
||
}
|
||
}
|
||
else if (selectedMongoBackup != null)
|
||
{
|
||
switch (backupRestoreTarget)
|
||
{
|
||
case "current":
|
||
await MongoService.RestoreAsync(
|
||
TenantId, selectedMongoBackupCluster!.Id, selectedMongoBackup.Id);
|
||
break;
|
||
case "new":
|
||
if (string.IsNullOrWhiteSpace(backupRestoreNewName))
|
||
{
|
||
backupRestoreError = "New cluster name is required.";
|
||
return;
|
||
}
|
||
await MongoService.RestoreToNewClusterAsync(
|
||
TenantId, selectedMongoBackupCluster!.Id, selectedMongoBackup.Id,
|
||
backupRestoreNewName.Trim());
|
||
break;
|
||
case "external":
|
||
if (string.IsNullOrWhiteSpace(backupRestoreExternalUri))
|
||
{
|
||
backupRestoreError = "External connection URI is required.";
|
||
return;
|
||
}
|
||
await MongoService.RestoreToExternalAsync(
|
||
TenantId, selectedMongoBackupCluster!.Id, selectedMongoBackup.Id,
|
||
backupRestoreExternalUri.Trim());
|
||
break;
|
||
}
|
||
}
|
||
|
||
selectedCnpgBackup = null;
|
||
selectedCnpgBackupCluster = null;
|
||
selectedMongoBackup = null;
|
||
selectedMongoBackupCluster = null;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
backupRestoreError = ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
managedClusters = await CnpgService.GetClustersAsync(TenantId);
|
||
managedMongoClusters = await MongoService.GetClustersAsync(TenantId);
|
||
backupRestoring = false;
|
||
}
|
||
}
|
||
|
||
private void ShowMongoUpgrade(MongoCluster cluster)
|
||
{
|
||
mongoUpgradeCluster = cluster;
|
||
mongoUpgradeVersion = "8.2.9";
|
||
mongoUpgradeError = null;
|
||
}
|
||
|
||
private async Task ExecuteMongoUpgrade()
|
||
{
|
||
if (string.IsNullOrWhiteSpace(mongoUpgradeVersion))
|
||
{
|
||
mongoUpgradeError = "Version is required (e.g. 8.2.9).";
|
||
return;
|
||
}
|
||
|
||
mongoUpgrading = true;
|
||
mongoUpgradeError = null;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
await MongoService.UpgradeClusterAsync(TenantId, mongoUpgradeCluster!.Id, mongoUpgradeVersion);
|
||
mongoUpgradeCluster = null;
|
||
managedMongoClusters = await MongoService.GetClustersAsync(TenantId);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
mongoUpgradeError = ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
mongoUpgrading = false;
|
||
}
|
||
}
|
||
|
||
private void ShowMongoResize(MongoCluster cluster)
|
||
{
|
||
mongoResizeCluster = cluster;
|
||
mongoResizeStorageSize = "";
|
||
mongoResizeCpuRequest = cluster.CpuRequest ?? "";
|
||
mongoResizeCpuLimit = cluster.CpuLimit ?? "";
|
||
mongoResizeMemoryRequest = cluster.MemoryRequest ?? "";
|
||
mongoResizeMemoryLimit = cluster.MemoryLimit ?? "";
|
||
mongoResizeError = null;
|
||
}
|
||
|
||
private async Task ExecuteMongoResize()
|
||
{
|
||
if (string.IsNullOrWhiteSpace(mongoResizeStorageSize)
|
||
&& string.IsNullOrWhiteSpace(mongoResizeCpuRequest)
|
||
&& string.IsNullOrWhiteSpace(mongoResizeCpuLimit)
|
||
&& string.IsNullOrWhiteSpace(mongoResizeMemoryRequest)
|
||
&& string.IsNullOrWhiteSpace(mongoResizeMemoryLimit))
|
||
{
|
||
mongoResizeError = "Enter at least one value to change.";
|
||
return;
|
||
}
|
||
|
||
mongoResizing = true;
|
||
mongoResizeError = null;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
await MongoService.ResizeClusterAsync(
|
||
TenantId, mongoResizeCluster!.Id,
|
||
string.IsNullOrWhiteSpace(mongoResizeStorageSize) ? null : mongoResizeStorageSize,
|
||
string.IsNullOrWhiteSpace(mongoResizeCpuRequest) ? null : mongoResizeCpuRequest,
|
||
string.IsNullOrWhiteSpace(mongoResizeCpuLimit) ? null : mongoResizeCpuLimit,
|
||
string.IsNullOrWhiteSpace(mongoResizeMemoryRequest) ? null : mongoResizeMemoryRequest,
|
||
string.IsNullOrWhiteSpace(mongoResizeMemoryLimit) ? null : mongoResizeMemoryLimit);
|
||
|
||
mongoResizeCluster = null;
|
||
managedMongoClusters = await MongoService.GetClustersAsync(TenantId);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
mongoResizeError = ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
mongoResizing = false;
|
||
}
|
||
}
|
||
|
||
private void ShowMongoAddDatabase(MongoCluster cluster)
|
||
{
|
||
mongoAddDbCluster = cluster;
|
||
mongoNewDbName = "";
|
||
mongoDbError = null;
|
||
}
|
||
|
||
private async Task AddMongoDatabase()
|
||
{
|
||
if (string.IsNullOrWhiteSpace(mongoNewDbName))
|
||
{
|
||
mongoDbError = "Database name is required.";
|
||
return;
|
||
}
|
||
|
||
mongoAddingDb = true;
|
||
mongoDbError = null;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
await MongoService.CreateDatabaseAsync(TenantId, mongoAddDbCluster!.Id, mongoNewDbName.Trim());
|
||
mongoAddDbCluster = null;
|
||
managedMongoClusters = await MongoService.GetClustersAsync(TenantId);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
mongoDbError = ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
mongoAddingDb = false;
|
||
}
|
||
}
|
||
|
||
private void InitiateDeleteMongoDatabase(MongoCluster cluster, Guid databaseId)
|
||
{
|
||
mongoDbDeleteConfirmId = databaseId;
|
||
}
|
||
|
||
private async Task DeleteMongoDatabase(Guid mongoClusterId, Guid databaseId)
|
||
{
|
||
mongoDroppingDbId = databaseId;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
await MongoService.DeleteDatabaseAsync(TenantId, mongoClusterId, databaseId);
|
||
managedMongoClusters = await MongoService.GetClustersAsync(TenantId);
|
||
mongoDbDeleteConfirmId = null;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
mongoDbError = $"Failed to delete database: {ex.Message}";
|
||
}
|
||
finally
|
||
{
|
||
mongoDroppingDbId = null;
|
||
}
|
||
}
|
||
|
||
private async Task UnregisterMongoDatabase(Guid mongoClusterId, Guid databaseId)
|
||
{
|
||
mongoDroppingDbId = databaseId;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
await MongoService.UnregisterDatabaseAsync(TenantId, mongoClusterId, databaseId);
|
||
managedMongoClusters = await MongoService.GetClustersAsync(TenantId);
|
||
mongoDbDeleteConfirmId = null;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
mongoDbError = $"Failed to unregister database: {ex.Message}";
|
||
}
|
||
finally
|
||
{
|
||
mongoDroppingDbId = null;
|
||
}
|
||
}
|
||
|
||
// ── MongoDB database credentials ──
|
||
|
||
private async Task ToggleMongoDatabaseCredentials(Guid mongoClusterId, Guid databaseId)
|
||
{
|
||
if (mongoCredDbId == databaseId)
|
||
{
|
||
mongoCredDbId = null;
|
||
mongoDbCredentials = null;
|
||
return;
|
||
}
|
||
|
||
mongoCredDbId = databaseId;
|
||
mongoDbCredentials = null;
|
||
mongoDbActionError = null;
|
||
mongoDbActionErrorId = null;
|
||
|
||
try
|
||
{
|
||
mongoDbCredentials = await MongoService.GetDatabaseCredentialsAsync(TenantId, databaseId);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
mongoDbActionError = ex.Message;
|
||
mongoDbActionErrorId = databaseId;
|
||
mongoCredDbId = null;
|
||
}
|
||
}
|
||
|
||
private async Task SyncMongoDatabaseCredentials(Guid mongoClusterId, Guid databaseId)
|
||
{
|
||
mongoSyncingDbId = databaseId;
|
||
mongoDbActionError = null;
|
||
mongoDbActionErrorId = null;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
await MongoService.SyncDatabaseCredentialsToK8sAsync(TenantId, mongoClusterId, databaseId);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
mongoDbActionError = ex.Message;
|
||
mongoDbActionErrorId = databaseId;
|
||
}
|
||
finally
|
||
{
|
||
mongoSyncingDbId = null;
|
||
}
|
||
}
|
||
|
||
private async Task RotateMongoDatabasePassword(Guid mongoClusterId, Guid databaseId)
|
||
{
|
||
mongoRotatingDbId = databaseId;
|
||
mongoDbActionError = null;
|
||
mongoDbActionErrorId = null;
|
||
mongoCredDbId = null;
|
||
mongoDbCredentials = null;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
await MongoService.RotateDatabasePasswordAsync(TenantId, mongoClusterId, databaseId);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
mongoDbActionError = ex.Message;
|
||
mongoDbActionErrorId = databaseId;
|
||
}
|
||
finally
|
||
{
|
||
mongoRotatingDbId = null;
|
||
}
|
||
}
|
||
|
||
// ── MongoDB per-database migrate ──
|
||
|
||
private void ShowMongoDatabaseMigrate(MongoCluster sourceCluster, MongoDatabase db)
|
||
{
|
||
mongoMigrateDbId = db.Id;
|
||
mongoMigrateDbClusterId = sourceCluster.Id;
|
||
mongoMigrateDbTargetName = db.Name;
|
||
// Default to first other managed cluster in the same environment, if available.
|
||
mongoMigrateDbTargetClusterId = managedMongoClusters?
|
||
.FirstOrDefault(c => !c.IsExternal && c.Id != sourceCluster.Id)?.Id ?? Guid.Empty;
|
||
mongoMigrateDbError = null;
|
||
mongoMigrateDbSuccess = null;
|
||
}
|
||
|
||
private async Task ExecuteMongoDatabaseMigrate()
|
||
{
|
||
if (mongoMigrateDbTargetClusterId == Guid.Empty)
|
||
{
|
||
mongoMigrateDbError = "Select a target cluster.";
|
||
return;
|
||
}
|
||
|
||
mongoMigratingDb = true;
|
||
mongoMigrateDbError = null;
|
||
mongoMigrateDbSuccess = null;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
await MongoService.MigrateDatabaseAsync(
|
||
TenantId,
|
||
mongoMigrateDbClusterId!.Value,
|
||
mongoMigrateDbId!.Value,
|
||
mongoMigrateDbTargetClusterId,
|
||
mongoMigrateDbTargetName.Trim());
|
||
|
||
mongoMigrateDbSuccess = "Migration job started. Check the source cluster's Jobs for progress.";
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
mongoMigrateDbError = ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
mongoMigratingDb = false;
|
||
}
|
||
}
|
||
|
||
// ── External cluster settings ──
|
||
|
||
private async Task ShowMongoExtSettings(MongoCluster cluster)
|
||
{
|
||
storageLinks ??= await StorageService.GetStorageLinksAsync(TenantId);
|
||
mongoExtSettingsClusterId = cluster.Id;
|
||
mongoExtUri = ""; // admin password — never pre-filled for security
|
||
mongoExtStorageLinkId = cluster.StorageLinkId ?? Guid.Empty;
|
||
mongoExtSchedule = cluster.BackupSchedule ?? "";
|
||
mongoExtRetentionDays = cluster.RetentionDays;
|
||
mongoExtMaxBackups = cluster.MaxBackups;
|
||
mongoExtError = null;
|
||
mongoExtSuccess = null;
|
||
}
|
||
|
||
private async Task SaveMongoExtSettings(Guid mongoClusterId)
|
||
{
|
||
mongoExtSaving = true;
|
||
mongoExtError = null;
|
||
mongoExtSuccess = null;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
Guid? linkId = mongoExtStorageLinkId != Guid.Empty ? mongoExtStorageLinkId : null;
|
||
// mongoExtUri holds the admin password (blank = keep existing K8s Secret unchanged)
|
||
await MongoService.UpdateExternalClusterSettingsAsync(
|
||
TenantId, mongoClusterId,
|
||
string.IsNullOrWhiteSpace(mongoExtUri) ? null : mongoExtUri.Trim(),
|
||
linkId,
|
||
string.IsNullOrWhiteSpace(mongoExtSchedule) ? null : mongoExtSchedule.Trim(),
|
||
mongoExtRetentionDays, mongoExtMaxBackups);
|
||
|
||
managedMongoClusters = await MongoService.GetClustersAsync(TenantId);
|
||
mongoExtSuccess = "Settings saved.";
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
mongoExtError = ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
mongoExtSaving = false;
|
||
}
|
||
}
|
||
|
||
// ── Track external database ──
|
||
|
||
private void ShowMongoTrackDb(MongoCluster cluster)
|
||
{
|
||
mongoTrackDbClusterId = cluster.Id;
|
||
mongoTrackDbName = "";
|
||
mongoTrackDbOwner = "";
|
||
mongoTrackDbError = null;
|
||
}
|
||
|
||
private async Task TrackExternalDatabase()
|
||
{
|
||
if (string.IsNullOrWhiteSpace(mongoTrackDbName))
|
||
{
|
||
mongoTrackDbError = "Database name is required.";
|
||
return;
|
||
}
|
||
|
||
mongoTrackingDb = true;
|
||
mongoTrackDbError = null;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
await MongoService.RegisterExternalDatabaseAsync(
|
||
TenantId, mongoTrackDbClusterId!.Value,
|
||
mongoTrackDbName.Trim(),
|
||
string.IsNullOrWhiteSpace(mongoTrackDbOwner) ? mongoTrackDbName.Trim() : mongoTrackDbOwner.Trim());
|
||
|
||
mongoTrackDbClusterId = null;
|
||
managedMongoClusters = await MongoService.GetClustersAsync(TenantId);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
mongoTrackDbError = ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
mongoTrackingDb = false;
|
||
}
|
||
}
|
||
|
||
// ── Migrate external → managed ──
|
||
|
||
private void ShowMongoMigrate(MongoCluster source)
|
||
{
|
||
mongoMigrateSourceClusterId = source.Id;
|
||
var managed = managedMongoClusters?.Where(c => !c.IsExternal).ToList();
|
||
mongoMigrateTargetClusterId = managed?.FirstOrDefault()?.Id ?? Guid.Empty;
|
||
mongoMigrateError = null;
|
||
mongoMigrateSuccess = null;
|
||
}
|
||
|
||
private async Task ExecuteMongoMigrate()
|
||
{
|
||
if (mongoMigrateTargetClusterId == Guid.Empty)
|
||
{
|
||
mongoMigrateError = "Select a target managed cluster.";
|
||
return;
|
||
}
|
||
|
||
mongoMigrating = true;
|
||
mongoMigrateError = null;
|
||
mongoMigrateSuccess = null;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
await MongoService.MigrateFromExternalToManagedAsync(
|
||
TenantId, mongoMigrateSourceClusterId!.Value, mongoMigrateTargetClusterId);
|
||
|
||
mongoMigrateSuccess = "Migration job started. Check the target cluster's Jobs for progress.";
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
mongoMigrateError = ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
mongoMigrating = false;
|
||
}
|
||
}
|
||
|
||
private async Task ToggleMongoClusterDetail(MongoCluster cluster)
|
||
{
|
||
if (expandedMongoClusterId == cluster.Id)
|
||
{
|
||
expandedMongoClusterId = null;
|
||
mongoClusterDetail = null;
|
||
return;
|
||
}
|
||
|
||
expandedMongoClusterId = cluster.Id;
|
||
mongoClusterDetail = null;
|
||
mongoLoadingDetail = true;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
mongoClusterDetail = await MongoService.GetClusterDetailAsync(TenantId, cluster.Id);
|
||
}
|
||
catch (Exception)
|
||
{
|
||
mongoClusterDetail = new MongoClusterDetail
|
||
{
|
||
Cluster = cluster,
|
||
Phase = "Error fetching status"
|
||
};
|
||
}
|
||
finally
|
||
{
|
||
mongoLoadingDetail = false;
|
||
}
|
||
}
|
||
|
||
private static RenderFragment GetMongoManagedStatusBadge(MongoClusterStatus status) => status switch
|
||
{
|
||
MongoClusterStatus.Running => __builder =>
|
||
{
|
||
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Running</span>
|
||
},
|
||
MongoClusterStatus.Creating => __builder =>
|
||
{
|
||
<span class="badge bg-warning text-dark"><i class="bi bi-hourglass me-1"></i>Creating</span>
|
||
},
|
||
MongoClusterStatus.Upgrading => __builder =>
|
||
{
|
||
<span class="badge bg-info"><i class="bi bi-arrow-up-circle me-1"></i>Upgrading</span>
|
||
},
|
||
MongoClusterStatus.Restoring => __builder =>
|
||
{
|
||
<span class="badge bg-info"><i class="bi bi-clock-history me-1"></i>Restoring</span>
|
||
},
|
||
MongoClusterStatus.Failed => __builder =>
|
||
{
|
||
<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>Failed</span>
|
||
},
|
||
MongoClusterStatus.Deleting => __builder =>
|
||
{
|
||
<span class="badge bg-secondary"><i class="bi bi-trash me-1"></i>Deleting</span>
|
||
},
|
||
_ => __builder =>
|
||
{
|
||
<span class="badge bg-secondary">@status</span>
|
||
}
|
||
};
|
||
|
||
// ──────── Status Helpers (CNPG) ────────
|
||
|
||
private static RenderFragment GetStatusBadge(string status) => status.ToLowerInvariant() switch
|
||
{
|
||
"cluster in healthy state" or "healthy" or "running" => __builder =>
|
||
{
|
||
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Healthy</span>
|
||
},
|
||
"setting up primary" or "creating primary" or "pending" => __builder =>
|
||
{
|
||
<span class="badge bg-warning text-dark"><i class="bi bi-hourglass me-1"></i>Provisioning</span>
|
||
},
|
||
"failed" or "error" => __builder =>
|
||
{
|
||
<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>Failed</span>
|
||
},
|
||
_ => __builder =>
|
||
{
|
||
<span class="badge bg-secondary">@status</span>
|
||
}
|
||
};
|
||
|
||
// ──────── Registered PostgreSQL ────────
|
||
|
||
private void ShowRegisterPgForm()
|
||
{
|
||
showRegisterPgForm = true;
|
||
registerPgError = null;
|
||
}
|
||
|
||
private void CancelRegisterPgForm()
|
||
{
|
||
showRegisterPgForm = false;
|
||
regPgName = "";
|
||
regPgClusterId = Guid.Empty;
|
||
regPgNamespace = "";
|
||
regPgServiceName = "";
|
||
regPgPort = 5432;
|
||
regPgAdminPodName = "";
|
||
regPgAdminUsername = "postgres";
|
||
regPgAdminPassword = "";
|
||
regPgNotes = "";
|
||
registerPgError = null;
|
||
}
|
||
|
||
private async Task RegisterPgInstance()
|
||
{
|
||
registerPgError = null;
|
||
|
||
if (string.IsNullOrWhiteSpace(regPgName) || regPgClusterId == Guid.Empty
|
||
|| string.IsNullOrWhiteSpace(regPgNamespace) || string.IsNullOrWhiteSpace(regPgServiceName)
|
||
|| string.IsNullOrWhiteSpace(regPgAdminPodName) || string.IsNullOrWhiteSpace(regPgAdminUsername)
|
||
|| string.IsNullOrWhiteSpace(regPgAdminPassword))
|
||
{
|
||
registerPgError = "Please fill in all required fields.";
|
||
return;
|
||
}
|
||
|
||
registeringPg = true;
|
||
|
||
try
|
||
{
|
||
await RegisteredPostgresService.RegisterInstanceAsync(
|
||
TenantId, regPgClusterId, regPgName.Trim(), regPgNamespace.Trim(),
|
||
regPgServiceName.Trim(), regPgPort, regPgAdminPodName.Trim(),
|
||
regPgAdminUsername.Trim(), regPgAdminPassword,
|
||
string.IsNullOrWhiteSpace(regPgNotes) ? null : regPgNotes.Trim(),
|
||
testConnection: true);
|
||
|
||
registeredPgInstances = await RegisteredPostgresService.GetInstancesAsync(TenantId);
|
||
CancelRegisterPgForm();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
registerPgError = ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
registeringPg = false;
|
||
}
|
||
}
|
||
|
||
private async Task ToggleRegPgDetail(Guid instanceId)
|
||
{
|
||
expandedRegPgId = expandedRegPgId == instanceId ? null : instanceId;
|
||
|
||
if (expandedRegPgId is null)
|
||
{
|
||
addDbRegPgInstance = null;
|
||
importRegPgInstance = null;
|
||
regPgCredDbId = null;
|
||
regPgCredentials = null;
|
||
dumpsByDatabase = null;
|
||
showDumpFormForDbId = null;
|
||
showMigrateFormForDbId = null;
|
||
showRestoreFormForDumpId = null;
|
||
regPgPods.Clear();
|
||
regPgPodsLoading.Clear();
|
||
}
|
||
else
|
||
{
|
||
await LoadDumpsForInstance(instanceId);
|
||
regPgPodsLoading[instanceId] = true;
|
||
StateHasChanged();
|
||
regPgPods[instanceId] = await RegisteredPostgresService.GetPodsAsync(TenantId, instanceId);
|
||
regPgPodsLoading[instanceId] = false;
|
||
}
|
||
}
|
||
|
||
private async Task TestRegPgConnection(Guid instanceId)
|
||
{
|
||
testingRegPgId = instanceId;
|
||
testRegPgResultId = null;
|
||
testRegPgResult = null;
|
||
|
||
try
|
||
{
|
||
await RegisteredPostgresService.TestConnectionAsync(TenantId, instanceId);
|
||
testRegPgResult = "OK — connection successful.";
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
testRegPgResult = $"Failed: {ex.Message}";
|
||
}
|
||
finally
|
||
{
|
||
testingRegPgId = null;
|
||
testRegPgResultId = instanceId;
|
||
}
|
||
}
|
||
|
||
private async Task DeleteRegPgInstance(Guid instanceId)
|
||
{
|
||
deletingRegPgId = instanceId;
|
||
deleteRegPgError = null;
|
||
|
||
try
|
||
{
|
||
await RegisteredPostgresService.DeleteInstanceAsync(TenantId, instanceId);
|
||
registeredPgInstances = await RegisteredPostgresService.GetInstancesAsync(TenantId);
|
||
if (expandedRegPgId == instanceId) expandedRegPgId = null;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
deleteRegPgError = ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
deletingRegPgId = null;
|
||
}
|
||
}
|
||
|
||
private void ShowAddRegPgDatabase(RegisteredPostgresInstance instance)
|
||
{
|
||
addDbRegPgInstance = instance;
|
||
importRegPgInstance = null;
|
||
newRegPgDbName = "";
|
||
addRegPgDbError = null;
|
||
}
|
||
|
||
private void ShowImportRegPgDatabase(RegisteredPostgresInstance instance)
|
||
{
|
||
importRegPgInstance = instance;
|
||
addDbRegPgInstance = null;
|
||
importRegPgDbName = "";
|
||
importRegPgOwner = "";
|
||
importRegPgPassword = "";
|
||
importRegPgError = null;
|
||
}
|
||
|
||
private async Task CreateRegPgDatabase()
|
||
{
|
||
addRegPgDbError = null;
|
||
|
||
if (addDbRegPgInstance is null || string.IsNullOrWhiteSpace(newRegPgDbName))
|
||
{
|
||
addRegPgDbError = "Database name is required.";
|
||
return;
|
||
}
|
||
|
||
addingRegPgDb = true;
|
||
|
||
try
|
||
{
|
||
await RegisteredPostgresService.CreateDatabaseAsync(TenantId, addDbRegPgInstance.Id, newRegPgDbName.Trim());
|
||
registeredPgInstances = await RegisteredPostgresService.GetInstancesAsync(TenantId);
|
||
addDbRegPgInstance = null;
|
||
newRegPgDbName = "";
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
addRegPgDbError = ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
addingRegPgDb = false;
|
||
}
|
||
}
|
||
|
||
private async Task ImportRegPgDatabase()
|
||
{
|
||
importRegPgError = null;
|
||
|
||
if (importRegPgInstance is null || string.IsNullOrWhiteSpace(importRegPgDbName)
|
||
|| string.IsNullOrWhiteSpace(importRegPgOwner) || string.IsNullOrWhiteSpace(importRegPgPassword))
|
||
{
|
||
importRegPgError = "All fields are required.";
|
||
return;
|
||
}
|
||
|
||
importingRegPgDb = true;
|
||
|
||
try
|
||
{
|
||
await RegisteredPostgresService.ImportDatabaseAsync(
|
||
TenantId, importRegPgInstance.Id,
|
||
importRegPgDbName.Trim(), importRegPgOwner.Trim(), importRegPgPassword);
|
||
|
||
registeredPgInstances = await RegisteredPostgresService.GetInstancesAsync(TenantId);
|
||
importRegPgInstance = null;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
importRegPgError = ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
importingRegPgDb = false;
|
||
}
|
||
}
|
||
|
||
private async Task ViewRegPgCredentials(RegisteredPostgresInstance instance, Guid databaseId)
|
||
{
|
||
if (regPgCredDbId == databaseId)
|
||
{
|
||
regPgCredDbId = null;
|
||
regPgCredentials = null;
|
||
return;
|
||
}
|
||
|
||
regPgCredDbId = databaseId;
|
||
regPgCredentials = await RegisteredPostgresService.GetDatabaseCredentialsAsync(TenantId, databaseId);
|
||
}
|
||
|
||
private async Task SyncRegPgCredentials(Guid instanceId, Guid databaseId)
|
||
{
|
||
syncingRegPgDbId = databaseId;
|
||
regPgDbActionError = null;
|
||
regPgDbActionErrorId = null;
|
||
|
||
try
|
||
{
|
||
await RegisteredPostgresService.SyncCredentialsToK8sAsync(TenantId, instanceId, databaseId);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
regPgDbActionError = ex.Message;
|
||
regPgDbActionErrorId = databaseId;
|
||
}
|
||
finally
|
||
{
|
||
syncingRegPgDbId = null;
|
||
}
|
||
}
|
||
|
||
private async Task RotateRegPgPassword(Guid instanceId, Guid databaseId)
|
||
{
|
||
rotatingRegPgDbId = databaseId;
|
||
regPgDbActionError = null;
|
||
regPgDbActionErrorId = null;
|
||
|
||
try
|
||
{
|
||
await RegisteredPostgresService.RotatePasswordAsync(TenantId, instanceId, databaseId);
|
||
if (regPgCredDbId == databaseId)
|
||
{
|
||
regPgCredentials = await RegisteredPostgresService.GetDatabaseCredentialsAsync(TenantId, databaseId);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
regPgDbActionError = ex.Message;
|
||
regPgDbActionErrorId = databaseId;
|
||
}
|
||
finally
|
||
{
|
||
rotatingRegPgDbId = null;
|
||
}
|
||
}
|
||
|
||
private async Task DropRegPgDatabase(Guid instanceId, Guid databaseId)
|
||
{
|
||
droppingRegPgDbId = databaseId;
|
||
regPgDbActionError = null;
|
||
regPgDbActionErrorId = null;
|
||
|
||
try
|
||
{
|
||
await RegisteredPostgresService.DropDatabaseAsync(TenantId, instanceId, databaseId);
|
||
registeredPgInstances = await RegisteredPostgresService.GetInstancesAsync(TenantId);
|
||
regPgDbDeleteConfirmId = null;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
regPgDbActionError = ex.Message;
|
||
regPgDbActionErrorId = databaseId;
|
||
}
|
||
finally
|
||
{
|
||
droppingRegPgDbId = null;
|
||
}
|
||
}
|
||
|
||
private async Task UnregisterRegPgDatabase(Guid instanceId, Guid databaseId)
|
||
{
|
||
droppingRegPgDbId = databaseId;
|
||
regPgDbActionError = null;
|
||
regPgDbActionErrorId = null;
|
||
|
||
try
|
||
{
|
||
await RegisteredPostgresService.UnregisterDatabaseAsync(TenantId, instanceId, databaseId);
|
||
registeredPgInstances = await RegisteredPostgresService.GetInstancesAsync(TenantId);
|
||
regPgDbDeleteConfirmId = null;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
regPgDbActionError = ex.Message;
|
||
regPgDbActionErrorId = databaseId;
|
||
}
|
||
finally
|
||
{
|
||
droppingRegPgDbId = null;
|
||
}
|
||
}
|
||
|
||
// ── Dump / Migrate State ──
|
||
|
||
// Per-database inline forms (keyed by database Id)
|
||
private Guid? showDumpFormForDbId;
|
||
private Guid dumpFormStorageLinkId;
|
||
private Guid? dumpingRegPgDbId;
|
||
private Guid? dumpErrorDbId;
|
||
private string? dumpError;
|
||
private string? dumpSuccess;
|
||
|
||
private Guid? showMigrateFormForDbId;
|
||
private Guid migrateFormCnpgClusterId;
|
||
private string migrateFormNewDbName = "";
|
||
private Guid? migratingRegPgDbId;
|
||
private Guid? migrateErrorDbId;
|
||
private string? migrateError;
|
||
private string? migrateSuccess;
|
||
|
||
// Dump history (loaded when instance is expanded)
|
||
private Dictionary<Guid, List<RegisteredPostgresDump>>? dumpsByDatabase;
|
||
private bool loadingDumps;
|
||
|
||
// Restore form (one at a time)
|
||
private Guid? showRestoreFormForDumpId;
|
||
private Guid restoreFormCnpgClusterId;
|
||
private string restoreFormNewDbName = "";
|
||
private Guid? restoringDumpId;
|
||
private Guid? dumpRestoreErrorDumpId;
|
||
private string? dumpRestoreError;
|
||
private string? dumpRestoreSuccess;
|
||
|
||
private Guid? deletingDumpId;
|
||
|
||
private void ShowRegPgDumpForm(RegisteredPostgresInstance rpi, RegisteredPostgresDatabase db)
|
||
{
|
||
showDumpFormForDbId = db.Id;
|
||
showMigrateFormForDbId = null;
|
||
dumpFormStorageLinkId = storageLinks?.FirstOrDefault()?.Id ?? Guid.Empty;
|
||
dumpError = null;
|
||
dumpSuccess = null;
|
||
dumpErrorDbId = null;
|
||
}
|
||
|
||
private void ShowRegPgMigrateForm(RegisteredPostgresInstance rpi, RegisteredPostgresDatabase db)
|
||
{
|
||
showMigrateFormForDbId = db.Id;
|
||
showDumpFormForDbId = null;
|
||
migrateFormNewDbName = db.Name;
|
||
migrateFormCnpgClusterId = managedClusters?.FirstOrDefault()?.Id ?? Guid.Empty;
|
||
migrateError = null;
|
||
migrateSuccess = null;
|
||
migrateErrorDbId = null;
|
||
}
|
||
|
||
private void ShowDumpRestoreForm(RegisteredPostgresDump dump)
|
||
{
|
||
showRestoreFormForDumpId = dump.Id;
|
||
restoreFormCnpgClusterId = managedClusters?.FirstOrDefault()?.Id ?? Guid.Empty;
|
||
restoreFormNewDbName = "";
|
||
dumpRestoreError = null;
|
||
dumpRestoreSuccess = null;
|
||
dumpRestoreErrorDumpId = null;
|
||
}
|
||
|
||
private async Task CreateRegPgDump(RegisteredPostgresInstance rpi, RegisteredPostgresDatabase db)
|
||
{
|
||
dumpingRegPgDbId = db.Id;
|
||
dumpError = null;
|
||
dumpSuccess = null;
|
||
dumpErrorDbId = db.Id;
|
||
|
||
try
|
||
{
|
||
RegisteredPostgresDump dump = await RegisteredPostgresService.CreateDumpAsync(
|
||
TenantId, rpi.Id, db.Id, dumpFormStorageLinkId);
|
||
|
||
// Refresh dump list for this instance
|
||
await LoadDumpsForInstance(rpi.Id);
|
||
|
||
dumpSuccess = $"Dump created ({FormatBytes(dump.SizeBytes)}) and uploaded to S3.";
|
||
showDumpFormForDbId = null;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
dumpError = ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
dumpingRegPgDbId = null;
|
||
}
|
||
}
|
||
|
||
private async Task MigrateRegPgToCnpg(RegisteredPostgresInstance rpi, RegisteredPostgresDatabase db)
|
||
{
|
||
migratingRegPgDbId = db.Id;
|
||
migrateError = null;
|
||
migrateSuccess = null;
|
||
migrateErrorDbId = db.Id;
|
||
|
||
try
|
||
{
|
||
await RegisteredPostgresService.MigrateDirectlyToCnpgAsync(
|
||
TenantId, rpi.Id, db.Id, migrateFormCnpgClusterId, migrateFormNewDbName);
|
||
|
||
migrateSuccess = $"Database '{migrateFormNewDbName}' created on CNPG cluster.";
|
||
showMigrateFormForDbId = null;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
migrateError = ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
migratingRegPgDbId = null;
|
||
}
|
||
}
|
||
|
||
private async Task RestoreRegPgDump(RegisteredPostgresDump dump)
|
||
{
|
||
restoringDumpId = dump.Id;
|
||
dumpRestoreError = null;
|
||
dumpRestoreSuccess = null;
|
||
dumpRestoreErrorDumpId = dump.Id;
|
||
|
||
try
|
||
{
|
||
await RegisteredPostgresService.RestoreDumpToCnpgAsync(
|
||
dump.Id, restoreFormCnpgClusterId, restoreFormNewDbName);
|
||
|
||
dumpRestoreSuccess = $"Database '{restoreFormNewDbName}' restored successfully.";
|
||
showRestoreFormForDumpId = null;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
dumpRestoreError = ex.Message;
|
||
}
|
||
finally
|
||
{
|
||
restoringDumpId = null;
|
||
}
|
||
}
|
||
|
||
private async Task DeleteRegPgDump(RegisteredPostgresDump dump, Guid instanceId)
|
||
{
|
||
deletingDumpId = dump.Id;
|
||
|
||
try
|
||
{
|
||
await RegisteredPostgresService.DeleteDumpAsync(dump.Id);
|
||
await LoadDumpsForInstance(instanceId);
|
||
}
|
||
catch { }
|
||
finally
|
||
{
|
||
deletingDumpId = null;
|
||
}
|
||
}
|
||
|
||
private async Task LoadDumpsForInstance(Guid instanceId)
|
||
{
|
||
loadingDumps = true;
|
||
StateHasChanged();
|
||
|
||
try
|
||
{
|
||
RegisteredPostgresInstance? inst = registeredPgInstances?.FirstOrDefault(i => i.Id == instanceId);
|
||
|
||
if (inst is null) return;
|
||
|
||
Dictionary<Guid, List<RegisteredPostgresDump>> byDb = [];
|
||
|
||
foreach (RegisteredPostgresDatabase db in inst.Databases)
|
||
{
|
||
byDb[db.Id] = await RegisteredPostgresService.GetDumpsAsync(db.Id);
|
||
}
|
||
|
||
dumpsByDatabase = byDb;
|
||
}
|
||
finally
|
||
{
|
||
loadingDumps = false;
|
||
}
|
||
}
|
||
|
||
private static RenderFragment GetManagedStatusBadge(CnpgClusterStatus status) => status switch
|
||
{
|
||
CnpgClusterStatus.Running => __builder =>
|
||
{
|
||
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Running</span>
|
||
},
|
||
CnpgClusterStatus.Creating => __builder =>
|
||
{
|
||
<span class="badge bg-warning text-dark"><i class="bi bi-hourglass me-1"></i>Creating</span>
|
||
},
|
||
CnpgClusterStatus.Upgrading => __builder =>
|
||
{
|
||
<span class="badge bg-info"><i class="bi bi-arrow-up-circle me-1"></i>Upgrading</span>
|
||
},
|
||
CnpgClusterStatus.Restoring => __builder =>
|
||
{
|
||
<span class="badge bg-info"><i class="bi bi-clock-history me-1"></i>Restoring</span>
|
||
},
|
||
CnpgClusterStatus.Failed => __builder =>
|
||
{
|
||
<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>Failed</span>
|
||
},
|
||
CnpgClusterStatus.Deleting => __builder =>
|
||
{
|
||
<span class="badge bg-secondary"><i class="bi bi-trash me-1"></i>Deleting</span>
|
||
},
|
||
_ => __builder =>
|
||
{
|
||
<span class="badge bg-secondary">@status</span>
|
||
}
|
||
};
|
||
}
|