server management
This commit is contained in:
18
Dockerfile
18
Dockerfile
@@ -13,19 +13,33 @@ RUN dotnet restore src/EntKube.Web/EntKube.Web.csproj
|
|||||||
|
|
||||||
COPY src/ src/
|
COPY src/ src/
|
||||||
RUN dotnet publish src/EntKube.Web/EntKube.Web.csproj \
|
RUN dotnet publish src/EntKube.Web/EntKube.Web.csproj \
|
||||||
--no-restore \
|
|
||||||
-c Release \
|
-c Release \
|
||||||
-o /app/publish
|
-o /app/publish
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# libgit2sharp bundles its native lib, but needs libssl/libcurl on Debian
|
# libgit2sharp needs libssl/libcurl; git is used by GitOperationsService;
|
||||||
|
# kubectl and helm are invoked by KubernetesOperationsService/ComponentLifecycleService.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
libssl3 \
|
libssl3 \
|
||||||
libcurl4 \
|
libcurl4 \
|
||||||
|
git \
|
||||||
|
openssh-client \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# kubectl — latest stable, architecture-aware
|
||||||
|
RUN ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') && \
|
||||||
|
KUBECTL_VERSION=$(curl -fsSL https://dl.k8s.io/release/stable.txt) && \
|
||||||
|
curl -fsSL "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${ARCH}/kubectl" \
|
||||||
|
-o /usr/local/bin/kubectl && \
|
||||||
|
chmod +x /usr/local/bin/kubectl
|
||||||
|
|
||||||
|
# helm — latest stable via official installer script
|
||||||
|
RUN curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
|
||||||
|
|
||||||
# Run as non-root
|
# Run as non-root
|
||||||
RUN groupadd --system appgroup && useradd --system --gid appgroup --no-create-home appuser
|
RUN groupadd --system appgroup && useradd --system --gid appgroup --no-create-home appuser
|
||||||
RUN mkdir -p /app/Data && chown appuser:appgroup /app/Data
|
RUN mkdir -p /app/Data && chown appuser:appgroup /app/Data
|
||||||
|
|||||||
@@ -18,8 +18,10 @@
|
|||||||
<body>
|
<body>
|
||||||
<Routes />
|
<Routes />
|
||||||
<ReconnectModal />
|
<ReconnectModal />
|
||||||
|
<script src="@Assets["lib/bootstrap/dist/js/bootstrap.bundle.min.js"]"></script>
|
||||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||||
<script src="@Assets["Components/Account/Shared/PasskeySubmit.razor.js"]" type="module"></script>
|
<script src="@Assets["Components/Account/Shared/PasskeySubmit.razor.js"]" type="module"></script>
|
||||||
|
<script>window.scrollToBottom = (el) => { if (el) el.scrollTop = el.scrollHeight; };</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -59,6 +59,11 @@
|
|||||||
<span class="bi bi-arrow-repeat-nav-menu" aria-hidden="true"></span> Backup
|
<span class="bi bi-arrow-repeat-nav-menu" aria-hidden="true"></span> Backup
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="nav-item px-3">
|
||||||
|
<NavLink class="nav-link" href="admin/notifications">
|
||||||
|
<span class="bi bi-bell-nav-menu" aria-hidden="true"></span> Notifications
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
</AuthorizeView>
|
</AuthorizeView>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,499 @@
|
|||||||
|
@page "/admin/notifications"
|
||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@using System.Text.Json
|
||||||
|
@using System.Security.Claims
|
||||||
|
@using EntKube.Web.Data
|
||||||
|
@using EntKube.Web.Services
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
@attribute [Authorize(Roles = "Admin")]
|
||||||
|
|
||||||
|
@inject NotificationProviderConfigService ProviderConfigService
|
||||||
|
@inject AuthenticationStateProvider AuthState
|
||||||
|
|
||||||
|
<PageTitle>Notification Settings</PageTitle>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="mb-0"><i class="bi bi-bell-fill me-2 text-primary"></i>Notification Settings</h1>
|
||||||
|
<p class="text-muted small mb-0 mt-1">Configure global notification providers used by tenant alert channels.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading)
|
||||||
|
{
|
||||||
|
<div class="d-flex justify-content-center py-5">
|
||||||
|
<div class="spinner-border text-primary" role="status"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="row g-4">
|
||||||
|
|
||||||
|
<!-- SMTP -->
|
||||||
|
<div class="col-12 col-xl-6">
|
||||||
|
<div class="card border-0 shadow-sm h-100">
|
||||||
|
<div class="card-header bg-transparent d-flex align-items-center gap-2">
|
||||||
|
<i class="bi bi-envelope-fill text-warning fs-5"></i>
|
||||||
|
<h5 class="mb-0">SMTP / Email</h5>
|
||||||
|
@if (smtpConfig is not null)
|
||||||
|
{
|
||||||
|
<span class="badge @(smtpConfig.IsEnabled ? "bg-success" : "bg-secondary") ms-auto">@(smtpConfig.IsEnabled ? "Enabled" : "Disabled")</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="badge bg-light text-dark ms-auto">Not configured</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small">Configure an SMTP server so email notification channels can deliver alerts.</p>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Host</label>
|
||||||
|
<input class="form-control" @bind="smtpHost" placeholder="smtp.example.com" />
|
||||||
|
</div>
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col-4">
|
||||||
|
<label class="form-label fw-semibold">Port</label>
|
||||||
|
<input class="form-control" type="number" @bind="smtpPort" />
|
||||||
|
</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<label class="form-label fw-semibold">From Address</label>
|
||||||
|
<input class="form-control" type="email" @bind="smtpFrom" placeholder="alerts@example.com" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Username</label>
|
||||||
|
<input class="form-control" @bind="smtpUsername" placeholder="(optional)" autocomplete="username" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Password</label>
|
||||||
|
<input class="form-control" type="password" @bind="smtpPassword" placeholder="@(smtpConfig is not null ? "•••••••• (leave blank to keep current)" : "(optional)")" autocomplete="current-password" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" id="smtpSsl" @bind="smtpEnableSsl" />
|
||||||
|
<label class="form-check-label" for="smtpSsl">Enable SSL/TLS</label>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" id="smtpEnabled" @bind="smtpEnabled" />
|
||||||
|
<label class="form-check-label" for="smtpEnabled">Enabled</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(smtpError))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger py-2 small">@smtpError</div>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrEmpty(smtpSuccess))
|
||||||
|
{
|
||||||
|
<div class="alert alert-success py-2 small">@smtpSuccess</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 mt-3">
|
||||||
|
<button class="btn btn-primary" @onclick="SaveSmtp" disabled="@smtpBusy">
|
||||||
|
@if (smtpBusy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<div class="input-group" style="max-width:260px">
|
||||||
|
<input class="form-control form-control-sm" @bind="smtpTestRecipient" placeholder="test@example.com" />
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" @onclick="TestSmtp" disabled="@smtpBusy">
|
||||||
|
Test
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (smtpConfig is not null)
|
||||||
|
{
|
||||||
|
<button class="btn btn-outline-danger ms-auto" @onclick="DeleteSmtp" disabled="@smtpBusy">Remove</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MS Teams via Microsoft Graph -->
|
||||||
|
<div class="col-12 col-xl-6">
|
||||||
|
<div class="card border-0 shadow-sm h-100">
|
||||||
|
<div class="card-header bg-transparent d-flex align-items-center gap-2">
|
||||||
|
<i class="bi bi-microsoft-teams text-primary fs-5"></i>
|
||||||
|
<h5 class="mb-0">Microsoft Teams (Graph API)</h5>
|
||||||
|
@if (teamsConfig is not null)
|
||||||
|
{
|
||||||
|
<span class="badge @(teamsConfig.IsEnabled ? "bg-success" : "bg-secondary") ms-auto">@(teamsConfig.IsEnabled ? "Enabled" : "Disabled")</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="badge bg-light text-dark ms-auto">Not configured</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small">
|
||||||
|
Send alerts directly to Teams channels using the Microsoft Graph API.
|
||||||
|
Requires an Azure AD app registration with <code>ChannelMessage.Send</code> application permission.
|
||||||
|
</p>
|
||||||
|
<div class="alert alert-info py-2 small mb-3">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
Once configured, add a Teams notification channel to a tenant and supply a <strong>Team ID</strong> and <strong>Channel ID</strong> instead of a webhook URL.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Tenant ID (Directory ID)</label>
|
||||||
|
<input class="form-control font-monospace" @bind="teamsTenantId" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Client ID (Application ID)</label>
|
||||||
|
<input class="form-control font-monospace" @bind="teamsClientId" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Client Secret</label>
|
||||||
|
<input class="form-control" type="password" @bind="teamsClientSecret"
|
||||||
|
placeholder="@(teamsConfig is not null ? "•••••••• (leave blank to keep current)" : "")"
|
||||||
|
autocomplete="current-password" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" id="teamsEnabled" @bind="teamsEnabled" />
|
||||||
|
<label class="form-check-label" for="teamsEnabled">Enabled</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(teamsError))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger py-2 small">@teamsError</div>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrEmpty(teamsSuccess))
|
||||||
|
{
|
||||||
|
<div class="alert alert-success py-2 small">@teamsSuccess</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 mt-3">
|
||||||
|
<button class="btn btn-primary" @onclick="SaveTeams" disabled="@teamsBusy">
|
||||||
|
@if (teamsBusy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary" @onclick="TestTeams" disabled="@teamsBusy">
|
||||||
|
Test Credentials
|
||||||
|
</button>
|
||||||
|
@if (teamsConfig is not null)
|
||||||
|
{
|
||||||
|
<button class="btn btn-outline-danger ms-auto" @onclick="DeleteTeams" disabled="@teamsBusy">Remove</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Slack info card -->
|
||||||
|
<div class="col-12 col-xl-6">
|
||||||
|
<div class="card border-0 shadow-sm bg-light">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-2">
|
||||||
|
<i class="bi bi-slack text-success fs-5"></i>
|
||||||
|
<h6 class="mb-0">Slack</h6>
|
||||||
|
<span class="badge bg-success ms-auto">Ready</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small mb-0">
|
||||||
|
Slack notifications use <strong>Incoming Webhooks</strong> — no global configuration needed.
|
||||||
|
Each tenant notification channel stores its own webhook URL.
|
||||||
|
Create webhooks at <strong>api.slack.com/apps</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Webhook info card -->
|
||||||
|
<div class="col-12 col-xl-6">
|
||||||
|
<div class="card border-0 shadow-sm bg-light">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-2">
|
||||||
|
<i class="bi bi-link-45deg text-secondary fs-5"></i>
|
||||||
|
<h6 class="mb-0">Generic Webhook</h6>
|
||||||
|
<span class="badge bg-success ms-auto">Ready</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small mb-0">
|
||||||
|
Webhook channels post a JSON payload to any URL and optionally authenticate with a Bearer token.
|
||||||
|
No global configuration required — configure per-channel in the tenant settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private bool loading = true;
|
||||||
|
private string? currentUserId;
|
||||||
|
|
||||||
|
// SMTP state
|
||||||
|
private NotificationProviderConfig? smtpConfig;
|
||||||
|
private string smtpHost = "";
|
||||||
|
private int smtpPort = 587;
|
||||||
|
private string smtpFrom = "";
|
||||||
|
private string smtpUsername = "";
|
||||||
|
private string smtpPassword = "";
|
||||||
|
private bool smtpEnableSsl = true;
|
||||||
|
private bool smtpEnabled = true;
|
||||||
|
private string smtpTestRecipient = "";
|
||||||
|
private bool smtpBusy;
|
||||||
|
private string? smtpError;
|
||||||
|
private string? smtpSuccess;
|
||||||
|
|
||||||
|
// Teams state
|
||||||
|
private NotificationProviderConfig? teamsConfig;
|
||||||
|
private string teamsTenantId = "";
|
||||||
|
private string teamsClientId = "";
|
||||||
|
private string teamsClientSecret = "";
|
||||||
|
private bool teamsEnabled = true;
|
||||||
|
private bool teamsBusy;
|
||||||
|
private string? teamsError;
|
||||||
|
private string? teamsSuccess;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
AuthenticationState authState = await AuthState.GetAuthenticationStateAsync();
|
||||||
|
currentUserId = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
|
||||||
|
await LoadAsync();
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
smtpConfig = await ProviderConfigService.GetAsync(NotificationProviderType.Smtp);
|
||||||
|
teamsConfig = await ProviderConfigService.GetAsync(NotificationProviderType.MsTeamsGraph);
|
||||||
|
|
||||||
|
if (smtpConfig is not null)
|
||||||
|
{
|
||||||
|
using JsonDocument doc = JsonDocument.Parse(smtpConfig.ConfigurationJson);
|
||||||
|
JsonElement root = doc.RootElement;
|
||||||
|
smtpHost = root.TryGetProperty("host", out var h) ? h.GetString() ?? "" : "";
|
||||||
|
smtpPort = root.TryGetProperty("port", out var p) ? p.GetInt32() : 587;
|
||||||
|
smtpFrom = root.TryGetProperty("from", out var f) ? f.GetString() ?? "" : "";
|
||||||
|
smtpUsername = root.TryGetProperty("username", out var u) ? u.GetString() ?? "" : "";
|
||||||
|
smtpEnableSsl = !root.TryGetProperty("enableSsl", out var ssl) || ssl.GetBoolean();
|
||||||
|
smtpEnabled = smtpConfig.IsEnabled;
|
||||||
|
smtpPassword = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (teamsConfig is not null)
|
||||||
|
{
|
||||||
|
using JsonDocument doc = JsonDocument.Parse(teamsConfig.ConfigurationJson);
|
||||||
|
JsonElement root = doc.RootElement;
|
||||||
|
teamsTenantId = root.TryGetProperty("tenantId", out var t) ? t.GetString() ?? "" : "";
|
||||||
|
teamsClientId = root.TryGetProperty("clientId", out var c) ? c.GetString() ?? "" : "";
|
||||||
|
teamsEnabled = teamsConfig.IsEnabled;
|
||||||
|
teamsClientSecret = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveSmtp()
|
||||||
|
{
|
||||||
|
smtpError = null;
|
||||||
|
smtpSuccess = null;
|
||||||
|
if (string.IsNullOrWhiteSpace(smtpHost))
|
||||||
|
{
|
||||||
|
smtpError = "Host is required.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
smtpBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string password = smtpPassword;
|
||||||
|
if (string.IsNullOrEmpty(password) && smtpConfig is not null)
|
||||||
|
{
|
||||||
|
using JsonDocument existing = JsonDocument.Parse(smtpConfig.ConfigurationJson);
|
||||||
|
password = existing.RootElement.TryGetProperty("password", out var pw) ? pw.GetString() ?? "" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
string configJson = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
host = smtpHost.Trim(),
|
||||||
|
port = smtpPort,
|
||||||
|
from = smtpFrom.Trim(),
|
||||||
|
username = smtpUsername.Trim(),
|
||||||
|
password,
|
||||||
|
enableSsl = smtpEnableSsl
|
||||||
|
});
|
||||||
|
|
||||||
|
await ProviderConfigService.SaveAsync(NotificationProviderType.Smtp, configJson, smtpEnabled, currentUserId);
|
||||||
|
smtpSuccess = "SMTP configuration saved.";
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
smtpError = ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
smtpBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TestSmtp()
|
||||||
|
{
|
||||||
|
smtpError = null;
|
||||||
|
smtpSuccess = null;
|
||||||
|
if (string.IsNullOrWhiteSpace(smtpTestRecipient))
|
||||||
|
{
|
||||||
|
smtpError = "Enter a test recipient email address.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
smtpBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string password = smtpPassword;
|
||||||
|
if (string.IsNullOrEmpty(password) && smtpConfig is not null)
|
||||||
|
{
|
||||||
|
using JsonDocument existing = JsonDocument.Parse(smtpConfig.ConfigurationJson);
|
||||||
|
password = existing.RootElement.TryGetProperty("password", out var pw) ? pw.GetString() ?? "" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
string configJson = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
host = smtpHost.Trim(),
|
||||||
|
port = smtpPort,
|
||||||
|
from = smtpFrom.Trim(),
|
||||||
|
username = smtpUsername.Trim(),
|
||||||
|
password,
|
||||||
|
enableSsl = smtpEnableSsl
|
||||||
|
});
|
||||||
|
|
||||||
|
await ProviderConfigService.TestSmtpAsync(configJson, smtpTestRecipient.Trim());
|
||||||
|
smtpSuccess = $"Test email sent to {smtpTestRecipient}.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
smtpError = $"Test failed: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
smtpBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteSmtp()
|
||||||
|
{
|
||||||
|
smtpBusy = true;
|
||||||
|
smtpError = null;
|
||||||
|
smtpSuccess = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ProviderConfigService.DeleteAsync(NotificationProviderType.Smtp);
|
||||||
|
smtpHost = ""; smtpPort = 587; smtpFrom = ""; smtpUsername = ""; smtpPassword = ""; smtpEnableSsl = true; smtpEnabled = true;
|
||||||
|
await LoadAsync();
|
||||||
|
smtpSuccess = "SMTP configuration removed.";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
smtpBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveTeams()
|
||||||
|
{
|
||||||
|
teamsError = null;
|
||||||
|
teamsSuccess = null;
|
||||||
|
if (string.IsNullOrWhiteSpace(teamsTenantId) || string.IsNullOrWhiteSpace(teamsClientId))
|
||||||
|
{
|
||||||
|
teamsError = "Tenant ID and Client ID are required.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
teamsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string secret = teamsClientSecret;
|
||||||
|
if (string.IsNullOrEmpty(secret) && teamsConfig is not null)
|
||||||
|
{
|
||||||
|
using JsonDocument existing = JsonDocument.Parse(teamsConfig.ConfigurationJson);
|
||||||
|
secret = existing.RootElement.TryGetProperty("clientSecret", out var s) ? s.GetString() ?? "" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(secret))
|
||||||
|
{
|
||||||
|
teamsError = "Client Secret is required.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string configJson = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
tenantId = teamsTenantId.Trim(),
|
||||||
|
clientId = teamsClientId.Trim(),
|
||||||
|
clientSecret = secret
|
||||||
|
});
|
||||||
|
|
||||||
|
await ProviderConfigService.SaveAsync(NotificationProviderType.MsTeamsGraph, configJson, teamsEnabled, currentUserId);
|
||||||
|
teamsSuccess = "Teams Graph configuration saved.";
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
teamsError = ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
teamsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TestTeams()
|
||||||
|
{
|
||||||
|
teamsError = null;
|
||||||
|
teamsSuccess = null;
|
||||||
|
if (string.IsNullOrWhiteSpace(teamsTenantId) || string.IsNullOrWhiteSpace(teamsClientId))
|
||||||
|
{
|
||||||
|
teamsError = "Tenant ID and Client ID are required.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
teamsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string secret = teamsClientSecret;
|
||||||
|
if (string.IsNullOrEmpty(secret) && teamsConfig is not null)
|
||||||
|
{
|
||||||
|
using JsonDocument existing = JsonDocument.Parse(teamsConfig.ConfigurationJson);
|
||||||
|
secret = existing.RootElement.TryGetProperty("clientSecret", out var s) ? s.GetString() ?? "" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(secret))
|
||||||
|
{
|
||||||
|
teamsError = "Client Secret is required.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string configJson = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
tenantId = teamsTenantId.Trim(),
|
||||||
|
clientId = teamsClientId.Trim(),
|
||||||
|
clientSecret = secret
|
||||||
|
});
|
||||||
|
|
||||||
|
await ProviderConfigService.TestMsTeamsGraphAsync(configJson);
|
||||||
|
teamsSuccess = "Successfully authenticated with Azure AD. Credentials are valid.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
teamsError = $"Test failed: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
teamsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteTeams()
|
||||||
|
{
|
||||||
|
teamsBusy = true;
|
||||||
|
teamsError = null;
|
||||||
|
teamsSuccess = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ProviderConfigService.DeleteAsync(NotificationProviderType.MsTeamsGraph);
|
||||||
|
teamsTenantId = ""; teamsClientId = ""; teamsClientSecret = ""; teamsEnabled = true;
|
||||||
|
await LoadAsync();
|
||||||
|
teamsSuccess = "Teams Graph configuration removed.";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
teamsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,12 @@ else if (user is not null)
|
|||||||
<button type="button" class="btn-close btn-close-sm" @onclick="() => errorMessage = null"></button>
|
<button type="button" class="btn-close btn-close-sm" @onclick="() => errorMessage = null"></button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (!string.IsNullOrEmpty(successMessage))
|
||||||
|
{
|
||||||
|
<div class="alert alert-success py-2 small" role="alert">
|
||||||
|
<i class="bi bi-check-circle me-1"></i>@successMessage
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
|
|
||||||
@@ -102,20 +108,25 @@ else if (user is not null)
|
|||||||
</td>
|
</td>
|
||||||
<td class="align-middle text-muted small">@m.JoinedAt.ToString("MMM d, yyyy")</td>
|
<td class="align-middle text-muted small">@m.JoinedAt.ToString("MMM d, yyyy")</td>
|
||||||
<td class="align-middle text-end">
|
<td class="align-middle text-end">
|
||||||
@if (confirmRemoveTenantId == m.TenantId)
|
<button class="btn btn-sm btn-outline-danger"
|
||||||
{
|
@onclick="() => confirmRemoveTenantId = m.TenantId">
|
||||||
<span class="me-2 small text-danger">Remove?</span>
|
<i class="bi bi-trash"></i>
|
||||||
<button class="btn btn-sm btn-danger me-1" @onclick="() => RemoveTenantMembership(m.TenantId)">Yes</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmRemoveTenantId = null">No</button>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => confirmRemoveTenantId = m.TenantId">
|
|
||||||
<i class="bi bi-trash"></i>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@if (confirmRemoveTenantId == m.TenantId)
|
||||||
|
{
|
||||||
|
<tr class="table-danger table-sm">
|
||||||
|
<td colspan="4" class="px-3 py-2">
|
||||||
|
<ConfirmDialog Visible="true"
|
||||||
|
Message="@($"Remove {user!.Email} from tenant '{m.Tenant.Name}'?")"
|
||||||
|
ConfirmText="Remove"
|
||||||
|
IsBusy="removingMembership"
|
||||||
|
OnConfirm="() => RemoveTenantMembership(m.TenantId)"
|
||||||
|
OnCancel="() => confirmRemoveTenantId = null" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -196,20 +207,25 @@ else if (user is not null)
|
|||||||
<td class="align-middle text-muted small">@gm.Group.Tenant.Name</td>
|
<td class="align-middle text-muted small">@gm.Group.Tenant.Name</td>
|
||||||
<td class="align-middle text-muted small">@gm.JoinedAt.ToString("MMM d, yyyy")</td>
|
<td class="align-middle text-muted small">@gm.JoinedAt.ToString("MMM d, yyyy")</td>
|
||||||
<td class="align-middle text-end">
|
<td class="align-middle text-end">
|
||||||
@if (confirmRemoveGroupId == gm.GroupId)
|
<button class="btn btn-sm btn-outline-danger"
|
||||||
{
|
@onclick="() => confirmRemoveGroupId = gm.GroupId">
|
||||||
<span class="me-2 small text-danger">Remove?</span>
|
<i class="bi bi-trash"></i>
|
||||||
<button class="btn btn-sm btn-danger me-1" @onclick="() => RemoveGroupMembership(gm.GroupId)">Yes</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmRemoveGroupId = null">No</button>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => confirmRemoveGroupId = gm.GroupId">
|
|
||||||
<i class="bi bi-trash"></i>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@if (confirmRemoveGroupId == gm.GroupId)
|
||||||
|
{
|
||||||
|
<tr class="table-danger table-sm">
|
||||||
|
<td colspan="4" class="px-3 py-2">
|
||||||
|
<ConfirmDialog Visible="true"
|
||||||
|
Message="@($"Remove {user!.Email} from group '{gm.Group.Name}'?")"
|
||||||
|
ConfirmText="Remove"
|
||||||
|
IsBusy="removingMembership"
|
||||||
|
OnConfirm="() => RemoveGroupMembership(gm.GroupId)"
|
||||||
|
OnCancel="() => confirmRemoveGroupId = null" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -260,20 +276,16 @@ else if (user is not null)
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title text-danger mb-1"><i class="bi bi-exclamation-triangle me-2"></i>Danger Zone</h5>
|
<h5 class="card-title text-danger mb-1"><i class="bi bi-exclamation-triangle me-2"></i>Danger Zone</h5>
|
||||||
<p class="text-muted small mb-3">Permanently delete this user account. All tenant and group memberships are removed. This cannot be undone.</p>
|
<p class="text-muted small mb-3">Permanently delete this user account. All tenant and group memberships are removed. This cannot be undone.</p>
|
||||||
@if (confirmDelete)
|
<button class="btn btn-outline-danger" @onclick="() => confirmDelete = true"
|
||||||
{
|
hidden="@confirmDelete">
|
||||||
<div class="alert alert-danger py-2 small mb-3">
|
<i class="bi bi-trash me-1"></i>Delete User
|
||||||
<strong>Are you sure?</strong> This will permanently delete <strong>@user.Email</strong>.
|
</button>
|
||||||
</div>
|
<ConfirmDialog Visible="confirmDelete"
|
||||||
<button class="btn btn-danger me-2" @onclick="DeleteUser">Delete Permanently</button>
|
Message="@($"Permanently delete {user.Email}? All memberships are removed and this cannot be undone.")"
|
||||||
<button class="btn btn-outline-secondary" @onclick="() => confirmDelete = false">Cancel</button>
|
ConfirmText="Delete Permanently"
|
||||||
}
|
IsBusy="deletingUser"
|
||||||
else
|
OnConfirm="DeleteUser"
|
||||||
{
|
OnCancel="() => confirmDelete = false" />
|
||||||
<button class="btn btn-outline-danger" @onclick="() => confirmDelete = true">
|
|
||||||
<i class="bi bi-trash me-1"></i>Delete User
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -330,6 +342,9 @@ else if (user is not null)
|
|||||||
private Guid? confirmRemoveTenantId;
|
private Guid? confirmRemoveTenantId;
|
||||||
private Guid? confirmRemoveGroupId;
|
private Guid? confirmRemoveGroupId;
|
||||||
private bool confirmDelete;
|
private bool confirmDelete;
|
||||||
|
private bool removingMembership;
|
||||||
|
private bool deletingUser;
|
||||||
|
private string? successMessage;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -411,14 +426,27 @@ else if (user is not null)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ShowSuccessAsync(string message)
|
||||||
|
{
|
||||||
|
successMessage = message;
|
||||||
|
StateHasChanged();
|
||||||
|
await Task.Delay(3500);
|
||||||
|
successMessage = null;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task RemoveTenantMembership(Guid tenantId)
|
private async Task RemoveTenantMembership(Guid tenantId)
|
||||||
{
|
{
|
||||||
confirmRemoveTenantId = null;
|
removingMembership = true;
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
|
string? tenantName = tenantMemberships.FirstOrDefault(m => m.TenantId == tenantId)?.Tenant.Name;
|
||||||
|
|
||||||
await UserManagement.RemoveTenantMembershipAsync(UserId, tenantId);
|
await UserManagement.RemoveTenantMembershipAsync(UserId, tenantId);
|
||||||
tenantMemberships = await UserManagement.GetUserTenantMembershipsAsync(UserId);
|
tenantMemberships = await UserManagement.GetUserTenantMembershipsAsync(UserId);
|
||||||
RefreshAvailableTenants();
|
RefreshAvailableTenants();
|
||||||
|
removingMembership = false;
|
||||||
|
confirmRemoveTenantId = null;
|
||||||
|
if (tenantName is not null) _ = ShowSuccessAsync($"Removed from tenant '{tenantName}'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AddGroupMembership()
|
private async Task AddGroupMembership()
|
||||||
@@ -440,17 +468,24 @@ else if (user is not null)
|
|||||||
|
|
||||||
private async Task RemoveGroupMembership(Guid groupId)
|
private async Task RemoveGroupMembership(Guid groupId)
|
||||||
{
|
{
|
||||||
confirmRemoveGroupId = null;
|
removingMembership = true;
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
|
string? groupName = groupMemberships.FirstOrDefault(gm => gm.GroupId == groupId)?.Group.Name;
|
||||||
|
|
||||||
await UserManagement.RemoveGroupMembershipAsync(UserId, groupId);
|
await UserManagement.RemoveGroupMembershipAsync(UserId, groupId);
|
||||||
groupMemberships = await UserManagement.GetUserGroupMembershipsAsync(UserId);
|
groupMemberships = await UserManagement.GetUserGroupMembershipsAsync(UserId);
|
||||||
await LoadGroupsForSelectedTenant();
|
await LoadGroupsForSelectedTenant();
|
||||||
|
removingMembership = false;
|
||||||
|
confirmRemoveGroupId = null;
|
||||||
|
if (groupName is not null) _ = ShowSuccessAsync($"Removed from group '{groupName}'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteUser()
|
private async Task DeleteUser()
|
||||||
{
|
{
|
||||||
|
deletingUser = true;
|
||||||
IdentityResult result = await UserManagement.DeleteUserAsync(UserId);
|
IdentityResult result = await UserManagement.DeleteUserAsync(UserId);
|
||||||
|
deletingUser = false;
|
||||||
|
|
||||||
if (!result.Succeeded)
|
if (!result.Succeeded)
|
||||||
{
|
{
|
||||||
errorMessage = string.Join(" ", result.Errors.Select(e => e.Description));
|
errorMessage = string.Join(" ", result.Errors.Select(e => e.Description));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@using EntKube.Web.Data
|
@using EntKube.Web.Data
|
||||||
@using EntKube.Web.Services
|
@using EntKube.Web.Services
|
||||||
@inject AppGovernanceService GovernanceService
|
@inject AppGovernanceService GovernanceService
|
||||||
|
@inject CustomerGitService CustomerGitService
|
||||||
|
|
||||||
@* ═══════════════════════════════════════════════════════════════════
|
@* ═══════════════════════════════════════════════════════════════════
|
||||||
AppGovernancePanel — read-only portal view of an app's governance
|
AppGovernancePanel — read-only portal view of an app's governance
|
||||||
@@ -23,17 +24,21 @@ else
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* Namespace *@
|
@* Namespace *@
|
||||||
<div class="mb-3">
|
@if (!string.IsNullOrWhiteSpace(data?.Namespace))
|
||||||
<label class="form-label small fw-medium">Kubernetes Namespace</label>
|
{
|
||||||
@if (!string.IsNullOrWhiteSpace(data?.Namespace))
|
<div class="card shadow-sm mb-3">
|
||||||
{
|
<div class="card-header bg-white py-2">
|
||||||
<input class="form-control form-control-sm font-monospace" value="@data.Namespace" disabled />
|
<i class="bi bi-box me-2 text-primary"></i>
|
||||||
}
|
<strong class="small">Namespace</strong>
|
||||||
else
|
</div>
|
||||||
{
|
<div class="card-body py-2">
|
||||||
<input class="form-control form-control-sm text-muted fst-italic" value="Not assigned" disabled />
|
<div class="d-flex align-items-center gap-2">
|
||||||
}
|
<i class="bi bi-lock-fill text-warning small"></i>
|
||||||
</div>
|
<span class="small">Your app runs in namespace <code>@data.Namespace</code>.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@* Quota *@
|
@* Quota *@
|
||||||
<div class="card shadow-sm mb-3">
|
<div class="card shadow-sm mb-3">
|
||||||
@@ -166,19 +171,122 @@ else
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@* Git URL Policies *@
|
||||||
|
<div class="card shadow-sm mt-3">
|
||||||
|
<div class="card-header bg-white py-2">
|
||||||
|
<i class="bi bi-git me-2 text-secondary"></i>
|
||||||
|
<strong class="small">Git URL Policies</strong>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
@if (gitPolicies.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-muted small mb-0">No git URL policies — all repositories are allowed.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<ul class="list-group list-group-flush mb-0">
|
||||||
|
@foreach (CustomerGitRepoPolicy p in gitPolicies)
|
||||||
|
{
|
||||||
|
<li class="list-group-item d-flex align-items-center gap-2 px-0 py-1">
|
||||||
|
<i class="bi bi-check-circle text-success small"></i>
|
||||||
|
<code class="small">@p.UrlPattern</code>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Allowed Databases *@
|
||||||
|
@if (data?.AllowedDatabases is { Count: > 0 } allowedDbs)
|
||||||
|
{
|
||||||
|
<div class="card shadow-sm mt-3">
|
||||||
|
<div class="card-header bg-white py-2">
|
||||||
|
<i class="bi bi-database me-2 text-primary"></i>
|
||||||
|
<strong class="small">Allowed Databases</strong>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul class="list-group list-group-flush mb-0">
|
||||||
|
@foreach (AppAllowedDatabase db in allowedDbs)
|
||||||
|
{
|
||||||
|
<li class="list-group-item d-flex align-items-center gap-2 px-0 py-1">
|
||||||
|
<i class="bi bi-check-circle text-success small"></i>
|
||||||
|
<span class="badge bg-primary-subtle text-primary border border-primary-subtle small">@AllowedDbTypeLabel(db)</span>
|
||||||
|
<span class="small">@AllowedDbName(db)</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Allowed Cache Services *@
|
||||||
|
@if (data?.AllowedCaches is { Count: > 0 } allowedCaches)
|
||||||
|
{
|
||||||
|
<div class="card shadow-sm mt-3">
|
||||||
|
<div class="card-header bg-white py-2">
|
||||||
|
<i class="bi bi-lightning me-2 text-warning"></i>
|
||||||
|
<strong class="small">Allowed Cache Services</strong>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul class="list-group list-group-flush mb-0">
|
||||||
|
@foreach (AppAllowedCache cache in allowedCaches)
|
||||||
|
{
|
||||||
|
<li class="list-group-item d-flex align-items-center gap-2 px-0 py-1">
|
||||||
|
<i class="bi bi-check-circle text-success small"></i>
|
||||||
|
<span class="small fw-medium">@cache.RedisCluster.Name</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Allowed S3 Storage *@
|
||||||
|
@if (data?.AllowedStorages is { Count: > 0 } allowedStorages)
|
||||||
|
{
|
||||||
|
<div class="card shadow-sm mt-3">
|
||||||
|
<div class="card-header bg-white py-2">
|
||||||
|
<i class="bi bi-bucket me-2 text-info"></i>
|
||||||
|
<strong class="small">Allowed S3 Storage</strong>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul class="list-group list-group-flush mb-0">
|
||||||
|
@foreach (AppAllowedStorage storage in allowedStorages)
|
||||||
|
{
|
||||||
|
<li class="list-group-item d-flex align-items-center gap-2 px-0 py-1">
|
||||||
|
<i class="bi bi-check-circle text-success small"></i>
|
||||||
|
<span class="badge bg-info-subtle text-info border border-info-subtle small">@storage.StorageLink.Provider</span>
|
||||||
|
<span class="small fw-medium">@storage.StorageLink.Name</span>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(storage.StorageLink.BucketName))
|
||||||
|
{
|
||||||
|
<code class="small text-muted">@storage.StorageLink.BucketName</code>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter, EditorRequired] public Guid AppId { get; set; }
|
[Parameter, EditorRequired] public Guid AppId { get; set; }
|
||||||
[Parameter, EditorRequired] public string AppName { get; set; } = "";
|
[Parameter, EditorRequired] public string AppName { get; set; } = "";
|
||||||
|
[Parameter, EditorRequired] public Guid EnvironmentId { get; set; }
|
||||||
|
[Parameter] public Guid CustomerId { get; set; }
|
||||||
|
|
||||||
private AppGovernanceData? data;
|
private AppGovernanceData? data;
|
||||||
|
private List<CustomerGitRepoPolicy> gitPolicies = [];
|
||||||
private bool loading = true;
|
private bool loading = true;
|
||||||
|
|
||||||
protected override async Task OnParametersSetAsync()
|
protected override async Task OnParametersSetAsync()
|
||||||
{
|
{
|
||||||
loading = true;
|
loading = true;
|
||||||
data = await GovernanceService.LoadAsync(AppId);
|
data = await GovernanceService.LoadAsync(AppId, EnvironmentId);
|
||||||
|
if (CustomerId != Guid.Empty)
|
||||||
|
gitPolicies = await CustomerGitService.GetRepoPoliciesAsync(CustomerId, EnvironmentId);
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,4 +306,20 @@ else
|
|||||||
AppNetworkPolicyType.AllowFromIngress or AppNetworkPolicyType.AllowFromNamespace or AppNetworkPolicyType.AllowFromSameNamespace => "bi-check-circle",
|
AppNetworkPolicyType.AllowFromIngress or AppNetworkPolicyType.AllowFromNamespace or AppNetworkPolicyType.AllowFromSameNamespace => "bi-check-circle",
|
||||||
_ => "bi-code-slash"
|
_ => "bi-code-slash"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static string AllowedDbTypeLabel(AppAllowedDatabase entry)
|
||||||
|
{
|
||||||
|
if (entry.CnpgDatabase is not null) return "PostgreSQL";
|
||||||
|
if (entry.MongoDatabase is not null) return "MongoDB";
|
||||||
|
if (entry.RegisteredPostgresDatabase is not null) return "Postgres";
|
||||||
|
return "Database";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string AllowedDbName(AppAllowedDatabase entry)
|
||||||
|
{
|
||||||
|
if (entry.CnpgDatabase is not null) return entry.CnpgDatabase.Name;
|
||||||
|
if (entry.MongoDatabase is not null) return entry.MongoDatabase.Name;
|
||||||
|
if (entry.RegisteredPostgresDatabase is not null) return entry.RegisteredPostgresDatabase.Name;
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,628 @@
|
|||||||
|
@using EntKube.Web.Data
|
||||||
|
@using EntKube.Web.Services
|
||||||
|
@inject AppRouteService AppRouteService
|
||||||
|
@inject DeploymentService DeploymentService
|
||||||
|
@inject KubernetesOperationsService K8sOps
|
||||||
|
|
||||||
|
@* ═══════════════════════════════════════════════════════════════════
|
||||||
|
Portal URL Management — customers configure which paths under
|
||||||
|
their ops-assigned hostnames route to which services.
|
||||||
|
|
||||||
|
Ops owns: hostname + TLS (AppRoute)
|
||||||
|
Customer owns: path prefixes + target services (AppDeploymentRoute)
|
||||||
|
═══════════════════════════════════════════════════════════════════ *@
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<i class="bi bi-globe fs-4 me-2 text-primary"></i>
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-0">URL Configuration — @App.Name</h5>
|
||||||
|
<small class="text-muted">Manage which paths route to which services for each hostname.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loadError is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger py-2 small">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||||
|
Failed to load URL configuration: @loadError
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (routes is null)
|
||||||
|
{
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (routes.Count == 0)
|
||||||
|
{
|
||||||
|
<EmptyState Icon="bi-globe2"
|
||||||
|
Title="No URLs configured"
|
||||||
|
Message="No hostnames have been configured for this app yet. Contact your operations team to set up a hostname and TLS certificate." />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@foreach (AppRoute route in routes)
|
||||||
|
{
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
@* ── Hostname header (ops-managed, read-only) ── *@
|
||||||
|
<div class="card-header bg-white py-2">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<i class="bi bi-globe2 text-primary"></i>
|
||||||
|
<a href="https://@route.Hostname" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="fw-medium text-decoration-none">
|
||||||
|
@route.Hostname
|
||||||
|
</a>
|
||||||
|
@TlsBadge(route)
|
||||||
|
@if (!route.IsEnabled)
|
||||||
|
{
|
||||||
|
<span class="badge bg-secondary">Disabled by ops</span>
|
||||||
|
}
|
||||||
|
<span class="badge bg-light text-muted border ms-auto small">ops-managed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body p-0">
|
||||||
|
@* ── Existing deployment routes ── *@
|
||||||
|
@if (route.DeploymentRoutes.Count > 0)
|
||||||
|
{
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th class="small ps-3">Path</th>
|
||||||
|
<th class="small">Deployment</th>
|
||||||
|
<th class="small">Env</th>
|
||||||
|
<th class="small">Service</th>
|
||||||
|
<th class="small">Status</th>
|
||||||
|
@if (AccessRole >= CustomerAccessRole.Operator)
|
||||||
|
{
|
||||||
|
<th></th>
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (AppDeploymentRoute dr in route.DeploymentRoutes.OrderBy(r => r.PathPrefix))
|
||||||
|
{
|
||||||
|
bool isEditing = editingDrId == dr.Id;
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
@if (isEditing)
|
||||||
|
{
|
||||||
|
<td colspan="@(AccessRole >= CustomerAccessRole.Operator ? 6 : 5)" class="px-3 py-2">
|
||||||
|
@if (editError is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger py-1 small mb-2">@editError</div>
|
||||||
|
}
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small">Path Prefix</label>
|
||||||
|
<input class="form-control form-control-sm" @bind="editPath" placeholder="/" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">
|
||||||
|
Service
|
||||||
|
@if (editLoadingServices)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm ms-1" style="width:.7rem;height:.7rem;"></span>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
@if (editAvailableServices.Count > 0)
|
||||||
|
{
|
||||||
|
<select class="form-select form-select-sm"
|
||||||
|
value="@editService"
|
||||||
|
@onchange="OnEditServiceSelected">
|
||||||
|
<option value="">— select —</option>
|
||||||
|
@foreach (KubeServiceInfo svc in editAvailableServices)
|
||||||
|
{
|
||||||
|
<option value="@svc.Name">@svc.Name (@svc.Type)</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<input class="form-control form-control-sm" @bind="editService" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small">Port</label>
|
||||||
|
@if (editAvailablePorts.Count > 0)
|
||||||
|
{
|
||||||
|
<select class="form-select form-select-sm" @bind="editPort">
|
||||||
|
@foreach (KubeServicePort p in editAvailablePorts)
|
||||||
|
{
|
||||||
|
<option value="@p.Port">@p.Port@(p.Name is not null ? $" ({p.Name})" : "")</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<input type="number" class="form-control form-control-sm" @bind="editPort" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@* Endpoint health for edit *@
|
||||||
|
@if (editEndpointSummary is not null && editEndpointSummary.HasEndpoints)
|
||||||
|
{
|
||||||
|
<div class="mt-1 small">
|
||||||
|
<span class="text-success me-2">
|
||||||
|
<i class="bi bi-circle-fill me-1" style="font-size:.5rem;vertical-align:middle;"></i>
|
||||||
|
@editEndpointSummary.TotalReady ready
|
||||||
|
</span>
|
||||||
|
@if (editEndpointSummary.TotalNotReady > 0)
|
||||||
|
{
|
||||||
|
<span class="text-warning">
|
||||||
|
<i class="bi bi-circle-fill me-1" style="font-size:.5rem;vertical-align:middle;"></i>
|
||||||
|
@editEndpointSummary.TotalNotReady not ready
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (editEndpointSummary is not null && !editEndpointSummary.HasEndpoints && !string.IsNullOrEmpty(editService))
|
||||||
|
{
|
||||||
|
<div class="mt-1 small text-warning">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>No endpoints — service has no ready pods.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="d-flex gap-1 mt-2">
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="() => SaveEdit(dr.Id)"
|
||||||
|
disabled="@(string.IsNullOrWhiteSpace(editService) || saving)">
|
||||||
|
@if (saving) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelEdit">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<td class="small align-middle ps-3"><code class="small">@dr.PathPrefix</code></td>
|
||||||
|
<td class="small align-middle">@dr.AppDeployment?.Name</td>
|
||||||
|
<td class="small align-middle text-muted">@dr.AppDeployment?.Environment?.Name</td>
|
||||||
|
<td class="small align-middle text-muted">@dr.ServiceName<span class="text-muted">:@dr.ServicePort</span></td>
|
||||||
|
<td class="small align-middle">@HealthBadge(dr)</td>
|
||||||
|
@if (AccessRole >= CustomerAccessRole.Operator)
|
||||||
|
{
|
||||||
|
<td class="text-end align-middle pe-2" style="white-space: nowrap;">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary py-0"
|
||||||
|
@onclick="() => StartEdit(dr)">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
@if (deleteConfirmId == dr.Id)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-danger py-0 ms-1"
|
||||||
|
@onclick="() => DeleteDeploymentRoute(dr.Id)">Confirm</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary py-0 ms-1"
|
||||||
|
@onclick="() => deleteConfirmId = null">Cancel</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-danger py-0 ms-1"
|
||||||
|
title="Remove this path"
|
||||||
|
@onclick="() => deleteConfirmId = dr.Id">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
else if (AccessRole < CustomerAccessRole.Operator)
|
||||||
|
{
|
||||||
|
<div class="p-3 text-muted small">No paths configured yet.</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ── Add path form ── *@
|
||||||
|
@if (AccessRole >= CustomerAccessRole.Operator)
|
||||||
|
{
|
||||||
|
@if (addingToRouteId == route.Id)
|
||||||
|
{
|
||||||
|
<div class="px-3 py-2 border-top bg-light">
|
||||||
|
@if (addError is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger py-1 small mb-2">@addError</div>
|
||||||
|
}
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small">Deployment</label>
|
||||||
|
<select class="form-select form-select-sm"
|
||||||
|
value="@newDrDeploymentId"
|
||||||
|
@onchange="OnAddDeploymentSelected">
|
||||||
|
<option value="">— select —</option>
|
||||||
|
@foreach (AppDeployment d in (deployments ?? []))
|
||||||
|
{
|
||||||
|
<option value="@d.Id">@d.Name (@d.Environment?.Name)</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small">Path Prefix</label>
|
||||||
|
<input class="form-control form-control-sm" placeholder="/" @bind="newDrPath" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">
|
||||||
|
Service
|
||||||
|
@if (addLoadingServices)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm ms-1" style="width:.7rem;height:.7rem;"></span>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
@if (addAvailableServices.Count > 0)
|
||||||
|
{
|
||||||
|
<select class="form-select form-select-sm"
|
||||||
|
value="@newDrService"
|
||||||
|
@onchange="OnAddServiceSelected">
|
||||||
|
<option value="">— select —</option>
|
||||||
|
@foreach (KubeServiceInfo svc in addAvailableServices)
|
||||||
|
{
|
||||||
|
<option value="@svc.Name">@svc.Name (@svc.Type)</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<input class="form-control form-control-sm" placeholder="my-service"
|
||||||
|
@bind="newDrService" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small">Port</label>
|
||||||
|
@if (addAvailablePorts.Count > 0)
|
||||||
|
{
|
||||||
|
<select class="form-select form-select-sm" @bind="newDrPort">
|
||||||
|
@foreach (KubeServicePort p in addAvailablePorts)
|
||||||
|
{
|
||||||
|
<option value="@p.Port">@p.Port@(p.Name is not null ? $" ({p.Name})" : "")</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<input type="number" class="form-control form-control-sm" @bind="newDrPort" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Endpoint health preview *@
|
||||||
|
@if (addEndpointSummary is not null && addEndpointSummary.HasEndpoints)
|
||||||
|
{
|
||||||
|
<div class="mt-1 small">
|
||||||
|
<span class="text-success me-2">
|
||||||
|
<i class="bi bi-circle-fill me-1" style="font-size:.5rem;vertical-align:middle;"></i>
|
||||||
|
@addEndpointSummary.TotalReady ready
|
||||||
|
</span>
|
||||||
|
@if (addEndpointSummary.TotalNotReady > 0)
|
||||||
|
{
|
||||||
|
<span class="text-warning">
|
||||||
|
<i class="bi bi-circle-fill me-1" style="font-size:.5rem;vertical-align:middle;"></i>
|
||||||
|
@addEndpointSummary.TotalNotReady not ready
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (addEndpointSummary is not null && !addEndpointSummary.HasEndpoints && !string.IsNullOrEmpty(newDrService))
|
||||||
|
{
|
||||||
|
<div class="mt-1 small text-warning">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>No endpoints — service has no ready pods.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="d-flex gap-1 mt-2">
|
||||||
|
<button class="btn btn-sm btn-primary"
|
||||||
|
disabled="@(newDrDeploymentId == Guid.Empty || string.IsNullOrWhiteSpace(newDrService) || adding)"
|
||||||
|
@onclick="() => AddPath(route.Id)">
|
||||||
|
@if (adding) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
Add Path
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelAdd">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="px-3 py-2 border-top">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary"
|
||||||
|
@onclick="() => StartAdd(route.Id)">
|
||||||
|
<i class="bi bi-plus me-1"></i>Add Path
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="alert alert-info small py-2">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
Path changes update the routing configuration. Your platform team will apply the updated HTTPRoute to the cluster.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public required Data.App App { get; set; }
|
||||||
|
[Parameter] public CustomerAccessRole AccessRole { get; set; }
|
||||||
|
|
||||||
|
private List<AppRoute>? routes;
|
||||||
|
private List<AppDeployment>? deployments;
|
||||||
|
|
||||||
|
// Add form
|
||||||
|
private Guid? addingToRouteId;
|
||||||
|
private Guid newDrDeploymentId;
|
||||||
|
private string newDrPath = "/";
|
||||||
|
private string newDrService = "";
|
||||||
|
private int newDrPort = 80;
|
||||||
|
private string? addError;
|
||||||
|
private bool adding;
|
||||||
|
private bool addLoadingServices;
|
||||||
|
private List<KubeServiceInfo> addAvailableServices = [];
|
||||||
|
private List<KubeServicePort> addAvailablePorts = [];
|
||||||
|
private KubeEndpointSummary? addEndpointSummary;
|
||||||
|
|
||||||
|
// Edit form
|
||||||
|
private Guid? editingDrId;
|
||||||
|
private AppDeployment? editingDeployment;
|
||||||
|
private string editPath = "/";
|
||||||
|
private string editService = "";
|
||||||
|
private int editPort = 80;
|
||||||
|
private string? editError;
|
||||||
|
private bool saving;
|
||||||
|
private bool editLoadingServices;
|
||||||
|
private List<KubeServiceInfo> editAvailableServices = [];
|
||||||
|
private List<KubeServicePort> editAvailablePorts = [];
|
||||||
|
private KubeEndpointSummary? editEndpointSummary;
|
||||||
|
|
||||||
|
// Delete confirm
|
||||||
|
private Guid? deleteConfirmId;
|
||||||
|
private string? loadError;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
loadError = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
routes = await AppRouteService.GetRoutesForAppAsync(App.Id);
|
||||||
|
deployments = await DeploymentService.GetDeploymentsAsync(App.Id);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
loadError = ex.Message;
|
||||||
|
routes = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add flow ──
|
||||||
|
|
||||||
|
private async Task StartAdd(Guid routeId)
|
||||||
|
{
|
||||||
|
addingToRouteId = routeId;
|
||||||
|
newDrDeploymentId = Guid.Empty;
|
||||||
|
newDrPath = "/";
|
||||||
|
newDrService = "";
|
||||||
|
newDrPort = 80;
|
||||||
|
addError = null;
|
||||||
|
addAvailableServices = [];
|
||||||
|
addAvailablePorts = [];
|
||||||
|
addEndpointSummary = null;
|
||||||
|
editingDrId = null;
|
||||||
|
|
||||||
|
if (deployments?.Count == 1)
|
||||||
|
await LoadAddServices(deployments[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelAdd()
|
||||||
|
{
|
||||||
|
addingToRouteId = null;
|
||||||
|
addError = null;
|
||||||
|
addAvailableServices = [];
|
||||||
|
addAvailablePorts = [];
|
||||||
|
addEndpointSummary = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnAddDeploymentSelected(ChangeEventArgs e)
|
||||||
|
{
|
||||||
|
addAvailableServices = [];
|
||||||
|
addAvailablePorts = [];
|
||||||
|
addEndpointSummary = null;
|
||||||
|
newDrService = "";
|
||||||
|
|
||||||
|
if (!Guid.TryParse(e.Value?.ToString(), out Guid id) || id == Guid.Empty)
|
||||||
|
{
|
||||||
|
newDrDeploymentId = Guid.Empty;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
newDrDeploymentId = id;
|
||||||
|
AppDeployment? d = deployments?.FirstOrDefault(d => d.Id == id);
|
||||||
|
if (d is not null) await LoadAddServices(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAddServices(AppDeployment d)
|
||||||
|
{
|
||||||
|
if (d.Cluster is null) return;
|
||||||
|
newDrDeploymentId = d.Id;
|
||||||
|
addLoadingServices = true;
|
||||||
|
addAvailableServices = await K8sOps.GetServicesInNamespaceAsync(d.Cluster.Id, d.Namespace);
|
||||||
|
addLoadingServices = false;
|
||||||
|
if (addAvailableServices.Count == 1)
|
||||||
|
await SelectAddService(addAvailableServices[0].Name, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnAddServiceSelected(ChangeEventArgs e)
|
||||||
|
{
|
||||||
|
string name = e.Value?.ToString() ?? "";
|
||||||
|
newDrService = name;
|
||||||
|
addAvailablePorts = [];
|
||||||
|
addEndpointSummary = null;
|
||||||
|
if (string.IsNullOrEmpty(name)) return;
|
||||||
|
AppDeployment? d = deployments?.FirstOrDefault(d => d.Id == newDrDeploymentId);
|
||||||
|
if (d is not null) await SelectAddService(name, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SelectAddService(string name, AppDeployment d)
|
||||||
|
{
|
||||||
|
newDrService = name;
|
||||||
|
KubeServiceInfo? svc = addAvailableServices.FirstOrDefault(s => s.Name == name);
|
||||||
|
addAvailablePorts = svc?.Ports ?? [];
|
||||||
|
if (addAvailablePorts.Count > 0) newDrPort = addAvailablePorts[0].Port;
|
||||||
|
if (d.Cluster is not null)
|
||||||
|
addEndpointSummary = await K8sOps.GetEndpointsForServiceAsync(d.Cluster.Id, d.Namespace, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddPath(Guid routeId)
|
||||||
|
{
|
||||||
|
addError = null;
|
||||||
|
adding = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await AppRouteService.AddDeploymentRouteAsync(routeId, newDrDeploymentId, new AppDeploymentRouteRequest
|
||||||
|
{
|
||||||
|
ServiceName = newDrService,
|
||||||
|
ServicePort = newDrPort,
|
||||||
|
PathPrefix = newDrPath
|
||||||
|
});
|
||||||
|
CancelAdd();
|
||||||
|
routes = await AppRouteService.GetRoutesForAppAsync(App.Id);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
addError = ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
adding = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit flow ──
|
||||||
|
|
||||||
|
private async Task StartEdit(AppDeploymentRoute dr)
|
||||||
|
{
|
||||||
|
editingDrId = dr.Id;
|
||||||
|
editPath = dr.PathPrefix;
|
||||||
|
editService = dr.ServiceName;
|
||||||
|
editPort = dr.ServicePort;
|
||||||
|
editError = null;
|
||||||
|
editAvailableServices = [];
|
||||||
|
editAvailablePorts = [];
|
||||||
|
editEndpointSummary = null;
|
||||||
|
addingToRouteId = null;
|
||||||
|
|
||||||
|
editingDeployment = deployments?.FirstOrDefault(d => d.Id == dr.AppDeploymentId);
|
||||||
|
if (editingDeployment?.Cluster is not null)
|
||||||
|
{
|
||||||
|
editLoadingServices = true;
|
||||||
|
editAvailableServices = await K8sOps.GetServicesInNamespaceAsync(
|
||||||
|
editingDeployment.Cluster.Id, editingDeployment.Namespace);
|
||||||
|
editLoadingServices = false;
|
||||||
|
|
||||||
|
KubeServiceInfo? svc = editAvailableServices.FirstOrDefault(s => s.Name == editService);
|
||||||
|
editAvailablePorts = svc?.Ports ?? [];
|
||||||
|
editEndpointSummary = await K8sOps.GetEndpointsForServiceAsync(
|
||||||
|
editingDeployment.Cluster.Id, editingDeployment.Namespace, editService);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelEdit()
|
||||||
|
{
|
||||||
|
editingDrId = null;
|
||||||
|
editingDeployment = null;
|
||||||
|
editError = null;
|
||||||
|
editAvailableServices = [];
|
||||||
|
editAvailablePorts = [];
|
||||||
|
editEndpointSummary = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnEditServiceSelected(ChangeEventArgs e)
|
||||||
|
{
|
||||||
|
string name = e.Value?.ToString() ?? "";
|
||||||
|
editService = name;
|
||||||
|
editAvailablePorts = [];
|
||||||
|
editEndpointSummary = null;
|
||||||
|
if (string.IsNullOrEmpty(name) || editingDeployment?.Cluster is null) return;
|
||||||
|
|
||||||
|
KubeServiceInfo? svc = editAvailableServices.FirstOrDefault(s => s.Name == name);
|
||||||
|
editAvailablePorts = svc?.Ports ?? [];
|
||||||
|
if (editAvailablePorts.Count > 0) editPort = editAvailablePorts[0].Port;
|
||||||
|
editEndpointSummary = await K8sOps.GetEndpointsForServiceAsync(
|
||||||
|
editingDeployment.Cluster.Id, editingDeployment.Namespace, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveEdit(Guid drId)
|
||||||
|
{
|
||||||
|
editError = null;
|
||||||
|
saving = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await AppRouteService.UpdateDeploymentRouteAsync(drId, new AppDeploymentRouteRequest
|
||||||
|
{
|
||||||
|
ServiceName = editService,
|
||||||
|
ServicePort = editPort,
|
||||||
|
PathPrefix = editPath
|
||||||
|
});
|
||||||
|
CancelEdit();
|
||||||
|
routes = await AppRouteService.GetRoutesForAppAsync(App.Id);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
editError = ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteDeploymentRoute(Guid drId)
|
||||||
|
{
|
||||||
|
await AppRouteService.DeleteDeploymentRouteAsync(drId);
|
||||||
|
deleteConfirmId = null;
|
||||||
|
routes = await AppRouteService.GetRoutesForAppAsync(App.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RenderFragment TlsBadge(AppRoute route) => __builder =>
|
||||||
|
{
|
||||||
|
if (route.TlsMode == TlsMode.ClusterIssuer)
|
||||||
|
{
|
||||||
|
__builder.OpenElement(0, "span");
|
||||||
|
__builder.AddAttribute(1, "class", "badge bg-success bg-opacity-75 small");
|
||||||
|
__builder.AddAttribute(2, "title", $"Auto TLS — {route.ClusterIssuerName}");
|
||||||
|
__builder.AddContent(3, $"TLS auto · {route.ClusterIssuerName}");
|
||||||
|
__builder.CloseElement();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
__builder.OpenElement(0, "span");
|
||||||
|
__builder.AddAttribute(1, "class", "badge bg-warning text-dark small");
|
||||||
|
__builder.AddAttribute(2, "title", "Manual TLS certificate");
|
||||||
|
__builder.AddContent(3, "TLS manual");
|
||||||
|
__builder.CloseElement();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static RenderFragment HealthBadge(AppDeploymentRoute dr) => __builder =>
|
||||||
|
{
|
||||||
|
if (dr.IsReachable == true)
|
||||||
|
{
|
||||||
|
__builder.OpenElement(0, "span");
|
||||||
|
__builder.AddAttribute(1, "class", "badge bg-success bg-opacity-75 small");
|
||||||
|
__builder.AddContent(2, $"✓ {dr.LastStatusCode}");
|
||||||
|
__builder.CloseElement();
|
||||||
|
}
|
||||||
|
else if (dr.IsReachable == false)
|
||||||
|
{
|
||||||
|
__builder.OpenElement(0, "span");
|
||||||
|
__builder.AddAttribute(1, "class", "badge bg-danger bg-opacity-75 small");
|
||||||
|
__builder.AddContent(2, dr.LastStatusCode.HasValue ? $"✗ {dr.LastStatusCode}" : "✗ unreachable");
|
||||||
|
__builder.CloseElement();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
__builder.OpenElement(0, "span");
|
||||||
|
__builder.AddAttribute(1, "class", "badge bg-secondary bg-opacity-50 small");
|
||||||
|
__builder.AddContent(2, "not checked");
|
||||||
|
__builder.CloseElement();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,11 +2,13 @@
|
|||||||
@using EntKube.Web.Services
|
@using EntKube.Web.Services
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@implements IAsyncDisposable
|
@implements IAsyncDisposable
|
||||||
|
@inject IJSRuntime JS
|
||||||
@inject IncidentService IncidentService
|
@inject IncidentService IncidentService
|
||||||
@inject LokiService LokiService
|
@inject LokiService LokiService
|
||||||
@inject GitSyncService GitSyncService
|
@inject GitSyncService GitSyncService
|
||||||
@inject CustomerGitService CustomerGitService
|
@inject CustomerGitService CustomerGitService
|
||||||
@inject IDbContextFactory<ApplicationDbContext> DbFactory
|
@inject IDbContextFactory<ApplicationDbContext> DbFactory
|
||||||
|
@inject DeploymentStatusNotifier StatusNotifier
|
||||||
|
|
||||||
@* ═══════════════════════════════════════════════════════════════════
|
@* ═══════════════════════════════════════════════════════════════════
|
||||||
Deployment operations view — the heart of the customer portal.
|
Deployment operations view — the heart of the customer portal.
|
||||||
@@ -134,13 +136,13 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!showEditDeployment && Deployment.GitRepositoryId is not null)
|
@if (!showEditDeployment && Deployment.GitUrl is not null)
|
||||||
{
|
{
|
||||||
<div class="mt-2 p-2 bg-light rounded d-flex align-items-center justify-content-between flex-wrap gap-2">
|
<div class="mt-2 p-2 bg-light rounded d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||||
<div class="small text-muted">
|
<div class="small text-muted">
|
||||||
<i class="bi bi-git me-1"></i>
|
<i class="bi bi-git me-1"></i>
|
||||||
<strong>@Deployment.GitRepository?.Name</strong>
|
<strong class="font-monospace">@Deployment.GitUrl</strong>
|
||||||
<span class="ms-1">@(Deployment.GitRevision ?? Deployment.GitRepository?.DefaultBranch)</span>
|
<span class="ms-1">@(Deployment.GitRevision ?? "main")</span>
|
||||||
@if (!string.IsNullOrEmpty(Deployment.GitPath) && Deployment.GitPath != ".")
|
@if (!string.IsNullOrEmpty(Deployment.GitPath) && Deployment.GitPath != ".")
|
||||||
{
|
{
|
||||||
<span class="ms-1">→ <code>@Deployment.GitPath</code></span>
|
<span class="ms-1">→ <code>@Deployment.GitPath</code></span>
|
||||||
@@ -257,15 +259,49 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@* --- Operation buttons (role-gated) --- *@
|
@* --- Operation bar (refresh always visible; destructive ops gated to Operator+) --- *@
|
||||||
@if (AccessRole >= CustomerAccessRole.Operator)
|
<div class="d-flex align-items-center gap-2 mb-3 flex-wrap">
|
||||||
{
|
@if (AccessRole >= CustomerAccessRole.Operator)
|
||||||
<div class="d-flex gap-2 mb-3">
|
{
|
||||||
<button class="btn btn-sm btn-outline-warning" @onclick="RestartAllDeployments" disabled="@operationInProgress">
|
<button class="btn btn-sm btn-outline-warning" @onclick="RestartAllDeployments" disabled="@operationInProgress">
|
||||||
<i class="bi bi-arrow-repeat me-1"></i>Restart Deployment
|
@if (operationInProgress) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
else { <i class="bi bi-arrow-repeat me-1"></i> }
|
||||||
|
Restart
|
||||||
</button>
|
</button>
|
||||||
</div>
|
@if (AccessRole >= CustomerAccessRole.Admin)
|
||||||
}
|
{
|
||||||
|
<div class="d-flex align-items-center gap-1">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" title="Decrease replicas"
|
||||||
|
@onclick="() => _scaleReplicas = Math.Max(0, _scaleReplicas - 1)"
|
||||||
|
disabled="@operationInProgress">
|
||||||
|
<i class="bi bi-dash"></i>
|
||||||
|
</button>
|
||||||
|
<input type="number" class="form-control form-control-sm text-center" style="width: 58px;"
|
||||||
|
min="0" max="99" @bind="_scaleReplicas" disabled="@operationInProgress" />
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" title="Increase replicas"
|
||||||
|
@onclick="() => _scaleReplicas++"
|
||||||
|
disabled="@operationInProgress">
|
||||||
|
<i class="bi bi-plus"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" @onclick="ScaleMainDeployment"
|
||||||
|
disabled="@operationInProgress" title="Scale workloads">
|
||||||
|
<i class="bi bi-sliders me-1"></i>Scale
|
||||||
|
</button>
|
||||||
|
@if (pods is not null)
|
||||||
|
{
|
||||||
|
int running = pods.Count(p => p.Status == "Running");
|
||||||
|
<span class="text-muted small" title="Currently running pods">
|
||||||
|
(@running running)
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<button class="btn btn-sm btn-outline-secondary @(AccessRole >= CustomerAccessRole.Operator ? "ms-auto" : "")"
|
||||||
|
title="Refresh pods" @onclick="LoadPodsInternal" disabled="@podsLoading">
|
||||||
|
<i class="bi bi-arrow-clockwise @(podsLoading ? "rg-spin" : "")"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<LoadingPanel Loading="@podsLoading" LoadingText="Loading pods from cluster…" Error="@podsError">
|
<LoadingPanel Loading="@podsLoading" LoadingText="Loading pods from cluster…" Error="@podsError">
|
||||||
@if (!podsLoading && string.IsNullOrEmpty(podsError) && (pods is null || pods.Count == 0))
|
@if (!podsLoading && string.IsNullOrEmpty(podsError) && (pods is null || pods.Count == 0))
|
||||||
@@ -279,7 +315,7 @@
|
|||||||
@if (showLogViewer)
|
@if (showLogViewer)
|
||||||
{
|
{
|
||||||
<div class="card border-dark mb-3">
|
<div class="card border-dark mb-3">
|
||||||
<div class="card-header bg-dark text-white py-2 d-flex align-items-center justify-content-between">
|
<div class="card-header bg-dark text-white py-2 d-flex align-items-center gap-2">
|
||||||
<div>
|
<div>
|
||||||
<i class="bi bi-terminal me-1"></i>
|
<i class="bi bi-terminal me-1"></i>
|
||||||
Logs: <strong>@logPodName</strong>
|
Logs: <strong>@logPodName</strong>
|
||||||
@@ -288,9 +324,24 @@
|
|||||||
<span class="text-muted ms-1">(@logContainerName)</span>
|
<span class="text-muted ms-1">(@logContainerName)</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-sm btn-outline-light" @onclick="CloseLogViewer">
|
<div class="d-flex gap-1 ms-auto align-items-center">
|
||||||
<i class="bi bi-x"></i>
|
@foreach (int tl in new[] { 100, 500, 1000 })
|
||||||
</button>
|
{
|
||||||
|
int tlCopy = tl;
|
||||||
|
<button class="btn btn-sm py-0 px-2 @(_logTailLines == tlCopy ? "btn-light" : "btn-outline-secondary")"
|
||||||
|
style="font-size:.72rem;" title="@(tlCopy) lines"
|
||||||
|
@onclick="() => { _logTailLines = tlCopy; _ = ViewLogs(logPodName, logContainerName); }">@tlCopy</button>
|
||||||
|
}
|
||||||
|
<div class="vr opacity-50 mx-1"></div>
|
||||||
|
<button class="btn btn-sm btn-outline-light" title="Refresh logs"
|
||||||
|
@onclick="() => ViewLogs(logPodName, logContainerName)"
|
||||||
|
disabled="@logLoading">
|
||||||
|
<i class="bi bi-arrow-clockwise @(logLoading ? "rg-spin" : "")"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-light" @onclick="CloseLogViewer">
|
||||||
|
<i class="bi bi-x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
@if (logLoading)
|
@if (logLoading)
|
||||||
@@ -305,14 +356,42 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<pre class="mb-0 p-2 small bg-dark text-light" style="max-height: 400px; overflow-y: auto; white-space: pre-wrap; word-break: break-all;">@logContent</pre>
|
<pre @ref="_logPreRef" class="mb-0 p-2 small bg-dark text-light" style="max-height: 400px; overflow-y: auto; white-space: pre-wrap; word-break: break-all;">@logContent</pre>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@* --- Pod filter --- *@
|
||||||
|
<div class="d-flex align-items-center gap-2 flex-wrap mb-2">
|
||||||
|
<div class="input-group input-group-sm flex-nowrap" style="max-width:220px;">
|
||||||
|
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||||
|
<input type="text" class="form-control" placeholder="Filter pods…"
|
||||||
|
@bind="_podFilter" @bind:event="oninput" />
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_podFilter))
|
||||||
|
{
|
||||||
|
<button class="btn btn-outline-secondary" type="button" @onclick="() => _podFilter = string.Empty">
|
||||||
|
<i class="bi bi-x"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@foreach (string st in new[] { "All", "Running", "Pending", "Failed" })
|
||||||
|
{
|
||||||
|
string stCopy = st;
|
||||||
|
int cnt = stCopy == "All" ? pods.Count : pods.Count(p => p.Status == stCopy);
|
||||||
|
<button class="btn btn-sm @(_podStatusFilter == stCopy ? "btn-secondary" : "btn-outline-secondary")"
|
||||||
|
@onclick="() => _podStatusFilter = stCopy">
|
||||||
|
@stCopy@(cnt > 0 ? $" ({cnt})" : "")
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
@* --- Pod cards --- *@
|
@* --- Pod cards --- *@
|
||||||
@foreach (PodInfo pod in pods)
|
@if (!FilteredPods.Any())
|
||||||
|
{
|
||||||
|
<EmptyState Icon="bi-grid" Message="No pods match the current filter." />
|
||||||
|
}
|
||||||
|
@foreach (PodInfo pod in FilteredPods)
|
||||||
{
|
{
|
||||||
<div class="card mb-2">
|
<div class="card mb-2">
|
||||||
<div class="card-body py-2">
|
<div class="card-body py-2">
|
||||||
@@ -325,7 +404,14 @@
|
|||||||
<span class="text-muted small ms-2">@pod.ReadyContainers/@pod.TotalContainers ready</span>
|
<span class="text-muted small ms-2">@pod.ReadyContainers/@pod.TotalContainers ready</span>
|
||||||
@if (pod.Restarts > 0)
|
@if (pod.Restarts > 0)
|
||||||
{
|
{
|
||||||
<span class="badge bg-warning text-dark ms-1">@pod.Restarts restarts</span>
|
<span class="badge @RestartBadgeClass(pod.Restarts) ms-1">@pod.Restarts restarts</span>
|
||||||
|
}
|
||||||
|
@if (pod.StartTime.HasValue)
|
||||||
|
{
|
||||||
|
<span class="text-muted small ms-2"
|
||||||
|
title="@pod.StartTime.Value.ToLocalTime().ToString("g")">
|
||||||
|
@PodAge(pod.StartTime.Value)
|
||||||
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-1">
|
<div class="d-flex gap-1">
|
||||||
@@ -393,7 +479,7 @@
|
|||||||
<div class="d-flex align-items-center text-muted small mb-1">
|
<div class="d-flex align-items-center text-muted small mb-1">
|
||||||
<i class="bi bi-box me-1 @(container.Ready ? "text-success" : "text-warning")"></i>
|
<i class="bi bi-box me-1 @(container.Ready ? "text-success" : "text-warning")"></i>
|
||||||
<span class="fw-medium me-2">@container.Name</span>
|
<span class="fw-medium me-2">@container.Name</span>
|
||||||
<span class="me-2">@container.Image</span>
|
<span class="me-2 font-monospace" title="@container.Image">@TruncateImage(container.Image)</span>
|
||||||
<span class="badge @(container.Ready ? "bg-success" : "bg-warning text-dark")">
|
<span class="badge @(container.Ready ? "bg-success" : "bg-warning text-dark")">
|
||||||
@container.State
|
@container.State
|
||||||
</span>
|
</span>
|
||||||
@@ -406,12 +492,6 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (pod.StartTime.HasValue)
|
|
||||||
{
|
|
||||||
<div class="text-muted small mt-1 ms-4">
|
|
||||||
<i class="bi bi-clock me-1"></i>Started @pod.StartTime.Value.ToString("g")
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -686,7 +766,7 @@
|
|||||||
}
|
}
|
||||||
else if (auditEvents is not null && auditEvents.Count == 0)
|
else if (auditEvents is not null && auditEvents.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="text-muted small py-3 text-center">No activity recorded yet.</div>
|
<EmptyState Icon="bi-clock-history" Message="No activity recorded yet." />
|
||||||
}
|
}
|
||||||
else if (auditEvents is not null)
|
else if (auditEvents is not null)
|
||||||
{
|
{
|
||||||
@@ -921,18 +1001,18 @@
|
|||||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showGitEdit = false">Cancel</button>
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showGitEdit = false">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else if (Deployment.GitRepositoryId is not null)
|
else if (Deployment.GitUrl is not null)
|
||||||
{
|
{
|
||||||
@* ── Current settings (read) ── *@
|
@* ── Current settings (read) ── *@
|
||||||
<dl class="row small mb-0">
|
<dl class="row small mb-0">
|
||||||
<dt class="col-sm-3">Repository</dt>
|
<dt class="col-sm-3">Repository</dt>
|
||||||
<dd class="col-sm-9 font-monospace">@(Deployment.GitRepository?.Url ?? "—")</dd>
|
<dd class="col-sm-9 font-monospace">@Deployment.GitUrl</dd>
|
||||||
|
|
||||||
<dt class="col-sm-3">Path</dt>
|
<dt class="col-sm-3">Path</dt>
|
||||||
<dd class="col-sm-9 font-monospace">@(Deployment.GitPath ?? ".")</dd>
|
<dd class="col-sm-9 font-monospace">@(Deployment.GitPath ?? ".")</dd>
|
||||||
|
|
||||||
<dt class="col-sm-3">Revision</dt>
|
<dt class="col-sm-3">Revision</dt>
|
||||||
<dd class="col-sm-9 font-monospace">@(Deployment.GitRevision ?? Deployment.GitRepository?.DefaultBranch ?? "main")</dd>
|
<dd class="col-sm-9 font-monospace">@(Deployment.GitRevision ?? "main")</dd>
|
||||||
|
|
||||||
<dt class="col-sm-3">Auto-sync</dt>
|
<dt class="col-sm-3">Auto-sync</dt>
|
||||||
<dd class="col-sm-9">
|
<dd class="col-sm-9">
|
||||||
@@ -994,6 +1074,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.rg-spin { animation: spin .7s linear infinite; }
|
||||||
|
</style>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public required AppDeployment Deployment { get; set; }
|
[Parameter] public required AppDeployment Deployment { get; set; }
|
||||||
[Parameter] public CustomerAccessRole AccessRole { get; set; }
|
[Parameter] public CustomerAccessRole AccessRole { get; set; }
|
||||||
@@ -1003,6 +1088,7 @@
|
|||||||
[Parameter] public required AuditService AuditService { get; set; }
|
[Parameter] public required AuditService AuditService { get; set; }
|
||||||
[Parameter] public Guid CustomerId { get; set; }
|
[Parameter] public Guid CustomerId { get; set; }
|
||||||
[Parameter] public Guid TenantId { get; set; }
|
[Parameter] public Guid TenantId { get; set; }
|
||||||
|
[Parameter] public Guid EnvironmentId { get; set; }
|
||||||
[Parameter] public string? GovernanceNamespace { get; set; }
|
[Parameter] public string? GovernanceNamespace { get; set; }
|
||||||
[Parameter] public EventCallback OnBack { get; set; }
|
[Parameter] public EventCallback OnBack { get; set; }
|
||||||
[Parameter] public EventCallback OnDeleted { get; set; }
|
[Parameter] public EventCallback OnDeleted { get; set; }
|
||||||
@@ -1035,16 +1121,21 @@
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
GitSyncResult result = await GitSyncService.SyncDeploymentAsync(Deployment.Id);
|
GitSyncResult result = await GitSyncService.SyncDeploymentAsync(Deployment.Id);
|
||||||
gitSyncSuccess = result.IsSuccess;
|
gitSyncSuccess = result.IsSuccess && result.ApplySuccess;
|
||||||
gitSyncMessage = result.AlreadyCurrent
|
if (result.AlreadyCurrent)
|
||||||
? "Already up to date."
|
gitSyncMessage = "Already up to date.";
|
||||||
: result.IsSuccess
|
else if (!result.IsSuccess)
|
||||||
? $"Synced to {result.CommitSha[..Math.Min(7, result.CommitSha.Length)]} — {result.CommitMessage}"
|
gitSyncMessage = result.Error ?? "Sync failed.";
|
||||||
: result.Error ?? "Sync failed.";
|
else if (!result.ApplySuccess)
|
||||||
|
gitSyncMessage = $"Synced to {result.CommitSha[..Math.Min(7, result.CommitSha.Length)]} but cluster apply failed: {result.ApplyOutput}";
|
||||||
|
else
|
||||||
|
gitSyncMessage = $"Synced and applied to cluster — {result.CommitSha[..Math.Min(7, result.CommitSha.Length)]} {result.CommitMessage}";
|
||||||
if (result.IsSuccess)
|
if (result.IsSuccess)
|
||||||
{
|
{
|
||||||
Deployment.GitLastSyncedCommit = result.CommitSha;
|
Deployment.GitLastSyncedCommit = result.CommitSha;
|
||||||
Deployment.GitLastSyncedAt = DateTime.UtcNow;
|
Deployment.GitLastSyncedAt = DateTime.UtcNow;
|
||||||
|
if (manifests is not null)
|
||||||
|
manifests = await DeploymentService.GetManifestsAsync(Deployment.Id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex) { gitSyncSuccess = false; gitSyncMessage = ex.Message; }
|
catch (Exception ex) { gitSyncSuccess = false; gitSyncMessage = ex.Message; }
|
||||||
@@ -1056,16 +1147,16 @@
|
|||||||
private async Task LoadGitTab()
|
private async Task LoadGitTab()
|
||||||
{
|
{
|
||||||
activeTab = "git";
|
activeTab = "git";
|
||||||
if (gitTabPolicies.Count == 0 && CustomerId != Guid.Empty)
|
if (gitTabPolicies.Count == 0 && CustomerId != Guid.Empty && EnvironmentId != Guid.Empty)
|
||||||
{
|
{
|
||||||
gitTabPolicies = await CustomerGitService.GetRepoPoliciesAsync(CustomerId);
|
gitTabPolicies = await CustomerGitService.GetRepoPoliciesAsync(CustomerId, EnvironmentId);
|
||||||
gitTabCredentials = await CustomerGitService.GetCredentialsAsync(CustomerId);
|
gitTabCredentials = await CustomerGitService.GetCredentialsAsync(CustomerId, EnvironmentId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void StartGitEdit()
|
private void StartGitEdit()
|
||||||
{
|
{
|
||||||
gitEditUrl = Deployment.GitRepository?.Url ?? "";
|
gitEditUrl = Deployment.GitUrl ?? "";
|
||||||
gitEditPath = Deployment.GitPath ?? "";
|
gitEditPath = Deployment.GitPath ?? "";
|
||||||
gitEditRevision = Deployment.GitRevision ?? "";
|
gitEditRevision = Deployment.GitRevision ?? "";
|
||||||
gitEditAutoSync = Deployment.GitAutoSync;
|
gitEditAutoSync = Deployment.GitAutoSync;
|
||||||
@@ -1100,40 +1191,16 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Guid? repoId = null;
|
string? resolvedUrl = string.IsNullOrWhiteSpace(gitEditUrl) ? null : gitEditUrl.Trim();
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(gitEditUrl))
|
|
||||||
{
|
|
||||||
CustomerGitCredential? cred = gitTabCredentials.FirstOrDefault();
|
|
||||||
if (cred is not null)
|
|
||||||
{
|
|
||||||
GitRepository repo = await CustomerGitService.FindOrCreateRepositoryForCustomerAsync(
|
|
||||||
TenantId, CustomerId, gitEditUrl, cred.Id);
|
|
||||||
repoId = repo.Id;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// No customer credential — check if existing repo already has this URL.
|
|
||||||
using ApplicationDbContext db = DbFactory.CreateDbContext();
|
|
||||||
GitRepository? existing = await db.GitRepositories
|
|
||||||
.FirstOrDefaultAsync(r => r.TenantId == TenantId && r.Url == gitEditUrl);
|
|
||||||
repoId = existing?.Id;
|
|
||||||
if (repoId is null)
|
|
||||||
{
|
|
||||||
gitEditError = "No stored credential found. Ask your administrator to add a git credential for your account.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await DeploymentService.UpdateGitSettingsAsync(
|
await DeploymentService.UpdateGitSettingsAsync(
|
||||||
Deployment.Id, repoId,
|
Deployment.Id, resolvedUrl,
|
||||||
string.IsNullOrWhiteSpace(gitEditPath) ? null : gitEditPath,
|
string.IsNullOrWhiteSpace(gitEditPath) ? null : gitEditPath,
|
||||||
string.IsNullOrWhiteSpace(gitEditRevision) ? null : gitEditRevision,
|
string.IsNullOrWhiteSpace(gitEditRevision) ? null : gitEditRevision,
|
||||||
gitEditAutoSync);
|
gitEditAutoSync);
|
||||||
|
|
||||||
// Update local state so the UI reflects the change without reload.
|
// Update local state so the UI reflects the change without reload.
|
||||||
Deployment.GitRepositoryId = repoId;
|
Deployment.GitUrl = resolvedUrl;
|
||||||
Deployment.GitPath = string.IsNullOrWhiteSpace(gitEditPath) ? null : gitEditPath;
|
Deployment.GitPath = string.IsNullOrWhiteSpace(gitEditPath) ? null : gitEditPath;
|
||||||
Deployment.GitRevision = string.IsNullOrWhiteSpace(gitEditRevision) ? null : gitEditRevision;
|
Deployment.GitRevision = string.IsNullOrWhiteSpace(gitEditRevision) ? null : gitEditRevision;
|
||||||
Deployment.GitAutoSync = gitEditAutoSync;
|
Deployment.GitAutoSync = gitEditAutoSync;
|
||||||
@@ -1162,6 +1229,8 @@
|
|||||||
private string? operationMessage;
|
private string? operationMessage;
|
||||||
private bool operationSuccess;
|
private bool operationSuccess;
|
||||||
private string? confirmDeletePod;
|
private string? confirmDeletePod;
|
||||||
|
private int _scaleReplicas = 1;
|
||||||
|
private bool _scaleInitialized;
|
||||||
|
|
||||||
// Logs
|
// Logs
|
||||||
private bool showLogViewer;
|
private bool showLogViewer;
|
||||||
@@ -1170,6 +1239,25 @@
|
|||||||
private string? logContainerName;
|
private string? logContainerName;
|
||||||
private string logContent = "";
|
private string logContent = "";
|
||||||
private string? logError;
|
private string? logError;
|
||||||
|
private int _logTailLines = 500;
|
||||||
|
private ElementReference _logPreRef;
|
||||||
|
private bool _pendingLogScroll;
|
||||||
|
|
||||||
|
private string _podFilter = "";
|
||||||
|
private string _podStatusFilter = "All";
|
||||||
|
|
||||||
|
private IEnumerable<PodInfo> FilteredPods
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
IEnumerable<PodInfo> result = pods ?? [];
|
||||||
|
if (!string.IsNullOrWhiteSpace(_podFilter))
|
||||||
|
result = result.Where(p => p.Name.Contains(_podFilter, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (_podStatusFilter != "All")
|
||||||
|
result = result.Where(p => p.Status == _podStatusFilter);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Manifests
|
// Manifests
|
||||||
private List<DeploymentManifest>? manifests;
|
private List<DeploymentManifest>? manifests;
|
||||||
@@ -1213,6 +1301,8 @@
|
|||||||
private List<DeploymentResource>? resourceTree;
|
private List<DeploymentResource>? resourceTree;
|
||||||
private string? resourcesError;
|
private string? resourcesError;
|
||||||
private DateTime? _resourcesRefreshedAt;
|
private DateTime? _resourcesRefreshedAt;
|
||||||
|
private PeriodicTimer? _resourceTimer;
|
||||||
|
private CancellationTokenSource? _resourceTimerCts;
|
||||||
|
|
||||||
// Metrics
|
// Metrics
|
||||||
private DeploymentMetricsSummary? metrics;
|
private DeploymentMetricsSummary? metrics;
|
||||||
@@ -1233,15 +1323,39 @@
|
|||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
|
StatusNotifier.OnStatusChanged += HandleDeploymentStatusChanged;
|
||||||
await LoadPodsInternal();
|
await LoadPodsInternal();
|
||||||
}
|
}
|
||||||
|
|
||||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
private void HandleDeploymentStatusChanged(Guid deploymentId, SyncStatus sync, HealthStatus health)
|
||||||
|
{
|
||||||
|
if (deploymentId != Deployment.Id) return;
|
||||||
|
Deployment.SyncStatus = sync;
|
||||||
|
Deployment.HealthStatus = health;
|
||||||
|
InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
StatusNotifier.OnStatusChanged -= HandleDeploymentStatusChanged;
|
||||||
|
StopResourceRefresh();
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopResourceRefresh()
|
||||||
|
{
|
||||||
|
_resourceTimerCts?.Cancel();
|
||||||
|
_resourceTimerCts?.Dispose();
|
||||||
|
_resourceTimerCts = null;
|
||||||
|
_resourceTimer?.Dispose();
|
||||||
|
_resourceTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
// ──────── Pod operations ────────
|
// ──────── Pod operations ────────
|
||||||
|
|
||||||
private async Task LoadPods()
|
private async Task LoadPods()
|
||||||
{
|
{
|
||||||
|
StopResourceRefresh();
|
||||||
activeTab = "pods";
|
activeTab = "pods";
|
||||||
await LoadPodsInternal();
|
await LoadPodsInternal();
|
||||||
}
|
}
|
||||||
@@ -1259,6 +1373,12 @@
|
|||||||
{
|
{
|
||||||
pods = result.Data ?? [];
|
pods = result.Data ?? [];
|
||||||
|
|
||||||
|
if (!_scaleInitialized && pods.Count > 0)
|
||||||
|
{
|
||||||
|
_scaleReplicas = pods.Count;
|
||||||
|
_scaleInitialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Derive and persist live status from the pod list so the badges
|
// Derive and persist live status from the pod list so the badges
|
||||||
// reflect actual cluster state rather than the DB default (Unknown).
|
// reflect actual cluster state rather than the DB default (Unknown).
|
||||||
(SyncStatus sync, HealthStatus health) =
|
(SyncStatus sync, HealthStatus health) =
|
||||||
@@ -1287,11 +1407,12 @@
|
|||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
|
|
||||||
KubernetesOperationResult<string> result = await K8sOps.GetPodLogsAsync(
|
KubernetesOperationResult<string> result = await K8sOps.GetPodLogsAsync(
|
||||||
Deployment.Id, podName, containerName);
|
Deployment.Id, podName, containerName, _logTailLines);
|
||||||
|
|
||||||
if (result.IsSuccess)
|
if (result.IsSuccess)
|
||||||
{
|
{
|
||||||
logContent = result.Data ?? "(empty)";
|
logContent = result.Data ?? "(empty)";
|
||||||
|
_pendingLogScroll = true;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -1301,6 +1422,15 @@
|
|||||||
logLoading = false;
|
logLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (_pendingLogScroll)
|
||||||
|
{
|
||||||
|
_pendingLogScroll = false;
|
||||||
|
try { await JS.InvokeVoidAsync("scrollToBottom", _logPreRef); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void CloseLogViewer()
|
private void CloseLogViewer()
|
||||||
{
|
{
|
||||||
showLogViewer = false;
|
showLogViewer = false;
|
||||||
@@ -1340,13 +1470,11 @@
|
|||||||
|
|
||||||
if (failures.Count == 0)
|
if (failures.Count == 0)
|
||||||
{
|
{
|
||||||
operationMessage = $"Rolling restart triggered for {deploymentNames.Count} deployment(s).";
|
SetOperationMessage($"Rolling restart triggered for {deploymentNames.Count} deployment(s).", true);
|
||||||
operationSuccess = true;
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
operationMessage = $"Some restarts failed: {string.Join("; ", failures)}";
|
SetOperationMessage($"Some restarts failed: {string.Join("; ", failures)}", false);
|
||||||
operationSuccess = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
operationInProgress = false;
|
operationInProgress = false;
|
||||||
@@ -1356,6 +1484,41 @@
|
|||||||
await LoadPodsInternal();
|
await LoadPodsInternal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ScaleMainDeployment()
|
||||||
|
{
|
||||||
|
operationInProgress = true;
|
||||||
|
operationMessage = null;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
List<DeploymentResource> tree = await DeploymentService.GetResourceTreeAsync(Deployment.Id);
|
||||||
|
List<(string Kind, string Name)> targets = tree
|
||||||
|
.Where(r => r.Kind is "Deployment" or "StatefulSet" or "ReplicaSet")
|
||||||
|
.Select(r => (r.Kind, r.Name))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (targets.Count == 0)
|
||||||
|
targets.Add(("Deployment", Deployment.Name));
|
||||||
|
|
||||||
|
List<string> failures = [];
|
||||||
|
|
||||||
|
foreach ((string kind, string name) in targets)
|
||||||
|
{
|
||||||
|
KubernetesOperationResult result = await K8sOps.ScaleWorkloadAsync(
|
||||||
|
Deployment.Id, kind, name, _scaleReplicas);
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
failures.Add($"{name}: {result.Error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.Count == 0)
|
||||||
|
SetOperationMessage($"Scaled to {_scaleReplicas} replica{(_scaleReplicas == 1 ? "" : "s")}.", true);
|
||||||
|
else
|
||||||
|
SetOperationMessage($"Scale failed: {string.Join("; ", failures)}", false);
|
||||||
|
|
||||||
|
operationInProgress = false;
|
||||||
|
await Task.Delay(2000);
|
||||||
|
await LoadPodsInternal();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task DeletePod(string podName)
|
private async Task DeletePod(string podName)
|
||||||
{
|
{
|
||||||
operationInProgress = true;
|
operationInProgress = true;
|
||||||
@@ -1364,16 +1527,11 @@
|
|||||||
|
|
||||||
KubernetesOperationResult result = await K8sOps.DeletePodAsync(Deployment.Id, podName);
|
KubernetesOperationResult result = await K8sOps.DeletePodAsync(Deployment.Id, podName);
|
||||||
|
|
||||||
if (result.IsSuccess)
|
SetOperationMessage(
|
||||||
{
|
result.IsSuccess
|
||||||
operationMessage = $"Pod '{podName}' deleted. Kubernetes will create a replacement.";
|
? $"Pod '{podName}' deleted. Kubernetes will create a replacement."
|
||||||
operationSuccess = true;
|
: result.Error,
|
||||||
}
|
result.IsSuccess);
|
||||||
else
|
|
||||||
{
|
|
||||||
operationMessage = result.Error;
|
|
||||||
operationSuccess = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
operationInProgress = false;
|
operationInProgress = false;
|
||||||
|
|
||||||
@@ -1461,6 +1619,7 @@
|
|||||||
|
|
||||||
private async Task LoadManifests()
|
private async Task LoadManifests()
|
||||||
{
|
{
|
||||||
|
StopResourceRefresh();
|
||||||
activeTab = "manifests";
|
activeTab = "manifests";
|
||||||
yamlApplyOutput = null;
|
yamlApplyOutput = null;
|
||||||
yamlApplySuccess = null;
|
yamlApplySuccess = null;
|
||||||
@@ -1575,11 +1734,20 @@
|
|||||||
|
|
||||||
private async Task LoadResources()
|
private async Task LoadResources()
|
||||||
{
|
{
|
||||||
|
StopResourceRefresh();
|
||||||
activeTab = "resources";
|
activeTab = "resources";
|
||||||
resourceTree = null; // trigger spinner
|
resourceTree = null;
|
||||||
resourcesError = null;
|
resourcesError = null;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
|
|
||||||
|
await RefreshResourcesAsync();
|
||||||
|
|
||||||
|
_resourceTimerCts = new CancellationTokenSource();
|
||||||
|
_ = RunResourceAutoRefreshAsync(_resourceTimerCts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RefreshResourcesAsync()
|
||||||
|
{
|
||||||
KubernetesOperationResult<List<DeploymentResource>> result =
|
KubernetesOperationResult<List<DeploymentResource>> result =
|
||||||
await K8sOps.GetLiveResourcesAsync(Deployment.Id);
|
await K8sOps.GetLiveResourcesAsync(Deployment.Id);
|
||||||
|
|
||||||
@@ -1588,7 +1756,6 @@
|
|||||||
resourceTree = result.Data ?? [];
|
resourceTree = result.Data ?? [];
|
||||||
_resourcesRefreshedAt = DateTime.UtcNow;
|
_resourcesRefreshedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
// Persist the live status so the header badges are accurate.
|
|
||||||
(SyncStatus sync, HealthStatus health) =
|
(SyncStatus sync, HealthStatus health) =
|
||||||
KubernetesOperationsService.ComputeStatusFromResources(resourceTree);
|
KubernetesOperationsService.ComputeStatusFromResources(resourceTree);
|
||||||
|
|
||||||
@@ -1603,10 +1770,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task RunResourceAutoRefreshAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
_resourceTimer = new PeriodicTimer(TimeSpan.FromSeconds(15));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (await _resourceTimer.WaitForNextTickAsync(ct))
|
||||||
|
{
|
||||||
|
await InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
await RefreshResourcesAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
}
|
||||||
|
|
||||||
// ──────── Metrics ────────
|
// ──────── Metrics ────────
|
||||||
|
|
||||||
private async Task LoadMetrics()
|
private async Task LoadMetrics()
|
||||||
{
|
{
|
||||||
|
StopResourceRefresh();
|
||||||
activeTab = "metrics";
|
activeTab = "metrics";
|
||||||
metricsLoading = true;
|
metricsLoading = true;
|
||||||
metricsError = null;
|
metricsError = null;
|
||||||
@@ -1628,6 +1813,7 @@
|
|||||||
|
|
||||||
private async Task LoadActivity()
|
private async Task LoadActivity()
|
||||||
{
|
{
|
||||||
|
StopResourceRefresh();
|
||||||
activeTab = "activity";
|
activeTab = "activity";
|
||||||
activityLoading = true;
|
activityLoading = true;
|
||||||
auditEvents = null;
|
auditEvents = null;
|
||||||
@@ -1639,6 +1825,7 @@
|
|||||||
|
|
||||||
private async Task LoadMonitoring()
|
private async Task LoadMonitoring()
|
||||||
{
|
{
|
||||||
|
StopResourceRefresh();
|
||||||
activeTab = "monitoring";
|
activeTab = "monitoring";
|
||||||
monitoringLoading = true;
|
monitoringLoading = true;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
@@ -1677,8 +1864,41 @@
|
|||||||
monitoringLoading = false;
|
monitoringLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────── Operation message helpers ────────
|
||||||
|
|
||||||
|
private void SetOperationMessage(string? msg, bool success)
|
||||||
|
{
|
||||||
|
operationMessage = msg;
|
||||||
|
operationSuccess = success;
|
||||||
|
if (msg is not null)
|
||||||
|
_ = AutoDismissMessageAsync(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AutoDismissMessageAsync(string snapshot)
|
||||||
|
{
|
||||||
|
await Task.Delay(6000);
|
||||||
|
// Only clear if the message hasn't been replaced by a newer one.
|
||||||
|
if (operationMessage == snapshot)
|
||||||
|
{
|
||||||
|
operationMessage = null;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ──────── Rendering helpers ────────
|
// ──────── Rendering helpers ────────
|
||||||
|
|
||||||
|
private static string PodAge(DateTime startTime)
|
||||||
|
{
|
||||||
|
TimeSpan age = DateTime.UtcNow - startTime;
|
||||||
|
if (age.TotalDays >= 1) return $"{(int)age.TotalDays}d";
|
||||||
|
if (age.TotalHours >= 1) return $"{(int)age.TotalHours}h";
|
||||||
|
if (age.TotalMinutes >= 1) return $"{(int)age.TotalMinutes}m";
|
||||||
|
return "<1m";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RestartBadgeClass(int restarts) =>
|
||||||
|
restarts >= 5 ? "bg-danger" : "bg-warning text-dark";
|
||||||
|
|
||||||
private static string GetPodStatusColor(string status) => status switch
|
private static string GetPodStatusColor(string status) => status switch
|
||||||
{
|
{
|
||||||
"Running" => "text-success",
|
"Running" => "text-success",
|
||||||
@@ -1697,6 +1917,12 @@
|
|||||||
_ => "bg-secondary"
|
_ => "bg-secondary"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static string TruncateImage(string image)
|
||||||
|
{
|
||||||
|
int slash = image.LastIndexOf('/');
|
||||||
|
return slash >= 0 ? image[(slash + 1)..] : image;
|
||||||
|
}
|
||||||
|
|
||||||
private RenderFragment TypeBadge(DeploymentType type) => type switch
|
private RenderFragment TypeBadge(DeploymentType type) => type switch
|
||||||
{
|
{
|
||||||
DeploymentType.Manual => @<span class="badge bg-info">Manual</span>,
|
DeploymentType.Manual => @<span class="badge bg-info">Manual</span>,
|
||||||
|
|||||||
@@ -114,10 +114,19 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-sm btn-outline-danger border-0 ms-2"
|
<button class="btn btn-sm btn-outline-danger border-0 ms-2"
|
||||||
@onclick="() => DeleteUser(user.Id)">
|
@onclick="() => confirmDeleteUserId = user.Id">
|
||||||
<i class="bi bi-trash"></i>
|
<i class="bi bi-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@if (confirmDeleteUserId == user.Id)
|
||||||
|
{
|
||||||
|
<ConfirmDialog Visible="true"
|
||||||
|
Message="@($"Delete user '{user.Username}'? This cannot be undone.")"
|
||||||
|
ConfirmText="Delete"
|
||||||
|
IsBusy="deletingItem"
|
||||||
|
OnConfirm="() => DeleteUser(user.Id)"
|
||||||
|
OnCancel="() => confirmDeleteUserId = null" />
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -183,20 +192,31 @@
|
|||||||
<div class="list-group">
|
<div class="list-group">
|
||||||
@foreach (KeycloakIdpInfo idp in idps)
|
@foreach (KeycloakIdpInfo idp in idps)
|
||||||
{
|
{
|
||||||
<div class="list-group-item d-flex justify-content-between align-items-center py-2">
|
<div class="list-group-item py-2">
|
||||||
<div>
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<i class="bi bi-box-arrow-in-right me-2 text-muted"></i>
|
<div>
|
||||||
<strong>@idp.Alias</strong>
|
<i class="bi bi-box-arrow-in-right me-2 text-muted"></i>
|
||||||
<span class="badge bg-secondary-subtle text-secondary ms-2 small">@idp.ProviderId</span>
|
<strong>@idp.Alias</strong>
|
||||||
@if (!idp.Enabled)
|
<span class="badge bg-secondary-subtle text-secondary ms-2 small">@idp.ProviderId</span>
|
||||||
{
|
@if (!idp.Enabled)
|
||||||
<span class="badge bg-secondary-subtle text-secondary ms-1 small">Disabled</span>
|
{
|
||||||
}
|
<span class="badge bg-secondary-subtle text-secondary ms-1 small">Disabled</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline-danger border-0"
|
||||||
|
@onclick="() => confirmDeleteIdpAlias = idp.Alias">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-sm btn-outline-danger border-0"
|
@if (confirmDeleteIdpAlias == idp.Alias)
|
||||||
@onclick="() => DeleteIdp(idp.Alias)">
|
{
|
||||||
<i class="bi bi-trash"></i>
|
<ConfirmDialog Visible="true"
|
||||||
</button>
|
Message="@($"Delete identity provider '{idp.Alias}'?")"
|
||||||
|
ConfirmText="Delete"
|
||||||
|
IsBusy="deletingItem"
|
||||||
|
OnConfirm="() => DeleteIdp(idp.Alias)"
|
||||||
|
OnCancel="() => confirmDeleteIdpAlias = null" />
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -239,12 +259,23 @@
|
|||||||
<div class="list-group">
|
<div class="list-group">
|
||||||
@foreach (KeycloakGroupInfo g in groups)
|
@foreach (KeycloakGroupInfo g in groups)
|
||||||
{
|
{
|
||||||
<div class="list-group-item d-flex justify-content-between align-items-center py-2">
|
<div class="list-group-item py-2">
|
||||||
<span><i class="bi bi-people me-2 text-muted"></i>@g.Name <span class="text-muted small">@g.Path</span></span>
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<button class="btn btn-sm btn-outline-danger border-0"
|
<span><i class="bi bi-people me-2 text-muted"></i>@g.Name <span class="text-muted small">@g.Path</span></span>
|
||||||
@onclick="() => DeleteGroup(g.Id)">
|
<button class="btn btn-sm btn-outline-danger border-0"
|
||||||
<i class="bi bi-trash"></i>
|
@onclick="() => confirmDeleteGroupId = g.Id">
|
||||||
</button>
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (confirmDeleteGroupId == g.Id)
|
||||||
|
{
|
||||||
|
<ConfirmDialog Visible="true"
|
||||||
|
Message="@($"Delete group '{g.Name}'?")"
|
||||||
|
ConfirmText="Delete"
|
||||||
|
IsBusy="deletingItem"
|
||||||
|
OnConfirm="() => DeleteGroup(g.Id)"
|
||||||
|
OnCancel="() => confirmDeleteGroupId = null" />
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -329,6 +360,12 @@
|
|||||||
|
|
||||||
private bool loadingUsers, loadingIdps, loadingGroups, loadingOrgs;
|
private bool loadingUsers, loadingIdps, loadingGroups, loadingOrgs;
|
||||||
|
|
||||||
|
// Delete confirm state
|
||||||
|
private string? confirmDeleteUserId;
|
||||||
|
private string? confirmDeleteIdpAlias;
|
||||||
|
private string? confirmDeleteGroupId;
|
||||||
|
private bool deletingItem;
|
||||||
|
|
||||||
// User form
|
// User form
|
||||||
private bool showAddUser, savingUser;
|
private bool showAddUser, savingUser;
|
||||||
private string? userError;
|
private string? userError;
|
||||||
@@ -418,12 +455,15 @@
|
|||||||
|
|
||||||
private async Task DeleteUser(string userId)
|
private async Task DeleteUser(string userId)
|
||||||
{
|
{
|
||||||
|
deletingItem = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await KeycloakService.DeleteUserAsync(TenantId, Realm.Id, userId);
|
await KeycloakService.DeleteUserAsync(TenantId, Realm.Id, userId);
|
||||||
|
confirmDeleteUserId = null;
|
||||||
users = await KeycloakService.GetUsersAsync(TenantId, Realm.Id);
|
users = await KeycloakService.GetUsersAsync(TenantId, Realm.Id);
|
||||||
}
|
}
|
||||||
catch (Exception ex) { userError = ex.Message; }
|
catch (Exception ex) { userError = ex.Message; }
|
||||||
|
finally { deletingItem = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AddIdp()
|
private async Task AddIdp()
|
||||||
@@ -445,12 +485,15 @@
|
|||||||
|
|
||||||
private async Task DeleteIdp(string alias)
|
private async Task DeleteIdp(string alias)
|
||||||
{
|
{
|
||||||
|
deletingItem = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await KeycloakService.DeleteIdpAsync(TenantId, Realm.Id, alias);
|
await KeycloakService.DeleteIdpAsync(TenantId, Realm.Id, alias);
|
||||||
|
confirmDeleteIdpAlias = null;
|
||||||
idps = await KeycloakService.GetIdpsAsync(TenantId, Realm.Id);
|
idps = await KeycloakService.GetIdpsAsync(TenantId, Realm.Id);
|
||||||
}
|
}
|
||||||
catch (Exception ex) { idpError = ex.Message; }
|
catch (Exception ex) { idpError = ex.Message; }
|
||||||
|
finally { deletingItem = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AddGroup()
|
private async Task AddGroup()
|
||||||
@@ -470,12 +513,15 @@
|
|||||||
|
|
||||||
private async Task DeleteGroup(string groupId)
|
private async Task DeleteGroup(string groupId)
|
||||||
{
|
{
|
||||||
|
deletingItem = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await KeycloakService.DeleteGroupAsync(TenantId, Realm.Id, groupId);
|
await KeycloakService.DeleteGroupAsync(TenantId, Realm.Id, groupId);
|
||||||
|
confirmDeleteGroupId = null;
|
||||||
groups = await KeycloakService.GetGroupsAsync(TenantId, Realm.Id);
|
groups = await KeycloakService.GetGroupsAsync(TenantId, Realm.Id);
|
||||||
}
|
}
|
||||||
catch (Exception ex) { groupError = ex.Message; }
|
catch (Exception ex) { groupError = ex.Message; }
|
||||||
|
finally { deletingItem = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AddOrg()
|
private async Task AddOrg()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
62
src/EntKube.Web/Components/Pages/Shared/Breadcrumb.razor
Normal file
62
src/EntKube.Web/Components/Pages/Shared/Breadcrumb.razor
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<nav aria-label="breadcrumb" class="mb-3">
|
||||||
|
<ol class="breadcrumb small">
|
||||||
|
@foreach (BreadcrumbItem item in Items)
|
||||||
|
{
|
||||||
|
@if (item.IsActive)
|
||||||
|
{
|
||||||
|
<li class="breadcrumb-item active">
|
||||||
|
@if (!string.IsNullOrEmpty(item.Icon))
|
||||||
|
{
|
||||||
|
<i class="bi @item.Icon me-1"></i>
|
||||||
|
}
|
||||||
|
@item.Label
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
else if (item.OnClick.HasDelegate)
|
||||||
|
{
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<a href="javascript:void(0)" class="text-decoration-none" @onclick="item.OnClick">
|
||||||
|
@if (!string.IsNullOrEmpty(item.Icon))
|
||||||
|
{
|
||||||
|
<i class="bi @item.Icon me-1"></i>
|
||||||
|
}
|
||||||
|
@item.Label
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrEmpty(item.Href))
|
||||||
|
{
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<a href="@item.Href" class="text-decoration-none">
|
||||||
|
@if (!string.IsNullOrEmpty(item.Icon))
|
||||||
|
{
|
||||||
|
<i class="bi @item.Icon me-1"></i>
|
||||||
|
}
|
||||||
|
@item.Label
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<li class="breadcrumb-item text-muted">
|
||||||
|
@if (!string.IsNullOrEmpty(item.Icon))
|
||||||
|
{
|
||||||
|
<i class="bi @item.Icon me-1"></i>
|
||||||
|
}
|
||||||
|
@item.Label
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public IReadOnlyList<BreadcrumbItem> Items { get; set; } = [];
|
||||||
|
|
||||||
|
public record BreadcrumbItem(
|
||||||
|
string Label,
|
||||||
|
string? Icon = null,
|
||||||
|
string? Href = null,
|
||||||
|
EventCallback OnClick = default,
|
||||||
|
bool IsActive = false);
|
||||||
|
}
|
||||||
27
src/EntKube.Web/Components/Pages/Shared/ConfirmDialog.razor
Normal file
27
src/EntKube.Web/Components/Pages/Shared/ConfirmDialog.razor
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
@if (Visible)
|
||||||
|
{
|
||||||
|
<div class="mt-2 p-2 bg-@Variant bg-opacity-10 rounded border border-@Variant border-opacity-25 d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||||
|
<span class="text-@Variant small">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||||
|
@Message
|
||||||
|
</span>
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
<button class="btn btn-sm btn-@Variant" @onclick="OnConfirm" disabled="@IsBusy">
|
||||||
|
@if (IsBusy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
@ConfirmText
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="OnCancel" disabled="@IsBusy">@CancelText</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public bool Visible { get; set; }
|
||||||
|
[Parameter] public string Message { get; set; } = "Are you sure?";
|
||||||
|
[Parameter] public string ConfirmText { get; set; } = "Confirm";
|
||||||
|
[Parameter] public string CancelText { get; set; } = "Cancel";
|
||||||
|
[Parameter] public string Variant { get; set; } = "danger";
|
||||||
|
[Parameter] public bool IsBusy { get; set; }
|
||||||
|
[Parameter] public EventCallback OnConfirm { get; set; }
|
||||||
|
[Parameter] public EventCallback OnCancel { get; set; }
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
@using EntKube.Web.Data
|
@using EntKube.Web.Data
|
||||||
@using EntKube.Web.Services
|
@using EntKube.Web.Services
|
||||||
@inject KubernetesOperationsService K8sOps
|
@inject KubernetesOperationsService K8sOps
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
@* ═══════════════════════════════════════════════════════════════════
|
@* ═══════════════════════════════════════════════════════════════════
|
||||||
ResourceTreePanel — proper ArgoCD-style graph.
|
ResourceTreePanel — proper ArgoCD-style graph.
|
||||||
@@ -138,7 +139,7 @@
|
|||||||
.rg-panel {
|
.rg-panel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
width: 340px;
|
width: 360px;
|
||||||
background: #1e1e2e;
|
background: #1e1e2e;
|
||||||
border: 1px solid #3a3a4e;
|
border: 1px solid #3a3a4e;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -180,7 +181,7 @@
|
|||||||
}
|
}
|
||||||
@if (!Loading && Resources is not null)
|
@if (!Loading && Resources is not null)
|
||||||
{
|
{
|
||||||
<span class="text-muted small">@CountAllResources(Resources) resources</span>
|
<span class="text-muted small">@_nodes.Count resources</span>
|
||||||
}
|
}
|
||||||
@if (LastRefreshed.HasValue && !Loading)
|
@if (LastRefreshed.HasValue && !Loading)
|
||||||
{
|
{
|
||||||
@@ -195,6 +196,11 @@
|
|||||||
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
|
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
<button class="btn btn-sm @(_hideCompleted ? "btn-outline-secondary" : "btn-secondary")"
|
||||||
|
title="@(_hideCompleted ? "Showing condensed view — click to show completed jobs & old ReplicaSets" : "Click to hide completed jobs & old ReplicaSets")"
|
||||||
|
@onclick="ToggleHideCompleted">
|
||||||
|
<i class="bi @(_hideCompleted ? "bi-funnel-fill" : "bi-funnel")"></i>
|
||||||
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-secondary @(!LastRefreshed.HasValue && !OnRefresh.HasDelegate ? "ms-auto" : "")"
|
<button class="btn btn-sm btn-outline-secondary @(!LastRefreshed.HasValue && !OnRefresh.HasDelegate ? "ms-auto" : "")"
|
||||||
title="@(_fullscreen ? "Exit fullscreen" : "Fullscreen")"
|
title="@(_fullscreen ? "Exit fullscreen" : "Fullscreen")"
|
||||||
@onclick="ToggleFullscreen">
|
@onclick="ToggleFullscreen">
|
||||||
@@ -277,6 +283,26 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count >
|
|||||||
<i class="bi bi-list-ul"></i> Events
|
<i class="bi bi-list-ul"></i> Events
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
@if (CanEvents)
|
||||||
|
{
|
||||||
|
<button class="rg-btn @(IsPanel(nd.Node.Id, "yaml") ? "active" : "")"
|
||||||
|
title="YAML" @onclick="() => PanelYaml(nd.Node)">
|
||||||
|
<i class="bi bi-file-code"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (nd.Node.Kind == "CronJob" && CanRestart)
|
||||||
|
{
|
||||||
|
bool cronSuspended = nd.Node.HealthStatus == HealthStatus.Suspended;
|
||||||
|
<button class="rg-btn @(IsPanel(nd.Node.Id, "trigger") ? "active-warn" : "")"
|
||||||
|
title="Trigger manual run" @onclick="() => PanelTrigger(nd.Node)">
|
||||||
|
<i class="bi bi-lightning"></i>
|
||||||
|
</button>
|
||||||
|
<button class="rg-btn" disabled="@_opBusy"
|
||||||
|
title="@(cronSuspended ? "Resume schedule" : "Suspend schedule")"
|
||||||
|
@onclick="() => _ = ToggleSuspendCronJobAsync(nd.Node)">
|
||||||
|
<i class="bi @(cronSuspended ? "bi-play-fill" : "bi-pause-fill")"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
@if ((nd.Node.Kind is "Deployment" or "StatefulSet" or "DaemonSet") && CanRestart)
|
@if ((nd.Node.Kind is "Deployment" or "StatefulSet" or "DaemonSet") && CanRestart)
|
||||||
{
|
{
|
||||||
<button class="rg-btn @(IsPanel(nd.Node.Id, "restart") ? "active-warn" : "")"
|
<button class="rg-btn @(IsPanel(nd.Node.Id, "restart") ? "active-warn" : "")"
|
||||||
@@ -291,6 +317,13 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count >
|
|||||||
<i class="bi bi-sliders"></i>
|
<i class="bi bi-sliders"></i>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
@if (nd.Node.Kind == "ReplicaSet" && ParseDesiredReplicas(nd.Node.StatusMessage) == 0 && CanDelete)
|
||||||
|
{
|
||||||
|
<button class="rg-btn @(IsPanel(nd.Node.Id, "delete") ? "active-danger" : "")"
|
||||||
|
title="Delete old ReplicaSet" @onclick="() => PanelDelete(nd.Node)">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
@if ((nd.Node.Kind == "Pod" || nd.Node.Kind == "Job") && CanDelete)
|
@if ((nd.Node.Kind == "Pod" || nd.Node.Kind == "Job") && CanDelete)
|
||||||
{
|
{
|
||||||
<button class="rg-btn @(IsPanel(nd.Node.Id, "delete") ? "active-danger" : "")"
|
<button class="rg-btn @(IsPanel(nd.Node.Id, "delete") ? "active-danger" : "")"
|
||||||
@@ -298,13 +331,28 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count >
|
|||||||
<i class="bi bi-trash"></i>
|
<i class="bi bi-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
@if (_hideCompleted && _hiddenJobCounts.TryGetValue(nd.Node.Id, out int hj) && hj > 0)
|
||||||
|
{
|
||||||
|
<span class="rg-btn ms-auto" style="cursor:default;opacity:.7;"
|
||||||
|
title="@hj completed job(s) hidden — click the funnel to show">
|
||||||
|
<i class="bi bi-clock"></i> @hj
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@if (_hideCompleted && _hiddenRsCounts.TryGetValue(nd.Node.Id, out int hr) && hr > 0)
|
||||||
|
{
|
||||||
|
<span class="rg-btn @(_hiddenJobCounts.ContainsKey(nd.Node.Id) ? "" : "ms-auto")"
|
||||||
|
style="cursor:default;opacity:.7;"
|
||||||
|
title="@hr old ReplicaSet(s) hidden — click the funnel to show">
|
||||||
|
<i class="bi bi-files"></i> @hr
|
||||||
|
</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* ── Floating action panel for this node ── *@
|
@* ── Floating action panel for this node ── *@
|
||||||
@if (isActive && _panelMode is not null)
|
@if (isActive && _panelMode is not null)
|
||||||
{
|
{
|
||||||
@RenderPanel(nd.Node, cx, cy + CardH + 6)
|
@RenderPanel(nd.Node, ClampPanelX(cx), PanelY(cy))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -323,18 +371,6 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count >
|
|||||||
[Parameter] public EventCallback OnRefresh { get; set; }
|
[Parameter] public EventCallback OnRefresh { get; set; }
|
||||||
[Parameter] public DateTime? LastRefreshed { get; set; }
|
[Parameter] public DateTime? LastRefreshed { get; set; }
|
||||||
|
|
||||||
private static int CountAllResources(IEnumerable<DeploymentResource>? list)
|
|
||||||
{
|
|
||||||
if (list is null) return 0;
|
|
||||||
int n = 0;
|
|
||||||
foreach (DeploymentResource r in list)
|
|
||||||
{
|
|
||||||
n++;
|
|
||||||
n += CountAllResources(r.ChildResources);
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Access gates ────────────────────────────────────────────────────────
|
// ── Access gates ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private bool CanLogs => AccessRole is null || AccessRole >= CustomerAccessRole.Viewer;
|
private bool CanLogs => AccessRole is null || AccessRole >= CustomerAccessRole.Viewer;
|
||||||
@@ -379,6 +415,9 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count >
|
|||||||
private string? _feedback;
|
private string? _feedback;
|
||||||
private bool _feedbackOk;
|
private bool _feedbackOk;
|
||||||
private bool _fullscreen;
|
private bool _fullscreen;
|
||||||
|
private bool _hideCompleted = true;
|
||||||
|
private Dictionary<Guid, int> _hiddenJobCounts = new();
|
||||||
|
private Dictionary<Guid, int> _hiddenRsCounts = new();
|
||||||
|
|
||||||
// ── Lifecycle ────────────────────────────────────────────────────────────
|
// ── Lifecycle ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -393,6 +432,8 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count >
|
|||||||
{
|
{
|
||||||
_nodes.Clear();
|
_nodes.Clear();
|
||||||
_lines.Clear();
|
_lines.Clear();
|
||||||
|
_hiddenJobCounts.Clear();
|
||||||
|
_hiddenRsCounts.Clear();
|
||||||
if (Resources is null || Resources.Count == 0) return;
|
if (Resources is null || Resources.Count == 0) return;
|
||||||
|
|
||||||
double y = 0;
|
double y = 0;
|
||||||
@@ -401,7 +442,39 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count >
|
|||||||
{
|
{
|
||||||
double top = y;
|
double top = y;
|
||||||
|
|
||||||
if (node.ChildResources is { Count: > 0 } kids)
|
List<DeploymentResource> kids = node.ChildResources is { Count: > 0 } c ? new(c) : [];
|
||||||
|
|
||||||
|
if (_hideCompleted)
|
||||||
|
{
|
||||||
|
// Completed Jobs owned by a CronJob are history — hide them, show count on parent
|
||||||
|
if (node.Kind == "CronJob")
|
||||||
|
{
|
||||||
|
List<DeploymentResource> done = kids
|
||||||
|
.Where(k => k.Kind == "Job" && k.HealthStatus != HealthStatus.Progressing)
|
||||||
|
.ToList();
|
||||||
|
if (done.Count > 0)
|
||||||
|
{
|
||||||
|
_hiddenJobCounts[node.Id] = done.Count;
|
||||||
|
kids = kids.Except(done).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Completed standalone Jobs: keep the Job card but collapse its pod children
|
||||||
|
if (node.Kind == "Job" && node.HealthStatus != HealthStatus.Progressing)
|
||||||
|
kids = [];
|
||||||
|
|
||||||
|
// Zero-replica ReplicaSets are inactive rollback history — hide them
|
||||||
|
List<DeploymentResource> zeroRs = kids
|
||||||
|
.Where(k => k.Kind == "ReplicaSet" && ParseDesiredReplicas(k.StatusMessage) == 0)
|
||||||
|
.ToList();
|
||||||
|
if (zeroRs.Count > 0)
|
||||||
|
{
|
||||||
|
_hiddenRsCounts[node.Id] = zeroRs.Count;
|
||||||
|
kids = kids.Except(zeroRs).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kids.Count > 0)
|
||||||
foreach (DeploymentResource kid in kids)
|
foreach (DeploymentResource kid in kids)
|
||||||
Layout(kid, col + 1, node.Id);
|
Layout(kid, col + 1, node.Id);
|
||||||
else
|
else
|
||||||
@@ -449,6 +522,8 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count >
|
|||||||
// String-literal-safe wrappers — avoids embedding "string" inside @onclick="..." attributes.
|
// String-literal-safe wrappers — avoids embedding "string" inside @onclick="..." attributes.
|
||||||
private void PanelLogs(DeploymentResource n) => TogglePanel(n.Id, n, "logs");
|
private void PanelLogs(DeploymentResource n) => TogglePanel(n.Id, n, "logs");
|
||||||
private void PanelEvents(DeploymentResource n) => TogglePanel(n.Id, n, "events");
|
private void PanelEvents(DeploymentResource n) => TogglePanel(n.Id, n, "events");
|
||||||
|
private void PanelYaml(DeploymentResource n) => TogglePanel(n.Id, n, "yaml");
|
||||||
|
private void PanelTrigger(DeploymentResource n) => TogglePanel(n.Id, n, "trigger");
|
||||||
private void PanelRestart(DeploymentResource n) => TogglePanel(n.Id, n, "restart");
|
private void PanelRestart(DeploymentResource n) => TogglePanel(n.Id, n, "restart");
|
||||||
private void PanelScale(DeploymentResource n) => TogglePanel(n.Id, n, "scale");
|
private void PanelScale(DeploymentResource n) => TogglePanel(n.Id, n, "scale");
|
||||||
private void PanelDelete(DeploymentResource n) => TogglePanel(n.Id, n, "delete");
|
private void PanelDelete(DeploymentResource n) => TogglePanel(n.Id, n, "delete");
|
||||||
@@ -464,9 +539,10 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count >
|
|||||||
_activePanelNode = node;
|
_activePanelNode = node;
|
||||||
_panelMode = mode;
|
_panelMode = mode;
|
||||||
|
|
||||||
if (mode == "logs") _ = LoadLogsAsync(node);
|
if (mode == "logs") _ = LoadLogsAsync(node);
|
||||||
if (mode == "events") _ = LoadEventsAsync(node);
|
if (mode == "events") _ = LoadEventsAsync(node);
|
||||||
if (mode == "scale") _scaleValue = ParseDesiredReplicas(node.StatusMessage) ?? 1;
|
if (mode == "yaml") _ = LoadYamlAsync(node);
|
||||||
|
if (mode == "scale") _scaleValue = ParseDesiredReplicas(node.StatusMessage) ?? 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ClosePanel()
|
private void ClosePanel()
|
||||||
@@ -495,13 +571,37 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count >
|
|||||||
await FetchLogsAsync(node.Name);
|
await FetchLogsAsync(node.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int _panelTailLines = 500;
|
||||||
|
private ElementReference _logPreRef;
|
||||||
|
private bool _pendingLogScroll;
|
||||||
|
|
||||||
private async Task FetchLogsAsync(string podName)
|
private async Task FetchLogsAsync(string podName)
|
||||||
{
|
{
|
||||||
_panelLoading = true; _panelText = null; _panelError = null;
|
_panelLoading = true; _panelText = null; _panelError = null;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
var r = await K8sOps.GetPodLogsAsync(DeploymentId, podName, _selectedContainer);
|
var r = await K8sOps.GetPodLogsAsync(DeploymentId, podName, _selectedContainer, _panelTailLines);
|
||||||
_panelLoading = false;
|
_panelLoading = false;
|
||||||
if (r.IsSuccess) _panelText = r.Data ?? "(no output)"; else _panelError = r.Error;
|
if (r.IsSuccess) { _panelText = r.Data ?? "(no output)"; _pendingLogScroll = true; }
|
||||||
|
else _panelError = r.Error;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (_pendingLogScroll)
|
||||||
|
{
|
||||||
|
_pendingLogScroll = false;
|
||||||
|
try { await JS.InvokeVoidAsync("scrollToBottom", _logPreRef); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadYamlAsync(DeploymentResource node)
|
||||||
|
{
|
||||||
|
_panelLoading = true; _panelText = null; _panelError = null;
|
||||||
|
StateHasChanged();
|
||||||
|
var r = await K8sOps.GetResourceYamlAsync(DeploymentId, node.Kind, node.Name);
|
||||||
|
_panelLoading = false;
|
||||||
|
if (r.IsSuccess) _panelText = r.Data ?? "(empty)"; else _panelError = r.Error;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,6 +643,33 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count >
|
|||||||
: r.Error ?? "Restart failed.");
|
: r.Error ?? "Restart failed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ApplyTriggerAsync(DeploymentResource node)
|
||||||
|
{
|
||||||
|
_opBusy = true; StateHasChanged();
|
||||||
|
var r = await K8sOps.TriggerCronJobAsync(DeploymentId, node.Name);
|
||||||
|
_opBusy = false; ClosePanel();
|
||||||
|
SetFeedback(r.IsSuccess, r.IsSuccess
|
||||||
|
? r.Data ?? "Job created."
|
||||||
|
: r.Error ?? "Trigger failed.");
|
||||||
|
if (r.IsSuccess && OnRefresh.HasDelegate)
|
||||||
|
await OnRefresh.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ToggleSuspendCronJobAsync(DeploymentResource node)
|
||||||
|
{
|
||||||
|
if (_opBusy) return;
|
||||||
|
_opBusy = true;
|
||||||
|
StateHasChanged();
|
||||||
|
bool wasSuspended = node.HealthStatus == HealthStatus.Suspended;
|
||||||
|
var r = await K8sOps.SetCronJobSuspendedAsync(DeploymentId, node.Name, !wasSuspended);
|
||||||
|
_opBusy = false;
|
||||||
|
SetFeedback(r.IsSuccess, r.IsSuccess
|
||||||
|
? $"CronJob {node.Name} {(!wasSuspended ? "suspended" : "resumed")}."
|
||||||
|
: r.Error ?? "Operation failed.");
|
||||||
|
if (r.IsSuccess && OnRefresh.HasDelegate)
|
||||||
|
await OnRefresh.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ApplyDeleteAsync(DeploymentResource node)
|
private async Task ApplyDeleteAsync(DeploymentResource node)
|
||||||
{
|
{
|
||||||
_opBusy = true; StateHasChanged();
|
_opBusy = true; StateHasChanged();
|
||||||
@@ -551,8 +678,18 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count >
|
|||||||
SetFeedback(r.IsSuccess, r.IsSuccess
|
SetFeedback(r.IsSuccess, r.IsSuccess
|
||||||
? $"{node.Kind} {node.Name} deleted."
|
? $"{node.Kind} {node.Name} deleted."
|
||||||
: r.Error ?? "Delete failed.");
|
: r.Error ?? "Delete failed.");
|
||||||
|
if (r.IsSuccess && OnRefresh.HasDelegate)
|
||||||
|
await OnRefresh.InvokeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Task RefreshPanel(DeploymentResource node) => _panelMode switch
|
||||||
|
{
|
||||||
|
"logs" => FetchLogsAsync(node.Name),
|
||||||
|
"events" => LoadEventsAsync(node),
|
||||||
|
"yaml" => LoadYamlAsync(node),
|
||||||
|
_ => Task.CompletedTask
|
||||||
|
};
|
||||||
|
|
||||||
private void SetFeedback(bool ok, string msg)
|
private void SetFeedback(bool ok, string msg)
|
||||||
{
|
{
|
||||||
_feedbackOk = ok; _feedback = msg; StateHasChanged();
|
_feedbackOk = ok; _feedback = msg; StateHasChanged();
|
||||||
@@ -560,6 +697,37 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count >
|
|||||||
|
|
||||||
private void ToggleFullscreen() => _fullscreen = !_fullscreen;
|
private void ToggleFullscreen() => _fullscreen = !_fullscreen;
|
||||||
|
|
||||||
|
private void ToggleHideCompleted()
|
||||||
|
{
|
||||||
|
_hideCompleted = !_hideCompleted;
|
||||||
|
ComputeLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
private double ClampPanelX(double cardX)
|
||||||
|
{
|
||||||
|
const double PanelW = 360;
|
||||||
|
double canvasW = _graphW + 24;
|
||||||
|
return Math.Max(0, Math.Min(cardX, canvasW - PanelW));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double EstimatedPanelHeight(string? mode) => mode switch
|
||||||
|
{
|
||||||
|
"logs" => 430,
|
||||||
|
"events" => 310,
|
||||||
|
"yaml" => 410,
|
||||||
|
"scale" => 120,
|
||||||
|
"trigger" => 90,
|
||||||
|
_ => 70 // restart / delete
|
||||||
|
};
|
||||||
|
|
||||||
|
private double PanelY(double cardY)
|
||||||
|
{
|
||||||
|
double below = cardY + CardH + 6;
|
||||||
|
double panelH = EstimatedPanelHeight(_panelMode);
|
||||||
|
// Flip above the card when the panel would extend past the canvas bottom.
|
||||||
|
return below + panelH > _graphH ? Math.Max(0, cardY - panelH - 6) : below;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Floating panel renderer ──────────────────────────────────────────────
|
// ── Floating panel renderer ──────────────────────────────────────────────
|
||||||
|
|
||||||
private RenderFragment RenderPanel(DeploymentResource node, double px, double py) => __builder =>
|
private RenderFragment RenderPanel(DeploymentResource node, double px, double py) => __builder =>
|
||||||
@@ -583,14 +751,32 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count >
|
|||||||
{
|
{
|
||||||
<i class="bi bi-arrow-repeat me-1"></i><text>Restart — @node.Name</text>
|
<i class="bi bi-arrow-repeat me-1"></i><text>Restart — @node.Name</text>
|
||||||
}
|
}
|
||||||
|
else if (_panelMode == "yaml")
|
||||||
|
{
|
||||||
|
<i class="bi bi-file-code me-1"></i><text>YAML — @node.Kind/@node.Name</text>
|
||||||
|
}
|
||||||
|
else if (_panelMode == "trigger")
|
||||||
|
{
|
||||||
|
<i class="bi bi-lightning me-1"></i><text>Trigger — @node.Name</text>
|
||||||
|
}
|
||||||
else if (_panelMode == "delete")
|
else if (_panelMode == "delete")
|
||||||
{
|
{
|
||||||
<i class="bi bi-trash me-1"></i><text>Delete — @node.Name</text>
|
<i class="bi bi-trash me-1"></i><text>Delete — @node.Name</text>
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
<button class="btn btn-sm btn-link text-white py-0 px-1" @onclick="ClosePanel">
|
<div class="d-flex align-items-center gap-1">
|
||||||
<i class="bi bi-x-lg" style="font-size:.75rem;"></i>
|
@if (_panelMode is "logs" or "events" or "yaml")
|
||||||
</button>
|
{
|
||||||
|
<button class="btn btn-sm btn-link text-white py-0 px-1" title="Refresh"
|
||||||
|
disabled="@_panelLoading"
|
||||||
|
@onclick="() => _ = RefreshPanel(node)">
|
||||||
|
<i class="bi bi-arrow-clockwise" style="font-size:.75rem;"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button class="btn btn-sm btn-link text-white py-0 px-1" @onclick="ClosePanel">
|
||||||
|
<i class="bi bi-x-lg" style="font-size:.75rem;"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (_panelMode == "logs")
|
@if (_panelMode == "logs")
|
||||||
@@ -607,6 +793,16 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count >
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
<div class="px-2 py-1 d-flex gap-2 align-items-center" style="background:#2a2a3e;border-bottom:1px solid #3a3a4e;">
|
||||||
|
<span class="text-muted small">Lines:</span>
|
||||||
|
@foreach (int tl in new[] { 100, 500, 1000 })
|
||||||
|
{
|
||||||
|
int tlCopy = tl;
|
||||||
|
<button class="btn btn-sm py-0 px-2 @(_panelTailLines == tlCopy ? "btn-primary" : "btn-outline-secondary")"
|
||||||
|
style="font-size:.68rem;"
|
||||||
|
@onclick="() => { _panelTailLines = tlCopy; _ = FetchLogsAsync(node.Name); }">@tlCopy</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@if (_panelLoading)
|
@if (_panelLoading)
|
||||||
{
|
{
|
||||||
<div class="text-center py-3"><div class="spinner-border spinner-border-sm text-light"></div></div>
|
<div class="text-center py-3"><div class="spinner-border spinner-border-sm text-light"></div></div>
|
||||||
@@ -617,7 +813,7 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count >
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<pre class="m-0 p-2 text-light" style="max-height:320px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;font-size:.7rem;background:transparent;">@_panelText</pre>
|
<pre @ref="_logPreRef" class="m-0 p-2 text-light" style="max-height:320px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;font-size:.7rem;background:transparent;">@_panelText</pre>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -637,17 +833,51 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count >
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (_panelMode == "yaml")
|
||||||
|
{
|
||||||
|
@if (_panelLoading)
|
||||||
|
{
|
||||||
|
<div class="text-center py-3"><div class="spinner-border spinner-border-sm text-light"></div></div>
|
||||||
|
}
|
||||||
|
else if (_panelError is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning m-2 py-1 small">@_panelError</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<pre class="m-0 p-2 text-light" style="max-height:360px;overflow-y:auto;overflow-x:auto;white-space:pre;font-size:.68rem;background:transparent;tab-size:2;">@_panelText</pre>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@if (_panelMode == "scale")
|
@if (_panelMode == "scale")
|
||||||
{
|
{
|
||||||
<div class="rg-panel-body d-flex align-items-center gap-2 flex-wrap">
|
var current = ParseDesiredReplicas(node.StatusMessage);
|
||||||
<span class="text-light small">Replicas for <strong>@node.Name</strong></span>
|
<div class="rg-panel-body">
|
||||||
<input type="number" class="form-control form-control-sm"
|
@if (current.HasValue)
|
||||||
style="width:70px;background:#2a2a3e;border-color:#555;color:white;"
|
{
|
||||||
min="0" max="99" @bind="_scaleValue" />
|
<div class="text-muted small mb-2">
|
||||||
<button class="btn btn-sm btn-primary" disabled="@_opBusy" @onclick="() => ApplyScaleAsync(node)">
|
Current: <strong class="text-light">@current</strong> replica@(current == 1 ? "" : "s")
|
||||||
@if (_opBusy) { <span class="spinner-border spinner-border-sm me-1"></span> } Apply
|
</div>
|
||||||
</button>
|
}
|
||||||
<button class="btn btn-sm btn-outline-secondary" @onclick="ClosePanel">Cancel</button>
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<div class="d-flex align-items-center gap-1">
|
||||||
|
<button class="btn btn-sm py-0 px-2" style="color:#ccc;border:1px solid #555;border-radius:4px;background:none;"
|
||||||
|
@onclick="() => _scaleValue = Math.Max(0, _scaleValue - 1)" disabled="@_opBusy">
|
||||||
|
<i class="bi bi-dash"></i>
|
||||||
|
</button>
|
||||||
|
<input type="number" class="form-control form-control-sm text-center"
|
||||||
|
style="width:60px;background:#2a2a3e;border-color:#555;color:white;"
|
||||||
|
min="0" max="99" @bind="_scaleValue" />
|
||||||
|
<button class="btn btn-sm py-0 px-2" style="color:#ccc;border:1px solid #555;border-radius:4px;background:none;"
|
||||||
|
@onclick="() => _scaleValue++" disabled="@_opBusy">
|
||||||
|
<i class="bi bi-plus"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-primary" disabled="@_opBusy" @onclick="() => ApplyScaleAsync(node)">
|
||||||
|
@if (_opBusy) { <span class="spinner-border spinner-border-sm me-1"></span> } Apply
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="ClosePanel">Cancel</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,6 +899,7 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count >
|
|||||||
<span class="text-light small">
|
<span class="text-light small">
|
||||||
Delete <strong>@node.Kind @node.Name</strong>?
|
Delete <strong>@node.Kind @node.Name</strong>?
|
||||||
@if (node.Kind == "Pod") { <span class="text-muted"> K8s will restart it.</span> }
|
@if (node.Kind == "Pod") { <span class="text-muted"> K8s will restart it.</span> }
|
||||||
|
@if (node.Kind == "ReplicaSet") { <span class="text-muted"> Old revision, safe to remove.</span> }
|
||||||
</span>
|
</span>
|
||||||
<button class="btn btn-sm btn-danger" disabled="@_opBusy" @onclick="() => ApplyDeleteAsync(node)">
|
<button class="btn btn-sm btn-danger" disabled="@_opBusy" @onclick="() => ApplyDeleteAsync(node)">
|
||||||
@if (_opBusy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
@if (_opBusy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
@@ -677,6 +908,26 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count >
|
|||||||
<button class="btn btn-sm btn-outline-secondary" @onclick="ClosePanel">Cancel</button>
|
<button class="btn btn-sm btn-outline-secondary" @onclick="ClosePanel">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (_panelMode == "trigger")
|
||||||
|
{
|
||||||
|
<div class="rg-panel-body">
|
||||||
|
<div class="text-light small mb-2">
|
||||||
|
Create a manual Job from <strong>@node.Name</strong>?
|
||||||
|
@if (!string.IsNullOrEmpty(node.StatusMessage))
|
||||||
|
{
|
||||||
|
<span class="text-muted ms-1">(@node.StatusMessage)</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<button class="btn btn-sm btn-warning" disabled="@_opBusy" @onclick="() => ApplyTriggerAsync(node)">
|
||||||
|
@if (_opBusy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
<i class="bi bi-lightning me-1"></i>Run Job
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="ClosePanel">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
481
src/EntKube.Web/Components/Pages/Tenants/AlertRoutingTab.razor
Normal file
481
src/EntKube.Web/Components/Pages/Tenants/AlertRoutingTab.razor
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
@using EntKube.Web.Data
|
||||||
|
@using EntKube.Web.Services
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@inject AlertRoutingService AlertRoutingService
|
||||||
|
@inject IDbContextFactory<ApplicationDbContext> DbFactory
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<p class="text-muted small mb-2">
|
||||||
|
Routing rules match incoming alerts and direct them to a notification channel — or suppress them so no incident is created at all.
|
||||||
|
Rules are evaluated in priority order (lower number = higher priority). The first matching rule wins.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h6 class="mb-0">Routing Rules</h6>
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="() => { showForm = !showForm; editId = null; ResetForm(); }">
|
||||||
|
<span class="bi bi-plus-circle me-1"></span>Add Rule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (showForm)
|
||||||
|
{
|
||||||
|
<div class="card border-primary mb-4">
|
||||||
|
<div class="card-header py-2">
|
||||||
|
<strong class="small">@(editId.HasValue ? "Edit Rule" : "New Rule")</strong>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-2 mb-2">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">Rule name <span class="text-danger">*</span></label>
|
||||||
|
<input class="form-control form-control-sm" @bind="formName" placeholder="Suppress KubeProxy on autoscale cluster" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small">Priority</label>
|
||||||
|
<input type="number" class="form-control form-control-sm" @bind="formPriority" min="0" max="999" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small">Scope to cluster</label>
|
||||||
|
<select class="form-select form-select-sm" @bind="formMatchClusterId">
|
||||||
|
<option value="">Any cluster</option>
|
||||||
|
@foreach (KubernetesCluster c in clusters)
|
||||||
|
{
|
||||||
|
<option value="@c.Id">@c.Name</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 d-flex flex-column justify-content-end">
|
||||||
|
<div class="form-check mb-1">
|
||||||
|
<input class="form-check-input" type="checkbox" id="chkSuppress" @bind="formSuppressIncident" />
|
||||||
|
<label class="form-check-label small fw-semibold" for="chkSuppress">
|
||||||
|
Suppress incident
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="chkEnabled" @bind="formEnabled" />
|
||||||
|
<label class="form-check-label small" for="chkEnabled">Enabled</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (formSuppressIncident)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning py-2 mb-2 small">
|
||||||
|
<span class="bi bi-slash-circle me-1"></span>
|
||||||
|
Matching alerts will be silently dropped — no incident is created and no notification is sent.
|
||||||
|
No channel needed.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="row g-2 mb-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Channel <span class="text-danger">*</span></label>
|
||||||
|
<select class="form-select form-select-sm" @bind="formChannelId">
|
||||||
|
<option value="">— select channel —</option>
|
||||||
|
@foreach (NotificationChannel ch in channels)
|
||||||
|
{
|
||||||
|
<option value="@ch.Id">@ch.Name (@ch.Type)</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small text-muted">Match alert name (contains)</label>
|
||||||
|
<input class="form-control form-control-sm font-monospace" @bind="formAlertName" placeholder="KubeProxy…" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small text-muted">Match severity</label>
|
||||||
|
<select class="form-select form-select-sm" @bind="formSeverity">
|
||||||
|
<option value="">Any</option>
|
||||||
|
<option value="critical">critical</option>
|
||||||
|
<option value="warning">warning</option>
|
||||||
|
<option value="info">info</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small text-muted">Match namespace</label>
|
||||||
|
<input class="form-control form-control-sm font-monospace" @bind="formNamespace" placeholder="kube-system" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small text-muted">Label key</label>
|
||||||
|
<input class="form-control form-control-sm font-monospace" @bind="formLabelKey" placeholder="team" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small text-muted">Label value</label>
|
||||||
|
<input class="form-control form-control-sm font-monospace" @bind="formLabelValue" placeholder="backend" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(formError))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger py-1 small mb-2">@formError</div>
|
||||||
|
}
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="SaveRule"
|
||||||
|
disabled="@(string.IsNullOrWhiteSpace(formName) || (!formSuppressIncident && string.IsNullOrWhiteSpace(formChannelId)))">
|
||||||
|
@(editId.HasValue ? "Save Changes" : "Create Rule")
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelForm">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (rules.Count == 0 && !isLoading)
|
||||||
|
{
|
||||||
|
<EmptyState Icon="bi-funnel"
|
||||||
|
Title="No routing rules"
|
||||||
|
Message="Without rules, alerts are delivered to channels based on the channel's severity filter setting." />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th style="width:60px">Priority</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Matches</th>
|
||||||
|
<th style="width:80px">Status</th>
|
||||||
|
<th style="width:100px"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (AlertRoutingRule rule in rules)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted small font-monospace">@rule.Priority</td>
|
||||||
|
<td class="fw-semibold small">@rule.Name</td>
|
||||||
|
<td class="small">
|
||||||
|
@if (rule.SuppressIncident)
|
||||||
|
{
|
||||||
|
<span class="badge bg-danger"><span class="bi bi-slash-circle me-1"></span>Suppress</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="bi bi-send me-1 text-muted"></span>@rule.Channel?.Name
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex flex-wrap gap-1">
|
||||||
|
@if (rule.MatchClusterId.HasValue)
|
||||||
|
{
|
||||||
|
string clusterName = clusters.FirstOrDefault(c => c.Id == rule.MatchClusterId)?.Name ?? rule.MatchClusterId.Value.ToString("D")[..8];
|
||||||
|
<span class="badge bg-secondary small font-monospace">cluster=@clusterName</span>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrEmpty(rule.MatchAlertName))
|
||||||
|
{
|
||||||
|
<span class="badge bg-light text-dark border small font-monospace">alert~@rule.MatchAlertName</span>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrEmpty(rule.MatchSeverity))
|
||||||
|
{
|
||||||
|
string sevColor = rule.MatchSeverity == "critical" ? "danger" : rule.MatchSeverity == "warning" ? "warning" : "info";
|
||||||
|
<span class="badge bg-@sevColor @(rule.MatchSeverity == "warning" ? "text-dark" : "") small">@rule.MatchSeverity</span>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrEmpty(rule.MatchNamespace))
|
||||||
|
{
|
||||||
|
<span class="badge bg-light text-dark border small font-monospace">ns=@rule.MatchNamespace</span>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrEmpty(rule.MatchLabelKey))
|
||||||
|
{
|
||||||
|
<span class="badge bg-light text-dark border small font-monospace">
|
||||||
|
@rule.MatchLabelKey@(!string.IsNullOrEmpty(rule.MatchLabelValue) ? $"={rule.MatchLabelValue}" : "")
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@if (!rule.MatchClusterId.HasValue && string.IsNullOrEmpty(rule.MatchAlertName) && string.IsNullOrEmpty(rule.MatchSeverity)
|
||||||
|
&& string.IsNullOrEmpty(rule.MatchNamespace) && string.IsNullOrEmpty(rule.MatchLabelKey))
|
||||||
|
{
|
||||||
|
<span class="text-muted small">catch-all</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (rule.IsEnabled)
|
||||||
|
{
|
||||||
|
<span class="badge bg-success">Enabled</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="badge bg-secondary">Disabled</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => StartEdit(rule)"
|
||||||
|
title="Edit rule">
|
||||||
|
<span class="bi bi-pencil"></span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteRule(rule)"
|
||||||
|
title="Delete rule">
|
||||||
|
<span class="bi bi-trash"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ── Test routing rules ── *@
|
||||||
|
@if (rules.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="card mt-4 border-secondary">
|
||||||
|
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||||
|
<strong class="small"><span class="bi bi-play-circle me-1"></span>Test Routing</strong>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showTest = !showTest">
|
||||||
|
@(showTest ? "Hide" : "Test")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (showTest)
|
||||||
|
{
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small mb-3">Enter alert attributes to see which rule would match.</p>
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small">Cluster</label>
|
||||||
|
<select class="form-select form-select-sm" @bind="testClusterId">
|
||||||
|
<option value="">Any / unspecified</option>
|
||||||
|
@foreach (KubernetesCluster c in clusters)
|
||||||
|
{
|
||||||
|
<option value="@c.Id">@c.Name</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small">Alert name</label>
|
||||||
|
<input class="form-control form-control-sm font-monospace" @bind="testAlertName" placeholder="KubePodCrashLooping" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small">Severity</label>
|
||||||
|
<select class="form-select form-select-sm" @bind="testSeverity">
|
||||||
|
<option value="">—</option>
|
||||||
|
<option value="critical">critical</option>
|
||||||
|
<option value="warning">warning</option>
|
||||||
|
<option value="info">info</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small">Namespace</label>
|
||||||
|
<input class="form-control form-control-sm font-monospace" @bind="testNamespace" placeholder="kube-system" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1">
|
||||||
|
<label class="form-label small">Label key</label>
|
||||||
|
<input class="form-control form-control-sm font-monospace" @bind="testLabelKey" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1">
|
||||||
|
<label class="form-label small">Label value</label>
|
||||||
|
<input class="form-control form-control-sm font-monospace" @bind="testLabelValue" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-secondary mb-3" @onclick="RunTest">
|
||||||
|
<span class="bi bi-play-fill me-1"></span>Test
|
||||||
|
</button>
|
||||||
|
@if (testRan)
|
||||||
|
{
|
||||||
|
@if (testMatchedRule is not null && testMatchedRule.SuppressIncident)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger py-2 mb-0">
|
||||||
|
<span class="bi bi-slash-circle me-1"></span>
|
||||||
|
Matched suppression rule <strong>@testMatchedRule.Name</strong>
|
||||||
|
(priority @testMatchedRule.Priority) — <strong>no incident would be created</strong>.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (testMatchedRule is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-success py-2 mb-0">
|
||||||
|
<span class="bi bi-check-circle me-1"></span>
|
||||||
|
Matched rule <strong>@testMatchedRule.Name</strong>
|
||||||
|
(priority @testMatchedRule.Priority)
|
||||||
|
→ channel <strong>@testMatchedRule.Channel?.Name</strong>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="alert alert-secondary py-2 mb-0">
|
||||||
|
<span class="bi bi-funnel me-1"></span>
|
||||||
|
No rule matched — alert would be sent to <strong>all enabled channels</strong>
|
||||||
|
(filtered by each channel's severity setting).
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public required Guid TenantId { get; set; }
|
||||||
|
|
||||||
|
private List<AlertRoutingRule> rules = [];
|
||||||
|
private List<NotificationChannel> channels = [];
|
||||||
|
private List<KubernetesCluster> clusters = [];
|
||||||
|
private bool isLoading = true;
|
||||||
|
private bool showForm;
|
||||||
|
private Guid? editId;
|
||||||
|
|
||||||
|
private string formName = "";
|
||||||
|
private int formPriority;
|
||||||
|
private string formChannelId = "";
|
||||||
|
private string formMatchClusterId = "";
|
||||||
|
private bool formEnabled = true;
|
||||||
|
private bool formSuppressIncident;
|
||||||
|
private string formAlertName = "";
|
||||||
|
private string formSeverity = "";
|
||||||
|
private string formNamespace = "";
|
||||||
|
private string formLabelKey = "";
|
||||||
|
private string formLabelValue = "";
|
||||||
|
private string? formError;
|
||||||
|
|
||||||
|
// Test panel
|
||||||
|
private bool showTest;
|
||||||
|
private string testClusterId = "";
|
||||||
|
private string testAlertName = "";
|
||||||
|
private string testSeverity = "";
|
||||||
|
private string testNamespace = "";
|
||||||
|
private string testLabelKey = "";
|
||||||
|
private string testLabelValue = "";
|
||||||
|
private bool testRan;
|
||||||
|
private AlertRoutingRule? testMatchedRule;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await Load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Load()
|
||||||
|
{
|
||||||
|
isLoading = true;
|
||||||
|
|
||||||
|
using ApplicationDbContext db = DbFactory.CreateDbContext();
|
||||||
|
channels = await db.NotificationChannels
|
||||||
|
.Where(c => c.TenantId == TenantId)
|
||||||
|
.OrderBy(c => c.Name)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
clusters = await db.KubernetesClusters
|
||||||
|
.Where(c => c.TenantId == TenantId)
|
||||||
|
.OrderBy(c => c.Name)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
rules = await AlertRoutingService.GetRulesAsync(TenantId);
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetForm()
|
||||||
|
{
|
||||||
|
formName = "";
|
||||||
|
formPriority = rules.Count > 0 ? (rules.Max(r => r.Priority) + 10) : 0;
|
||||||
|
formChannelId = "";
|
||||||
|
formMatchClusterId = "";
|
||||||
|
formEnabled = true;
|
||||||
|
formSuppressIncident = false;
|
||||||
|
formAlertName = "";
|
||||||
|
formSeverity = "";
|
||||||
|
formNamespace = "";
|
||||||
|
formLabelKey = "";
|
||||||
|
formLabelValue = "";
|
||||||
|
formError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartEdit(AlertRoutingRule rule)
|
||||||
|
{
|
||||||
|
editId = rule.Id;
|
||||||
|
formName = rule.Name;
|
||||||
|
formPriority = rule.Priority;
|
||||||
|
formChannelId = rule.ChannelId?.ToString() ?? "";
|
||||||
|
formMatchClusterId = rule.MatchClusterId?.ToString() ?? "";
|
||||||
|
formEnabled = rule.IsEnabled;
|
||||||
|
formSuppressIncident = rule.SuppressIncident;
|
||||||
|
formAlertName = rule.MatchAlertName ?? "";
|
||||||
|
formSeverity = rule.MatchSeverity ?? "";
|
||||||
|
formNamespace = rule.MatchNamespace ?? "";
|
||||||
|
formLabelKey = rule.MatchLabelKey ?? "";
|
||||||
|
formLabelValue = rule.MatchLabelValue ?? "";
|
||||||
|
formError = null;
|
||||||
|
showForm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveRule()
|
||||||
|
{
|
||||||
|
formError = null;
|
||||||
|
if (string.IsNullOrWhiteSpace(formName)) return;
|
||||||
|
|
||||||
|
Guid? channelGuid = null;
|
||||||
|
if (!formSuppressIncident)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(formChannelId)) return;
|
||||||
|
if (!Guid.TryParse(formChannelId, out Guid cg))
|
||||||
|
{
|
||||||
|
formError = "Invalid channel.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
channelGuid = cg;
|
||||||
|
}
|
||||||
|
|
||||||
|
Guid? matchClusterGuid = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(formMatchClusterId) && Guid.TryParse(formMatchClusterId, out Guid mcg))
|
||||||
|
matchClusterGuid = mcg;
|
||||||
|
|
||||||
|
if (editId.HasValue)
|
||||||
|
{
|
||||||
|
await AlertRoutingService.UpdateRuleAsync(editId.Value, formName.Trim(), formPriority, channelGuid,
|
||||||
|
formAlertName, formNamespace, formSeverity, formLabelKey, formLabelValue, formEnabled,
|
||||||
|
formSuppressIncident, matchClusterGuid);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await AlertRoutingService.CreateRuleAsync(TenantId, formName.Trim(), formPriority, channelGuid,
|
||||||
|
formAlertName, formNamespace, formSeverity, formLabelKey, formLabelValue,
|
||||||
|
formSuppressIncident, matchClusterGuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
showForm = false;
|
||||||
|
editId = null;
|
||||||
|
await Load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelForm()
|
||||||
|
{
|
||||||
|
showForm = false;
|
||||||
|
editId = null;
|
||||||
|
formError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteRule(AlertRoutingRule rule)
|
||||||
|
{
|
||||||
|
await AlertRoutingService.DeleteRuleAsync(rule.Id);
|
||||||
|
await Load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunTest()
|
||||||
|
{
|
||||||
|
Dictionary<string, string>? labels = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(testLabelKey))
|
||||||
|
labels = new() { [testLabelKey.Trim()] = testLabelValue.Trim() };
|
||||||
|
|
||||||
|
Guid? clusterId = null;
|
||||||
|
if (Guid.TryParse(testClusterId, out Guid cid))
|
||||||
|
clusterId = cid;
|
||||||
|
|
||||||
|
testMatchedRule = await AlertRoutingService.MatchAsync(
|
||||||
|
TenantId,
|
||||||
|
clusterId,
|
||||||
|
testAlertName,
|
||||||
|
testSeverity,
|
||||||
|
string.IsNullOrWhiteSpace(testNamespace) ? null : testNamespace.Trim(),
|
||||||
|
labels);
|
||||||
|
|
||||||
|
// Reload the channel nav property from the already-loaded rules list
|
||||||
|
if (testMatchedRule is not null)
|
||||||
|
testMatchedRule = rules.FirstOrDefault(r => r.Id == testMatchedRule.Id) ?? testMatchedRule;
|
||||||
|
|
||||||
|
testRan = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
2910
src/EntKube.Web/Components/Pages/Tenants/AppEnvironmentDetail.razor
Normal file
2910
src/EntKube.Web/Components/Pages/Tenants/AppEnvironmentDetail.razor
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,826 @@
|
|||||||
|
@using EntKube.Web.Data
|
||||||
|
@using EntKube.Web.Services
|
||||||
|
@inject AppRouteService AppRouteService
|
||||||
|
@inject DeploymentService DeploymentService
|
||||||
|
@inject TenantService TenantService
|
||||||
|
@inject ComponentLifecycleService ComponentLifecycleService
|
||||||
|
@inject KubernetesOperationsService K8sOps
|
||||||
|
|
||||||
|
@* ═══════════════════════════════════════════════════════════════════
|
||||||
|
App External Access — ops panel for managing Gateway API HTTPRoutes.
|
||||||
|
Ops configures hostname + TLS (AppRoute); each deployment environment
|
||||||
|
attaches under a path prefix with target service (AppDeploymentRoute).
|
||||||
|
═══════════════════════════════════════════════════════════════════ *@
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<i class="bi bi-globe me-2 text-primary"></i>
|
||||||
|
<strong>External Access</strong>
|
||||||
|
</div>
|
||||||
|
@if (!IsPortalView)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-primary" @onclick="OpenAddRoute">
|
||||||
|
<i class="bi bi-plus me-1"></i>Add Hostname
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Add new AppRoute form (ops only) ── *@
|
||||||
|
@if (!IsPortalView && showAddRoute)
|
||||||
|
{
|
||||||
|
<div class="card-body border-bottom">
|
||||||
|
@if (addRouteError is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger py-1 small mb-2">@addRouteError</div>
|
||||||
|
}
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label class="form-label small">Hostname</label>
|
||||||
|
<input class="form-control form-control-sm" placeholder="app.example.com"
|
||||||
|
@bind="newHostname" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small">TLS Mode</label>
|
||||||
|
<select class="form-select form-select-sm" @bind="newTlsMode">
|
||||||
|
<option value="@TlsMode.ClusterIssuer">ClusterIssuer (auto)</option>
|
||||||
|
<option value="@TlsMode.Manual">Manual certificate</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (newTlsMode == TlsMode.ClusterIssuer)
|
||||||
|
{
|
||||||
|
@* Cluster selector drives the ClusterIssuer dropdown *@
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">Load issuers from cluster</label>
|
||||||
|
<select class="form-select form-select-sm" value="@issuerClusterId"
|
||||||
|
@onchange="OnIssuerClusterChanged">
|
||||||
|
<option value="">— pick cluster —</option>
|
||||||
|
@foreach (KubernetesCluster c in (tenantClusters ?? []))
|
||||||
|
{
|
||||||
|
<option value="@c.Id">@c.Name</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">ClusterIssuer</label>
|
||||||
|
@if (loadingIssuers)
|
||||||
|
{
|
||||||
|
<div class="form-control form-control-sm text-muted small">
|
||||||
|
<span class="spinner-border spinner-border-sm me-1"></span>Loading…
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (availableIssuers.Count > 0)
|
||||||
|
{
|
||||||
|
<select class="form-select form-select-sm" @bind="newClusterIssuer">
|
||||||
|
<option value="">— select —</option>
|
||||||
|
@foreach (string issuer in availableIssuers)
|
||||||
|
{
|
||||||
|
<option value="@issuer">@issuer</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<input class="form-control form-control-sm" placeholder="letsencrypt-prod"
|
||||||
|
@bind="newClusterIssuer" />
|
||||||
|
<div class="form-text small text-muted">
|
||||||
|
@(issuerClusterId == Guid.Empty ? "Pick a cluster above to load available issuers." : "No ClusterIssuers found — enter manually.")
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label small">Certificate (PEM)</label>
|
||||||
|
<textarea class="form-control form-control-sm font-monospace" rows="3"
|
||||||
|
placeholder="-----BEGIN CERTIFICATE-----" @bind="newTlsCert" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label small">Private Key (PEM, optional)</label>
|
||||||
|
<textarea class="form-control form-control-sm font-monospace" rows="3"
|
||||||
|
placeholder="-----BEGIN PRIVATE KEY-----" @bind="newTlsKey" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-1 mt-2">
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="AddRoute"
|
||||||
|
disabled="@(string.IsNullOrWhiteSpace(newHostname) || addingRoute)">
|
||||||
|
@if (addingRoute) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelAddRoute">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ── Route list ── *@
|
||||||
|
<div class="card-body p-0">
|
||||||
|
@if (loadError is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger m-3 py-2 small">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||||
|
Failed to load routes: @loadError
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<LoadingPanel Loading="@(routes is null && loadError is null)">
|
||||||
|
@if (routes is not null && routes.Count == 0 && !showAddRoute)
|
||||||
|
{
|
||||||
|
<div class="p-3">
|
||||||
|
<EmptyState Icon="bi-globe"
|
||||||
|
Message="No hostnames configured. Click 'Add Hostname' to expose this app externally." />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (routes is not null)
|
||||||
|
{
|
||||||
|
@foreach (AppRoute route in routes)
|
||||||
|
{
|
||||||
|
bool isExpanded = expandedRouteId == route.Id;
|
||||||
|
|
||||||
|
<div class="border-bottom">
|
||||||
|
@* ── Route header ── *@
|
||||||
|
<div class="d-flex align-items-center px-3 py-2 gap-2"
|
||||||
|
style="cursor: pointer;" @onclick="() => ToggleExpand(route.Id)">
|
||||||
|
<i class="bi @(isExpanded ? "bi-chevron-down" : "bi-chevron-right") text-muted small"></i>
|
||||||
|
<i class="bi bi-globe2 text-primary"></i>
|
||||||
|
<a href="https://@route.Hostname" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="fw-medium text-decoration-none" @onclick:stopPropagation="true">
|
||||||
|
@route.Hostname
|
||||||
|
</a>
|
||||||
|
@TlsBadge(route)
|
||||||
|
@if (!route.IsEnabled)
|
||||||
|
{
|
||||||
|
<span class="badge bg-secondary ms-1">Disabled</span>
|
||||||
|
}
|
||||||
|
<span class="badge bg-light text-dark border ms-1">
|
||||||
|
@route.DeploymentRoutes.Count deployment@(route.DeploymentRoutes.Count != 1 ? "s" : "")
|
||||||
|
</span>
|
||||||
|
@if (!IsPortalView)
|
||||||
|
{
|
||||||
|
<div class="ms-auto d-flex gap-1" @onclick:stopPropagation="true">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary py-0"
|
||||||
|
title="Toggle enabled"
|
||||||
|
@onclick="() => ToggleRouteEnabled(route)">
|
||||||
|
<i class="bi @(route.IsEnabled ? "bi-toggle-on text-success" : "bi-toggle-off text-muted")"></i>
|
||||||
|
</button>
|
||||||
|
@if (deleteConfirmRouteId == route.Id)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-danger py-0" @onclick="() => DeleteRoute(route.Id)">Confirm</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary py-0" @onclick="() => deleteConfirmRouteId = null">Cancel</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-danger py-0"
|
||||||
|
title="Delete hostname and all deployment routes"
|
||||||
|
@onclick="() => deleteConfirmRouteId = route.Id">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Deployment routes (expanded) ── *@
|
||||||
|
@if (isExpanded)
|
||||||
|
{
|
||||||
|
<div class="px-4 pb-2">
|
||||||
|
@if (applyRouteError is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger py-1 small mx-0 mb-2">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>Apply failed: @applyRouteError
|
||||||
|
<button type="button" class="btn-close btn-close-sm float-end"
|
||||||
|
@onclick="() => applyRouteError = null"></button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (route.DeploymentRoutes.Count > 0)
|
||||||
|
{
|
||||||
|
<table class="table table-sm table-hover mb-2">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th class="small">Deployment</th>
|
||||||
|
<th class="small">Env</th>
|
||||||
|
<th class="small">Path</th>
|
||||||
|
<th class="small">Service</th>
|
||||||
|
<th class="small">Cluster</th>
|
||||||
|
<th class="small">Health</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (AppDeploymentRoute dr in route.DeploymentRoutes.OrderBy(r => r.PathPrefix))
|
||||||
|
{
|
||||||
|
bool isApplying = applyingRouteId == dr.Id;
|
||||||
|
<tr>
|
||||||
|
<td class="small align-middle">@dr.AppDeployment.Name</td>
|
||||||
|
<td class="small align-middle text-muted">@dr.AppDeployment.Environment?.Name</td>
|
||||||
|
<td class="small align-middle"><code class="small">@dr.PathPrefix</code></td>
|
||||||
|
<td class="small align-middle text-muted">@dr.ServiceName:@dr.ServicePort</td>
|
||||||
|
<td class="small align-middle">@ClusterStatusBadge(dr)</td>
|
||||||
|
<td class="small align-middle">@HealthBadge(dr)</td>
|
||||||
|
<td class="text-end align-middle" style="white-space: nowrap;">
|
||||||
|
@if (dr.ClusterAppliedAt is null)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-primary py-0"
|
||||||
|
title="Apply HTTPRoute to cluster"
|
||||||
|
disabled="@isApplying"
|
||||||
|
@onclick="() => ApplyRouteToCluster(dr.Id)">
|
||||||
|
@if (isApplying)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<i class="bi bi-cloud-upload me-1"></i>
|
||||||
|
}
|
||||||
|
Apply to cluster
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-secondary py-0"
|
||||||
|
title="Re-apply to cluster"
|
||||||
|
disabled="@isApplying"
|
||||||
|
@onclick="() => ApplyRouteToCluster(dr.Id)">
|
||||||
|
@if (isApplying)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm"></span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<i class="bi bi-arrow-repeat"></i>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button class="btn btn-sm btn-outline-secondary py-0 ms-1"
|
||||||
|
title="View manifest YAML"
|
||||||
|
@onclick="() => ShowYaml(dr)">
|
||||||
|
<i class="bi bi-code-slash"></i>
|
||||||
|
</button>
|
||||||
|
@if (deleteConfirmDrId == dr.Id)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-danger py-0 ms-1"
|
||||||
|
@onclick="() => DeleteDeploymentRoute(dr.Id, route.Id)">Confirm</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary py-0 ms-1"
|
||||||
|
@onclick="() => deleteConfirmDrId = null">Cancel</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-danger py-0 ms-1"
|
||||||
|
title="Remove deployment route"
|
||||||
|
@onclick="() => deleteConfirmDrId = dr.Id">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ── Add deployment route form ── *@
|
||||||
|
@if (addDrRouteId == route.Id)
|
||||||
|
{
|
||||||
|
<div class="p-2 bg-light rounded mb-2">
|
||||||
|
@if (addDrError is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger py-1 small mb-2">@addDrError</div>
|
||||||
|
}
|
||||||
|
<div class="row g-2">
|
||||||
|
@* Deployment selector — loading services when changed *@
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">Deployment</label>
|
||||||
|
<select class="form-select form-select-sm"
|
||||||
|
value="@newDrDeploymentId"
|
||||||
|
@onchange="OnDeploymentSelected">
|
||||||
|
<option value="">— select —</option>
|
||||||
|
@foreach (AppDeployment d in (deployments ?? []))
|
||||||
|
{
|
||||||
|
<option value="@d.Id">@d.Name (@d.Environment?.Name)</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Path prefix *@
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small">Path Prefix</label>
|
||||||
|
<input class="form-control form-control-sm" placeholder="/"
|
||||||
|
@bind="newDrPath" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Service name — dropdown from K8s or fallback text *@
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small">
|
||||||
|
Service
|
||||||
|
@if (loadingServices)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm ms-1" style="width:.7rem;height:.7rem;"></span>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
@if (availableServices.Count > 0)
|
||||||
|
{
|
||||||
|
<select class="form-select form-select-sm"
|
||||||
|
value="@newDrService"
|
||||||
|
@onchange="OnServiceSelected">
|
||||||
|
<option value="">— select —</option>
|
||||||
|
@foreach (KubeServiceInfo svc in availableServices)
|
||||||
|
{
|
||||||
|
<option value="@svc.Name">@svc.Name (@svc.Type)</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<input class="form-control form-control-sm" placeholder="my-service"
|
||||||
|
@bind="newDrService" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Port — dropdown from selected service or fallback number *@
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small">Port</label>
|
||||||
|
@if (availablePorts.Count > 0)
|
||||||
|
{
|
||||||
|
<select class="form-select form-select-sm" @bind="newDrPort">
|
||||||
|
@foreach (KubeServicePort p in availablePorts)
|
||||||
|
{
|
||||||
|
<option value="@p.Port">@p.Port@(p.Name is not null ? $" ({p.Name})" : "")</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<input type="number" class="form-control form-control-sm"
|
||||||
|
@bind="newDrPort" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Endpoint health preview *@
|
||||||
|
@if (endpointSummary is not null && endpointSummary.HasEndpoints)
|
||||||
|
{
|
||||||
|
<div class="mt-1 small">
|
||||||
|
<span class="text-success me-2">
|
||||||
|
<i class="bi bi-circle-fill me-1" style="font-size:.5rem;vertical-align:middle;"></i>
|
||||||
|
@endpointSummary.TotalReady ready
|
||||||
|
</span>
|
||||||
|
@if (endpointSummary.TotalNotReady > 0)
|
||||||
|
{
|
||||||
|
<span class="text-warning">
|
||||||
|
<i class="bi bi-circle-fill me-1" style="font-size:.5rem;vertical-align:middle;"></i>
|
||||||
|
@endpointSummary.TotalNotReady not ready
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (endpointSummary is not null && !endpointSummary.HasEndpoints && !string.IsNullOrEmpty(newDrService))
|
||||||
|
{
|
||||||
|
<div class="mt-1 small text-warning">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>No endpoints — service has no ready pods.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="d-flex gap-1 mt-2">
|
||||||
|
<button class="btn btn-sm btn-primary"
|
||||||
|
disabled="@(newDrDeploymentId == Guid.Empty || string.IsNullOrWhiteSpace(newDrService) || addingDr)"
|
||||||
|
@onclick="() => AddDeploymentRoute(route.Id)">
|
||||||
|
@if (addingDr) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
Link Deployment
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelAddDr">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-secondary mb-1"
|
||||||
|
@onclick="() => StartAddDr(route.Id)">
|
||||||
|
<i class="bi bi-plus me-1"></i>Link Deployment
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</LoadingPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── YAML preview modal ── *@
|
||||||
|
@if (yamlPreview is not null)
|
||||||
|
{
|
||||||
|
<div class="modal fade show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header py-2">
|
||||||
|
<h6 class="modal-title mb-0">
|
||||||
|
<i class="bi bi-code-slash me-2"></i>HTTPRoute Manifest
|
||||||
|
</h6>
|
||||||
|
<button type="button" class="btn-close" @onclick="() => yamlPreview = null"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-0">
|
||||||
|
<pre class="m-0 p-3 bg-dark text-white small"
|
||||||
|
style="max-height: 400px; overflow-y: auto;"><code>@yamlPreview</code></pre>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer py-2">
|
||||||
|
<small class="text-muted me-auto">Apply this to the target cluster.</small>
|
||||||
|
<button class="btn btn-sm btn-secondary" @onclick="() => yamlPreview = null">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public required Data.App App { get; set; }
|
||||||
|
[Parameter] public Guid TenantId { get; set; }
|
||||||
|
[Parameter] public Guid? EnvironmentId { get; set; }
|
||||||
|
[Parameter] public bool IsPortalView { get; set; }
|
||||||
|
|
||||||
|
private List<AppRoute>? routes;
|
||||||
|
private List<AppDeployment>? deployments;
|
||||||
|
private List<KubernetesCluster>? tenantClusters;
|
||||||
|
|
||||||
|
// Add route form
|
||||||
|
private bool showAddRoute;
|
||||||
|
private string newHostname = "";
|
||||||
|
private TlsMode newTlsMode = TlsMode.ClusterIssuer;
|
||||||
|
private string newClusterIssuer = "";
|
||||||
|
private string newTlsCert = "";
|
||||||
|
private string newTlsKey = "";
|
||||||
|
private string? addRouteError;
|
||||||
|
private bool addingRoute;
|
||||||
|
|
||||||
|
// ClusterIssuer selector
|
||||||
|
private Guid issuerClusterId;
|
||||||
|
private bool loadingIssuers;
|
||||||
|
private List<string> availableIssuers = [];
|
||||||
|
|
||||||
|
// Deployment route form
|
||||||
|
private Guid? addDrRouteId;
|
||||||
|
private Guid newDrDeploymentId;
|
||||||
|
private string newDrPath = "/";
|
||||||
|
private string newDrService = "";
|
||||||
|
private int newDrPort = 80;
|
||||||
|
private string? addDrError;
|
||||||
|
private bool addingDr;
|
||||||
|
|
||||||
|
// Service / endpoint discovery
|
||||||
|
private bool loadingServices;
|
||||||
|
private List<KubeServiceInfo> availableServices = [];
|
||||||
|
private List<KubeServicePort> availablePorts = [];
|
||||||
|
private KubeEndpointSummary? endpointSummary;
|
||||||
|
|
||||||
|
// Expand / delete state
|
||||||
|
private Guid? expandedRouteId;
|
||||||
|
private Guid? deleteConfirmRouteId;
|
||||||
|
private Guid? deleteConfirmDrId;
|
||||||
|
|
||||||
|
// Apply to cluster
|
||||||
|
private Guid? applyingRouteId;
|
||||||
|
private string? applyRouteError;
|
||||||
|
|
||||||
|
// YAML preview
|
||||||
|
private string? yamlPreview;
|
||||||
|
private string? loadError;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
loadError = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await LoadAsync();
|
||||||
|
tenantClusters ??= await TenantService.GetClustersAsync(TenantId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
loadError = ex.Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
List<AppRoute> allRoutes = await AppRouteService.GetRoutesForAppAsync(App.Id);
|
||||||
|
List<AppDeployment> allDeployments = await DeploymentService.GetDeploymentsAsync(App.Id);
|
||||||
|
|
||||||
|
if (EnvironmentId.HasValue)
|
||||||
|
{
|
||||||
|
HashSet<Guid> envDeploymentIds = allDeployments
|
||||||
|
.Where(d => d.EnvironmentId == EnvironmentId.Value)
|
||||||
|
.Select(d => d.Id)
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
routes = allRoutes
|
||||||
|
.Where(r => r.DeploymentRoutes.Any(dr => envDeploymentIds.Contains(dr.AppDeploymentId))
|
||||||
|
|| r.DeploymentRoutes.Count == 0)
|
||||||
|
.ToList();
|
||||||
|
deployments = allDeployments.Where(d => d.EnvironmentId == EnvironmentId.Value).ToList();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
routes = allRoutes;
|
||||||
|
deployments = allDeployments;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenAddRoute()
|
||||||
|
{
|
||||||
|
showAddRoute = !showAddRoute;
|
||||||
|
if (showAddRoute)
|
||||||
|
{
|
||||||
|
newHostname = "";
|
||||||
|
newTlsMode = TlsMode.ClusterIssuer;
|
||||||
|
newClusterIssuer = "";
|
||||||
|
newTlsCert = "";
|
||||||
|
newTlsKey = "";
|
||||||
|
addRouteError = null;
|
||||||
|
issuerClusterId = Guid.Empty;
|
||||||
|
availableIssuers = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnIssuerClusterChanged(ChangeEventArgs e)
|
||||||
|
{
|
||||||
|
if (!Guid.TryParse(e.Value?.ToString(), out Guid clusterId) || clusterId == Guid.Empty)
|
||||||
|
{
|
||||||
|
issuerClusterId = Guid.Empty;
|
||||||
|
availableIssuers = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
issuerClusterId = clusterId;
|
||||||
|
loadingIssuers = true;
|
||||||
|
availableIssuers = [];
|
||||||
|
newClusterIssuer = "";
|
||||||
|
|
||||||
|
availableIssuers = await ComponentLifecycleService.ListClusterIssuersAsync(clusterId);
|
||||||
|
if (availableIssuers.Count == 1)
|
||||||
|
newClusterIssuer = availableIssuers[0];
|
||||||
|
|
||||||
|
loadingIssuers = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddRoute()
|
||||||
|
{
|
||||||
|
addRouteError = null;
|
||||||
|
addingRoute = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await AppRouteService.AddRouteAsync(App.Id, new AppRouteRequest
|
||||||
|
{
|
||||||
|
Hostname = newHostname,
|
||||||
|
TlsMode = newTlsMode,
|
||||||
|
ClusterIssuerName = newTlsMode == TlsMode.ClusterIssuer ? newClusterIssuer : null,
|
||||||
|
TlsCertificate = newTlsMode == TlsMode.Manual ? newTlsCert : null,
|
||||||
|
TlsPrivateKey = newTlsMode == TlsMode.Manual ? newTlsKey : null,
|
||||||
|
});
|
||||||
|
showAddRoute = false;
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
addRouteError = ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
addingRoute = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelAddRoute()
|
||||||
|
{
|
||||||
|
showAddRoute = false;
|
||||||
|
addRouteError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ToggleRouteEnabled(AppRoute route)
|
||||||
|
{
|
||||||
|
await AppRouteService.UpdateRouteAsync(route.Id, new AppRouteRequest
|
||||||
|
{
|
||||||
|
Hostname = route.Hostname,
|
||||||
|
TlsMode = route.TlsMode,
|
||||||
|
ClusterIssuerName = route.ClusterIssuerName,
|
||||||
|
TlsCertificate = route.TlsCertificate,
|
||||||
|
TlsPrivateKey = route.TlsPrivateKey,
|
||||||
|
IsEnabled = !route.IsEnabled
|
||||||
|
});
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteRoute(Guid routeId)
|
||||||
|
{
|
||||||
|
await AppRouteService.DeleteRouteAsync(routeId);
|
||||||
|
deleteConfirmRouteId = null;
|
||||||
|
if (expandedRouteId == routeId) expandedRouteId = null;
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ToggleExpand(Guid routeId)
|
||||||
|
{
|
||||||
|
expandedRouteId = expandedRouteId == routeId ? null : routeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StartAddDr(Guid routeId)
|
||||||
|
{
|
||||||
|
addDrRouteId = routeId;
|
||||||
|
newDrDeploymentId = Guid.Empty;
|
||||||
|
newDrPath = "/";
|
||||||
|
newDrService = "";
|
||||||
|
newDrPort = 80;
|
||||||
|
addDrError = null;
|
||||||
|
availableServices = [];
|
||||||
|
availablePorts = [];
|
||||||
|
endpointSummary = null;
|
||||||
|
|
||||||
|
// If there's only one deployment, pre-select it and load its services.
|
||||||
|
if (deployments?.Count == 1)
|
||||||
|
await SelectDeployment(deployments[0].Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelAddDr()
|
||||||
|
{
|
||||||
|
addDrRouteId = null;
|
||||||
|
addDrError = null;
|
||||||
|
availableServices = [];
|
||||||
|
availablePorts = [];
|
||||||
|
endpointSummary = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnDeploymentSelected(ChangeEventArgs e)
|
||||||
|
{
|
||||||
|
if (!Guid.TryParse(e.Value?.ToString(), out Guid id) || id == Guid.Empty)
|
||||||
|
{
|
||||||
|
newDrDeploymentId = Guid.Empty;
|
||||||
|
availableServices = [];
|
||||||
|
availablePorts = [];
|
||||||
|
endpointSummary = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await SelectDeployment(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SelectDeployment(Guid deploymentId)
|
||||||
|
{
|
||||||
|
newDrDeploymentId = deploymentId;
|
||||||
|
newDrService = "";
|
||||||
|
availablePorts = [];
|
||||||
|
endpointSummary = null;
|
||||||
|
|
||||||
|
AppDeployment? deployment = deployments?.FirstOrDefault(d => d.Id == deploymentId);
|
||||||
|
if (deployment?.Cluster is null) return;
|
||||||
|
|
||||||
|
loadingServices = true;
|
||||||
|
availableServices = await K8sOps.GetServicesInNamespaceAsync(deployment.Cluster.Id, deployment.Namespace);
|
||||||
|
loadingServices = false;
|
||||||
|
|
||||||
|
// Auto-select if there's only one service.
|
||||||
|
if (availableServices.Count == 1)
|
||||||
|
await SelectService(availableServices[0].Name, deploymentId, deployment.Namespace, deployment.Cluster.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnServiceSelected(ChangeEventArgs e)
|
||||||
|
{
|
||||||
|
string svcName = e.Value?.ToString() ?? "";
|
||||||
|
newDrService = svcName;
|
||||||
|
availablePorts = [];
|
||||||
|
endpointSummary = null;
|
||||||
|
if (string.IsNullOrEmpty(svcName)) return;
|
||||||
|
|
||||||
|
AppDeployment? deployment = deployments?.FirstOrDefault(d => d.Id == newDrDeploymentId);
|
||||||
|
if (deployment?.Cluster is null) return;
|
||||||
|
|
||||||
|
await SelectService(svcName, newDrDeploymentId, deployment.Namespace, deployment.Cluster.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SelectService(string serviceName, Guid deploymentId, string ns, Guid clusterId)
|
||||||
|
{
|
||||||
|
newDrService = serviceName;
|
||||||
|
|
||||||
|
KubeServiceInfo? svc = availableServices.FirstOrDefault(s => s.Name == serviceName);
|
||||||
|
availablePorts = svc?.Ports ?? [];
|
||||||
|
if (availablePorts.Count > 0) newDrPort = availablePorts[0].Port;
|
||||||
|
|
||||||
|
endpointSummary = await K8sOps.GetEndpointsForServiceAsync(clusterId, ns, serviceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddDeploymentRoute(Guid routeId)
|
||||||
|
{
|
||||||
|
addDrError = null;
|
||||||
|
addingDr = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await AppRouteService.AddDeploymentRouteAsync(routeId, newDrDeploymentId, new AppDeploymentRouteRequest
|
||||||
|
{
|
||||||
|
ServiceName = newDrService,
|
||||||
|
ServicePort = newDrPort,
|
||||||
|
PathPrefix = newDrPath
|
||||||
|
});
|
||||||
|
CancelAddDr();
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
addDrError = ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
addingDr = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteDeploymentRoute(Guid drId, Guid routeId)
|
||||||
|
{
|
||||||
|
await K8sOps.DeleteDeploymentRouteFromClusterAsync(drId);
|
||||||
|
await AppRouteService.DeleteDeploymentRouteAsync(drId);
|
||||||
|
deleteConfirmDrId = null;
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ApplyRouteToCluster(Guid drId)
|
||||||
|
{
|
||||||
|
applyingRouteId = drId;
|
||||||
|
applyRouteError = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
KubernetesOperationResult<string> result = await K8sOps.ApplyDeploymentRouteAsync(drId);
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
applyRouteError = result.Error;
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex) { applyRouteError = ex.Message; }
|
||||||
|
finally { applyingRouteId = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowYaml(AppDeploymentRoute dr)
|
||||||
|
{
|
||||||
|
yamlPreview = AppRouteService.GenerateManifestYaml(dr);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RenderFragment TlsBadge(AppRoute route) => __builder =>
|
||||||
|
{
|
||||||
|
if (route.TlsMode == TlsMode.ClusterIssuer)
|
||||||
|
{
|
||||||
|
__builder.OpenElement(0, "span");
|
||||||
|
__builder.AddAttribute(1, "class", "badge bg-success bg-opacity-75 small");
|
||||||
|
__builder.AddAttribute(2, "title", $"Auto TLS via {route.ClusterIssuerName}");
|
||||||
|
__builder.AddContent(3, $"TLS auto · {route.ClusterIssuerName}");
|
||||||
|
__builder.CloseElement();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
__builder.OpenElement(0, "span");
|
||||||
|
__builder.AddAttribute(1, "class", "badge bg-warning text-dark small");
|
||||||
|
__builder.AddAttribute(2, "title", "Manual TLS certificate");
|
||||||
|
__builder.AddContent(3, "TLS manual");
|
||||||
|
__builder.CloseElement();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static RenderFragment ClusterStatusBadge(AppDeploymentRoute dr) => __builder =>
|
||||||
|
{
|
||||||
|
if (dr.ClusterAppliedAt is null)
|
||||||
|
{
|
||||||
|
__builder.OpenElement(0, "span");
|
||||||
|
__builder.AddAttribute(1, "class", "badge bg-warning text-dark small");
|
||||||
|
__builder.AddAttribute(2, "title", "HTTPRoute not yet applied to cluster");
|
||||||
|
__builder.AddContent(3, "not applied");
|
||||||
|
__builder.CloseElement();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
__builder.OpenElement(0, "span");
|
||||||
|
__builder.AddAttribute(1, "class", "badge bg-success bg-opacity-75 small");
|
||||||
|
__builder.AddAttribute(2, "title", $"Applied {dr.ClusterAppliedAt:yyyy-MM-dd HH:mm} UTC");
|
||||||
|
__builder.AddContent(3, $"applied {dr.ClusterAppliedAt:dd MMM HH:mm}");
|
||||||
|
__builder.CloseElement();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static RenderFragment HealthBadge(AppDeploymentRoute dr) => __builder =>
|
||||||
|
{
|
||||||
|
if (dr.IsReachable == true)
|
||||||
|
{
|
||||||
|
__builder.OpenElement(0, "span");
|
||||||
|
__builder.AddAttribute(1, "class", "badge bg-success bg-opacity-75 small");
|
||||||
|
__builder.AddContent(2, $"✓ {dr.LastStatusCode}");
|
||||||
|
__builder.CloseElement();
|
||||||
|
}
|
||||||
|
else if (dr.IsReachable == false)
|
||||||
|
{
|
||||||
|
__builder.OpenElement(0, "span");
|
||||||
|
__builder.AddAttribute(1, "class", "badge bg-danger bg-opacity-75 small");
|
||||||
|
__builder.AddContent(2, dr.LastStatusCode.HasValue ? $"✗ {dr.LastStatusCode}" : "✗ unreachable");
|
||||||
|
__builder.CloseElement();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
__builder.OpenElement(0, "span");
|
||||||
|
__builder.AddAttribute(1, "class", "badge bg-secondary bg-opacity-50 small");
|
||||||
|
__builder.AddContent(2, "—");
|
||||||
|
__builder.CloseElement();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
@using EntKube.Web.Data
|
@using EntKube.Web.Data
|
||||||
@using EntKube.Web.Services
|
@using EntKube.Web.Services
|
||||||
|
@implements IDisposable
|
||||||
@inject DeploymentService DeploymentService
|
@inject DeploymentService DeploymentService
|
||||||
@inject KubernetesOperationsService K8sOps
|
@inject KubernetesOperationsService K8sOps
|
||||||
|
|
||||||
@@ -166,6 +167,7 @@ else
|
|||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter, EditorRequired] public Guid AppId { get; set; }
|
[Parameter, EditorRequired] public Guid AppId { get; set; }
|
||||||
|
[Parameter] public Guid? EnvironmentId { get; set; }
|
||||||
|
|
||||||
private List<AppDeployment>? deployments;
|
private List<AppDeployment>? deployments;
|
||||||
|
|
||||||
@@ -176,15 +178,24 @@ else
|
|||||||
private List<DeploymentResource>? tree;
|
private List<DeploymentResource>? tree;
|
||||||
private DateTime? _lastRefreshed;
|
private DateTime? _lastRefreshed;
|
||||||
|
|
||||||
|
// Auto-refresh
|
||||||
|
private PeriodicTimer? _refreshTimer;
|
||||||
|
private CancellationTokenSource? _timerCts;
|
||||||
|
private const int AutoRefreshSeconds = 15;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
deployments = await DeploymentService.GetDeploymentsAsync(AppId);
|
List<AppDeployment> all = await DeploymentService.GetDeploymentsAsync(AppId);
|
||||||
|
deployments = EnvironmentId.HasValue
|
||||||
|
? all.Where(d => d.EnvironmentId == EnvironmentId.Value).ToList()
|
||||||
|
: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SelectDeployment(AppDeployment d)
|
private async Task SelectDeployment(AppDeployment d)
|
||||||
{
|
{
|
||||||
selectedDeployment = d;
|
selectedDeployment = d;
|
||||||
await LoadTreeForSelected();
|
await LoadTreeForSelected();
|
||||||
|
StartAutoRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RefreshSelected()
|
private async Task RefreshSelected()
|
||||||
@@ -221,14 +232,50 @@ else
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void StartAutoRefresh()
|
||||||
|
{
|
||||||
|
StopAutoRefresh();
|
||||||
|
_timerCts = new CancellationTokenSource();
|
||||||
|
_ = RunAutoRefreshAsync(_timerCts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunAutoRefreshAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
_refreshTimer = new PeriodicTimer(TimeSpan.FromSeconds(AutoRefreshSeconds));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (await _refreshTimer.WaitForNextTickAsync(ct))
|
||||||
|
{
|
||||||
|
await InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
await LoadTreeForSelected();
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopAutoRefresh()
|
||||||
|
{
|
||||||
|
_timerCts?.Cancel();
|
||||||
|
_timerCts?.Dispose();
|
||||||
|
_timerCts = null;
|
||||||
|
_refreshTimer?.Dispose();
|
||||||
|
_refreshTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
private void BackToGrid()
|
private void BackToGrid()
|
||||||
{
|
{
|
||||||
|
StopAutoRefresh();
|
||||||
selectedDeployment = null;
|
selectedDeployment = null;
|
||||||
tree = null;
|
tree = null;
|
||||||
treeError = null;
|
treeError = null;
|
||||||
_lastRefreshed = null;
|
_lastRefreshed = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Dispose() => StopAutoRefresh();
|
||||||
|
|
||||||
// ── Visual helpers ────────────────────────────────────────────────────────
|
// ── Visual helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static string HealthColor(HealthStatus h) => h switch
|
private static string HealthColor(HealthStatus h) => h switch
|
||||||
|
|||||||
@@ -73,6 +73,11 @@
|
|||||||
<i class="bi bi-heart-pulse me-1"></i>Monitoring
|
<i class="bi bi-heart-pulse me-1"></i>Monitoring
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link @(section == "nodes" ? "active" : "")" @onclick='() => section = "nodes"'>
|
||||||
|
<i class="bi bi-hdd-stack me-1"></i>Nodes
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link @(section == "components" ? "active" : "")" @onclick='() => section = "components"'>
|
<button class="nav-link @(section == "components" ? "active" : "")" @onclick='() => section = "components"'>
|
||||||
<i class="bi bi-puzzle me-1"></i>Components
|
<i class="bi bi-puzzle me-1"></i>Components
|
||||||
@@ -89,6 +94,10 @@
|
|||||||
{
|
{
|
||||||
<ClusterMonitoring ClusterId="Cluster.Id" />
|
<ClusterMonitoring ClusterId="Cluster.Id" />
|
||||||
}
|
}
|
||||||
|
else if (section == "nodes")
|
||||||
|
{
|
||||||
|
<ClusterNodes ClusterId="Cluster.Id" />
|
||||||
|
}
|
||||||
else if (section == "logs")
|
else if (section == "logs")
|
||||||
{
|
{
|
||||||
<LogBrowser ClusterId="Cluster.Id" />
|
<LogBrowser ClusterId="Cluster.Id" />
|
||||||
|
|||||||
848
src/EntKube.Web/Components/Pages/Tenants/ClusterNodes.razor
Normal file
848
src/EntKube.Web/Components/Pages/Tenants/ClusterNodes.razor
Normal file
@@ -0,0 +1,848 @@
|
|||||||
|
@using EntKube.Web.Data
|
||||||
|
@using EntKube.Web.Services
|
||||||
|
@inject NodeManagementService NodeService
|
||||||
|
@inject AuthenticationStateProvider AuthState
|
||||||
|
|
||||||
|
@* ═══════════════════════════════════════════════════════════════════
|
||||||
|
ClusterNodes — live node management + server inventory panel.
|
||||||
|
Shows all K8s nodes with cordon/drain/uncordon actions, detailed
|
||||||
|
condition/label/taint inspection, pods-per-node, and the server
|
||||||
|
inventory records that back each node.
|
||||||
|
═══════════════════════════════════════════════════════════════════ *@
|
||||||
|
|
||||||
|
@if (loading)
|
||||||
|
{
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<div class="spinner-border spinner-border-sm me-2"></div>Loading nodes…
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (loadError is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning d-flex align-items-center gap-2">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||||
|
<span>@loadError</span>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary ms-auto" @onclick="LoadAsync">Retry</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||||
|
<span class="text-muted small">@nodes.Count node@(nodes.Count == 1 ? "" : "s") in cluster</span>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="LoadAsync" disabled="@loading">
|
||||||
|
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Node table ──────────────────────────────────────────── *@
|
||||||
|
<div class="list-group list-group-flush mb-4">
|
||||||
|
@foreach (NodeInfo node in nodes)
|
||||||
|
{
|
||||||
|
bool expanded = expandedNode == node.Name;
|
||||||
|
ClusterServer? server = servers.FirstOrDefault(s => s.NodeName == node.Name);
|
||||||
|
|
||||||
|
<div class="list-group-item p-0 border rounded mb-2 shadow-sm">
|
||||||
|
|
||||||
|
@* ── Row header ── *@
|
||||||
|
<div class="d-flex align-items-center gap-2 p-3" style="cursor:pointer"
|
||||||
|
@onclick="() => ToggleExpand(node.Name)">
|
||||||
|
|
||||||
|
@* Status dot *@
|
||||||
|
<span class="@(node.Ready ? "text-success" : "text-danger")" title="@(node.Ready ? "Ready" : "Not Ready")">
|
||||||
|
<i class="bi @(node.Ready ? "bi-circle-fill" : "bi-exclamation-circle-fill")"></i>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="fw-semibold">@node.Name</span>
|
||||||
|
|
||||||
|
@* Schedulability badge *@
|
||||||
|
@if (!node.Schedulable)
|
||||||
|
{
|
||||||
|
<span class="badge bg-warning text-dark">cordoned</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Roles *@
|
||||||
|
@foreach (string role in node.Roles)
|
||||||
|
{
|
||||||
|
<span class="badge bg-secondary bg-opacity-10 text-secondary border">@role</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Taints indicator *@
|
||||||
|
@if (node.Taints.Count > 0)
|
||||||
|
{
|
||||||
|
<span class="badge bg-warning bg-opacity-10 text-warning border border-warning"
|
||||||
|
title="@string.Join(", ", node.Taints.Select(t => $"{t.Key}={t.Value}:{t.Effect}"))">
|
||||||
|
<i class="bi bi-shield-exclamation me-1"></i>@node.Taints.Count taint@(node.Taints.Count == 1 ? "" : "s")
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* OS + version *@
|
||||||
|
<span class="text-muted small ms-auto">@node.OsImage</span>
|
||||||
|
<span class="text-muted small">@node.KubeletVersion</span>
|
||||||
|
|
||||||
|
@* Server link icon *@
|
||||||
|
@if (server is not null)
|
||||||
|
{
|
||||||
|
<i class="bi bi-server text-primary" title="Server: @server.DisplayName"></i>
|
||||||
|
}
|
||||||
|
|
||||||
|
<i class="bi @(expanded ? "bi-chevron-up" : "bi-chevron-down") text-muted ms-1"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Action buttons (visible without expanding) ── *@
|
||||||
|
<div class="d-flex gap-2 px-3 pb-3 pt-0">
|
||||||
|
@if (node.Schedulable)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-warning"
|
||||||
|
@onclick="() => CordonAsync(node.Name)"
|
||||||
|
@onclick:stopPropagation
|
||||||
|
disabled="@IsBusy(node.Name)">
|
||||||
|
<i class="bi bi-slash-circle me-1"></i>Cordon
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-success"
|
||||||
|
@onclick="() => UncordonAsync(node.Name)"
|
||||||
|
@onclick:stopPropagation
|
||||||
|
disabled="@IsBusy(node.Name)">
|
||||||
|
<i class="bi bi-check-circle me-1"></i>Uncordon
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (drainConfirmNode == node.Name)
|
||||||
|
{
|
||||||
|
<div class="d-flex align-items-center gap-2" @onclick:stopPropagation>
|
||||||
|
<span class="text-danger small"><i class="bi bi-exclamation-triangle me-1"></i>Evicts all pods from <strong>@node.Name</strong>. Continue?</span>
|
||||||
|
<button class="btn btn-sm btn-danger" @onclick="() => DrainConfirmedAsync(node.Name)" disabled="@IsBusy(node.Name)">Drain</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => drainConfirmNode = null">Cancel</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-danger"
|
||||||
|
@onclick="() => drainConfirmNode = node.Name"
|
||||||
|
@onclick:stopPropagation
|
||||||
|
disabled="@IsBusy(node.Name)">
|
||||||
|
<i class="bi bi-water me-1"></i>Drain
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (IsBusy(node.Name))
|
||||||
|
{
|
||||||
|
<span class="text-muted small align-self-center">
|
||||||
|
<span class="spinner-border spinner-border-sm me-1"></span>@busyMessage
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (actionResults.TryGetValue(node.Name, out (bool Ok, string Msg) r))
|
||||||
|
{
|
||||||
|
<span class="small align-self-center @(r.Ok ? "text-success" : "text-danger")">
|
||||||
|
<i class="bi @(r.Ok ? "bi-check-circle" : "bi-x-circle") me-1"></i>@r.Msg
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Expanded detail ── *@
|
||||||
|
@if (expanded)
|
||||||
|
{
|
||||||
|
<div class="border-top p-3">
|
||||||
|
<ul class="nav nav-pills nav-sm gap-1 mb-3">
|
||||||
|
@foreach (string tab in new[] { "overview", "conditions", "labels", "taints", "pods", "server" })
|
||||||
|
{
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link py-1 px-2 @(detailTab == tab ? "active" : "")"
|
||||||
|
@onclick="() => detailTab = tab">
|
||||||
|
@TabLabel(tab)
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
@* Overview *@
|
||||||
|
@if (detailTab == "overview")
|
||||||
|
{
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<table class="table table-sm table-borderless mb-0">
|
||||||
|
<tbody>
|
||||||
|
<tr><th class="text-muted fw-normal small" style="width:40%">Architecture</th><td class="small">@(node.Architecture ?? "—")</td></tr>
|
||||||
|
<tr><th class="text-muted fw-normal small">OS Image</th><td class="small">@(node.OsImage ?? "—")</td></tr>
|
||||||
|
<tr><th class="text-muted fw-normal small">Kernel</th><td class="small">@(node.KernelVersion ?? "—")</td></tr>
|
||||||
|
<tr><th class="text-muted fw-normal small">Container Runtime</th><td class="small">@(node.ContainerRuntime ?? "—")</td></tr>
|
||||||
|
<tr><th class="text-muted fw-normal small">Kubelet Version</th><td class="small">@(node.KubeletVersion ?? "—")</td></tr>
|
||||||
|
<tr><th class="text-muted fw-normal small">Age</th><td class="small">@(node.CreatedAt.HasValue ? FormatAge(node.CreatedAt.Value) : "—")</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<table class="table table-sm table-borderless mb-0">
|
||||||
|
<tbody>
|
||||||
|
<tr><th class="text-muted fw-normal small" style="width:50%">CPU (capacity)</th><td class="small">@(node.CpuCapacity ?? "—")</td></tr>
|
||||||
|
<tr><th class="text-muted fw-normal small">CPU (allocatable)</th><td class="small">@(node.CpuAllocatable ?? "—")</td></tr>
|
||||||
|
<tr><th class="text-muted fw-normal small">Memory (capacity)</th><td class="small">@(FormatMemory(node.MemoryCapacity))</td></tr>
|
||||||
|
<tr><th class="text-muted fw-normal small">Memory (allocatable)</th><td class="small">@(FormatMemory(node.MemoryAllocatable))</td></tr>
|
||||||
|
<tr><th class="text-muted fw-normal small">Ephemeral storage</th><td class="small">@(FormatMemory(node.EphemeralStorageAllocatable))</td></tr>
|
||||||
|
<tr><th class="text-muted fw-normal small">Max pods</th><td class="small">@(node.MaxPodsAllocatable?.ToString() ?? "—")</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
@if (node.Addresses.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="mt-2">
|
||||||
|
@foreach (string addr in node.Addresses)
|
||||||
|
{
|
||||||
|
<span class="badge bg-light text-dark border me-1 mb-1">@addr</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Conditions *@
|
||||||
|
else if (detailTab == "conditions")
|
||||||
|
{
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<thead><tr><th>Type</th><th>Status</th><th>Reason</th><th>Message</th><th>Since</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (NodeConditionInfo cond in node.Conditions)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td class="fw-semibold small">@cond.Type</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge @ConditionBadge(cond)">@cond.Status</span>
|
||||||
|
</td>
|
||||||
|
<td class="small text-muted">@(cond.Reason ?? "—")</td>
|
||||||
|
<td class="small text-muted" style="max-width:300px;white-space:normal">@(cond.Message ?? "—")</td>
|
||||||
|
<td class="small text-muted">@(cond.LastTransitionTime.HasValue ? FormatAge(cond.LastTransitionTime.Value) : "—")</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Labels *@
|
||||||
|
else if (detailTab == "labels")
|
||||||
|
{
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="d-flex flex-wrap gap-1 mb-2">
|
||||||
|
@foreach ((string k, string v) in node.Labels)
|
||||||
|
{
|
||||||
|
<span class="badge bg-primary bg-opacity-10 text-primary border border-primary font-monospace small">
|
||||||
|
@k=@v
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@if (node.Labels.Count == 0)
|
||||||
|
{
|
||||||
|
<span class="text-muted small">No custom labels.</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Add label form *@
|
||||||
|
<div class="card card-body bg-light border-0 p-2">
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-5">
|
||||||
|
<label class="form-label small mb-1">Key</label>
|
||||||
|
<input class="form-control form-control-sm font-monospace" @bind="newLabelKey" placeholder="e.g. topology.kubernetes.io/zone" />
|
||||||
|
</div>
|
||||||
|
<div class="col-5">
|
||||||
|
<label class="form-label small mb-1">Value</label>
|
||||||
|
<input class="form-control form-control-sm font-monospace" @bind="newLabelValue" placeholder="us-east-1a" />
|
||||||
|
</div>
|
||||||
|
<div class="col-2">
|
||||||
|
<button class="btn btn-sm btn-primary w-100"
|
||||||
|
@onclick="() => SetLabelAsync(node.Name)"
|
||||||
|
disabled="@(string.IsNullOrWhiteSpace(newLabelKey))">
|
||||||
|
Set
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small mt-2 mb-0">Leave value blank to remove the label.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Taints *@
|
||||||
|
else if (detailTab == "taints")
|
||||||
|
{
|
||||||
|
@if (node.Taints.Count > 0)
|
||||||
|
{
|
||||||
|
<table class="table table-sm mb-2">
|
||||||
|
<thead><tr><th>Key</th><th>Value</th><th>Effect</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (NodeTaintInfo taint in node.Taints)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td class="font-monospace small">@taint.Key</td>
|
||||||
|
<td class="font-monospace small text-muted">@(taint.Value ?? "—")</td>
|
||||||
|
<td><span class="badge @TaintEffectBadge(taint.Effect)">@taint.Effect</span></td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-outline-danger py-0"
|
||||||
|
@onclick="() => RemoveTaintAsync(node.Name, node.Taints, taint)">
|
||||||
|
<i class="bi bi-x"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="text-muted small mb-2">No taints on this node.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Add taint form *@
|
||||||
|
<div class="card card-body bg-light border-0 p-2">
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-4">
|
||||||
|
<label class="form-label small mb-1">Key</label>
|
||||||
|
<input class="form-control form-control-sm font-monospace" @bind="newTaintKey" placeholder="dedicated" />
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<label class="form-label small mb-1">Value</label>
|
||||||
|
<input class="form-control form-control-sm font-monospace" @bind="newTaintValue" placeholder="gpu" />
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<label class="form-label small mb-1">Effect</label>
|
||||||
|
<select class="form-select form-select-sm" @bind="newTaintEffect">
|
||||||
|
<option value="NoSchedule">NoSchedule</option>
|
||||||
|
<option value="PreferNoSchedule">PreferNoSchedule</option>
|
||||||
|
<option value="NoExecute">NoExecute</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-2">
|
||||||
|
<button class="btn btn-sm btn-primary w-100"
|
||||||
|
@onclick="() => AddTaintAsync(node.Name, node.Taints)"
|
||||||
|
disabled="@(string.IsNullOrWhiteSpace(newTaintKey))">
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Pods on node *@
|
||||||
|
else if (detailTab == "pods")
|
||||||
|
{
|
||||||
|
@if (loadingPods)
|
||||||
|
{
|
||||||
|
<div class="text-muted small">
|
||||||
|
<span class="spinner-border spinner-border-sm me-1"></span>Loading pods…
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (nodePods.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-muted small mb-0">No pods running on this node.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Namespace</th><th>Pod</th><th>Status</th><th>Ready</th><th>Restarts</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (PodInfo pod in nodePods)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td class="small text-muted">@pod.Namespace</td>
|
||||||
|
<td class="small font-monospace">@pod.Name</td>
|
||||||
|
<td><span class="badge @PodStatusBadge(pod.Status)">@pod.Status</span></td>
|
||||||
|
<td class="small">@pod.ReadyContainers/@pod.TotalContainers</td>
|
||||||
|
<td class="small @(pod.Restarts > 5 ? "text-danger" : "")">@pod.Restarts</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Server inventory *@
|
||||||
|
else if (detailTab == "server")
|
||||||
|
{
|
||||||
|
@if (editingServer is null)
|
||||||
|
{
|
||||||
|
@if (server is not null)
|
||||||
|
{
|
||||||
|
<table class="table table-sm table-borderless mb-2">
|
||||||
|
<tbody>
|
||||||
|
<tr><th class="text-muted fw-normal small" style="width:30%">Display name</th><td class="small">@server.DisplayName</td></tr>
|
||||||
|
<tr><th class="text-muted fw-normal small">Provider</th><td class="small">@server.Provider</td></tr>
|
||||||
|
<tr><th class="text-muted fw-normal small">IP address</th><td class="small font-monospace">@(server.IpAddress ?? "—")</td></tr>
|
||||||
|
<tr><th class="text-muted fw-normal small">Management IP</th><td class="small font-monospace">@(server.ManagementIpAddress ?? "—")</td></tr>
|
||||||
|
<tr><th class="text-muted fw-normal small">OS</th><td class="small">@(server.OsDistribution ?? "—")</td></tr>
|
||||||
|
<tr><th class="text-muted fw-normal small">Hardware</th><td class="small">@ServerHardwareSummary(server)</td></tr>
|
||||||
|
<tr><th class="text-muted fw-normal small">Location</th><td class="small">@(server.Location ?? "—")</td></tr>
|
||||||
|
<tr><th class="text-muted fw-normal small">SSH</th><td class="small font-monospace">@ServerSshSummary(server)</td></tr>
|
||||||
|
@if (!string.IsNullOrEmpty(server.JumpHost))
|
||||||
|
{
|
||||||
|
<tr><th class="text-muted fw-normal small">Jump host</th><td class="small font-monospace">@server.JumpHost</td></tr>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrEmpty(server.Notes))
|
||||||
|
{
|
||||||
|
<tr><th class="text-muted fw-normal small">Notes</th><td class="small">@server.Notes</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-sm btn-outline-primary"
|
||||||
|
@onclick="() => StartEditServer(server, node.Name)">
|
||||||
|
<i class="bi bi-pencil me-1"></i>Edit
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger"
|
||||||
|
@onclick="() => DeleteServerAsync(server.Id)">
|
||||||
|
<i class="bi bi-trash me-1"></i>Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="text-muted small mb-2">No server record linked to this node.</p>
|
||||||
|
<button class="btn btn-sm btn-outline-success"
|
||||||
|
@onclick="() => StartEditServer(null, node.Name)">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i>Add server record
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<ServerEditForm Server="editingServer"
|
||||||
|
OnSave="SaveServerAsync"
|
||||||
|
OnCancel="CancelEditServer" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Unlinked servers ───────────────────────────────────────── *@
|
||||||
|
@if (UnlinkedServers.Count > 0 || showAddUnlinked)
|
||||||
|
{
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-header bg-white d-flex align-items-center justify-content-between py-2">
|
||||||
|
<span><i class="bi bi-server me-2"></i><strong>Unlinked server records</strong></span>
|
||||||
|
<button class="btn btn-sm btn-outline-success"
|
||||||
|
@onclick="() => { showAddUnlinked = true; StartEditServer(null, null); }">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i>Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
@if (UnlinkedServers.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-muted small p-3 mb-0">No unlinked server records.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<thead><tr><th>Name</th><th>IP</th><th>Provider</th><th>Hardware</th><th>Location</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (ClusterServer s in UnlinkedServers)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td class="small fw-semibold">@s.DisplayName</td>
|
||||||
|
<td class="small font-monospace">@(s.IpAddress ?? "—")</td>
|
||||||
|
<td class="small">@s.Provider</td>
|
||||||
|
<td class="small">@ServerHardwareSummary(s)</td>
|
||||||
|
<td class="small text-muted">@(s.Location ?? "—")</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-outline-primary py-0 me-1"
|
||||||
|
@onclick="() => StartEditServer(s, s.NodeName)">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger py-0"
|
||||||
|
@onclick="() => DeleteServerAsync(s.Id)">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (showAddUnlinked && editingServer is not null)
|
||||||
|
{
|
||||||
|
<div class="p-3 border-top">
|
||||||
|
<ServerEditForm Server="editingServer"
|
||||||
|
OnSave="SaveServerAsync"
|
||||||
|
OnCancel="CancelEditServer" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ══════════════════════════════════════════════════════════════════
|
||||||
|
Inline server edit form component
|
||||||
|
══════════════════════════════════════════════════════════════════ *@
|
||||||
|
@* Defined as a nested private RenderFragment-style via code — see below *@
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter, EditorRequired] public Guid ClusterId { get; set; }
|
||||||
|
|
||||||
|
private List<ClusterServer> UnlinkedServers => servers
|
||||||
|
.Where(s => string.IsNullOrEmpty(s.NodeName) || !nodes.Any(n => n.Name == s.NodeName))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// ── State ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private List<NodeInfo> nodes = [];
|
||||||
|
private List<ClusterServer> servers = [];
|
||||||
|
private string? loadError;
|
||||||
|
private bool loading = true;
|
||||||
|
|
||||||
|
private string? expandedNode;
|
||||||
|
private string detailTab = "overview";
|
||||||
|
|
||||||
|
// busy state per-node
|
||||||
|
private string? busyNode;
|
||||||
|
private string busyMessage = "";
|
||||||
|
private string? drainConfirmNode;
|
||||||
|
private Dictionary<string, (bool Ok, string Msg)> actionResults = new();
|
||||||
|
|
||||||
|
// pods-on-node
|
||||||
|
private List<PodInfo> nodePods = [];
|
||||||
|
private bool loadingPods;
|
||||||
|
|
||||||
|
// label form
|
||||||
|
private string newLabelKey = "";
|
||||||
|
private string newLabelValue = "";
|
||||||
|
|
||||||
|
// taint form
|
||||||
|
private string newTaintKey = "";
|
||||||
|
private string newTaintValue = "";
|
||||||
|
private string newTaintEffect = "NoSchedule";
|
||||||
|
|
||||||
|
// server edit
|
||||||
|
private ClusterServer? editingServer;
|
||||||
|
private bool showAddUnlinked;
|
||||||
|
|
||||||
|
// ── Lifecycle ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync() => await LoadAsync();
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
loading = true;
|
||||||
|
loadError = null;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
Task<KubernetesOperationResult<List<NodeInfo>>> nodeTask = NodeService.GetNodesAsync(ClusterId);
|
||||||
|
Task<List<ClusterServer>> serverTask = NodeService.GetServersAsync(ClusterId);
|
||||||
|
await Task.WhenAll(nodeTask, serverTask);
|
||||||
|
KubernetesOperationResult<List<NodeInfo>> nodeResult = nodeTask.Result;
|
||||||
|
List<ClusterServer> serverList = serverTask.Result;
|
||||||
|
|
||||||
|
if (nodeResult.IsSuccess)
|
||||||
|
nodes = nodeResult.Data!;
|
||||||
|
else
|
||||||
|
loadError = nodeResult.Error;
|
||||||
|
|
||||||
|
servers = serverList;
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Expand / tabs ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task ToggleExpand(string nodeName)
|
||||||
|
{
|
||||||
|
if (expandedNode == nodeName)
|
||||||
|
{
|
||||||
|
expandedNode = null;
|
||||||
|
nodePods = [];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
expandedNode = nodeName;
|
||||||
|
detailTab = "overview";
|
||||||
|
await LoadPodsAsync(nodeName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadPodsAsync(string nodeName)
|
||||||
|
{
|
||||||
|
loadingPods = true;
|
||||||
|
nodePods = [];
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
KubernetesOperationResult<List<PodInfo>> result =
|
||||||
|
await NodeService.GetPodsOnNodeAsync(ClusterId, nodeName);
|
||||||
|
|
||||||
|
if (result.IsSuccess) nodePods = result.Data!;
|
||||||
|
loadingPods = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch to pods tab and reload if needed
|
||||||
|
private async Task SwitchTabAsync(string tab)
|
||||||
|
{
|
||||||
|
detailTab = tab;
|
||||||
|
if (tab == "pods" && expandedNode is not null && nodePods.Count == 0 && !loadingPods)
|
||||||
|
await LoadPodsAsync(expandedNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Node actions ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
private bool IsBusy(string nodeName) => busyNode == nodeName;
|
||||||
|
|
||||||
|
private async Task CordonAsync(string nodeName)
|
||||||
|
{
|
||||||
|
await RunNodeActionAsync(nodeName, "Cordoning…", async () =>
|
||||||
|
{
|
||||||
|
string? user = await GetCurrentUserAsync();
|
||||||
|
return await NodeService.CordonNodeAsync(ClusterId, nodeName, user);
|
||||||
|
}, "Cordoned", "Cordon failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UncordonAsync(string nodeName)
|
||||||
|
{
|
||||||
|
await RunNodeActionAsync(nodeName, "Uncordoning…", async () =>
|
||||||
|
{
|
||||||
|
string? user = await GetCurrentUserAsync();
|
||||||
|
return await NodeService.UncordonNodeAsync(ClusterId, nodeName, user);
|
||||||
|
}, "Uncordoned", "Uncordon failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DrainConfirmedAsync(string nodeName)
|
||||||
|
{
|
||||||
|
drainConfirmNode = null;
|
||||||
|
await RunNodeActionAsync(nodeName, "Draining…", async () =>
|
||||||
|
{
|
||||||
|
string? user = await GetCurrentUserAsync();
|
||||||
|
KubernetesOperationResult<string> r = await NodeService.DrainNodeAsync(
|
||||||
|
ClusterId, nodeName, performedBy: user);
|
||||||
|
return r.IsSuccess
|
||||||
|
? KubernetesOperationResult.Success()
|
||||||
|
: KubernetesOperationResult.Failure(r.Error ?? "Drain failed");
|
||||||
|
}, "Drained", "Drain failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunNodeActionAsync(
|
||||||
|
string nodeName, string progressMsg,
|
||||||
|
Func<Task<KubernetesOperationResult>> action,
|
||||||
|
string successMsg, string failPrefix)
|
||||||
|
{
|
||||||
|
busyNode = nodeName;
|
||||||
|
busyMessage = progressMsg;
|
||||||
|
actionResults.Remove(nodeName);
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
KubernetesOperationResult result = await action();
|
||||||
|
|
||||||
|
busyNode = null;
|
||||||
|
actionResults[nodeName] = result.IsSuccess
|
||||||
|
? (true, successMsg)
|
||||||
|
: (false, result.Error ?? failPrefix);
|
||||||
|
|
||||||
|
if (result.IsSuccess) await LoadAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Label management ──────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task SetLabelAsync(string nodeName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(newLabelKey)) return;
|
||||||
|
string? user = await GetCurrentUserAsync();
|
||||||
|
string? value = string.IsNullOrWhiteSpace(newLabelValue) ? null : newLabelValue.Trim();
|
||||||
|
|
||||||
|
KubernetesOperationResult result = await NodeService.SetNodeLabelAsync(
|
||||||
|
ClusterId, nodeName, newLabelKey.Trim(), value, user);
|
||||||
|
|
||||||
|
actionResults[nodeName] = result.IsSuccess
|
||||||
|
? (true, value is null ? "Label removed" : "Label set")
|
||||||
|
: (false, result.Error ?? "Failed");
|
||||||
|
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
newLabelKey = "";
|
||||||
|
newLabelValue = "";
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Taint management ──────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task AddTaintAsync(string nodeName, List<NodeTaintInfo> existing)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(newTaintKey)) return;
|
||||||
|
string? user = await GetCurrentUserAsync();
|
||||||
|
|
||||||
|
List<NodeTaintInfo> updated = [.. existing, new NodeTaintInfo
|
||||||
|
{
|
||||||
|
Key = newTaintKey.Trim(),
|
||||||
|
Value = string.IsNullOrWhiteSpace(newTaintValue) ? null : newTaintValue.Trim(),
|
||||||
|
Effect = newTaintEffect
|
||||||
|
}];
|
||||||
|
|
||||||
|
KubernetesOperationResult result = await NodeService.SetNodeTaintsAsync(
|
||||||
|
ClusterId, nodeName, updated, user);
|
||||||
|
|
||||||
|
actionResults[nodeName] = result.IsSuccess
|
||||||
|
? (true, "Taint added") : (false, result.Error ?? "Failed");
|
||||||
|
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
newTaintKey = "";
|
||||||
|
newTaintValue = "";
|
||||||
|
newTaintEffect = "NoSchedule";
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RemoveTaintAsync(string nodeName, List<NodeTaintInfo> existing, NodeTaintInfo remove)
|
||||||
|
{
|
||||||
|
string? user = await GetCurrentUserAsync();
|
||||||
|
List<NodeTaintInfo> updated = existing
|
||||||
|
.Where(t => !(t.Key == remove.Key && t.Effect == remove.Effect))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
KubernetesOperationResult result = await NodeService.SetNodeTaintsAsync(
|
||||||
|
ClusterId, nodeName, updated, user);
|
||||||
|
|
||||||
|
actionResults[nodeName] = result.IsSuccess
|
||||||
|
? (true, "Taint removed") : (false, result.Error ?? "Failed");
|
||||||
|
|
||||||
|
if (result.IsSuccess) await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Server inventory ──────────────────────────────────────────
|
||||||
|
|
||||||
|
private void StartEditServer(ClusterServer? existing, string? nodeName)
|
||||||
|
{
|
||||||
|
editingServer = existing is null
|
||||||
|
? new ClusterServer { Id = Guid.Empty, ClusterId = ClusterId, DisplayName = "", NodeName = nodeName }
|
||||||
|
: new ClusterServer
|
||||||
|
{
|
||||||
|
Id = existing.Id,
|
||||||
|
ClusterId = existing.ClusterId,
|
||||||
|
NodeName = existing.NodeName,
|
||||||
|
DisplayName = existing.DisplayName,
|
||||||
|
IpAddress = existing.IpAddress,
|
||||||
|
ManagementIpAddress = existing.ManagementIpAddress,
|
||||||
|
Provider = existing.Provider,
|
||||||
|
OsDistribution = existing.OsDistribution,
|
||||||
|
CpuCores = existing.CpuCores,
|
||||||
|
RamGb = existing.RamGb,
|
||||||
|
DiskGb = existing.DiskGb,
|
||||||
|
Location = existing.Location,
|
||||||
|
SshUser = existing.SshUser,
|
||||||
|
SshPort = existing.SshPort,
|
||||||
|
JumpHost = existing.JumpHost,
|
||||||
|
Notes = existing.Notes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveServerAsync(ClusterServer server)
|
||||||
|
{
|
||||||
|
await NodeService.SaveServerAsync(server);
|
||||||
|
editingServer = null;
|
||||||
|
showAddUnlinked = false;
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelEditServer()
|
||||||
|
{
|
||||||
|
editingServer = null;
|
||||||
|
showAddUnlinked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteServerAsync(Guid serverId)
|
||||||
|
{
|
||||||
|
await NodeService.DeleteServerAsync(serverId);
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task<string?> GetCurrentUserAsync()
|
||||||
|
{
|
||||||
|
AuthenticationState state = await AuthState.GetAuthenticationStateAsync();
|
||||||
|
return state.User.Identity?.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatAge(DateTime dt)
|
||||||
|
{
|
||||||
|
TimeSpan age = DateTime.UtcNow - dt.ToUniversalTime();
|
||||||
|
if (age.TotalDays >= 1) return $"{(int)age.TotalDays}d";
|
||||||
|
if (age.TotalHours >= 1) return $"{(int)age.TotalHours}h";
|
||||||
|
return $"{(int)age.TotalMinutes}m";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatMemory(string? raw)
|
||||||
|
{
|
||||||
|
if (raw is null) return "—";
|
||||||
|
if (raw.EndsWith("Ki") && long.TryParse(raw[..^2], out long ki))
|
||||||
|
{
|
||||||
|
double gb = ki / 1024.0 / 1024.0;
|
||||||
|
return gb >= 1 ? $"{gb:F1} GiB" : $"{ki / 1024.0:F0} MiB";
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ServerHardwareSummary(ClusterServer s)
|
||||||
|
{
|
||||||
|
List<string> parts = [];
|
||||||
|
if (s.CpuCores.HasValue) parts.Add($"{s.CpuCores} vCPU");
|
||||||
|
if (s.RamGb.HasValue) parts.Add($"{s.RamGb} GB RAM");
|
||||||
|
if (s.DiskGb.HasValue) parts.Add($"{s.DiskGb} GB disk");
|
||||||
|
return parts.Count > 0 ? string.Join(", ", parts) : "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ServerSshSummary(ClusterServer s)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(s.SshUser) && string.IsNullOrEmpty(s.IpAddress)) return "—";
|
||||||
|
string host = s.IpAddress ?? "?";
|
||||||
|
string user = string.IsNullOrEmpty(s.SshUser) ? "" : s.SshUser + "@";
|
||||||
|
int port = s.SshPort == 22 ? 0 : s.SshPort;
|
||||||
|
string portSuffix = port > 0 ? $":{port}" : "";
|
||||||
|
return $"{user}{host}{portSuffix}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ConditionBadge(NodeConditionInfo cond) => cond.Type switch
|
||||||
|
{
|
||||||
|
"Ready" when cond.Status == "True" => "bg-success",
|
||||||
|
"Ready" when cond.Status != "True" => "bg-danger",
|
||||||
|
"MemoryPressure" when cond.Status == "True" => "bg-warning text-dark",
|
||||||
|
"DiskPressure" when cond.Status == "True" => "bg-warning text-dark",
|
||||||
|
"PIDPressure" when cond.Status == "True" => "bg-warning text-dark",
|
||||||
|
"NetworkUnavailable" when cond.Status == "True" => "bg-danger",
|
||||||
|
_ when cond.Status == "False" => "bg-success bg-opacity-75",
|
||||||
|
_ => "bg-secondary"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string TaintEffectBadge(string effect) => effect switch
|
||||||
|
{
|
||||||
|
"NoExecute" => "bg-danger",
|
||||||
|
"NoSchedule" => "bg-warning text-dark",
|
||||||
|
_ => "bg-secondary"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string PodStatusBadge(string status) => status switch
|
||||||
|
{
|
||||||
|
"Running" => "bg-success",
|
||||||
|
"Pending" => "bg-warning text-dark",
|
||||||
|
"Succeeded" => "bg-secondary",
|
||||||
|
"Failed" => "bg-danger",
|
||||||
|
_ => "bg-light text-dark border"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string TabLabel(string tab) => tab switch
|
||||||
|
{
|
||||||
|
"overview" => "Overview",
|
||||||
|
"conditions" => "Conditions",
|
||||||
|
"labels" => "Labels",
|
||||||
|
"taints" => "Taints",
|
||||||
|
"pods" => "Pods",
|
||||||
|
"server" => "Server",
|
||||||
|
_ => tab
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ══════════════════════════════════════════════════════════════════
|
||||||
|
Nested server edit form — defined as a separate component
|
||||||
|
══════════════════════════════════════════════════════════════════ *@
|
||||||
|
|
||||||
|
@* This would normally be a separate file but is declared here
|
||||||
|
as a partial-style inline component via a RenderFragment approach.
|
||||||
|
Instead we use a sibling file ServerEditForm.razor below. *@
|
||||||
@@ -4,7 +4,6 @@
|
|||||||
@inject TenantService TenantService
|
@inject TenantService TenantService
|
||||||
@inject CustomerAccessService CustomerAccessService
|
@inject CustomerAccessService CustomerAccessService
|
||||||
@inject PrometheusService PrometheusService
|
@inject PrometheusService PrometheusService
|
||||||
@inject CustomerGitService CustomerGitService
|
|
||||||
|
|
||||||
@* ===========================================================================
|
@* ===========================================================================
|
||||||
Three-level drill-down: Customers ▸ Apps ▸ App Detail
|
Three-level drill-down: Customers ▸ Apps ▸ App Detail
|
||||||
@@ -162,203 +161,6 @@ else if (selectedCustomer is not null)
|
|||||||
}
|
}
|
||||||
</LoadingPanel>
|
</LoadingPanel>
|
||||||
|
|
||||||
@* --- Git Repo URL Policies --- *@
|
|
||||||
<div class="card shadow-sm mt-4">
|
|
||||||
<div class="card-header bg-white py-2">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<i class="bi bi-git me-2 text-primary"></i>
|
|
||||||
<strong>Git Repo URL Policies</strong>
|
|
||||||
</div>
|
|
||||||
<small class="text-muted">Allowed git repository URL patterns for this customer. Supports <code>*</code> (within a path segment) and <code>**</code> (across segments).</small>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="input-group mb-3">
|
|
||||||
<input type="text" class="form-control form-control-sm font-monospace"
|
|
||||||
placeholder="e.g. https://github.com/acme/* or git@github.com:acme/**"
|
|
||||||
@bind="newRepoPolicyPattern" @bind:event="oninput"
|
|
||||||
@onkeydown="@(async (e) => { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newRepoPolicyPattern)) await AddRepoPolicy(); })" />
|
|
||||||
<button class="btn btn-sm btn-outline-primary" @onclick="AddRepoPolicy"
|
|
||||||
disabled="@string.IsNullOrWhiteSpace(newRepoPolicyPattern)">
|
|
||||||
<i class="bi bi-plus me-1"></i>Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
@if (!string.IsNullOrEmpty(repoPolicyError))
|
|
||||||
{
|
|
||||||
<div class="alert alert-danger py-1 small mb-2">@repoPolicyError</div>
|
|
||||||
}
|
|
||||||
<LoadingPanel Loading="@(gitRepoPolicies is null)">
|
|
||||||
@if (gitRepoPolicies is not null && gitRepoPolicies.Count == 0)
|
|
||||||
{
|
|
||||||
<EmptyState Icon="bi-slash-circle"
|
|
||||||
Message="No URL patterns defined. All repo URLs are implicitly denied for this customer." />
|
|
||||||
}
|
|
||||||
else if (gitRepoPolicies is not null)
|
|
||||||
{
|
|
||||||
@foreach (CustomerGitRepoPolicy policy in gitRepoPolicies)
|
|
||||||
{
|
|
||||||
<div class="d-flex align-items-center justify-content-between py-1 border-bottom">
|
|
||||||
<code class="small">@policy.UrlPattern</code>
|
|
||||||
@if (confirmDeletePolicyId == policy.Id)
|
|
||||||
{
|
|
||||||
<div class="d-flex gap-1">
|
|
||||||
<button class="btn btn-sm btn-danger" @onclick="() => DeleteRepoPolicy(policy.Id)">Remove</button>
|
|
||||||
<button class="btn btn-sm btn-link p-0" @onclick="() => confirmDeletePolicyId = null">Cancel</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<button class="btn btn-sm btn-outline-danger" title="Remove pattern"
|
|
||||||
@onclick="() => confirmDeletePolicyId = policy.Id">
|
|
||||||
<i class="bi bi-x"></i>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</LoadingPanel>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@* --- Git Credentials --- *@
|
|
||||||
<div class="card shadow-sm mt-4">
|
|
||||||
<div class="card-header bg-white py-2">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<i class="bi bi-key me-2 text-primary"></i>
|
|
||||||
<strong>Git Credentials</strong>
|
|
||||||
</div>
|
|
||||||
<small class="text-muted">Reusable credential sets for this customer's git repositories.</small>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
@* --- Add credential form --- *@
|
|
||||||
<div class="row g-2 align-items-end mb-3">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label class="form-label small mb-1">Name</label>
|
|
||||||
<input type="text" class="form-control form-control-sm" placeholder="e.g. GitHub PAT"
|
|
||||||
@bind="newCredName" @bind:event="oninput" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label small mb-1">Auth type</label>
|
|
||||||
<select class="form-select form-select-sm" @bind="newCredAuthType">
|
|
||||||
<option value="@GitAuthType.None">None (public)</option>
|
|
||||||
<option value="@GitAuthType.HttpsPat">HTTPS – PAT</option>
|
|
||||||
<option value="@GitAuthType.HttpsPassword">HTTPS – Password</option>
|
|
||||||
<option value="@GitAuthType.SshKey">SSH Key</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
@if (newCredAuthType == GitAuthType.HttpsPassword)
|
|
||||||
{
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label small mb-1">Username</label>
|
|
||||||
<input type="text" class="form-control form-control-sm" placeholder="e.g. myuser"
|
|
||||||
@bind="newCredUsername" @bind:event="oninput" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="col-auto">
|
|
||||||
<button class="btn btn-sm btn-outline-primary" @onclick="AddCredential"
|
|
||||||
disabled="@string.IsNullOrWhiteSpace(newCredName)">
|
|
||||||
<i class="bi bi-plus me-1"></i>Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@if (!string.IsNullOrEmpty(credentialError))
|
|
||||||
{
|
|
||||||
<div class="alert alert-danger py-1 small mb-2">@credentialError</div>
|
|
||||||
}
|
|
||||||
@if (!string.IsNullOrEmpty(credentialSuccess))
|
|
||||||
{
|
|
||||||
<div class="alert alert-success py-1 small mb-2">@credentialSuccess</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<LoadingPanel Loading="@(gitCredentials is null)">
|
|
||||||
@if (gitCredentials is not null && gitCredentials.Count == 0)
|
|
||||||
{
|
|
||||||
<EmptyState Icon="bi-key" Message="No credentials configured yet." />
|
|
||||||
}
|
|
||||||
else if (gitCredentials is not null)
|
|
||||||
{
|
|
||||||
@foreach (CustomerGitCredential cred in gitCredentials)
|
|
||||||
{
|
|
||||||
<div class="border rounded p-3 mb-2">
|
|
||||||
<div class="d-flex align-items-start justify-content-between">
|
|
||||||
<div>
|
|
||||||
<span class="fw-semibold"><i class="bi bi-key me-1 text-primary"></i>@cred.Name</span>
|
|
||||||
<span class="badge bg-secondary ms-2">@cred.AuthType</span>
|
|
||||||
@if (cred.AuthType == GitAuthType.HttpsPassword && cred.Username is not null)
|
|
||||||
{
|
|
||||||
<span class="text-muted small ms-2">(@cred.Username)</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@if (confirmDeleteCredentialId == cred.Id)
|
|
||||||
{
|
|
||||||
<div class="d-flex gap-1">
|
|
||||||
<button class="btn btn-sm btn-danger" @onclick="() => DeleteCredential(cred.Id)">Delete</button>
|
|
||||||
<button class="btn btn-sm btn-link p-0" @onclick="() => confirmDeleteCredentialId = null">Cancel</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<button class="btn btn-sm btn-outline-danger" title="Delete credential"
|
|
||||||
@onclick="() => confirmDeleteCredentialId = cred.Id">
|
|
||||||
<i class="bi bi-trash"></i>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@* --- Secret value form --- *@
|
|
||||||
@if (cred.AuthType != GitAuthType.None)
|
|
||||||
{
|
|
||||||
<div class="mt-2">
|
|
||||||
@if (cred.AuthType == GitAuthType.HttpsPat)
|
|
||||||
{
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<span class="input-group-text">PAT</span>
|
|
||||||
<input type="password" class="form-control form-control-sm"
|
|
||||||
placeholder="Personal Access Token"
|
|
||||||
@bind="credSecretValues[cred.Id]" @bind:event="oninput" />
|
|
||||||
<button class="btn btn-sm btn-outline-secondary"
|
|
||||||
@onclick="() => SaveCredentialSecret(cred)">
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else if (cred.AuthType == GitAuthType.HttpsPassword)
|
|
||||||
{
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<span class="input-group-text">Password</span>
|
|
||||||
<input type="password" class="form-control form-control-sm"
|
|
||||||
placeholder="Password"
|
|
||||||
@bind="credSecretValues[cred.Id]" @bind:event="oninput" />
|
|
||||||
<button class="btn btn-sm btn-outline-secondary"
|
|
||||||
@onclick="() => SaveCredentialSecret(cred)">
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else if (cred.AuthType == GitAuthType.SshKey)
|
|
||||||
{
|
|
||||||
<textarea class="form-control form-control-sm font-monospace mb-1"
|
|
||||||
rows="3" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
|
||||||
@bind="credSecretValues[cred.Id]" @bind:event="oninput"></textarea>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary"
|
|
||||||
@onclick="() => SaveCredentialSecret(cred)">
|
|
||||||
Save SSH Key
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
@if (credSaveMessages.TryGetValue(cred.Id, out string? msg) && msg is not null)
|
|
||||||
{
|
|
||||||
<div class="small mt-1 @(credSaveOk.GetValueOrDefault(cred.Id) ? "text-success" : "text-danger")">
|
|
||||||
<i class="bi @(credSaveOk.GetValueOrDefault(cred.Id) ? "bi-check-circle" : "bi-x-circle") me-1"></i>@msg
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</LoadingPanel>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@* --- Portal Access Management --- *@
|
@* --- Portal Access Management --- *@
|
||||||
<div class="card shadow-sm mt-4">
|
<div class="card shadow-sm mt-4">
|
||||||
<div class="card-header bg-white py-2">
|
<div class="card-header bg-white py-2">
|
||||||
@@ -546,23 +348,6 @@ else
|
|||||||
private string? accessError;
|
private string? accessError;
|
||||||
private string? accessSuccess;
|
private string? accessSuccess;
|
||||||
|
|
||||||
// --- Git repo policies ---
|
|
||||||
private List<CustomerGitRepoPolicy>? gitRepoPolicies;
|
|
||||||
private string newRepoPolicyPattern = "";
|
|
||||||
private string? repoPolicyError;
|
|
||||||
private Guid? confirmDeletePolicyId;
|
|
||||||
|
|
||||||
// --- Git credentials ---
|
|
||||||
private List<CustomerGitCredential>? gitCredentials;
|
|
||||||
private string newCredName = "";
|
|
||||||
private GitAuthType newCredAuthType = GitAuthType.HttpsPat;
|
|
||||||
private string newCredUsername = "";
|
|
||||||
private string? credentialError;
|
|
||||||
private string? credentialSuccess;
|
|
||||||
private Dictionary<Guid, string> credSecretValues = new();
|
|
||||||
private Dictionary<Guid, string?> credSaveMessages = new();
|
|
||||||
private Dictionary<Guid, bool> credSaveOk = new();
|
|
||||||
private Guid? confirmDeleteCredentialId;
|
|
||||||
|
|
||||||
// --- Portal access ---
|
// --- Portal access ---
|
||||||
private string? confirmRevokeUserId;
|
private string? confirmRevokeUserId;
|
||||||
@@ -621,20 +406,8 @@ else
|
|||||||
accessSuccess = null;
|
accessSuccess = null;
|
||||||
customerView = "apps";
|
customerView = "apps";
|
||||||
customerMetrics = null;
|
customerMetrics = null;
|
||||||
newRepoPolicyPattern = "";
|
|
||||||
repoPolicyError = null;
|
|
||||||
newCredName = "";
|
|
||||||
newCredAuthType = GitAuthType.HttpsPat;
|
|
||||||
newCredUsername = "";
|
|
||||||
credentialError = null;
|
|
||||||
credentialSuccess = null;
|
|
||||||
credSecretValues = new();
|
|
||||||
credSaveMessages = new();
|
|
||||||
credSaveOk = new();
|
|
||||||
await LoadApps();
|
await LoadApps();
|
||||||
await LoadAccesses();
|
await LoadAccesses();
|
||||||
await LoadGitRepoPolicies();
|
|
||||||
await LoadGitCredentials();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OpenCustomerMetrics()
|
private async Task OpenCustomerMetrics()
|
||||||
@@ -745,115 +518,6 @@ else
|
|||||||
await LoadAccesses();
|
await LoadAccesses();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────── Git Repo Policies ────────────────
|
|
||||||
|
|
||||||
private async Task LoadGitRepoPolicies()
|
|
||||||
{
|
|
||||||
gitRepoPolicies = await CustomerGitService.GetRepoPoliciesAsync(selectedCustomer!.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task AddRepoPolicy()
|
|
||||||
{
|
|
||||||
repoPolicyError = null;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await CustomerGitService.AddRepoPolicyAsync(selectedCustomer!.Id, newRepoPolicyPattern.Trim());
|
|
||||||
newRepoPolicyPattern = "";
|
|
||||||
await LoadGitRepoPolicies();
|
|
||||||
}
|
|
||||||
catch (DbUpdateException)
|
|
||||||
{
|
|
||||||
repoPolicyError = "That URL pattern already exists for this customer.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DeleteRepoPolicy(Guid policyId)
|
|
||||||
{
|
|
||||||
confirmDeletePolicyId = null;
|
|
||||||
await CustomerGitService.DeleteRepoPolicyAsync(selectedCustomer!.Id, policyId);
|
|
||||||
await LoadGitRepoPolicies();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ──────────────── Git Credentials ────────────────
|
|
||||||
|
|
||||||
private async Task LoadGitCredentials()
|
|
||||||
{
|
|
||||||
gitCredentials = await CustomerGitService.GetCredentialsAsync(selectedCustomer!.Id);
|
|
||||||
foreach (CustomerGitCredential cred in gitCredentials)
|
|
||||||
credSecretValues.TryAdd(cred.Id, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task AddCredential()
|
|
||||||
{
|
|
||||||
credentialError = null;
|
|
||||||
credentialSuccess = null;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await CustomerGitService.CreateCredentialAsync(
|
|
||||||
selectedCustomer!.Id, TenantId,
|
|
||||||
newCredName.Trim(), newCredAuthType,
|
|
||||||
newCredAuthType == GitAuthType.HttpsPassword ? newCredUsername : null);
|
|
||||||
newCredName = "";
|
|
||||||
newCredAuthType = GitAuthType.HttpsPat;
|
|
||||||
newCredUsername = "";
|
|
||||||
await LoadGitCredentials();
|
|
||||||
}
|
|
||||||
catch (DbUpdateException)
|
|
||||||
{
|
|
||||||
credentialError = "A credential with that name already exists for this customer.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DeleteCredential(Guid credentialId)
|
|
||||||
{
|
|
||||||
confirmDeleteCredentialId = null;
|
|
||||||
credentialError = null;
|
|
||||||
await CustomerGitService.DeleteCredentialAsync(selectedCustomer!.Id, credentialId);
|
|
||||||
credSecretValues.Remove(credentialId);
|
|
||||||
credSaveMessages.Remove(credentialId);
|
|
||||||
credSaveOk.Remove(credentialId);
|
|
||||||
await LoadGitCredentials();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SaveCredentialSecret(CustomerGitCredential cred)
|
|
||||||
{
|
|
||||||
credSaveMessages[cred.Id] = null;
|
|
||||||
|
|
||||||
if (!credSecretValues.TryGetValue(cred.Id, out string? value) || string.IsNullOrWhiteSpace(value))
|
|
||||||
{
|
|
||||||
credSaveMessages[cred.Id] = "Value cannot be empty.";
|
|
||||||
credSaveOk[cred.Id] = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
switch (cred.AuthType)
|
|
||||||
{
|
|
||||||
case GitAuthType.HttpsPat:
|
|
||||||
await CustomerGitService.SetPatAsync(TenantId, cred.Id, value);
|
|
||||||
break;
|
|
||||||
case GitAuthType.HttpsPassword:
|
|
||||||
await CustomerGitService.SetPasswordAsync(TenantId, cred.Id, value);
|
|
||||||
break;
|
|
||||||
case GitAuthType.SshKey:
|
|
||||||
await CustomerGitService.SetSshKeyAsync(TenantId, cred.Id, value);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
credSecretValues[cred.Id] = "";
|
|
||||||
credSaveMessages[cred.Id] = "Saved.";
|
|
||||||
credSaveOk[cred.Id] = true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
credSaveMessages[cred.Id] = ex.Message;
|
|
||||||
credSaveOk[cred.Id] = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetRoleBadgeClass(CustomerAccessRole role) => role switch
|
private static string GetRoleBadgeClass(CustomerAccessRole role) => role switch
|
||||||
{
|
{
|
||||||
CustomerAccessRole.Admin => "bg-danger",
|
CustomerAccessRole.Admin => "bg-danger",
|
||||||
|
|||||||
@@ -2,32 +2,86 @@
|
|||||||
@using EntKube.Web.Services
|
@using EntKube.Web.Services
|
||||||
@inject AppGovernanceService GovernanceService
|
@inject AppGovernanceService GovernanceService
|
||||||
@inject TenantService TenantService
|
@inject TenantService TenantService
|
||||||
|
@inject DeploymentService DeploymentService
|
||||||
|
@inject StorageService StorageService
|
||||||
|
|
||||||
@* ═══════════════════════════════════════════════════════════════════
|
@* ═══════════════════════════════════════════════════════════════════
|
||||||
GovernanceTab — tenant admin view for app-level namespace, resource
|
GovernanceTab — per-environment governance: resource quotas,
|
||||||
quotas, network policies, and RBAC. All changes are saved to the
|
network policies, and RBAC for this app in this specific environment.
|
||||||
database. "Apply to clusters" pushes to every cluster where this
|
Namespace is derived from each deployment (not stored here).
|
||||||
app has a deployment.
|
|
||||||
═══════════════════════════════════════════════════════════════════ *@
|
═══════════════════════════════════════════════════════════════════ *@
|
||||||
|
|
||||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h5 class="mb-0"><i class="bi bi-shield-check me-2 text-primary"></i>Governance</h5>
|
<h5 class="mb-0"><i class="bi bi-shield-check me-2 text-primary"></i>Governance</h5>
|
||||||
<small class="text-muted">Namespace, quotas, network policies, and RBAC — read-only in the customer portal.</small>
|
<small class="text-muted">Quota, network policies, and RBAC for this environment. Applied to each deployment's namespace on its cluster.</small>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 align-items-center">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="OpenCopyFrom">
|
||||||
|
<i class="bi bi-copy me-1"></i>Copy from env
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-success" @onclick="ApplyToEnvironmentClusters" disabled="@applying">
|
||||||
|
@if (applying) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
else { <i class="bi bi-cloud-upload me-1"></i> }
|
||||||
|
Apply to clusters
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-sm btn-success" @onclick="ApplyToAllClusters" disabled="@(applying || string.IsNullOrWhiteSpace(ns))">
|
|
||||||
@if (applying) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
|
||||||
else { <i class="bi bi-cloud-upload me-1"></i> }
|
|
||||||
Apply to clusters
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@* ── Copy from environment ── *@
|
||||||
|
@if (showCopyFrom)
|
||||||
|
{
|
||||||
|
<div class="card border-info mb-3">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<i class="bi bi-copy text-info"></i>
|
||||||
|
<span class="small fw-medium">Copy governance from:</span>
|
||||||
|
<select class="form-select form-select-sm w-auto" @bind="copyFromEnvId">
|
||||||
|
<option value="@Guid.Empty">Select source environment…</option>
|
||||||
|
@if (otherEnvironments is not null)
|
||||||
|
{
|
||||||
|
@foreach (Data.Environment env in otherEnvironments)
|
||||||
|
{
|
||||||
|
<option value="@env.Id">@env.Name</option>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
@if (!confirmCopy)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-info" @onclick="() => confirmCopy = true"
|
||||||
|
disabled="@(copyFromEnvId == Guid.Empty)">
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="small text-info">This will overwrite all governance in <strong>this environment</strong>.</span>
|
||||||
|
<button class="btn btn-sm btn-danger" @onclick="DoCopyFrom" disabled="@copying">
|
||||||
|
@if (copying) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
Yes, overwrite
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmCopy = false">Cancel</button>
|
||||||
|
}
|
||||||
|
<button class="btn btn-sm btn-link text-muted p-0 ms-auto" @onclick="() => showCopyFrom = false">
|
||||||
|
<i class="bi bi-x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (copyMessage is not null)
|
||||||
|
{
|
||||||
|
<div class="mt-1 small @(copyOk ? "text-success" : "text-danger")">
|
||||||
|
<i class="bi @(copyOk ? "bi-check-circle" : "bi-x-circle") me-1"></i>@copyMessage
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (applyOutput is not null)
|
@if (applyOutput is not null)
|
||||||
{
|
{
|
||||||
<div class="alert @(applySuccess ? "alert-success" : "alert-danger") py-2 small mb-3 d-flex align-items-start justify-content-between">
|
<div class="alert @(applySuccess ? "alert-success" : "alert-danger") py-2 small mb-3 d-flex align-items-start justify-content-between">
|
||||||
<div>
|
<div>
|
||||||
<i class="bi @(applySuccess ? "bi-check-circle" : "bi-exclamation-triangle") me-1"></i>
|
<i class="bi @(applySuccess ? "bi-check-circle" : "bi-exclamation-triangle") me-1"></i>
|
||||||
<strong>@(applySuccess ? "Applied successfully" : "Apply failed")</strong>
|
<strong>@(applySuccess ? "Applied successfully" : "Apply had errors")</strong>
|
||||||
<pre class="mb-0 mt-1 small" style="white-space:pre-wrap;max-height:200px;overflow-y:auto;">@applyOutput</pre>
|
<pre class="mb-0 mt-1 small" style="white-space:pre-wrap;max-height:200px;overflow-y:auto;">@applyOutput</pre>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-close btn-close-sm ms-2 flex-shrink-0" @onclick="() => applyOutput = null"></button>
|
<button class="btn-close btn-close-sm ms-2 flex-shrink-0" @onclick="() => applyOutput = null"></button>
|
||||||
@@ -40,47 +94,55 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@* ── 1. Namespace ─────────────────────────────────────────────── *@
|
@* ── 0. Namespace ─────────────────────────────────────────────── *@
|
||||||
<div class="card shadow-sm mb-3">
|
<div class="card shadow-sm mb-3">
|
||||||
<div class="card-header bg-white py-2">
|
<div class="card-header bg-white py-2">
|
||||||
<i class="bi bi-box me-2 text-primary"></i>
|
<i class="bi bi-box me-2 text-primary"></i>
|
||||||
<strong>Namespace</strong>
|
<strong>Namespace</strong>
|
||||||
<span class="text-muted small ms-2">The Kubernetes namespace where quotas and policies are enforced.</span>
|
<span class="text-muted small ms-2">Locks the Kubernetes namespace for all deployments of this app in this environment.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row g-2 align-items-end">
|
@if (nsMessage is not null)
|
||||||
<div class="col-md-5">
|
{
|
||||||
<label class="form-label small">Namespace</label>
|
<div class="alert @(nsOk ? "alert-success" : "alert-danger") py-1 small mb-2">
|
||||||
<input class="form-control form-control-sm font-monospace" @bind="ns"
|
<i class="bi @(nsOk ? "bi-check-circle" : "bi-x-circle") me-1"></i>@nsMessage
|
||||||
placeholder="e.g. my-app" />
|
|
||||||
<div class="form-text">Must be a valid DNS label (lowercase, hyphens, max 63 chars).</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
}
|
||||||
<button class="btn btn-sm btn-primary" @onclick="SaveNamespace"
|
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||||
disabled="@savingNs">
|
<input class="form-control form-control-sm font-monospace" style="max-width:280px"
|
||||||
@if (savingNs) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
@bind="nsValue" placeholder="e.g. customer-prod" />
|
||||||
Save
|
<button class="btn btn-sm btn-primary" @onclick="SaveNamespace" disabled="@savingNs">
|
||||||
</button>
|
@if (savingNs) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
</div>
|
Save
|
||||||
@if (nsMessage is not null)
|
</button>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(nsValue))
|
||||||
{
|
{
|
||||||
<div class="col-auto">
|
<button class="btn btn-sm btn-outline-danger" @onclick="ClearNamespace">
|
||||||
<span class="small @(nsOk ? "text-success" : "text-danger")">
|
<i class="bi bi-x me-1"></i>Remove lock
|
||||||
<i class="bi @(nsOk ? "bi-check-circle" : "bi-x-circle") me-1"></i>@nsMessage
|
</button>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(nsValue))
|
||||||
|
{
|
||||||
|
<div class="form-text mt-1">
|
||||||
|
<i class="bi bi-lock-fill text-warning me-1"></i>
|
||||||
|
Deployments in this environment will be locked to namespace <code>@nsValue</code>.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="form-text mt-1">No namespace lock — deployments may use any namespace.</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* ── 2. Resource Quota ─────────────────────────────────────────── *@
|
@* ── 1. Resource Quota ─────────────────────────────────────────── *@
|
||||||
<div class="card shadow-sm mb-3">
|
<div class="card shadow-sm mb-3">
|
||||||
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between">
|
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between">
|
||||||
<div>
|
<div>
|
||||||
<i class="bi bi-speedometer me-2 text-warning"></i>
|
<i class="bi bi-speedometer me-2 text-warning"></i>
|
||||||
<strong>Resource Quota</strong>
|
<strong>Resource Quota</strong>
|
||||||
<span class="text-muted small ms-2">Limits applied as a Kubernetes ResourceQuota in the namespace.</span>
|
<span class="text-muted small ms-2">Applied as a Kubernetes ResourceQuota in each deployment's namespace.</span>
|
||||||
</div>
|
</div>
|
||||||
@if (quota is not null)
|
@if (quota is not null)
|
||||||
{
|
{
|
||||||
@@ -96,6 +158,19 @@ else
|
|||||||
<i class="bi @(quotaOk ? "bi-check-circle" : "bi-x-circle") me-1"></i>@quotaMessage
|
<i class="bi @(quotaOk ? "bi-check-circle" : "bi-x-circle") me-1"></i>@quotaMessage
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
<div class="mb-2 d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<span class="text-muted small">Presets:</span>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary py-0" title="1–2 pods · single service · no autoscaling"
|
||||||
|
@onclick='() => ApplyQuotaPreset("100m", "500m", "128Mi", "512Mi", 5, 2)'>Micro</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary py-0" title="~5 services · light autoscaling · up to 20 pods"
|
||||||
|
@onclick='() => ApplyQuotaPreset("500m", "2", "512Mi", "2Gi", 20, 5)'>Small</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary py-0" title="~15 services · moderate autoscaling · up to 60 pods"
|
||||||
|
@onclick='() => ApplyQuotaPreset("4", "16", "4Gi", "16Gi", 60, 20)'>Medium</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary py-0" title="~30 services · active autoscaling · up to 150 pods"
|
||||||
|
@onclick='() => ApplyQuotaPreset("8", "32", "16Gi", "64Gi", 150, 50)'>Large</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary py-0" title="70+ microservices + frontend · heavy autoscaling · up to 500 pods"
|
||||||
|
@onclick='() => ApplyQuotaPreset("32", "128", "64Gi", "256Gi", 500, 100)'>XLarge</button>
|
||||||
|
</div>
|
||||||
<div class="row g-2 mb-2">
|
<div class="row g-2 mb-2">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label class="form-label small">CPU request</label>
|
<label class="form-label small">CPU request</label>
|
||||||
@@ -129,7 +204,7 @@ else
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* ── 3. Network Policies ───────────────────────────────────────── *@
|
@* ── 2. Network Policies ───────────────────────────────────────── *@
|
||||||
<div class="card shadow-sm mb-3">
|
<div class="card shadow-sm mb-3">
|
||||||
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between">
|
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -251,14 +326,12 @@ else
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* ── 4. RBAC ───────────────────────────────────────────────────── *@
|
@* ── 3. RBAC ───────────────────────────────────────────────────── *@
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm mb-3">
|
||||||
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between">
|
<div class="card-header bg-white py-2">
|
||||||
<div>
|
<i class="bi bi-person-badge me-2 text-success"></i>
|
||||||
<i class="bi bi-person-badge me-2 text-success"></i>
|
<strong>RBAC</strong>
|
||||||
<strong>RBAC</strong>
|
<span class="text-muted small ms-2">ServiceAccount, Role, and RoleBinding applied to the namespace.</span>
|
||||||
<span class="text-muted small ms-2">ServiceAccount, Role, and RoleBinding applied to the namespace.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@if (rbacMessage is not null)
|
@if (rbacMessage is not null)
|
||||||
@@ -268,7 +341,6 @@ else
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@* Service account settings *@
|
|
||||||
<div class="row g-2 mb-3 align-items-end">
|
<div class="row g-2 mb-3 align-items-end">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label small">Service account name</label>
|
<label class="form-label small">Service account name</label>
|
||||||
@@ -277,8 +349,8 @@ else
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-auto d-flex align-items-center gap-2" style="padding-bottom: 0.25rem;">
|
<div class="col-auto d-flex align-items-center gap-2" style="padding-bottom: 0.25rem;">
|
||||||
<div class="form-check mb-0">
|
<div class="form-check mb-0">
|
||||||
<input class="form-check-input" type="checkbox" id="rbacAutoMount" @bind="rbacAutoMount" />
|
<input class="form-check-input" type="checkbox" id="rbacAutoMountEnv" @bind="rbacAutoMount" />
|
||||||
<label class="form-check-label small" for="rbacAutoMount">Auto-mount token</label>
|
<label class="form-check-label small" for="rbacAutoMountEnv">Auto-mount token</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
@@ -294,7 +366,6 @@ else
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* Rules table *@
|
|
||||||
@if (rbacPolicy is not null)
|
@if (rbacPolicy is not null)
|
||||||
{
|
{
|
||||||
<div class="mb-2 d-flex align-items-center justify-content-between">
|
<div class="mb-2 d-flex align-items-center justify-content-between">
|
||||||
@@ -380,20 +451,279 @@ else
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@* ── 4. Allowed Services ───────────────────────────────────────── *@
|
||||||
|
<div class="card shadow-sm mt-3">
|
||||||
|
<div class="card-header bg-white py-2">
|
||||||
|
<i class="bi bi-shield-lock me-2 text-secondary"></i>
|
||||||
|
<strong>Allowed Services</strong>
|
||||||
|
<span class="text-muted small ms-2">Restrict which databases, cache clusters, and storage buckets this app may link to. Empty = no restriction.</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
|
||||||
|
@* ── Allowed Databases ── *@
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||||
|
<span class="small fw-medium"><i class="bi bi-database me-1 text-primary"></i>Allowed Databases</span>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" @onclick="OpenAddDb">
|
||||||
|
<i class="bi bi-plus me-1"></i>Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (allowedDbMessage is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger py-1 small mb-2">@allowedDbMessage</div>
|
||||||
|
}
|
||||||
|
@if (showAddDb)
|
||||||
|
{
|
||||||
|
<div class="card border-primary mb-2">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
@if (availableDbs is null)
|
||||||
|
{
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary me-2"></div>
|
||||||
|
<span class="small text-muted">Loading databases…</span>
|
||||||
|
}
|
||||||
|
else if (availableDbs.Count == 0)
|
||||||
|
{
|
||||||
|
<span class="text-muted small">No databases found in this environment.</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||||
|
<select class="form-select form-select-sm w-auto" @bind="newDbOptionId">
|
||||||
|
<option value="@Guid.Empty">Select database…</option>
|
||||||
|
@foreach (AvailableDatabaseOption opt in availableDbs)
|
||||||
|
{
|
||||||
|
<option value="@opt.Id">@opt.TypeLabel — @opt.DisplayName</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="AddAllowedDatabase"
|
||||||
|
disabled="@(newDbOptionId == Guid.Empty || addingDb)">
|
||||||
|
@if (addingDb) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showAddDb = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (allowedDatabases.Count == 0 && !showAddDb)
|
||||||
|
{
|
||||||
|
<p class="text-muted small mb-0">No restrictions — all databases are available.</p>
|
||||||
|
}
|
||||||
|
else if (allowedDatabases.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
@foreach (AppAllowedDatabase entry in allowedDatabases)
|
||||||
|
{
|
||||||
|
<div class="list-group-item d-flex align-items-center justify-content-between px-0 py-1">
|
||||||
|
<div class="small">
|
||||||
|
<span class="badge bg-primary-subtle text-primary border border-primary-subtle me-2">@DbTypeLabel(entry)</span>
|
||||||
|
<span class="fw-medium">@DbAllowedName(entry)</span>
|
||||||
|
</div>
|
||||||
|
@if (confirmDeleteDbId != entry.Id)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-danger p-0 px-1"
|
||||||
|
@onclick="() => confirmDeleteDbId = entry.Id">
|
||||||
|
<i class="bi bi-x" style="font-size:.8rem;"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-danger" @onclick="() => DeleteAllowedDatabase(entry.Id)">Yes</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary ms-1" @onclick="() => confirmDeleteDbId = null">No</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Allowed Cache Services ── *@
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||||
|
<span class="small fw-medium"><i class="bi bi-lightning me-1 text-warning"></i>Allowed Cache Services</span>
|
||||||
|
<button class="btn btn-sm btn-outline-warning" @onclick="OpenAddCache">
|
||||||
|
<i class="bi bi-plus me-1"></i>Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (allowedCacheMessage is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger py-1 small mb-2">@allowedCacheMessage</div>
|
||||||
|
}
|
||||||
|
@if (showAddCache)
|
||||||
|
{
|
||||||
|
<div class="card border-warning mb-2">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
@if (availableCaches is null)
|
||||||
|
{
|
||||||
|
<div class="spinner-border spinner-border-sm text-warning me-2"></div>
|
||||||
|
<span class="small text-muted">Loading Redis clusters…</span>
|
||||||
|
}
|
||||||
|
else if (availableCaches.Count == 0)
|
||||||
|
{
|
||||||
|
<span class="text-muted small">No Redis clusters found for this tenant.</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||||
|
<select class="form-select form-select-sm w-auto" @bind="newCacheId">
|
||||||
|
<option value="@Guid.Empty">Select Redis cluster…</option>
|
||||||
|
@foreach (RedisCluster c in availableCaches)
|
||||||
|
{
|
||||||
|
<option value="@c.Id">@c.Name</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-sm btn-warning" @onclick="AddAllowedCache"
|
||||||
|
disabled="@(newCacheId == Guid.Empty || addingCache)">
|
||||||
|
@if (addingCache) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showAddCache = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (allowedCaches.Count == 0 && !showAddCache)
|
||||||
|
{
|
||||||
|
<p class="text-muted small mb-0">No restrictions — all Redis clusters are available.</p>
|
||||||
|
}
|
||||||
|
else if (allowedCaches.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
@foreach (AppAllowedCache entry in allowedCaches)
|
||||||
|
{
|
||||||
|
<div class="list-group-item d-flex align-items-center justify-content-between px-0 py-1">
|
||||||
|
<div class="small">
|
||||||
|
<i class="bi bi-lightning text-warning me-1"></i>
|
||||||
|
<span class="fw-medium">@entry.RedisCluster.Name</span>
|
||||||
|
</div>
|
||||||
|
@if (confirmDeleteCacheId != entry.Id)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-danger p-0 px-1"
|
||||||
|
@onclick="() => confirmDeleteCacheId = entry.Id">
|
||||||
|
<i class="bi bi-x" style="font-size:.8rem;"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-danger" @onclick="() => DeleteAllowedCache(entry.Id)">Yes</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary ms-1" @onclick="() => confirmDeleteCacheId = null">No</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Allowed S3 Storage ── *@
|
||||||
|
<div>
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||||
|
<span class="small fw-medium"><i class="bi bi-bucket me-1 text-info"></i>Allowed S3 Storage</span>
|
||||||
|
<button class="btn btn-sm btn-outline-info" @onclick="OpenAddStorage">
|
||||||
|
<i class="bi bi-plus me-1"></i>Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (allowedStorageMessage is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger py-1 small mb-2">@allowedStorageMessage</div>
|
||||||
|
}
|
||||||
|
@if (showAddStorage)
|
||||||
|
{
|
||||||
|
<div class="card border-info mb-2">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
@if (availableStorages is null)
|
||||||
|
{
|
||||||
|
<div class="spinner-border spinner-border-sm text-info me-2"></div>
|
||||||
|
<span class="small text-muted">Loading storage buckets…</span>
|
||||||
|
}
|
||||||
|
else if (availableStorages.Count == 0)
|
||||||
|
{
|
||||||
|
<span class="text-muted small">No storage buckets found in this environment.</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||||
|
<select class="form-select form-select-sm w-auto" @bind="newStorageId">
|
||||||
|
<option value="@Guid.Empty">Select storage bucket…</option>
|
||||||
|
@foreach (StorageLink s in availableStorages)
|
||||||
|
{
|
||||||
|
<option value="@s.Id">@s.Name (@s.Provider@(s.BucketName is not null ? $" · {s.BucketName}" : ""))</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-sm btn-info" @onclick="AddAllowedStorage"
|
||||||
|
disabled="@(newStorageId == Guid.Empty || addingStorage)">
|
||||||
|
@if (addingStorage) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showAddStorage = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (allowedStorages.Count == 0 && !showAddStorage)
|
||||||
|
{
|
||||||
|
<p class="text-muted small mb-0">No restrictions — all storage buckets are available.</p>
|
||||||
|
}
|
||||||
|
else if (allowedStorages.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
@foreach (AppAllowedStorage entry in allowedStorages)
|
||||||
|
{
|
||||||
|
<div class="list-group-item d-flex align-items-center justify-content-between px-0 py-1">
|
||||||
|
<div class="small">
|
||||||
|
<span class="badge bg-info-subtle text-info border border-info-subtle me-2">@entry.StorageLink.Provider</span>
|
||||||
|
<span class="fw-medium">@entry.StorageLink.Name</span>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(entry.StorageLink.BucketName))
|
||||||
|
{
|
||||||
|
<code class="ms-1 text-muted">@entry.StorageLink.BucketName</code>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (confirmDeleteStorageId != entry.Id)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-danger p-0 px-1"
|
||||||
|
@onclick="() => confirmDeleteStorageId = entry.Id">
|
||||||
|
<i class="bi bi-x" style="font-size:.8rem;"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-danger" @onclick="() => DeleteAllowedStorage(entry.Id)">Yes</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary ms-1" @onclick="() => confirmDeleteStorageId = null">No</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter, EditorRequired] public Guid AppId { get; set; }
|
[Parameter, EditorRequired] public Guid AppId { get; set; }
|
||||||
[Parameter, EditorRequired] public Guid TenantId { get; set; }
|
[Parameter, EditorRequired] public Guid TenantId { get; set; }
|
||||||
|
[Parameter, EditorRequired] public Guid EnvironmentId { get; set; }
|
||||||
|
|
||||||
private bool loading = true;
|
private bool loading = true;
|
||||||
|
|
||||||
// Namespace
|
// Namespace
|
||||||
private string ns = "";
|
private string nsValue = "";
|
||||||
private bool savingNs;
|
private bool savingNs;
|
||||||
private string? nsMessage;
|
private string? nsMessage;
|
||||||
private bool nsOk;
|
private bool nsOk;
|
||||||
|
|
||||||
|
// Copy from environment
|
||||||
|
private bool showCopyFrom;
|
||||||
|
private List<Data.Environment>? otherEnvironments;
|
||||||
|
private Guid copyFromEnvId;
|
||||||
|
private bool confirmCopy, copying;
|
||||||
|
private string? copyMessage;
|
||||||
|
private bool copyOk;
|
||||||
|
|
||||||
// Quota
|
// Quota
|
||||||
private AppQuota? quota;
|
private AppQuota? quota;
|
||||||
private string qCpuReq = "", qCpuLim = "", qMemReq = "", qMemLim = "";
|
private string qCpuReq = "", qCpuLim = "", qMemReq = "", qMemLim = "";
|
||||||
@@ -436,10 +766,17 @@ else
|
|||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (showCopyFrom && otherEnvironments is null)
|
||||||
|
await LoadOtherEnvironments();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task LoadAsync()
|
private async Task LoadAsync()
|
||||||
{
|
{
|
||||||
AppGovernanceData data = await GovernanceService.LoadAsync(AppId);
|
AppGovernanceData data = await GovernanceService.LoadAsync(AppId, EnvironmentId);
|
||||||
ns = data.Namespace ?? "";
|
|
||||||
|
nsValue = data.Namespace ?? "";
|
||||||
|
|
||||||
quota = data.Quota;
|
quota = data.Quota;
|
||||||
if (quota is not null)
|
if (quota is not null)
|
||||||
@@ -460,20 +797,66 @@ else
|
|||||||
rbacSaName = rbacPolicy.ServiceAccountName;
|
rbacSaName = rbacPolicy.ServiceAccountName;
|
||||||
rbacAutoMount = rbacPolicy.AutoMountToken;
|
rbacAutoMount = rbacPolicy.AutoMountToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allowedDatabases = data.AllowedDatabases;
|
||||||
|
allowedCaches = data.AllowedCaches;
|
||||||
|
allowedStorages = data.AllowedStorages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task LoadOtherEnvironments()
|
||||||
|
{
|
||||||
|
List<Data.Environment> all = await TenantService.GetEnvironmentsAsync(TenantId);
|
||||||
|
otherEnvironments = all.Where(e => e.Id != EnvironmentId).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Copy from environment ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task DoCopyFrom()
|
||||||
|
{
|
||||||
|
copying = true; copyMessage = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await GovernanceService.CopyFromEnvironmentAsync(AppId, copyFromEnvId, EnvironmentId);
|
||||||
|
await LoadAsync();
|
||||||
|
copyOk = true;
|
||||||
|
copyMessage = "Governance copied and loaded.";
|
||||||
|
showCopyFrom = false;
|
||||||
|
confirmCopy = false;
|
||||||
|
}
|
||||||
|
catch (Exception ex) { copyOk = false; copyMessage = ex.Message; }
|
||||||
|
finally { copying = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Namespace ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async Task SaveNamespace()
|
private async Task SaveNamespace()
|
||||||
{
|
{
|
||||||
savingNs = true; nsMessage = null;
|
savingNs = true; nsMessage = null;
|
||||||
await GovernanceService.SetNamespaceAsync(AppId, ns);
|
await GovernanceService.SaveNamespaceAsync(AppId, EnvironmentId, nsValue);
|
||||||
savingNs = false; nsOk = true;
|
nsValue = nsValue.Trim().ToLowerInvariant();
|
||||||
nsMessage = "Saved.";
|
savingNs = false; nsOk = true; nsMessage = "Namespace saved.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ClearNamespace()
|
||||||
|
{
|
||||||
|
await GovernanceService.SaveNamespaceAsync(AppId, EnvironmentId, null);
|
||||||
|
nsValue = ""; nsMessage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Quota ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void ApplyQuotaPreset(string cpuReq, string cpuLim, string memReq, string memLim, int maxPods, int maxPvcs)
|
||||||
|
{
|
||||||
|
qCpuReq = cpuReq; qCpuLim = cpuLim;
|
||||||
|
qMemReq = memReq; qMemLim = memLim;
|
||||||
|
qMaxPods = maxPods; qMaxPvcs = maxPvcs;
|
||||||
|
quotaMessage = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SaveQuota()
|
private async Task SaveQuota()
|
||||||
{
|
{
|
||||||
savingQuota = true; quotaMessage = null;
|
savingQuota = true; quotaMessage = null;
|
||||||
quota = await GovernanceService.SaveQuotaAsync(AppId,
|
quota = await GovernanceService.SaveQuotaAsync(AppId, EnvironmentId,
|
||||||
qCpuReq, qCpuLim, qMemReq, qMemLim, qMaxPods, qMaxPvcs);
|
qCpuReq, qCpuLim, qMemReq, qMemLim, qMaxPods, qMaxPvcs);
|
||||||
savingQuota = false; quotaOk = true;
|
savingQuota = false; quotaOk = true;
|
||||||
quotaMessage = "Quota saved.";
|
quotaMessage = "Quota saved.";
|
||||||
@@ -481,19 +864,21 @@ else
|
|||||||
|
|
||||||
private async Task DeleteQuota()
|
private async Task DeleteQuota()
|
||||||
{
|
{
|
||||||
await GovernanceService.DeleteQuotaAsync(AppId);
|
await GovernanceService.DeleteQuotaAsync(AppId, EnvironmentId);
|
||||||
quota = null;
|
quota = null;
|
||||||
qCpuReq = qCpuLim = qMemReq = qMemLim = "";
|
qCpuReq = qCpuLim = qMemReq = qMemLim = "";
|
||||||
qMaxPods = qMaxPvcs = null;
|
qMaxPods = qMaxPvcs = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Network policies ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async Task AddNetworkPolicy()
|
private async Task AddNetworkPolicy()
|
||||||
{
|
{
|
||||||
addingNp = true; npMessage = null;
|
addingNp = true; npMessage = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
AppNetworkPolicy p = await GovernanceService.AddNetworkPolicyAsync(
|
AppNetworkPolicy p = await GovernanceService.AddNetworkPolicyAsync(
|
||||||
AppId, newNpName, newNpType,
|
AppId, EnvironmentId, newNpName, newNpType,
|
||||||
newNpAllowNs, newNpCustomYaml);
|
newNpAllowNs, newNpCustomYaml);
|
||||||
networkPolicies.Add(p);
|
networkPolicies.Add(p);
|
||||||
showAddNp = false;
|
showAddNp = false;
|
||||||
@@ -511,17 +896,19 @@ else
|
|||||||
confirmDeleteNpId = null;
|
confirmDeleteNpId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── RBAC ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async Task SaveRbac()
|
private async Task SaveRbac()
|
||||||
{
|
{
|
||||||
savingRbac = true; rbacMessage = null;
|
savingRbac = true; rbacMessage = null;
|
||||||
rbacPolicy = await GovernanceService.SaveRbacPolicyAsync(AppId, rbacSaName, rbacAutoMount);
|
rbacPolicy = await GovernanceService.SaveRbacPolicyAsync(AppId, EnvironmentId, rbacSaName, rbacAutoMount);
|
||||||
savingRbac = false; rbacOk = true;
|
savingRbac = false; rbacOk = true;
|
||||||
rbacMessage = "Saved.";
|
rbacMessage = "Saved.";
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteRbac()
|
private async Task DeleteRbac()
|
||||||
{
|
{
|
||||||
await GovernanceService.DeleteRbacPolicyAsync(AppId);
|
await GovernanceService.DeleteRbacPolicyAsync(AppId, EnvironmentId);
|
||||||
rbacPolicy = null; rbacSaName = ""; rbacAutoMount = false;
|
rbacPolicy = null; rbacSaName = ""; rbacAutoMount = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,26 +936,49 @@ else
|
|||||||
rbacPolicy?.Rules.Remove(rbacPolicy.Rules.First(r => r.Id == id));
|
rbacPolicy?.Rules.Remove(rbacPolicy.Rules.First(r => r.Id == id));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ApplyToAllClusters()
|
// ── Apply ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task ApplyToEnvironmentClusters()
|
||||||
{
|
{
|
||||||
applying = true; applyOutput = null;
|
applying = true; applyOutput = null;
|
||||||
var sb = new System.Text.StringBuilder();
|
var sb = new System.Text.StringBuilder();
|
||||||
|
|
||||||
List<KubernetesCluster> clusters = await TenantService.GetClustersAsync(TenantId);
|
// Only apply to clusters that have a deployment for this app+environment.
|
||||||
|
List<AppDeployment> envDeployments = (await DeploymentService.GetDeploymentsAsync(AppId))
|
||||||
|
.Where(d => d.EnvironmentId == EnvironmentId && d.Cluster is not null)
|
||||||
|
.DistinctBy(d => d.ClusterId)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
foreach (KubernetesCluster cluster in clusters)
|
if (envDeployments.Count == 0)
|
||||||
{
|
{
|
||||||
var (ok, output) = await GovernanceService.ApplyToClusterAsync(AppId, cluster);
|
applyOutput = "No deployments found for this environment. Create a deployment first.";
|
||||||
sb.AppendLine($"── {cluster.Name} ──");
|
applySuccess = false;
|
||||||
sb.AppendLine(ok ? "✓ Applied" : $"✗ {output}");
|
applying = false;
|
||||||
if (!ok && !string.IsNullOrWhiteSpace(output))
|
return;
|
||||||
sb.AppendLine(output);
|
|
||||||
applySuccess = ok;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
applying = false;
|
bool anyFail = false;
|
||||||
|
foreach (AppDeployment deploy in envDeployments)
|
||||||
|
{
|
||||||
|
(bool ok, string output) = await GovernanceService.ApplyToClusterAsync(
|
||||||
|
AppId, EnvironmentId, deploy.Cluster!);
|
||||||
|
sb.AppendLine($"── {deploy.Cluster!.Name} ({deploy.Namespace}) ──");
|
||||||
|
sb.AppendLine(ok ? "✓ Applied" : $"✗ {output}");
|
||||||
|
if (!ok) anyFail = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
applySuccess = !anyFail;
|
||||||
applyOutput = sb.ToString().TrimEnd();
|
applyOutput = sb.ToString().TrimEnd();
|
||||||
applySuccess = !applyOutput.Contains("✗");
|
applying = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lazy load copy panel ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task OpenCopyFrom()
|
||||||
|
{
|
||||||
|
showCopyFrom = !showCopyFrom;
|
||||||
|
if (showCopyFrom && otherEnvironments is null)
|
||||||
|
await LoadOtherEnvironments();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string NpTypeLabel(AppNetworkPolicyType t) => t switch
|
private static string NpTypeLabel(AppNetworkPolicyType t) => t switch
|
||||||
@@ -580,4 +990,148 @@ else
|
|||||||
AppNetworkPolicyType.Custom => "Custom YAML",
|
AppNetworkPolicyType.Custom => "Custom YAML",
|
||||||
_ => t.ToString()
|
_ => t.ToString()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Allowed databases ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private List<AppAllowedDatabase> allowedDatabases = [];
|
||||||
|
private List<AvailableDatabaseOption>? availableDbs;
|
||||||
|
private bool showAddDb;
|
||||||
|
private bool addingDb;
|
||||||
|
private Guid newDbOptionId;
|
||||||
|
private Guid? confirmDeleteDbId;
|
||||||
|
private string? allowedDbMessage;
|
||||||
|
|
||||||
|
private async Task OpenAddDb()
|
||||||
|
{
|
||||||
|
showAddDb = true;
|
||||||
|
if (availableDbs is null)
|
||||||
|
availableDbs = await GovernanceService.GetAvailableDatabasesAsync(TenantId, EnvironmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddAllowedDatabase()
|
||||||
|
{
|
||||||
|
allowedDbMessage = null;
|
||||||
|
AvailableDatabaseOption? opt = availableDbs?.FirstOrDefault(o => o.Id == newDbOptionId);
|
||||||
|
if (opt is null) return;
|
||||||
|
|
||||||
|
addingDb = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
AppAllowedDatabase entry = await GovernanceService.AddAllowedDatabaseAsync(
|
||||||
|
AppId, EnvironmentId, opt.CnpgId, opt.MongoId, opt.RegPostgresId);
|
||||||
|
allowedDatabases.Add(entry);
|
||||||
|
showAddDb = false;
|
||||||
|
newDbOptionId = Guid.Empty;
|
||||||
|
}
|
||||||
|
catch (Exception ex) { allowedDbMessage = ex.Message; }
|
||||||
|
finally { addingDb = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteAllowedDatabase(Guid id)
|
||||||
|
{
|
||||||
|
await GovernanceService.DeleteAllowedDatabaseAsync(id);
|
||||||
|
allowedDatabases.RemoveAll(a => a.Id == id);
|
||||||
|
confirmDeleteDbId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DbTypeLabel(AppAllowedDatabase entry)
|
||||||
|
{
|
||||||
|
if (entry.CnpgDatabase is not null) return "PostgreSQL";
|
||||||
|
if (entry.MongoDatabase is not null) return "MongoDB";
|
||||||
|
if (entry.RegisteredPostgresDatabase is not null) return "Postgres (registered)";
|
||||||
|
return "Database";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DbAllowedName(AppAllowedDatabase entry)
|
||||||
|
{
|
||||||
|
if (entry.CnpgDatabase is not null)
|
||||||
|
return entry.CnpgDatabase.CnpgCluster is not null
|
||||||
|
? $"{entry.CnpgDatabase.Name} ({entry.CnpgDatabase.CnpgCluster.Name})"
|
||||||
|
: entry.CnpgDatabase.Name;
|
||||||
|
if (entry.MongoDatabase is not null)
|
||||||
|
return entry.MongoDatabase.MongoCluster is not null
|
||||||
|
? $"{entry.MongoDatabase.Name} ({entry.MongoDatabase.MongoCluster.Name})"
|
||||||
|
: entry.MongoDatabase.Name;
|
||||||
|
if (entry.RegisteredPostgresDatabase is not null)
|
||||||
|
return entry.RegisteredPostgresDatabase.Name;
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Allowed caches ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private List<AppAllowedCache> allowedCaches = [];
|
||||||
|
private List<RedisCluster>? availableCaches;
|
||||||
|
private bool showAddCache;
|
||||||
|
private bool addingCache;
|
||||||
|
private Guid newCacheId;
|
||||||
|
private Guid? confirmDeleteCacheId;
|
||||||
|
private string? allowedCacheMessage;
|
||||||
|
|
||||||
|
private async Task OpenAddCache()
|
||||||
|
{
|
||||||
|
showAddCache = true;
|
||||||
|
if (availableCaches is null)
|
||||||
|
availableCaches = await GovernanceService.GetAvailableCachesAsync(TenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddAllowedCache()
|
||||||
|
{
|
||||||
|
allowedCacheMessage = null;
|
||||||
|
addingCache = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
AppAllowedCache entry = await GovernanceService.AddAllowedCacheAsync(AppId, EnvironmentId, newCacheId);
|
||||||
|
allowedCaches.Add(entry);
|
||||||
|
showAddCache = false;
|
||||||
|
newCacheId = Guid.Empty;
|
||||||
|
}
|
||||||
|
catch (Exception ex) { allowedCacheMessage = ex.Message; }
|
||||||
|
finally { addingCache = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteAllowedCache(Guid id)
|
||||||
|
{
|
||||||
|
await GovernanceService.DeleteAllowedCacheAsync(id);
|
||||||
|
allowedCaches.RemoveAll(a => a.Id == id);
|
||||||
|
confirmDeleteCacheId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Allowed storages ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private List<AppAllowedStorage> allowedStorages = [];
|
||||||
|
private List<StorageLink>? availableStorages;
|
||||||
|
private bool showAddStorage;
|
||||||
|
private bool addingStorage;
|
||||||
|
private Guid newStorageId;
|
||||||
|
private Guid? confirmDeleteStorageId;
|
||||||
|
private string? allowedStorageMessage;
|
||||||
|
|
||||||
|
private async Task OpenAddStorage()
|
||||||
|
{
|
||||||
|
showAddStorage = true;
|
||||||
|
if (availableStorages is null)
|
||||||
|
availableStorages = await GovernanceService.GetAvailableStoragesAsync(TenantId, EnvironmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddAllowedStorage()
|
||||||
|
{
|
||||||
|
allowedStorageMessage = null;
|
||||||
|
addingStorage = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
AppAllowedStorage entry = await GovernanceService.AddAllowedStorageAsync(AppId, EnvironmentId, newStorageId);
|
||||||
|
allowedStorages.Add(entry);
|
||||||
|
showAddStorage = false;
|
||||||
|
newStorageId = Guid.Empty;
|
||||||
|
}
|
||||||
|
catch (Exception ex) { allowedStorageMessage = ex.Message; }
|
||||||
|
finally { addingStorage = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteAllowedStorage(Guid id)
|
||||||
|
{
|
||||||
|
await GovernanceService.DeleteAllowedStorageAsync(id);
|
||||||
|
allowedStorages.RemoveAll(a => a.Id == id);
|
||||||
|
confirmDeleteStorageId = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,26 @@
|
|||||||
@using EntKube.Web.Data
|
@using EntKube.Web.Data
|
||||||
@using EntKube.Web.Services
|
@using EntKube.Web.Services
|
||||||
@inject IncidentService IncidentService
|
@inject IncidentService IncidentService
|
||||||
|
@inject OnCallService OnCallService
|
||||||
|
@inject NotificationService NotificationService
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
|
|
||||||
|
@* ── On-call banner ── *@
|
||||||
|
@if (currentOnCall is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-success d-flex align-items-center gap-2 py-2 mb-3">
|
||||||
|
<span class="bi bi-person-check-fill"></span>
|
||||||
|
<span class="small">
|
||||||
|
On call now: <strong>@currentOnCall.AssigneeName</strong>
|
||||||
|
@if (!string.IsNullOrEmpty(currentOnCall.AssigneeEmail))
|
||||||
|
{
|
||||||
|
<a href="mailto:@currentOnCall.AssigneeEmail" class="ms-1">@currentOnCall.AssigneeEmail</a>
|
||||||
|
}
|
||||||
|
— until @currentOnCall.EndsAt.ToLocalTime().ToString("MMM d HH:mm")
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (stats is not null)
|
@if (stats is not null)
|
||||||
{
|
{
|
||||||
<div class="row g-2 mb-3">
|
<div class="row g-2 mb-3">
|
||||||
@@ -42,6 +60,20 @@
|
|||||||
<div class="text-muted small">MTTA</div>
|
<div class="text-muted small">MTTA</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@{
|
||||||
|
int unassignedActive = incidents.Count(i =>
|
||||||
|
i.AssignedTo is null && i.Status != IncidentStatus.Resolved);
|
||||||
|
}
|
||||||
|
@if (unassignedActive > 0)
|
||||||
|
{
|
||||||
|
<div class="col-auto">
|
||||||
|
<div class="card border-0 bg-warning-subtle text-center px-3 py-2"
|
||||||
|
style="cursor:pointer" @onclick="() => { filterUnassigned = true; _ = LoadIncidents(); }">
|
||||||
|
<div class="fw-bold fs-5 text-warning">@unassignedActive</div>
|
||||||
|
<div class="text-muted small">Unassigned</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@if (stats.Daily.Count > 1)
|
@if (stats.Daily.Count > 1)
|
||||||
{
|
{
|
||||||
int maxCount = stats.Daily.Max(d => d.Count);
|
int maxCount = stats.Daily.Max(d => d.Count);
|
||||||
@@ -87,6 +119,23 @@
|
|||||||
<option value="30">Last 30 days</option>
|
<option value="30">Last 30 days</option>
|
||||||
<option value="90">Last 90 days</option>
|
<option value="90">Last 90 days</option>
|
||||||
</select>
|
</select>
|
||||||
|
<div class="form-check ms-1">
|
||||||
|
<input class="form-check-input" type="checkbox" id="chkUnassigned" @bind="filterUnassigned" @bind:after="LoadIncidents" />
|
||||||
|
<label class="form-check-label small" for="chkUnassigned">Unassigned only</label>
|
||||||
|
</div>
|
||||||
|
<input class="form-control form-control-sm ms-1" style="max-width:200px"
|
||||||
|
@bind="searchText" @bind:event="oninput" placeholder="Search alerts…" />
|
||||||
|
@if (selectedIds.Count > 0)
|
||||||
|
{
|
||||||
|
<span class="text-muted small">@selectedIds.Count selected</span>
|
||||||
|
<button class="btn btn-sm btn-outline-warning" @onclick="BulkAcknowledge">
|
||||||
|
<span class="bi bi-check-all me-1"></span>Ack
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-success" @onclick="BulkResolve">
|
||||||
|
<span class="bi bi-check-circle me-1"></span>Resolve
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => selectedIds.Clear()">Clear</button>
|
||||||
|
}
|
||||||
<button class="btn btn-sm btn-outline-secondary" @onclick="LoadIncidents">
|
<button class="btn btn-sm btn-outline-secondary" @onclick="LoadIncidents">
|
||||||
<span class="bi bi-arrow-clockwise"></span> Refresh
|
<span class="bi bi-arrow-clockwise"></span> Refresh
|
||||||
</button>
|
</button>
|
||||||
@@ -106,22 +155,46 @@ else if (!isLoading)
|
|||||||
<table class="table table-hover align-middle">
|
<table class="table table-hover align-middle">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style="width:32px">
|
||||||
|
<input type="checkbox" class="form-check-input"
|
||||||
|
@onchange="e => ToggleSelectAll((bool)(e.Value ?? false))" />
|
||||||
|
</th>
|
||||||
<th style="width:90px">Severity</th>
|
<th style="width:90px">Severity</th>
|
||||||
<th>Alert</th>
|
<th>Alert</th>
|
||||||
<th>Cluster</th>
|
<th>Cluster</th>
|
||||||
<th>Started</th>
|
<th>Started</th>
|
||||||
<th>Duration</th>
|
<th>Duration</th>
|
||||||
<th style="width:110px">Status</th>
|
<th style="width:110px">Status</th>
|
||||||
<th style="width:180px">Actions</th>
|
<th style="width:200px">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (AlertIncident incident in incidents)
|
@foreach (AlertIncident incident in incidents)
|
||||||
{
|
{
|
||||||
bool expanded = expandedId == incident.Id;
|
bool expanded = expandedId == incident.Id;
|
||||||
<tr class="@(expanded ? "table-active" : "")" style="cursor:pointer" @onclick="() => ToggleExpand(incident.Id)">
|
bool selected = selectedIds.Contains(incident.Id);
|
||||||
<td>@SeverityBadge(incident.Severity)</td>
|
<tr class="@(expanded ? "table-active" : selected ? "table-warning" : "")"
|
||||||
<td class="fw-semibold">@incident.AlertName</td>
|
style="cursor:pointer" @onclick="() => ToggleExpand(incident.Id)">
|
||||||
|
<td @onclick:stopPropagation="true">
|
||||||
|
<input type="checkbox" class="form-check-input" checked="@selected"
|
||||||
|
@onchange="() => ToggleSelect(incident.Id)" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@SeverityBadge(incident.Severity)
|
||||||
|
@if (incident.EscalatedAt.HasValue)
|
||||||
|
{
|
||||||
|
<div class="mt-1"><span class="badge bg-danger-subtle text-danger border border-danger small">Escalated</span></div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="fw-semibold small">@incident.AlertName</div>
|
||||||
|
@if (incident.AssignedTo is not null)
|
||||||
|
{
|
||||||
|
<div class="text-muted" style="font-size:.77rem">
|
||||||
|
<span class="bi bi-person me-1"></span>@incident.AssignedTo
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
<td class="text-muted small">@incident.Cluster?.Name</td>
|
<td class="text-muted small">@incident.Cluster?.Name</td>
|
||||||
<td class="text-muted small">@incident.StartsAt.ToLocalTime().ToString("MMM d HH:mm")</td>
|
<td class="text-muted small">@incident.StartsAt.ToLocalTime().ToString("MMM d HH:mm")</td>
|
||||||
<td class="text-muted small">@FormatDuration(incident)</td>
|
<td class="text-muted small">@FormatDuration(incident)</td>
|
||||||
@@ -140,50 +213,162 @@ else if (!isLoading)
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => ToggleExpand(incident.Id)">
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => ToggleExpand(incident.Id)">
|
||||||
Notes @(incident.Notes.Count > 0 ? $"({incident.Notes.Count})" : "")
|
Details @(incident.Notes.Count > 0 ? $"({incident.Notes.Count})" : "")
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@if (expanded)
|
@if (expanded)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7" class="bg-light px-4 py-3">
|
<td colspan="8" class="bg-light px-4 py-3">
|
||||||
@if (!string.IsNullOrEmpty(incident.Summary))
|
<div class="row g-4">
|
||||||
{
|
|
||||||
<p class="mb-1"><strong>Summary:</strong> @incident.Summary</p>
|
|
||||||
}
|
|
||||||
@if (!string.IsNullOrEmpty(incident.Description))
|
|
||||||
{
|
|
||||||
<p class="mb-2 text-muted">@incident.Description</p>
|
|
||||||
}
|
|
||||||
@if (incident.AcknowledgedBy is not null)
|
|
||||||
{
|
|
||||||
<p class="mb-2 small text-muted">
|
|
||||||
Acknowledged by <strong>@incident.AcknowledgedBy</strong>
|
|
||||||
at @incident.AcknowledgedAt?.ToLocalTime().ToString("MMM d HH:mm")
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (incident.Notes.Count > 0)
|
@* ── Left: Timeline ── *@
|
||||||
{
|
<div class="col-12 col-lg-5">
|
||||||
<div class="mb-3">
|
@if (!string.IsNullOrEmpty(incident.Summary))
|
||||||
<strong class="small">Notes</strong>
|
|
||||||
@foreach (IncidentNote note in incident.Notes)
|
|
||||||
{
|
{
|
||||||
<div class="border-start border-3 border-secondary ps-3 mb-2 mt-1">
|
<p class="mb-1 small"><strong>Summary:</strong> @incident.Summary</p>
|
||||||
<div class="small text-muted">@note.Author · @note.CreatedAt.ToLocalTime().ToString("MMM d HH:mm")</div>
|
}
|
||||||
<div>@note.Content</div>
|
@if (!string.IsNullOrEmpty(incident.Description))
|
||||||
|
{
|
||||||
|
<p class="mb-2 small text-muted">@incident.Description</p>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrEmpty(incident.RunbookUrl))
|
||||||
|
{
|
||||||
|
<p class="mb-3">
|
||||||
|
<a href="@incident.RunbookUrl" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="btn btn-sm btn-outline-secondary">
|
||||||
|
<span class="bi bi-book me-1"></span>Runbook
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<strong class="small d-block mb-2">Timeline</strong>
|
||||||
|
<div style="position:relative;padding-left:28px">
|
||||||
|
<div style="position:absolute;left:9px;top:0;bottom:0;width:2px;background:#dee2e6"></div>
|
||||||
|
@foreach (TimelineEvent ev in BuildTimeline(incident))
|
||||||
|
{
|
||||||
|
<div style="position:relative;margin-bottom:14px">
|
||||||
|
<span style="position:absolute;left:-19px;top:2px;width:20px;height:20px;border-radius:50%;background:white;border:2px solid #dee2e6;display:flex;align-items:center;justify-content:center;font-size:.72rem"
|
||||||
|
class="@ev.IconColor">
|
||||||
|
<i class="bi @ev.Icon"></i>
|
||||||
|
</span>
|
||||||
|
<div class="d-flex gap-2 align-items-baseline">
|
||||||
|
<span class="small fw-semibold">@ev.Label</span>
|
||||||
|
<span class="text-muted" style="font-size:.72rem">@ev.At.ToLocalTime().ToString("MMM d HH:mm")</span>
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrEmpty(ev.Detail))
|
||||||
|
{
|
||||||
|
<div class="text-muted small mt-1">@ev.Detail</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Right: Actions ── *@
|
||||||
|
<div class="col-12 col-lg-7">
|
||||||
|
|
||||||
|
@* Assignment *@
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong class="small d-block mb-2">Assignment</strong>
|
||||||
|
@if (incident.AssignedTo is not null)
|
||||||
|
{
|
||||||
|
<p class="mb-1 small text-muted">
|
||||||
|
<span class="bi bi-person-check me-1"></span>
|
||||||
|
Assigned to <strong>@incident.AssignedTo</strong>
|
||||||
|
@if (incident.AssignedAt.HasValue)
|
||||||
|
{
|
||||||
|
<span>at @incident.AssignedAt.Value.ToLocalTime().ToString("MMM d HH:mm")</span>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
@if (assigningId == incident.Id)
|
||||||
|
{
|
||||||
|
<div class="d-flex gap-2 align-items-center mt-1">
|
||||||
|
<input class="form-control form-control-sm" style="max-width:220px"
|
||||||
|
@bind="assigneeInput" placeholder="Assignee name or email" />
|
||||||
|
<button class="btn btn-sm btn-primary"
|
||||||
|
@onclick="() => SubmitAssign(incident)"
|
||||||
|
disabled="@string.IsNullOrWhiteSpace(assigneeInput)">
|
||||||
|
Assign
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary"
|
||||||
|
@onclick="() => assigningId = null">Cancel</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-secondary mt-1"
|
||||||
|
@onclick="() => StartAssign(incident)">
|
||||||
|
<span class="bi bi-person-plus me-1"></span>
|
||||||
|
@(incident.AssignedTo is null ? "Assign" : "Reassign")
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Notification delivery history *@
|
||||||
|
@if (deliveriesByIncident.TryGetValue(incident.Id, out List<NotificationDelivery>? deliveries) && deliveries.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong class="small d-block mb-1">Notification history</strong>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-borderless mb-0" style="font-size:.77rem">
|
||||||
|
<tbody>
|
||||||
|
@foreach (NotificationDelivery d in deliveries)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td class="py-0 text-muted">@d.SentAt.ToLocalTime().ToString("MMM d HH:mm")</td>
|
||||||
|
<td class="py-0">@d.Channel?.Name</td>
|
||||||
|
<td class="py-0 text-muted">@(d.IsFiring ? "FIRING" : "RESOLVED")</td>
|
||||||
|
<td class="py-0">
|
||||||
|
@if (d.Success)
|
||||||
|
{
|
||||||
|
<span class="text-success"><span class="bi bi-check-circle me-1"></span>Sent</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="text-danger" title="@d.Error"><span class="bi bi-x-circle me-1"></span>Failed</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="d-flex gap-2">
|
@* Re-notify *@
|
||||||
<textarea class="form-control form-control-sm" rows="2" placeholder="Add a note…"
|
<div class="mb-3 d-flex gap-2 align-items-center">
|
||||||
@bind="noteText" style="max-width:400px"></textarea>
|
<button class="btn btn-sm btn-outline-info"
|
||||||
<button class="btn btn-sm btn-primary align-self-end" @onclick="() => AddNote(incident)">
|
@onclick="() => ReNotify(incident)"
|
||||||
Add Note
|
disabled="@(reNotifyingId == incident.Id)">
|
||||||
</button>
|
@if (reNotifyingId == incident.Id)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||||
|
}
|
||||||
|
<span class="bi bi-send me-1"></span>Re-notify
|
||||||
|
</button>
|
||||||
|
@if (reNotifyResults.TryGetValue(incident.Id, out string? reNotifyMsg))
|
||||||
|
{
|
||||||
|
<span class="small @(reNotifyMsg.StartsWith("✓") ? "text-success" : "text-danger")">@reNotifyMsg</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Add note *@
|
||||||
|
<strong class="small d-block mb-1">Add note</strong>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<textarea class="form-control form-control-sm" rows="2"
|
||||||
|
placeholder="Add a note…"
|
||||||
|
@bind="noteText" style="max-width:360px"></textarea>
|
||||||
|
<button class="btn btn-sm btn-primary align-self-end"
|
||||||
|
@onclick="() => AddNote(incident)">
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -210,11 +395,26 @@ else if (!isLoading)
|
|||||||
private bool isLoading = true;
|
private bool isLoading = true;
|
||||||
private string? currentUser;
|
private string? currentUser;
|
||||||
|
|
||||||
|
private HashSet<Guid> selectedIds = [];
|
||||||
|
private Guid? assigningId;
|
||||||
|
private string assigneeInput = "";
|
||||||
|
private bool filterUnassigned;
|
||||||
|
private string searchText = "";
|
||||||
|
private OnCallShift? currentOnCall;
|
||||||
|
private Dictionary<Guid, List<NotificationDelivery>> deliveriesByIncident = new();
|
||||||
|
private Guid? reNotifyingId;
|
||||||
|
private Dictionary<Guid, string> reNotifyResults = new();
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
AuthenticationState authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
AuthenticationState authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
currentUser = authState.User.Identity?.Name ?? "Unknown";
|
currentUser = authState.User.Identity?.Name ?? "Unknown";
|
||||||
await Task.WhenAll(LoadIncidents(), LoadStats());
|
await Task.WhenAll(LoadIncidents(), LoadStats(), LoadOnCall());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadOnCall()
|
||||||
|
{
|
||||||
|
currentOnCall = await OnCallService.GetCurrentOnCallAsync(TenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LoadStats()
|
private async Task LoadStats()
|
||||||
@@ -227,39 +427,147 @@ else if (!isLoading)
|
|||||||
isLoading = true;
|
isLoading = true;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
|
|
||||||
IncidentStatus? status = filterStatus switch
|
try
|
||||||
{
|
{
|
||||||
"Active" => IncidentStatus.Active,
|
IncidentStatus? status = filterStatus switch
|
||||||
"Acknowledged" => IncidentStatus.Acknowledged,
|
{
|
||||||
"Resolved" => IncidentStatus.Resolved,
|
"Active" => IncidentStatus.Active,
|
||||||
_ => null
|
"Acknowledged" => IncidentStatus.Acknowledged,
|
||||||
};
|
"Resolved" => IncidentStatus.Resolved,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
Guid? clusterId = Guid.TryParse(filterClusterId, out Guid cid) ? cid : null;
|
Guid? clusterId = Guid.TryParse(filterClusterId, out Guid cid) ? cid : null;
|
||||||
|
|
||||||
incidents = await IncidentService.GetIncidentsForTenantAsync(
|
incidents = await IncidentService.GetIncidentsForTenantAsync(
|
||||||
TenantId,
|
TenantId,
|
||||||
status: status,
|
status: status,
|
||||||
severity: string.IsNullOrEmpty(filterSeverity) ? null : filterSeverity,
|
severity: string.IsNullOrEmpty(filterSeverity) ? null : filterSeverity,
|
||||||
clusterId: clusterId,
|
clusterId: clusterId,
|
||||||
from: DateTime.UtcNow.AddDays(-filterDays),
|
from: DateTime.UtcNow.AddDays(-filterDays),
|
||||||
to: null);
|
to: null);
|
||||||
|
|
||||||
// Collect unique clusters from results
|
if (filterUnassigned)
|
||||||
clusters = incidents
|
incidents = incidents.Where(i => i.AssignedTo is null).ToList();
|
||||||
.Where(i => i.Cluster is not null)
|
|
||||||
.Select(i => i.Cluster!)
|
|
||||||
.DistinctBy(c => c.Id)
|
|
||||||
.OrderBy(c => c.Name)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
isLoading = false;
|
if (!string.IsNullOrWhiteSpace(searchText))
|
||||||
|
{
|
||||||
|
string q = searchText.Trim();
|
||||||
|
incidents = incidents.Where(i =>
|
||||||
|
i.AlertName.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
i.Summary.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
deliveriesByIncident.Clear();
|
||||||
|
|
||||||
|
clusters = incidents
|
||||||
|
.Where(i => i.Cluster is not null)
|
||||||
|
.Select(i => i.Cluster!)
|
||||||
|
.DistinctBy(c => c.Id)
|
||||||
|
.OrderBy(c => c.Name)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
incidents = [];
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ToggleExpand(Guid id)
|
private void ToggleExpand(Guid id)
|
||||||
{
|
{
|
||||||
expandedId = expandedId == id ? null : id;
|
if (expandedId == id)
|
||||||
|
{
|
||||||
|
expandedId = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
expandedId = id;
|
||||||
|
_ = LoadDeliveriesAsync(id);
|
||||||
|
}
|
||||||
noteText = "";
|
noteText = "";
|
||||||
|
if (assigningId != id) assigningId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadDeliveriesAsync(Guid incidentId)
|
||||||
|
{
|
||||||
|
List<NotificationDelivery> deliveries = await IncidentService.GetDeliveriesAsync(incidentId);
|
||||||
|
deliveriesByIncident[incidentId] = deliveries;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReNotify(AlertIncident incident)
|
||||||
|
{
|
||||||
|
reNotifyingId = incident.Id;
|
||||||
|
reNotifyResults.Remove(incident.Id);
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int count = await NotificationService.ReNotifyAsync(incident.Id, incident.Cluster!.TenantId);
|
||||||
|
reNotifyResults[incident.Id] = count > 0
|
||||||
|
? $"✓ Notified {count} channel{(count == 1 ? "" : "s")}"
|
||||||
|
: "✓ No channels to notify";
|
||||||
|
|
||||||
|
// Refresh deliveries so the new entry appears
|
||||||
|
await LoadDeliveriesAsync(incident.Id);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
reNotifyResults[incident.Id] = $"✗ {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
reNotifyingId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ToggleSelect(Guid id)
|
||||||
|
{
|
||||||
|
if (!selectedIds.Remove(id))
|
||||||
|
selectedIds.Add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ToggleSelectAll(bool select)
|
||||||
|
{
|
||||||
|
selectedIds.Clear();
|
||||||
|
if (select)
|
||||||
|
{
|
||||||
|
foreach (AlertIncident incident in incidents)
|
||||||
|
selectedIds.Add(incident.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task BulkAcknowledge()
|
||||||
|
{
|
||||||
|
await IncidentService.BulkAcknowledgeAsync(selectedIds, currentUser!);
|
||||||
|
selectedIds.Clear();
|
||||||
|
await LoadIncidents();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task BulkResolve()
|
||||||
|
{
|
||||||
|
await IncidentService.BulkResolveAsync(selectedIds);
|
||||||
|
selectedIds.Clear();
|
||||||
|
await LoadIncidents();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartAssign(AlertIncident incident)
|
||||||
|
{
|
||||||
|
assigningId = incident.Id;
|
||||||
|
assigneeInput = incident.AssignedTo ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SubmitAssign(AlertIncident incident)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(assigneeInput)) return;
|
||||||
|
await IncidentService.AssignAsync(incident.Id, assigneeInput.Trim());
|
||||||
|
assigningId = null;
|
||||||
|
await LoadIncidents();
|
||||||
|
expandedId = incident.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task Acknowledge(AlertIncident incident)
|
private async Task Acknowledge(AlertIncident incident)
|
||||||
@@ -283,6 +591,36 @@ else if (!isLoading)
|
|||||||
expandedId = incident.Id;
|
expandedId = incident.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private record TimelineEvent(DateTime At, string Icon, string IconColor, string Label, string Detail);
|
||||||
|
|
||||||
|
private static List<TimelineEvent> BuildTimeline(AlertIncident incident)
|
||||||
|
{
|
||||||
|
List<TimelineEvent> events = [];
|
||||||
|
|
||||||
|
events.Add(new(incident.StartsAt, "bi-bell-fill", "text-danger", "Alert fired", ""));
|
||||||
|
|
||||||
|
if (incident.EscalatedAt.HasValue)
|
||||||
|
events.Add(new(incident.EscalatedAt.Value, "bi-exclamation-triangle-fill", "text-danger",
|
||||||
|
"Escalated", "No acknowledgement within threshold"));
|
||||||
|
|
||||||
|
if (incident.AssignedAt.HasValue && incident.AssignedTo is not null)
|
||||||
|
events.Add(new(incident.AssignedAt.Value, "bi-person-fill", "text-primary",
|
||||||
|
$"Assigned to {incident.AssignedTo}", ""));
|
||||||
|
|
||||||
|
if (incident.AcknowledgedAt.HasValue)
|
||||||
|
events.Add(new(incident.AcknowledgedAt.Value, "bi-check-circle-fill", "text-warning",
|
||||||
|
$"Acknowledged{(incident.AcknowledgedBy is not null ? $" by {incident.AcknowledgedBy}" : "")}", ""));
|
||||||
|
|
||||||
|
foreach (IncidentNote note in incident.Notes)
|
||||||
|
events.Add(new(note.CreatedAt, "bi-chat-left-text-fill", "text-secondary",
|
||||||
|
$"Note by {note.Author}", note.Content));
|
||||||
|
|
||||||
|
if (incident.ResolvedAt.HasValue)
|
||||||
|
events.Add(new(incident.ResolvedAt.Value, "bi-check2-circle", "text-success", "Resolved", ""));
|
||||||
|
|
||||||
|
return [.. events.OrderBy(e => e.At)];
|
||||||
|
}
|
||||||
|
|
||||||
private static string FormatDuration(AlertIncident incident)
|
private static string FormatDuration(AlertIncident incident)
|
||||||
{
|
{
|
||||||
DateTime end = incident.EndsAt ?? DateTime.UtcNow;
|
DateTime end = incident.EndsAt ?? DateTime.UtcNow;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@inject IDbContextFactory<ApplicationDbContext> DbFactory
|
@inject IDbContextFactory<ApplicationDbContext> DbFactory
|
||||||
@inject NotificationService NotificationService
|
@inject NotificationService NotificationService
|
||||||
|
@inject NotificationProviderConfigService ProviderConfigService
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h6 class="mb-0">Notification Channels</h6>
|
<h6 class="mb-0">Notification Channels</h6>
|
||||||
@@ -108,13 +109,45 @@ else
|
|||||||
<input class="form-control" @bind="newName" placeholder="e.g. Ops Slack" />
|
<input class="form-control" @bind="newName" placeholder="e.g. Ops Slack" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (newType == NotificationChannelType.Slack || newType == NotificationChannelType.Teams)
|
@if (newType == NotificationChannelType.Slack)
|
||||||
{
|
{
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Webhook URL</label>
|
<label class="form-label">Webhook URL</label>
|
||||||
<input class="form-control" @bind="newWebhookUrl" placeholder="https://hooks.slack.com/…" />
|
<input class="form-control" @bind="newWebhookUrl" placeholder="https://hooks.slack.com/services/…" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
else if (newType == NotificationChannelType.Teams)
|
||||||
|
{
|
||||||
|
@if (teamsGraphConfigured)
|
||||||
|
{
|
||||||
|
<div class="alert alert-info py-2 small mb-3">
|
||||||
|
<i class="bi bi-microsoft-teams me-1"></i>
|
||||||
|
Teams Graph API is configured. Messages will be sent via Microsoft Graph.
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Team ID</label>
|
||||||
|
<input class="form-control font-monospace" @bind="newTeamId" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" />
|
||||||
|
<div class="form-text">Found in Teams → right-click team → Get link to team</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Channel ID</label>
|
||||||
|
<input class="form-control font-monospace" @bind="newChannelId" placeholder="19:xxxxxxxxxxxxxxxx@thread.tacv2" />
|
||||||
|
<div class="form-text">Found in Teams → right-click channel → Get link to channel</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning py-2 small mb-3">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||||
|
Teams Graph API is not configured. Using legacy incoming webhook.
|
||||||
|
<a href="/admin/notifications" target="_blank">Configure it here</a>.
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Webhook URL</label>
|
||||||
|
<input class="form-control" @bind="newWebhookUrl" placeholder="https://…webhook.office.com/…" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
else if (newType == NotificationChannelType.Email)
|
else if (newType == NotificationChannelType.Email)
|
||||||
{
|
{
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -177,12 +210,20 @@ else
|
|||||||
private string newWebhookUrl = "";
|
private string newWebhookUrl = "";
|
||||||
private string newEmail = "";
|
private string newEmail = "";
|
||||||
private string newBearerToken = "";
|
private string newBearerToken = "";
|
||||||
|
private string newTeamId = "";
|
||||||
|
private string newChannelId = "";
|
||||||
private AlertSeverityFilter newSeverityFilter = AlertSeverityFilter.All;
|
private AlertSeverityFilter newSeverityFilter = AlertSeverityFilter.All;
|
||||||
private string? modalError;
|
private string? modalError;
|
||||||
private Guid? testingId;
|
private Guid? testingId;
|
||||||
private Dictionary<Guid, string> testResults = new();
|
private Dictionary<Guid, string> testResults = new();
|
||||||
|
private bool teamsGraphConfigured;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync() => await LoadChannels();
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
NotificationProviderConfig? graphConfig = await ProviderConfigService.GetAsync(NotificationProviderType.MsTeamsGraph);
|
||||||
|
teamsGraphConfigured = graphConfig?.IsEnabled == true;
|
||||||
|
await LoadChannels();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task LoadChannels()
|
private async Task LoadChannels()
|
||||||
{
|
{
|
||||||
@@ -211,6 +252,8 @@ else
|
|||||||
newWebhookUrl = "";
|
newWebhookUrl = "";
|
||||||
newEmail = "";
|
newEmail = "";
|
||||||
newBearerToken = "";
|
newBearerToken = "";
|
||||||
|
newTeamId = "";
|
||||||
|
newChannelId = "";
|
||||||
newSeverityFilter = AlertSeverityFilter.All;
|
newSeverityFilter = AlertSeverityFilter.All;
|
||||||
modalError = null;
|
modalError = null;
|
||||||
}
|
}
|
||||||
@@ -261,7 +304,17 @@ else
|
|||||||
{
|
{
|
||||||
return newType switch
|
return newType switch
|
||||||
{
|
{
|
||||||
NotificationChannelType.Slack or NotificationChannelType.Teams =>
|
NotificationChannelType.Slack =>
|
||||||
|
!string.IsNullOrWhiteSpace(newWebhookUrl)
|
||||||
|
? System.Text.Json.JsonSerializer.Serialize(new { webhookUrl = newWebhookUrl.Trim() })
|
||||||
|
: throw new InvalidOperationException("Webhook URL is required."),
|
||||||
|
|
||||||
|
NotificationChannelType.Teams when teamsGraphConfigured =>
|
||||||
|
!string.IsNullOrWhiteSpace(newTeamId) && !string.IsNullOrWhiteSpace(newChannelId)
|
||||||
|
? System.Text.Json.JsonSerializer.Serialize(new { teamId = newTeamId.Trim(), channelId = newChannelId.Trim() })
|
||||||
|
: throw new InvalidOperationException("Team ID and Channel ID are required."),
|
||||||
|
|
||||||
|
NotificationChannelType.Teams =>
|
||||||
!string.IsNullOrWhiteSpace(newWebhookUrl)
|
!string.IsNullOrWhiteSpace(newWebhookUrl)
|
||||||
? System.Text.Json.JsonSerializer.Serialize(new { webhookUrl = newWebhookUrl.Trim() })
|
? System.Text.Json.JsonSerializer.Serialize(new { webhookUrl = newWebhookUrl.Trim() })
|
||||||
: throw new InvalidOperationException("Webhook URL is required."),
|
: throw new InvalidOperationException("Webhook URL is required."),
|
||||||
|
|||||||
342
src/EntKube.Web/Components/Pages/Tenants/OnCallTab.razor
Normal file
342
src/EntKube.Web/Components/Pages/Tenants/OnCallTab.razor
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
@using EntKube.Web.Data
|
||||||
|
@using EntKube.Web.Services
|
||||||
|
@inject OnCallService OnCallService
|
||||||
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
|
|
||||||
|
@* ── Current on-call banner ── *@
|
||||||
|
@if (currentShift is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-success d-flex align-items-center gap-2 py-2 mb-3">
|
||||||
|
<span class="bi bi-person-check-fill fs-5"></span>
|
||||||
|
<div>
|
||||||
|
<strong>@currentShift.AssigneeName</strong> is currently on call
|
||||||
|
@if (!string.IsNullOrEmpty(currentShift.AssigneeEmail))
|
||||||
|
{
|
||||||
|
<span class="text-muted ms-1">— <a href="mailto:@currentShift.AssigneeEmail">@currentShift.AssigneeEmail</a></span>
|
||||||
|
}
|
||||||
|
<span class="text-muted ms-2 small">until @currentShift.EndsAt.ToLocalTime().ToString("MMM d HH:mm")</span>
|
||||||
|
<span class="badge bg-success ms-2 small">@currentShift.Schedule.Name</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning d-flex align-items-center gap-2 py-2 mb-3">
|
||||||
|
<span class="bi bi-person-exclamation fs-5"></span>
|
||||||
|
<span>No one is currently on call. Add a shift to cover now.</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ── Schedules + Create ── *@
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h6 class="mb-0">Schedules</h6>
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="() => showCreateSchedule = !showCreateSchedule">
|
||||||
|
<span class="bi bi-plus-circle me-1"></span>New Schedule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (showCreateSchedule)
|
||||||
|
{
|
||||||
|
<div class="card border-primary mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-2 mb-2">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label class="form-label small">Name</label>
|
||||||
|
<input class="form-control form-control-sm" @bind="newScheduleName" placeholder="Primary On-Call" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-7">
|
||||||
|
<label class="form-label small">Description</label>
|
||||||
|
<input class="form-control form-control-sm" @bind="newScheduleDescription" placeholder="Optional description" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrEmpty(scheduleError))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger py-1 small mb-2">@scheduleError</div>
|
||||||
|
}
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="CreateSchedule"
|
||||||
|
disabled="@(string.IsNullOrWhiteSpace(newScheduleName))">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showCreateSchedule = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (schedules.Count == 0 && !isLoading)
|
||||||
|
{
|
||||||
|
<EmptyState Icon="bi-calendar2-week"
|
||||||
|
Title="No schedules"
|
||||||
|
Message="Create a schedule to track who is on call." />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@foreach (OnCallSchedule schedule in schedules)
|
||||||
|
{
|
||||||
|
bool isExpanded = expandedScheduleId == schedule.Id;
|
||||||
|
<div class="card mb-3 @(isExpanded ? "border-primary" : "")">
|
||||||
|
<div class="card-header d-flex align-items-center py-2 gap-2" style="cursor:pointer"
|
||||||
|
@onclick="() => ToggleSchedule(schedule.Id)">
|
||||||
|
<span class="bi bi-calendar2-week text-primary"></span>
|
||||||
|
<strong class="me-auto">@schedule.Name</strong>
|
||||||
|
@if (!string.IsNullOrEmpty(schedule.Description))
|
||||||
|
{
|
||||||
|
<span class="text-muted small me-2">@schedule.Description</span>
|
||||||
|
}
|
||||||
|
@if (!schedule.IsEnabled)
|
||||||
|
{
|
||||||
|
<span class="badge bg-secondary">Disabled</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
int activeCount = schedule.Shifts.Count(sh => sh.StartsAt <= DateTime.UtcNow && sh.EndsAt >= DateTime.UtcNow);
|
||||||
|
int upcomingCount = schedule.Shifts.Count(sh => sh.StartsAt > DateTime.UtcNow);
|
||||||
|
@if (activeCount > 0)
|
||||||
|
{
|
||||||
|
<span class="badge bg-success">Active now</span>
|
||||||
|
}
|
||||||
|
<span class="text-muted small">@schedule.Shifts.Count shift@(schedule.Shifts.Count == 1 ? "" : "s")</span>
|
||||||
|
}
|
||||||
|
<button class="btn btn-sm btn-outline-danger ms-2"
|
||||||
|
@onclick:stopPropagation="true"
|
||||||
|
@onclick="() => DeleteSchedule(schedule)">
|
||||||
|
<span class="bi bi-trash"></span>
|
||||||
|
</button>
|
||||||
|
<span class="bi bi-chevron-@(isExpanded ? "up" : "down") text-muted"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (isExpanded)
|
||||||
|
{
|
||||||
|
<div class="card-body">
|
||||||
|
@* ── Shifts list ── *@
|
||||||
|
@if (schedule.Shifts.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-muted small mb-3">No shifts yet. Add the first shift below.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="table-responsive mb-3">
|
||||||
|
<table class="table table-sm align-middle">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Assignee</th>
|
||||||
|
<th>Start</th>
|
||||||
|
<th>End</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th style="width:60px"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (OnCallShift shift in schedule.Shifts.OrderBy(sh => sh.StartsAt))
|
||||||
|
{
|
||||||
|
bool active = shift.StartsAt <= DateTime.UtcNow && shift.EndsAt >= DateTime.UtcNow;
|
||||||
|
bool past = shift.EndsAt < DateTime.UtcNow;
|
||||||
|
<tr class="@(active ? "table-success" : past ? "text-muted" : "")">
|
||||||
|
<td>
|
||||||
|
<div class="fw-semibold small">@shift.AssigneeName</div>
|
||||||
|
@if (!string.IsNullOrEmpty(shift.AssigneeEmail))
|
||||||
|
{
|
||||||
|
<div class="text-muted" style="font-size:.77rem">@shift.AssigneeEmail</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="small">@shift.StartsAt.ToLocalTime().ToString("MMM d HH:mm")</td>
|
||||||
|
<td class="small">@shift.EndsAt.ToLocalTime().ToString("MMM d HH:mm")</td>
|
||||||
|
<td>
|
||||||
|
@if (active)
|
||||||
|
{
|
||||||
|
<span class="badge bg-success">Active</span>
|
||||||
|
}
|
||||||
|
else if (past)
|
||||||
|
{
|
||||||
|
<span class="badge bg-secondary">Past</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="badge bg-info text-dark">Upcoming</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-outline-danger"
|
||||||
|
@onclick="() => DeleteShift(shift)">
|
||||||
|
<span class="bi bi-trash"></span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ── Add shift form ── *@
|
||||||
|
@if (addShiftScheduleId == schedule.Id)
|
||||||
|
{
|
||||||
|
<div class="border rounded p-3 bg-light">
|
||||||
|
<h6 class="mb-2 small fw-semibold">Add Shift</h6>
|
||||||
|
<div class="row g-2 mb-2">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">Assignee name</label>
|
||||||
|
<input class="form-control form-control-sm" @bind="newShiftName" placeholder="Alice Smith" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">Email (optional)</label>
|
||||||
|
<input class="form-control form-control-sm" @bind="newShiftEmail" placeholder="alice@example.com" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">Notes</label>
|
||||||
|
<input class="form-control form-control-sm" @bind="newShiftNotes" placeholder="Optional notes" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-2 mb-2">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">Starts at (local time)</label>
|
||||||
|
<input type="datetime-local" class="form-control form-control-sm" @bind="newShiftStartsAt" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">Ends at (local time)</label>
|
||||||
|
<input type="datetime-local" class="form-control form-control-sm" @bind="newShiftEndsAt" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrEmpty(shiftError))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger py-1 small mb-2">@shiftError</div>
|
||||||
|
}
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="() => AddShift(schedule.Id)"
|
||||||
|
disabled="@(string.IsNullOrWhiteSpace(newShiftName))">
|
||||||
|
Add Shift
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelAddShift">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-primary" @onclick="() => StartAddShift(schedule.Id)">
|
||||||
|
<span class="bi bi-plus-circle me-1"></span>Add Shift
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public required Guid TenantId { get; set; }
|
||||||
|
|
||||||
|
private List<OnCallSchedule> schedules = [];
|
||||||
|
private OnCallShift? currentShift;
|
||||||
|
private bool isLoading = true;
|
||||||
|
private Guid? expandedScheduleId;
|
||||||
|
|
||||||
|
// Create schedule form
|
||||||
|
private bool showCreateSchedule;
|
||||||
|
private string newScheduleName = "";
|
||||||
|
private string? newScheduleDescription;
|
||||||
|
private string? scheduleError;
|
||||||
|
|
||||||
|
// Add shift form
|
||||||
|
private Guid? addShiftScheduleId;
|
||||||
|
private string newShiftName = "";
|
||||||
|
private string? newShiftEmail;
|
||||||
|
private string? newShiftNotes;
|
||||||
|
private DateTime newShiftStartsAt = DateTime.Now.Date.AddHours(DateTime.Now.Hour + 1);
|
||||||
|
private DateTime newShiftEndsAt = DateTime.Now.Date.AddHours(DateTime.Now.Hour + 9);
|
||||||
|
private string? shiftError;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await Load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Load()
|
||||||
|
{
|
||||||
|
isLoading = true;
|
||||||
|
Task<List<OnCallSchedule>> schedulesTask = OnCallService.GetSchedulesAsync(TenantId);
|
||||||
|
Task<OnCallShift?> currentTask = OnCallService.GetCurrentOnCallAsync(TenantId);
|
||||||
|
await Task.WhenAll(schedulesTask, currentTask);
|
||||||
|
schedules = schedulesTask.Result;
|
||||||
|
currentShift = currentTask.Result;
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ToggleSchedule(Guid id)
|
||||||
|
{
|
||||||
|
expandedScheduleId = expandedScheduleId == id ? null : id;
|
||||||
|
addShiftScheduleId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CreateSchedule()
|
||||||
|
{
|
||||||
|
scheduleError = null;
|
||||||
|
if (string.IsNullOrWhiteSpace(newScheduleName)) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await OnCallService.CreateScheduleAsync(TenantId, newScheduleName.Trim(), newScheduleDescription?.Trim());
|
||||||
|
newScheduleName = "";
|
||||||
|
newScheduleDescription = null;
|
||||||
|
showCreateSchedule = false;
|
||||||
|
await Load();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
scheduleError = ex.Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteSchedule(OnCallSchedule schedule)
|
||||||
|
{
|
||||||
|
await OnCallService.DeleteScheduleAsync(schedule.Id);
|
||||||
|
await Load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartAddShift(Guid scheduleId)
|
||||||
|
{
|
||||||
|
addShiftScheduleId = scheduleId;
|
||||||
|
newShiftName = "";
|
||||||
|
newShiftEmail = null;
|
||||||
|
newShiftNotes = null;
|
||||||
|
newShiftStartsAt = DateTime.Now.Date.AddHours(DateTime.Now.Hour + 1);
|
||||||
|
newShiftEndsAt = DateTime.Now.Date.AddHours(DateTime.Now.Hour + 9);
|
||||||
|
shiftError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelAddShift()
|
||||||
|
{
|
||||||
|
addShiftScheduleId = null;
|
||||||
|
shiftError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddShift(Guid scheduleId)
|
||||||
|
{
|
||||||
|
shiftError = null;
|
||||||
|
if (string.IsNullOrWhiteSpace(newShiftName)) return;
|
||||||
|
if (newShiftEndsAt <= newShiftStartsAt)
|
||||||
|
{
|
||||||
|
shiftError = "End time must be after start time.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await OnCallService.AddShiftAsync(
|
||||||
|
scheduleId,
|
||||||
|
newShiftName.Trim(),
|
||||||
|
string.IsNullOrWhiteSpace(newShiftEmail) ? null : newShiftEmail.Trim(),
|
||||||
|
newShiftStartsAt.ToUniversalTime(),
|
||||||
|
newShiftEndsAt.ToUniversalTime(),
|
||||||
|
string.IsNullOrWhiteSpace(newShiftNotes) ? null : newShiftNotes.Trim());
|
||||||
|
|
||||||
|
addShiftScheduleId = null;
|
||||||
|
await Load();
|
||||||
|
expandedScheduleId = scheduleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteShift(OnCallShift shift)
|
||||||
|
{
|
||||||
|
await OnCallService.DeleteShiftAsync(shift.Id);
|
||||||
|
await Load();
|
||||||
|
expandedScheduleId = shift.ScheduleId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
@using EntKube.Web.Data
|
||||||
|
|
||||||
|
@* ── Server record edit / create form ── *@
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small mb-1">Display name <span class="text-danger">*</span></label>
|
||||||
|
<input class="form-control form-control-sm" @bind="Server.DisplayName" placeholder="e.g. node-01" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small mb-1">Provider</label>
|
||||||
|
<select class="form-select form-select-sm" @bind="Server.Provider">
|
||||||
|
@foreach (ServerProvider p in Enum.GetValues<ServerProvider>())
|
||||||
|
{
|
||||||
|
<option value="@p">@p</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small mb-1">Link to node</label>
|
||||||
|
<input class="form-control form-control-sm font-monospace" @bind="Server.NodeName"
|
||||||
|
placeholder="node-1.example.com" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small mb-1">IP address</label>
|
||||||
|
<input class="form-control form-control-sm font-monospace" @bind="Server.IpAddress" placeholder="10.0.0.1" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small mb-1">Management IP</label>
|
||||||
|
<input class="form-control form-control-sm font-monospace" @bind="Server.ManagementIpAddress" placeholder="10.0.1.1" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small mb-1">OS distribution</label>
|
||||||
|
<input class="form-control form-control-sm" @bind="Server.OsDistribution" placeholder="Ubuntu 22.04 LTS" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small mb-1">CPU cores</label>
|
||||||
|
<input class="form-control form-control-sm" type="number" min="1" @bind="Server.CpuCores" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small mb-1">RAM (GB)</label>
|
||||||
|
<input class="form-control form-control-sm" type="number" min="1" @bind="Server.RamGb" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small mb-1">Disk (GB)</label>
|
||||||
|
<input class="form-control form-control-sm" type="number" min="1" @bind="Server.DiskGb" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small mb-1">Location / zone / datacenter</label>
|
||||||
|
<input class="form-control form-control-sm" @bind="Server.Location" placeholder="us-east-1a / rack-B2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small mb-1">SSH user</label>
|
||||||
|
<input class="form-control form-control-sm font-monospace" @bind="Server.SshUser" placeholder="ubuntu" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small mb-1">SSH port</label>
|
||||||
|
<input class="form-control form-control-sm" type="number" min="1" max="65535" @bind="Server.SshPort" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-7">
|
||||||
|
<label class="form-label small mb-1">Jump host</label>
|
||||||
|
<input class="form-control form-control-sm font-monospace" @bind="Server.JumpHost"
|
||||||
|
placeholder="bastion.example.com:22 (optional)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label small mb-1">Notes</label>
|
||||||
|
<textarea class="form-control form-control-sm" rows="2" @bind="Server.Notes"
|
||||||
|
placeholder="Maintenance history, special config, caveats…"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 d-flex gap-2 mt-1">
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="HandleSave"
|
||||||
|
disabled="@(string.IsNullOrWhiteSpace(Server.DisplayName))">
|
||||||
|
<i class="bi bi-check-lg me-1"></i>Save
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => OnCancel.InvokeAsync()">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter, EditorRequired] public ClusterServer Server { get; set; } = null!;
|
||||||
|
[Parameter, EditorRequired] public EventCallback<ClusterServer> OnSave { get; set; }
|
||||||
|
[Parameter, EditorRequired] public EventCallback OnCancel { get; set; }
|
||||||
|
|
||||||
|
private Task HandleSave() => OnSave.InvokeAsync(Server);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
@using EntKube.Web.Data
|
||||||
|
@using EntKube.Web.Services
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@inject IDbContextFactory<ApplicationDbContext> DbFactory
|
||||||
|
|
||||||
|
@* Cluster picker — AlertRulesPanel is cluster-scoped *@
|
||||||
|
<div class="d-flex align-items-center gap-3 mb-4">
|
||||||
|
<label class="form-label small text-muted mb-0 text-nowrap">Cluster</label>
|
||||||
|
<select class="form-select form-select-sm" style="min-width:220px"
|
||||||
|
@bind="selectedClusterId" @bind:after="OnClusterChanged">
|
||||||
|
<option value="">— select a cluster —</option>
|
||||||
|
@foreach (ClusterOption opt in clusterOptions)
|
||||||
|
{
|
||||||
|
<option value="@opt.Id" disabled="@(!opt.HasPrometheus)">
|
||||||
|
@opt.Name@(!opt.HasPrometheus ? " (Prometheus not installed)" : "")
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
@if (clusterOptions.Any(c => c.HasPrometheus) && string.IsNullOrEmpty(selectedClusterId))
|
||||||
|
{
|
||||||
|
<span class="text-muted small">Select a cluster to view its alert rules.</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (string.IsNullOrEmpty(selectedClusterId))
|
||||||
|
{
|
||||||
|
@if (!isLoading && clusterOptions.Count == 0)
|
||||||
|
{
|
||||||
|
<EmptyState Icon="bi-hdd-rack"
|
||||||
|
Title="No clusters"
|
||||||
|
Message="No clusters are registered for this tenant." />
|
||||||
|
}
|
||||||
|
else if (!isLoading && !clusterOptions.Any(c => c.HasPrometheus))
|
||||||
|
{
|
||||||
|
<EmptyState Icon="bi-speedometer2"
|
||||||
|
Title="No Prometheus clusters"
|
||||||
|
Message="Install kube-prometheus-stack on a cluster to enable alert rules." />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<AlertRulesPanel ClusterId="@(Guid.Parse(selectedClusterId))" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public required Guid TenantId { get; set; }
|
||||||
|
|
||||||
|
private List<ClusterOption> clusterOptions = [];
|
||||||
|
private string selectedClusterId = "";
|
||||||
|
private bool isLoading = true;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
using ApplicationDbContext db = DbFactory.CreateDbContext();
|
||||||
|
List<KubernetesCluster> clusters = await db.KubernetesClusters
|
||||||
|
.Include(c => c.Components)
|
||||||
|
.Where(c => c.TenantId == TenantId)
|
||||||
|
.OrderBy(c => c.Name)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
clusterOptions = clusters.Select(c => new ClusterOption(
|
||||||
|
c.Id.ToString(),
|
||||||
|
c.Name,
|
||||||
|
HasPrometheus: c.Components.Any(comp =>
|
||||||
|
comp.HelmChartName == "kube-prometheus-stack" &&
|
||||||
|
comp.Status == ComponentStatus.Installed)
|
||||||
|
)).ToList();
|
||||||
|
|
||||||
|
// Auto-select first cluster with Prometheus
|
||||||
|
ClusterOption? best = clusterOptions.FirstOrDefault(c => c.HasPrometheus);
|
||||||
|
if (best is not null)
|
||||||
|
selectedClusterId = best.Id;
|
||||||
|
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnClusterChanged() { }
|
||||||
|
|
||||||
|
private record ClusterOption(string Id, string Name, bool HasPrometheus);
|
||||||
|
}
|
||||||
679
src/EntKube.Web/Components/Pages/Tenants/TenantAlertsTab.razor
Normal file
679
src/EntKube.Web/Components/Pages/Tenants/TenantAlertsTab.razor
Normal file
@@ -0,0 +1,679 @@
|
|||||||
|
@using EntKube.Web.Data
|
||||||
|
@using EntKube.Web.Services
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@inject PrometheusService PrometheusService
|
||||||
|
@inject RemediationService RemediationService
|
||||||
|
@inject IncidentService IncidentService
|
||||||
|
@inject IDbContextFactory<ApplicationDbContext> DbFactory
|
||||||
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
|
@* ── Toolbar ── *@
|
||||||
|
<div class="d-flex flex-wrap gap-2 align-items-center mb-3">
|
||||||
|
<select class="form-select form-select-sm" style="width:auto" @bind="filterClusterId">
|
||||||
|
<option value="">All clusters</option>
|
||||||
|
@foreach (ClusterAlertVm vm in clusterAlerts)
|
||||||
|
{
|
||||||
|
int count = vm.Alerts?.Count(a => a.State == "active") ?? 0;
|
||||||
|
<option value="@vm.Cluster.Id">@vm.Cluster.Name @(count > 0 ? $"({count})" : "")</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<select class="form-select form-select-sm" style="width:auto" @bind="filterSeverity">
|
||||||
|
<option value="">All severities</option>
|
||||||
|
<option value="critical">Critical</option>
|
||||||
|
<option value="warning">Warning</option>
|
||||||
|
<option value="info">Info</option>
|
||||||
|
</select>
|
||||||
|
@if (selectedKeys.Count > 0)
|
||||||
|
{
|
||||||
|
<span class="text-muted small">@selectedKeys.Count selected</span>
|
||||||
|
<button class="btn btn-sm btn-outline-warning" @onclick="BulkAcknowledge">
|
||||||
|
<span class="bi bi-check-all me-1"></span>Ack
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-warning" @onclick="StartBulkSilence">
|
||||||
|
<span class="bi bi-bell-slash me-1"></span>Silence
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => selectedKeys.Clear()">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<div class="ms-auto d-flex align-items-center gap-2">
|
||||||
|
@if (lastRefreshed.HasValue)
|
||||||
|
{
|
||||||
|
<span class="text-muted small">Refreshed @lastRefreshed.Value.ToLocalTime().ToString("HH:mm:ss")</span>
|
||||||
|
}
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="Refresh" disabled="@isLoading">
|
||||||
|
<span class="bi bi-arrow-clockwise @(isLoading ? "spin" : "")"></span> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Summary strip ── *@
|
||||||
|
@{
|
||||||
|
int totalFiring = Filtered.Count(a => a.Alert.State == "active");
|
||||||
|
int totalWarning = Filtered.Count(a => a.Alert.Severity == "warning");
|
||||||
|
int withRemediation = Filtered.Count(a => a.Remediation is not null);
|
||||||
|
}
|
||||||
|
@if (clusterAlerts.Any(vm => vm.Alerts is not null))
|
||||||
|
{
|
||||||
|
<div class="d-flex gap-2 flex-wrap mb-3">
|
||||||
|
<div class="card border-0 bg-light text-center px-3 py-2">
|
||||||
|
<div class="fw-bold fs-5 @(totalFiring > 0 ? "text-danger" : "text-success")">@totalFiring</div>
|
||||||
|
<div class="text-muted small">Firing</div>
|
||||||
|
</div>
|
||||||
|
<div class="card border-0 bg-light text-center px-3 py-2">
|
||||||
|
<div class="fw-bold fs-5 @(totalWarning > 0 ? "text-warning" : "")">@totalWarning</div>
|
||||||
|
<div class="text-muted small">Warning</div>
|
||||||
|
</div>
|
||||||
|
<div class="card border-0 bg-light text-center px-3 py-2">
|
||||||
|
<div class="fw-bold fs-5 @(withRemediation > 0 ? "text-primary" : "")">@withRemediation</div>
|
||||||
|
<div class="text-muted small">Auto-fixable</div>
|
||||||
|
</div>
|
||||||
|
@foreach (ClusterAlertVm vm in clusterAlerts.Where(v => v.Error is not null))
|
||||||
|
{
|
||||||
|
<div class="card border-warning text-center px-3 py-2">
|
||||||
|
<div class="text-muted small"><span class="bi bi-exclamation-triangle me-1 text-warning"></span>@vm.Cluster.Name unavailable</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isLoading && !clusterAlerts.Any())
|
||||||
|
{
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||||
|
<p class="text-muted small mt-2">Loading alerts from clusters…</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (!Filtered.Any())
|
||||||
|
{
|
||||||
|
<EmptyState Icon="bi-check-circle"
|
||||||
|
Title="No alerts"
|
||||||
|
Message="@(clusterAlerts.Any() ? "All clusters healthy — no active alerts match the filter." : "No clusters with Prometheus found.")" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th style="width:32px">
|
||||||
|
<input type="checkbox" class="form-check-input"
|
||||||
|
@onchange="e => ToggleSelectAll((bool)(e.Value ?? false))" />
|
||||||
|
</th>
|
||||||
|
<th style="width:90px">Severity</th>
|
||||||
|
<th>Alert</th>
|
||||||
|
<th>Cluster</th>
|
||||||
|
<th>Namespace</th>
|
||||||
|
<th>Started</th>
|
||||||
|
<th style="width:180px">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (AlertRow row in Filtered.OrderByDescending(r => r.Alert.Severity == "critical")
|
||||||
|
.ThenByDescending(r => r.Alert.Severity == "warning")
|
||||||
|
.ThenBy(r => r.Alert.StartsAt))
|
||||||
|
{
|
||||||
|
bool expanded = expandedKey == row.Key;
|
||||||
|
bool confirming = confirmKey == row.Key;
|
||||||
|
bool selected = selectedKeys.Contains(row.Key);
|
||||||
|
|
||||||
|
<tr class="@(expanded || confirming ? "table-active" : selected ? "table-warning" : "")"
|
||||||
|
style="cursor:pointer" @onclick="() => Toggle(row.Key)">
|
||||||
|
<td @onclick:stopPropagation="true">
|
||||||
|
<input type="checkbox" class="form-check-input" checked="@selected"
|
||||||
|
@onchange="() => ToggleSelect(row.Key)" />
|
||||||
|
</td>
|
||||||
|
<td>@SeverityBadge(row.Alert.Severity)</td>
|
||||||
|
<td>
|
||||||
|
<div class="fw-semibold small">@row.Alert.Name</div>
|
||||||
|
@if (!string.IsNullOrEmpty(row.Alert.Summary))
|
||||||
|
{
|
||||||
|
<div class="text-muted" style="font-size:0.77rem">@row.Alert.Summary</div>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrEmpty(row.Alert.RunbookUrl))
|
||||||
|
{
|
||||||
|
<a href="@row.Alert.RunbookUrl" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="small text-decoration-none" style="font-size:0.73rem"
|
||||||
|
@onclick:stopPropagation="true">
|
||||||
|
<span class="bi bi-book me-1"></span>Runbook
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="small text-muted">@row.ClusterName</td>
|
||||||
|
<td class="small text-muted font-monospace">
|
||||||
|
@row.Alert.Labels.GetValueOrDefault("namespace", "—")
|
||||||
|
</td>
|
||||||
|
<td class="small text-muted">@FormatAge(row.Alert.StartsAt)</td>
|
||||||
|
<td @onclick:stopPropagation="true">
|
||||||
|
<div class="d-flex gap-1 flex-wrap align-items-center">
|
||||||
|
@* DB incident status badge *@
|
||||||
|
@if (row.Incident is not null)
|
||||||
|
{
|
||||||
|
@if (row.Incident.Status == IncidentStatus.Active)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-warning"
|
||||||
|
@onclick="() => AcknowledgeIncident(row.Incident)"
|
||||||
|
title="Acknowledge incident">
|
||||||
|
Ack
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
else if (row.Incident.Status == IncidentStatus.Acknowledged)
|
||||||
|
{
|
||||||
|
<span class="badge bg-warning text-dark">Acked</span>
|
||||||
|
<button class="btn btn-sm btn-outline-success"
|
||||||
|
@onclick="() => ResolveIncident(row.Incident)"
|
||||||
|
title="Resolve incident">
|
||||||
|
Resolve
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (row.Remediation is not null)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm @(confirming ? "btn-primary" : "btn-outline-primary")"
|
||||||
|
@onclick="() => StartConfirm(row.Key)"
|
||||||
|
title="Auto-fix available">
|
||||||
|
<span class="bi bi-lightning-charge me-1"></span>Fix
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button class="btn btn-sm btn-outline-warning"
|
||||||
|
@onclick="() => StartSilence(row)"
|
||||||
|
title="Silence this alert">
|
||||||
|
<span class="bi bi-bell-slash"></span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary"
|
||||||
|
@onclick="() => Toggle(row.Key)"
|
||||||
|
title="Show labels">
|
||||||
|
<span class="bi bi-tags"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
@* ── Confirm remediation ── *@
|
||||||
|
@if (confirming && row.Remediation is not null)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="px-4 py-3 bg-primary-subtle border-start border-primary border-3">
|
||||||
|
<div class="d-flex align-items-start gap-3">
|
||||||
|
<span class="bi bi-lightning-charge-fill text-primary fs-4 mt-1"></span>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="fw-semibold mb-1">Auto-fix: @row.Remediation.ResourceKind/@row.Remediation.ResourceName</div>
|
||||||
|
<div class="text-muted small mb-3">@row.Remediation.Description</div>
|
||||||
|
@if (remediationResults.TryGetValue(row.Key, out string? result))
|
||||||
|
{
|
||||||
|
<div class="alert @(result.StartsWith("✓") ? "alert-success" : "alert-danger") py-2 px-3 small mb-2">
|
||||||
|
@result
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-sm btn-primary"
|
||||||
|
@onclick="() => ExecuteRemediation(row)"
|
||||||
|
disabled="@(executingKey == row.Key)">
|
||||||
|
@if (executingKey == row.Key)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||||
|
}
|
||||||
|
Confirm Fix
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary"
|
||||||
|
@onclick="() => confirmKey = null">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ── Labels / details expand ── *@
|
||||||
|
@if (expanded)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="px-4 py-3 bg-light">
|
||||||
|
@if (!string.IsNullOrEmpty(row.Alert.Description))
|
||||||
|
{
|
||||||
|
<p class="mb-2 text-muted small">@row.Alert.Description</p>
|
||||||
|
}
|
||||||
|
<div class="d-flex flex-wrap gap-1 mb-2">
|
||||||
|
@foreach (KeyValuePair<string, string> label in row.Alert.Labels
|
||||||
|
.Where(l => l.Key != "alertname" && l.Key != "severity"))
|
||||||
|
{
|
||||||
|
<span class="badge bg-light text-dark border small font-monospace">
|
||||||
|
@label.Key=@label.Value
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted">
|
||||||
|
Fingerprint: <code>@row.Alert.Fingerprint</code>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ── Bulk silence form ── *@
|
||||||
|
@if (showBulkSilenceForm)
|
||||||
|
{
|
||||||
|
<div class="card mt-3 border-warning">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center py-2">
|
||||||
|
<h6 class="mb-0"><span class="bi bi-bell-slash me-1"></span>Silence @selectedKeys.Count selected alert@(selectedKeys.Count == 1 ? "" : "s")</h6>
|
||||||
|
<button class="btn-close btn-sm" @onclick="() => showBulkSilenceForm = false"></button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-2 mb-2">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small">Duration</label>
|
||||||
|
<select class="form-select form-select-sm" @bind="bulkSilenceHours">
|
||||||
|
<option value="1">1 hour</option>
|
||||||
|
<option value="2">2 hours</option>
|
||||||
|
<option value="4">4 hours</option>
|
||||||
|
<option value="8">8 hours</option>
|
||||||
|
<option value="24">24 hours</option>
|
||||||
|
<option value="72">3 days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-9">
|
||||||
|
<label class="form-label small">Comment</label>
|
||||||
|
<input class="form-control form-control-sm" @bind="bulkSilenceComment"
|
||||||
|
placeholder="Reason for silence" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrEmpty(bulkSilenceError))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger py-1 small mb-2">@bulkSilenceError</div>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrEmpty(bulkSilenceResult))
|
||||||
|
{
|
||||||
|
<div class="alert alert-success py-1 small mb-2">@bulkSilenceResult</div>
|
||||||
|
}
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-sm btn-warning" @onclick="SubmitBulkSilence"
|
||||||
|
disabled="@(string.IsNullOrWhiteSpace(bulkSilenceComment) || isBulkSilencing)">
|
||||||
|
@if (isBulkSilencing) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
<span class="bi bi-bell-slash me-1"></span>Create Silences
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showBulkSilenceForm = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ── Create silence form ── *@
|
||||||
|
@if (showSilenceForm && silenceTarget is not null)
|
||||||
|
{
|
||||||
|
<div class="card mt-3 border-warning">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center py-2">
|
||||||
|
<h6 class="mb-0"><span class="bi bi-bell-slash me-1"></span>Silence — @silenceTarget.Alert.Name</h6>
|
||||||
|
<button class="btn-close btn-sm" @onclick="CancelSilence"></button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-2 mb-2">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">Matcher (label=value)</label>
|
||||||
|
<input class="form-control form-control-sm font-monospace"
|
||||||
|
@bind="silenceMatcher" placeholder="alertname=…" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small">Duration</label>
|
||||||
|
<select class="form-select form-select-sm" @bind="silenceHours">
|
||||||
|
<option value="1">1 hour</option>
|
||||||
|
<option value="2">2 hours</option>
|
||||||
|
<option value="4">4 hours</option>
|
||||||
|
<option value="8">8 hours</option>
|
||||||
|
<option value="24">24 hours</option>
|
||||||
|
<option value="72">3 days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label class="form-label small">Comment</label>
|
||||||
|
<input class="form-control form-control-sm" @bind="silenceComment"
|
||||||
|
placeholder="Reason for silence" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrEmpty(silenceError))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger py-1 small mb-2">@silenceError</div>
|
||||||
|
}
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-sm btn-warning" @onclick="SubmitSilence"
|
||||||
|
disabled="@(string.IsNullOrWhiteSpace(silenceMatcher) || string.IsNullOrWhiteSpace(silenceComment) || isSilencing)">
|
||||||
|
@if (isSilencing) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
<span class="bi bi-bell-slash me-1"></span>Create Silence
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelSilence">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public required Guid TenantId { get; set; }
|
||||||
|
|
||||||
|
private List<ClusterAlertVm> clusterAlerts = [];
|
||||||
|
private Dictionary<string, AlertIncident> incidentsByFingerprint = new();
|
||||||
|
private string filterClusterId = "";
|
||||||
|
private string filterSeverity = "";
|
||||||
|
private string? expandedKey;
|
||||||
|
private string? confirmKey;
|
||||||
|
private string? executingKey;
|
||||||
|
private Dictionary<string, string> remediationResults = new();
|
||||||
|
private bool isLoading = true;
|
||||||
|
private DateTime? lastRefreshed;
|
||||||
|
private string? currentUser;
|
||||||
|
private Timer? refreshTimer;
|
||||||
|
|
||||||
|
// Silence form
|
||||||
|
private bool showSilenceForm;
|
||||||
|
private AlertRow? silenceTarget;
|
||||||
|
private string silenceMatcher = "";
|
||||||
|
private int silenceHours = 2;
|
||||||
|
private string silenceComment = "";
|
||||||
|
private string? silenceError;
|
||||||
|
private bool isSilencing;
|
||||||
|
|
||||||
|
// Bulk silence form
|
||||||
|
private bool showBulkSilenceForm;
|
||||||
|
private int bulkSilenceHours = 2;
|
||||||
|
private string bulkSilenceComment = "";
|
||||||
|
private string? bulkSilenceError;
|
||||||
|
private string? bulkSilenceResult;
|
||||||
|
private bool isBulkSilencing;
|
||||||
|
|
||||||
|
private HashSet<string> selectedKeys = [];
|
||||||
|
|
||||||
|
private IEnumerable<AlertRow> Filtered => clusterAlerts
|
||||||
|
.Where(vm => vm.Alerts is not null)
|
||||||
|
.SelectMany(vm => vm.Alerts!.Select(a =>
|
||||||
|
{
|
||||||
|
string key = $"{vm.Cluster.Id}:{a.Fingerprint}";
|
||||||
|
incidentsByFingerprint.TryGetValue(key, out AlertIncident? incident);
|
||||||
|
return new AlertRow(
|
||||||
|
Key: key,
|
||||||
|
ClusterId: vm.Cluster.Id,
|
||||||
|
ClusterName: vm.Cluster.Name,
|
||||||
|
Alert: a,
|
||||||
|
Remediation: RemediationService.TryGetRemediation(a),
|
||||||
|
Incident: incident);
|
||||||
|
}))
|
||||||
|
.Where(r =>
|
||||||
|
(string.IsNullOrEmpty(filterClusterId) || r.ClusterId.ToString() == filterClusterId) &&
|
||||||
|
(string.IsNullOrEmpty(filterSeverity) || r.Alert.Severity == filterSeverity));
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
AuthenticationState auth = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
|
currentUser = auth.User.Identity?.Name ?? "Unknown";
|
||||||
|
await Refresh();
|
||||||
|
refreshTimer = new Timer(_ => InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
await Refresh();
|
||||||
|
StateHasChanged();
|
||||||
|
}), null, TimeSpan.FromMinutes(2), TimeSpan.FromMinutes(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Refresh()
|
||||||
|
{
|
||||||
|
isLoading = true;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
List<KubernetesCluster> clusters;
|
||||||
|
using (ApplicationDbContext db = DbFactory.CreateDbContext())
|
||||||
|
{
|
||||||
|
clusters = await db.KubernetesClusters
|
||||||
|
.Include(c => c.Components)
|
||||||
|
.Where(c => c.TenantId == TenantId)
|
||||||
|
.OrderBy(c => c.Name)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only query clusters that have Prometheus installed
|
||||||
|
List<KubernetesCluster> prometheusEnabled = clusters
|
||||||
|
.Where(c => c.Components.Any(comp =>
|
||||||
|
comp.HelmChartName == "kube-prometheus-stack" &&
|
||||||
|
comp.Status == ComponentStatus.Installed))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
List<Task<ClusterAlertVm>> tasks = prometheusEnabled
|
||||||
|
.Select(c => LoadClusterAlertsAsync(c))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
clusterAlerts = [.. await Task.WhenAll(tasks)];
|
||||||
|
|
||||||
|
// Build fingerprint → incident lookup keyed by "clusterId:fingerprint"
|
||||||
|
List<AlertIncident> activeIncidents =
|
||||||
|
await IncidentService.GetActiveIncidentsForTenantAsync(TenantId);
|
||||||
|
incidentsByFingerprint = activeIncidents
|
||||||
|
.Where(i => !string.IsNullOrEmpty(i.Fingerprint))
|
||||||
|
.ToDictionary(i => $"{i.ClusterId}:{i.Fingerprint}");
|
||||||
|
|
||||||
|
lastRefreshed = DateTime.UtcNow;
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ClusterAlertVm> LoadClusterAlertsAsync(KubernetesCluster cluster)
|
||||||
|
{
|
||||||
|
KubernetesOperationResult<List<AlertInfo>> result =
|
||||||
|
await PrometheusService.GetAlertsAsync(cluster.Id);
|
||||||
|
|
||||||
|
return result.IsSuccess
|
||||||
|
? new ClusterAlertVm(cluster, result.Data ?? [], null)
|
||||||
|
: new ClusterAlertVm(cluster, null, result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Toggle(string key)
|
||||||
|
{
|
||||||
|
if (expandedKey == key) expandedKey = null;
|
||||||
|
else { expandedKey = key; confirmKey = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartConfirm(string key)
|
||||||
|
{
|
||||||
|
confirmKey = confirmKey == key ? null : key;
|
||||||
|
expandedKey = null;
|
||||||
|
remediationResults.Remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExecuteRemediation(AlertRow row)
|
||||||
|
{
|
||||||
|
if (row.Remediation is null) return;
|
||||||
|
executingKey = row.Key;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
KubernetesOperationResult result =
|
||||||
|
await RemediationService.ExecuteAsync(row.ClusterId, row.Remediation, currentUser);
|
||||||
|
|
||||||
|
remediationResults[row.Key] = result.IsSuccess
|
||||||
|
? "✓ Remediation executed successfully. The alert should clear shortly."
|
||||||
|
: $"✗ {result.Error}";
|
||||||
|
|
||||||
|
executingKey = null;
|
||||||
|
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
// Wait a moment then refresh alerts
|
||||||
|
await Task.Delay(2000);
|
||||||
|
await Refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartSilence(AlertRow row)
|
||||||
|
{
|
||||||
|
silenceTarget = row;
|
||||||
|
silenceMatcher = $"alertname={row.Alert.Name}";
|
||||||
|
silenceComment = "";
|
||||||
|
silenceError = null;
|
||||||
|
showSilenceForm = true;
|
||||||
|
expandedKey = null;
|
||||||
|
confirmKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AcknowledgeIncident(AlertIncident incident)
|
||||||
|
{
|
||||||
|
await IncidentService.AcknowledgeAsync(incident.Id, currentUser!);
|
||||||
|
await Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ResolveIncident(AlertIncident incident)
|
||||||
|
{
|
||||||
|
await IncidentService.ResolveAsync(incident.Id, currentUser!);
|
||||||
|
await Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ToggleSelect(string key)
|
||||||
|
{
|
||||||
|
if (!selectedKeys.Remove(key))
|
||||||
|
selectedKeys.Add(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ToggleSelectAll(bool select)
|
||||||
|
{
|
||||||
|
selectedKeys.Clear();
|
||||||
|
if (select)
|
||||||
|
{
|
||||||
|
foreach (AlertRow row in Filtered)
|
||||||
|
selectedKeys.Add(row.Key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task BulkAcknowledge()
|
||||||
|
{
|
||||||
|
List<Guid> incidentIds = Filtered
|
||||||
|
.Where(r => selectedKeys.Contains(r.Key)
|
||||||
|
&& r.Incident?.Status == IncidentStatus.Active)
|
||||||
|
.Select(r => r.Incident!.Id)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (incidentIds.Count > 0)
|
||||||
|
await IncidentService.BulkAcknowledgeAsync(incidentIds, currentUser!);
|
||||||
|
|
||||||
|
selectedKeys.Clear();
|
||||||
|
await Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartBulkSilence()
|
||||||
|
{
|
||||||
|
bulkSilenceComment = "";
|
||||||
|
bulkSilenceError = null;
|
||||||
|
bulkSilenceResult = null;
|
||||||
|
showBulkSilenceForm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SubmitBulkSilence()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(bulkSilenceComment)) return;
|
||||||
|
|
||||||
|
bulkSilenceError = null;
|
||||||
|
bulkSilenceResult = null;
|
||||||
|
isBulkSilencing = true;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
List<AlertRow> selected = Filtered
|
||||||
|
.Where(r => selectedKeys.Contains(r.Key))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
int ok = 0, fail = 0;
|
||||||
|
foreach (AlertRow row in selected)
|
||||||
|
{
|
||||||
|
KubernetesOperationResult result = await PrometheusService.CreateSilenceAsync(
|
||||||
|
row.ClusterId,
|
||||||
|
bulkSilenceComment,
|
||||||
|
currentUser ?? "entkube-user",
|
||||||
|
TimeSpan.FromHours(bulkSilenceHours),
|
||||||
|
[new SilenceMatcher { Name = "alertname", Value = row.Alert.Name, IsEqual = true }]);
|
||||||
|
|
||||||
|
if (result.IsSuccess) ok++; else fail++;
|
||||||
|
}
|
||||||
|
|
||||||
|
isBulkSilencing = false;
|
||||||
|
selectedKeys.Clear();
|
||||||
|
|
||||||
|
if (fail == 0)
|
||||||
|
{
|
||||||
|
bulkSilenceResult = $"✓ Created {ok} silence{(ok == 1 ? "" : "s")} successfully.";
|
||||||
|
await Task.Delay(1500);
|
||||||
|
showBulkSilenceForm = false;
|
||||||
|
await Refresh();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
bulkSilenceError = $"{ok} silence{(ok == 1 ? "" : "s")} created, {fail} failed.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelSilence()
|
||||||
|
{
|
||||||
|
showSilenceForm = false;
|
||||||
|
silenceTarget = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SubmitSilence()
|
||||||
|
{
|
||||||
|
if (silenceTarget is null) return;
|
||||||
|
silenceError = null;
|
||||||
|
|
||||||
|
string[] parts = silenceMatcher.Split('=', 2);
|
||||||
|
if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[0]))
|
||||||
|
{
|
||||||
|
silenceError = "Format: label=value";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSilencing = true;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
KubernetesOperationResult result = await PrometheusService.CreateSilenceAsync(
|
||||||
|
silenceTarget.ClusterId,
|
||||||
|
silenceComment,
|
||||||
|
currentUser ?? "entkube-user",
|
||||||
|
TimeSpan.FromHours(silenceHours),
|
||||||
|
[new SilenceMatcher { Name = parts[0].Trim(), Value = parts[1].Trim(), IsEqual = true }]);
|
||||||
|
|
||||||
|
isSilencing = false;
|
||||||
|
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
showSilenceForm = false;
|
||||||
|
silenceTarget = null;
|
||||||
|
await Refresh();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
silenceError = result.Error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatAge(DateTime startsAt)
|
||||||
|
{
|
||||||
|
TimeSpan age = DateTime.UtcNow - startsAt;
|
||||||
|
return age switch
|
||||||
|
{
|
||||||
|
{ TotalMinutes: < 1 } => "just now",
|
||||||
|
{ TotalMinutes: < 60 } => $"{(int)age.TotalMinutes}m ago",
|
||||||
|
{ TotalHours: < 24 } => $"{(int)age.TotalHours}h ago",
|
||||||
|
_ => $"{(int)age.TotalDays}d ago"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RenderFragment SeverityBadge(string severity) => severity switch
|
||||||
|
{
|
||||||
|
"critical" => @<span class="badge bg-danger">Critical</span>,
|
||||||
|
"warning" => @<span class="badge bg-warning text-dark">Warning</span>,
|
||||||
|
"info" => @<span class="badge bg-info text-dark">Info</span>,
|
||||||
|
_ => @<span class="badge bg-secondary">@severity</span>
|
||||||
|
};
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (refreshTimer is not null)
|
||||||
|
await refreshTimer.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ClusterAlertVm(KubernetesCluster Cluster, List<AlertInfo>? Alerts, string? Error);
|
||||||
|
private record AlertRow(string Key, Guid ClusterId, string ClusterName, AlertInfo Alert, AlertRemediation? Remediation, AlertIncident? Incident);
|
||||||
|
}
|
||||||
@@ -37,22 +37,22 @@ else
|
|||||||
@* ── Category (top-level) nav ── *@
|
@* ── Category (top-level) nav ── *@
|
||||||
<ul class="nav nav-pills mb-2 gap-1 tenant-category-nav">
|
<ul class="nav nav-pills mb-2 gap-1 tenant-category-nav">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link @(activeCategory == "apps" ? "active" : "")" @onclick='() => SetCategory("apps")'>
|
<button class="nav-link @(activeCategory == "apps" ? "active" : "")" @onclick='() => SetCategory("apps")' disabled="@tabLoading">
|
||||||
<i class="bi bi-people me-1"></i>Applications
|
<i class="bi bi-people me-1"></i>Applications
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link @(activeCategory == "infra" ? "active" : "")" @onclick='() => SetCategory("infra")'>
|
<button class="nav-link @(activeCategory == "infra" ? "active" : "")" @onclick='() => SetCategory("infra")' disabled="@tabLoading">
|
||||||
<i class="bi bi-layers me-1"></i>Infrastructure
|
<i class="bi bi-layers me-1"></i>Infrastructure
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link @(activeCategory == "services" ? "active" : "")" @onclick='() => SetCategory("services")'>
|
<button class="nav-link @(activeCategory == "services" ? "active" : "")" @onclick='() => SetCategory("services")' disabled="@tabLoading">
|
||||||
<i class="bi bi-box-seam me-1"></i>Services
|
<i class="bi bi-box-seam me-1"></i>Services
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link @(activeCategory == "ops" ? "active" : "")" @onclick='() => SetCategory("ops")'>
|
<button class="nav-link @(activeCategory == "ops" ? "active" : "")" @onclick='() => SetCategory("ops")' disabled="@tabLoading">
|
||||||
<i class="bi bi-speedometer2 me-1"></i>Operations
|
<i class="bi bi-speedometer2 me-1"></i>Operations
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -63,35 +63,25 @@ else
|
|||||||
@if (activeCategory == "apps")
|
@if (activeCategory == "apps")
|
||||||
{
|
{
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link @(activeTab == "customers" ? "active" : "")" @onclick='() => activeTab = "customers"'>
|
<button class="nav-link @(activeTab == "customers" ? "active" : "")" @onclick='() => SetTab("customers")'>
|
||||||
<i class="bi bi-people me-1"></i>Customers
|
<i class="bi bi-people me-1"></i>Customers
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link @(activeTab == "groups" ? "active" : "")" @onclick='() => activeTab = "groups"'>
|
<button class="nav-link @(activeTab == "groups" ? "active" : "")" @onclick='() => SetTab("groups")'>
|
||||||
<i class="bi bi-diagram-3 me-1"></i>Groups
|
<i class="bi bi-diagram-3 me-1"></i>Groups
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
|
||||||
<button class="nav-link @(activeTab == "vault" ? "active" : "")" @onclick='() => activeTab = "vault"'>
|
|
||||||
<i class="bi bi-shield-lock me-1"></i>Vault
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<button class="nav-link @(activeTab == "git" ? "active" : "")" @onclick='() => activeTab = "git"'>
|
|
||||||
<i class="bi bi-git me-1"></i>Git
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
}
|
}
|
||||||
else if (activeCategory == "infra")
|
else if (activeCategory == "infra")
|
||||||
{
|
{
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link @(activeTab == "environments" ? "active" : "")" @onclick='() => activeTab = "environments"'>
|
<button class="nav-link @(activeTab == "environments" ? "active" : "")" @onclick='() => SetTab("environments")'>
|
||||||
<i class="bi bi-layers me-1"></i>Environments
|
<i class="bi bi-layers me-1"></i>Environments
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link @(activeTab == "networking" ? "active" : "")" @onclick='() => activeTab = "networking"'>
|
<button class="nav-link @(activeTab == "networking" ? "active" : "")" @onclick='() => SetTab("networking")'>
|
||||||
<i class="bi bi-diagram-2 me-1"></i>Networking
|
<i class="bi bi-diagram-2 me-1"></i>Networking
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -99,60 +89,95 @@ else
|
|||||||
else if (activeCategory == "services")
|
else if (activeCategory == "services")
|
||||||
{
|
{
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link @(activeTab == "databases" ? "active" : "")" @onclick='() => activeTab = "databases"'>
|
<button class="nav-link @(activeTab == "databases" ? "active" : "")" @onclick='() => SetTab("databases")'>
|
||||||
<i class="bi bi-database me-1"></i>Databases
|
<i class="bi bi-database me-1"></i>Databases
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link @(activeTab == "storage" ? "active" : "")" @onclick='() => activeTab = "storage"'>
|
<button class="nav-link @(activeTab == "storage" ? "active" : "")" @onclick='() => SetTab("storage")'>
|
||||||
<i class="bi bi-bucket me-1"></i>Storage
|
<i class="bi bi-bucket me-1"></i>Storage
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link @(activeTab == "messaging" ? "active" : "")" @onclick='() => activeTab = "messaging"'>
|
<button class="nav-link @(activeTab == "messaging" ? "active" : "")" @onclick='() => SetTab("messaging")'>
|
||||||
<i class="bi bi-collection me-1"></i>Messaging
|
<i class="bi bi-collection me-1"></i>Messaging
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link @(activeTab == "cache" ? "active" : "")" @onclick='() => activeTab = "cache"'>
|
<button class="nav-link @(activeTab == "cache" ? "active" : "")" @onclick='() => SetTab("cache")'>
|
||||||
<i class="bi bi-lightning-charge me-1"></i>Cache
|
<i class="bi bi-lightning-charge me-1"></i>Cache
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link @(activeTab == "identity" ? "active" : "")" @onclick='() => activeTab = "identity"'>
|
<button class="nav-link @(activeTab == "identity" ? "active" : "")" @onclick='() => SetTab("identity")'>
|
||||||
<i class="bi bi-person-badge me-1"></i>Identity
|
<i class="bi bi-person-badge me-1"></i>Identity
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link @(activeTab == "registry" ? "active" : "")" @onclick='() => activeTab = "registry"'>
|
<button class="nav-link @(activeTab == "registry" ? "active" : "")" @onclick='() => SetTab("registry")'>
|
||||||
<i class="bi bi-archive me-1"></i>Registry
|
<i class="bi bi-archive me-1"></i>Registry
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link @(activeTab == "vault" ? "active" : "")" @onclick='() => SetTab("vault")'>
|
||||||
|
<i class="bi bi-shield-lock me-1"></i>Vault
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
}
|
}
|
||||||
else if (activeCategory == "ops")
|
else if (activeCategory == "ops")
|
||||||
{
|
{
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link @(activeTab == "overview" ? "active" : "")" @onclick='() => activeTab = "overview"'>
|
<button class="nav-link @(activeTab == "overview" ? "active" : "")" @onclick='() => SetTab("overview")'>
|
||||||
<i class="bi bi-speedometer2 me-1"></i>Overview
|
<i class="bi bi-speedometer2 me-1"></i>Overview
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link @(activeTab == "incidents" ? "active" : "")" @onclick='() => activeTab = "incidents"'>
|
<button class="nav-link @(activeTab == "alerts" ? "active" : "")" @onclick='() => SetTab("alerts")'>
|
||||||
|
<i class="bi bi-bell me-1"></i>Alerts
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link @(activeTab == "alert-rules" ? "active" : "")" @onclick='() => SetTab("alert-rules")'>
|
||||||
|
<i class="bi bi-list-check me-1"></i>Alert Rules
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link @(activeTab == "logs" ? "active" : "")" @onclick='() => SetTab("logs")'>
|
||||||
|
<i class="bi bi-journal-text me-1"></i>Logs
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link @(activeTab == "incidents" ? "active" : "")" @onclick='() => SetTab("incidents")'>
|
||||||
<i class="bi bi-bell-fill me-1"></i>Incidents
|
<i class="bi bi-bell-fill me-1"></i>Incidents
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link @(activeTab == "notifications" ? "active" : "")" @onclick='() => activeTab = "notifications"'>
|
<button class="nav-link @(activeTab == "notifications" ? "active" : "")" @onclick='() => SetTab("notifications")'>
|
||||||
<i class="bi bi-send-fill me-1"></i>Notifications
|
<i class="bi bi-send-fill me-1"></i>Notifications
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link @(activeTab == "maintenance" ? "active" : "")" @onclick='() => activeTab = "maintenance"'>
|
<button class="nav-link @(activeTab == "maintenance" ? "active" : "")" @onclick='() => SetTab("maintenance")'>
|
||||||
<i class="bi bi-tools me-1"></i>Maintenance
|
<i class="bi bi-tools me-1"></i>Maintenance
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link @(activeTab == "sla" ? "active" : "")" @onclick='() => activeTab = "sla"'>
|
<button class="nav-link @(activeTab == "on-call" ? "active" : "")" @onclick='() => SetTab("on-call")'>
|
||||||
|
<i class="bi bi-person-badge me-1"></i>On-Call
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link @(activeTab == "alert-routing" ? "active" : "")" @onclick='() => SetTab("alert-routing")'>
|
||||||
|
<i class="bi bi-funnel me-1"></i>Alert Routing
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link @(activeTab == "analytics" ? "active" : "")" @onclick='() => SetTab("analytics")'>
|
||||||
|
<i class="bi bi-bar-chart-line me-1"></i>Analytics
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link @(activeTab == "sla" ? "active" : "")" @onclick='() => SetTab("sla")'>
|
||||||
<i class="bi bi-shield-check me-1"></i>SLA
|
<i class="bi bi-shield-check me-1"></i>SLA
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -160,7 +185,13 @@ else
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
@switch (activeTab)
|
@if (tabLoading)
|
||||||
|
{
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else @switch (activeTab)
|
||||||
{
|
{
|
||||||
case "environments":
|
case "environments":
|
||||||
<EnvironmentTab TenantId="tenant.Id" />
|
<EnvironmentTab TenantId="tenant.Id" />
|
||||||
@@ -193,7 +224,16 @@ else
|
|||||||
<CacheTab TenantId="tenant.Id" />
|
<CacheTab TenantId="tenant.Id" />
|
||||||
break;
|
break;
|
||||||
case "overview":
|
case "overview":
|
||||||
<TenantMonitoringOverview TenantId="tenant.Id" TenantSlug="tenant.Slug" />
|
<TenantMonitoringOverview TenantId="tenant.Id" TenantSlug="tenant.Slug" OnNavigateTab="SetTab" />
|
||||||
|
break;
|
||||||
|
case "alerts":
|
||||||
|
<TenantAlertsTab TenantId="tenant.Id" />
|
||||||
|
break;
|
||||||
|
case "alert-rules":
|
||||||
|
<TenantAlertRulesTab TenantId="tenant.Id" />
|
||||||
|
break;
|
||||||
|
case "logs":
|
||||||
|
<TenantLogsTab TenantId="tenant.Id" />
|
||||||
break;
|
break;
|
||||||
case "incidents":
|
case "incidents":
|
||||||
<IncidentManagement TenantId="tenant.Id" />
|
<IncidentManagement TenantId="tenant.Id" />
|
||||||
@@ -204,15 +244,21 @@ else
|
|||||||
case "maintenance":
|
case "maintenance":
|
||||||
<MaintenanceWindows TenantId="tenant.Id" />
|
<MaintenanceWindows TenantId="tenant.Id" />
|
||||||
break;
|
break;
|
||||||
|
case "on-call":
|
||||||
|
<OnCallTab TenantId="tenant.Id" />
|
||||||
|
break;
|
||||||
|
case "alert-routing":
|
||||||
|
<AlertRoutingTab TenantId="tenant.Id" />
|
||||||
|
break;
|
||||||
|
case "analytics":
|
||||||
|
<TenantOpsAnalyticsTab TenantId="tenant.Id" />
|
||||||
|
break;
|
||||||
case "sla":
|
case "sla":
|
||||||
<SlaTargetManager TenantId="tenant.Id" />
|
<SlaTargetManager TenantId="tenant.Id" />
|
||||||
break;
|
break;
|
||||||
case "networking":
|
case "networking":
|
||||||
<NetworkingTab TenantId="tenant.Id" />
|
<NetworkingTab TenantId="tenant.Id" />
|
||||||
break;
|
break;
|
||||||
case "git":
|
|
||||||
<GitReposTab TenantId="tenant.Id" />
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -223,6 +269,7 @@ else
|
|||||||
private Tenant? tenant;
|
private Tenant? tenant;
|
||||||
private string activeCategory = "apps";
|
private string activeCategory = "apps";
|
||||||
private string activeTab = "customers";
|
private string activeTab = "customers";
|
||||||
|
private bool tabLoading;
|
||||||
|
|
||||||
private static readonly Dictionary<string, string> CategoryDefaults = new()
|
private static readonly Dictionary<string, string> CategoryDefaults = new()
|
||||||
{
|
{
|
||||||
@@ -232,10 +279,23 @@ else
|
|||||||
["ops"] = "overview",
|
["ops"] = "overview",
|
||||||
};
|
};
|
||||||
|
|
||||||
private void SetCategory(string category)
|
private async Task SetCategory(string category)
|
||||||
{
|
{
|
||||||
|
tabLoading = true;
|
||||||
activeCategory = category;
|
activeCategory = category;
|
||||||
activeTab = CategoryDefaults[category];
|
activeTab = CategoryDefaults[category];
|
||||||
|
StateHasChanged();
|
||||||
|
await Task.Yield();
|
||||||
|
tabLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SetTab(string tab)
|
||||||
|
{
|
||||||
|
tabLoading = true;
|
||||||
|
activeTab = tab;
|
||||||
|
StateHasChanged();
|
||||||
|
await Task.Yield();
|
||||||
|
tabLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
|
|||||||
@@ -29,14 +29,21 @@
|
|||||||
<input type="text" class="form-control border-start-0" placeholder="New tenant name (e.g. Acme Corp)"
|
<input type="text" class="form-control border-start-0" placeholder="New tenant name (e.g. Acme Corp)"
|
||||||
@bind="newTenantName" @bind:event="oninput"
|
@bind="newTenantName" @bind:event="oninput"
|
||||||
@onkeydown="@(async (e) => { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newTenantName)) await CreateTenant(); })" />
|
@onkeydown="@(async (e) => { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newTenantName)) await CreateTenant(); })" />
|
||||||
<button class="btn btn-primary" @onclick="CreateTenant" disabled="@string.IsNullOrWhiteSpace(newTenantName)">
|
<button class="btn btn-primary" @onclick="CreateTenant"
|
||||||
<i class="bi bi-plus-lg me-1"></i>Create Tenant
|
disabled="@(string.IsNullOrWhiteSpace(newTenantName) || creating)">
|
||||||
|
@if (creating) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
else { <i class="bi bi-plus-lg me-1"></i> }
|
||||||
|
Create Tenant
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@if (!string.IsNullOrEmpty(errorMessage))
|
@if (!string.IsNullOrEmpty(errorMessage))
|
||||||
{
|
{
|
||||||
<div class="text-danger small mt-2"><i class="bi bi-exclamation-triangle me-1"></i>@errorMessage</div>
|
<div class="text-danger small mt-2"><i class="bi bi-exclamation-triangle me-1"></i>@errorMessage</div>
|
||||||
}
|
}
|
||||||
|
@if (!string.IsNullOrEmpty(successMessage))
|
||||||
|
{
|
||||||
|
<div class="text-success small mt-2"><i class="bi bi-check-circle me-1"></i>@successMessage</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -76,14 +83,13 @@
|
|||||||
|
|
||||||
@if (isAdmin && confirmDeleteId == tenant.Id)
|
@if (isAdmin && confirmDeleteId == tenant.Id)
|
||||||
{
|
{
|
||||||
<div class="card-body py-2 border-top bg-danger bg-opacity-10">
|
<div class="card-body py-2 border-top">
|
||||||
<div class="d-flex align-items-center justify-content-between">
|
<ConfirmDialog Visible="true"
|
||||||
<small class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>Delete this tenant?</small>
|
Message="@($"Permanently delete tenant '{tenant.Name}' and all its data?")"
|
||||||
<div>
|
ConfirmText="Delete"
|
||||||
<button class="btn btn-sm btn-danger me-1" @onclick="() => DeleteTenant(tenant.Id)">Delete</button>
|
IsBusy="deleting"
|
||||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmDeleteId = null">Cancel</button>
|
OnConfirm="() => DeleteTenant(tenant.Id)"
|
||||||
</div>
|
OnCancel="() => confirmDeleteId = null" />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,9 +110,12 @@
|
|||||||
private List<Tenant>? tenants;
|
private List<Tenant>? tenants;
|
||||||
private string newTenantName = "";
|
private string newTenantName = "";
|
||||||
private string? errorMessage;
|
private string? errorMessage;
|
||||||
|
private string? successMessage;
|
||||||
private Guid? confirmDeleteId;
|
private Guid? confirmDeleteId;
|
||||||
private bool isAdmin;
|
private bool isAdmin;
|
||||||
private bool loaded;
|
private bool loaded;
|
||||||
|
private bool creating;
|
||||||
|
private bool deleting;
|
||||||
private string? userId;
|
private string? userId;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
@@ -146,28 +155,43 @@
|
|||||||
tenants = await UserAccessService.GetAccessibleTenantsAsync(userId);
|
tenants = await UserAccessService.GetAccessibleTenantsAsync(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ShowSuccessAsync(string message)
|
||||||
|
{
|
||||||
|
successMessage = message;
|
||||||
|
StateHasChanged();
|
||||||
|
await Task.Delay(3500);
|
||||||
|
successMessage = null;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task CreateTenant()
|
private async Task CreateTenant()
|
||||||
{
|
{
|
||||||
if (!isAdmin) return;
|
if (!isAdmin) return;
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
|
creating = true;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await TenantService.CreateTenantAsync(newTenantName.Trim());
|
string name = newTenantName.Trim();
|
||||||
|
await TenantService.CreateTenantAsync(name);
|
||||||
newTenantName = "";
|
newTenantName = "";
|
||||||
await LoadTenants();
|
await LoadTenants();
|
||||||
|
_ = ShowSuccessAsync($"Tenant '{name}' created.");
|
||||||
}
|
}
|
||||||
catch (DbUpdateException)
|
catch (DbUpdateException)
|
||||||
{
|
{
|
||||||
errorMessage = "A tenant with that name already exists.";
|
errorMessage = "A tenant with that name already exists.";
|
||||||
}
|
}
|
||||||
|
finally { creating = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteTenant(Guid id)
|
private async Task DeleteTenant(Guid id)
|
||||||
{
|
{
|
||||||
if (!isAdmin) return;
|
if (!isAdmin) return;
|
||||||
confirmDeleteId = null;
|
deleting = true;
|
||||||
await TenantService.DeleteTenantAsync(id);
|
await TenantService.DeleteTenantAsync(id);
|
||||||
|
deleting = false;
|
||||||
|
confirmDeleteId = null;
|
||||||
await LoadTenants();
|
await LoadTenants();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
97
src/EntKube.Web/Components/Pages/Tenants/TenantLogsTab.razor
Normal file
97
src/EntKube.Web/Components/Pages/Tenants/TenantLogsTab.razor
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
@using EntKube.Web.Data
|
||||||
|
@using EntKube.Web.Services
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@inject IDbContextFactory<ApplicationDbContext> DbFactory
|
||||||
|
|
||||||
|
@* Cluster picker — required because LogBrowser is cluster-scoped *@
|
||||||
|
<div class="d-flex align-items-center gap-3 mb-4">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<label class="form-label small text-muted mb-0 text-nowrap">Cluster</label>
|
||||||
|
<select class="form-select form-select-sm" style="min-width:220px"
|
||||||
|
@bind="selectedClusterId" @bind:after="OnClusterChanged">
|
||||||
|
<option value="">— select a cluster —</option>
|
||||||
|
@foreach (ClusterOption opt in clusterOptions)
|
||||||
|
{
|
||||||
|
<option value="@opt.Id" disabled="@(!opt.HasLoki && !opt.HasPrometheus)">
|
||||||
|
@opt.Name
|
||||||
|
@if (!opt.HasLoki) { @(" (Loki not installed)") }
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (selectedCluster is not null)
|
||||||
|
{
|
||||||
|
<div class="d-flex gap-2 small">
|
||||||
|
<span class="badge @(selectedCluster.HasLoki ? "bg-success" : "bg-secondary")">
|
||||||
|
<span class="bi bi-journal me-1"></span>Loki @(selectedCluster.HasLoki ? "installed" : "not installed")
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (string.IsNullOrEmpty(selectedClusterId) && clusterOptions.Count == 0 && !isLoading)
|
||||||
|
{
|
||||||
|
<EmptyState Icon="bi-hdd-rack"
|
||||||
|
Title="No clusters"
|
||||||
|
Message="No clusters are registered for this tenant." />
|
||||||
|
}
|
||||||
|
else if (string.IsNullOrEmpty(selectedClusterId))
|
||||||
|
{
|
||||||
|
@if (clusterOptions.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<span class="bi bi-journal display-4 d-block mb-2 opacity-25"></span>
|
||||||
|
Select a cluster above to browse logs.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<LogBrowser ClusterId="@(Guid.Parse(selectedClusterId))" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public required Guid TenantId { get; set; }
|
||||||
|
|
||||||
|
private List<ClusterOption> clusterOptions = [];
|
||||||
|
private ClusterOption? selectedCluster;
|
||||||
|
private string selectedClusterId = "";
|
||||||
|
private bool isLoading = true;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
using ApplicationDbContext db = DbFactory.CreateDbContext();
|
||||||
|
List<KubernetesCluster> clusters = await db.KubernetesClusters
|
||||||
|
.Include(c => c.Components)
|
||||||
|
.Where(c => c.TenantId == TenantId)
|
||||||
|
.OrderBy(c => c.Name)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
clusterOptions = clusters.Select(c => new ClusterOption(
|
||||||
|
c.Id.ToString(),
|
||||||
|
c.Name,
|
||||||
|
HasLoki: c.Components.Any(comp => comp.HelmChartName == "loki-stack" && comp.Status == ComponentStatus.Installed)
|
||||||
|
|| c.Components.Any(comp => comp.HelmChartName == "grafana-loki" && comp.Status == ComponentStatus.Installed),
|
||||||
|
HasPrometheus: c.Components.Any(comp => comp.HelmChartName == "kube-prometheus-stack" && comp.Status == ComponentStatus.Installed)
|
||||||
|
)).ToList();
|
||||||
|
|
||||||
|
// Auto-select the first cluster that has Loki, then any cluster
|
||||||
|
ClusterOption? best = clusterOptions.FirstOrDefault(c => c.HasLoki)
|
||||||
|
?? clusterOptions.FirstOrDefault();
|
||||||
|
if (best is not null)
|
||||||
|
{
|
||||||
|
selectedClusterId = best.Id;
|
||||||
|
selectedCluster = best;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnClusterChanged()
|
||||||
|
{
|
||||||
|
selectedCluster = clusterOptions.FirstOrDefault(c => c.Id == selectedClusterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ClusterOption(string Id, string Name, bool HasLoki, bool HasPrometheus);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
@inject PrometheusService PrometheusService
|
@inject PrometheusService PrometheusService
|
||||||
@inject IncidentService IncidentService
|
@inject IncidentService IncidentService
|
||||||
|
@inject OnCallService OnCallService
|
||||||
@inject IDbContextFactory<ApplicationDbContext> DbFactory
|
@inject IDbContextFactory<ApplicationDbContext> DbFactory
|
||||||
@implements IAsyncDisposable
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
@@ -18,37 +19,54 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@* ── Tenant-level summary strip ── *@
|
||||||
@if (clusterSummaries.Count > 0)
|
@if (clusterSummaries.Count > 0)
|
||||||
{
|
{
|
||||||
<div class="row g-2 mb-4">
|
<div class="d-flex flex-wrap gap-2 mb-4">
|
||||||
<div class="col-auto">
|
<div class="card border-0 bg-light text-center px-3 py-2">
|
||||||
<div class="card text-center border-0 bg-light px-3 py-2">
|
<div class="fw-bold fs-4">@clusterSummaries.Count</div>
|
||||||
<div class="fw-bold fs-4">@clusterSummaries.Count</div>
|
<div class="text-muted small">Clusters</div>
|
||||||
<div class="text-muted small">Clusters</div>
|
</div>
|
||||||
|
<div class="card border-0 bg-light text-center px-3 py-2">
|
||||||
|
<div class="fw-bold fs-4">@clusterSummaries.Sum(s => s.Health?.TotalNodes ?? 0)</div>
|
||||||
|
<div class="text-muted small">Nodes</div>
|
||||||
|
</div>
|
||||||
|
<div class="card border-0 bg-light text-center px-3 py-2">
|
||||||
|
<div class="fw-bold fs-4">@clusterSummaries.Sum(s => s.Health?.RunningPods ?? 0)</div>
|
||||||
|
<div class="text-muted small">Running Pods</div>
|
||||||
|
</div>
|
||||||
|
<div class="card border-0 text-center px-3 py-2 @(tenantActiveAlerts > 0 ? "bg-danger-subtle" : "bg-light")"
|
||||||
|
style="cursor:@(OnNavigateTab.HasDelegate ? "pointer" : "default")"
|
||||||
|
@onclick='() => NavigateTo("alerts")'>
|
||||||
|
<div class="fw-bold fs-4 @(tenantActiveAlerts > 0 ? "text-danger" : "text-success")">@tenantActiveAlerts</div>
|
||||||
|
<div class="text-muted small d-flex align-items-center gap-1">
|
||||||
|
Active Alerts
|
||||||
|
@if (OnNavigateTab.HasDelegate)
|
||||||
|
{
|
||||||
|
<span class="bi bi-arrow-right" style="font-size:.7rem"></span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
@if (criticalIncidents > 0)
|
||||||
<div class="card text-center border-0 bg-light px-3 py-2">
|
{
|
||||||
<div class="fw-bold fs-4">@clusterSummaries.Sum(s => s.Health?.TotalNodes ?? 0)</div>
|
<div class="card border-danger text-center px-3 py-2"
|
||||||
<div class="text-muted small">Nodes</div>
|
style="cursor:@(OnNavigateTab.HasDelegate ? "pointer" : "default")"
|
||||||
|
@onclick='() => NavigateTo("incidents")'>
|
||||||
|
<div class="fw-bold fs-4 text-danger">@criticalIncidents</div>
|
||||||
|
<div class="text-muted small d-flex align-items-center gap-1">
|
||||||
|
Critical
|
||||||
|
@if (OnNavigateTab.HasDelegate)
|
||||||
|
{
|
||||||
|
<span class="bi bi-arrow-right" style="font-size:.7rem"></span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
<div class="col-auto">
|
|
||||||
<div class="card text-center border-0 bg-light px-3 py-2">
|
|
||||||
<div class="fw-bold fs-4">@clusterSummaries.Sum(s => s.Health?.RunningPods ?? 0)</div>
|
|
||||||
<div class="text-muted small">Running Pods</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<div class="card text-center border-0 bg-light px-3 py-2">
|
|
||||||
<div class="fw-bold fs-4 @(tenantActiveAlerts > 0 ? "text-danger" : "text-success")">@tenantActiveAlerts</div>
|
|
||||||
<div class="text-muted small">Active Alerts</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="row g-3">
|
@* ── Cluster cards ── *@
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
@foreach (ClusterSummaryVm vm in clusterSummaries)
|
@foreach (ClusterSummaryVm vm in clusterSummaries)
|
||||||
{
|
{
|
||||||
<div class="col-12 col-md-6 col-xl-4">
|
<div class="col-12 col-md-6 col-xl-4">
|
||||||
@@ -97,25 +115,146 @@
|
|||||||
<span class="bi bi-server me-1"></span>@vm.Health.ReadyNodes/@vm.Health.TotalNodes nodes ready
|
<span class="bi bi-server me-1"></span>@vm.Health.ReadyNodes/@vm.Health.TotalNodes nodes ready
|
||||||
·
|
·
|
||||||
<span class="bi bi-box me-1"></span>@vm.Health.RunningPods pods running
|
<span class="bi bi-box me-1"></span>@vm.Health.RunningPods pods running
|
||||||
|
@if (vm.Health.FailedPods > 0)
|
||||||
|
{
|
||||||
|
<span class="text-danger"> · <span class="bi bi-exclamation-circle me-1"></span>@vm.Health.FailedPods failed</span>
|
||||||
|
}
|
||||||
|
@if (vm.Health.PendingPods > 0)
|
||||||
|
{
|
||||||
|
<span class="text-warning"> · <span class="bi bi-hourglass me-1"></span>@vm.Health.PendingPods pending</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer py-1 bg-transparent border-0">
|
<div class="card-footer py-1 bg-transparent border-0 d-flex gap-3">
|
||||||
<a href="tenants/@TenantSlug/clusters/@vm.Cluster.Id/monitoring" class="btn btn-sm btn-link p-0 text-decoration-none">
|
<a href="tenants/@TenantSlug/clusters/@vm.Cluster.Id/monitoring"
|
||||||
<span class="bi bi-graph-up me-1"></span>View monitoring
|
class="btn btn-sm btn-link p-0 text-decoration-none">
|
||||||
|
<span class="bi bi-graph-up me-1"></span>Monitoring
|
||||||
</a>
|
</a>
|
||||||
|
@if (vm.ActiveAlerts > 0 && OnNavigateTab.HasDelegate)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-link p-0 text-decoration-none text-danger"
|
||||||
|
@onclick='() => NavigateTo("alerts")'>
|
||||||
|
<span class="bi bi-bell me-1"></span>View alerts
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@* ── On-call banner ── *@
|
||||||
|
@if (currentOnCall is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-success d-flex align-items-center gap-2 py-2 mb-3">
|
||||||
|
<span class="bi bi-person-check-fill"></span>
|
||||||
|
<span class="small">
|
||||||
|
On call: <strong>@currentOnCall.AssigneeName</strong>
|
||||||
|
@if (!string.IsNullOrEmpty(currentOnCall.AssigneeEmail))
|
||||||
|
{
|
||||||
|
<a href="mailto:@currentOnCall.AssigneeEmail" class="ms-1">@currentOnCall.AssigneeEmail</a>
|
||||||
|
}
|
||||||
|
— until @currentOnCall.EndsAt.ToLocalTime().ToString("MMM d HH:mm")
|
||||||
|
</span>
|
||||||
|
@if (OnNavigateTab.HasDelegate)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-link p-0 ms-auto text-decoration-none small"
|
||||||
|
@onclick='() => NavigateTo("on-call")'>
|
||||||
|
Schedule <span class="bi bi-arrow-right ms-1"></span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ── Active incidents ── *@
|
||||||
|
@if (activeIncidents.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h6 class="mb-0"><span class="bi bi-bell-fill text-danger me-1"></span>Active Incidents</h6>
|
||||||
|
@if (OnNavigateTab.HasDelegate)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-link p-0 text-decoration-none"
|
||||||
|
@onclick='() => NavigateTo("incidents")'>
|
||||||
|
View all <span class="bi bi-arrow-right ms-1"></span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="list-group">
|
||||||
|
@foreach (AlertIncident incident in activeIncidents.Take(5))
|
||||||
|
{
|
||||||
|
<div class="list-group-item list-group-item-action py-2 px-3 d-flex align-items-center gap-2">
|
||||||
|
@SeverityDot(incident.Severity)
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="fw-semibold small">@incident.AlertName</div>
|
||||||
|
@if (!string.IsNullOrEmpty(incident.Summary))
|
||||||
|
{
|
||||||
|
<div class="text-muted" style="font-size:.77rem">@incident.Summary</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<div class="small text-muted">@incident.Cluster?.Name</div>
|
||||||
|
<div class="text-muted" style="font-size:.72rem">@FormatAge(incident.StartsAt)</div>
|
||||||
|
</div>
|
||||||
|
@if (incident.Status == IncidentStatus.Acknowledged)
|
||||||
|
{
|
||||||
|
<span class="badge bg-warning text-dark ms-1">Acked</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (activeIncidents.Count > 5)
|
||||||
|
{
|
||||||
|
<div class="text-center mt-1">
|
||||||
|
<button class="btn btn-sm btn-link text-decoration-none"
|
||||||
|
@onclick='() => NavigateTo("incidents")'>
|
||||||
|
+@(activeIncidents.Count - 5) more
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ── Upcoming maintenance ── *@
|
||||||
|
@if (upcomingMaintenance.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning d-flex align-items-start gap-2 py-2 mb-3">
|
||||||
|
<span class="bi bi-tools fs-5 mt-1"></span>
|
||||||
|
<div>
|
||||||
|
<strong class="small">Upcoming maintenance</strong>
|
||||||
|
<div class="d-flex flex-column gap-1 mt-1">
|
||||||
|
@foreach (MaintenanceWindow w in upcomingMaintenance)
|
||||||
|
{
|
||||||
|
bool active = w.StartsAt <= DateTime.UtcNow && w.EndsAt >= DateTime.UtcNow;
|
||||||
|
<div class="small">
|
||||||
|
@if (active)
|
||||||
|
{
|
||||||
|
<span class="badge bg-warning text-dark me-1">Active now</span>
|
||||||
|
}
|
||||||
|
<strong>@w.Title</strong>
|
||||||
|
— @(w.Cluster?.Name ?? "All clusters")
|
||||||
|
<span class="text-muted ms-1">
|
||||||
|
@w.StartsAt.ToLocalTime().ToString("MMM d HH:mm") – @w.EndsAt.ToLocalTime().ToString("HH:mm")
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public required Guid TenantId { get; set; }
|
[Parameter] public required Guid TenantId { get; set; }
|
||||||
[Parameter] public required string TenantSlug { get; set; }
|
[Parameter] public required string TenantSlug { get; set; }
|
||||||
|
[Parameter] public EventCallback<string> OnNavigateTab { get; set; }
|
||||||
|
|
||||||
private List<ClusterSummaryVm> clusterSummaries = [];
|
private List<ClusterSummaryVm> clusterSummaries = [];
|
||||||
|
private List<AlertIncident> activeIncidents = [];
|
||||||
|
private List<MaintenanceWindow> upcomingMaintenance = [];
|
||||||
|
private OnCallShift? currentOnCall;
|
||||||
private int tenantActiveAlerts;
|
private int tenantActiveAlerts;
|
||||||
|
private int criticalIncidents;
|
||||||
private DateTime? lastRefreshed;
|
private DateTime? lastRefreshed;
|
||||||
private bool isLoading;
|
private bool isLoading;
|
||||||
private Timer? refreshTimer;
|
private Timer? refreshTimer;
|
||||||
@@ -145,10 +284,28 @@
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
tenantActiveAlerts = await IncidentService.GetActiveAlertCountForTenantAsync(TenantId);
|
// Load per-cluster active alert counts and tenant-level stats in parallel
|
||||||
|
Dictionary<Guid, int> perClusterCounts = await IncidentService
|
||||||
|
.GetActiveCountsPerClusterAsync(clusters.Select(c => c.Id));
|
||||||
|
|
||||||
|
tenantActiveAlerts = perClusterCounts.Values.Sum();
|
||||||
|
|
||||||
|
Task<List<AlertIncident>> incidentTask =
|
||||||
|
IncidentService.GetActiveIncidentsForTenantAsync(TenantId);
|
||||||
|
Task<List<MaintenanceWindow>> maintenanceTask =
|
||||||
|
IncidentService.GetUpcomingMaintenanceAsync(TenantId);
|
||||||
|
Task<OnCallShift?> onCallTask =
|
||||||
|
OnCallService.GetCurrentOnCallAsync(TenantId);
|
||||||
|
|
||||||
|
await Task.WhenAll(incidentTask, maintenanceTask, onCallTask);
|
||||||
|
|
||||||
|
activeIncidents = incidentTask.Result;
|
||||||
|
criticalIncidents = activeIncidents.Count(i => i.Severity == "critical");
|
||||||
|
upcomingMaintenance = maintenanceTask.Result;
|
||||||
|
currentOnCall = onCallTask.Result;
|
||||||
|
|
||||||
List<Task<ClusterSummaryVm>> tasks = clusters
|
List<Task<ClusterSummaryVm>> tasks = clusters
|
||||||
.Select(c => LoadClusterSummaryAsync(c))
|
.Select(c => LoadClusterSummaryAsync(c, perClusterCounts.GetValueOrDefault(c.Id)))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
clusterSummaries = [.. await Task.WhenAll(tasks)];
|
clusterSummaries = [.. await Task.WhenAll(tasks)];
|
||||||
@@ -157,13 +314,13 @@
|
|||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<ClusterSummaryVm> LoadClusterSummaryAsync(KubernetesCluster cluster)
|
private async Task<ClusterSummaryVm> LoadClusterSummaryAsync(KubernetesCluster cluster, int activeAlerts)
|
||||||
{
|
{
|
||||||
bool hasPrometheus = cluster.Components.Any(c =>
|
bool hasPrometheus = cluster.Components.Any(c =>
|
||||||
c.HelmChartName == "kube-prometheus-stack" && c.Status == ComponentStatus.Installed);
|
c.HelmChartName == "kube-prometheus-stack" && c.Status == ComponentStatus.Installed);
|
||||||
|
|
||||||
if (!hasPrometheus)
|
if (!hasPrometheus)
|
||||||
return new ClusterSummaryVm(cluster, null, null, 0);
|
return new ClusterSummaryVm(cluster, null, null, activeAlerts);
|
||||||
|
|
||||||
KubernetesOperationResult<ClusterHealthSummary> result =
|
KubernetesOperationResult<ClusterHealthSummary> result =
|
||||||
await PrometheusService.GetClusterHealthAsync(cluster.Id);
|
await PrometheusService.GetClusterHealthAsync(cluster.Id);
|
||||||
@@ -172,7 +329,13 @@
|
|||||||
cluster,
|
cluster,
|
||||||
result.IsSuccess ? result.Data : null,
|
result.IsSuccess ? result.Data : null,
|
||||||
result.IsSuccess ? null : result.Error,
|
result.IsSuccess ? null : result.Error,
|
||||||
0);
|
activeAlerts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task NavigateTo(string tab)
|
||||||
|
{
|
||||||
|
if (OnNavigateTab.HasDelegate)
|
||||||
|
await OnNavigateTab.InvokeAsync(tab);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string UtilColor(double pct) =>
|
private static string UtilColor(double pct) =>
|
||||||
@@ -186,6 +349,24 @@
|
|||||||
return "success";
|
return "success";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string FormatAge(DateTime t)
|
||||||
|
{
|
||||||
|
TimeSpan ago = DateTime.UtcNow - t;
|
||||||
|
return ago switch
|
||||||
|
{
|
||||||
|
{ TotalMinutes: < 60 } => $"{(int)ago.TotalMinutes}m",
|
||||||
|
{ TotalHours: < 24 } => $"{(int)ago.TotalHours}h",
|
||||||
|
_ => $"{(int)ago.TotalDays}d"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RenderFragment SeverityDot(string severity) => severity switch
|
||||||
|
{
|
||||||
|
"critical" => @<span class="bi bi-circle-fill text-danger" style="font-size:.55rem"></span>,
|
||||||
|
"warning" => @<span class="bi bi-circle-fill text-warning" style="font-size:.55rem"></span>,
|
||||||
|
_ => @<span class="bi bi-circle-fill text-info" style="font-size:.55rem"></span>
|
||||||
|
};
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
if (refreshTimer is not null)
|
if (refreshTimer is not null)
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
@using EntKube.Web.Data
|
||||||
|
@using EntKube.Web.Services
|
||||||
|
@inject IncidentService IncidentService
|
||||||
|
|
||||||
|
@if (isLoading)
|
||||||
|
{
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||||
|
<p class="text-muted small mt-2">Loading analytics…</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (loadError is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger small">
|
||||||
|
<strong>Failed to load analytics.</strong> @loadError
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (stats is null)
|
||||||
|
{
|
||||||
|
<EmptyState Icon="bi-bar-chart" Title="No data" Message="No incidents in the selected window." />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div class="d-flex gap-2 align-items-center">
|
||||||
|
<label class="form-label small text-muted mb-0">Window</label>
|
||||||
|
<select class="form-select form-select-sm" style="width:auto" @bind="windowDays" @bind:after="Load">
|
||||||
|
<option value="7">Last 7 days</option>
|
||||||
|
<option value="30">Last 30 days</option>
|
||||||
|
<option value="90">Last 90 days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="Load">
|
||||||
|
<span class="bi bi-arrow-clockwise"></span> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Summary strip ── *@
|
||||||
|
<div class="d-flex flex-wrap gap-2 mb-4">
|
||||||
|
<div class="card border-0 bg-light text-center px-3 py-2">
|
||||||
|
<div class="fw-bold fs-4">@stats.Total</div>
|
||||||
|
<div class="text-muted small">Total incidents</div>
|
||||||
|
</div>
|
||||||
|
<div class="card border-0 bg-danger-subtle text-center px-3 py-2">
|
||||||
|
<div class="fw-bold fs-4 text-danger">@stats.Critical</div>
|
||||||
|
<div class="text-muted small">Critical</div>
|
||||||
|
</div>
|
||||||
|
<div class="card border-0 bg-warning-subtle text-center px-3 py-2">
|
||||||
|
<div class="fw-bold fs-4 text-warning">@stats.Warning</div>
|
||||||
|
<div class="text-muted small">Warning</div>
|
||||||
|
</div>
|
||||||
|
<div class="card border-0 bg-light text-center px-3 py-2">
|
||||||
|
<div class="fw-bold fs-4">@stats.FormatMttr()</div>
|
||||||
|
<div class="text-muted small">Avg MTTR</div>
|
||||||
|
</div>
|
||||||
|
<div class="card border-0 bg-light text-center px-3 py-2">
|
||||||
|
<div class="fw-bold fs-4">@stats.FormatMtta()</div>
|
||||||
|
<div class="text-muted small">Avg MTTA</div>
|
||||||
|
</div>
|
||||||
|
<div class="card border-0 bg-light text-center px-3 py-2">
|
||||||
|
<div class="fw-bold fs-4 @(stats.Active > 0 ? "text-danger" : "text-success")">@stats.Active</div>
|
||||||
|
<div class="text-muted small">Still active</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
@* ── Daily volume sparkline ── *@
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header py-2 small fw-semibold">
|
||||||
|
<span class="bi bi-bar-chart me-1"></span>Alert volume — last @windowDays days
|
||||||
|
</div>
|
||||||
|
<div class="card-body py-3">
|
||||||
|
@{
|
||||||
|
int maxDaily = stats.Daily.Count > 0 ? Math.Max(1, stats.Daily.Max(d => d.Count)) : 1;
|
||||||
|
}
|
||||||
|
<div class="d-flex align-items-end gap-px" style="height:80px;gap:3px;overflow:hidden">
|
||||||
|
@foreach (DailyAlertCount day in stats.Daily)
|
||||||
|
{
|
||||||
|
int h = (int)Math.Max(4, day.Count * 76.0 / maxDaily);
|
||||||
|
string color = day.Count == 0 ? "#dee2e6"
|
||||||
|
: day.Count >= maxDaily * 0.8 ? "#dc3545"
|
||||||
|
: day.Count >= maxDaily * 0.5 ? "#fd7e14"
|
||||||
|
: "#0d6efd";
|
||||||
|
<div style="flex:1;min-width:4px;height:@(h)px;background:@color;border-radius:2px 2px 0 0"
|
||||||
|
title="@day.Date.ToString("MMM d"): @day.Count incident@(day.Count == 1 ? "" : "s")">
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between mt-1">
|
||||||
|
@if (stats.Daily.Count > 0)
|
||||||
|
{
|
||||||
|
<span class="text-muted" style="font-size:.72rem">@stats.Daily.First().Date.ToString("MMM d")</span>
|
||||||
|
<span class="text-muted" style="font-size:.72rem">@stats.Daily.Last().Date.ToString("MMM d")</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Top alerts ── *@
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header py-2 small fw-semibold">
|
||||||
|
<span class="bi bi-list-ol me-1"></span>Most frequent alerts
|
||||||
|
</div>
|
||||||
|
<div class="card-body py-2">
|
||||||
|
@if (topAlerts.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-muted small mb-0">No data.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
int maxCount = topAlerts.Max(a => a.Count);
|
||||||
|
@foreach (AlertFrequency alert in topAlerts)
|
||||||
|
{
|
||||||
|
int pct = maxCount > 0 ? (int)(alert.Count * 100.0 / maxCount) : 0;
|
||||||
|
string barColor = alert.Severity == "critical" ? "#dc3545"
|
||||||
|
: alert.Severity == "warning" ? "#fd7e14"
|
||||||
|
: "#0d6efd";
|
||||||
|
<div class="mb-2">
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<span class="small text-truncate me-2" style="max-width:200px"
|
||||||
|
title="@alert.Name">@alert.Name</span>
|
||||||
|
<span class="small fw-semibold text-muted">@alert.Count</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress" style="height:6px">
|
||||||
|
<div class="progress-bar" role="progressbar"
|
||||||
|
style="width:@(pct)%;background:@barColor"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Top namespaces ── *@
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header py-2 small fw-semibold">
|
||||||
|
<span class="bi bi-diagram-2 me-1"></span>Alerts by namespace
|
||||||
|
</div>
|
||||||
|
<div class="card-body py-2">
|
||||||
|
@if (topNamespaces.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-muted small mb-0">No namespace data.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
int maxCount = topNamespaces.Max(n => n.Count);
|
||||||
|
@foreach (AlertFrequency ns in topNamespaces)
|
||||||
|
{
|
||||||
|
int pct = maxCount > 0 ? (int)(ns.Count * 100.0 / maxCount) : 0;
|
||||||
|
<div class="mb-2">
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<span class="small font-monospace text-truncate me-2">@ns.Name</span>
|
||||||
|
<span class="small fw-semibold text-muted">@ns.Count</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress" style="height:6px">
|
||||||
|
<div class="progress-bar bg-primary" role="progressbar"
|
||||||
|
style="width:@(pct)%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Alerts by cluster ── *@
|
||||||
|
@if (byCluster.Count > 1)
|
||||||
|
{
|
||||||
|
<div class="col-12 col-lg-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header py-2 small fw-semibold">
|
||||||
|
<span class="bi bi-hdd-rack me-1"></span>Alerts by cluster
|
||||||
|
</div>
|
||||||
|
<div class="card-body py-2">
|
||||||
|
@{
|
||||||
|
int maxCluster = byCluster.Max(c => c.Count);
|
||||||
|
}
|
||||||
|
@foreach (AlertFrequency cluster in byCluster)
|
||||||
|
{
|
||||||
|
int pct = maxCluster > 0 ? (int)(cluster.Count * 100.0 / maxCluster) : 0;
|
||||||
|
<div class="mb-2">
|
||||||
|
<div class="d-flex justify-content-between mb-1">
|
||||||
|
<span class="small text-truncate me-2">@cluster.Name</span>
|
||||||
|
<span class="small fw-semibold text-muted">@cluster.Count</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress" style="height:6px">
|
||||||
|
<div class="progress-bar bg-secondary" role="progressbar"
|
||||||
|
style="width:@(pct)%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ── Severity breakdown ── *@
|
||||||
|
<div class="col-12 col-lg-@(byCluster.Count > 1 ? "6" : "12")">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header py-2 small fw-semibold">
|
||||||
|
<span class="bi bi-pie-chart me-1"></span>Severity breakdown
|
||||||
|
</div>
|
||||||
|
<div class="card-body py-2">
|
||||||
|
@if (stats.Total > 0)
|
||||||
|
{
|
||||||
|
int critPct = (int)(stats.Critical * 100.0 / stats.Total);
|
||||||
|
int warnPct = (int)(stats.Warning * 100.0 / stats.Total);
|
||||||
|
int otherPct = 100 - critPct - warnPct;
|
||||||
|
<div class="d-flex rounded overflow-hidden mb-2" style="height:24px">
|
||||||
|
@if (critPct > 0)
|
||||||
|
{
|
||||||
|
<div class="bg-danger d-flex align-items-center justify-content-center text-white"
|
||||||
|
style="width:@(critPct)%;font-size:.7rem" title="Critical: @stats.Critical">
|
||||||
|
@if (critPct > 10) { <span>@(critPct)%</span> }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (warnPct > 0)
|
||||||
|
{
|
||||||
|
<div class="bg-warning d-flex align-items-center justify-content-center text-dark"
|
||||||
|
style="width:@(warnPct)%;font-size:.7rem" title="Warning: @stats.Warning">
|
||||||
|
@if (warnPct > 10) { <span>@(warnPct)%</span> }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (otherPct > 0)
|
||||||
|
{
|
||||||
|
<div class="bg-info d-flex align-items-center justify-content-center text-white"
|
||||||
|
style="width:@(otherPct)%;font-size:.7rem" title="Other: @(stats.Total - stats.Critical - stats.Warning)">
|
||||||
|
@if (otherPct > 10) { <span>@(otherPct)%</span> }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-3 small">
|
||||||
|
<span><span class="badge bg-danger me-1">@stats.Critical</span>Critical</span>
|
||||||
|
<span><span class="badge bg-warning text-dark me-1">@stats.Warning</span>Warning</span>
|
||||||
|
<span><span class="badge bg-info me-1">@(stats.Total - stats.Critical - stats.Warning)</span>Other</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="text-muted small mb-0">No incidents in window.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public required Guid TenantId { get; set; }
|
||||||
|
|
||||||
|
private AlertStats? stats;
|
||||||
|
private List<AlertFrequency> topAlerts = [];
|
||||||
|
private List<AlertFrequency> topNamespaces = [];
|
||||||
|
private List<AlertFrequency> byCluster = [];
|
||||||
|
private int windowDays = 30;
|
||||||
|
private bool isLoading = true;
|
||||||
|
private string? loadError;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync() => await Load();
|
||||||
|
|
||||||
|
private async Task Load()
|
||||||
|
{
|
||||||
|
isLoading = true;
|
||||||
|
loadError = null;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.WhenAll(
|
||||||
|
LoadStats(),
|
||||||
|
LoadTopAlerts(),
|
||||||
|
LoadTopNamespaces(),
|
||||||
|
LoadByCluster());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
loadError = ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadStats()
|
||||||
|
=> stats = await IncidentService.GetStatsForTenantAsync(TenantId, windowDays);
|
||||||
|
|
||||||
|
private async Task LoadTopAlerts()
|
||||||
|
=> topAlerts = await IncidentService.GetTopAlertsAsync(TenantId, windowDays);
|
||||||
|
|
||||||
|
private async Task LoadTopNamespaces()
|
||||||
|
=> topNamespaces = await IncidentService.GetTopNamespacesAsync(TenantId, windowDays);
|
||||||
|
|
||||||
|
private async Task LoadByCluster()
|
||||||
|
=> byCluster = await IncidentService.GetAlertsByClusterAsync(TenantId, windowDays);
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
@inject TenantService TenantService
|
@inject TenantService TenantService
|
||||||
@inject StorageService StorageService
|
@inject StorageService StorageService
|
||||||
@inject CnpgService CnpgService
|
@inject CnpgService CnpgService
|
||||||
|
@inject MongoService MongoService
|
||||||
|
@inject RegisteredPostgresService RegisteredPostgresService
|
||||||
|
|
||||||
<div class="d-flex align-items-center mb-2">
|
<div class="d-flex align-items-center mb-2">
|
||||||
<i class="bi bi-shield-lock fs-4 me-2 text-primary"></i>
|
<i class="bi bi-shield-lock fs-4 me-2 text-primary"></i>
|
||||||
@@ -42,7 +44,13 @@ else
|
|||||||
<i class="bi bi-hdd me-1"></i>Storage Secrets
|
<i class="bi bi-hdd me-1"></i>Storage Secrets
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm @(scope == "cnpg" ? "btn-primary" : "btn-outline-primary")" @onclick='() => SwitchScope("cnpg")'>
|
<button class="btn btn-sm @(scope == "cnpg" ? "btn-primary" : "btn-outline-primary")" @onclick='() => SwitchScope("cnpg")'>
|
||||||
<i class="bi bi-database me-1"></i>Database Secrets
|
<i class="bi bi-database me-1"></i>PostgreSQL
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm @(scope == "mongodb" ? "btn-primary" : "btn-outline-primary")" @onclick='() => SwitchScope("mongodb")'>
|
||||||
|
<i class="bi bi-database me-1"></i>MongoDB
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm @(scope == "regpostgres" ? "btn-primary" : "btn-outline-primary")" @onclick='() => SwitchScope("regpostgres")'>
|
||||||
|
<i class="bi bi-database me-1"></i>Registered DB
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm @(scope == "docker" ? "btn-primary" : "btn-outline-primary")" @onclick='() => SwitchScope("docker")'>
|
<button class="btn btn-sm @(scope == "docker" ? "btn-primary" : "btn-outline-primary")" @onclick='() => SwitchScope("docker")'>
|
||||||
<i class="bi bi-box-seam me-1"></i>Docker Registries
|
<i class="bi bi-box-seam me-1"></i>Docker Registries
|
||||||
@@ -153,6 +161,58 @@ else
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
else if (scope == "mongodb")
|
||||||
|
{
|
||||||
|
@if (mongoClusterOptions is not null && mongoClusterOptions.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="card border-0 shadow-sm mb-3">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text bg-transparent"><i class="bi bi-database"></i></span>
|
||||||
|
<select class="form-select" @bind="selectedMongoClusterId" @bind:after="LoadSecrets">
|
||||||
|
<option value="@Guid.Empty">Select a MongoDB cluster...</option>
|
||||||
|
@foreach ((string label, Guid id) in mongoClusterOptions)
|
||||||
|
{
|
||||||
|
<option value="@id">@label</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<EmptyState Icon="bi-database"
|
||||||
|
Message="No MongoDB clusters found for this tenant." />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else if (scope == "regpostgres")
|
||||||
|
{
|
||||||
|
@if (regPostgresOptions is not null && regPostgresOptions.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="card border-0 shadow-sm mb-3">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text bg-transparent"><i class="bi bi-database"></i></span>
|
||||||
|
<select class="form-select" @bind="selectedRegPostgresInstanceId" @bind:after="LoadSecrets">
|
||||||
|
<option value="@Guid.Empty">Select a registered Postgres instance...</option>
|
||||||
|
@foreach ((string label, Guid id) in regPostgresOptions)
|
||||||
|
{
|
||||||
|
<option value="@id">@label</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<EmptyState Icon="bi-database"
|
||||||
|
Message="No registered Postgres instances found for this tenant." />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@* --- Docker Registries Panel --- *@
|
@* --- Docker Registries Panel --- *@
|
||||||
@if (scope == "docker")
|
@if (scope == "docker")
|
||||||
{
|
{
|
||||||
@@ -179,7 +239,9 @@ else
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@* Add secret form *@
|
@* Add secret form — hidden for read-only DB scopes *@
|
||||||
|
@if (scope is not "mongodb" and not "regpostgres")
|
||||||
|
{
|
||||||
<div class="row g-2 mb-3">
|
<div class="row g-2 mb-3">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="input-group input-group-sm">
|
<div class="input-group input-group-sm">
|
||||||
@@ -202,6 +264,7 @@ else
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(errorMessage))
|
@if (!string.IsNullOrEmpty(errorMessage))
|
||||||
{
|
{
|
||||||
@@ -231,9 +294,9 @@ else
|
|||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
@if (scope == "cnpg")
|
@if (scope is "cnpg" or "mongodb" or "regpostgres")
|
||||||
{
|
{
|
||||||
<th>Scope</th>
|
<th>Database</th>
|
||||||
}
|
}
|
||||||
<th>K8s Sync</th>
|
<th>K8s Sync</th>
|
||||||
<th>K8s Secret</th>
|
<th>K8s Secret</th>
|
||||||
@@ -247,13 +310,21 @@ else
|
|||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>@secret.Name</code></td>
|
<td><code>@secret.Name</code></td>
|
||||||
@if (scope == "cnpg")
|
@if (scope is "cnpg" or "mongodb" or "regpostgres")
|
||||||
{
|
{
|
||||||
<td>
|
<td>
|
||||||
@if (secret.CnpgDatabase is not null)
|
@if (secret.CnpgDatabase is not null)
|
||||||
{
|
{
|
||||||
<span class="badge bg-info-subtle text-info border border-info-subtle">@secret.CnpgDatabase.Name</span>
|
<span class="badge bg-info-subtle text-info border border-info-subtle">@secret.CnpgDatabase.Name</span>
|
||||||
}
|
}
|
||||||
|
else if (secret.MongoDatabase is not null)
|
||||||
|
{
|
||||||
|
<span class="badge bg-success-subtle text-success border border-success-subtle">@secret.MongoDatabase.Name</span>
|
||||||
|
}
|
||||||
|
else if (secret.RegisteredPostgresDatabase is not null)
|
||||||
|
{
|
||||||
|
<span class="badge bg-primary-subtle text-primary border border-primary-subtle">@secret.RegisteredPostgresDatabase.Name</span>
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">cluster</span>
|
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">cluster</span>
|
||||||
@@ -281,6 +352,9 @@ else
|
|||||||
<button class="btn btn-sm btn-outline-warning me-1" @onclick="() => StartEdit(secret)" title="Edit value">
|
<button class="btn btn-sm btn-outline-warning me-1" @onclick="() => StartEdit(secret)" title="Edit value">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary me-1" @onclick="() => ToggleHistory(secret.Id)" title="Version history">
|
||||||
|
<i class="bi bi-clock-history"></i>
|
||||||
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-info me-1" @onclick="() => ToggleSync(secret)"
|
<button class="btn btn-sm btn-outline-info me-1" @onclick="() => ToggleSync(secret)"
|
||||||
title="@(secret.SyncToKubernetes ? "Disable K8s sync" : "Enable K8s sync")">
|
title="@(secret.SyncToKubernetes ? "Disable K8s sync" : "Enable K8s sync")">
|
||||||
<i class="bi @(secret.SyncToKubernetes ? "bi-cloud-slash" : "bi-cloud-arrow-up")"></i>
|
<i class="bi @(secret.SyncToKubernetes ? "bi-cloud-slash" : "bi-cloud-arrow-up")"></i>
|
||||||
@@ -328,6 +402,81 @@ else
|
|||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@* Version history row *@
|
||||||
|
@if (historySecretId == secret.Id)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="bg-light p-0">
|
||||||
|
<div class="p-3">
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<i class="bi bi-clock-history me-2 text-secondary"></i>
|
||||||
|
<strong class="small">Version History</strong>
|
||||||
|
<span class="badge bg-secondary ms-2">@(secretVersions?.Count ?? 0)</span>
|
||||||
|
</div>
|
||||||
|
@if (secretVersions is null)
|
||||||
|
{
|
||||||
|
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
|
||||||
|
}
|
||||||
|
else if (secretVersions.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-muted small mb-0">No previous versions recorded.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<table class="table table-sm table-bordered mb-0">
|
||||||
|
<thead class="table-secondary">
|
||||||
|
<tr>
|
||||||
|
<th style="width: 60px;">#</th>
|
||||||
|
<th>Set by</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th class="text-end" style="width: 160px;">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (VaultSecretVersion ver in secretVersions)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td><span class="badge bg-secondary">v@ver.VersionNumber</span></td>
|
||||||
|
<td><small class="text-muted">@(ver.CreatedBy ?? "—")</small></td>
|
||||||
|
<td><small class="text-muted">@ver.CreatedAt.ToString("MMM d, HH:mm")</small></td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button class="btn btn-xs btn-outline-secondary me-1"
|
||||||
|
@onclick="() => RevealVersion(secret.Id, ver)"
|
||||||
|
title="@(revealedVersionId == ver.Id ? "Hide" : "Reveal value")">
|
||||||
|
<i class="bi @(revealedVersionId == ver.Id ? "bi-eye-slash" : "bi-eye")"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-xs btn-outline-warning"
|
||||||
|
@onclick="() => RollbackToVersion(secret.Id, ver.Id)"
|
||||||
|
title="Restore this value">
|
||||||
|
<i class="bi bi-arrow-counterclockwise me-1"></i>Restore
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@if (revealedVersionId == ver.Id)
|
||||||
|
{
|
||||||
|
<tr class="table-light">
|
||||||
|
<td colspan="4" class="px-2 py-1">
|
||||||
|
<span class="text-muted small me-2">Value:</span>
|
||||||
|
@if (versionRevealLoading)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm"></span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<code class="user-select-all">@revealedVersionValue</code>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
|
||||||
@if (syncConfigSecretId == secret.Id)
|
@if (syncConfigSecretId == secret.Id)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
@@ -402,6 +551,14 @@ else
|
|||||||
private List<(string Label, Guid Id)>? cnpgClusterOptions;
|
private List<(string Label, Guid Id)>? cnpgClusterOptions;
|
||||||
private Guid selectedCnpgClusterId;
|
private Guid selectedCnpgClusterId;
|
||||||
|
|
||||||
|
// MongoDB cluster scope
|
||||||
|
private List<(string Label, Guid Id)>? mongoClusterOptions;
|
||||||
|
private Guid selectedMongoClusterId;
|
||||||
|
|
||||||
|
// Registered Postgres scope
|
||||||
|
private List<(string Label, Guid Id)>? regPostgresOptions;
|
||||||
|
private Guid selectedRegPostgresInstanceId;
|
||||||
|
|
||||||
// Cluster options (used in app-scope sync config)
|
// Cluster options (used in app-scope sync config)
|
||||||
private List<(string Label, Guid Id)>? clusterOptions;
|
private List<(string Label, Guid Id)>? clusterOptions;
|
||||||
|
|
||||||
@@ -426,6 +583,13 @@ else
|
|||||||
private Guid? editSecretId;
|
private Guid? editSecretId;
|
||||||
private string editSecretValue = "";
|
private string editSecretValue = "";
|
||||||
|
|
||||||
|
// Version history state
|
||||||
|
private Guid? historySecretId;
|
||||||
|
private List<VaultSecretVersion>? secretVersions;
|
||||||
|
private Guid? revealedVersionId;
|
||||||
|
private string? revealedVersionValue;
|
||||||
|
private bool versionRevealLoading;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
SecretVault? vault = await VaultService.GetVaultAsync(TenantId);
|
SecretVault? vault = await VaultService.GetVaultAsync(TenantId);
|
||||||
@@ -486,6 +650,18 @@ else
|
|||||||
cnpgClusterOptions = cnpgClusters
|
cnpgClusterOptions = cnpgClusters
|
||||||
.Select(c => ($"{c.KubernetesCluster.Name} / {c.Name}", c.Id))
|
.Select(c => ($"{c.KubernetesCluster.Name} / {c.Name}", c.Id))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
// MongoDB clusters
|
||||||
|
List<MongoCluster> mongoClusters = await MongoService.GetClustersAsync(TenantId);
|
||||||
|
mongoClusterOptions = mongoClusters
|
||||||
|
.Select(c => ($"{c.KubernetesCluster.Name} / {c.Name}", c.Id))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Registered Postgres instances
|
||||||
|
List<RegisteredPostgresInstance> regInstances = await RegisteredPostgresService.GetInstancesAsync(TenantId);
|
||||||
|
regPostgresOptions = regInstances
|
||||||
|
.Select(i => (i.Name, i.Id))
|
||||||
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SwitchScope(string newScope)
|
private void SwitchScope(string newScope)
|
||||||
@@ -496,6 +672,8 @@ else
|
|||||||
selectedComponentId = Guid.Empty;
|
selectedComponentId = Guid.Empty;
|
||||||
selectedStorageLinkId = Guid.Empty;
|
selectedStorageLinkId = Guid.Empty;
|
||||||
selectedCnpgClusterId = Guid.Empty;
|
selectedCnpgClusterId = Guid.Empty;
|
||||||
|
selectedMongoClusterId = Guid.Empty;
|
||||||
|
selectedRegPostgresInstanceId = Guid.Empty;
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
successMessage = null;
|
successMessage = null;
|
||||||
syncClusterId = Guid.Empty;
|
syncClusterId = Guid.Empty;
|
||||||
@@ -508,7 +686,9 @@ else
|
|||||||
return (scope == "app" && selectedAppId != Guid.Empty)
|
return (scope == "app" && selectedAppId != Guid.Empty)
|
||||||
|| (scope == "component" && selectedComponentId != Guid.Empty)
|
|| (scope == "component" && selectedComponentId != Guid.Empty)
|
||||||
|| (scope == "storage" && selectedStorageLinkId != Guid.Empty)
|
|| (scope == "storage" && selectedStorageLinkId != Guid.Empty)
|
||||||
|| (scope == "cnpg" && selectedCnpgClusterId != Guid.Empty);
|
|| (scope == "cnpg" && selectedCnpgClusterId != Guid.Empty)
|
||||||
|
|| (scope == "mongodb" && selectedMongoClusterId != Guid.Empty)
|
||||||
|
|| (scope == "regpostgres" && selectedRegPostgresInstanceId != Guid.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LoadSecrets()
|
private async Task LoadSecrets()
|
||||||
@@ -532,6 +712,14 @@ else
|
|||||||
{
|
{
|
||||||
secrets = await VaultService.GetAllCnpgSecretsForClusterAsync(TenantId, selectedCnpgClusterId);
|
secrets = await VaultService.GetAllCnpgSecretsForClusterAsync(TenantId, selectedCnpgClusterId);
|
||||||
}
|
}
|
||||||
|
else if (scope == "mongodb" && selectedMongoClusterId != Guid.Empty)
|
||||||
|
{
|
||||||
|
secrets = await VaultService.GetAllMongoSecretsForClusterAsync(TenantId, selectedMongoClusterId);
|
||||||
|
}
|
||||||
|
else if (scope == "regpostgres" && selectedRegPostgresInstanceId != Guid.Empty)
|
||||||
|
{
|
||||||
|
secrets = await VaultService.GetAllRegisteredPostgresSecretsForInstanceAsync(TenantId, selectedRegPostgresInstanceId);
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
secrets = null;
|
secrets = null;
|
||||||
@@ -707,4 +895,58 @@ else
|
|||||||
syncingSecrets = false;
|
syncingSecrets = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ToggleHistory(Guid secretId)
|
||||||
|
{
|
||||||
|
if (historySecretId == secretId)
|
||||||
|
{
|
||||||
|
historySecretId = null;
|
||||||
|
secretVersions = null;
|
||||||
|
revealedVersionId = null;
|
||||||
|
revealedVersionValue = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
historySecretId = secretId;
|
||||||
|
revealedVersionId = null;
|
||||||
|
revealedVersionValue = null;
|
||||||
|
secretVersions = null;
|
||||||
|
secretVersions = await VaultService.GetSecretVersionsAsync(secretId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RevealVersion(Guid secretId, VaultSecretVersion version)
|
||||||
|
{
|
||||||
|
if (revealedVersionId == version.Id)
|
||||||
|
{
|
||||||
|
revealedVersionId = null;
|
||||||
|
revealedVersionValue = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
versionRevealLoading = true;
|
||||||
|
revealedVersionId = version.Id;
|
||||||
|
revealedVersionValue = null;
|
||||||
|
revealedVersionValue = await VaultService.GetSecretVersionValueAsync(secretId, version.Id);
|
||||||
|
versionRevealLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RollbackToVersion(Guid secretId, Guid versionId)
|
||||||
|
{
|
||||||
|
errorMessage = null;
|
||||||
|
bool ok = await VaultService.RollbackToVersionAsync(secretId, versionId);
|
||||||
|
|
||||||
|
if (ok)
|
||||||
|
{
|
||||||
|
successMessage = "Secret restored to selected version.";
|
||||||
|
historySecretId = null;
|
||||||
|
secretVersions = null;
|
||||||
|
revealedVersionId = null;
|
||||||
|
revealedVersionValue = null;
|
||||||
|
await LoadSecrets();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
errorMessage = "Failed to restore version.";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,7 @@ public class AlertIncident
|
|||||||
public required string Severity { get; set; }
|
public required string Severity { get; set; }
|
||||||
public string Summary { get; set; } = "";
|
public string Summary { get; set; } = "";
|
||||||
public string Description { get; set; } = "";
|
public string Description { get; set; } = "";
|
||||||
|
public string RunbookUrl { get; set; } = "";
|
||||||
public string LabelsJson { get; set; } = "{}";
|
public string LabelsJson { get; set; } = "{}";
|
||||||
public DateTime StartsAt { get; set; }
|
public DateTime StartsAt { get; set; }
|
||||||
public DateTime? EndsAt { get; set; }
|
public DateTime? EndsAt { get; set; }
|
||||||
@@ -18,6 +19,9 @@ public class AlertIncident
|
|||||||
public string? AcknowledgedBy { get; set; }
|
public string? AcknowledgedBy { get; set; }
|
||||||
public DateTime? AcknowledgedAt { get; set; }
|
public DateTime? AcknowledgedAt { get; set; }
|
||||||
public DateTime? ResolvedAt { get; set; }
|
public DateTime? ResolvedAt { get; set; }
|
||||||
|
public string? AssignedTo { get; set; }
|
||||||
|
public DateTime? AssignedAt { get; set; }
|
||||||
|
public DateTime? EscalatedAt { get; set; }
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
|||||||
27
src/EntKube.Web/Data/AlertRoutingRule.cs
Normal file
27
src/EntKube.Web/Data/AlertRoutingRule.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
namespace EntKube.Web.Data;
|
||||||
|
|
||||||
|
public class AlertRoutingRule
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid TenantId { get; set; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public int Priority { get; set; }
|
||||||
|
public Guid? ChannelId { get; set; }
|
||||||
|
public string? MatchAlertName { get; set; }
|
||||||
|
public string? MatchNamespace { get; set; }
|
||||||
|
public string? MatchSeverity { get; set; }
|
||||||
|
public string? MatchLabelKey { get; set; }
|
||||||
|
public string? MatchLabelValue { get; set; }
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
// When true, matching alerts are silently dropped — no incident is created.
|
||||||
|
// ChannelId is not required for suppression rules.
|
||||||
|
public bool SuppressIncident { get; set; }
|
||||||
|
|
||||||
|
// Optional: restrict this rule to a specific cluster (null = match all clusters).
|
||||||
|
public Guid? MatchClusterId { get; set; }
|
||||||
|
|
||||||
|
public Tenant Tenant { get; set; } = null!;
|
||||||
|
public NotificationChannel? Channel { get; set; }
|
||||||
|
public KubernetesCluster? MatchCluster { get; set; }
|
||||||
|
}
|
||||||
@@ -29,7 +29,11 @@ public class App
|
|||||||
public ICollection<AppEnvironment> AppEnvironments { get; set; } = [];
|
public ICollection<AppEnvironment> AppEnvironments { get; set; } = [];
|
||||||
public ICollection<VaultSecret> Secrets { get; set; } = [];
|
public ICollection<VaultSecret> Secrets { get; set; } = [];
|
||||||
public ICollection<AppDeployment> Deployments { get; set; } = [];
|
public ICollection<AppDeployment> Deployments { get; set; } = [];
|
||||||
public AppQuota? Quota { get; set; }
|
public ICollection<AppQuota> Quotas { get; set; } = [];
|
||||||
public ICollection<AppNetworkPolicy> NetworkPolicies { get; set; } = [];
|
public ICollection<AppNetworkPolicy> NetworkPolicies { get; set; } = [];
|
||||||
public AppRbacPolicy? RbacPolicy { get; set; }
|
public ICollection<AppRbacPolicy> RbacPolicies { get; set; } = [];
|
||||||
|
public ICollection<AppRoute> Routes { get; set; } = [];
|
||||||
|
public ICollection<AppAllowedDatabase> AllowedDatabases { get; set; } = [];
|
||||||
|
public ICollection<AppAllowedCache> AllowedCaches { get; set; } = [];
|
||||||
|
public ICollection<AppAllowedStorage> AllowedStorages { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/EntKube.Web/Data/AppAllowedCache.cs
Normal file
19
src/EntKube.Web/Data/AppAllowedCache.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
namespace EntKube.Web.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Restricts which Redis clusters a customer's app may link to in a specific environment.
|
||||||
|
/// When any entries exist, only those clusters may be bound via CacheBinding. Empty = no restriction.
|
||||||
|
/// </summary>
|
||||||
|
public class AppAllowedCache
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid AppId { get; set; }
|
||||||
|
public Guid EnvironmentId { get; set; }
|
||||||
|
public Guid RedisClusterId { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public App App { get; set; } = null!;
|
||||||
|
public Environment Environment { get; set; } = null!;
|
||||||
|
public RedisCluster RedisCluster { get; set; } = null!;
|
||||||
|
}
|
||||||
25
src/EntKube.Web/Data/AppAllowedDatabase.cs
Normal file
25
src/EntKube.Web/Data/AppAllowedDatabase.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
namespace EntKube.Web.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Restricts which databases a customer's app may link to in a specific environment.
|
||||||
|
/// When any entries exist for an app+environment, only those databases may be bound
|
||||||
|
/// via DatabaseBinding. An empty list means no restriction.
|
||||||
|
/// </summary>
|
||||||
|
public class AppAllowedDatabase
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid AppId { get; set; }
|
||||||
|
public Guid EnvironmentId { get; set; }
|
||||||
|
|
||||||
|
public Guid? CnpgDatabaseId { get; set; }
|
||||||
|
public Guid? MongoDatabaseId { get; set; }
|
||||||
|
public Guid? RegisteredPostgresDatabaseId { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public App App { get; set; } = null!;
|
||||||
|
public Environment Environment { get; set; } = null!;
|
||||||
|
public CnpgDatabase? CnpgDatabase { get; set; }
|
||||||
|
public MongoDatabase? MongoDatabase { get; set; }
|
||||||
|
public RegisteredPostgresDatabase? RegisteredPostgresDatabase { get; set; }
|
||||||
|
}
|
||||||
19
src/EntKube.Web/Data/AppAllowedStorage.cs
Normal file
19
src/EntKube.Web/Data/AppAllowedStorage.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
namespace EntKube.Web.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Restricts which storage buckets (StorageLinks) a customer's app may link to in a specific environment.
|
||||||
|
/// When any entries exist, only those storage links may be bound via StorageBinding. Empty = no restriction.
|
||||||
|
/// </summary>
|
||||||
|
public class AppAllowedStorage
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid AppId { get; set; }
|
||||||
|
public Guid EnvironmentId { get; set; }
|
||||||
|
public Guid StorageLinkId { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public App App { get; set; } = null!;
|
||||||
|
public Environment Environment { get; set; } = null!;
|
||||||
|
public StorageLink StorageLink { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -82,7 +82,13 @@ public class AppDeployment
|
|||||||
// ── Git source fields (only used when Type is GitYaml, GitHelm, or GitAppOfApps) ──
|
// ── Git source fields (only used when Type is GitYaml, GitHelm, or GitAppOfApps) ──
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The registered GitRepository to sync from.
|
/// The URL of the Git repository to sync from. Used for policy-credential-based
|
||||||
|
/// git access (no GitRepository record required). Takes precedence over GitRepositoryId.
|
||||||
|
/// </summary>
|
||||||
|
public string? GitUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Legacy: FK to a registered GitRepository. Superseded by GitUrl for new deployments.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid? GitRepositoryId { get; set; }
|
public Guid? GitRepositoryId { get; set; }
|
||||||
|
|
||||||
@@ -132,4 +138,5 @@ public class AppDeployment
|
|||||||
public ICollection<StorageBinding> StorageBindings { get; set; } = [];
|
public ICollection<StorageBinding> StorageBindings { get; set; } = [];
|
||||||
public ICollection<DatabaseBinding> DatabaseBindings { get; set; } = [];
|
public ICollection<DatabaseBinding> DatabaseBindings { get; set; } = [];
|
||||||
public ICollection<CacheBinding> CacheBindings { get; set; } = [];
|
public ICollection<CacheBinding> CacheBindings { get; set; } = [];
|
||||||
|
public ICollection<AppDeploymentRoute> Routes { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ public class AppEnvironment
|
|||||||
|
|
||||||
public DateTime LinkedAt { get; set; } = DateTime.UtcNow;
|
public DateTime LinkedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Locked namespace for this app in this environment. When set, deployments
|
||||||
|
/// must use this namespace — customers cannot override it.
|
||||||
|
/// </summary>
|
||||||
|
public string? Namespace { get; set; }
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
public App App { get; set; } = null!;
|
public App App { get; set; } = null!;
|
||||||
public Environment Environment { get; set; } = null!;
|
public Environment Environment { get; set; } = null!;
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ public class AppNetworkPolicy
|
|||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public Guid AppId { get; set; }
|
public Guid AppId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Which environment this policy applies to. Policies are per-environment.</summary>
|
||||||
|
public Guid EnvironmentId { get; set; }
|
||||||
|
|
||||||
/// <summary>Human-readable name and also the K8s NetworkPolicy resource name.</summary>
|
/// <summary>Human-readable name and also the K8s NetworkPolicy resource name.</summary>
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
|
|
||||||
@@ -41,4 +44,5 @@ public class AppNetworkPolicy
|
|||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
public App App { get; set; } = null!;
|
public App App { get; set; } = null!;
|
||||||
|
public Environment Environment { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ public class AppQuota
|
|||||||
|
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>Which environment this quota applies to. One quota per app per environment.</summary>
|
||||||
|
public Guid EnvironmentId { get; set; }
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
public App App { get; set; } = null!;
|
public App App { get; set; } = null!;
|
||||||
|
public Environment Environment { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ public class AppRbacPolicy
|
|||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public Guid AppId { get; set; }
|
public Guid AppId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Which environment this RBAC policy applies to. One policy per app per environment.</summary>
|
||||||
|
public Guid EnvironmentId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The Kubernetes ServiceAccount name, e.g. "billing-api".
|
/// The Kubernetes ServiceAccount name, e.g. "billing-api".
|
||||||
/// Must be a valid DNS label.
|
/// Must be a valid DNS label.
|
||||||
@@ -26,6 +29,7 @@ public class AppRbacPolicy
|
|||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
public App App { get; set; } = null!;
|
public App App { get; set; } = null!;
|
||||||
|
public Environment Environment { get; set; } = null!;
|
||||||
public ICollection<AppRbacRule> Rules { get; set; } = [];
|
public ICollection<AppRbacRule> Rules { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
86
src/EntKube.Web/Data/AppRoute.cs
Normal file
86
src/EntKube.Web/Data/AppRoute.cs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
namespace EntKube.Web.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// App-level hostname configuration for exposing a customer application externally.
|
||||||
|
/// Holds the hostname and TLS strategy shared across all deployment environments.
|
||||||
|
/// Each deployment environment can attach with its own path prefix via AppDeploymentRoute.
|
||||||
|
/// </summary>
|
||||||
|
public class AppRoute
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
public Guid AppId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The public hostname (e.g. "myapp.example.com"). Must be unique within a cluster.
|
||||||
|
/// </summary>
|
||||||
|
public required string Hostname { get; set; }
|
||||||
|
|
||||||
|
/// <summary>How TLS is handled — automatic via ClusterIssuer or manual cert upload.</summary>
|
||||||
|
public TlsMode TlsMode { get; set; } = TlsMode.ClusterIssuer;
|
||||||
|
|
||||||
|
/// <summary>Name of the ClusterIssuer when TlsMode is ClusterIssuer (e.g. "letsencrypt-prod").</summary>
|
||||||
|
public string? ClusterIssuerName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>PEM-encoded certificate for manual TLS mode.</summary>
|
||||||
|
public string? TlsCertificate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>PEM-encoded private key for manual TLS mode.</summary>
|
||||||
|
public string? TlsPrivateKey { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When false the route is kept in the database but no Kubernetes resources are applied.</summary>
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
public App App { get; set; } = null!;
|
||||||
|
public ICollection<AppDeploymentRoute> DeploymentRoutes { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Links one AppRoute to one AppDeployment, specifying the path prefix and target service
|
||||||
|
/// for that environment. A Kubernetes HTTPRoute is generated per AppDeploymentRoute.
|
||||||
|
/// </summary>
|
||||||
|
public class AppDeploymentRoute
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
public Guid AppRouteId { get; set; }
|
||||||
|
|
||||||
|
public Guid AppDeploymentId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Path prefix for this deployment (e.g. "/" for prod, "/staging" for staging).
|
||||||
|
/// Multiple deployments share the same hostname via different path prefixes.
|
||||||
|
/// </summary>
|
||||||
|
public string PathPrefix { get; set; } = "/";
|
||||||
|
|
||||||
|
/// <summary>The Kubernetes service name to route traffic to.</summary>
|
||||||
|
public required string ServiceName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The port on the service to route traffic to.</summary>
|
||||||
|
public int ServicePort { get; set; } = 80;
|
||||||
|
|
||||||
|
/// <summary>Gateway resource name resolved from the cluster's installed ingress controller.</summary>
|
||||||
|
public string? GatewayName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Namespace where the Gateway resource lives.</summary>
|
||||||
|
public string? GatewayNamespace { get; set; }
|
||||||
|
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Set when the HTTPRoute manifest was last successfully applied to the cluster. Null means not yet applied.</summary>
|
||||||
|
public DateTime? ClusterAppliedAt { get; set; }
|
||||||
|
|
||||||
|
// Health monitoring (updated by background health checks)
|
||||||
|
public DateTime? LastHealthCheckAt { get; set; }
|
||||||
|
public int? LastStatusCode { get; set; }
|
||||||
|
public bool? IsReachable { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
public AppRoute AppRoute { get; set; } = null!;
|
||||||
|
public AppDeployment AppDeployment { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
|
|||||||
public DbSet<KubernetesCluster> KubernetesClusters => Set<KubernetesCluster>();
|
public DbSet<KubernetesCluster> KubernetesClusters => Set<KubernetesCluster>();
|
||||||
public DbSet<SecretVault> SecretVaults => Set<SecretVault>();
|
public DbSet<SecretVault> SecretVaults => Set<SecretVault>();
|
||||||
public DbSet<VaultSecret> VaultSecrets => Set<VaultSecret>();
|
public DbSet<VaultSecret> VaultSecrets => Set<VaultSecret>();
|
||||||
|
public DbSet<VaultSecretVersion> VaultSecretVersions => Set<VaultSecretVersion>();
|
||||||
public DbSet<ClusterComponent> ClusterComponents => Set<ClusterComponent>();
|
public DbSet<ClusterComponent> ClusterComponents => Set<ClusterComponent>();
|
||||||
public DbSet<ExternalRoute> ExternalRoutes => Set<ExternalRoute>();
|
public DbSet<ExternalRoute> ExternalRoutes => Set<ExternalRoute>();
|
||||||
public DbSet<StorageLink> StorageLinks => Set<StorageLink>();
|
public DbSet<StorageLink> StorageLinks => Set<StorageLink>();
|
||||||
@@ -75,6 +76,16 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
|
|||||||
public DbSet<AppNetworkPolicy> AppNetworkPolicies => Set<AppNetworkPolicy>();
|
public DbSet<AppNetworkPolicy> AppNetworkPolicies => Set<AppNetworkPolicy>();
|
||||||
public DbSet<AppRbacPolicy> AppRbacPolicies => Set<AppRbacPolicy>();
|
public DbSet<AppRbacPolicy> AppRbacPolicies => Set<AppRbacPolicy>();
|
||||||
public DbSet<AppRbacRule> AppRbacRules => Set<AppRbacRule>();
|
public DbSet<AppRbacRule> AppRbacRules => Set<AppRbacRule>();
|
||||||
|
public DbSet<AppRoute> AppRoutes => Set<AppRoute>();
|
||||||
|
public DbSet<AppDeploymentRoute> AppDeploymentRoutes => Set<AppDeploymentRoute>();
|
||||||
|
public DbSet<AppAllowedDatabase> AppAllowedDatabases => Set<AppAllowedDatabase>();
|
||||||
|
public DbSet<AppAllowedCache> AppAllowedCaches => Set<AppAllowedCache>();
|
||||||
|
public DbSet<AppAllowedStorage> AppAllowedStorages => Set<AppAllowedStorage>();
|
||||||
|
public DbSet<OnCallSchedule> OnCallSchedules => Set<OnCallSchedule>();
|
||||||
|
public DbSet<OnCallShift> OnCallShifts => Set<OnCallShift>();
|
||||||
|
public DbSet<AlertRoutingRule> AlertRoutingRules => Set<AlertRoutingRule>();
|
||||||
|
public DbSet<NotificationProviderConfig> NotificationProviderConfigs => Set<NotificationProviderConfig>();
|
||||||
|
public DbSet<ClusterServer> ClusterServers => Set<ClusterServer>();
|
||||||
|
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
@@ -341,6 +352,22 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
|
|||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(s => s.RedisClusterId)
|
.HasForeignKey(s => s.RedisClusterId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasMany(s => s.Versions)
|
||||||
|
.WithOne(v => v.Secret)
|
||||||
|
.HasForeignKey(v => v.SecretId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
// VaultSecretVersion — immutable historical snapshots of a secret's value.
|
||||||
|
// At most 10 versions are retained per secret (pruned on write).
|
||||||
|
|
||||||
|
builder.Entity<VaultSecretVersion>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(v => v.Id);
|
||||||
|
entity.Property(v => v.CreatedBy).HasMaxLength(254);
|
||||||
|
|
||||||
|
entity.HasIndex(v => new { v.SecretId, v.VersionNumber });
|
||||||
});
|
});
|
||||||
|
|
||||||
// DockerRegistryCredential — encrypted registry auth stored in the tenant vault.
|
// DockerRegistryCredential — encrypted registry auth stored in the tenant vault.
|
||||||
@@ -993,8 +1020,11 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
|
|||||||
entity.Property(i => i.Severity).HasMaxLength(20).IsRequired();
|
entity.Property(i => i.Severity).HasMaxLength(20).IsRequired();
|
||||||
entity.Property(i => i.Summary).HasMaxLength(500);
|
entity.Property(i => i.Summary).HasMaxLength(500);
|
||||||
entity.Property(i => i.Description).HasMaxLength(2000);
|
entity.Property(i => i.Description).HasMaxLength(2000);
|
||||||
|
entity.Property(i => i.RunbookUrl).HasMaxLength(500);
|
||||||
entity.Property(i => i.AcknowledgedBy).HasMaxLength(256);
|
entity.Property(i => i.AcknowledgedBy).HasMaxLength(256);
|
||||||
|
entity.Property(i => i.AssignedTo).HasMaxLength(256);
|
||||||
entity.Property(i => i.Status).HasConversion<string>().HasMaxLength(20);
|
entity.Property(i => i.Status).HasConversion<string>().HasMaxLength(20);
|
||||||
|
entity.HasIndex(i => i.EscalatedAt);
|
||||||
|
|
||||||
entity.HasOne(i => i.Cluster)
|
entity.HasOne(i => i.Cluster)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
@@ -1210,27 +1240,33 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
|
|||||||
.HasForeignKey(s => s.GitRepositoryId)
|
.HasForeignKey(s => s.GitRepositoryId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
// CustomerGitRepoPolicy — URL allowlist per customer (wildcard patterns).
|
// CustomerGitRepoPolicy — URL allowlist per customer per environment (wildcard patterns).
|
||||||
builder.Entity<CustomerGitRepoPolicy>(entity =>
|
builder.Entity<CustomerGitRepoPolicy>(entity =>
|
||||||
{
|
{
|
||||||
entity.HasKey(p => p.Id);
|
entity.HasKey(p => p.Id);
|
||||||
entity.HasIndex(p => new { p.CustomerId, p.UrlPattern }).IsUnique();
|
entity.HasIndex(p => new { p.CustomerId, p.EnvironmentId, p.UrlPattern }).IsUnique();
|
||||||
entity.Property(p => p.UrlPattern).HasMaxLength(2000).IsRequired();
|
entity.Property(p => p.UrlPattern).HasMaxLength(2000).IsRequired();
|
||||||
|
|
||||||
entity.HasOne(p => p.Customer)
|
entity.HasOne(p => p.Customer)
|
||||||
.WithMany(c => c.GitRepoPolicies)
|
.WithMany(c => c.GitRepoPolicies)
|
||||||
.HasForeignKey(p => p.CustomerId)
|
.HasForeignKey(p => p.CustomerId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasOne(p => p.Environment)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(p => p.EnvironmentId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
});
|
});
|
||||||
|
|
||||||
// CustomerGitCredential — reusable credential sets per customer.
|
// CustomerGitCredential — reusable credential sets per customer per environment.
|
||||||
builder.Entity<CustomerGitCredential>(entity =>
|
builder.Entity<CustomerGitCredential>(entity =>
|
||||||
{
|
{
|
||||||
entity.HasKey(c => c.Id);
|
entity.HasKey(c => c.Id);
|
||||||
entity.HasIndex(c => new { c.CustomerId, c.Name }).IsUnique();
|
entity.HasIndex(c => new { c.CustomerId, c.EnvironmentId, c.Name }).IsUnique();
|
||||||
entity.Property(c => c.Name).HasMaxLength(200).IsRequired();
|
entity.Property(c => c.Name).HasMaxLength(200).IsRequired();
|
||||||
entity.Property(c => c.AuthType).HasConversion<string>().HasMaxLength(20);
|
entity.Property(c => c.AuthType).HasConversion<string>().HasMaxLength(20);
|
||||||
entity.Property(c => c.Username).HasMaxLength(300);
|
entity.Property(c => c.Username).HasMaxLength(300);
|
||||||
|
entity.Property(c => c.UrlPattern).HasMaxLength(500);
|
||||||
|
|
||||||
entity.HasOne(c => c.Customer)
|
entity.HasOne(c => c.Customer)
|
||||||
.WithMany(cu => cu.GitCredentials)
|
.WithMany(cu => cu.GitCredentials)
|
||||||
@@ -1241,6 +1277,11 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
|
|||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(c => c.TenantId)
|
.HasForeignKey(c => c.TenantId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasOne(c => c.Environment)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.EnvironmentId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
});
|
});
|
||||||
|
|
||||||
// VaultSecret — CustomerGitCredential scoping (PAT / SSH key / password for a customer credential).
|
// VaultSecret — CustomerGitCredential scoping (PAT / SSH key / password for a customer credential).
|
||||||
@@ -1256,27 +1297,32 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
|
|||||||
entity.Property(a => a.Namespace).HasMaxLength(63);
|
entity.Property(a => a.Namespace).HasMaxLength(63);
|
||||||
});
|
});
|
||||||
|
|
||||||
// AppQuota — 1:1 with App, cascades on app delete.
|
// AppQuota — one per app per environment. Cascades on app delete.
|
||||||
builder.Entity<AppQuota>(entity =>
|
builder.Entity<AppQuota>(entity =>
|
||||||
{
|
{
|
||||||
entity.HasKey(q => q.Id);
|
entity.HasKey(q => q.Id);
|
||||||
entity.HasIndex(q => q.AppId).IsUnique();
|
entity.HasIndex(q => new { q.AppId, q.EnvironmentId }).IsUnique();
|
||||||
entity.Property(q => q.CpuRequest).HasMaxLength(20);
|
entity.Property(q => q.CpuRequest).HasMaxLength(20);
|
||||||
entity.Property(q => q.CpuLimit).HasMaxLength(20);
|
entity.Property(q => q.CpuLimit).HasMaxLength(20);
|
||||||
entity.Property(q => q.MemoryRequest).HasMaxLength(20);
|
entity.Property(q => q.MemoryRequest).HasMaxLength(20);
|
||||||
entity.Property(q => q.MemoryLimit).HasMaxLength(20);
|
entity.Property(q => q.MemoryLimit).HasMaxLength(20);
|
||||||
|
|
||||||
entity.HasOne(q => q.App)
|
entity.HasOne(q => q.App)
|
||||||
.WithOne(a => a.Quota)
|
.WithMany(a => a.Quotas)
|
||||||
.HasForeignKey<AppQuota>(q => q.AppId)
|
.HasForeignKey(q => q.AppId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasOne(q => q.Environment)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(q => q.EnvironmentId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
});
|
});
|
||||||
|
|
||||||
// AppNetworkPolicy — many per app, name unique within app.
|
// AppNetworkPolicy — many per app per environment; name unique within (app, environment).
|
||||||
builder.Entity<AppNetworkPolicy>(entity =>
|
builder.Entity<AppNetworkPolicy>(entity =>
|
||||||
{
|
{
|
||||||
entity.HasKey(p => p.Id);
|
entity.HasKey(p => p.Id);
|
||||||
entity.HasIndex(p => new { p.AppId, p.Name }).IsUnique();
|
entity.HasIndex(p => new { p.AppId, p.EnvironmentId, p.Name }).IsUnique();
|
||||||
entity.Property(p => p.Name).HasMaxLength(63).IsRequired();
|
entity.Property(p => p.Name).HasMaxLength(63).IsRequired();
|
||||||
entity.Property(p => p.PolicyType).HasConversion<string>().HasMaxLength(30);
|
entity.Property(p => p.PolicyType).HasConversion<string>().HasMaxLength(30);
|
||||||
entity.Property(p => p.AllowFromNamespace).HasMaxLength(63);
|
entity.Property(p => p.AllowFromNamespace).HasMaxLength(63);
|
||||||
@@ -1285,19 +1331,29 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
|
|||||||
.WithMany(a => a.NetworkPolicies)
|
.WithMany(a => a.NetworkPolicies)
|
||||||
.HasForeignKey(p => p.AppId)
|
.HasForeignKey(p => p.AppId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasOne(p => p.Environment)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(p => p.EnvironmentId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
});
|
});
|
||||||
|
|
||||||
// AppRbacPolicy — 1:1 with App.
|
// AppRbacPolicy — one per app per environment.
|
||||||
builder.Entity<AppRbacPolicy>(entity =>
|
builder.Entity<AppRbacPolicy>(entity =>
|
||||||
{
|
{
|
||||||
entity.HasKey(p => p.Id);
|
entity.HasKey(p => p.Id);
|
||||||
entity.HasIndex(p => p.AppId).IsUnique();
|
entity.HasIndex(p => new { p.AppId, p.EnvironmentId }).IsUnique();
|
||||||
entity.Property(p => p.ServiceAccountName).HasMaxLength(63).IsRequired();
|
entity.Property(p => p.ServiceAccountName).HasMaxLength(63).IsRequired();
|
||||||
|
|
||||||
entity.HasOne(p => p.App)
|
entity.HasOne(p => p.App)
|
||||||
.WithOne(a => a.RbacPolicy)
|
.WithMany(a => a.RbacPolicies)
|
||||||
.HasForeignKey<AppRbacPolicy>(p => p.AppId)
|
.HasForeignKey(p => p.AppId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasOne(p => p.Environment)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(p => p.EnvironmentId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
});
|
});
|
||||||
|
|
||||||
// AppRbacRule — many per AppRbacPolicy.
|
// AppRbacRule — many per AppRbacPolicy.
|
||||||
@@ -1371,6 +1427,215 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
|
|||||||
.HasForeignKey(d => d.ParentDeploymentId)
|
.HasForeignKey(d => d.ParentDeploymentId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// AppRoute — app-level hostname + TLS config. Cascades from App.
|
||||||
|
|
||||||
|
builder.Entity<AppRoute>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(r => r.Id);
|
||||||
|
entity.Property(r => r.Hostname).HasMaxLength(253).IsRequired();
|
||||||
|
entity.Property(r => r.TlsMode).HasConversion<string>().HasMaxLength(20);
|
||||||
|
entity.Property(r => r.ClusterIssuerName).HasMaxLength(200);
|
||||||
|
|
||||||
|
entity.HasOne(r => r.App)
|
||||||
|
.WithMany(a => a.Routes)
|
||||||
|
.HasForeignKey(r => r.AppId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
// AppDeploymentRoute — per-deployment path + service target. Cascades from AppRoute.
|
||||||
|
|
||||||
|
builder.Entity<AppDeploymentRoute>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(r => r.Id);
|
||||||
|
entity.Property(r => r.PathPrefix).HasMaxLength(200).IsRequired();
|
||||||
|
entity.Property(r => r.ServiceName).HasMaxLength(200).IsRequired();
|
||||||
|
entity.Property(r => r.GatewayName).HasMaxLength(200);
|
||||||
|
entity.Property(r => r.GatewayNamespace).HasMaxLength(63);
|
||||||
|
|
||||||
|
entity.HasOne(r => r.AppRoute)
|
||||||
|
.WithMany(ar => ar.DeploymentRoutes)
|
||||||
|
.HasForeignKey(r => r.AppRouteId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasOne(r => r.AppDeployment)
|
||||||
|
.WithMany(d => d.Routes)
|
||||||
|
.HasForeignKey(r => r.AppDeploymentId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
// AppAllowedDatabase — governance allowlist for which databases an app may bind to
|
||||||
|
// in a given environment. Cascades when the app is deleted; restricted on environment.
|
||||||
|
|
||||||
|
builder.Entity<AppAllowedDatabase>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(a => a.Id);
|
||||||
|
|
||||||
|
entity.HasOne(a => a.App)
|
||||||
|
.WithMany(app => app.AllowedDatabases)
|
||||||
|
.HasForeignKey(a => a.AppId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasOne(a => a.Environment)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(a => a.EnvironmentId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
entity.HasOne(a => a.CnpgDatabase)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(a => a.CnpgDatabaseId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasOne(a => a.MongoDatabase)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(a => a.MongoDatabaseId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasOne(a => a.RegisteredPostgresDatabase)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(a => a.RegisteredPostgresDatabaseId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
// AppAllowedCache — governance allowlist for which Redis clusters an app may bind to.
|
||||||
|
|
||||||
|
builder.Entity<AppAllowedCache>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(a => a.Id);
|
||||||
|
|
||||||
|
entity.HasOne(a => a.App)
|
||||||
|
.WithMany(app => app.AllowedCaches)
|
||||||
|
.HasForeignKey(a => a.AppId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasOne(a => a.Environment)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(a => a.EnvironmentId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
entity.HasOne(a => a.RedisCluster)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(a => a.RedisClusterId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
// AppAllowedStorage — governance allowlist for which StorageLinks an app may bind to.
|
||||||
|
|
||||||
|
builder.Entity<AppAllowedStorage>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(a => a.Id);
|
||||||
|
|
||||||
|
entity.HasOne(a => a.App)
|
||||||
|
.WithMany(app => app.AllowedStorages)
|
||||||
|
.HasForeignKey(a => a.AppId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasOne(a => a.Environment)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(a => a.EnvironmentId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
entity.HasOne(a => a.StorageLink)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(a => a.StorageLinkId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
// OnCallSchedule — named rotation schedule owned by a tenant.
|
||||||
|
|
||||||
|
builder.Entity<OnCallSchedule>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(s => s.Id);
|
||||||
|
entity.HasIndex(s => new { s.TenantId, s.Name }).IsUnique();
|
||||||
|
entity.Property(s => s.Name).HasMaxLength(200).IsRequired();
|
||||||
|
entity.Property(s => s.Description).HasMaxLength(500);
|
||||||
|
|
||||||
|
entity.HasOne(s => s.Tenant)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(s => s.TenantId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasMany(s => s.Shifts)
|
||||||
|
.WithOne(sh => sh.Schedule)
|
||||||
|
.HasForeignKey(sh => sh.ScheduleId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
// OnCallShift — a single time-boxed assignment within a schedule.
|
||||||
|
|
||||||
|
builder.Entity<OnCallShift>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(sh => sh.Id);
|
||||||
|
entity.HasIndex(sh => sh.ScheduleId);
|
||||||
|
entity.HasIndex(sh => sh.StartsAt);
|
||||||
|
entity.Property(sh => sh.AssigneeName).HasMaxLength(256).IsRequired();
|
||||||
|
entity.Property(sh => sh.AssigneeEmail).HasMaxLength(256);
|
||||||
|
entity.Property(sh => sh.Notes).HasMaxLength(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// AlertRoutingRule — tenant-specific rules that map alert criteria to a notification channel.
|
||||||
|
|
||||||
|
builder.Entity<AlertRoutingRule>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(r => r.Id);
|
||||||
|
entity.HasIndex(r => r.TenantId);
|
||||||
|
entity.HasIndex(r => r.ChannelId);
|
||||||
|
entity.Property(r => r.Name).HasMaxLength(200).IsRequired();
|
||||||
|
entity.Property(r => r.MatchAlertName).HasMaxLength(200);
|
||||||
|
entity.Property(r => r.MatchNamespace).HasMaxLength(200);
|
||||||
|
entity.Property(r => r.MatchSeverity).HasMaxLength(20);
|
||||||
|
entity.Property(r => r.MatchLabelKey).HasMaxLength(100);
|
||||||
|
entity.Property(r => r.MatchLabelValue).HasMaxLength(200);
|
||||||
|
|
||||||
|
entity.HasOne(r => r.Tenant)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(r => r.TenantId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasOne(r => r.Channel)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(r => r.ChannelId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired(false);
|
||||||
|
|
||||||
|
entity.HasOne(r => r.MatchCluster)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(r => r.MatchClusterId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull)
|
||||||
|
.IsRequired(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Entity<NotificationProviderConfig>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(c => c.Id);
|
||||||
|
entity.HasIndex(c => c.ProviderType).IsUnique();
|
||||||
|
entity.Property(c => c.ProviderType).HasConversion<string>().HasMaxLength(30);
|
||||||
|
entity.Property(c => c.UpdatedByUserId).HasMaxLength(450);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ClusterServer — inventory record for physical/VM nodes behind a K8s cluster.
|
||||||
|
// Name (DisplayName) must be unique within a cluster.
|
||||||
|
|
||||||
|
builder.Entity<ClusterServer>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(s => s.Id);
|
||||||
|
entity.HasIndex(s => new { s.ClusterId, s.DisplayName }).IsUnique();
|
||||||
|
entity.Property(s => s.DisplayName).HasMaxLength(200).IsRequired();
|
||||||
|
entity.Property(s => s.NodeName).HasMaxLength(253);
|
||||||
|
entity.Property(s => s.IpAddress).HasMaxLength(45);
|
||||||
|
entity.Property(s => s.ManagementIpAddress).HasMaxLength(45);
|
||||||
|
entity.Property(s => s.Provider).HasConversion<string>().HasMaxLength(20);
|
||||||
|
entity.Property(s => s.OsDistribution).HasMaxLength(200);
|
||||||
|
entity.Property(s => s.Location).HasMaxLength(200);
|
||||||
|
entity.Property(s => s.SshUser).HasMaxLength(100);
|
||||||
|
entity.Property(s => s.JumpHost).HasMaxLength(500);
|
||||||
|
entity.Property(s => s.Notes).HasMaxLength(2000);
|
||||||
|
|
||||||
|
entity.HasOne(s => s.Cluster)
|
||||||
|
.WithMany(c => c.Servers)
|
||||||
|
.HasForeignKey(s => s.ClusterId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
69
src/EntKube.Web/Data/ClusterServer.cs
Normal file
69
src/EntKube.Web/Data/ClusterServer.cs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
namespace EntKube.Web.Data;
|
||||||
|
|
||||||
|
public enum ServerProvider
|
||||||
|
{
|
||||||
|
BareMetal,
|
||||||
|
CloudVm,
|
||||||
|
OnPremVm,
|
||||||
|
Other
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inventory record for the physical or virtual machine that backs a Kubernetes node.
|
||||||
|
/// Stores hardware specs, location, and SSH access info for out-of-band management.
|
||||||
|
/// Optionally linked to a K8s node name for the live-node ↔ server mapping.
|
||||||
|
/// </summary>
|
||||||
|
public class ClusterServer
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
public Guid ClusterId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// K8s node name this server maps to (e.g. "node-1.example.com").
|
||||||
|
/// Null means the server is registered but not yet associated with a live node.
|
||||||
|
/// </summary>
|
||||||
|
public string? NodeName { get; set; }
|
||||||
|
|
||||||
|
public required string DisplayName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Primary IP reachable within the cluster network.</summary>
|
||||||
|
public string? IpAddress { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Out-of-band / IPMI / management IP for direct server access.</summary>
|
||||||
|
public string? ManagementIpAddress { get; set; }
|
||||||
|
|
||||||
|
public ServerProvider Provider { get; set; } = ServerProvider.BareMetal;
|
||||||
|
|
||||||
|
/// <summary>OS description, e.g. "Ubuntu 22.04 LTS".</summary>
|
||||||
|
public string? OsDistribution { get; set; }
|
||||||
|
|
||||||
|
public int? CpuCores { get; set; }
|
||||||
|
|
||||||
|
/// <summary>RAM in GB.</summary>
|
||||||
|
public int? RamGb { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Primary disk size in GB.</summary>
|
||||||
|
public int? DiskGb { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Physical or cloud location — datacenter, availability zone, region, etc.</summary>
|
||||||
|
public string? Location { get; set; }
|
||||||
|
|
||||||
|
// ── SSH access ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public string? SshUser { get; set; }
|
||||||
|
|
||||||
|
public int SshPort { get; set; } = 22;
|
||||||
|
|
||||||
|
/// <summary>Jump / bastion host to tunnel through, e.g. "bastion.example.com:22".</summary>
|
||||||
|
public string? JumpHost { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Free-form notes — maintenance history, special config, caveats, etc.</summary>
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
public KubernetesCluster Cluster { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ public class CustomerGitCredential
|
|||||||
|
|
||||||
public Guid TenantId { get; set; }
|
public Guid TenantId { get; set; }
|
||||||
|
|
||||||
|
public Guid EnvironmentId { get; set; }
|
||||||
|
|
||||||
/// <summary>Human-friendly label, e.g. "GitHub PAT". Unique within a customer.</summary>
|
/// <summary>Human-friendly label, e.g. "GitHub PAT". Unique within a customer.</summary>
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
|
|
||||||
@@ -31,9 +33,17 @@ public class CustomerGitCredential
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Username { get; set; }
|
public string? Username { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The URL pattern this credential covers — same glob syntax as CustomerGitRepoPolicy
|
||||||
|
/// (e.g. https://github.com/acme/*). When set, the sync service picks this credential
|
||||||
|
/// automatically for repos whose URL matches. Null means "applies to any URL".
|
||||||
|
/// </summary>
|
||||||
|
public string? UrlPattern { get; set; }
|
||||||
|
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
public Customer Customer { get; set; } = null!;
|
public Customer Customer { get; set; } = null!;
|
||||||
public Tenant Tenant { get; set; } = null!;
|
public Tenant Tenant { get; set; } = null!;
|
||||||
|
public Environment Environment { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,11 @@ public class CustomerGitRepoPolicy
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public required string UrlPattern { get; set; }
|
public required string UrlPattern { get; set; }
|
||||||
|
|
||||||
|
public Guid EnvironmentId { get; set; }
|
||||||
|
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
public Customer Customer { get; set; } = null!;
|
public Customer Customer { get; set; } = null!;
|
||||||
|
public Environment Environment { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,4 +42,5 @@ public class KubernetesCluster
|
|||||||
public Tenant Tenant { get; set; } = null!;
|
public Tenant Tenant { get; set; } = null!;
|
||||||
public Environment Environment { get; set; } = null!;
|
public Environment Environment { get; set; } = null!;
|
||||||
public ICollection<ClusterComponent> Components { get; set; } = [];
|
public ICollection<ClusterComponent> Components { get; set; } = [];
|
||||||
|
public ICollection<ClusterServer> Servers { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
4589
src/EntKube.Web/Data/Migrations/Postgres/20260609100654_AddAppRoutes.Designer.cs
generated
Normal file
4589
src/EntKube.Web/Data/Migrations/Postgres/20260609100654_AddAppRoutes.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.Postgres
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAppRoutes : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AppRoutes",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
AppId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Hostname = table.Column<string>(type: "character varying(253)", maxLength: 253, nullable: false),
|
||||||
|
TlsMode = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
ClusterIssuerName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
TlsCertificate = table.Column<string>(type: "text", nullable: true),
|
||||||
|
TlsPrivateKey = table.Column<string>(type: "text", nullable: true),
|
||||||
|
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AppRoutes", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppRoutes_Apps_AppId",
|
||||||
|
column: x => x.AppId,
|
||||||
|
principalTable: "Apps",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AppDeploymentRoutes",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
AppRouteId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
AppDeploymentId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
PathPrefix = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
ServiceName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
ServicePort = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
GatewayName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
GatewayNamespace = table.Column<string>(type: "character varying(63)", maxLength: 63, nullable: true),
|
||||||
|
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
LastHealthCheckAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
LastStatusCode = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
IsReachable = table.Column<bool>(type: "boolean", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AppDeploymentRoutes", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppDeploymentRoutes_AppDeployments_AppDeploymentId",
|
||||||
|
column: x => x.AppDeploymentId,
|
||||||
|
principalTable: "AppDeployments",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppDeploymentRoutes_AppRoutes_AppRouteId",
|
||||||
|
column: x => x.AppRouteId,
|
||||||
|
principalTable: "AppRoutes",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppDeploymentRoutes_AppDeploymentId",
|
||||||
|
table: "AppDeploymentRoutes",
|
||||||
|
column: "AppDeploymentId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppDeploymentRoutes_AppRouteId",
|
||||||
|
table: "AppDeploymentRoutes",
|
||||||
|
column: "AppRouteId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppRoutes_AppId",
|
||||||
|
table: "AppRoutes",
|
||||||
|
column: "AppId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AppDeploymentRoutes");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AppRoutes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4628
src/EntKube.Web/Data/Migrations/Postgres/20260609111925_GovernancePerEnvironment.Designer.cs
generated
Normal file
4628
src/EntKube.Web/Data/Migrations/Postgres/20260609111925_GovernancePerEnvironment.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,179 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.Postgres
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class GovernancePerEnvironment : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_AppRbacPolicies_AppId",
|
||||||
|
table: "AppRbacPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_AppQuotas_AppId",
|
||||||
|
table: "AppQuotas");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_AppNetworkPolicies_AppId_Name",
|
||||||
|
table: "AppNetworkPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "EnvironmentId",
|
||||||
|
table: "AppRbacPolicies",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "EnvironmentId",
|
||||||
|
table: "AppQuotas",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "EnvironmentId",
|
||||||
|
table: "AppNetworkPolicies",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppRbacPolicies_AppId_EnvironmentId",
|
||||||
|
table: "AppRbacPolicies",
|
||||||
|
columns: new[] { "AppId", "EnvironmentId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppRbacPolicies_EnvironmentId",
|
||||||
|
table: "AppRbacPolicies",
|
||||||
|
column: "EnvironmentId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppQuotas_AppId_EnvironmentId",
|
||||||
|
table: "AppQuotas",
|
||||||
|
columns: new[] { "AppId", "EnvironmentId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppQuotas_EnvironmentId",
|
||||||
|
table: "AppQuotas",
|
||||||
|
column: "EnvironmentId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppNetworkPolicies_AppId_EnvironmentId_Name",
|
||||||
|
table: "AppNetworkPolicies",
|
||||||
|
columns: new[] { "AppId", "EnvironmentId", "Name" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppNetworkPolicies_EnvironmentId",
|
||||||
|
table: "AppNetworkPolicies",
|
||||||
|
column: "EnvironmentId");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("DELETE FROM \"AppQuotas\" WHERE \"EnvironmentId\" = '00000000-0000-0000-0000-000000000000'");
|
||||||
|
migrationBuilder.Sql("DELETE FROM \"AppNetworkPolicies\" WHERE \"EnvironmentId\" = '00000000-0000-0000-0000-000000000000'");
|
||||||
|
migrationBuilder.Sql("DELETE FROM \"AppRbacPolicies\" WHERE \"EnvironmentId\" = '00000000-0000-0000-0000-000000000000'");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_AppNetworkPolicies_Environments_EnvironmentId",
|
||||||
|
table: "AppNetworkPolicies",
|
||||||
|
column: "EnvironmentId",
|
||||||
|
principalTable: "Environments",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_AppQuotas_Environments_EnvironmentId",
|
||||||
|
table: "AppQuotas",
|
||||||
|
column: "EnvironmentId",
|
||||||
|
principalTable: "Environments",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_AppRbacPolicies_Environments_EnvironmentId",
|
||||||
|
table: "AppRbacPolicies",
|
||||||
|
column: "EnvironmentId",
|
||||||
|
principalTable: "Environments",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_AppNetworkPolicies_Environments_EnvironmentId",
|
||||||
|
table: "AppNetworkPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_AppQuotas_Environments_EnvironmentId",
|
||||||
|
table: "AppQuotas");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_AppRbacPolicies_Environments_EnvironmentId",
|
||||||
|
table: "AppRbacPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_AppRbacPolicies_AppId_EnvironmentId",
|
||||||
|
table: "AppRbacPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_AppRbacPolicies_EnvironmentId",
|
||||||
|
table: "AppRbacPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_AppQuotas_AppId_EnvironmentId",
|
||||||
|
table: "AppQuotas");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_AppQuotas_EnvironmentId",
|
||||||
|
table: "AppQuotas");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_AppNetworkPolicies_AppId_EnvironmentId_Name",
|
||||||
|
table: "AppNetworkPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_AppNetworkPolicies_EnvironmentId",
|
||||||
|
table: "AppNetworkPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "EnvironmentId",
|
||||||
|
table: "AppRbacPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "EnvironmentId",
|
||||||
|
table: "AppQuotas");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "EnvironmentId",
|
||||||
|
table: "AppNetworkPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppRbacPolicies_AppId",
|
||||||
|
table: "AppRbacPolicies",
|
||||||
|
column: "AppId",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppQuotas_AppId",
|
||||||
|
table: "AppQuotas",
|
||||||
|
column: "AppId",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppNetworkPolicies_AppId_Name",
|
||||||
|
table: "AppNetworkPolicies",
|
||||||
|
columns: new[] { "AppId", "Name" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4654
src/EntKube.Web/Data/Migrations/Postgres/20260609115713_GitPerEnvironment.Designer.cs
generated
Normal file
4654
src/EntKube.Web/Data/Migrations/Postgres/20260609115713_GitPerEnvironment.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.Postgres
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class GitPerEnvironment : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_CustomerGitRepoPolicies_CustomerId_UrlPattern",
|
||||||
|
table: "CustomerGitRepoPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_CustomerGitCredentials_CustomerId_Name",
|
||||||
|
table: "CustomerGitCredentials");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "EnvironmentId",
|
||||||
|
table: "CustomerGitRepoPolicies",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "EnvironmentId",
|
||||||
|
table: "CustomerGitCredentials",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CustomerGitRepoPolicies_CustomerId_EnvironmentId_UrlPattern",
|
||||||
|
table: "CustomerGitRepoPolicies",
|
||||||
|
columns: new[] { "CustomerId", "EnvironmentId", "UrlPattern" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CustomerGitRepoPolicies_EnvironmentId",
|
||||||
|
table: "CustomerGitRepoPolicies",
|
||||||
|
column: "EnvironmentId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CustomerGitCredentials_CustomerId_EnvironmentId_Name",
|
||||||
|
table: "CustomerGitCredentials",
|
||||||
|
columns: new[] { "CustomerId", "EnvironmentId", "Name" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CustomerGitCredentials_EnvironmentId",
|
||||||
|
table: "CustomerGitCredentials",
|
||||||
|
column: "EnvironmentId");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("DELETE FROM \"CustomerGitRepoPolicies\" WHERE \"EnvironmentId\" = '00000000-0000-0000-0000-000000000000'");
|
||||||
|
migrationBuilder.Sql("DELETE FROM \"CustomerGitCredentials\" WHERE \"EnvironmentId\" = '00000000-0000-0000-0000-000000000000'");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_CustomerGitCredentials_Environments_EnvironmentId",
|
||||||
|
table: "CustomerGitCredentials",
|
||||||
|
column: "EnvironmentId",
|
||||||
|
principalTable: "Environments",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_CustomerGitRepoPolicies_Environments_EnvironmentId",
|
||||||
|
table: "CustomerGitRepoPolicies",
|
||||||
|
column: "EnvironmentId",
|
||||||
|
principalTable: "Environments",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_CustomerGitCredentials_Environments_EnvironmentId",
|
||||||
|
table: "CustomerGitCredentials");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_CustomerGitRepoPolicies_Environments_EnvironmentId",
|
||||||
|
table: "CustomerGitRepoPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_CustomerGitRepoPolicies_CustomerId_EnvironmentId_UrlPattern",
|
||||||
|
table: "CustomerGitRepoPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_CustomerGitRepoPolicies_EnvironmentId",
|
||||||
|
table: "CustomerGitRepoPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_CustomerGitCredentials_CustomerId_EnvironmentId_Name",
|
||||||
|
table: "CustomerGitCredentials");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_CustomerGitCredentials_EnvironmentId",
|
||||||
|
table: "CustomerGitCredentials");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "EnvironmentId",
|
||||||
|
table: "CustomerGitRepoPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "EnvironmentId",
|
||||||
|
table: "CustomerGitCredentials");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CustomerGitRepoPolicies_CustomerId_UrlPattern",
|
||||||
|
table: "CustomerGitRepoPolicies",
|
||||||
|
columns: new[] { "CustomerId", "UrlPattern" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CustomerGitCredentials_CustomerId_Name",
|
||||||
|
table: "CustomerGitCredentials",
|
||||||
|
columns: new[] { "CustomerId", "Name" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4657
src/EntKube.Web/Data/Migrations/Postgres/20260609130903_AddAppEnvironmentNamespace.Designer.cs
generated
Normal file
4657
src/EntKube.Web/Data/Migrations/Postgres/20260609130903_AddAppEnvironmentNamespace.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.Postgres
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAppEnvironmentNamespace : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Namespace",
|
||||||
|
table: "AppEnvironments",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Namespace",
|
||||||
|
table: "AppEnvironments");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4861
src/EntKube.Web/Data/Migrations/Postgres/20260609170945_AddGovernanceAllowlists.Designer.cs
generated
Normal file
4861
src/EntKube.Web/Data/Migrations/Postgres/20260609170945_AddGovernanceAllowlists.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,196 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.Postgres
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddGovernanceAllowlists : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AppAllowedCaches",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
AppId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
EnvironmentId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
RedisClusterId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AppAllowedCaches", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppAllowedCaches_Apps_AppId",
|
||||||
|
column: x => x.AppId,
|
||||||
|
principalTable: "Apps",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppAllowedCaches_Environments_EnvironmentId",
|
||||||
|
column: x => x.EnvironmentId,
|
||||||
|
principalTable: "Environments",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppAllowedCaches_RedisClusters_RedisClusterId",
|
||||||
|
column: x => x.RedisClusterId,
|
||||||
|
principalTable: "RedisClusters",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AppAllowedDatabases",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
AppId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
EnvironmentId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
CnpgDatabaseId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
MongoDatabaseId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
RegisteredPostgresDatabaseId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AppAllowedDatabases", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppAllowedDatabases_Apps_AppId",
|
||||||
|
column: x => x.AppId,
|
||||||
|
principalTable: "Apps",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppAllowedDatabases_CnpgDatabases_CnpgDatabaseId",
|
||||||
|
column: x => x.CnpgDatabaseId,
|
||||||
|
principalTable: "CnpgDatabases",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppAllowedDatabases_Environments_EnvironmentId",
|
||||||
|
column: x => x.EnvironmentId,
|
||||||
|
principalTable: "Environments",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppAllowedDatabases_MongoDatabases_MongoDatabaseId",
|
||||||
|
column: x => x.MongoDatabaseId,
|
||||||
|
principalTable: "MongoDatabases",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppAllowedDatabases_RegisteredPostgresDatabases_RegisteredP~",
|
||||||
|
column: x => x.RegisteredPostgresDatabaseId,
|
||||||
|
principalTable: "RegisteredPostgresDatabases",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AppAllowedStorages",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
AppId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
EnvironmentId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
StorageLinkId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AppAllowedStorages", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppAllowedStorages_Apps_AppId",
|
||||||
|
column: x => x.AppId,
|
||||||
|
principalTable: "Apps",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppAllowedStorages_Environments_EnvironmentId",
|
||||||
|
column: x => x.EnvironmentId,
|
||||||
|
principalTable: "Environments",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppAllowedStorages_StorageLinks_StorageLinkId",
|
||||||
|
column: x => x.StorageLinkId,
|
||||||
|
principalTable: "StorageLinks",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppAllowedCaches_AppId",
|
||||||
|
table: "AppAllowedCaches",
|
||||||
|
column: "AppId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppAllowedCaches_EnvironmentId",
|
||||||
|
table: "AppAllowedCaches",
|
||||||
|
column: "EnvironmentId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppAllowedCaches_RedisClusterId",
|
||||||
|
table: "AppAllowedCaches",
|
||||||
|
column: "RedisClusterId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppAllowedDatabases_AppId",
|
||||||
|
table: "AppAllowedDatabases",
|
||||||
|
column: "AppId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppAllowedDatabases_CnpgDatabaseId",
|
||||||
|
table: "AppAllowedDatabases",
|
||||||
|
column: "CnpgDatabaseId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppAllowedDatabases_EnvironmentId",
|
||||||
|
table: "AppAllowedDatabases",
|
||||||
|
column: "EnvironmentId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppAllowedDatabases_MongoDatabaseId",
|
||||||
|
table: "AppAllowedDatabases",
|
||||||
|
column: "MongoDatabaseId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppAllowedDatabases_RegisteredPostgresDatabaseId",
|
||||||
|
table: "AppAllowedDatabases",
|
||||||
|
column: "RegisteredPostgresDatabaseId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppAllowedStorages_AppId",
|
||||||
|
table: "AppAllowedStorages",
|
||||||
|
column: "AppId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppAllowedStorages_EnvironmentId",
|
||||||
|
table: "AppAllowedStorages",
|
||||||
|
column: "EnvironmentId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppAllowedStorages_StorageLinkId",
|
||||||
|
table: "AppAllowedStorages",
|
||||||
|
column: "StorageLinkId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AppAllowedCaches");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AppAllowedDatabases");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AppAllowedStorages");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4661
src/EntKube.Web/Data/Migrations/Postgres/20260609200000_AddCredentialUrlPattern.Designer.cs
generated
Normal file
4661
src/EntKube.Web/Data/Migrations/Postgres/20260609200000_AddCredentialUrlPattern.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.Postgres
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddCredentialUrlPattern : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "UrlPattern",
|
||||||
|
table: "CustomerGitCredentials",
|
||||||
|
type: "character varying(500)",
|
||||||
|
maxLength: 500,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "UrlPattern",
|
||||||
|
table: "CustomerGitCredentials");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using System;
|
||||||
|
using EntKube.Web.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.Postgres
|
||||||
|
{
|
||||||
|
[DbContext(typeof(PostgresApplicationDbContext))]
|
||||||
|
[Migration("20260609210000_AddDeploymentRouteApplied")]
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddDeploymentRouteApplied : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""ALTER TABLE "AppDeploymentRoutes" ADD COLUMN IF NOT EXISTS "ClusterAppliedAt" timestamp with time zone;""");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ClusterAppliedAt",
|
||||||
|
table: "AppDeploymentRoutes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System;
|
||||||
|
using EntKube.Web.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.Postgres
|
||||||
|
{
|
||||||
|
[DbContext(typeof(PostgresApplicationDbContext))]
|
||||||
|
[Migration("20260610120000_AddVaultSecretVersions")]
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddVaultSecretVersions : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""ALTER TABLE "VaultSecrets" ADD COLUMN IF NOT EXISTS "UpdatedBy" character varying(254);""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
CREATE TABLE IF NOT EXISTS "VaultSecretVersions" (
|
||||||
|
"Id" uuid NOT NULL,
|
||||||
|
"SecretId" uuid NOT NULL,
|
||||||
|
"VersionNumber" integer NOT NULL,
|
||||||
|
"EncryptedValue" bytea NOT NULL,
|
||||||
|
"Nonce" bytea NOT NULL,
|
||||||
|
"CreatedBy" character varying(254),
|
||||||
|
"CreatedAt" timestamp with time zone NOT NULL,
|
||||||
|
CONSTRAINT "PK_VaultSecretVersions" PRIMARY KEY ("Id"),
|
||||||
|
CONSTRAINT "FK_VaultSecretVersions_VaultSecrets_SecretId"
|
||||||
|
FOREIGN KEY ("SecretId") REFERENCES "VaultSecrets" ("Id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_VaultSecretVersions_SecretId_VersionNumber"
|
||||||
|
ON "VaultSecretVersions" ("SecretId", "VersionNumber");
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "VaultSecretVersions");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "UpdatedBy",
|
||||||
|
table: "VaultSecrets");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using System;
|
||||||
|
using EntKube.Web.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.Postgres
|
||||||
|
{
|
||||||
|
[DbContext(typeof(PostgresApplicationDbContext))]
|
||||||
|
[Migration("20260611000000_AddOpsTeamFeatures")]
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddOpsTeamFeatures : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""ALTER TABLE "AlertIncidents" ADD COLUMN IF NOT EXISTS "AssignedTo" character varying(256);""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""ALTER TABLE "AlertIncidents" ADD COLUMN IF NOT EXISTS "AssignedAt" timestamp with time zone;""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
CREATE TABLE IF NOT EXISTS "OnCallSchedules" (
|
||||||
|
"Id" uuid NOT NULL,
|
||||||
|
"TenantId" uuid NOT NULL,
|
||||||
|
"Name" character varying(200) NOT NULL,
|
||||||
|
"Description" character varying(500),
|
||||||
|
"IsEnabled" boolean NOT NULL DEFAULT true,
|
||||||
|
"CreatedAt" timestamp with time zone NOT NULL,
|
||||||
|
CONSTRAINT "PK_OnCallSchedules" PRIMARY KEY ("Id"),
|
||||||
|
CONSTRAINT "FK_OnCallSchedules_Tenants_TenantId"
|
||||||
|
FOREIGN KEY ("TenantId") REFERENCES "Tenants" ("Id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
CREATE TABLE IF NOT EXISTS "OnCallShifts" (
|
||||||
|
"Id" uuid NOT NULL,
|
||||||
|
"ScheduleId" uuid NOT NULL,
|
||||||
|
"AssigneeName" character varying(256) NOT NULL,
|
||||||
|
"AssigneeEmail" character varying(256),
|
||||||
|
"StartsAt" timestamp with time zone NOT NULL,
|
||||||
|
"EndsAt" timestamp with time zone NOT NULL,
|
||||||
|
"Notes" character varying(1000),
|
||||||
|
CONSTRAINT "PK_OnCallShifts" PRIMARY KEY ("Id"),
|
||||||
|
CONSTRAINT "FK_OnCallShifts_OnCallSchedules_ScheduleId"
|
||||||
|
FOREIGN KEY ("ScheduleId") REFERENCES "OnCallSchedules" ("Id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
CREATE TABLE IF NOT EXISTS "AlertRoutingRules" (
|
||||||
|
"Id" uuid NOT NULL,
|
||||||
|
"TenantId" uuid NOT NULL,
|
||||||
|
"Name" character varying(200) NOT NULL,
|
||||||
|
"Priority" integer NOT NULL DEFAULT 0,
|
||||||
|
"ChannelId" uuid NOT NULL,
|
||||||
|
"MatchAlertName" character varying(200),
|
||||||
|
"MatchNamespace" character varying(200),
|
||||||
|
"MatchSeverity" character varying(20),
|
||||||
|
"MatchLabelKey" character varying(100),
|
||||||
|
"MatchLabelValue" character varying(200),
|
||||||
|
"IsEnabled" boolean NOT NULL DEFAULT true,
|
||||||
|
CONSTRAINT "PK_AlertRoutingRules" PRIMARY KEY ("Id"),
|
||||||
|
CONSTRAINT "FK_AlertRoutingRules_Tenants_TenantId"
|
||||||
|
FOREIGN KEY ("TenantId") REFERENCES "Tenants" ("Id") ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "FK_AlertRoutingRules_NotificationChannels_ChannelId"
|
||||||
|
FOREIGN KEY ("ChannelId") REFERENCES "NotificationChannels" ("Id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_OnCallShifts_ScheduleId"
|
||||||
|
ON "OnCallShifts" ("ScheduleId");
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_OnCallShifts_StartsAt"
|
||||||
|
ON "OnCallShifts" ("StartsAt");
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_AlertRoutingRules_TenantId"
|
||||||
|
ON "AlertRoutingRules" ("TenantId");
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_AlertRoutingRules_ChannelId"
|
||||||
|
ON "AlertRoutingRules" ("ChannelId");
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AlertRoutingRules");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "OnCallShifts");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "OnCallSchedules");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AssignedTo",
|
||||||
|
table: "AlertIncidents");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AssignedAt",
|
||||||
|
table: "AlertIncidents");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using EntKube.Web.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.Postgres
|
||||||
|
{
|
||||||
|
[DbContext(typeof(PostgresApplicationDbContext))]
|
||||||
|
[Migration("20260611010000_AddAlertIncidentRunbookUrl")]
|
||||||
|
public partial class AddAlertIncidentRunbookUrl : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
ALTER TABLE "AlertIncidents"
|
||||||
|
ADD COLUMN IF NOT EXISTS "RunbookUrl" character varying(500) NOT NULL DEFAULT '';
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "RunbookUrl",
|
||||||
|
table: "AlertIncidents");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using EntKube.Web.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.Postgres
|
||||||
|
{
|
||||||
|
[DbContext(typeof(PostgresApplicationDbContext))]
|
||||||
|
[Migration("20260611020000_AddAlertIncidentEscalation")]
|
||||||
|
public partial class AddAlertIncidentEscalation : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
ALTER TABLE "AlertIncidents"
|
||||||
|
ADD COLUMN IF NOT EXISTS "EscalatedAt" timestamp with time zone NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_AlertIncidents_EscalatedAt"
|
||||||
|
ON "AlertIncidents" ("EscalatedAt");
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_AlertIncidents_EscalatedAt",
|
||||||
|
table: "AlertIncidents");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "EscalatedAt",
|
||||||
|
table: "AlertIncidents");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using EntKube.Web.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.Postgres
|
||||||
|
{
|
||||||
|
[DbContext(typeof(PostgresApplicationDbContext))]
|
||||||
|
[Migration("20260611030000_AddDeploymentGitUrl")]
|
||||||
|
public partial class AddDeploymentGitUrl : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
ALTER TABLE "AppDeployments"
|
||||||
|
ADD COLUMN IF NOT EXISTS "GitUrl" character varying(2000) NULL;
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "GitUrl",
|
||||||
|
table: "AppDeployments");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using System;
|
||||||
|
using EntKube.Web.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.Postgres
|
||||||
|
{
|
||||||
|
[DbContext(typeof(PostgresApplicationDbContext))]
|
||||||
|
[Migration("20260611040000_AddNotificationProviderConfig")]
|
||||||
|
public partial class AddNotificationProviderConfig : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "NotificationProviderConfigs",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
ProviderType = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||||
|
ConfigurationJson = table.Column<string>(type: "text", nullable: false),
|
||||||
|
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedByUserId = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_NotificationProviderConfigs", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_NotificationProviderConfigs_ProviderType",
|
||||||
|
table: "NotificationProviderConfigs",
|
||||||
|
column: "ProviderType",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "NotificationProviderConfigs");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using System;
|
||||||
|
using EntKube.Web.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.Postgres
|
||||||
|
{
|
||||||
|
[DbContext(typeof(PostgresApplicationDbContext))]
|
||||||
|
[Migration("20260611060000_AddAlertRoutingSuppression")]
|
||||||
|
public partial class AddAlertRoutingSuppression : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// Make ChannelId nullable
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
ALTER TABLE "AlertRoutingRules"
|
||||||
|
DROP CONSTRAINT "FK_AlertRoutingRules_NotificationChannels_ChannelId";
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
ALTER TABLE "AlertRoutingRules"
|
||||||
|
ALTER COLUMN "ChannelId" DROP NOT NULL;
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
ALTER TABLE "AlertRoutingRules"
|
||||||
|
ADD CONSTRAINT "FK_AlertRoutingRules_NotificationChannels_ChannelId"
|
||||||
|
FOREIGN KEY ("ChannelId") REFERENCES "NotificationChannels" ("Id") ON DELETE CASCADE;
|
||||||
|
""");
|
||||||
|
|
||||||
|
// Add new columns
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
ALTER TABLE "AlertRoutingRules"
|
||||||
|
ADD COLUMN IF NOT EXISTS "SuppressIncident" boolean NOT NULL DEFAULT false;
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
ALTER TABLE "AlertRoutingRules"
|
||||||
|
ADD COLUMN IF NOT EXISTS "MatchClusterId" uuid;
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
ALTER TABLE "AlertRoutingRules"
|
||||||
|
ADD CONSTRAINT "FK_AlertRoutingRules_KubernetesClusters_MatchClusterId"
|
||||||
|
FOREIGN KEY ("MatchClusterId") REFERENCES "KubernetesClusters" ("Id") ON DELETE SET NULL;
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_AlertRoutingRules_MatchClusterId"
|
||||||
|
ON "AlertRoutingRules" ("MatchClusterId");
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
DROP INDEX IF EXISTS "IX_AlertRoutingRules_MatchClusterId";
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
ALTER TABLE "AlertRoutingRules"
|
||||||
|
DROP CONSTRAINT IF EXISTS "FK_AlertRoutingRules_KubernetesClusters_MatchClusterId";
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
ALTER TABLE "AlertRoutingRules"
|
||||||
|
DROP COLUMN IF EXISTS "MatchClusterId";
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
ALTER TABLE "AlertRoutingRules"
|
||||||
|
DROP COLUMN IF EXISTS "SuppressIncident";
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
ALTER TABLE "AlertRoutingRules"
|
||||||
|
DROP CONSTRAINT "FK_AlertRoutingRules_NotificationChannels_ChannelId";
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
ALTER TABLE "AlertRoutingRules"
|
||||||
|
ALTER COLUMN "ChannelId" SET NOT NULL;
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
ALTER TABLE "AlertRoutingRules"
|
||||||
|
ADD CONSTRAINT "FK_AlertRoutingRules_NotificationChannels_ChannelId"
|
||||||
|
FOREIGN KEY ("ChannelId") REFERENCES "NotificationChannels" ("Id") ON DELETE CASCADE;
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5238
src/EntKube.Web/Data/Migrations/Postgres/20260611122524_AddClusterServers.Designer.cs
generated
Normal file
5238
src/EntKube.Web/Data/Migrations/Postgres/20260611122524_AddClusterServers.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,236 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.Postgres
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddClusterServers : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_OnCallSchedules_TenantId",
|
||||||
|
table: "OnCallSchedules");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "UpdatedBy",
|
||||||
|
table: "VaultSecrets",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<bool>(
|
||||||
|
name: "IsEnabled",
|
||||||
|
table: "OnCallSchedules",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(bool),
|
||||||
|
oldType: "boolean",
|
||||||
|
oldDefaultValue: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "GitUrl",
|
||||||
|
table: "AppDeployments",
|
||||||
|
type: "text",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(2000)",
|
||||||
|
oldMaxLength: 2000,
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<bool>(
|
||||||
|
name: "SuppressIncident",
|
||||||
|
table: "AlertRoutingRules",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(bool),
|
||||||
|
oldType: "boolean",
|
||||||
|
oldDefaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<int>(
|
||||||
|
name: "Priority",
|
||||||
|
table: "AlertRoutingRules",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(int),
|
||||||
|
oldType: "integer",
|
||||||
|
oldDefaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<bool>(
|
||||||
|
name: "IsEnabled",
|
||||||
|
table: "AlertRoutingRules",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(bool),
|
||||||
|
oldType: "boolean",
|
||||||
|
oldDefaultValue: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "RunbookUrl",
|
||||||
|
table: "AlertIncidents",
|
||||||
|
type: "character varying(500)",
|
||||||
|
maxLength: 500,
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(500)",
|
||||||
|
oldMaxLength: 500,
|
||||||
|
oldDefaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ClusterServers",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
ClusterId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
NodeName = table.Column<string>(type: "character varying(253)", maxLength: 253, nullable: true),
|
||||||
|
DisplayName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
IpAddress = table.Column<string>(type: "character varying(45)", maxLength: 45, nullable: true),
|
||||||
|
ManagementIpAddress = table.Column<string>(type: "character varying(45)", maxLength: 45, nullable: true),
|
||||||
|
Provider = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
OsDistribution = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
CpuCores = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
RamGb = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
DiskGb = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
Location = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
SshUser = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||||
|
SshPort = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
JumpHost = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
Notes = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ClusterServers", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ClusterServers_KubernetesClusters_ClusterId",
|
||||||
|
column: x => x.ClusterId,
|
||||||
|
principalTable: "KubernetesClusters",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "VaultSecretVersions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
SecretId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
VersionNumber = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
EncryptedValue = table.Column<byte[]>(type: "bytea", nullable: false),
|
||||||
|
Nonce = table.Column<byte[]>(type: "bytea", nullable: false),
|
||||||
|
CreatedBy = table.Column<string>(type: "character varying(254)", maxLength: 254, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_VaultSecretVersions", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_VaultSecretVersions_VaultSecrets_SecretId",
|
||||||
|
column: x => x.SecretId,
|
||||||
|
principalTable: "VaultSecrets",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_OnCallSchedules_TenantId_Name",
|
||||||
|
table: "OnCallSchedules",
|
||||||
|
columns: new[] { "TenantId", "Name" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ClusterServers_ClusterId_DisplayName",
|
||||||
|
table: "ClusterServers",
|
||||||
|
columns: new[] { "ClusterId", "DisplayName" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_VaultSecretVersions_SecretId_VersionNumber",
|
||||||
|
table: "VaultSecretVersions",
|
||||||
|
columns: new[] { "SecretId", "VersionNumber" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ClusterServers");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "VaultSecretVersions");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_OnCallSchedules_TenantId_Name",
|
||||||
|
table: "OnCallSchedules");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "UpdatedBy",
|
||||||
|
table: "VaultSecrets");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<bool>(
|
||||||
|
name: "IsEnabled",
|
||||||
|
table: "OnCallSchedules",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: true,
|
||||||
|
oldClrType: typeof(bool),
|
||||||
|
oldType: "boolean");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "GitUrl",
|
||||||
|
table: "AppDeployments",
|
||||||
|
type: "character varying(2000)",
|
||||||
|
maxLength: 2000,
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<bool>(
|
||||||
|
name: "SuppressIncident",
|
||||||
|
table: "AlertRoutingRules",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false,
|
||||||
|
oldClrType: typeof(bool),
|
||||||
|
oldType: "boolean");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<int>(
|
||||||
|
name: "Priority",
|
||||||
|
table: "AlertRoutingRules",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0,
|
||||||
|
oldClrType: typeof(int),
|
||||||
|
oldType: "integer");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<bool>(
|
||||||
|
name: "IsEnabled",
|
||||||
|
table: "AlertRoutingRules",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: true,
|
||||||
|
oldClrType: typeof(bool),
|
||||||
|
oldType: "boolean");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "RunbookUrl",
|
||||||
|
table: "AlertIncidents",
|
||||||
|
type: "character varying(500)",
|
||||||
|
maxLength: 500,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "",
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(500)",
|
||||||
|
oldMaxLength: 500);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_OnCallSchedules_TenantId",
|
||||||
|
table: "OnCallSchedules",
|
||||||
|
column: "TenantId");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
4591
src/EntKube.Web/Data/Migrations/SqlServer/20260609100704_AddAppRoutes.Designer.cs
generated
Normal file
4591
src/EntKube.Web/Data/Migrations/SqlServer/20260609100704_AddAppRoutes.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.SqlServer
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAppRoutes : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AppRoutes",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
AppId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
Hostname = table.Column<string>(type: "nvarchar(253)", maxLength: 253, nullable: false),
|
||||||
|
TlsMode = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
||||||
|
ClusterIssuerName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||||
|
TlsCertificate = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
TlsPrivateKey = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
IsEnabled = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AppRoutes", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppRoutes_Apps_AppId",
|
||||||
|
column: x => x.AppId,
|
||||||
|
principalTable: "Apps",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AppDeploymentRoutes",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
AppRouteId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
AppDeploymentId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
PathPrefix = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||||
|
ServiceName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||||
|
ServicePort = table.Column<int>(type: "int", nullable: false),
|
||||||
|
GatewayName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||||
|
GatewayNamespace = table.Column<string>(type: "nvarchar(63)", maxLength: 63, nullable: true),
|
||||||
|
IsEnabled = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
LastHealthCheckAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
LastStatusCode = table.Column<int>(type: "int", nullable: true),
|
||||||
|
IsReachable = table.Column<bool>(type: "bit", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AppDeploymentRoutes", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppDeploymentRoutes_AppDeployments_AppDeploymentId",
|
||||||
|
column: x => x.AppDeploymentId,
|
||||||
|
principalTable: "AppDeployments",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppDeploymentRoutes_AppRoutes_AppRouteId",
|
||||||
|
column: x => x.AppRouteId,
|
||||||
|
principalTable: "AppRoutes",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppDeploymentRoutes_AppDeploymentId",
|
||||||
|
table: "AppDeploymentRoutes",
|
||||||
|
column: "AppDeploymentId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppDeploymentRoutes_AppRouteId",
|
||||||
|
table: "AppDeploymentRoutes",
|
||||||
|
column: "AppRouteId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppRoutes_AppId",
|
||||||
|
table: "AppRoutes",
|
||||||
|
column: "AppId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AppDeploymentRoutes");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AppRoutes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4630
src/EntKube.Web/Data/Migrations/SqlServer/20260609111941_GovernancePerEnvironment.Designer.cs
generated
Normal file
4630
src/EntKube.Web/Data/Migrations/SqlServer/20260609111941_GovernancePerEnvironment.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,179 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.SqlServer
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class GovernancePerEnvironment : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_AppRbacPolicies_AppId",
|
||||||
|
table: "AppRbacPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_AppQuotas_AppId",
|
||||||
|
table: "AppQuotas");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_AppNetworkPolicies_AppId_Name",
|
||||||
|
table: "AppNetworkPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "EnvironmentId",
|
||||||
|
table: "AppRbacPolicies",
|
||||||
|
type: "uniqueidentifier",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "EnvironmentId",
|
||||||
|
table: "AppQuotas",
|
||||||
|
type: "uniqueidentifier",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "EnvironmentId",
|
||||||
|
table: "AppNetworkPolicies",
|
||||||
|
type: "uniqueidentifier",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppRbacPolicies_AppId_EnvironmentId",
|
||||||
|
table: "AppRbacPolicies",
|
||||||
|
columns: new[] { "AppId", "EnvironmentId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppRbacPolicies_EnvironmentId",
|
||||||
|
table: "AppRbacPolicies",
|
||||||
|
column: "EnvironmentId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppQuotas_AppId_EnvironmentId",
|
||||||
|
table: "AppQuotas",
|
||||||
|
columns: new[] { "AppId", "EnvironmentId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppQuotas_EnvironmentId",
|
||||||
|
table: "AppQuotas",
|
||||||
|
column: "EnvironmentId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppNetworkPolicies_AppId_EnvironmentId_Name",
|
||||||
|
table: "AppNetworkPolicies",
|
||||||
|
columns: new[] { "AppId", "EnvironmentId", "Name" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppNetworkPolicies_EnvironmentId",
|
||||||
|
table: "AppNetworkPolicies",
|
||||||
|
column: "EnvironmentId");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("DELETE FROM AppQuotas WHERE EnvironmentId = '00000000-0000-0000-0000-000000000000'");
|
||||||
|
migrationBuilder.Sql("DELETE FROM AppNetworkPolicies WHERE EnvironmentId = '00000000-0000-0000-0000-000000000000'");
|
||||||
|
migrationBuilder.Sql("DELETE FROM AppRbacPolicies WHERE EnvironmentId = '00000000-0000-0000-0000-000000000000'");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_AppNetworkPolicies_Environments_EnvironmentId",
|
||||||
|
table: "AppNetworkPolicies",
|
||||||
|
column: "EnvironmentId",
|
||||||
|
principalTable: "Environments",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_AppQuotas_Environments_EnvironmentId",
|
||||||
|
table: "AppQuotas",
|
||||||
|
column: "EnvironmentId",
|
||||||
|
principalTable: "Environments",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_AppRbacPolicies_Environments_EnvironmentId",
|
||||||
|
table: "AppRbacPolicies",
|
||||||
|
column: "EnvironmentId",
|
||||||
|
principalTable: "Environments",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_AppNetworkPolicies_Environments_EnvironmentId",
|
||||||
|
table: "AppNetworkPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_AppQuotas_Environments_EnvironmentId",
|
||||||
|
table: "AppQuotas");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_AppRbacPolicies_Environments_EnvironmentId",
|
||||||
|
table: "AppRbacPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_AppRbacPolicies_AppId_EnvironmentId",
|
||||||
|
table: "AppRbacPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_AppRbacPolicies_EnvironmentId",
|
||||||
|
table: "AppRbacPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_AppQuotas_AppId_EnvironmentId",
|
||||||
|
table: "AppQuotas");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_AppQuotas_EnvironmentId",
|
||||||
|
table: "AppQuotas");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_AppNetworkPolicies_AppId_EnvironmentId_Name",
|
||||||
|
table: "AppNetworkPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_AppNetworkPolicies_EnvironmentId",
|
||||||
|
table: "AppNetworkPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "EnvironmentId",
|
||||||
|
table: "AppRbacPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "EnvironmentId",
|
||||||
|
table: "AppQuotas");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "EnvironmentId",
|
||||||
|
table: "AppNetworkPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppRbacPolicies_AppId",
|
||||||
|
table: "AppRbacPolicies",
|
||||||
|
column: "AppId",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppQuotas_AppId",
|
||||||
|
table: "AppQuotas",
|
||||||
|
column: "AppId",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppNetworkPolicies_AppId_Name",
|
||||||
|
table: "AppNetworkPolicies",
|
||||||
|
columns: new[] { "AppId", "Name" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4656
src/EntKube.Web/Data/Migrations/SqlServer/20260609115724_GitPerEnvironment.Designer.cs
generated
Normal file
4656
src/EntKube.Web/Data/Migrations/SqlServer/20260609115724_GitPerEnvironment.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.SqlServer
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class GitPerEnvironment : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_CustomerGitRepoPolicies_CustomerId_UrlPattern",
|
||||||
|
table: "CustomerGitRepoPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_CustomerGitCredentials_CustomerId_Name",
|
||||||
|
table: "CustomerGitCredentials");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "EnvironmentId",
|
||||||
|
table: "CustomerGitRepoPolicies",
|
||||||
|
type: "uniqueidentifier",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "EnvironmentId",
|
||||||
|
table: "CustomerGitCredentials",
|
||||||
|
type: "uniqueidentifier",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CustomerGitRepoPolicies_CustomerId_EnvironmentId_UrlPattern",
|
||||||
|
table: "CustomerGitRepoPolicies",
|
||||||
|
columns: new[] { "CustomerId", "EnvironmentId", "UrlPattern" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CustomerGitRepoPolicies_EnvironmentId",
|
||||||
|
table: "CustomerGitRepoPolicies",
|
||||||
|
column: "EnvironmentId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CustomerGitCredentials_CustomerId_EnvironmentId_Name",
|
||||||
|
table: "CustomerGitCredentials",
|
||||||
|
columns: new[] { "CustomerId", "EnvironmentId", "Name" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CustomerGitCredentials_EnvironmentId",
|
||||||
|
table: "CustomerGitCredentials",
|
||||||
|
column: "EnvironmentId");
|
||||||
|
|
||||||
|
migrationBuilder.Sql("DELETE FROM CustomerGitRepoPolicies WHERE EnvironmentId = '00000000-0000-0000-0000-000000000000'");
|
||||||
|
migrationBuilder.Sql("DELETE FROM CustomerGitCredentials WHERE EnvironmentId = '00000000-0000-0000-0000-000000000000'");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_CustomerGitCredentials_Environments_EnvironmentId",
|
||||||
|
table: "CustomerGitCredentials",
|
||||||
|
column: "EnvironmentId",
|
||||||
|
principalTable: "Environments",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_CustomerGitRepoPolicies_Environments_EnvironmentId",
|
||||||
|
table: "CustomerGitRepoPolicies",
|
||||||
|
column: "EnvironmentId",
|
||||||
|
principalTable: "Environments",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_CustomerGitCredentials_Environments_EnvironmentId",
|
||||||
|
table: "CustomerGitCredentials");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_CustomerGitRepoPolicies_Environments_EnvironmentId",
|
||||||
|
table: "CustomerGitRepoPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_CustomerGitRepoPolicies_CustomerId_EnvironmentId_UrlPattern",
|
||||||
|
table: "CustomerGitRepoPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_CustomerGitRepoPolicies_EnvironmentId",
|
||||||
|
table: "CustomerGitRepoPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_CustomerGitCredentials_CustomerId_EnvironmentId_Name",
|
||||||
|
table: "CustomerGitCredentials");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_CustomerGitCredentials_EnvironmentId",
|
||||||
|
table: "CustomerGitCredentials");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "EnvironmentId",
|
||||||
|
table: "CustomerGitRepoPolicies");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "EnvironmentId",
|
||||||
|
table: "CustomerGitCredentials");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CustomerGitRepoPolicies_CustomerId_UrlPattern",
|
||||||
|
table: "CustomerGitRepoPolicies",
|
||||||
|
columns: new[] { "CustomerId", "UrlPattern" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CustomerGitCredentials_CustomerId_Name",
|
||||||
|
table: "CustomerGitCredentials",
|
||||||
|
columns: new[] { "CustomerId", "Name" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4659
src/EntKube.Web/Data/Migrations/SqlServer/20260609130943_AddAppEnvironmentNamespace.Designer.cs
generated
Normal file
4659
src/EntKube.Web/Data/Migrations/SqlServer/20260609130943_AddAppEnvironmentNamespace.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.SqlServer
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAppEnvironmentNamespace : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Namespace",
|
||||||
|
table: "AppEnvironments",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Namespace",
|
||||||
|
table: "AppEnvironments");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4863
src/EntKube.Web/Data/Migrations/SqlServer/20260609171000_AddGovernanceAllowlists.Designer.cs
generated
Normal file
4863
src/EntKube.Web/Data/Migrations/SqlServer/20260609171000_AddGovernanceAllowlists.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,196 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.SqlServer
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddGovernanceAllowlists : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AppAllowedCaches",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
AppId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
EnvironmentId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
RedisClusterId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AppAllowedCaches", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppAllowedCaches_Apps_AppId",
|
||||||
|
column: x => x.AppId,
|
||||||
|
principalTable: "Apps",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppAllowedCaches_Environments_EnvironmentId",
|
||||||
|
column: x => x.EnvironmentId,
|
||||||
|
principalTable: "Environments",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppAllowedCaches_RedisClusters_RedisClusterId",
|
||||||
|
column: x => x.RedisClusterId,
|
||||||
|
principalTable: "RedisClusters",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AppAllowedDatabases",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
AppId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
EnvironmentId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
CnpgDatabaseId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
MongoDatabaseId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
RegisteredPostgresDatabaseId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AppAllowedDatabases", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppAllowedDatabases_Apps_AppId",
|
||||||
|
column: x => x.AppId,
|
||||||
|
principalTable: "Apps",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppAllowedDatabases_CnpgDatabases_CnpgDatabaseId",
|
||||||
|
column: x => x.CnpgDatabaseId,
|
||||||
|
principalTable: "CnpgDatabases",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppAllowedDatabases_Environments_EnvironmentId",
|
||||||
|
column: x => x.EnvironmentId,
|
||||||
|
principalTable: "Environments",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppAllowedDatabases_MongoDatabases_MongoDatabaseId",
|
||||||
|
column: x => x.MongoDatabaseId,
|
||||||
|
principalTable: "MongoDatabases",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppAllowedDatabases_RegisteredPostgresDatabases_RegisteredPostgresDatabaseId",
|
||||||
|
column: x => x.RegisteredPostgresDatabaseId,
|
||||||
|
principalTable: "RegisteredPostgresDatabases",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AppAllowedStorages",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
AppId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
EnvironmentId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
StorageLinkId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AppAllowedStorages", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppAllowedStorages_Apps_AppId",
|
||||||
|
column: x => x.AppId,
|
||||||
|
principalTable: "Apps",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppAllowedStorages_Environments_EnvironmentId",
|
||||||
|
column: x => x.EnvironmentId,
|
||||||
|
principalTable: "Environments",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppAllowedStorages_StorageLinks_StorageLinkId",
|
||||||
|
column: x => x.StorageLinkId,
|
||||||
|
principalTable: "StorageLinks",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppAllowedCaches_AppId",
|
||||||
|
table: "AppAllowedCaches",
|
||||||
|
column: "AppId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppAllowedCaches_EnvironmentId",
|
||||||
|
table: "AppAllowedCaches",
|
||||||
|
column: "EnvironmentId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppAllowedCaches_RedisClusterId",
|
||||||
|
table: "AppAllowedCaches",
|
||||||
|
column: "RedisClusterId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppAllowedDatabases_AppId",
|
||||||
|
table: "AppAllowedDatabases",
|
||||||
|
column: "AppId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppAllowedDatabases_CnpgDatabaseId",
|
||||||
|
table: "AppAllowedDatabases",
|
||||||
|
column: "CnpgDatabaseId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppAllowedDatabases_EnvironmentId",
|
||||||
|
table: "AppAllowedDatabases",
|
||||||
|
column: "EnvironmentId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppAllowedDatabases_MongoDatabaseId",
|
||||||
|
table: "AppAllowedDatabases",
|
||||||
|
column: "MongoDatabaseId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppAllowedDatabases_RegisteredPostgresDatabaseId",
|
||||||
|
table: "AppAllowedDatabases",
|
||||||
|
column: "RegisteredPostgresDatabaseId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppAllowedStorages_AppId",
|
||||||
|
table: "AppAllowedStorages",
|
||||||
|
column: "AppId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppAllowedStorages_EnvironmentId",
|
||||||
|
table: "AppAllowedStorages",
|
||||||
|
column: "EnvironmentId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppAllowedStorages_StorageLinkId",
|
||||||
|
table: "AppAllowedStorages",
|
||||||
|
column: "StorageLinkId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AppAllowedCaches");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AppAllowedDatabases");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AppAllowedStorages");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4663
src/EntKube.Web/Data/Migrations/SqlServer/20260609200000_AddCredentialUrlPattern.Designer.cs
generated
Normal file
4663
src/EntKube.Web/Data/Migrations/SqlServer/20260609200000_AddCredentialUrlPattern.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.SqlServer
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddCredentialUrlPattern : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "UrlPattern",
|
||||||
|
table: "CustomerGitCredentials",
|
||||||
|
type: "nvarchar(500)",
|
||||||
|
maxLength: 500,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "UrlPattern",
|
||||||
|
table: "CustomerGitCredentials");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.SqlServer
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddDeploymentRouteApplied : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "ClusterAppliedAt",
|
||||||
|
table: "AppDeploymentRoutes",
|
||||||
|
type: "datetime2",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ClusterAppliedAt",
|
||||||
|
table: "AppDeploymentRoutes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.SqlServer
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddVaultSecretVersions : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "UpdatedBy",
|
||||||
|
table: "VaultSecrets",
|
||||||
|
type: "nvarchar(254)",
|
||||||
|
maxLength: 254,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "VaultSecretVersions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
SecretId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
VersionNumber = table.Column<int>(type: "int", nullable: false),
|
||||||
|
EncryptedValue = table.Column<byte[]>(type: "varbinary(max)", nullable: false),
|
||||||
|
Nonce = table.Column<byte[]>(type: "varbinary(max)", nullable: false),
|
||||||
|
CreatedBy = table.Column<string>(type: "nvarchar(254)", maxLength: 254, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_VaultSecretVersions", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_VaultSecretVersions_VaultSecrets_SecretId",
|
||||||
|
column: x => x.SecretId,
|
||||||
|
principalTable: "VaultSecrets",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_VaultSecretVersions_SecretId_VersionNumber",
|
||||||
|
table: "VaultSecretVersions",
|
||||||
|
columns: new[] { "SecretId", "VersionNumber" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "VaultSecretVersions");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "UpdatedBy",
|
||||||
|
table: "VaultSecrets");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
using System;
|
||||||
|
using EntKube.Web.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.SqlServer
|
||||||
|
{
|
||||||
|
[DbContext(typeof(SqlServerApplicationDbContext))]
|
||||||
|
[Migration("20260611000000_AddOpsTeamFeatures")]
|
||||||
|
public partial class AddOpsTeamFeatures : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "AssignedTo",
|
||||||
|
table: "AlertIncidents",
|
||||||
|
type: "nvarchar(256)",
|
||||||
|
maxLength: 256,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "AssignedAt",
|
||||||
|
table: "AlertIncidents",
|
||||||
|
type: "datetime2",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "OnCallSchedules",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
TenantId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||||
|
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||||
|
IsEnabled = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_OnCallSchedules", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_OnCallSchedules_Tenants_TenantId",
|
||||||
|
column: x => x.TenantId,
|
||||||
|
principalTable: "Tenants",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "OnCallShifts",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
ScheduleId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
AssigneeName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||||
|
AssigneeEmail = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
|
||||||
|
StartsAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
EndsAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
Notes = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_OnCallShifts", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_OnCallShifts_OnCallSchedules_ScheduleId",
|
||||||
|
column: x => x.ScheduleId,
|
||||||
|
principalTable: "OnCallSchedules",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AlertRoutingRules",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
TenantId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||||
|
Priority = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
|
||||||
|
ChannelId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
MatchAlertName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||||
|
MatchNamespace = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||||
|
MatchSeverity = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
|
||||||
|
MatchLabelKey = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||||
|
MatchLabelValue = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||||
|
IsEnabled = table.Column<bool>(type: "bit", nullable: false, defaultValue: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AlertRoutingRules", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AlertRoutingRules_Tenants_TenantId",
|
||||||
|
column: x => x.TenantId,
|
||||||
|
principalTable: "Tenants",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AlertRoutingRules_NotificationChannels_ChannelId",
|
||||||
|
column: x => x.ChannelId,
|
||||||
|
principalTable: "NotificationChannels",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_OnCallShifts_ScheduleId",
|
||||||
|
table: "OnCallShifts",
|
||||||
|
column: "ScheduleId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_OnCallShifts_StartsAt",
|
||||||
|
table: "OnCallShifts",
|
||||||
|
column: "StartsAt");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AlertRoutingRules_TenantId",
|
||||||
|
table: "AlertRoutingRules",
|
||||||
|
column: "TenantId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AlertRoutingRules_ChannelId",
|
||||||
|
table: "AlertRoutingRules",
|
||||||
|
column: "ChannelId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AlertRoutingRules");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "OnCallShifts");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "OnCallSchedules");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AssignedTo",
|
||||||
|
table: "AlertIncidents");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AssignedAt",
|
||||||
|
table: "AlertIncidents");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using EntKube.Web.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.SqlServer
|
||||||
|
{
|
||||||
|
[DbContext(typeof(SqlServerApplicationDbContext))]
|
||||||
|
[Migration("20260611010000_AddAlertIncidentRunbookUrl")]
|
||||||
|
public partial class AddAlertIncidentRunbookUrl : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "RunbookUrl",
|
||||||
|
table: "AlertIncidents",
|
||||||
|
type: "nvarchar(500)",
|
||||||
|
maxLength: 500,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "RunbookUrl",
|
||||||
|
table: "AlertIncidents");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using System;
|
||||||
|
using EntKube.Web.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.SqlServer
|
||||||
|
{
|
||||||
|
[DbContext(typeof(SqlServerApplicationDbContext))]
|
||||||
|
[Migration("20260611020000_AddAlertIncidentEscalation")]
|
||||||
|
public partial class AddAlertIncidentEscalation : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "EscalatedAt",
|
||||||
|
table: "AlertIncidents",
|
||||||
|
type: "datetime2",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AlertIncidents_EscalatedAt",
|
||||||
|
table: "AlertIncidents",
|
||||||
|
column: "EscalatedAt");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_AlertIncidents_EscalatedAt",
|
||||||
|
table: "AlertIncidents");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "EscalatedAt",
|
||||||
|
table: "AlertIncidents");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.SqlServer
|
||||||
|
{
|
||||||
|
public partial class AddDeploymentGitUrl : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "GitUrl",
|
||||||
|
table: "AppDeployments",
|
||||||
|
type: "nvarchar(2000)",
|
||||||
|
maxLength: 2000,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "GitUrl",
|
||||||
|
table: "AppDeployments");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.SqlServer
|
||||||
|
{
|
||||||
|
public partial class AddNotificationProviderConfig : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "NotificationProviderConfigs",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
ProviderType = table.Column<string>(type: "nvarchar(30)", maxLength: 30, nullable: false),
|
||||||
|
ConfigurationJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
IsEnabled = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UpdatedByUserId = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_NotificationProviderConfigs", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_NotificationProviderConfigs_ProviderType",
|
||||||
|
table: "NotificationProviderConfigs",
|
||||||
|
column: "ProviderType",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "NotificationProviderConfigs");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
using System;
|
||||||
|
using EntKube.Web.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.SqlServer
|
||||||
|
{
|
||||||
|
[DbContext(typeof(SqlServerApplicationDbContext))]
|
||||||
|
[Migration("20260611060000_AddAlertRoutingSuppression")]
|
||||||
|
public partial class AddAlertRoutingSuppression : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// Drop existing FK, make ChannelId nullable, re-add FK
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_AlertRoutingRules_NotificationChannels_ChannelId",
|
||||||
|
table: "AlertRoutingRules");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<Guid>(
|
||||||
|
name: "ChannelId",
|
||||||
|
table: "AlertRoutingRules",
|
||||||
|
type: "uniqueidentifier",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(Guid),
|
||||||
|
oldType: "uniqueidentifier");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_AlertRoutingRules_NotificationChannels_ChannelId",
|
||||||
|
table: "AlertRoutingRules",
|
||||||
|
column: "ChannelId",
|
||||||
|
principalTable: "NotificationChannels",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
|
||||||
|
// Add new columns
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "SuppressIncident",
|
||||||
|
table: "AlertRoutingRules",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "MatchClusterId",
|
||||||
|
table: "AlertRoutingRules",
|
||||||
|
type: "uniqueidentifier",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AlertRoutingRules_MatchClusterId",
|
||||||
|
table: "AlertRoutingRules",
|
||||||
|
column: "MatchClusterId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_AlertRoutingRules_KubernetesClusters_MatchClusterId",
|
||||||
|
table: "AlertRoutingRules",
|
||||||
|
column: "MatchClusterId",
|
||||||
|
principalTable: "KubernetesClusters",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_AlertRoutingRules_KubernetesClusters_MatchClusterId",
|
||||||
|
table: "AlertRoutingRules");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_AlertRoutingRules_MatchClusterId",
|
||||||
|
table: "AlertRoutingRules");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(name: "MatchClusterId", table: "AlertRoutingRules");
|
||||||
|
migrationBuilder.DropColumn(name: "SuppressIncident", table: "AlertRoutingRules");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_AlertRoutingRules_NotificationChannels_ChannelId",
|
||||||
|
table: "AlertRoutingRules");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<Guid>(
|
||||||
|
name: "ChannelId",
|
||||||
|
table: "AlertRoutingRules",
|
||||||
|
type: "uniqueidentifier",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(Guid),
|
||||||
|
oldType: "uniqueidentifier",
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_AlertRoutingRules_NotificationChannels_ChannelId",
|
||||||
|
table: "AlertRoutingRules",
|
||||||
|
column: "ChannelId",
|
||||||
|
principalTable: "NotificationChannels",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5240
src/EntKube.Web/Data/Migrations/SqlServer/20260611122526_AddClusterServers.Designer.cs
generated
Normal file
5240
src/EntKube.Web/Data/Migrations/SqlServer/20260611122526_AddClusterServers.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,236 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.SqlServer
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddClusterServers : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_OnCallSchedules_TenantId",
|
||||||
|
table: "OnCallSchedules");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "UpdatedBy",
|
||||||
|
table: "VaultSecrets",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<bool>(
|
||||||
|
name: "IsEnabled",
|
||||||
|
table: "OnCallSchedules",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(bool),
|
||||||
|
oldType: "bit",
|
||||||
|
oldDefaultValue: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "GitUrl",
|
||||||
|
table: "AppDeployments",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "nvarchar(2000)",
|
||||||
|
oldMaxLength: 2000,
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<bool>(
|
||||||
|
name: "SuppressIncident",
|
||||||
|
table: "AlertRoutingRules",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(bool),
|
||||||
|
oldType: "bit",
|
||||||
|
oldDefaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<int>(
|
||||||
|
name: "Priority",
|
||||||
|
table: "AlertRoutingRules",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(int),
|
||||||
|
oldType: "int",
|
||||||
|
oldDefaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<bool>(
|
||||||
|
name: "IsEnabled",
|
||||||
|
table: "AlertRoutingRules",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(bool),
|
||||||
|
oldType: "bit",
|
||||||
|
oldDefaultValue: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "RunbookUrl",
|
||||||
|
table: "AlertIncidents",
|
||||||
|
type: "nvarchar(500)",
|
||||||
|
maxLength: 500,
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "nvarchar(500)",
|
||||||
|
oldMaxLength: 500,
|
||||||
|
oldDefaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ClusterServers",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
ClusterId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
NodeName = table.Column<string>(type: "nvarchar(253)", maxLength: 253, nullable: true),
|
||||||
|
DisplayName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||||
|
IpAddress = table.Column<string>(type: "nvarchar(45)", maxLength: 45, nullable: true),
|
||||||
|
ManagementIpAddress = table.Column<string>(type: "nvarchar(45)", maxLength: 45, nullable: true),
|
||||||
|
Provider = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
||||||
|
OsDistribution = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||||
|
CpuCores = table.Column<int>(type: "int", nullable: true),
|
||||||
|
RamGb = table.Column<int>(type: "int", nullable: true),
|
||||||
|
DiskGb = table.Column<int>(type: "int", nullable: true),
|
||||||
|
Location = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||||
|
SshUser = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||||
|
SshPort = table.Column<int>(type: "int", nullable: false),
|
||||||
|
JumpHost = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||||
|
Notes = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ClusterServers", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ClusterServers_KubernetesClusters_ClusterId",
|
||||||
|
column: x => x.ClusterId,
|
||||||
|
principalTable: "KubernetesClusters",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "VaultSecretVersions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
SecretId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
VersionNumber = table.Column<int>(type: "int", nullable: false),
|
||||||
|
EncryptedValue = table.Column<byte[]>(type: "varbinary(max)", nullable: false),
|
||||||
|
Nonce = table.Column<byte[]>(type: "varbinary(max)", nullable: false),
|
||||||
|
CreatedBy = table.Column<string>(type: "nvarchar(254)", maxLength: 254, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_VaultSecretVersions", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_VaultSecretVersions_VaultSecrets_SecretId",
|
||||||
|
column: x => x.SecretId,
|
||||||
|
principalTable: "VaultSecrets",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_OnCallSchedules_TenantId_Name",
|
||||||
|
table: "OnCallSchedules",
|
||||||
|
columns: new[] { "TenantId", "Name" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ClusterServers_ClusterId_DisplayName",
|
||||||
|
table: "ClusterServers",
|
||||||
|
columns: new[] { "ClusterId", "DisplayName" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_VaultSecretVersions_SecretId_VersionNumber",
|
||||||
|
table: "VaultSecretVersions",
|
||||||
|
columns: new[] { "SecretId", "VersionNumber" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ClusterServers");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "VaultSecretVersions");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_OnCallSchedules_TenantId_Name",
|
||||||
|
table: "OnCallSchedules");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "UpdatedBy",
|
||||||
|
table: "VaultSecrets");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<bool>(
|
||||||
|
name: "IsEnabled",
|
||||||
|
table: "OnCallSchedules",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: true,
|
||||||
|
oldClrType: typeof(bool),
|
||||||
|
oldType: "bit");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "GitUrl",
|
||||||
|
table: "AppDeployments",
|
||||||
|
type: "nvarchar(2000)",
|
||||||
|
maxLength: 2000,
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "nvarchar(max)",
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<bool>(
|
||||||
|
name: "SuppressIncident",
|
||||||
|
table: "AlertRoutingRules",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false,
|
||||||
|
oldClrType: typeof(bool),
|
||||||
|
oldType: "bit");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<int>(
|
||||||
|
name: "Priority",
|
||||||
|
table: "AlertRoutingRules",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0,
|
||||||
|
oldClrType: typeof(int),
|
||||||
|
oldType: "int");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<bool>(
|
||||||
|
name: "IsEnabled",
|
||||||
|
table: "AlertRoutingRules",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: true,
|
||||||
|
oldClrType: typeof(bool),
|
||||||
|
oldType: "bit");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "RunbookUrl",
|
||||||
|
table: "AlertIncidents",
|
||||||
|
type: "nvarchar(500)",
|
||||||
|
maxLength: 500,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "",
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "nvarchar(500)",
|
||||||
|
oldMaxLength: 500);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_OnCallSchedules_TenantId",
|
||||||
|
table: "OnCallSchedules",
|
||||||
|
column: "TenantId");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
4580
src/EntKube.Web/Data/Migrations/Sqlite/20260609100644_AddAppRoutes.Designer.cs
generated
Normal file
4580
src/EntKube.Web/Data/Migrations/Sqlite/20260609100644_AddAppRoutes.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EntKube.Web.Data.Migrations.Sqlite
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAppRoutes : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AppRoutes",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
AppId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
Hostname = table.Column<string>(type: "TEXT", maxLength: 253, nullable: false),
|
||||||
|
TlsMode = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
|
||||||
|
ClusterIssuerName = table.Column<string>(type: "TEXT", maxLength: 200, nullable: true),
|
||||||
|
TlsCertificate = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
TlsPrivateKey = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
IsEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AppRoutes", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppRoutes_Apps_AppId",
|
||||||
|
column: x => x.AppId,
|
||||||
|
principalTable: "Apps",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AppDeploymentRoutes",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
AppRouteId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
AppDeploymentId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
PathPrefix = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||||
|
ServiceName = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||||
|
ServicePort = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
GatewayName = table.Column<string>(type: "TEXT", maxLength: 200, nullable: true),
|
||||||
|
GatewayNamespace = table.Column<string>(type: "TEXT", maxLength: 63, nullable: true),
|
||||||
|
IsEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||||
|
LastHealthCheckAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||||
|
LastStatusCode = table.Column<int>(type: "INTEGER", nullable: true),
|
||||||
|
IsReachable = table.Column<bool>(type: "INTEGER", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AppDeploymentRoutes", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppDeploymentRoutes_AppDeployments_AppDeploymentId",
|
||||||
|
column: x => x.AppDeploymentId,
|
||||||
|
principalTable: "AppDeployments",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AppDeploymentRoutes_AppRoutes_AppRouteId",
|
||||||
|
column: x => x.AppRouteId,
|
||||||
|
principalTable: "AppRoutes",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppDeploymentRoutes_AppDeploymentId",
|
||||||
|
table: "AppDeploymentRoutes",
|
||||||
|
column: "AppDeploymentId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppDeploymentRoutes_AppRouteId",
|
||||||
|
table: "AppDeploymentRoutes",
|
||||||
|
column: "AppRouteId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppRoutes_AppId",
|
||||||
|
table: "AppRoutes",
|
||||||
|
column: "AppId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AppDeploymentRoutes");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AppRoutes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user