1510 lines
87 KiB
Plaintext
1510 lines
87 KiB
Plaintext
@using EntKube.Web.Data
|
|
@using EntKube.Web.Services
|
|
@inject RabbitMQService RabbitMQService
|
|
@inject StorageService StorageService
|
|
@inject TenantService TenantService
|
|
|
|
@* ===========================================================================
|
|
Messaging Tab — RabbitMQ cluster management and topology.
|
|
Full lifecycle for clusters plus topology management via the K8s Topology
|
|
Operator (vhosts, queues, exchanges, routing bindings, users, permissions).
|
|
App linkage syncs AMQP connection details into app namespace K8s Secrets.
|
|
=========================================================================== *@
|
|
|
|
@if (loading)
|
|
{
|
|
<div class="text-center py-5">
|
|
<div class="spinner-border text-primary" role="status"></div>
|
|
<p class="text-muted mt-2">Loading RabbitMQ clusters...</p>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
@* ── Operator availability badges ── *@
|
|
<div class="d-flex gap-2 mb-4 flex-wrap">
|
|
@if (clusterOperatorAvailable)
|
|
{
|
|
<span class="badge bg-success-subtle text-success border border-success-subtle px-3 py-2">
|
|
<i class="bi bi-collection me-1"></i>RabbitMQ Cluster Operator
|
|
@if (operatorStatus?.ClusterOperatorClusterName is not null)
|
|
{
|
|
<small class="ms-1 opacity-75">on @operatorStatus.ClusterOperatorClusterName</small>
|
|
}
|
|
</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="badge bg-secondary-subtle text-secondary border px-3 py-2">
|
|
<i class="bi bi-collection me-1"></i>RabbitMQ Cluster Operator — not installed
|
|
</span>
|
|
}
|
|
|
|
@if (topologyOperatorAvailable)
|
|
{
|
|
<span class="badge bg-success-subtle text-success border border-success-subtle px-3 py-2">
|
|
<i class="bi bi-diagram-3 me-1"></i>Topology Operator
|
|
@if (operatorStatus?.TopologyOperatorClusterName is not null)
|
|
{
|
|
<small class="ms-1 opacity-75">on @operatorStatus.TopologyOperatorClusterName</small>
|
|
}
|
|
</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="badge bg-secondary-subtle text-secondary border px-3 py-2">
|
|
<i class="bi bi-diagram-3 me-1"></i>Topology Operator — not installed
|
|
</span>
|
|
}
|
|
</div>
|
|
|
|
@* ── Cluster list ── *@
|
|
<div class="mb-4">
|
|
<h5 class="d-flex align-items-center gap-2 mb-3">
|
|
<i class="bi bi-collection text-primary"></i>RabbitMQ Clusters
|
|
<span class="badge bg-secondary rounded-pill">@clusters.Count</span>
|
|
<button class="btn btn-sm btn-secondary ms-auto" @onclick="ShowCreateForm" disabled="@showCreateForm">
|
|
<i class="bi bi-plus-lg me-1"></i>Create Cluster
|
|
</button>
|
|
</h5>
|
|
|
|
@if (showCreateForm)
|
|
{
|
|
<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-plus-circle me-2"></i>New RabbitMQ 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="rabbitmq-prod" />
|
|
<div class="form-text small">DNS-safe, max 63 chars.</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label small">Kubernetes Cluster</label>
|
|
<select class="form-select form-select-sm" @bind="createK8sClusterId">
|
|
<option value="@Guid.Empty">Select cluster...</option>
|
|
@foreach (KubernetesCluster k in allK8sClusters)
|
|
{
|
|
<option value="@k.Id">@k.Name (@k.Environment.Name)</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label small">Namespace</label>
|
|
<input class="form-control form-control-sm" @bind="createNamespace" placeholder="rabbitmq" />
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label small">RabbitMQ Version</label>
|
|
<input class="form-control form-control-sm" @bind="createVersion" placeholder="3.13" />
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label small">Replicas</label>
|
|
<input type="number" class="form-control form-control-sm" @bind="createReplicas" min="1" max="9" />
|
|
</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-4">
|
|
<label class="form-label small">Storage Class <span class="text-muted">(optional)</span></label>
|
|
<input class="form-control form-control-sm" @bind="createStorageClass" placeholder="default" />
|
|
</div>
|
|
<div class="col-md-8">
|
|
<label class="form-label small">S3 Storage Link <span class="text-muted">(optional, for backups)</span></label>
|
|
<select class="form-select form-select-sm" @bind="createStorageLinkId">
|
|
<option value="@Guid.Empty">No backup storage</option>
|
|
@foreach (StorageLink sl in storageLinks)
|
|
{
|
|
<option value="@sl.Id">@sl.Name (@sl.BucketName)</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
@if (!string.IsNullOrEmpty(createError))
|
|
{
|
|
<div class="alert alert-danger mt-3 mb-0 py-2 small">@createError</div>
|
|
}
|
|
<div class="mt-3 d-flex 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> }
|
|
Create
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelCreate">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@if (clusters.Count == 0)
|
|
{
|
|
<div class="text-muted small py-3 ps-1">No RabbitMQ clusters provisioned for this tenant.</div>
|
|
}
|
|
else
|
|
{
|
|
@foreach (RabbitMQCluster cluster in clusters)
|
|
{
|
|
bool expanded = expandedClusterId == cluster.Id;
|
|
|
|
<div class="card shadow-sm mb-3 @(expanded ? "border-primary" : "")">
|
|
<div class="card-header d-flex align-items-center gap-2 @(expanded ? "bg-primary bg-opacity-10" : "bg-light")"
|
|
style="cursor:pointer" @onclick="() => ToggleCluster(cluster.Id)">
|
|
<i class="bi bi-collection @(expanded ? "text-primary" : "text-secondary")"></i>
|
|
<span class="fw-semibold">@cluster.Name</span>
|
|
<small class="text-muted">@cluster.Namespace</small>
|
|
<span class="badge @StatusBadgeClass(cluster.Status) ms-1">@cluster.Status</span>
|
|
<small class="text-muted ms-2">
|
|
<i class="bi bi-server me-1"></i>@cluster.KubernetesCluster.Name
|
|
<span class="ms-2"><i class="bi bi-layers me-1"></i>@cluster.KubernetesCluster.Environment.Name</span>
|
|
</small>
|
|
<small class="text-muted ms-2">v@cluster.RabbitMQVersion • @cluster.Replicas nodes • @cluster.StorageSize</small>
|
|
@if (cluster.StorageLinkId.HasValue)
|
|
{
|
|
<span class="badge bg-info-subtle text-info border border-info-subtle ms-1">
|
|
<i class="bi bi-cloud me-1"></i>Backups
|
|
</span>
|
|
}
|
|
<i class="bi @(expanded ? "bi-chevron-up" : "bi-chevron-down") ms-auto text-muted"></i>
|
|
</div>
|
|
|
|
@if (!string.IsNullOrEmpty(cluster.LastError) && cluster.Status == RabbitMQClusterStatus.Failed)
|
|
{
|
|
<div class="px-3 py-2 bg-danger-subtle text-danger small border-bottom">
|
|
<i class="bi bi-exclamation-triangle me-1"></i>@cluster.LastError
|
|
</div>
|
|
}
|
|
|
|
@if (expanded)
|
|
{
|
|
<div class="card-body pt-3">
|
|
<ul class="nav nav-pills mb-3 gap-1 flex-wrap">
|
|
@foreach (string tab in new[] { "vhosts", "queues", "exchanges", "routing", "users", "app-links", "backup", "credentials", "metrics" })
|
|
{
|
|
<li class="nav-item">
|
|
<button class="nav-link @(clusterTab == tab ? "active" : "")"
|
|
@onclick="() => SetClusterTab(tab, cluster.Id)">
|
|
@TabLabel(tab)
|
|
</button>
|
|
</li>
|
|
}
|
|
</ul>
|
|
|
|
@if (!string.IsNullOrEmpty(topologyError))
|
|
{
|
|
<div class="alert alert-danger py-2 small mb-3">
|
|
<i class="bi bi-exclamation-triangle me-1"></i>@topologyError
|
|
<button type="button" class="btn-close float-end" @onclick="() => topologyError = null"></button>
|
|
</div>
|
|
}
|
|
|
|
@* ── Vhosts ── *@
|
|
@if (clusterTab == "vhosts")
|
|
{
|
|
<div class="d-flex align-items-center mb-2">
|
|
<h6 class="mb-0 me-2"><i class="bi bi-hdd-rack me-1 text-primary"></i>Virtual Hosts</h6>
|
|
<button class="btn btn-sm btn-secondary ms-auto" @onclick="() => showVhostForm = !showVhostForm">
|
|
<i class="bi bi-plus-lg me-1"></i>Add Vhost
|
|
</button>
|
|
</div>
|
|
|
|
@if (showVhostForm)
|
|
{
|
|
<div class="card border-secondary mb-3">
|
|
<div class="card-body py-2">
|
|
<div class="row g-2 align-items-end">
|
|
<div class="col">
|
|
<label class="form-label small">Vhost Path</label>
|
|
<input class="form-control form-control-sm" @bind="newVhostName" placeholder="/my-app" />
|
|
<div class="form-text small">Use "/" for the default vhost or "/path" for a named vhost.</div>
|
|
</div>
|
|
<div class="col-auto">
|
|
<button class="btn btn-sm btn-primary" @onclick="() => CreateVhost(cluster)" disabled="@submitting">
|
|
@if (submitting) { <span class="spinner-border spinner-border-sm me-1"></span> }Create
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-secondary ms-1" @onclick="() => showVhostForm = false">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@if (loadingTopology)
|
|
{
|
|
<div class="text-center py-3"><span class="spinner-border spinner-border-sm text-primary"></span></div>
|
|
}
|
|
else if (vhosts.Count == 0)
|
|
{
|
|
<p class="text-muted small">No vhosts managed via topology operator yet.</p>
|
|
}
|
|
else
|
|
{
|
|
<table class="table table-sm table-hover small">
|
|
<thead><tr><th>Vhost</th><th>K8s Name</th><th></th></tr></thead>
|
|
<tbody>
|
|
@foreach (RabbitMQVhostInfo v in vhosts)
|
|
{
|
|
<tr>
|
|
<td class="align-middle fw-semibold">@v.VhostName</td>
|
|
<td class="align-middle text-muted font-monospace">@v.K8sName</td>
|
|
<td class="align-middle text-end">
|
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteVhost(cluster, v.K8sName)">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
}
|
|
}
|
|
|
|
@* ── Queues ── *@
|
|
@if (clusterTab == "queues")
|
|
{
|
|
<div class="d-flex align-items-center mb-2">
|
|
<h6 class="mb-0"><i class="bi bi-stack me-1 text-primary"></i>Queues</h6>
|
|
<button class="btn btn-sm btn-secondary ms-auto" @onclick="() => showQueueForm = !showQueueForm">
|
|
<i class="bi bi-plus-lg me-1"></i>Add Queue
|
|
</button>
|
|
</div>
|
|
|
|
@if (showQueueForm)
|
|
{
|
|
<div class="card border-secondary mb-3">
|
|
<div class="card-body py-2">
|
|
<div class="row g-2">
|
|
<div class="col-md-5">
|
|
<label class="form-label small">Queue Name</label>
|
|
<input class="form-control form-control-sm" @bind="newQueueName" placeholder="orders.created" />
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label small">Vhost</label>
|
|
<input class="form-control form-control-sm" @bind="newQueueVhost" placeholder="/" />
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label small">Type</label>
|
|
<select class="form-select form-select-sm" @bind="newQueueType">
|
|
<option value="quorum">Quorum</option>
|
|
<option value="classic">Classic</option>
|
|
<option value="stream">Stream</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-2 d-flex align-items-end gap-2">
|
|
<div class="form-check form-check-inline">
|
|
<input class="form-check-input" type="checkbox" @bind="newQueueDurable" />
|
|
<label class="form-check-label small">Durable</label>
|
|
</div>
|
|
</div>
|
|
<div class="col-12 d-flex gap-2">
|
|
<button class="btn btn-sm btn-primary" @onclick="() => CreateQueue(cluster)" disabled="@submitting">
|
|
@if (submitting) { <span class="spinner-border spinner-border-sm me-1"></span> }Create
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showQueueForm = false">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@if (loadingTopology) { <div class="text-center py-3"><span class="spinner-border spinner-border-sm text-primary"></span></div> }
|
|
else if (queues.Count == 0) { <p class="text-muted small">No queues yet.</p> }
|
|
else
|
|
{
|
|
<table class="table table-sm table-hover small">
|
|
<thead><tr><th>Name</th><th>Vhost</th><th>Type</th><th>Durable</th><th></th></tr></thead>
|
|
<tbody>
|
|
@foreach (RabbitMQQueueInfo q in queues)
|
|
{
|
|
<tr>
|
|
<td class="align-middle fw-semibold">@q.QueueName</td>
|
|
<td class="align-middle text-muted">@q.Vhost</td>
|
|
<td class="align-middle"><span class="badge bg-secondary-subtle text-secondary border">@q.QueueType</span></td>
|
|
<td class="align-middle">@(q.Durable ? "✓" : "—")</td>
|
|
<td class="align-middle text-end">
|
|
<button class="btn btn-sm btn-outline-info me-1"
|
|
@onclick="() => PrepareAppLink(cluster, q.QueueName, null, q.Vhost)">
|
|
<i class="bi bi-link-45deg me-1"></i>Link to App
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteQueue(cluster, q.K8sName)">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
}
|
|
}
|
|
|
|
@* ── Exchanges ── *@
|
|
@if (clusterTab == "exchanges")
|
|
{
|
|
<div class="d-flex align-items-center mb-2">
|
|
<h6 class="mb-0"><i class="bi bi-arrow-left-right me-1 text-primary"></i>Exchanges</h6>
|
|
<button class="btn btn-sm btn-secondary ms-auto" @onclick="() => showExchangeForm = !showExchangeForm">
|
|
<i class="bi bi-plus-lg me-1"></i>Add Exchange
|
|
</button>
|
|
</div>
|
|
|
|
@if (showExchangeForm)
|
|
{
|
|
<div class="card border-secondary mb-3">
|
|
<div class="card-body py-2">
|
|
<div class="row g-2">
|
|
<div class="col-md-5">
|
|
<label class="form-label small">Exchange Name</label>
|
|
<input class="form-control form-control-sm" @bind="newExchangeName" placeholder="orders" />
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label small">Vhost</label>
|
|
<input class="form-control form-control-sm" @bind="newExchangeVhost" placeholder="/" />
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label small">Type</label>
|
|
<select class="form-select form-select-sm" @bind="newExchangeType">
|
|
<option value="direct">Direct</option>
|
|
<option value="fanout">Fanout</option>
|
|
<option value="topic">Topic</option>
|
|
<option value="headers">Headers</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-2 d-flex align-items-end">
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" @bind="newExchangeDurable" />
|
|
<label class="form-check-label small">Durable</label>
|
|
</div>
|
|
</div>
|
|
<div class="col-12 d-flex gap-2">
|
|
<button class="btn btn-sm btn-primary" @onclick="() => CreateExchange(cluster)" disabled="@submitting">
|
|
@if (submitting) { <span class="spinner-border spinner-border-sm me-1"></span> }Create
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showExchangeForm = false">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@if (loadingTopology) { <div class="text-center py-3"><span class="spinner-border spinner-border-sm text-primary"></span></div> }
|
|
else if (exchanges.Count == 0) { <p class="text-muted small">No exchanges yet.</p> }
|
|
else
|
|
{
|
|
<table class="table table-sm table-hover small">
|
|
<thead><tr><th>Name</th><th>Vhost</th><th>Type</th><th>Durable</th><th></th></tr></thead>
|
|
<tbody>
|
|
@foreach (RabbitMQExchangeInfo ex in exchanges)
|
|
{
|
|
<tr>
|
|
<td class="align-middle fw-semibold">@ex.ExchangeName</td>
|
|
<td class="align-middle text-muted">@ex.Vhost</td>
|
|
<td class="align-middle"><span class="badge bg-secondary-subtle text-secondary border">@ex.ExchangeType</span></td>
|
|
<td class="align-middle">@(ex.Durable ? "✓" : "—")</td>
|
|
<td class="align-middle text-end">
|
|
<button class="btn btn-sm btn-outline-info me-1"
|
|
@onclick="() => PrepareAppLink(cluster, null, ex.ExchangeName, ex.Vhost)">
|
|
<i class="bi bi-link-45deg me-1"></i>Link to App
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteExchange(cluster, ex.K8sName)">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
}
|
|
}
|
|
|
|
@* ── Routing Bindings ── *@
|
|
@if (clusterTab == "routing")
|
|
{
|
|
<div class="d-flex align-items-center mb-2">
|
|
<h6 class="mb-0"><i class="bi bi-signpost-split me-1 text-primary"></i>Routing Bindings</h6>
|
|
<button class="btn btn-sm btn-secondary ms-auto" @onclick="() => showRoutingForm = !showRoutingForm">
|
|
<i class="bi bi-plus-lg me-1"></i>Add Binding
|
|
</button>
|
|
</div>
|
|
|
|
@if (showRoutingForm)
|
|
{
|
|
<div class="card border-secondary mb-3">
|
|
<div class="card-body py-2">
|
|
<div class="row g-2">
|
|
<div class="col-md-3">
|
|
<label class="form-label small">Vhost</label>
|
|
<input class="form-control form-control-sm" @bind="newBindingVhost" placeholder="/" />
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label small">Source Exchange</label>
|
|
<input class="form-control form-control-sm" @bind="newBindingSource" placeholder="orders" />
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label small">Destination</label>
|
|
<input class="form-control form-control-sm" @bind="newBindingDest" placeholder="orders.created" />
|
|
</div>
|
|
<div class="col-md-1">
|
|
<label class="form-label small">Type</label>
|
|
<select class="form-select form-select-sm" @bind="newBindingDestType">
|
|
<option value="queue">Queue</option>
|
|
<option value="exchange">Exchange</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label small">Routing Key</label>
|
|
<input class="form-control form-control-sm" @bind="newBindingRoutingKey" placeholder="created" />
|
|
</div>
|
|
<div class="col-12 d-flex gap-2">
|
|
<button class="btn btn-sm btn-primary" @onclick="() => CreateRoutingBinding(cluster)" disabled="@submitting">
|
|
@if (submitting) { <span class="spinner-border spinner-border-sm me-1"></span> }Create
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showRoutingForm = false">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@if (loadingTopology) { <div class="text-center py-3"><span class="spinner-border spinner-border-sm text-primary"></span></div> }
|
|
else if (routingBindings.Count == 0) { <p class="text-muted small">No routing bindings yet.</p> }
|
|
else
|
|
{
|
|
<table class="table table-sm table-hover small">
|
|
<thead><tr><th>Source</th><th>→</th><th>Destination</th><th>Type</th><th>Routing Key</th><th>Vhost</th><th></th></tr></thead>
|
|
<tbody>
|
|
@foreach (RabbitMQRoutingBindingInfo rb in routingBindings)
|
|
{
|
|
<tr>
|
|
<td class="align-middle">@rb.Source</td>
|
|
<td class="align-middle text-muted">→</td>
|
|
<td class="align-middle fw-semibold">@rb.Destination</td>
|
|
<td class="align-middle"><span class="badge bg-secondary-subtle text-secondary border">@rb.DestinationType</span></td>
|
|
<td class="align-middle font-monospace text-muted">@rb.RoutingKey</td>
|
|
<td class="align-middle text-muted">@rb.Vhost</td>
|
|
<td class="align-middle text-end">
|
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteRoutingBinding(cluster, rb.K8sName)">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
}
|
|
}
|
|
|
|
@* ── Users ── *@
|
|
@if (clusterTab == "users")
|
|
{
|
|
<div class="d-flex align-items-center mb-2">
|
|
<h6 class="mb-0"><i class="bi bi-person-badge me-1 text-primary"></i>Users</h6>
|
|
<button class="btn btn-sm btn-secondary ms-auto" @onclick="() => showUserForm = !showUserForm">
|
|
<i class="bi bi-plus-lg me-1"></i>Add User
|
|
</button>
|
|
</div>
|
|
|
|
@if (showUserForm)
|
|
{
|
|
<div class="card border-secondary mb-3">
|
|
<div class="card-body py-2">
|
|
<div class="row g-2">
|
|
<div class="col-md-4">
|
|
<label class="form-label small">Username</label>
|
|
<input class="form-control form-control-sm" @bind="newUsername" placeholder="app-user" />
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label small">Password</label>
|
|
<input type="password" class="form-control form-control-sm" @bind="newUserPassword" />
|
|
</div>
|
|
<div class="col-md-4">
|
|
<label class="form-label small">Tags (comma-separated)</label>
|
|
<input class="form-control form-control-sm" @bind="newUserTags" placeholder="monitoring,management" />
|
|
<div class="form-text small">management, monitoring, administrator, policymaker</div>
|
|
</div>
|
|
<div class="col-12 d-flex gap-2">
|
|
<button class="btn btn-sm btn-primary" @onclick="() => CreateUser(cluster)" disabled="@submitting">
|
|
@if (submitting) { <span class="spinner-border spinner-border-sm me-1"></span> }Create
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showUserForm = false">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@if (loadingTopology) { <div class="text-center py-3"><span class="spinner-border spinner-border-sm text-primary"></span></div> }
|
|
else if (users.Count == 0) { <p class="text-muted small">No topology-managed users yet.</p> }
|
|
else
|
|
{
|
|
<table class="table table-sm table-hover small">
|
|
<thead><tr><th>Username</th><th>Tags</th><th>K8s Name</th><th></th></tr></thead>
|
|
<tbody>
|
|
@foreach (RabbitMQUserInfo u in users)
|
|
{
|
|
<tr>
|
|
<td class="align-middle fw-semibold">@u.Username</td>
|
|
<td class="align-middle">
|
|
@foreach (string tag in u.Tags)
|
|
{
|
|
<span class="badge bg-secondary-subtle text-secondary border me-1">@tag</span>
|
|
}
|
|
</td>
|
|
<td class="align-middle text-muted font-monospace">@u.K8sName</td>
|
|
<td class="align-middle text-end">
|
|
<button class="btn btn-sm btn-outline-warning me-1"
|
|
@onclick="() => PreparePermission(u.Username)">
|
|
<i class="bi bi-shield-check me-1"></i>Permissions
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteUser(cluster, u.K8sName)">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
}
|
|
|
|
@if (showPermissionForm && permissionTargetUser is not null)
|
|
{
|
|
<div class="card border-warning mt-3">
|
|
<div class="card-header bg-warning bg-opacity-10 small fw-semibold">
|
|
<i class="bi bi-shield-check me-1"></i>Set Permissions — @permissionTargetUser
|
|
</div>
|
|
<div class="card-body py-2">
|
|
<div class="row g-2">
|
|
<div class="col-md-3">
|
|
<label class="form-label small">Vhost</label>
|
|
<input class="form-control form-control-sm" @bind="permVhost" placeholder="/" />
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label small">Configure (regex)</label>
|
|
<input class="form-control form-control-sm" @bind="permConfigure" placeholder=".*" />
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label small">Write (regex)</label>
|
|
<input class="form-control form-control-sm" @bind="permWrite" placeholder=".*" />
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label small">Read (regex)</label>
|
|
<input class="form-control form-control-sm" @bind="permRead" placeholder=".*" />
|
|
</div>
|
|
<div class="col-12 d-flex gap-2">
|
|
<button class="btn btn-sm btn-warning" @onclick="() => SetPermission(cluster)" disabled="@submitting">
|
|
@if (submitting) { <span class="spinner-border spinner-border-sm me-1"></span> }Apply
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => { showPermissionForm = false; }">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|
|
|
|
@* ── App Links ── *@
|
|
@if (clusterTab == "app-links")
|
|
{
|
|
<div class="d-flex align-items-center mb-2">
|
|
<h6 class="mb-0"><i class="bi bi-link-45deg me-1 text-primary"></i>App Links</h6>
|
|
<button class="btn btn-sm btn-secondary ms-auto" @onclick="() => showAppLinkForm = !showAppLinkForm">
|
|
<i class="bi bi-plus-lg me-1"></i>Link App
|
|
</button>
|
|
</div>
|
|
|
|
<p class="text-muted small mb-3">
|
|
Each link provisions a dedicated RabbitMQ user for the app and syncs the connection details
|
|
(host, port, vhost, credentials, queue/exchange) into a Kubernetes Secret in the app's namespace.
|
|
</p>
|
|
|
|
@if (showAppLinkForm)
|
|
{
|
|
<div class="card border-secondary mb-3">
|
|
<div class="card-body py-2">
|
|
<div class="alert alert-info py-2 small mb-3">
|
|
<i class="bi bi-person-plus me-1"></i>
|
|
A dedicated RabbitMQ user with full access to the chosen vhost will be automatically
|
|
created and its credentials stored in the vault, then synced into the K8s secret below.
|
|
</div>
|
|
<div class="row g-2">
|
|
<div class="col-md-4">
|
|
<label class="form-label small">App Deployment</label>
|
|
<select class="form-select form-select-sm" @bind="linkAppDeploymentId">
|
|
<option value="@Guid.Empty">Select deployment...</option>
|
|
@foreach (AppDeployment dep in allDeployments)
|
|
{
|
|
<option value="@dep.Id">@dep.App.Customer.Name / @dep.App.Name / @dep.Name (@dep.Cluster.Name)</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label small">Vhost</label>
|
|
<input class="form-control form-control-sm" @bind="linkVhost" placeholder="/" />
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label small">Queue <span class="text-muted">(optional)</span></label>
|
|
<input class="form-control form-control-sm" @bind="linkQueue" placeholder="orders.created" />
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label small">Exchange <span class="text-muted">(optional)</span></label>
|
|
<input class="form-control form-control-sm" @bind="linkExchange" placeholder="orders" />
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label small">K8s Secret Name</label>
|
|
<input class="form-control form-control-sm" @bind="linkSecretName" placeholder="rabbitmq" />
|
|
</div>
|
|
<div class="col-12 d-flex gap-2">
|
|
<button class="btn btn-sm btn-primary" @onclick="() => CreateAppLink(cluster)" disabled="@submitting">
|
|
@if (submitting) { <span class="spinner-border spinner-border-sm me-1"></span> }Link & Sync
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showAppLinkForm = false">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@if (loadingTopology) { <div class="text-center py-3"><span class="spinner-border spinner-border-sm text-primary"></span></div> }
|
|
else if (messagingBindings.Count == 0) { <p class="text-muted small">No app links yet.</p> }
|
|
else
|
|
{
|
|
<table class="table table-sm table-hover small">
|
|
<thead>
|
|
<tr><th>App / Deployment</th><th>Vhost</th><th>Queue</th><th>Exchange</th><th>Secret</th><th>Last Synced</th><th></th></tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (MessagingBinding mb in messagingBindings)
|
|
{
|
|
<tr>
|
|
<td class="align-middle">
|
|
<div class="fw-semibold">@mb.AppDeployment.App.Customer.Name / @mb.AppDeployment.App.Name</div>
|
|
<small class="text-muted">@mb.AppDeployment.Name (@mb.AppDeployment.Cluster.Name)</small>
|
|
</td>
|
|
<td class="align-middle">@mb.Vhost</td>
|
|
<td class="align-middle text-muted">@(mb.QueueName ?? "—")</td>
|
|
<td class="align-middle text-muted">@(mb.ExchangeName ?? "—")</td>
|
|
<td class="align-middle font-monospace">@mb.KubernetesSecretName</td>
|
|
<td class="align-middle text-muted">
|
|
@if (mb.LastSyncedAt.HasValue)
|
|
{
|
|
@mb.LastSyncedAt.Value.ToLocalTime().ToString("MM-dd HH:mm")
|
|
}
|
|
else
|
|
{
|
|
<span class="text-warning">Not synced</span>
|
|
}
|
|
</td>
|
|
<td class="align-middle text-end">
|
|
<button class="btn btn-sm btn-outline-primary me-1"
|
|
@onclick="() => SyncAppLink(mb)" disabled="@(syncingLinkId == mb.Id)">
|
|
@if (syncingLinkId == mb.Id)
|
|
{
|
|
<span class="spinner-border spinner-border-sm"></span>
|
|
}
|
|
else
|
|
{
|
|
<i class="bi bi-arrow-clockwise"></i>
|
|
}
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAppLink(cluster, mb)">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
}
|
|
}
|
|
|
|
@* ── Backup & Restore ── *@
|
|
@if (clusterTab == "backup")
|
|
{
|
|
@if (!cluster.StorageLinkId.HasValue)
|
|
{
|
|
<div class="alert alert-secondary py-2 small">
|
|
<i class="bi bi-info-circle me-1"></i>
|
|
No S3 storage link is attached to this cluster. Edit the cluster to add one before creating backups.
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="d-flex align-items-center gap-2 mb-3">
|
|
<span class="small text-muted">
|
|
Definitions exported via <code>rabbitmqctl export_definitions</code> and stored as JSON in S3.
|
|
</span>
|
|
<button class="btn btn-sm btn-primary ms-auto" @onclick="() => TriggerBackup(cluster)"
|
|
disabled="@(backingUp == cluster.Id)">
|
|
@if (backingUp == cluster.Id) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
|
<i class="bi bi-cloud-arrow-up me-1"></i>Export Now
|
|
</button>
|
|
</div>
|
|
}
|
|
|
|
@if (!string.IsNullOrEmpty(backupError))
|
|
{
|
|
<div class="alert alert-danger py-2 small mb-3">@backupError</div>
|
|
}
|
|
|
|
@if (showRestoreForm && restoreBackupId.HasValue)
|
|
{
|
|
<div class="card border-warning mb-3">
|
|
<div class="card-header bg-warning bg-opacity-10 small fw-semibold">
|
|
<i class="bi bi-cloud-arrow-down me-1"></i>Restore Definitions
|
|
</div>
|
|
<div class="card-body py-2">
|
|
<div class="mb-2">
|
|
<label class="form-label small">Target Cluster</label>
|
|
<select class="form-select form-select-sm" @bind="restoreTargetClusterId">
|
|
<option value="@cluster.Id">This cluster — @cluster.Name</option>
|
|
@foreach (RabbitMQCluster other in clusters.Where(c => c.Id != cluster.Id))
|
|
{
|
|
<option value="@other.Id">@other.Name (@other.KubernetesCluster.Name)</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="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="() => { showRestoreForm = false; restoreBackupId = null; }">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@if (loadingBackups) { <div class="text-center py-3"><span class="spinner-border spinner-border-sm text-primary"></span></div> }
|
|
else if (clusterBackups.Count == 0) { <p class="text-muted small">No backups yet.</p> }
|
|
else
|
|
{
|
|
<table class="table table-sm table-hover small">
|
|
<thead><tr><th>Created</th><th>Size</th><th>Status</th><th></th></tr></thead>
|
|
<tbody>
|
|
@foreach (RabbitMQBackup backup in clusterBackups)
|
|
{
|
|
<tr>
|
|
<td class="align-middle">@backup.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm")</td>
|
|
<td class="align-middle text-muted">@FormatBytes(backup.SizeBytes)</td>
|
|
<td class="align-middle">
|
|
<span class="badge @BackupBadgeClass(backup.Status)">@backup.Status</span>
|
|
</td>
|
|
<td class="align-middle text-end">
|
|
@if (backup.Status == RabbitMQBackupStatus.Ready)
|
|
{
|
|
<button class="btn btn-sm btn-outline-warning me-1" @onclick="() => PrepareRestore(backup)">
|
|
<i class="bi bi-cloud-arrow-down me-1"></i>Restore
|
|
</button>
|
|
}
|
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteBackup(backup)">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
}
|
|
}
|
|
|
|
@* ── Credentials ── *@
|
|
@if (clusterTab == "credentials")
|
|
{
|
|
<p class="small text-muted mb-2">
|
|
Admin credentials generated by the Cluster Operator, stored encrypted in the tenant vault.
|
|
Source: K8s Secret <code>@(cluster.Name)-default-user</code> in <code>@cluster.Namespace</code>.
|
|
</p>
|
|
|
|
@if (loadingCredentials) { <div class="text-center py-3"><span class="spinner-border spinner-border-sm text-primary"></span></div> }
|
|
else if (adminUsername is not null)
|
|
{
|
|
<div class="row g-2 mb-2">
|
|
<div class="col-md-6">
|
|
<label class="form-label small text-muted">Username</label>
|
|
<input type="text" class="form-control form-control-sm font-monospace" value="@adminUsername" readonly />
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label small text-muted">Password</label>
|
|
<div class="input-group input-group-sm">
|
|
<input type="@(showPassword ? "text" : "password")"
|
|
class="form-control form-control-sm font-monospace" value="@adminPassword" readonly />
|
|
<button class="btn btn-outline-secondary" @onclick="() => showPassword = !showPassword">
|
|
<i class="bi @(showPassword ? "bi-eye-slash" : "bi-eye")"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<span class="badge bg-success-subtle text-success border border-success-subtle">
|
|
<i class="bi bi-shield-lock me-1"></i>Stored in vault
|
|
</span>
|
|
}
|
|
else
|
|
{
|
|
<div class="alert alert-secondary py-2 small">
|
|
<i class="bi bi-info-circle me-1"></i>
|
|
Credentials not yet in vault. Click <strong>Sync from K8s</strong> once the cluster is running.
|
|
</div>
|
|
}
|
|
|
|
@if (!string.IsNullOrEmpty(credentialError))
|
|
{
|
|
<div class="alert alert-danger py-2 small mt-2">@credentialError</div>
|
|
}
|
|
|
|
<div class="mt-2 d-flex gap-2">
|
|
<button class="btn btn-sm btn-primary" @onclick="() => SyncCredentials(cluster)" disabled="@syncingCredentials">
|
|
@if (syncingCredentials) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
|
<i class="bi bi-cloud-download me-1"></i>Sync from K8s
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => LoadCredentials(cluster)">
|
|
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
|
|
</button>
|
|
</div>
|
|
|
|
<hr class="my-3" />
|
|
|
|
<div class="d-flex justify-content-end">
|
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => ConfirmDeleteCluster(cluster)">
|
|
<i class="bi bi-trash me-1"></i>Delete Cluster
|
|
</button>
|
|
</div>
|
|
}
|
|
|
|
@if (clusterTab == "metrics")
|
|
{
|
|
<RabbitMQMonitoringPanel ClusterId="cluster.KubernetesClusterId" />
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
}
|
|
</div>
|
|
|
|
@* ── Delete confirmation modal ── *@
|
|
@if (deleteTarget is not null)
|
|
{
|
|
<div class="modal fade show d-block" tabindex="-1" style="background:rgba(0,0,0,0.5)">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title text-danger"><i class="bi bi-trash me-2"></i>Delete RabbitMQ Cluster</h5>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p>Delete <strong>@deleteTarget.Name</strong> from Kubernetes?</p>
|
|
<p class="text-danger small">
|
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
|
This deletes the RabbitmqCluster CRD and associated PVCs. Messages in non-durable queues are lost.
|
|
S3 backups are not affected.
|
|
</p>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-outline-secondary" @onclick="() => deleteTarget = null">Cancel</button>
|
|
<button class="btn btn-danger" @onclick="DeleteCluster" disabled="@deleting">
|
|
@if (deleting) { <span class="spinner-border spinner-border-sm me-1"></span> }Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|
|
|
|
@code {
|
|
[Parameter] public Guid TenantId { get; set; }
|
|
|
|
// ── State ──────────────────────────────────────────────────────────────────
|
|
|
|
private bool loading = true;
|
|
private List<RabbitMQCluster> clusters = [];
|
|
private List<KubernetesCluster> allK8sClusters = [];
|
|
private List<StorageLink> storageLinks = [];
|
|
private List<AppDeployment> allDeployments = [];
|
|
private bool clusterOperatorAvailable;
|
|
private bool topologyOperatorAvailable;
|
|
private RabbitMQOperatorStatus? operatorStatus;
|
|
|
|
// Create cluster form
|
|
private bool showCreateForm;
|
|
private bool creating;
|
|
private string createName = "";
|
|
private string createNamespace = "rabbitmq";
|
|
private string createVersion = "3.13";
|
|
private int createReplicas = 3;
|
|
private string createStorageSize = "10Gi";
|
|
private string createStorageClass = "";
|
|
private Guid createK8sClusterId = Guid.Empty;
|
|
private Guid createStorageLinkId = Guid.Empty;
|
|
private string? createError;
|
|
|
|
// Cluster detail
|
|
private Guid? expandedClusterId;
|
|
private string clusterTab = "vhosts";
|
|
private bool loadingTopology;
|
|
private string? topologyError;
|
|
private bool submitting;
|
|
|
|
// Topology state
|
|
private List<RabbitMQVhostInfo> vhosts = [];
|
|
private List<RabbitMQQueueInfo> queues = [];
|
|
private List<RabbitMQExchangeInfo> exchanges = [];
|
|
private List<RabbitMQRoutingBindingInfo> routingBindings = [];
|
|
private List<RabbitMQUserInfo> users = [];
|
|
private List<MessagingBinding> messagingBindings = [];
|
|
|
|
// Vhost form
|
|
private bool showVhostForm;
|
|
private string newVhostName = "/";
|
|
|
|
// Queue form
|
|
private bool showQueueForm;
|
|
private string newQueueName = "";
|
|
private string newQueueVhost = "/";
|
|
private string newQueueType = "quorum";
|
|
private bool newQueueDurable = true;
|
|
|
|
// Exchange form
|
|
private bool showExchangeForm;
|
|
private string newExchangeName = "";
|
|
private string newExchangeVhost = "/";
|
|
private string newExchangeType = "direct";
|
|
private bool newExchangeDurable = true;
|
|
|
|
// Routing binding form
|
|
private bool showRoutingForm;
|
|
private string newBindingVhost = "/";
|
|
private string newBindingSource = "";
|
|
private string newBindingDest = "";
|
|
private string newBindingDestType = "queue";
|
|
private string newBindingRoutingKey = "";
|
|
|
|
// User form
|
|
private bool showUserForm;
|
|
private string newUsername = "";
|
|
private string newUserPassword = "";
|
|
private string newUserTags = "";
|
|
|
|
// Permission form
|
|
private bool showPermissionForm;
|
|
private string? permissionTargetUser;
|
|
private string permVhost = "/";
|
|
private string permConfigure = ".*";
|
|
private string permWrite = ".*";
|
|
private string permRead = ".*";
|
|
|
|
// App link form
|
|
private bool showAppLinkForm;
|
|
private Guid linkAppDeploymentId = Guid.Empty;
|
|
private string linkVhost = "/";
|
|
private string linkQueue = "";
|
|
private string linkExchange = "";
|
|
private string linkSecretName = "rabbitmq";
|
|
private Guid? syncingLinkId;
|
|
|
|
// Backup
|
|
private bool loadingBackups;
|
|
private List<RabbitMQBackup> clusterBackups = [];
|
|
private Guid? backingUp;
|
|
private string? backupError;
|
|
private bool showRestoreForm;
|
|
private Guid? restoreBackupId;
|
|
private Guid restoreTargetClusterId;
|
|
private bool restoring;
|
|
|
|
// Credentials
|
|
private bool loadingCredentials;
|
|
private bool syncingCredentials;
|
|
private string? adminUsername;
|
|
private string? adminPassword;
|
|
private bool showPassword;
|
|
private string? credentialError;
|
|
|
|
// Delete cluster
|
|
private RabbitMQCluster? deleteTarget;
|
|
private bool deleting;
|
|
|
|
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
|
|
|
protected override async Task OnInitializedAsync() => await LoadAsync();
|
|
|
|
private async Task LoadAsync()
|
|
{
|
|
loading = true;
|
|
|
|
Task<List<RabbitMQCluster>> clustersTask = RabbitMQService.GetClustersAsync(TenantId);
|
|
Task<List<KubernetesCluster>> k8sTask = TenantService.GetClustersAsync(TenantId);
|
|
Task<List<StorageLink>> storageTask = StorageService.GetStorageLinksAsync(TenantId);
|
|
Task<RabbitMQOperatorStatus> operatorTask = RabbitMQService.GetOperatorStatusAsync(TenantId);
|
|
Task<List<AppDeployment>> deploymentsTask = RabbitMQService.GetTenantDeploymentsAsync(TenantId);
|
|
|
|
await Task.WhenAll(clustersTask, k8sTask, storageTask, operatorTask, deploymentsTask);
|
|
|
|
clusters = clustersTask.Result;
|
|
allK8sClusters = k8sTask.Result;
|
|
storageLinks = storageTask.Result;
|
|
allDeployments = deploymentsTask.Result;
|
|
|
|
operatorStatus = operatorTask.Result;
|
|
clusterOperatorAvailable = operatorStatus.ClusterOperatorAvailable;
|
|
topologyOperatorAvailable = operatorStatus.TopologyOperatorAvailable;
|
|
|
|
loading = false;
|
|
}
|
|
|
|
// ── Cluster create / delete ────────────────────────────────────────────────
|
|
|
|
private void ShowCreateForm() { showCreateForm = true; createError = null; }
|
|
private void CancelCreate() { showCreateForm = false; createError = null; }
|
|
|
|
private async Task CreateCluster()
|
|
{
|
|
createError = null;
|
|
if (string.IsNullOrWhiteSpace(createName)) { createError = "Name is required."; return; }
|
|
if (createK8sClusterId == Guid.Empty) { createError = "Select a Kubernetes cluster."; return; }
|
|
if (string.IsNullOrWhiteSpace(createStorageSize)) { createError = "Storage size is required."; return; }
|
|
|
|
creating = true;
|
|
try
|
|
{
|
|
await RabbitMQService.CreateClusterAsync(
|
|
TenantId, createK8sClusterId,
|
|
createName.Trim().ToLowerInvariant(),
|
|
string.IsNullOrWhiteSpace(createNamespace) ? "rabbitmq" : createNamespace.Trim(),
|
|
string.IsNullOrWhiteSpace(createVersion) ? "3.13" : createVersion.Trim(),
|
|
createReplicas, createStorageSize.Trim(),
|
|
string.IsNullOrWhiteSpace(createStorageClass) ? null : createStorageClass.Trim(),
|
|
createStorageLinkId == Guid.Empty ? null : createStorageLinkId);
|
|
showCreateForm = false;
|
|
createName = "";
|
|
await LoadAsync();
|
|
}
|
|
catch (Exception ex) { createError = ex.Message; }
|
|
finally { creating = false; }
|
|
}
|
|
|
|
private void ConfirmDeleteCluster(RabbitMQCluster cluster) => deleteTarget = cluster;
|
|
|
|
private async Task DeleteCluster()
|
|
{
|
|
if (deleteTarget is null) return;
|
|
deleting = true;
|
|
try
|
|
{
|
|
await RabbitMQService.DeleteClusterAsync(TenantId, deleteTarget.Id);
|
|
expandedClusterId = null;
|
|
deleteTarget = null;
|
|
await LoadAsync();
|
|
}
|
|
catch (Exception ex) { createError = ex.Message; }
|
|
finally { deleting = false; }
|
|
}
|
|
|
|
// ── Cluster expand / tab switch ────────────────────────────────────────────
|
|
|
|
private async Task ToggleCluster(Guid clusterId)
|
|
{
|
|
if (expandedClusterId == clusterId) { expandedClusterId = null; return; }
|
|
expandedClusterId = clusterId;
|
|
clusterTab = "vhosts";
|
|
await LoadTopologyTab(clusterId);
|
|
}
|
|
|
|
private async Task SetClusterTab(string tab, Guid clusterId)
|
|
{
|
|
clusterTab = tab;
|
|
topologyError = null;
|
|
showVhostForm = showQueueForm = showExchangeForm = showRoutingForm = showUserForm = showPermissionForm = showAppLinkForm = false;
|
|
|
|
switch (tab)
|
|
{
|
|
case "vhosts": case "queues": case "exchanges": case "routing": case "users":
|
|
await LoadTopologyTab(clusterId);
|
|
break;
|
|
case "app-links":
|
|
await LoadAppLinks(clusterId);
|
|
break;
|
|
case "backup":
|
|
await LoadBackupsAsync(clusters.First(c => c.Id == clusterId));
|
|
break;
|
|
case "credentials":
|
|
await LoadCredentials(clusters.First(c => c.Id == clusterId));
|
|
break;
|
|
}
|
|
}
|
|
|
|
private async Task LoadTopologyTab(Guid clusterId)
|
|
{
|
|
loadingTopology = true;
|
|
topologyError = null;
|
|
vhosts = []; queues = []; exchanges = []; routingBindings = []; users = [];
|
|
|
|
try
|
|
{
|
|
switch (clusterTab)
|
|
{
|
|
case "vhosts":
|
|
vhosts = await RabbitMQService.GetVhostsAsync(TenantId, clusterId);
|
|
break;
|
|
case "queues":
|
|
queues = await RabbitMQService.GetQueuesAsync(TenantId, clusterId);
|
|
break;
|
|
case "exchanges":
|
|
exchanges = await RabbitMQService.GetExchangesAsync(TenantId, clusterId);
|
|
break;
|
|
case "routing":
|
|
routingBindings = await RabbitMQService.GetRoutingBindingsAsync(TenantId, clusterId);
|
|
break;
|
|
case "users":
|
|
users = await RabbitMQService.GetUsersAsync(TenantId, clusterId);
|
|
break;
|
|
}
|
|
}
|
|
catch (Exception ex) { topologyError = ex.Message; }
|
|
finally { loadingTopology = false; }
|
|
}
|
|
|
|
// ── Vhosts ────────────────────────────────────────────────────────────────
|
|
|
|
private async Task CreateVhost(RabbitMQCluster cluster)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(newVhostName)) return;
|
|
submitting = true; topologyError = null;
|
|
try
|
|
{
|
|
await RabbitMQService.CreateVhostAsync(TenantId, cluster.Id, newVhostName.Trim());
|
|
showVhostForm = false; newVhostName = "/";
|
|
vhosts = await RabbitMQService.GetVhostsAsync(TenantId, cluster.Id);
|
|
}
|
|
catch (Exception ex) { topologyError = ex.Message; }
|
|
finally { submitting = false; }
|
|
}
|
|
|
|
private async Task DeleteVhost(RabbitMQCluster cluster, string k8sName)
|
|
{
|
|
topologyError = null;
|
|
try
|
|
{
|
|
await RabbitMQService.DeleteVhostAsync(TenantId, cluster.Id, k8sName);
|
|
vhosts = await RabbitMQService.GetVhostsAsync(TenantId, cluster.Id);
|
|
}
|
|
catch (Exception ex) { topologyError = ex.Message; }
|
|
}
|
|
|
|
// ── Queues ────────────────────────────────────────────────────────────────
|
|
|
|
private async Task CreateQueue(RabbitMQCluster cluster)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(newQueueName)) return;
|
|
submitting = true; topologyError = null;
|
|
try
|
|
{
|
|
await RabbitMQService.CreateQueueAsync(TenantId, cluster.Id,
|
|
newQueueVhost, newQueueName.Trim(), newQueueType, newQueueDurable, false);
|
|
showQueueForm = false; newQueueName = "";
|
|
queues = await RabbitMQService.GetQueuesAsync(TenantId, cluster.Id);
|
|
}
|
|
catch (Exception ex) { topologyError = ex.Message; }
|
|
finally { submitting = false; }
|
|
}
|
|
|
|
private async Task DeleteQueue(RabbitMQCluster cluster, string k8sName)
|
|
{
|
|
topologyError = null;
|
|
try
|
|
{
|
|
await RabbitMQService.DeleteQueueAsync(TenantId, cluster.Id, k8sName);
|
|
queues = await RabbitMQService.GetQueuesAsync(TenantId, cluster.Id);
|
|
}
|
|
catch (Exception ex) { topologyError = ex.Message; }
|
|
}
|
|
|
|
// ── Exchanges ─────────────────────────────────────────────────────────────
|
|
|
|
private async Task CreateExchange(RabbitMQCluster cluster)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(newExchangeName)) return;
|
|
submitting = true; topologyError = null;
|
|
try
|
|
{
|
|
await RabbitMQService.CreateExchangeAsync(TenantId, cluster.Id,
|
|
newExchangeVhost, newExchangeName.Trim(), newExchangeType, newExchangeDurable, false);
|
|
showExchangeForm = false; newExchangeName = "";
|
|
exchanges = await RabbitMQService.GetExchangesAsync(TenantId, cluster.Id);
|
|
}
|
|
catch (Exception ex) { topologyError = ex.Message; }
|
|
finally { submitting = false; }
|
|
}
|
|
|
|
private async Task DeleteExchange(RabbitMQCluster cluster, string k8sName)
|
|
{
|
|
topologyError = null;
|
|
try
|
|
{
|
|
await RabbitMQService.DeleteExchangeAsync(TenantId, cluster.Id, k8sName);
|
|
exchanges = await RabbitMQService.GetExchangesAsync(TenantId, cluster.Id);
|
|
}
|
|
catch (Exception ex) { topologyError = ex.Message; }
|
|
}
|
|
|
|
// ── Routing bindings ──────────────────────────────────────────────────────
|
|
|
|
private async Task CreateRoutingBinding(RabbitMQCluster cluster)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(newBindingSource) || string.IsNullOrWhiteSpace(newBindingDest)) return;
|
|
submitting = true; topologyError = null;
|
|
try
|
|
{
|
|
await RabbitMQService.CreateRoutingBindingAsync(TenantId, cluster.Id,
|
|
newBindingVhost, newBindingSource, newBindingDest, newBindingDestType, newBindingRoutingKey);
|
|
showRoutingForm = false; newBindingSource = ""; newBindingDest = ""; newBindingRoutingKey = "";
|
|
routingBindings = await RabbitMQService.GetRoutingBindingsAsync(TenantId, cluster.Id);
|
|
}
|
|
catch (Exception ex) { topologyError = ex.Message; }
|
|
finally { submitting = false; }
|
|
}
|
|
|
|
private async Task DeleteRoutingBinding(RabbitMQCluster cluster, string k8sName)
|
|
{
|
|
topologyError = null;
|
|
try
|
|
{
|
|
await RabbitMQService.DeleteRoutingBindingAsync(TenantId, cluster.Id, k8sName);
|
|
routingBindings = await RabbitMQService.GetRoutingBindingsAsync(TenantId, cluster.Id);
|
|
}
|
|
catch (Exception ex) { topologyError = ex.Message; }
|
|
}
|
|
|
|
// ── Users ─────────────────────────────────────────────────────────────────
|
|
|
|
private async Task CreateUser(RabbitMQCluster cluster)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(newUsername) || string.IsNullOrWhiteSpace(newUserPassword)) return;
|
|
submitting = true; topologyError = null;
|
|
try
|
|
{
|
|
IEnumerable<string> tags = newUserTags
|
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
await RabbitMQService.CreateUserAsync(TenantId, cluster.Id, newUsername.Trim(), newUserPassword, tags);
|
|
showUserForm = false; newUsername = ""; newUserPassword = ""; newUserTags = "";
|
|
users = await RabbitMQService.GetUsersAsync(TenantId, cluster.Id);
|
|
}
|
|
catch (Exception ex) { topologyError = ex.Message; }
|
|
finally { submitting = false; }
|
|
}
|
|
|
|
private async Task DeleteUser(RabbitMQCluster cluster, string k8sName)
|
|
{
|
|
topologyError = null;
|
|
try
|
|
{
|
|
await RabbitMQService.DeleteUserAsync(TenantId, cluster.Id, k8sName);
|
|
users = await RabbitMQService.GetUsersAsync(TenantId, cluster.Id);
|
|
}
|
|
catch (Exception ex) { topologyError = ex.Message; }
|
|
}
|
|
|
|
private void PreparePermission(string username)
|
|
{
|
|
permissionTargetUser = username;
|
|
permVhost = "/"; permConfigure = ".*"; permWrite = ".*"; permRead = ".*";
|
|
showPermissionForm = true;
|
|
}
|
|
|
|
private async Task SetPermission(RabbitMQCluster cluster)
|
|
{
|
|
if (permissionTargetUser is null) return;
|
|
submitting = true; topologyError = null;
|
|
try
|
|
{
|
|
await RabbitMQService.SetPermissionAsync(TenantId, cluster.Id,
|
|
permVhost, permissionTargetUser, permConfigure, permWrite, permRead);
|
|
showPermissionForm = false;
|
|
}
|
|
catch (Exception ex) { topologyError = ex.Message; }
|
|
finally { submitting = false; }
|
|
}
|
|
|
|
// ── App links ─────────────────────────────────────────────────────────────
|
|
|
|
private void PrepareAppLink(RabbitMQCluster cluster, string? queueName, string? exchangeName, string vhost)
|
|
{
|
|
clusterTab = "app-links";
|
|
showAppLinkForm = true;
|
|
linkVhost = vhost;
|
|
linkQueue = queueName ?? "";
|
|
linkExchange = exchangeName ?? "";
|
|
linkSecretName = "rabbitmq";
|
|
_ = LoadAppLinks(cluster.Id);
|
|
}
|
|
|
|
private async Task LoadAppLinks(Guid clusterId)
|
|
{
|
|
loadingTopology = true;
|
|
try { messagingBindings = await RabbitMQService.GetMessagingBindingsAsync(TenantId, clusterId); }
|
|
catch (Exception ex) { topologyError = ex.Message; }
|
|
finally { loadingTopology = false; }
|
|
}
|
|
|
|
private async Task CreateAppLink(RabbitMQCluster cluster)
|
|
{
|
|
if (linkAppDeploymentId == Guid.Empty || string.IsNullOrWhiteSpace(linkSecretName)) return;
|
|
submitting = true; topologyError = null;
|
|
try
|
|
{
|
|
await RabbitMQService.CreateMessagingBindingAsync(
|
|
TenantId, cluster.Id, linkAppDeploymentId,
|
|
linkVhost,
|
|
string.IsNullOrWhiteSpace(linkQueue) ? null : linkQueue.Trim(),
|
|
string.IsNullOrWhiteSpace(linkExchange) ? null : linkExchange.Trim(),
|
|
linkSecretName.Trim());
|
|
showAppLinkForm = false;
|
|
linkAppDeploymentId = Guid.Empty; linkQueue = ""; linkExchange = ""; linkSecretName = "rabbitmq";
|
|
messagingBindings = await RabbitMQService.GetMessagingBindingsAsync(TenantId, cluster.Id);
|
|
}
|
|
catch (Exception ex) { topologyError = ex.Message; }
|
|
finally { submitting = false; }
|
|
}
|
|
|
|
private async Task SyncAppLink(MessagingBinding mb)
|
|
{
|
|
syncingLinkId = mb.Id; topologyError = null;
|
|
try
|
|
{
|
|
await RabbitMQService.SyncMessagingBindingAsync(TenantId, mb.Id);
|
|
messagingBindings = await RabbitMQService.GetMessagingBindingsAsync(TenantId, mb.RabbitMQClusterId);
|
|
}
|
|
catch (Exception ex) { topologyError = ex.Message; }
|
|
finally { syncingLinkId = null; }
|
|
}
|
|
|
|
private async Task DeleteAppLink(RabbitMQCluster cluster, MessagingBinding mb)
|
|
{
|
|
topologyError = null;
|
|
try
|
|
{
|
|
await RabbitMQService.DeleteMessagingBindingAsync(TenantId, mb.Id);
|
|
messagingBindings = await RabbitMQService.GetMessagingBindingsAsync(TenantId, cluster.Id);
|
|
}
|
|
catch (Exception ex) { topologyError = ex.Message; }
|
|
}
|
|
|
|
// ── Backups ────────────────────────────────────────────────────────────────
|
|
|
|
private async Task LoadBackupsAsync(RabbitMQCluster cluster)
|
|
{
|
|
loadingBackups = true; backupError = null;
|
|
try { clusterBackups = await RabbitMQService.GetBackupsAsync(TenantId, cluster.Id); }
|
|
catch (Exception ex) { backupError = ex.Message; }
|
|
finally { loadingBackups = false; }
|
|
}
|
|
|
|
private async Task TriggerBackup(RabbitMQCluster cluster)
|
|
{
|
|
backupError = null; backingUp = cluster.Id;
|
|
try
|
|
{
|
|
if (!cluster.StorageLinkId.HasValue) { backupError = "No storage link attached."; return; }
|
|
await RabbitMQService.CreateBackupAsync(TenantId, cluster.Id, cluster.StorageLinkId.Value);
|
|
await LoadBackupsAsync(cluster);
|
|
}
|
|
catch (Exception ex) { backupError = ex.Message; }
|
|
finally { backingUp = null; }
|
|
}
|
|
|
|
private void PrepareRestore(RabbitMQBackup backup)
|
|
{
|
|
restoreBackupId = backup.Id;
|
|
restoreTargetClusterId = expandedClusterId!.Value;
|
|
showRestoreForm = true;
|
|
}
|
|
|
|
private async Task ExecuteRestore()
|
|
{
|
|
if (!restoreBackupId.HasValue) return;
|
|
restoring = true; backupError = null;
|
|
try
|
|
{
|
|
await RabbitMQService.RestoreBackupAsync(TenantId, restoreBackupId.Value, restoreTargetClusterId);
|
|
showRestoreForm = false; restoreBackupId = null;
|
|
}
|
|
catch (Exception ex) { backupError = ex.Message; }
|
|
finally { restoring = false; }
|
|
}
|
|
|
|
private async Task DeleteBackup(RabbitMQBackup backup)
|
|
{
|
|
try
|
|
{
|
|
await RabbitMQService.DeleteBackupAsync(TenantId, backup.Id);
|
|
RabbitMQCluster? cluster = clusters.FirstOrDefault(c => c.Id == backup.RabbitMQClusterId);
|
|
if (cluster is not null) await LoadBackupsAsync(cluster);
|
|
}
|
|
catch (Exception ex) { backupError = ex.Message; }
|
|
}
|
|
|
|
// ── Credentials ────────────────────────────────────────────────────────────
|
|
|
|
private async Task LoadCredentials(RabbitMQCluster cluster)
|
|
{
|
|
loadingCredentials = true; adminUsername = null; adminPassword = null; credentialError = null; showPassword = false;
|
|
try
|
|
{
|
|
(string Username, string Password)? creds = await RabbitMQService.GetVaultCredentialsAsync(TenantId, cluster.Id);
|
|
if (creds.HasValue) { adminUsername = creds.Value.Username; adminPassword = creds.Value.Password; }
|
|
}
|
|
catch (Exception ex) { credentialError = ex.Message; }
|
|
finally { loadingCredentials = false; }
|
|
}
|
|
|
|
private async Task SyncCredentials(RabbitMQCluster cluster)
|
|
{
|
|
syncingCredentials = true; credentialError = null;
|
|
try
|
|
{
|
|
await RabbitMQService.SyncCredentialsToVaultAsync(TenantId, cluster.Id);
|
|
await LoadCredentials(cluster);
|
|
}
|
|
catch (Exception ex) { credentialError = ex.Message; }
|
|
finally { syncingCredentials = false; }
|
|
}
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
private static string TabLabel(string tab) => tab switch
|
|
{
|
|
"vhosts" => "Vhosts",
|
|
"queues" => "Queues",
|
|
"exchanges" => "Exchanges",
|
|
"routing" => "Routing",
|
|
"users" => "Users",
|
|
"app-links" => "App Links",
|
|
"backup" => "Backup & Restore",
|
|
"credentials" => "Credentials",
|
|
"metrics" => "Metrics",
|
|
_ => tab
|
|
};
|
|
|
|
private static string StatusBadgeClass(RabbitMQClusterStatus status) => status switch
|
|
{
|
|
RabbitMQClusterStatus.Running => "bg-success-subtle text-success border border-success-subtle",
|
|
RabbitMQClusterStatus.Creating => "bg-primary-subtle text-primary border border-primary-subtle",
|
|
RabbitMQClusterStatus.Failed => "bg-danger-subtle text-danger border border-danger-subtle",
|
|
RabbitMQClusterStatus.Deleting => "bg-warning-subtle text-warning border border-warning-subtle",
|
|
_ => "bg-secondary-subtle text-secondary border"
|
|
};
|
|
|
|
private static string BackupBadgeClass(RabbitMQBackupStatus status) => status switch
|
|
{
|
|
RabbitMQBackupStatus.Ready => "bg-success-subtle text-success border border-success-subtle",
|
|
RabbitMQBackupStatus.Creating => "bg-primary-subtle text-primary border border-primary-subtle",
|
|
RabbitMQBackupStatus.Failed => "bg-danger-subtle text-danger border border-danger-subtle",
|
|
_ => "bg-secondary-subtle text-secondary border"
|
|
};
|
|
|
|
private static string FormatBytes(long bytes)
|
|
{
|
|
if (bytes < 1024) return $"{bytes} B";
|
|
if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB";
|
|
return $"{bytes / (1024.0 * 1024):F1} MB";
|
|
}
|
|
}
|