server management
Some checks failed
Build and Deploy / Build and push image (push) Failing after 5m50s
Build and Deploy / Deploy to server (push) Has been skipped

This commit is contained in:
Nils Blomgren
2026-06-11 15:09:31 +02:00
parent 343de77810
commit 5da40698ad
152 changed files with 121747 additions and 3884 deletions

View File

@@ -13,19 +13,33 @@ RUN dotnet restore src/EntKube.Web/EntKube.Web.csproj
COPY src/ src/
RUN dotnet publish src/EntKube.Web/EntKube.Web.csproj \
--no-restore \
-c Release \
-o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
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 \
ca-certificates \
curl \
libssl3 \
libcurl4 \
git \
openssh-client \
&& 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 groupadd --system appgroup && useradd --system --gid appgroup --no-create-home appuser
RUN mkdir -p /app/Data && chown appuser:appgroup /app/Data

View File

@@ -18,8 +18,10 @@
<body>
<Routes />
<ReconnectModal />
<script src="@Assets["lib/bootstrap/dist/js/bootstrap.bundle.min.js"]"></script>
<script src="@Assets["_framework/blazor.web.js"]"></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>
</html>

View File

@@ -59,6 +59,11 @@
<span class="bi bi-arrow-repeat-nav-menu" aria-hidden="true"></span> Backup
</NavLink>
</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>
</AuthorizeView>

View File

@@ -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;
}
}
}

View File

@@ -40,6 +40,12 @@ else if (user is not null)
<button type="button" class="btn-close btn-close-sm" @onclick="() => errorMessage = null"></button>
</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">
@@ -102,20 +108,25 @@ else if (user is not null)
</td>
<td class="align-middle text-muted small">@m.JoinedAt.ToString("MMM d, yyyy")</td>
<td class="align-middle text-end">
@if (confirmRemoveTenantId == m.TenantId)
{
<span class="me-2 small text-danger">Remove?</span>
<button class="btn btn-sm btn-danger me-1" @onclick="() => RemoveTenantMembership(m.TenantId)">Yes</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>
}
<button class="btn btn-sm btn-outline-danger"
@onclick="() => confirmRemoveTenantId = m.TenantId">
<i class="bi bi-trash"></i>
</button>
</td>
</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>
</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.JoinedAt.ToString("MMM d, yyyy")</td>
<td class="align-middle text-end">
@if (confirmRemoveGroupId == gm.GroupId)
{
<span class="me-2 small text-danger">Remove?</span>
<button class="btn btn-sm btn-danger me-1" @onclick="() => RemoveGroupMembership(gm.GroupId)">Yes</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>
}
<button class="btn btn-sm btn-outline-danger"
@onclick="() => confirmRemoveGroupId = gm.GroupId">
<i class="bi bi-trash"></i>
</button>
</td>
</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>
</table>
@@ -260,20 +276,16 @@ else if (user is not null)
<div class="card-body">
<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>
@if (confirmDelete)
{
<div class="alert alert-danger py-2 small mb-3">
<strong>Are you sure?</strong> This will permanently delete <strong>@user.Email</strong>.
</div>
<button class="btn btn-danger me-2" @onclick="DeleteUser">Delete Permanently</button>
<button class="btn btn-outline-secondary" @onclick="() => confirmDelete = false">Cancel</button>
}
else
{
<button class="btn btn-outline-danger" @onclick="() => confirmDelete = true">
<i class="bi bi-trash me-1"></i>Delete User
</button>
}
<button class="btn btn-outline-danger" @onclick="() => confirmDelete = true"
hidden="@confirmDelete">
<i class="bi bi-trash me-1"></i>Delete User
</button>
<ConfirmDialog Visible="confirmDelete"
Message="@($"Permanently delete {user.Email}? All memberships are removed and this cannot be undone.")"
ConfirmText="Delete Permanently"
IsBusy="deletingUser"
OnConfirm="DeleteUser"
OnCancel="() => confirmDelete = false" />
</div>
</div>
</div>
@@ -330,6 +342,9 @@ else if (user is not null)
private Guid? confirmRemoveTenantId;
private Guid? confirmRemoveGroupId;
private bool confirmDelete;
private bool removingMembership;
private bool deletingUser;
private string? successMessage;
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)
{
confirmRemoveTenantId = null;
removingMembership = true;
errorMessage = null;
string? tenantName = tenantMemberships.FirstOrDefault(m => m.TenantId == tenantId)?.Tenant.Name;
await UserManagement.RemoveTenantMembershipAsync(UserId, tenantId);
tenantMemberships = await UserManagement.GetUserTenantMembershipsAsync(UserId);
RefreshAvailableTenants();
removingMembership = false;
confirmRemoveTenantId = null;
if (tenantName is not null) _ = ShowSuccessAsync($"Removed from tenant '{tenantName}'.");
}
private async Task AddGroupMembership()
@@ -440,17 +468,24 @@ else if (user is not null)
private async Task RemoveGroupMembership(Guid groupId)
{
confirmRemoveGroupId = null;
removingMembership = true;
errorMessage = null;
string? groupName = groupMemberships.FirstOrDefault(gm => gm.GroupId == groupId)?.Group.Name;
await UserManagement.RemoveGroupMembershipAsync(UserId, groupId);
groupMemberships = await UserManagement.GetUserGroupMembershipsAsync(UserId);
await LoadGroupsForSelectedTenant();
removingMembership = false;
confirmRemoveGroupId = null;
if (groupName is not null) _ = ShowSuccessAsync($"Removed from group '{groupName}'.");
}
private async Task DeleteUser()
{
deletingUser = true;
IdentityResult result = await UserManagement.DeleteUserAsync(UserId);
deletingUser = false;
if (!result.Succeeded)
{
errorMessage = string.Join(" ", result.Errors.Select(e => e.Description));

View File

@@ -1,6 +1,7 @@
@using EntKube.Web.Data
@using EntKube.Web.Services
@inject AppGovernanceService GovernanceService
@inject CustomerGitService CustomerGitService
@* ═══════════════════════════════════════════════════════════════════
AppGovernancePanel — read-only portal view of an app's governance
@@ -23,17 +24,21 @@ else
</div>
@* Namespace *@
<div class="mb-3">
<label class="form-label small fw-medium">Kubernetes Namespace</label>
@if (!string.IsNullOrWhiteSpace(data?.Namespace))
{
<input class="form-control form-control-sm font-monospace" value="@data.Namespace" disabled />
}
else
{
<input class="form-control form-control-sm text-muted fst-italic" value="Not assigned" disabled />
}
</div>
@if (!string.IsNullOrWhiteSpace(data?.Namespace))
{
<div class="card shadow-sm mb-3">
<div class="card-header bg-white py-2">
<i class="bi bi-box me-2 text-primary"></i>
<strong class="small">Namespace</strong>
</div>
<div class="card-body py-2">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-lock-fill text-warning small"></i>
<span class="small">Your app runs in namespace <code>@data.Namespace</code>.</span>
</div>
</div>
</div>
}
@* Quota *@
<div class="card shadow-sm mb-3">
@@ -166,19 +171,122 @@ else
}
</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 {
[Parameter, EditorRequired] public Guid AppId { 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 List<CustomerGitRepoPolicy> gitPolicies = [];
private bool loading = true;
protected override async Task OnParametersSetAsync()
{
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;
}
@@ -198,4 +306,20 @@ else
AppNetworkPolicyType.AllowFromIngress or AppNetworkPolicyType.AllowFromNamespace or AppNetworkPolicyType.AllowFromSameNamespace => "bi-check-circle",
_ => "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

View File

@@ -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();
}
};
}

View File

@@ -2,11 +2,13 @@
@using EntKube.Web.Services
@using Microsoft.EntityFrameworkCore
@implements IAsyncDisposable
@inject IJSRuntime JS
@inject IncidentService IncidentService
@inject LokiService LokiService
@inject GitSyncService GitSyncService
@inject CustomerGitService CustomerGitService
@inject IDbContextFactory<ApplicationDbContext> DbFactory
@inject DeploymentStatusNotifier StatusNotifier
@* ═══════════════════════════════════════════════════════════════════
Deployment operations view — the heart of the customer portal.
@@ -134,13 +136,13 @@
</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="small text-muted">
<i class="bi bi-git me-1"></i>
<strong>@Deployment.GitRepository?.Name</strong>
<span class="ms-1">@(Deployment.GitRevision ?? Deployment.GitRepository?.DefaultBranch)</span>
<strong class="font-monospace">@Deployment.GitUrl</strong>
<span class="ms-1">@(Deployment.GitRevision ?? "main")</span>
@if (!string.IsNullOrEmpty(Deployment.GitPath) && Deployment.GitPath != ".")
{
<span class="ms-1">→ <code>@Deployment.GitPath</code></span>
@@ -257,15 +259,49 @@
</div>
}
@* --- Operation buttons (role-gated) --- *@
@if (AccessRole >= CustomerAccessRole.Operator)
{
<div class="d-flex gap-2 mb-3">
@* --- Operation bar (refresh always visible; destructive ops gated to Operator+) --- *@
<div class="d-flex align-items-center gap-2 mb-3 flex-wrap">
@if (AccessRole >= CustomerAccessRole.Operator)
{
<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>
</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">
@if (!podsLoading && string.IsNullOrEmpty(podsError) && (pods is null || pods.Count == 0))
@@ -279,7 +315,7 @@
@if (showLogViewer)
{
<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>
<i class="bi bi-terminal me-1"></i>
Logs: <strong>@logPodName</strong>
@@ -288,9 +324,24 @@
<span class="text-muted ms-1">(@logContainerName)</span>
}
</div>
<button class="btn btn-sm btn-outline-light" @onclick="CloseLogViewer">
<i class="bi bi-x"></i>
</button>
<div class="d-flex gap-1 ms-auto align-items-center">
@foreach (int tl in new[] { 100, 500, 1000 })
{
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 class="card-body p-0">
@if (logLoading)
@@ -305,14 +356,42 @@
}
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>
}
@* --- 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 --- *@
@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-body py-2">
@@ -325,7 +404,14 @@
<span class="text-muted small ms-2">@pod.ReadyContainers/@pod.TotalContainers ready</span>
@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 class="d-flex gap-1">
@@ -393,7 +479,7 @@
<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>
<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")">
@container.State
</span>
@@ -406,12 +492,6 @@
</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>
}
@@ -686,7 +766,7 @@
}
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)
{
@@ -921,18 +1001,18 @@
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showGitEdit = false">Cancel</button>
</div>
}
else if (Deployment.GitRepositoryId is not null)
else if (Deployment.GitUrl is not null)
{
@* ── Current settings (read) ── *@
<dl class="row small mb-0">
<dt class="col-sm-3">Repository</dt>
<dd class="col-sm-9 font-monospace">@(Deployment.GitRepository?.Url ?? "—")</dd>
<dd class="col-sm-9 font-monospace">@Deployment.GitUrl</dd>
<dt class="col-sm-3">Path</dt>
<dd class="col-sm-9 font-monospace">@(Deployment.GitPath ?? ".")</dd>
<dt class="col-sm-3">Revision</dt>
<dd class="col-sm-9 font-monospace">@(Deployment.GitRevision ?? Deployment.GitRepository?.DefaultBranch ?? "main")</dd>
<dd class="col-sm-9 font-monospace">@(Deployment.GitRevision ?? "main")</dd>
<dt class="col-sm-3">Auto-sync</dt>
<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 {
[Parameter] public required AppDeployment Deployment { get; set; }
[Parameter] public CustomerAccessRole AccessRole { get; set; }
@@ -1003,6 +1088,7 @@
[Parameter] public required AuditService AuditService { get; set; }
[Parameter] public Guid CustomerId { get; set; }
[Parameter] public Guid TenantId { get; set; }
[Parameter] public Guid EnvironmentId { get; set; }
[Parameter] public string? GovernanceNamespace { get; set; }
[Parameter] public EventCallback OnBack { get; set; }
[Parameter] public EventCallback OnDeleted { get; set; }
@@ -1035,16 +1121,21 @@
try
{
GitSyncResult result = await GitSyncService.SyncDeploymentAsync(Deployment.Id);
gitSyncSuccess = result.IsSuccess;
gitSyncMessage = result.AlreadyCurrent
? "Already up to date."
: result.IsSuccess
? $"Synced to {result.CommitSha[..Math.Min(7, result.CommitSha.Length)]} — {result.CommitMessage}"
: result.Error ?? "Sync failed.";
gitSyncSuccess = result.IsSuccess && result.ApplySuccess;
if (result.AlreadyCurrent)
gitSyncMessage = "Already up to date.";
else if (!result.IsSuccess)
gitSyncMessage = 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)
{
Deployment.GitLastSyncedCommit = result.CommitSha;
Deployment.GitLastSyncedAt = DateTime.UtcNow;
if (manifests is not null)
manifests = await DeploymentService.GetManifestsAsync(Deployment.Id);
}
}
catch (Exception ex) { gitSyncSuccess = false; gitSyncMessage = ex.Message; }
@@ -1056,16 +1147,16 @@
private async Task LoadGitTab()
{
activeTab = "git";
if (gitTabPolicies.Count == 0 && CustomerId != Guid.Empty)
if (gitTabPolicies.Count == 0 && CustomerId != Guid.Empty && EnvironmentId != Guid.Empty)
{
gitTabPolicies = await CustomerGitService.GetRepoPoliciesAsync(CustomerId);
gitTabCredentials = await CustomerGitService.GetCredentialsAsync(CustomerId);
gitTabPolicies = await CustomerGitService.GetRepoPoliciesAsync(CustomerId, EnvironmentId);
gitTabCredentials = await CustomerGitService.GetCredentialsAsync(CustomerId, EnvironmentId);
}
}
private void StartGitEdit()
{
gitEditUrl = Deployment.GitRepository?.Url ?? "";
gitEditUrl = Deployment.GitUrl ?? "";
gitEditPath = Deployment.GitPath ?? "";
gitEditRevision = Deployment.GitRevision ?? "";
gitEditAutoSync = Deployment.GitAutoSync;
@@ -1100,40 +1191,16 @@
return;
}
Guid? repoId = null;
if (!string.IsNullOrWhiteSpace(gitEditUrl))
{
CustomerGitCredential? cred = gitTabCredentials.FirstOrDefault();
if (cred is not null)
{
GitRepository repo = await CustomerGitService.FindOrCreateRepositoryForCustomerAsync(
TenantId, CustomerId, gitEditUrl, cred.Id);
repoId = repo.Id;
}
else
{
// No customer credential — check if existing repo already has this URL.
using ApplicationDbContext db = DbFactory.CreateDbContext();
GitRepository? existing = await db.GitRepositories
.FirstOrDefaultAsync(r => r.TenantId == TenantId && r.Url == gitEditUrl);
repoId = existing?.Id;
if (repoId is null)
{
gitEditError = "No stored credential found. Ask your administrator to add a git credential for your account.";
return;
}
}
}
string? resolvedUrl = string.IsNullOrWhiteSpace(gitEditUrl) ? null : gitEditUrl.Trim();
await DeploymentService.UpdateGitSettingsAsync(
Deployment.Id, repoId,
Deployment.Id, resolvedUrl,
string.IsNullOrWhiteSpace(gitEditPath) ? null : gitEditPath,
string.IsNullOrWhiteSpace(gitEditRevision) ? null : gitEditRevision,
gitEditAutoSync);
// Update local state so the UI reflects the change without reload.
Deployment.GitRepositoryId = repoId;
Deployment.GitUrl = resolvedUrl;
Deployment.GitPath = string.IsNullOrWhiteSpace(gitEditPath) ? null : gitEditPath;
Deployment.GitRevision = string.IsNullOrWhiteSpace(gitEditRevision) ? null : gitEditRevision;
Deployment.GitAutoSync = gitEditAutoSync;
@@ -1162,6 +1229,8 @@
private string? operationMessage;
private bool operationSuccess;
private string? confirmDeletePod;
private int _scaleReplicas = 1;
private bool _scaleInitialized;
// Logs
private bool showLogViewer;
@@ -1170,6 +1239,25 @@
private string? logContainerName;
private string logContent = "";
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
private List<DeploymentManifest>? manifests;
@@ -1213,6 +1301,8 @@
private List<DeploymentResource>? resourceTree;
private string? resourcesError;
private DateTime? _resourcesRefreshedAt;
private PeriodicTimer? _resourceTimer;
private CancellationTokenSource? _resourceTimerCts;
// Metrics
private DeploymentMetricsSummary? metrics;
@@ -1233,15 +1323,39 @@
protected override async Task OnInitializedAsync()
{
StatusNotifier.OnStatusChanged += HandleDeploymentStatusChanged;
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 ────────
private async Task LoadPods()
{
StopResourceRefresh();
activeTab = "pods";
await LoadPodsInternal();
}
@@ -1259,6 +1373,12 @@
{
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
// reflect actual cluster state rather than the DB default (Unknown).
(SyncStatus sync, HealthStatus health) =
@@ -1287,11 +1407,12 @@
StateHasChanged();
KubernetesOperationResult<string> result = await K8sOps.GetPodLogsAsync(
Deployment.Id, podName, containerName);
Deployment.Id, podName, containerName, _logTailLines);
if (result.IsSuccess)
{
logContent = result.Data ?? "(empty)";
_pendingLogScroll = true;
}
else
{
@@ -1301,6 +1422,15 @@
logLoading = false;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (_pendingLogScroll)
{
_pendingLogScroll = false;
try { await JS.InvokeVoidAsync("scrollToBottom", _logPreRef); } catch { }
}
}
private void CloseLogViewer()
{
showLogViewer = false;
@@ -1340,13 +1470,11 @@
if (failures.Count == 0)
{
operationMessage = $"Rolling restart triggered for {deploymentNames.Count} deployment(s).";
operationSuccess = true;
SetOperationMessage($"Rolling restart triggered for {deploymentNames.Count} deployment(s).", true);
}
else
{
operationMessage = $"Some restarts failed: {string.Join("; ", failures)}";
operationSuccess = false;
SetOperationMessage($"Some restarts failed: {string.Join("; ", failures)}", false);
}
operationInProgress = false;
@@ -1356,6 +1484,41 @@
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)
{
operationInProgress = true;
@@ -1364,16 +1527,11 @@
KubernetesOperationResult result = await K8sOps.DeletePodAsync(Deployment.Id, podName);
if (result.IsSuccess)
{
operationMessage = $"Pod '{podName}' deleted. Kubernetes will create a replacement.";
operationSuccess = true;
}
else
{
operationMessage = result.Error;
operationSuccess = false;
}
SetOperationMessage(
result.IsSuccess
? $"Pod '{podName}' deleted. Kubernetes will create a replacement."
: result.Error,
result.IsSuccess);
operationInProgress = false;
@@ -1461,6 +1619,7 @@
private async Task LoadManifests()
{
StopResourceRefresh();
activeTab = "manifests";
yamlApplyOutput = null;
yamlApplySuccess = null;
@@ -1575,11 +1734,20 @@
private async Task LoadResources()
{
StopResourceRefresh();
activeTab = "resources";
resourceTree = null; // trigger spinner
resourceTree = null;
resourcesError = null;
StateHasChanged();
await RefreshResourcesAsync();
_resourceTimerCts = new CancellationTokenSource();
_ = RunResourceAutoRefreshAsync(_resourceTimerCts.Token);
}
private async Task RefreshResourcesAsync()
{
KubernetesOperationResult<List<DeploymentResource>> result =
await K8sOps.GetLiveResourcesAsync(Deployment.Id);
@@ -1588,7 +1756,6 @@
resourceTree = result.Data ?? [];
_resourcesRefreshedAt = DateTime.UtcNow;
// Persist the live status so the header badges are accurate.
(SyncStatus sync, HealthStatus health) =
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 ────────
private async Task LoadMetrics()
{
StopResourceRefresh();
activeTab = "metrics";
metricsLoading = true;
metricsError = null;
@@ -1628,6 +1813,7 @@
private async Task LoadActivity()
{
StopResourceRefresh();
activeTab = "activity";
activityLoading = true;
auditEvents = null;
@@ -1639,6 +1825,7 @@
private async Task LoadMonitoring()
{
StopResourceRefresh();
activeTab = "monitoring";
monitoringLoading = true;
StateHasChanged();
@@ -1677,8 +1864,41 @@
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 ────────
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
{
"Running" => "text-success",
@@ -1697,6 +1917,12 @@
_ => "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
{
DeploymentType.Manual => @<span class="badge bg-info">Manual</span>,

View File

@@ -114,10 +114,19 @@
}
</div>
<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>
</button>
</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>
@@ -183,20 +192,31 @@
<div class="list-group">
@foreach (KeycloakIdpInfo idp in idps)
{
<div class="list-group-item d-flex justify-content-between align-items-center py-2">
<div>
<i class="bi bi-box-arrow-in-right me-2 text-muted"></i>
<strong>@idp.Alias</strong>
<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>
}
<div class="list-group-item py-2">
<div class="d-flex justify-content-between align-items-center">
<div>
<i class="bi bi-box-arrow-in-right me-2 text-muted"></i>
<strong>@idp.Alias</strong>
<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>
}
</div>
<button class="btn btn-sm btn-outline-danger border-0"
@onclick="() => confirmDeleteIdpAlias = idp.Alias">
<i class="bi bi-trash"></i>
</button>
</div>
<button class="btn btn-sm btn-outline-danger border-0"
@onclick="() => DeleteIdp(idp.Alias)">
<i class="bi bi-trash"></i>
</button>
@if (confirmDeleteIdpAlias == idp.Alias)
{
<ConfirmDialog Visible="true"
Message="@($"Delete identity provider '{idp.Alias}'?")"
ConfirmText="Delete"
IsBusy="deletingItem"
OnConfirm="() => DeleteIdp(idp.Alias)"
OnCancel="() => confirmDeleteIdpAlias = null" />
}
</div>
}
</div>
@@ -239,12 +259,23 @@
<div class="list-group">
@foreach (KeycloakGroupInfo g in groups)
{
<div class="list-group-item d-flex justify-content-between align-items-center py-2">
<span><i class="bi bi-people me-2 text-muted"></i>@g.Name <span class="text-muted small">@g.Path</span></span>
<button class="btn btn-sm btn-outline-danger border-0"
@onclick="() => DeleteGroup(g.Id)">
<i class="bi bi-trash"></i>
</button>
<div class="list-group-item py-2">
<div class="d-flex justify-content-between align-items-center">
<span><i class="bi bi-people me-2 text-muted"></i>@g.Name <span class="text-muted small">@g.Path</span></span>
<button class="btn btn-sm btn-outline-danger border-0"
@onclick="() => confirmDeleteGroupId = g.Id">
<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>
@@ -329,6 +360,12 @@
private bool loadingUsers, loadingIdps, loadingGroups, loadingOrgs;
// Delete confirm state
private string? confirmDeleteUserId;
private string? confirmDeleteIdpAlias;
private string? confirmDeleteGroupId;
private bool deletingItem;
// User form
private bool showAddUser, savingUser;
private string? userError;
@@ -418,12 +455,15 @@
private async Task DeleteUser(string userId)
{
deletingItem = true;
try
{
await KeycloakService.DeleteUserAsync(TenantId, Realm.Id, userId);
confirmDeleteUserId = null;
users = await KeycloakService.GetUsersAsync(TenantId, Realm.Id);
}
catch (Exception ex) { userError = ex.Message; }
finally { deletingItem = false; }
}
private async Task AddIdp()
@@ -445,12 +485,15 @@
private async Task DeleteIdp(string alias)
{
deletingItem = true;
try
{
await KeycloakService.DeleteIdpAsync(TenantId, Realm.Id, alias);
confirmDeleteIdpAlias = null;
idps = await KeycloakService.GetIdpsAsync(TenantId, Realm.Id);
}
catch (Exception ex) { idpError = ex.Message; }
finally { deletingItem = false; }
}
private async Task AddGroup()
@@ -470,12 +513,15 @@
private async Task DeleteGroup(string groupId)
{
deletingItem = true;
try
{
await KeycloakService.DeleteGroupAsync(TenantId, Realm.Id, groupId);
confirmDeleteGroupId = null;
groups = await KeycloakService.GetGroupsAsync(TenantId, Realm.Id);
}
catch (Exception ex) { groupError = ex.Message; }
finally { deletingItem = false; }
}
private async Task AddOrg()

View 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);
}

View 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; }
}

View File

@@ -1,6 +1,7 @@
@using EntKube.Web.Data
@using EntKube.Web.Services
@inject KubernetesOperationsService K8sOps
@inject IJSRuntime JS
@* ═══════════════════════════════════════════════════════════════════
ResourceTreePanel — proper ArgoCD-style graph.
@@ -138,7 +139,7 @@
.rg-panel {
position: absolute;
z-index: 200;
width: 340px;
width: 360px;
background: #1e1e2e;
border: 1px solid #3a3a4e;
border-radius: 8px;
@@ -180,7 +181,7 @@
}
@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)
{
@@ -195,6 +196,11 @@
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
</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" : "")"
title="@(_fullscreen ? "Exit fullscreen" : "Fullscreen")"
@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
</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)
{
<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>
</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)
{
<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>
</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>
@* ── Floating action panel for this node ── *@
@if (isActive && _panelMode is not null)
{
@RenderPanel(nd.Node, cx, cy + CardH + 6)
@RenderPanel(nd.Node, ClampPanelX(cx), PanelY(cy))
}
}
</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 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 ────────────────────────────────────────────────────────
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 bool _feedbackOk;
private bool _fullscreen;
private bool _hideCompleted = true;
private Dictionary<Guid, int> _hiddenJobCounts = new();
private Dictionary<Guid, int> _hiddenRsCounts = new();
// ── Lifecycle ────────────────────────────────────────────────────────────
@@ -393,6 +432,8 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count >
{
_nodes.Clear();
_lines.Clear();
_hiddenJobCounts.Clear();
_hiddenRsCounts.Clear();
if (Resources is null || Resources.Count == 0) return;
double y = 0;
@@ -401,7 +442,39 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count >
{
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)
Layout(kid, col + 1, node.Id);
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.
private void PanelLogs(DeploymentResource n) => TogglePanel(n.Id, n, "logs");
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 PanelScale(DeploymentResource n) => TogglePanel(n.Id, n, "scale");
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;
_panelMode = mode;
if (mode == "logs") _ = LoadLogsAsync(node);
if (mode == "events") _ = LoadEventsAsync(node);
if (mode == "scale") _scaleValue = ParseDesiredReplicas(node.StatusMessage) ?? 1;
if (mode == "logs") _ = LoadLogsAsync(node);
if (mode == "events") _ = LoadEventsAsync(node);
if (mode == "yaml") _ = LoadYamlAsync(node);
if (mode == "scale") _scaleValue = ParseDesiredReplicas(node.StatusMessage) ?? 1;
}
private void ClosePanel()
@@ -495,13 +571,37 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count >
await FetchLogsAsync(node.Name);
}
private int _panelTailLines = 500;
private ElementReference _logPreRef;
private bool _pendingLogScroll;
private async Task FetchLogsAsync(string podName)
{
_panelLoading = true; _panelText = null; _panelError = null;
StateHasChanged();
var r = await K8sOps.GetPodLogsAsync(DeploymentId, podName, _selectedContainer);
var r = await K8sOps.GetPodLogsAsync(DeploymentId, podName, _selectedContainer, _panelTailLines);
_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();
}
@@ -543,6 +643,33 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count >
: 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)
{
_opBusy = true; StateHasChanged();
@@ -551,8 +678,18 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count >
SetFeedback(r.IsSuccess, r.IsSuccess
? $"{node.Kind} {node.Name} deleted."
: 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)
{
_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 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 ──────────────────────────────────────────────
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>
}
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")
{
<i class="bi bi-trash me-1"></i><text>Delete — @node.Name</text>
}
</span>
<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 class="d-flex align-items-center gap-1">
@if (_panelMode is "logs" or "events" or "yaml")
{
<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>
@if (_panelMode == "logs")
@@ -607,6 +793,16 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count >
}
</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)
{
<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
{
<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")
{
<div class="rg-panel-body d-flex align-items-center gap-2 flex-wrap">
<span class="text-light small">Replicas for <strong>@node.Name</strong></span>
<input type="number" class="form-control form-control-sm"
style="width:70px;background:#2a2a3e;border-color:#555;color:white;"
min="0" max="99" @bind="_scaleValue" />
<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>
var current = ParseDesiredReplicas(node.StatusMessage);
<div class="rg-panel-body">
@if (current.HasValue)
{
<div class="text-muted small mb-2">
Current: <strong class="text-light">@current</strong> replica@(current == 1 ? "" : "s")
</div>
}
<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>
}
@@ -669,6 +899,7 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count >
<span class="text-light small">
Delete <strong>@node.Kind @node.Name</strong>?
@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>
<button class="btn btn-sm btn-danger" disabled="@_opBusy" @onclick="() => ApplyDeleteAsync(node)">
@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>
</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>
};

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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();
}
};
}

View File

@@ -1,5 +1,6 @@
@using EntKube.Web.Data
@using EntKube.Web.Services
@implements IDisposable
@inject DeploymentService DeploymentService
@inject KubernetesOperationsService K8sOps
@@ -166,6 +167,7 @@ else
@code {
[Parameter, EditorRequired] public Guid AppId { get; set; }
[Parameter] public Guid? EnvironmentId { get; set; }
private List<AppDeployment>? deployments;
@@ -176,15 +178,24 @@ else
private List<DeploymentResource>? tree;
private DateTime? _lastRefreshed;
// Auto-refresh
private PeriodicTimer? _refreshTimer;
private CancellationTokenSource? _timerCts;
private const int AutoRefreshSeconds = 15;
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)
{
selectedDeployment = d;
await LoadTreeForSelected();
StartAutoRefresh();
}
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()
{
StopAutoRefresh();
selectedDeployment = null;
tree = null;
treeError = null;
_lastRefreshed = null;
}
public void Dispose() => StopAutoRefresh();
// ── Visual helpers ────────────────────────────────────────────────────────
private static string HealthColor(HealthStatus h) => h switch

View File

@@ -73,6 +73,11 @@
<i class="bi bi-heart-pulse me-1"></i>Monitoring
</button>
</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">
<button class="nav-link @(section == "components" ? "active" : "")" @onclick='() => section = "components"'>
<i class="bi bi-puzzle me-1"></i>Components
@@ -89,6 +94,10 @@
{
<ClusterMonitoring ClusterId="Cluster.Id" />
}
else if (section == "nodes")
{
<ClusterNodes ClusterId="Cluster.Id" />
}
else if (section == "logs")
{
<LogBrowser ClusterId="Cluster.Id" />

View 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. *@

View File

@@ -4,7 +4,6 @@
@inject TenantService TenantService
@inject CustomerAccessService CustomerAccessService
@inject PrometheusService PrometheusService
@inject CustomerGitService CustomerGitService
@* ===========================================================================
Three-level drill-down: Customers ▸ Apps ▸ App Detail
@@ -162,203 +161,6 @@ else if (selectedCustomer is not null)
}
</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 --- *@
<div class="card shadow-sm mt-4">
<div class="card-header bg-white py-2">
@@ -546,23 +348,6 @@ else
private string? accessError;
private string? accessSuccess;
// --- Git repo policies ---
private List<CustomerGitRepoPolicy>? gitRepoPolicies;
private string newRepoPolicyPattern = "";
private string? repoPolicyError;
private Guid? confirmDeletePolicyId;
// --- Git credentials ---
private List<CustomerGitCredential>? gitCredentials;
private string newCredName = "";
private GitAuthType newCredAuthType = GitAuthType.HttpsPat;
private string newCredUsername = "";
private string? credentialError;
private string? credentialSuccess;
private Dictionary<Guid, string> credSecretValues = new();
private Dictionary<Guid, string?> credSaveMessages = new();
private Dictionary<Guid, bool> credSaveOk = new();
private Guid? confirmDeleteCredentialId;
// --- Portal access ---
private string? confirmRevokeUserId;
@@ -621,20 +406,8 @@ else
accessSuccess = null;
customerView = "apps";
customerMetrics = null;
newRepoPolicyPattern = "";
repoPolicyError = null;
newCredName = "";
newCredAuthType = GitAuthType.HttpsPat;
newCredUsername = "";
credentialError = null;
credentialSuccess = null;
credSecretValues = new();
credSaveMessages = new();
credSaveOk = new();
await LoadApps();
await LoadAccesses();
await LoadGitRepoPolicies();
await LoadGitCredentials();
}
private async Task OpenCustomerMetrics()
@@ -745,115 +518,6 @@ else
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
{
CustomerAccessRole.Admin => "bg-danger",

View File

@@ -2,32 +2,86 @@
@using EntKube.Web.Services
@inject AppGovernanceService GovernanceService
@inject TenantService TenantService
@inject DeploymentService DeploymentService
@inject StorageService StorageService
@* ═══════════════════════════════════════════════════════════════════
GovernanceTab — tenant admin view for app-level namespace, resource
quotas, network policies, and RBAC. All changes are saved to the
database. "Apply to clusters" pushes to every cluster where this
app has a deployment.
GovernanceTab — per-environment governance: resource quotas,
network policies, and RBAC for this app in this specific environment.
Namespace is derived from each deployment (not stored here).
═══════════════════════════════════════════════════════════════════ *@
<div class="d-flex align-items-center justify-content-between mb-3">
<div>
<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>
<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>
@* ── 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)
{
<div class="alert @(applySuccess ? "alert-success" : "alert-danger") py-2 small mb-3 d-flex align-items-start justify-content-between">
<div>
<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>
</div>
<button class="btn-close btn-close-sm ms-2 flex-shrink-0" @onclick="() => applyOutput = null"></button>
@@ -40,47 +94,55 @@
}
else
{
@* ── 1. Namespace ─────────────────────────────────────────────── *@
@* ── 0. Namespace ─────────────────────────────────────────────── *@
<div class="card shadow-sm mb-3">
<div class="card-header bg-white py-2">
<i class="bi bi-box me-2 text-primary"></i>
<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 class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-5">
<label class="form-label small">Namespace</label>
<input class="form-control form-control-sm font-monospace" @bind="ns"
placeholder="e.g. my-app" />
<div class="form-text">Must be a valid DNS label (lowercase, hyphens, max 63 chars).</div>
@if (nsMessage is not null)
{
<div class="alert @(nsOk ? "alert-success" : "alert-danger") py-1 small mb-2">
<i class="bi @(nsOk ? "bi-check-circle" : "bi-x-circle") me-1"></i>@nsMessage
</div>
<div class="col-auto">
<button class="btn btn-sm btn-primary" @onclick="SaveNamespace"
disabled="@savingNs">
@if (savingNs) { <span class="spinner-border spinner-border-sm me-1"></span> }
Save
</button>
</div>
@if (nsMessage is not null)
}
<div class="d-flex gap-2 align-items-center flex-wrap">
<input class="form-control form-control-sm font-monospace" style="max-width:280px"
@bind="nsValue" placeholder="e.g. customer-prod" />
<button class="btn btn-sm btn-primary" @onclick="SaveNamespace" disabled="@savingNs">
@if (savingNs) { <span class="spinner-border spinner-border-sm me-1"></span> }
Save
</button>
@if (!string.IsNullOrWhiteSpace(nsValue))
{
<div class="col-auto">
<span class="small @(nsOk ? "text-success" : "text-danger")">
<i class="bi @(nsOk ? "bi-check-circle" : "bi-x-circle") me-1"></i>@nsMessage
</span>
</div>
<button class="btn btn-sm btn-outline-danger" @onclick="ClearNamespace">
<i class="bi bi-x me-1"></i>Remove lock
</button>
}
</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>
@* ── 2. Resource Quota ─────────────────────────────────────────── *@
@* ── 1. Resource Quota ─────────────────────────────────────────── *@
<div class="card shadow-sm mb-3">
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between">
<div>
<i class="bi bi-speedometer me-2 text-warning"></i>
<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>
@if (quota is not null)
{
@@ -96,6 +158,19 @@ else
<i class="bi @(quotaOk ? "bi-check-circle" : "bi-x-circle") me-1"></i>@quotaMessage
</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="12 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="col-md-3">
<label class="form-label small">CPU request</label>
@@ -129,7 +204,7 @@ else
</div>
</div>
@* ── 3. Network Policies ───────────────────────────────────────── *@
@* ── 2. Network Policies ───────────────────────────────────────── *@
<div class="card shadow-sm mb-3">
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between">
<div>
@@ -251,14 +326,12 @@ else
</div>
</div>
@* ── 4. RBAC ───────────────────────────────────────────────────── *@
<div class="card shadow-sm">
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between">
<div>
<i class="bi bi-person-badge me-2 text-success"></i>
<strong>RBAC</strong>
<span class="text-muted small ms-2">ServiceAccount, Role, and RoleBinding applied to the namespace.</span>
</div>
@* ── 3. RBAC ───────────────────────────────────────────────────── *@
<div class="card shadow-sm mb-3">
<div class="card-header bg-white py-2">
<i class="bi bi-person-badge me-2 text-success"></i>
<strong>RBAC</strong>
<span class="text-muted small ms-2">ServiceAccount, Role, and RoleBinding applied to the namespace.</span>
</div>
<div class="card-body">
@if (rbacMessage is not null)
@@ -268,7 +341,6 @@ else
</div>
}
@* Service account settings *@
<div class="row g-2 mb-3 align-items-end">
<div class="col-md-4">
<label class="form-label small">Service account name</label>
@@ -277,8 +349,8 @@ else
</div>
<div class="col-auto d-flex align-items-center gap-2" style="padding-bottom: 0.25rem;">
<div class="form-check mb-0">
<input class="form-check-input" type="checkbox" id="rbacAutoMount" @bind="rbacAutoMount" />
<label class="form-check-label small" for="rbacAutoMount">Auto-mount token</label>
<input class="form-check-input" type="checkbox" id="rbacAutoMountEnv" @bind="rbacAutoMount" />
<label class="form-check-label small" for="rbacAutoMountEnv">Auto-mount token</label>
</div>
</div>
<div class="col-auto">
@@ -294,7 +366,6 @@ else
</div>
</div>
@* Rules table *@
@if (rbacPolicy is not null)
{
<div class="mb-2 d-flex align-items-center justify-content-between">
@@ -380,20 +451,279 @@ else
}
</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 {
[Parameter, EditorRequired] public Guid AppId { get; set; }
[Parameter, EditorRequired] public Guid TenantId { get; set; }
[Parameter, EditorRequired] public Guid EnvironmentId { get; set; }
private bool loading = true;
// Namespace
private string ns = "";
private string nsValue = "";
private bool savingNs;
private string? nsMessage;
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
private AppQuota? quota;
private string qCpuReq = "", qCpuLim = "", qMemReq = "", qMemLim = "";
@@ -436,10 +766,17 @@ else
loading = false;
}
protected override async Task OnParametersSetAsync()
{
if (showCopyFrom && otherEnvironments is null)
await LoadOtherEnvironments();
}
private async Task LoadAsync()
{
AppGovernanceData data = await GovernanceService.LoadAsync(AppId);
ns = data.Namespace ?? "";
AppGovernanceData data = await GovernanceService.LoadAsync(AppId, EnvironmentId);
nsValue = data.Namespace ?? "";
quota = data.Quota;
if (quota is not null)
@@ -460,20 +797,66 @@ else
rbacSaName = rbacPolicy.ServiceAccountName;
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()
{
savingNs = true; nsMessage = null;
await GovernanceService.SetNamespaceAsync(AppId, ns);
savingNs = false; nsOk = true;
nsMessage = "Saved.";
await GovernanceService.SaveNamespaceAsync(AppId, EnvironmentId, nsValue);
nsValue = nsValue.Trim().ToLowerInvariant();
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()
{
savingQuota = true; quotaMessage = null;
quota = await GovernanceService.SaveQuotaAsync(AppId,
quota = await GovernanceService.SaveQuotaAsync(AppId, EnvironmentId,
qCpuReq, qCpuLim, qMemReq, qMemLim, qMaxPods, qMaxPvcs);
savingQuota = false; quotaOk = true;
quotaMessage = "Quota saved.";
@@ -481,19 +864,21 @@ else
private async Task DeleteQuota()
{
await GovernanceService.DeleteQuotaAsync(AppId);
await GovernanceService.DeleteQuotaAsync(AppId, EnvironmentId);
quota = null;
qCpuReq = qCpuLim = qMemReq = qMemLim = "";
qMaxPods = qMaxPvcs = null;
}
// ── Network policies ───────────────────────────────────────────────────────
private async Task AddNetworkPolicy()
{
addingNp = true; npMessage = null;
try
{
AppNetworkPolicy p = await GovernanceService.AddNetworkPolicyAsync(
AppId, newNpName, newNpType,
AppId, EnvironmentId, newNpName, newNpType,
newNpAllowNs, newNpCustomYaml);
networkPolicies.Add(p);
showAddNp = false;
@@ -511,17 +896,19 @@ else
confirmDeleteNpId = null;
}
// ── RBAC ───────────────────────────────────────────────────────────────────
private async Task SaveRbac()
{
savingRbac = true; rbacMessage = null;
rbacPolicy = await GovernanceService.SaveRbacPolicyAsync(AppId, rbacSaName, rbacAutoMount);
rbacPolicy = await GovernanceService.SaveRbacPolicyAsync(AppId, EnvironmentId, rbacSaName, rbacAutoMount);
savingRbac = false; rbacOk = true;
rbacMessage = "Saved.";
}
private async Task DeleteRbac()
{
await GovernanceService.DeleteRbacPolicyAsync(AppId);
await GovernanceService.DeleteRbacPolicyAsync(AppId, EnvironmentId);
rbacPolicy = null; rbacSaName = ""; rbacAutoMount = false;
}
@@ -549,26 +936,49 @@ else
rbacPolicy?.Rules.Remove(rbacPolicy.Rules.First(r => r.Id == id));
}
private async Task ApplyToAllClusters()
// ── Apply ──────────────────────────────────────────────────────────────────
private async Task ApplyToEnvironmentClusters()
{
applying = true; applyOutput = null;
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);
sb.AppendLine($"── {cluster.Name} ──");
sb.AppendLine(ok ? "✓ Applied" : $"✗ {output}");
if (!ok && !string.IsNullOrWhiteSpace(output))
sb.AppendLine(output);
applySuccess = ok;
applyOutput = "No deployments found for this environment. Create a deployment first.";
applySuccess = false;
applying = false;
return;
}
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();
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
@@ -580,4 +990,148 @@ else
AppNetworkPolicyType.Custom => "Custom YAML",
_ => 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;
}
}

View File

@@ -1,8 +1,26 @@
@using EntKube.Web.Data
@using EntKube.Web.Services
@inject IncidentService IncidentService
@inject OnCallService OnCallService
@inject NotificationService NotificationService
@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)
{
<div class="row g-2 mb-3">
@@ -42,6 +60,20 @@
<div class="text-muted small">MTTA</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)
{
int maxCount = stats.Daily.Max(d => d.Count);
@@ -87,6 +119,23 @@
<option value="30">Last 30 days</option>
<option value="90">Last 90 days</option>
</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">
<span class="bi bi-arrow-clockwise"></span> Refresh
</button>
@@ -106,22 +155,46 @@ else if (!isLoading)
<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>Started</th>
<th>Duration</th>
<th style="width:110px">Status</th>
<th style="width:180px">Actions</th>
<th style="width:200px">Actions</th>
</tr>
</thead>
<tbody>
@foreach (AlertIncident incident in incidents)
{
bool expanded = expandedId == incident.Id;
<tr class="@(expanded ? "table-active" : "")" style="cursor:pointer" @onclick="() => ToggleExpand(incident.Id)">
<td>@SeverityBadge(incident.Severity)</td>
<td class="fw-semibold">@incident.AlertName</td>
bool selected = selectedIds.Contains(incident.Id);
<tr class="@(expanded ? "table-active" : selected ? "table-warning" : "")"
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.StartsAt.ToLocalTime().ToString("MMM d HH:mm")</td>
<td class="text-muted small">@FormatDuration(incident)</td>
@@ -140,50 +213,162 @@ else if (!isLoading)
</button>
}
<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>
</td>
</tr>
@if (expanded)
{
<tr>
<td colspan="7" class="bg-light px-4 py-3">
@if (!string.IsNullOrEmpty(incident.Summary))
{
<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>
}
<td colspan="8" class="bg-light px-4 py-3">
<div class="row g-4">
@if (incident.Notes.Count > 0)
{
<div class="mb-3">
<strong class="small">Notes</strong>
@foreach (IncidentNote note in incident.Notes)
@* ── Left: Timeline ── *@
<div class="col-12 col-lg-5">
@if (!string.IsNullOrEmpty(incident.Summary))
{
<div class="border-start border-3 border-secondary ps-3 mb-2 mt-1">
<div class="small text-muted">@note.Author · @note.CreatedAt.ToLocalTime().ToString("MMM d HH:mm")</div>
<div>@note.Content</div>
<p class="mb-1 small"><strong>Summary:</strong> @incident.Summary</p>
}
@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 class="d-flex gap-2">
<textarea class="form-control form-control-sm" rows="2" placeholder="Add a note…"
@bind="noteText" style="max-width:400px"></textarea>
<button class="btn btn-sm btn-primary align-self-end" @onclick="() => AddNote(incident)">
Add Note
</button>
@* Re-notify *@
<div class="mb-3 d-flex gap-2 align-items-center">
<button class="btn btn-sm btn-outline-info"
@onclick="() => ReNotify(incident)"
disabled="@(reNotifyingId == incident.Id)">
@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>
</td>
</tr>
@@ -210,11 +395,26 @@ else if (!isLoading)
private bool isLoading = true;
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()
{
AuthenticationState authState = await AuthStateProvider.GetAuthenticationStateAsync();
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()
@@ -227,39 +427,147 @@ else if (!isLoading)
isLoading = true;
StateHasChanged();
IncidentStatus? status = filterStatus switch
try
{
"Active" => IncidentStatus.Active,
"Acknowledged" => IncidentStatus.Acknowledged,
"Resolved" => IncidentStatus.Resolved,
_ => null
};
IncidentStatus? status = filterStatus switch
{
"Active" => IncidentStatus.Active,
"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(
TenantId,
status: status,
severity: string.IsNullOrEmpty(filterSeverity) ? null : filterSeverity,
clusterId: clusterId,
from: DateTime.UtcNow.AddDays(-filterDays),
to: null);
incidents = await IncidentService.GetIncidentsForTenantAsync(
TenantId,
status: status,
severity: string.IsNullOrEmpty(filterSeverity) ? null : filterSeverity,
clusterId: clusterId,
from: DateTime.UtcNow.AddDays(-filterDays),
to: null);
// Collect unique clusters from results
clusters = incidents
.Where(i => i.Cluster is not null)
.Select(i => i.Cluster!)
.DistinctBy(c => c.Id)
.OrderBy(c => c.Name)
.ToList();
if (filterUnassigned)
incidents = incidents.Where(i => i.AssignedTo is null).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)
{
expandedId = expandedId == id ? null : id;
if (expandedId == id)
{
expandedId = null;
}
else
{
expandedId = id;
_ = LoadDeliveriesAsync(id);
}
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)
@@ -283,6 +591,36 @@ else if (!isLoading)
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)
{
DateTime end = incident.EndsAt ?? DateTime.UtcNow;

View File

@@ -3,6 +3,7 @@
@using Microsoft.EntityFrameworkCore
@inject IDbContextFactory<ApplicationDbContext> DbFactory
@inject NotificationService NotificationService
@inject NotificationProviderConfigService ProviderConfigService
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0">Notification Channels</h6>
@@ -108,13 +109,45 @@ else
<input class="form-control" @bind="newName" placeholder="e.g. Ops Slack" />
</div>
@if (newType == NotificationChannelType.Slack || newType == NotificationChannelType.Teams)
@if (newType == NotificationChannelType.Slack)
{
<div class="mb-3">
<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>
}
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)
{
<div class="mb-3">
@@ -177,12 +210,20 @@ else
private string newWebhookUrl = "";
private string newEmail = "";
private string newBearerToken = "";
private string newTeamId = "";
private string newChannelId = "";
private AlertSeverityFilter newSeverityFilter = AlertSeverityFilter.All;
private string? modalError;
private Guid? testingId;
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()
{
@@ -211,6 +252,8 @@ else
newWebhookUrl = "";
newEmail = "";
newBearerToken = "";
newTeamId = "";
newChannelId = "";
newSeverityFilter = AlertSeverityFilter.All;
modalError = null;
}
@@ -261,7 +304,17 @@ else
{
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)
? System.Text.Json.JsonSerializer.Serialize(new { webhookUrl = newWebhookUrl.Trim() })
: throw new InvalidOperationException("Webhook URL is required."),

View 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;
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View 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);
}

View File

@@ -37,22 +37,22 @@ else
@* ── Category (top-level) nav ── *@
<ul class="nav nav-pills mb-2 gap-1 tenant-category-nav">
<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
</button>
</li>
<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
</button>
</li>
<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
</button>
</li>
<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
</button>
</li>
@@ -63,35 +63,25 @@ else
@if (activeCategory == "apps")
{
<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
</button>
</li>
<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
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "vault" ? "active" : "")" @onclick='() => activeTab = "vault"'>
<i class="bi bi-shield-lock me-1"></i>Vault
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "git" ? "active" : "")" @onclick='() => activeTab = "git"'>
<i class="bi bi-git me-1"></i>Git
</button>
</li>
}
else if (activeCategory == "infra")
{
<li class="nav-item">
<button class="nav-link @(activeTab == "environments" ? "active" : "")" @onclick='() => activeTab = "environments"'>
<button class="nav-link @(activeTab == "environments" ? "active" : "")" @onclick='() => SetTab("environments")'>
<i class="bi bi-layers me-1"></i>Environments
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "networking" ? "active" : "")" @onclick='() => activeTab = "networking"'>
<button class="nav-link @(activeTab == "networking" ? "active" : "")" @onclick='() => SetTab("networking")'>
<i class="bi bi-diagram-2 me-1"></i>Networking
</button>
</li>
@@ -99,60 +89,95 @@ else
else if (activeCategory == "services")
{
<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
</button>
</li>
<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
</button>
</li>
<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
</button>
</li>
<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
</button>
</li>
<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
</button>
</li>
<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
</button>
</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")
{
<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
</button>
</li>
<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
</button>
</li>
<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
</button>
</li>
<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
</button>
</li>
<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
</button>
</li>
@@ -160,7 +185,13 @@ else
</ul>
<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":
<EnvironmentTab TenantId="tenant.Id" />
@@ -193,7 +224,16 @@ else
<CacheTab TenantId="tenant.Id" />
break;
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;
case "incidents":
<IncidentManagement TenantId="tenant.Id" />
@@ -204,15 +244,21 @@ else
case "maintenance":
<MaintenanceWindows TenantId="tenant.Id" />
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":
<SlaTargetManager TenantId="tenant.Id" />
break;
case "networking":
<NetworkingTab TenantId="tenant.Id" />
break;
case "git":
<GitReposTab TenantId="tenant.Id" />
break;
}
</div>
}
@@ -223,6 +269,7 @@ else
private Tenant? tenant;
private string activeCategory = "apps";
private string activeTab = "customers";
private bool tabLoading;
private static readonly Dictionary<string, string> CategoryDefaults = new()
{
@@ -232,10 +279,23 @@ else
["ops"] = "overview",
};
private void SetCategory(string category)
private async Task SetCategory(string category)
{
tabLoading = true;
activeCategory = 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()

View File

@@ -29,14 +29,21 @@
<input type="text" class="form-control border-start-0" placeholder="New tenant name (e.g. Acme Corp)"
@bind="newTenantName" @bind:event="oninput"
@onkeydown="@(async (e) => { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newTenantName)) await CreateTenant(); })" />
<button class="btn btn-primary" @onclick="CreateTenant" disabled="@string.IsNullOrWhiteSpace(newTenantName)">
<i class="bi bi-plus-lg me-1"></i>Create Tenant
<button class="btn btn-primary" @onclick="CreateTenant"
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>
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<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>
}
@@ -76,14 +83,13 @@
@if (isAdmin && confirmDeleteId == tenant.Id)
{
<div class="card-body py-2 border-top bg-danger bg-opacity-10">
<div class="d-flex align-items-center justify-content-between">
<small class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>Delete this tenant?</small>
<div>
<button class="btn btn-sm btn-danger me-1" @onclick="() => DeleteTenant(tenant.Id)">Delete</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmDeleteId = null">Cancel</button>
</div>
</div>
<div class="card-body py-2 border-top">
<ConfirmDialog Visible="true"
Message="@($"Permanently delete tenant '{tenant.Name}' and all its data?")"
ConfirmText="Delete"
IsBusy="deleting"
OnConfirm="() => DeleteTenant(tenant.Id)"
OnCancel="() => confirmDeleteId = null" />
</div>
}
@@ -104,9 +110,12 @@
private List<Tenant>? tenants;
private string newTenantName = "";
private string? errorMessage;
private string? successMessage;
private Guid? confirmDeleteId;
private bool isAdmin;
private bool loaded;
private bool creating;
private bool deleting;
private string? userId;
protected override async Task OnInitializedAsync()
@@ -146,28 +155,43 @@
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()
{
if (!isAdmin) return;
errorMessage = null;
creating = true;
try
{
await TenantService.CreateTenantAsync(newTenantName.Trim());
string name = newTenantName.Trim();
await TenantService.CreateTenantAsync(name);
newTenantName = "";
await LoadTenants();
_ = ShowSuccessAsync($"Tenant '{name}' created.");
}
catch (DbUpdateException)
{
errorMessage = "A tenant with that name already exists.";
}
finally { creating = false; }
}
private async Task DeleteTenant(Guid id)
{
if (!isAdmin) return;
confirmDeleteId = null;
deleting = true;
await TenantService.DeleteTenantAsync(id);
deleting = false;
confirmDeleteId = null;
await LoadTenants();
}
}

View 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);
}

View File

@@ -3,6 +3,7 @@
@using Microsoft.EntityFrameworkCore
@inject PrometheusService PrometheusService
@inject IncidentService IncidentService
@inject OnCallService OnCallService
@inject IDbContextFactory<ApplicationDbContext> DbFactory
@implements IAsyncDisposable
@@ -18,37 +19,54 @@
</button>
</div>
@* ── Tenant-level summary strip ── *@
@if (clusterSummaries.Count > 0)
{
<div class="row g-2 mb-4">
<div class="col-auto">
<div class="card text-center border-0 bg-light px-3 py-2">
<div class="fw-bold fs-4">@clusterSummaries.Count</div>
<div class="text-muted small">Clusters</div>
<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">@clusterSummaries.Count</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 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?.TotalNodes ?? 0)</div>
<div class="text-muted small">Nodes</div>
@if (criticalIncidents > 0)
{
<div class="card border-danger text-center px-3 py-2"
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 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 class="row g-3">
@* ── Cluster cards ── *@
<div class="row g-3 mb-4">
@foreach (ClusterSummaryVm vm in clusterSummaries)
{
<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
&nbsp;·&nbsp;
<span class="bi bi-box me-1"></span>@vm.Health.RunningPods pods running
@if (vm.Health.FailedPods > 0)
{
<span class="text-danger">&nbsp;·&nbsp;<span class="bi bi-exclamation-circle me-1"></span>@vm.Health.FailedPods failed</span>
}
@if (vm.Health.PendingPods > 0)
{
<span class="text-warning">&nbsp;·&nbsp;<span class="bi bi-hourglass me-1"></span>@vm.Health.PendingPods pending</span>
}
</div>
}
</div>
<div class="card-footer py-1 bg-transparent border-0">
<a href="tenants/@TenantSlug/clusters/@vm.Cluster.Id/monitoring" class="btn btn-sm btn-link p-0 text-decoration-none">
<span class="bi bi-graph-up me-1"></span>View monitoring
<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">
<span class="bi bi-graph-up me-1"></span>Monitoring
</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>
@* ── 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 {
[Parameter] public required Guid TenantId { get; set; }
[Parameter] public required string TenantSlug { get; set; }
[Parameter] public EventCallback<string> OnNavigateTab { get; set; }
private List<ClusterSummaryVm> clusterSummaries = [];
private List<AlertIncident> activeIncidents = [];
private List<MaintenanceWindow> upcomingMaintenance = [];
private OnCallShift? currentOnCall;
private int tenantActiveAlerts;
private int criticalIncidents;
private DateTime? lastRefreshed;
private bool isLoading;
private Timer? refreshTimer;
@@ -145,10 +284,28 @@
.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
.Select(c => LoadClusterSummaryAsync(c))
.Select(c => LoadClusterSummaryAsync(c, perClusterCounts.GetValueOrDefault(c.Id)))
.ToList();
clusterSummaries = [.. await Task.WhenAll(tasks)];
@@ -157,13 +314,13 @@
isLoading = false;
}
private async Task<ClusterSummaryVm> LoadClusterSummaryAsync(KubernetesCluster cluster)
private async Task<ClusterSummaryVm> LoadClusterSummaryAsync(KubernetesCluster cluster, int activeAlerts)
{
bool hasPrometheus = cluster.Components.Any(c =>
c.HelmChartName == "kube-prometheus-stack" && c.Status == ComponentStatus.Installed);
if (!hasPrometheus)
return new ClusterSummaryVm(cluster, null, null, 0);
return new ClusterSummaryVm(cluster, null, null, activeAlerts);
KubernetesOperationResult<ClusterHealthSummary> result =
await PrometheusService.GetClusterHealthAsync(cluster.Id);
@@ -172,7 +329,13 @@
cluster,
result.IsSuccess ? result.Data : null,
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) =>
@@ -186,6 +349,24 @@
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()
{
if (refreshTimer is not null)

View File

@@ -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);
}

View File

@@ -6,6 +6,8 @@
@inject TenantService TenantService
@inject StorageService StorageService
@inject CnpgService CnpgService
@inject MongoService MongoService
@inject RegisteredPostgresService RegisteredPostgresService
<div class="d-flex align-items-center mb-2">
<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
</button>
<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 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
@@ -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 --- *@
@if (scope == "docker")
{
@@ -179,7 +239,9 @@ else
}
</div>
<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="col-md-4">
<div class="input-group input-group-sm">
@@ -202,6 +264,7 @@ else
</button>
</div>
</div>
}
@if (!string.IsNullOrEmpty(errorMessage))
{
@@ -231,9 +294,9 @@ else
<thead class="table-light">
<tr>
<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 Secret</th>
@@ -247,13 +310,21 @@ else
{
<tr>
<td><code>@secret.Name</code></td>
@if (scope == "cnpg")
@if (scope is "cnpg" or "mongodb" or "regpostgres")
{
<td>
@if (secret.CnpgDatabase is not null)
{
<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
{
<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">
<i class="bi bi-pencil"></i>
</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)"
title="@(secret.SyncToKubernetes ? "Disable K8s sync" : "Enable K8s sync")">
<i class="bi @(secret.SyncToKubernetes ? "bi-cloud-slash" : "bi-cloud-arrow-up")"></i>
@@ -328,6 +402,81 @@ else
</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)
{
<tr>
@@ -402,6 +551,14 @@ else
private List<(string Label, Guid Id)>? cnpgClusterOptions;
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)
private List<(string Label, Guid Id)>? clusterOptions;
@@ -426,6 +583,13 @@ else
private Guid? editSecretId;
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()
{
SecretVault? vault = await VaultService.GetVaultAsync(TenantId);
@@ -486,6 +650,18 @@ else
cnpgClusterOptions = cnpgClusters
.Select(c => ($"{c.KubernetesCluster.Name} / {c.Name}", c.Id))
.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)
@@ -496,6 +672,8 @@ else
selectedComponentId = Guid.Empty;
selectedStorageLinkId = Guid.Empty;
selectedCnpgClusterId = Guid.Empty;
selectedMongoClusterId = Guid.Empty;
selectedRegPostgresInstanceId = Guid.Empty;
errorMessage = null;
successMessage = null;
syncClusterId = Guid.Empty;
@@ -508,7 +686,9 @@ else
return (scope == "app" && selectedAppId != Guid.Empty)
|| (scope == "component" && selectedComponentId != 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()
@@ -532,6 +712,14 @@ else
{
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
{
secrets = null;
@@ -707,4 +895,58 @@ else
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.";
}
}
}

View File

@@ -11,6 +11,7 @@ public class AlertIncident
public required string Severity { get; set; }
public string Summary { get; set; } = "";
public string Description { get; set; } = "";
public string RunbookUrl { get; set; } = "";
public string LabelsJson { get; set; } = "{}";
public DateTime StartsAt { get; set; }
public DateTime? EndsAt { get; set; }
@@ -18,6 +19,9 @@ public class AlertIncident
public string? AcknowledgedBy { get; set; }
public DateTime? AcknowledgedAt { 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 UpdatedAt { get; set; } = DateTime.UtcNow;

View 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; }
}

View File

@@ -29,7 +29,11 @@ public class App
public ICollection<AppEnvironment> AppEnvironments { get; set; } = [];
public ICollection<VaultSecret> Secrets { 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 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; } = [];
}

View 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!;
}

View 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; }
}

View 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!;
}

View File

@@ -82,7 +82,13 @@ public class AppDeployment
// ── Git source fields (only used when Type is GitYaml, GitHelm, or GitAppOfApps) ──
/// <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>
public Guid? GitRepositoryId { get; set; }
@@ -132,4 +138,5 @@ public class AppDeployment
public ICollection<StorageBinding> StorageBindings { get; set; } = [];
public ICollection<DatabaseBinding> DatabaseBindings { get; set; } = [];
public ICollection<CacheBinding> CacheBindings { get; set; } = [];
public ICollection<AppDeploymentRoute> Routes { get; set; } = [];
}

View File

@@ -13,6 +13,12 @@ public class AppEnvironment
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
public App App { get; set; } = null!;
public Environment Environment { get; set; } = null!;

View File

@@ -28,6 +28,9 @@ public class AppNetworkPolicy
public Guid Id { 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>
public required string Name { get; set; }
@@ -41,4 +44,5 @@ public class AppNetworkPolicy
// Navigation
public App App { get; set; } = null!;
public Environment Environment { get; set; } = null!;
}

View File

@@ -24,6 +24,10 @@ public class AppQuota
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
public App App { get; set; } = null!;
public Environment Environment { get; set; } = null!;
}

View File

@@ -12,6 +12,9 @@ public class AppRbacPolicy
public Guid Id { 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>
/// The Kubernetes ServiceAccount name, e.g. "billing-api".
/// Must be a valid DNS label.
@@ -26,6 +29,7 @@ public class AppRbacPolicy
// Navigation
public App App { get; set; } = null!;
public Environment Environment { get; set; } = null!;
public ICollection<AppRbacRule> Rules { get; set; } = [];
}

View 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!;
}

View File

@@ -24,6 +24,7 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
public DbSet<KubernetesCluster> KubernetesClusters => Set<KubernetesCluster>();
public DbSet<SecretVault> SecretVaults => Set<SecretVault>();
public DbSet<VaultSecret> VaultSecrets => Set<VaultSecret>();
public DbSet<VaultSecretVersion> VaultSecretVersions => Set<VaultSecretVersion>();
public DbSet<ClusterComponent> ClusterComponents => Set<ClusterComponent>();
public DbSet<ExternalRoute> ExternalRoutes => Set<ExternalRoute>();
public DbSet<StorageLink> StorageLinks => Set<StorageLink>();
@@ -75,6 +76,16 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
public DbSet<AppNetworkPolicy> AppNetworkPolicies => Set<AppNetworkPolicy>();
public DbSet<AppRbacPolicy> AppRbacPolicies => Set<AppRbacPolicy>();
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)
{
@@ -341,6 +352,22 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
.WithMany()
.HasForeignKey(s => s.RedisClusterId)
.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.
@@ -993,8 +1020,11 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
entity.Property(i => i.Severity).HasMaxLength(20).IsRequired();
entity.Property(i => i.Summary).HasMaxLength(500);
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.AssignedTo).HasMaxLength(256);
entity.Property(i => i.Status).HasConversion<string>().HasMaxLength(20);
entity.HasIndex(i => i.EscalatedAt);
entity.HasOne(i => i.Cluster)
.WithMany()
@@ -1210,27 +1240,33 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
.HasForeignKey(s => s.GitRepositoryId)
.OnDelete(DeleteBehavior.Cascade);
// CustomerGitRepoPolicy — URL allowlist per customer (wildcard patterns).
// CustomerGitRepoPolicy — URL allowlist per customer per environment (wildcard patterns).
builder.Entity<CustomerGitRepoPolicy>(entity =>
{
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.HasOne(p => p.Customer)
.WithMany(c => c.GitRepoPolicies)
.HasForeignKey(p => p.CustomerId)
.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 =>
{
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.AuthType).HasConversion<string>().HasMaxLength(20);
entity.Property(c => c.Username).HasMaxLength(300);
entity.Property(c => c.UrlPattern).HasMaxLength(500);
entity.HasOne(c => c.Customer)
.WithMany(cu => cu.GitCredentials)
@@ -1241,6 +1277,11 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
.WithMany()
.HasForeignKey(c => c.TenantId)
.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).
@@ -1256,27 +1297,32 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
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 =>
{
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.CpuLimit).HasMaxLength(20);
entity.Property(q => q.MemoryRequest).HasMaxLength(20);
entity.Property(q => q.MemoryLimit).HasMaxLength(20);
entity.HasOne(q => q.App)
.WithOne(a => a.Quota)
.HasForeignKey<AppQuota>(q => q.AppId)
.WithMany(a => a.Quotas)
.HasForeignKey(q => q.AppId)
.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 =>
{
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.PolicyType).HasConversion<string>().HasMaxLength(30);
entity.Property(p => p.AllowFromNamespace).HasMaxLength(63);
@@ -1285,19 +1331,29 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
.WithMany(a => a.NetworkPolicies)
.HasForeignKey(p => p.AppId)
.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 =>
{
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.HasOne(p => p.App)
.WithOne(a => a.RbacPolicy)
.HasForeignKey<AppRbacPolicy>(p => p.AppId)
.WithMany(a => a.RbacPolicies)
.HasForeignKey(p => p.AppId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(p => p.Environment)
.WithMany()
.HasForeignKey(p => p.EnvironmentId)
.OnDelete(DeleteBehavior.Restrict);
});
// AppRbacRule — many per AppRbacPolicy.
@@ -1371,6 +1427,215 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
.HasForeignKey(d => d.ParentDeploymentId)
.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);
});
}
}

View 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!;
}

View File

@@ -20,6 +20,8 @@ public class CustomerGitCredential
public Guid TenantId { get; set; }
public Guid EnvironmentId { get; set; }
/// <summary>Human-friendly label, e.g. "GitHub PAT". Unique within a customer.</summary>
public required string Name { get; set; }
@@ -31,9 +33,17 @@ public class CustomerGitCredential
/// </summary>
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;
// Navigation
public Customer Customer { get; set; } = null!;
public Tenant Tenant { get; set; } = null!;
public Environment Environment { get; set; } = null!;
}

View File

@@ -20,8 +20,11 @@ public class CustomerGitRepoPolicy
/// </summary>
public required string UrlPattern { get; set; }
public Guid EnvironmentId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation
public Customer Customer { get; set; } = null!;
public Environment Environment { get; set; } = null!;
}

View File

@@ -42,4 +42,5 @@ public class KubernetesCluster
public Tenant Tenant { get; set; } = null!;
public Environment Environment { get; set; } = null!;
public ICollection<ClusterComponent> Components { get; set; } = [];
public ICollection<ClusterServer> Servers { get; set; } = [];
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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;
""");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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