Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:35:57 +02:00
commit 1dfbca08a1
528 files changed
+131054

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { describe, it } from "node:test";
const shell = readFileSync(new URL("../components/AppShell.tsx", import.meta.url), "utf8");
const banner = readFileSync(new URL("../components/OperatorReadOnlyBanner.tsx", import.meta.url), "utf8");
const styles = readFileSync(new URL("../globals.css", import.meta.url), "utf8");
const notifications = readFileSync(new URL("../pages/Notifications.tsx", import.meta.url), "utf8");
describe("accessible operator shell", () => {
it("offers a keyboard-visible skip link to the focusable main landmark", () => {
assert.match(shell, /href="#main-content"/);
assert.match(shell, /<main id="main-content" tabIndex=\{-1\}/);
assert.match(styles, /\.app-skip-link:focus/);
});
it("announces read-only state without claiming the status data is locked", () => {
assert.match(banner, /role="status" aria-live="polite"/);
assert.match(banner, /Read-only modus/);
assert.match(banner, /Operatorsessie ontgrendelen/);
});
it("keeps notification mutations and write-only channel fields locked without an operator session", () => {
assert.match(notifications, /const unlocked = useOperatorSession\(\)/);
assert.match(notifications, /disabled=\{!unlocked \|\| working !== null\}/);
assert.match(notifications, /disabled=\{!unlocked\} className="border-slate-800 bg-slate-950 text-slate-100"/);
});
});
+104
View File
@@ -0,0 +1,104 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { ActionRegistryItem } from "@/types/appops";
import { buildActionRegistryPresetView, buildActionRegistryView, getActionRegistryPresetFilters, paginateActionRegistryItems, type ActionRegistryFilters } from "./actionRegistryView.ts";
const item = (overrides: Partial<ActionRegistryItem>): ActionRegistryItem => ({
id: "safety.summary",
label: "Safety / Summary",
category: "safety",
target: "safety",
risk: "low",
capability: "read_only",
policyDecision: "allowed",
policyReason: "Read-only summary.",
...overrides,
});
const registryItems: ActionRegistryItem[] = [
item({ id: "safety.summary", label: "Safety / Summary", category: "safety", target: "safety", risk: "low", capability: "read_only", policyDecision: "allowed" }),
item({ id: "volume.delete", label: "Volume / Delete", category: "volume", target: "volume", risk: "critical", capability: "destructive", policyDecision: "blocked" }),
item({ id: "stack.redeploy", label: "Stack / Redeploy", category: "stack", target: "stack", risk: "high", capability: "operator", policyDecision: "approval_required" }),
item({ id: "radarr.add_movie", label: "Radarr / Add Movie", category: "radarr", target: "radarr", risk: "low", capability: "operator", policyDecision: "allowed" }),
];
describe("buildActionRegistryView", () => {
it("filters by search, category, risk and policy", () => {
const filters: ActionRegistryFilters = {
query: "delete",
category: "volume",
risk: "critical",
policyDecision: "blocked",
capability: "destructive",
};
const result = buildActionRegistryView(registryItems, filters);
assert.deepEqual(result.items.map((entry) => entry.id), ["volume.delete"]);
assert.equal(result.activeFilterCount, 5);
});
it("computes facets from the full item set before filtering", () => {
const result = buildActionRegistryView(registryItems, { query: "radarr" });
assert.deepEqual(result.items.map((entry) => entry.id), ["radarr.add_movie"]);
assert.deepEqual(result.facets.categories, ["radarr", "safety", "stack", "volume"]);
assert.equal(result.facets.policy.blocked, 1);
assert.equal(result.facets.risk.critical, 1);
assert.equal(result.facets.capability.read_only, 1);
});
it("sorts highest-risk blocked actions first by default", () => {
const result = buildActionRegistryView(registryItems, {});
assert.deepEqual(result.items.map((entry) => entry.id), [
"volume.delete",
"stack.redeploy",
"radarr.add_movie",
"safety.summary",
]);
});
it("provides operator preset filters for common review modes", () => {
assert.deepEqual(getActionRegistryPresetFilters("blocked_destructive"), {
policyDecision: "blocked",
capability: "destructive",
});
assert.deepEqual(getActionRegistryPresetFilters("approval_needed"), {
policyDecision: "approval_required",
});
assert.deepEqual(getActionRegistryPresetFilters("safe_read_only"), {
policyDecision: "allowed",
capability: "read_only",
});
});
it("describes active presets with count and operator intent", () => {
const blocked = buildActionRegistryPresetView("blocked_destructive", registryItems);
const approval = buildActionRegistryPresetView("approval_needed", registryItems);
assert.equal(blocked.label, "Blocked destructive");
assert.equal(blocked.count, 1);
assert.equal(blocked.filters.policyDecision, "blocked");
assert.match(blocked.description, /gevaarlijke/i);
assert.equal(approval.count, 1);
assert.match(approval.description, /approval/i);
});
it("bounds registry rendering and clamps pages after filtering", () => {
const items = Array.from({ length: 65 }, (_, index) => item({ id: `action.${index}` }));
const secondPage = paginateActionRegistryItems(items, 2, 30);
assert.equal(secondPage.items.length, 30);
assert.equal(secondPage.start, 31);
assert.equal(secondPage.end, 60);
assert.equal(secondPage.pageCount, 3);
const clamped = paginateActionRegistryItems(items.slice(0, 4), 9, 30);
assert.equal(clamped.page, 1);
assert.equal(clamped.items.length, 4);
assert.equal(clamped.start, 1);
assert.equal(clamped.end, 4);
});
});
+202
View File
@@ -0,0 +1,202 @@
import type { ActionCapability, ActionRegistryItem, PolicyDecision, RiskLevel } from "@/types/appops";
export type ActionRegistryFilters = {
query?: string;
category?: string;
risk?: RiskLevel | "all";
policyDecision?: PolicyDecision | "all";
capability?: ActionCapability | "all";
};
export type ActionRegistryFacets = {
categories: string[];
risk: Record<RiskLevel, number>;
policy: Record<PolicyDecision, number>;
capability: Record<ActionCapability, number>;
};
export type ActionRegistryView = {
items: ActionRegistryItem[];
facets: ActionRegistryFacets;
activeFilterCount: number;
};
export type ActionRegistryPage = {
items: ActionRegistryItem[];
page: number;
pageCount: number;
pageSize: number;
total: number;
start: number;
end: number;
};
export type ActionRegistryPreset = "blocked_destructive" | "approval_needed" | "safe_read_only";
export type ActionRegistryPresetView = {
preset: ActionRegistryPreset;
label: string;
description: string;
count: number;
filters: ActionRegistryFilters;
};
const riskRank: Record<RiskLevel, number> = {
critical: 4,
high: 3,
medium: 2,
low: 1,
};
const policyRank: Record<PolicyDecision, number> = {
blocked: 3,
approval_required: 2,
allowed: 1,
};
const emptyFacets = (): ActionRegistryFacets => ({
categories: [],
risk: { low: 0, medium: 0, high: 0, critical: 0 },
policy: { allowed: 0, approval_required: 0, blocked: 0 },
capability: { read_only: 0, operator: 0, destructive: 0 },
});
const matchesQuery = (item: ActionRegistryItem, query: string): boolean => {
const normalized = query.trim().toLowerCase();
if (!normalized) return true;
return [
item.id,
item.label,
item.category,
item.target,
item.risk,
item.capability,
item.policyDecision,
item.policyReason,
].some((value) => value.toLowerCase().includes(normalized));
};
const sortRegistryItems = (items: ActionRegistryItem[]): ActionRegistryItem[] => (
[...items].sort((left, right) => {
const riskDelta = riskRank[right.risk] - riskRank[left.risk];
if (riskDelta) return riskDelta;
const policyDelta = policyRank[right.policyDecision] - policyRank[left.policyDecision];
if (policyDelta) return policyDelta;
return left.id.localeCompare(right.id);
})
);
export const buildActionRegistryView = (
items: ActionRegistryItem[],
filters: ActionRegistryFilters,
): ActionRegistryView => {
const facets = emptyFacets();
const categories = new Set<string>();
for (const item of items) {
categories.add(item.category);
facets.risk[item.risk] += 1;
facets.policy[item.policyDecision] += 1;
facets.capability[item.capability] += 1;
}
facets.categories = [...categories].sort((left, right) => left.localeCompare(right));
const category = filters.category && filters.category !== "all" ? filters.category : "";
const risk = filters.risk && filters.risk !== "all" ? filters.risk : "";
const policyDecision = filters.policyDecision && filters.policyDecision !== "all" ? filters.policyDecision : "";
const capability = filters.capability && filters.capability !== "all" ? filters.capability : "";
const query = filters.query?.trim() || "";
const activeFilterCount = [query, category, risk, policyDecision, capability].filter(Boolean).length;
const filtered = items.filter((item) => (
matchesQuery(item, query) &&
(!category || item.category === category) &&
(!risk || item.risk === risk) &&
(!policyDecision || item.policyDecision === policyDecision) &&
(!capability || item.capability === capability)
));
return {
items: sortRegistryItems(filtered),
facets,
activeFilterCount,
};
};
export const paginateActionRegistryItems = (
items: ActionRegistryItem[],
requestedPage: number,
requestedPageSize = 30,
): ActionRegistryPage => {
const pageSize = Math.max(1, Math.floor(requestedPageSize));
const pageCount = Math.max(1, Math.ceil(items.length / pageSize));
const page = Math.min(pageCount, Math.max(1, Math.floor(requestedPage) || 1));
const startIndex = (page - 1) * pageSize;
const pageItems = items.slice(startIndex, startIndex + pageSize);
return {
items: pageItems,
page,
pageCount,
pageSize,
total: items.length,
start: items.length ? startIndex + 1 : 0,
end: startIndex + pageItems.length,
};
};
export const getActionRegistryPresetFilters = (preset: ActionRegistryPreset): ActionRegistryFilters => {
if (preset === "blocked_destructive") {
return {
policyDecision: "blocked",
capability: "destructive",
};
}
if (preset === "approval_needed") {
return {
policyDecision: "approval_required",
};
}
return {
policyDecision: "allowed",
capability: "read_only",
};
};
const presetLabels: Record<ActionRegistryPreset, { label: string; description: string }> = {
blocked_destructive: {
label: "Blocked destructive",
description: "Toont gevaarlijke acties die bewust geblokkeerd blijven.",
},
approval_needed: {
label: "Approval nodig",
description: "Toont acties die eerst een expliciete approval-flow nodig hebben.",
},
safe_read_only: {
label: "Safe read-only",
description: "Toont veilige read-only checks die zonder mutatie gebruikt kunnen worden.",
},
};
export const buildActionRegistryPresetView = (
preset: ActionRegistryPreset,
items: ActionRegistryItem[],
): ActionRegistryPresetView => {
const filters = getActionRegistryPresetFilters(preset);
const view = buildActionRegistryView(items, filters);
const metadata = presetLabels[preset];
return {
preset,
label: metadata.label,
description: metadata.description,
count: view.items.length,
filters,
};
};
+68
View File
@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { buildActionResultFeedback, isApprovalRequiredActionResult } from "./actionResultStatus.ts";
describe("isApprovalRequiredActionResult", () => {
it("recognizes approval-required decisions from all action result fields", () => {
assert.equal(isApprovalRequiredActionResult({ decision: "approval_required" }), true);
assert.equal(isApprovalRequiredActionResult({ policy_decision: "approval_required" }), true);
assert.equal(isApprovalRequiredActionResult({ result: "approval_required" }), true);
});
it("does not mark allowed or failed results as approval-required", () => {
assert.equal(isApprovalRequiredActionResult({ decision: "allowed", policy_decision: "allowed", result: "success" }), false);
assert.equal(isApprovalRequiredActionResult({ decision: "blocked", policy_decision: "blocked", result: "failed" }), false);
});
});
describe("buildActionResultFeedback", () => {
it("summarizes approval requests with approval and audit references", () => {
assert.deepEqual(buildActionResultFeedback({
success: false,
result: "approval_required",
approvalId: "apr_123",
auditId: "aud_456",
message: "Approval aangemaakt.",
}, {
success: "Restart gestart.",
approval: "Restart approval aangevraagd.",
failure: "Restart mislukt.",
}), {
tone: "approval",
message: "Approval aangemaakt. Approval: apr_123. Audit: aud_456.",
});
});
it("treats successful approval creation as approval feedback, not execution success", () => {
assert.deepEqual(buildActionResultFeedback({
success: true,
result: "approval_requested",
approvalId: "apr_789",
message: "Approval request created.",
}, {
success: "Actie uitgevoerd.",
approval: "Approval aangevraagd.",
failure: "Actie mislukt.",
}), {
tone: "approval",
message: "Approval request created. Approval: apr_789.",
});
});
it("uses safe fallback copy for failed actions without leaking raw details", () => {
assert.deepEqual(buildActionResultFeedback({
success: false,
result: "failed",
message: "",
details: { token: "secret-value" },
}, {
success: "Actie gestart.",
approval: "Approval aangevraagd.",
failure: "Actie mislukt.",
}), {
tone: "error",
message: "Actie mislukt.",
});
});
});
+60
View File
@@ -0,0 +1,60 @@
import type { ActionResult } from "@/types/appops";
export const isApprovalRequiredActionResult = (result: Pick<ActionResult, "decision" | "policy_decision" | "result">): boolean => (
result.decision === "approval_required" ||
result.policy_decision === "approval_required" ||
result.result === "approval_required"
);
export type ActionResultFeedbackTone = "success" | "approval" | "error";
export interface ActionResultFeedbackCopy {
success: string;
approval: string;
failure: string;
}
export interface ActionResultFeedback {
tone: ActionResultFeedbackTone;
message: string;
}
const cleanMessage = (message?: string): string | null => {
const trimmed = message?.trim();
return trimmed ? trimmed : null;
};
const withReferences = (message: string, result: Pick<ActionResult, "approvalId" | "auditId" | "jobId">): string => {
const references = [
result.approvalId ? `Approval: ${result.approvalId}` : null,
result.jobId ? `Job: ${result.jobId}` : null,
result.auditId ? `Audit: ${result.auditId}` : null,
].filter(Boolean);
if (!references.length) return message;
return `${message.replace(/\.$/, "")}. ${references.join(". ")}.`;
};
export const buildActionResultFeedback = (
result: ActionResult,
copy: ActionResultFeedbackCopy,
): ActionResultFeedback => {
if (isApprovalRequiredActionResult(result) || result.result === "approval_requested") {
return {
tone: "approval",
message: withReferences(cleanMessage(result.message) || copy.approval, result),
};
}
if (result.success) {
return {
tone: "success",
message: withReferences(cleanMessage(result.message) || copy.success, result),
};
}
return {
tone: "error",
message: withReferences(cleanMessage(result.message) || copy.failure, result),
};
};
+12
View File
@@ -0,0 +1,12 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getAiChatSuggestedActionNotice } from "./aiChatView.ts";
test("AI chat suggested actions are presented as advisory copy only", () => {
const notice = getAiChatSuggestedActionNotice(["container.restart", "widefrog.retry_failed"]);
assert.equal(notice.executionCopyAllowed, false);
assert.match(notice.title, /Advisory/i);
assert.deepEqual(notice.items, ["container.restart", "widefrog.retry_failed"]);
});
+13
View File
@@ -0,0 +1,13 @@
export interface AiChatSuggestedActionNotice {
title: string;
description: string;
items: string[];
executionCopyAllowed: boolean;
}
export const getAiChatSuggestedActionNotice = (actions: string[] = []): AiChatSuggestedActionNotice => ({
title: "Advisory suggestions",
description: "Deze chat start geen jobs, approvals of acties. Gebruik de expliciete AppOps flows voor uitvoering.",
items: actions,
executionCopyAllowed: false,
});
+36
View File
@@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { AISummary } from "@/types/ai";
import { buildAiDashboardView } from "./aiDashboardView.ts";
const summary = (overrides: Partial<AISummary>): AISummary => ({
dailySummary: "AI advisor draait read-only.",
currentRisks: 0,
recommendedActions: 0,
storageForecast: "Geen forecast.",
securityScore: 100,
autopilotStatus: "off",
...overrides,
});
describe("buildAiDashboardView", () => {
it("labels low security scores as review required instead of stable", () => {
const view = buildAiDashboardView(summary({ securityScore: 52, currentRisks: 7 }));
assert.equal(view.securityLabel, "Review nodig");
assert.equal(view.securityTone, "rose");
assert.equal(view.riskCopy, "Gebaseerd op live Security Reviewer en Safety signalen.");
});
it("keeps activity copy advisory and avoids fake execution claims", () => {
const view = buildAiDashboardView(summary({ recommendedActions: 7 }));
assert.deepEqual(view.activityItems.map((item) => item.title), [
"Security Reviewer data gekoppeld",
"Recommendations zijn review-only",
]);
assert.ok(view.activityItems.every((item) => !item.description.toLowerCase().includes("uitgevoerd")));
assert.ok(view.activityItems.every((item) => !item.description.toLowerCase().includes("succes")));
});
});
+39
View File
@@ -0,0 +1,39 @@
import type { AISummary } from "@/types/ai";
export type AiDashboardView = {
securityLabel: string;
securityTone: "emerald" | "amber" | "rose";
riskCopy: string;
storageCopy: string;
activityItems: Array<{
title: string;
description: string;
tone: "blue" | "amber" | "emerald";
}>;
};
export const buildAiDashboardView = (summary: AISummary): AiDashboardView => {
const securityTone = summary.securityScore >= 80 ? "emerald" : summary.securityScore >= 60 ? "amber" : "rose";
const securityLabel = summary.securityScore >= 80 ? "Stabiel" : summary.securityScore >= 60 ? "Aandacht" : "Review nodig";
return {
securityLabel,
securityTone,
riskCopy: "Gebaseerd op live Security Reviewer en Safety signalen.",
storageCopy: summary.storageForecast.toLowerCase().includes("coverage partial")
? "Coverage is gedeeltelijk; forecast blijft indicatief."
: "Gebaseerd op beschikbare live status en connectorcoverage.",
activityItems: [
{
title: "Security Reviewer data gekoppeld",
description: `${summary.currentRisks} risico(s) en ${summary.recommendedActions} aanbeveling(en) uit live advisory endpoints.`,
tone: "blue",
},
{
title: "Recommendations zijn review-only",
description: "Lifecycle knoppen markeren alleen reviewed, snoozed of dismissed in de UI.",
tone: "amber",
},
],
};
};
+15
View File
@@ -0,0 +1,15 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { getAiRecommendationActions } from "./aiRecommendationView.ts";
describe("getAiRecommendationActions", () => {
it("uses review-only lifecycle labels for advisory AI recommendations", () => {
const actions = getAiRecommendationActions();
assert.deepEqual(actions.primary, { label: "Markeer reviewed", status: "applied" });
assert.deepEqual(actions.secondary, { label: "Snooze", status: "snoozed" });
assert.deepEqual(actions.destructive, { label: "Dismiss", status: "dismissed" });
assert.equal(actions.executionCopyAllowed, false);
});
});
+17
View File
@@ -0,0 +1,17 @@
import type { AIRecommendation } from "@/types/ai";
type RecommendationStatus = AIRecommendation["status"];
export type AiRecommendationActions = {
primary: { label: string; status: RecommendationStatus };
secondary: { label: string; status: RecommendationStatus };
destructive: { label: string; status: RecommendationStatus };
executionCopyAllowed: boolean;
};
export const getAiRecommendationActions = (): AiRecommendationActions => ({
primary: { label: "Markeer reviewed", status: "applied" },
secondary: { label: "Snooze", status: "snoozed" },
destructive: { label: "Dismiss", status: "dismissed" },
executionCopyAllowed: false,
});
+26
View File
@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { canRunFindingAction, canRunRecommendationAction, canRunRemediationPlanAction } from "./aiWorkflowActions.ts";
describe("ai workflow action guards", () => {
it("prevents repeated finding state transitions", () => {
assert.equal(canRunFindingAction("open", "acknowledge"), true);
assert.equal(canRunFindingAction("acknowledged", "acknowledge"), false);
assert.equal(canRunFindingAction("dismissed", "plan"), false);
});
it("keeps recommendation actions status-aware", () => {
assert.equal(canRunRecommendationAction("open", "snooze"), true);
assert.equal(canRunRecommendationAction("snoozed", "snooze"), false);
assert.equal(canRunRecommendationAction("accepted", "accept"), false);
assert.equal(canRunRecommendationAction("dismissed", "plan"), false);
});
it("only creates approval proposals after review", () => {
assert.equal(canRunRemediationPlanAction("draft", "review"), true);
assert.equal(canRunRemediationPlanAction("draft", "proposal"), false);
assert.equal(canRunRemediationPlanAction("reviewed", "proposal"), true);
assert.equal(canRunRemediationPlanAction("dismissed", "dry_run"), false);
});
});
+28
View File
@@ -0,0 +1,28 @@
import type { AiSecurityFindingStatus, AiSecurityRecommendationStatus, RemediationPlanStatus } from "@/types/appops";
export type AiFindingAction = "acknowledge" | "dismiss" | "plan";
export type AiRecommendationAction = "accept" | "snooze" | "dismiss" | "plan";
export type RemediationPlanAction = "dry_run" | "review" | "proposal" | "dismiss";
export const canRunFindingAction = (status: AiSecurityFindingStatus, action: AiFindingAction): boolean => {
if (action === "plan") return status !== "dismissed";
if (action === "acknowledge") return status === "open";
if (action === "dismiss") return status !== "dismissed" && status !== "resolved";
return false;
};
export const canRunRecommendationAction = (status: AiSecurityRecommendationStatus, action: AiRecommendationAction): boolean => {
if (action === "plan") return status !== "dismissed";
if (action === "snooze") return status === "open";
if (action === "accept") return status !== "accepted" && status !== "dismissed";
if (action === "dismiss") return status !== "dismissed";
return false;
};
export const canRunRemediationPlanAction = (status: RemediationPlanStatus, action: RemediationPlanAction): boolean => {
if (action === "dry_run") return status !== "dismissed";
if (action === "review") return status === "draft";
if (action === "proposal") return status === "reviewed";
if (action === "dismiss") return status !== "dismissed";
return false;
};
+93
View File
@@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { App, DiscoveredContainer } from "../types/appops.ts";
import { buildAppCategoryFilters, buildAppInventoryReviewSignal, DEFAULT_APP_CATALOG_FILTER, filterCanonicalAppsForCatalog, isInventoryOnlyApp } from "./appCatalogView.ts";
const app = (overrides: Partial<App> = {}): App => ({
id: "sonarr",
name: "Sonarr",
category: "Media",
status: "healthy",
criticality: "medium",
connectorStatus: "connected",
containerStatus: "running",
voiceEnabled: false,
protected: false,
...overrides,
});
const discovered = (overrides: Partial<DiscoveredContainer> = {}): DiscoveredContainer => ({
id: "dc-1",
discoveryRunId: "run-1",
containerId: "container-1",
name: "unknown-service",
image: "unknown:latest",
state: "running",
status: "running",
ports: [],
mounts: [],
networks: [],
labels: {},
suggestedAppId: "unknown-service",
displayName: "Unknown service",
suggestedAppType: "unknown",
suggestedCategory: "Generic",
suggestedCriticality: "medium",
suggestedConnector: "none",
suggestedPolicyProfile: "generic_unknown",
protected: false,
confidence: 0.4,
reasons: [],
importStatus: "unmapped",
source: "portainer",
lifecycleState: "unmapped",
connectorRequiredSecrets: [],
actionsAvailable: [],
setupChecklist: [],
...overrides,
});
describe("appCatalogView", () => {
it("opens the live catalog on all canonical apps", () => {
assert.equal(DEFAULT_APP_CATALOG_FILTER, "all");
});
it("keeps inventory-only discovered apps out of the live app catalog", () => {
const apps = [
app({ id: "sonarr" }),
app({ id: "discovered-old-container", name: "Old container", discoveryStatus: "unmapped", needsReview: true }),
app({ id: "radarr" }),
];
assert.equal(isInventoryOnlyApp(apps[1]), true);
assert.deepEqual(filterCanonicalAppsForCatalog(apps).map(item => item.id), ["sonarr", "radarr"]);
});
it("summarizes discovery review workload without turning it into live apps", () => {
const signal = buildAppInventoryReviewSignal([
discovered({ id: "unknown", importStatus: "unmapped", suggestedAppType: "unknown" }),
discovered({ id: "changed", importStatus: "changed", suggestedAppType: "media", protected: true }),
discovered({ id: "managed", importStatus: "managed", suggestedAppType: "media" }),
discovered({ id: "ignored", importStatus: "ignored", suggestedAppType: "unknown" }),
]);
assert.equal(signal.total, 4);
assert.equal(signal.actionable, 2);
assert.equal(signal.unknown, 1);
assert.equal(signal.changed, 1);
assert.equal(signal.protected, 1);
});
it("builds category filters from the live catalog and keeps useful custom categories", () => {
const categories = buildAppCategoryFilters([
app({ category: "Media" }),
app({ id: "brain", category: "Productivity" }),
app({ id: "quiz", category: "AI" }),
app({ id: "custom", category: "Custom Operations" }),
app({ id: "unknown", category: "Unknown" }),
]);
assert.deepEqual(categories, ["Media", "AI", "Productivity", "Custom Operations"]);
});
});
+59
View File
@@ -0,0 +1,59 @@
import type { App, DiscoveredContainer } from "../types/appops.ts";
const reviewStatuses = new Set(["discovered", "unmapped", "changed"]);
export const DEFAULT_APP_CATALOG_FILTER = "all";
export interface AppInventoryReviewSignal {
total: number;
actionable: number;
unknown: number;
changed: number;
protected: number;
}
export const isInventoryOnlyApp = (app: App): boolean => String(app.id || "").startsWith("discovered-");
export const filterCanonicalAppsForCatalog = (apps: App[]): App[] => (
apps.filter(app => !isInventoryOnlyApp(app))
);
const preferredCategories = ["Media", "Downloads", "Auth", "Database", "Network", "Infrastructure", "Monitoring", "AI", "Productivity", "Dev"];
export const buildAppCategoryFilters = (apps: App[]): string[] => {
const available = new Map<string, string>();
for (const app of apps) {
const category = String(app.category || "").trim();
if (!category || ["unknown", "generic"].includes(category.toLowerCase())) continue;
available.set(category.toLowerCase(), category);
}
return [
...preferredCategories.filter(category => available.has(category.toLowerCase())),
...[...available.values()]
.filter(category => !preferredCategories.some(preferred => preferred.toLowerCase() === category.toLowerCase()))
.sort((left, right) => left.localeCompare(right)),
];
};
export const buildAppInventoryReviewSignal = (containers: DiscoveredContainer[]): AppInventoryReviewSignal => {
const signal: AppInventoryReviewSignal = {
total: containers.length,
actionable: 0,
unknown: 0,
changed: 0,
protected: 0,
};
for (const container of containers) {
const status = String(container.importStatus || "").toLowerCase();
const appType = String(container.appType || container.suggestedAppType || "").toLowerCase();
if (!reviewStatuses.has(status)) continue;
signal.actionable += 1;
if (status === "changed") signal.changed += 1;
if (appType === "unknown" || container.suggestedAppId === "unknown") signal.unknown += 1;
if (container.protected || container.isProtected || container.privacySensitive) signal.protected += 1;
}
return signal;
};
+31
View File
@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { App } from "@/types/appops";
import { getAppExternalUrl } from "./appDetailActions.ts";
const app = (overrides: Partial<App>): App => ({
id: "widefrog",
name: "Widefrog",
status: "healthy",
criticality: "medium",
category: "media",
protected: false,
port: 1207,
url: "",
...overrides,
} as App);
describe("getAppExternalUrl", () => {
it("uses absolute http urls as-is", () => {
assert.equal(getAppExternalUrl(app({ url: "http://198.18.10.150:1207" })), "http://198.18.10.150:1207");
});
it("builds a lab http url from host and port", () => {
assert.equal(getAppExternalUrl(app({ url: "198.18.10.150" })), "http://198.18.10.150:1207");
});
it("returns null when no url is available", () => {
assert.equal(getAppExternalUrl(app({ url: "" })), null);
});
});
+9
View File
@@ -0,0 +1,9 @@
import type { App } from "@/types/appops";
export const getAppExternalUrl = (app: App): string | null => {
const url = app.url?.trim();
if (!url) return null;
if (/^https?:\/\//i.test(url)) return url;
if (app.port) return `http://${url}:${app.port}`;
return `http://${url}`;
};
+55
View File
@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { App } from "../types/appops.ts";
import { buildAppFocusSummary, filterAppsByFocus } from "./appFocusView.ts";
const app = (overrides: Partial<App> = {}): App => ({
id: "app-1",
name: "Healthy App",
category: "Media",
status: "healthy",
criticality: "low",
connectorStatus: "connected",
containerStatus: "running",
voiceEnabled: false,
protected: false,
discoveryStatus: "managed",
needsReview: false,
connector_type: "sonarr",
app_type: "media",
...overrides,
});
describe("appFocusView", () => {
it("summarizes operator attention without counting healthy connected apps as noise", () => {
const apps = [
app(),
app({ id: "review", name: "Review", needsReview: true }),
app({ id: "unknown", name: "Unknown", category: "Unknown", app_type: "unknown", connectorStatus: "disconnected" }),
app({ id: "missing", name: "Missing Config", connectorStatus: "missing_config" }),
app({ id: "unhealthy", name: "Unhealthy", status: "unhealthy" }),
];
const summary = buildAppFocusSummary(apps);
assert.equal(summary.total, 5);
assert.equal(summary.attention, 4);
assert.equal(summary.healthyConnected, 1);
assert.equal(summary.needsReview, 1);
assert.equal(summary.unknown, 1);
assert.equal(summary.missingConfig, 1);
assert.equal(summary.unhealthy, 1);
});
it("defaults the focus list to actionable apps and keeps all apps available", () => {
const apps = [
app({ id: "ok" }),
app({ id: "needs-review", needsReview: true }),
app({ id: "unknown", category: "Unknown", app_type: "unknown", connectorStatus: "disconnected" }),
];
assert.deepEqual(filterAppsByFocus(apps, "attention").map(item => item.id), ["needs-review", "unknown"]);
assert.deepEqual(filterAppsByFocus(apps, "all").map(item => item.id), ["ok", "needs-review", "unknown"]);
});
});
+87
View File
@@ -0,0 +1,87 @@
import type { App } from "../types/appops.ts";
export type AppFocusFilter =
| "all"
| "attention"
| "healthy_connected"
| "needs_review"
| "unknown"
| "missing_config"
| "no_connector"
| "unhealthy";
export interface AppFocusSummary {
total: number;
attention: number;
healthyConnected: number;
needsReview: number;
unknown: number;
missingConfig: number;
noConnector: number;
unhealthy: number;
}
const appConnectorStatus = (app: App): string => String(app.connectorStatus || app.connector_status || "").toLowerCase();
const appConnectorType = (app: App): string => String(app.connector_type || "").toLowerCase();
export const isUnknownApp = (app: App): boolean => (
String(app.category || "").toLowerCase() === "unknown"
|| String(app.app_type || "").toLowerCase() === "unknown"
|| String(app.policy_profile || "").toLowerCase() === "generic_unknown"
|| app.suggestedAppId === "unknown"
);
export const appNeedsOperatorAttention = (app: App): boolean => (
app.status === "unhealthy"
|| app.status === "critical"
|| app.needsReview === true
|| appConnectorStatus(app) === "missing_config"
|| isUnknownApp(app)
);
const hasNoConnector = (app: App): boolean => {
const status = appConnectorStatus(app);
const connectorType = appConnectorType(app);
return status === "disconnected" || !connectorType || connectorType === "none";
};
export const buildAppFocusSummary = (apps: App[]): AppFocusSummary => {
const summary: AppFocusSummary = {
total: apps.length,
attention: 0,
healthyConnected: 0,
needsReview: 0,
unknown: 0,
missingConfig: 0,
noConnector: 0,
unhealthy: 0,
};
for (const app of apps) {
if (appNeedsOperatorAttention(app)) summary.attention += 1;
if (app.status === "unhealthy" || app.status === "critical") summary.unhealthy += 1;
if (app.needsReview) summary.needsReview += 1;
if (isUnknownApp(app)) summary.unknown += 1;
if (appConnectorStatus(app) === "missing_config") summary.missingConfig += 1;
if (hasNoConnector(app)) summary.noConnector += 1;
if (app.status === "healthy" && appConnectorStatus(app) === "connected" && !isUnknownApp(app) && !app.needsReview) {
summary.healthyConnected += 1;
}
}
return summary;
};
export const filterAppsByFocus = (apps: App[], focus: AppFocusFilter): App[] => {
if (focus === "all") return apps;
if (focus === "attention") return apps.filter(appNeedsOperatorAttention);
if (focus === "healthy_connected") {
return apps.filter(app => app.status === "healthy" && appConnectorStatus(app) === "connected" && !isUnknownApp(app) && !app.needsReview);
}
if (focus === "needs_review") return apps.filter(app => app.needsReview === true);
if (focus === "unknown") return apps.filter(isUnknownApp);
if (focus === "missing_config") return apps.filter(app => appConnectorStatus(app) === "missing_config");
if (focus === "no_connector") return apps.filter(hasNoConnector);
if (focus === "unhealthy") return apps.filter(app => app.status === "unhealthy" || app.status === "critical");
return apps;
};
+77
View File
@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { App } from "@/types/appops";
import { buildAppReviewClosure } from "./appReviewClosure.ts";
const app = (overrides: Partial<App>): App => ({
id: "sonarr",
name: "Sonarr",
category: "Media",
status: "healthy",
criticality: "low",
connectorStatus: "connected",
containerStatus: "running",
voiceEnabled: false,
discoveryStatus: "managed",
needsReview: false,
...overrides,
});
describe("buildAppReviewClosure", () => {
it("marks managed connected apps as review complete", () => {
const closure = buildAppReviewClosure(app({}));
assert.equal(closure.state, "complete");
assert.equal(closure.title, "Review afgerond");
assert.equal(closure.actions.length, 0);
});
it("routes actionable discovery states back to setup review", () => {
const closure = buildAppReviewClosure(app({
id: "unmapped-container",
name: "Unmapped Container",
discoveryStatus: "unmapped",
needsReview: true,
discovery_confidence: 0.42,
}));
assert.equal(closure.state, "needs_review");
assert.equal(closure.status, "warning");
assert.equal(closure.actions[0].href, "/setup?step=review");
assert.match(closure.description, /review/i);
assert.ok(closure.badges.includes("confidence 42%"));
});
it("routes missing connector config to the connector setup deep-link", () => {
const closure = buildAppReviewClosure(app({
id: "radarr",
name: "Radarr",
connectorStatus: "missing_config",
connector_type: "radarr",
connector_required_secrets: ["RADARR_API_KEY"],
}));
assert.equal(closure.state, "connector_missing");
assert.equal(closure.status, "missing_config");
assert.equal(closure.actions[0].href, "/setup?step=connectors&connector=radarr");
assert.ok(closure.badges.includes("radarr"));
assert.ok(closure.badges.includes("1 secret"));
});
it("keeps protected apps in an explicit operator-review tone", () => {
const closure = buildAppReviewClosure(app({
id: "authentik",
name: "Authentik",
protected: true,
lifecycle_state: "changed",
connector_status: "connected",
criticality: "critical",
}));
assert.equal(closure.state, "protected_review");
assert.equal(closure.status, "warning");
assert.ok(closure.badges.includes("protected"));
assert.match(closure.description, /operator review/i);
});
});
+125
View File
@@ -0,0 +1,125 @@
import type { App, Status } from "@/types/appops";
export type AppReviewClosureState = "complete" | "needs_review" | "connector_missing" | "protected_review";
export type AppReviewClosureAction = {
label: string;
href: string;
tone: "primary" | "secondary";
};
export type AppReviewClosureView = {
state: AppReviewClosureState;
status: Status;
title: string;
description: string;
badges: string[];
actions: AppReviewClosureAction[];
};
const actionableLifecycleStates = new Set(["discovered", "unmapped", "changed", "missing"]);
const noConnectorValues = new Set(["", "none", "unknown", "portainer"]);
const cleanText = (value?: string | null): string => (value || "").trim();
const appLifecycleState = (app: App): string => (
cleanText(app.lifecycle_state) ||
cleanText(app.discoveryStatus) ||
cleanText(app.discovery?.lifecycleState) ||
cleanText(app.discovery?.importStatus) ||
"managed"
).toLowerCase();
const appConnectorId = (app: App): string => (
cleanText(app.connector_type) ||
cleanText(app.discovery?.connectorType) ||
cleanText(app.discovery?.suggestedConnector) ||
cleanText(app.id)
).toLowerCase();
const appConnectorStatus = (app: App): string => (
cleanText(app.connector_status) ||
cleanText(app.discovery?.connectorStatus) ||
cleanText(app.connectorStatus)
).toLowerCase();
const isProtected = (app: App): boolean => Boolean(app.protected || app.is_protected || app.discovery?.protected || app.discovery?.isProtected);
const requiredSecrets = (app: App): string[] => (
app.connector_required_secrets ||
app.discovery?.connectorRequiredSecrets ||
[]
).filter(Boolean);
const confidenceBadge = (app: App): string | null => {
const confidence = app.discovery_confidence ?? app.discovery?.confidence;
if (typeof confidence !== "number") return null;
return `confidence ${Math.round(confidence * 100)}%`;
};
const secretBadge = (count: number): string | null => {
if (count < 1) return null;
return count === 1 ? "1 secret" : `${count} secrets`;
};
export const buildAppReviewClosure = (app: App): AppReviewClosureView => {
const lifecycleState = appLifecycleState(app);
const connectorId = appConnectorId(app);
const connectorStatus = appConnectorStatus(app);
const secrets = requiredSecrets(app);
const protectedApp = isProtected(app);
const confidence = confidenceBadge(app);
const missingConnectorConfig = connectorStatus === "missing_config" || secrets.length > 0;
const needsReview = Boolean(app.needsReview) || actionableLifecycleStates.has(lifecycleState);
const badges = [
lifecycleState,
protectedApp ? "protected" : null,
confidence,
].filter(Boolean) as string[];
if (protectedApp && needsReview) {
return {
state: "protected_review",
status: "warning",
title: "Protected app review nodig",
description: "Deze app is beschermd en heeft operator review nodig voordat AppOps hem als stabiel beheerd ziet.",
badges,
actions: [{ label: "Open setup review", href: "/setup?step=review", tone: "primary" }],
};
}
if (missingConnectorConfig && !noConnectorValues.has(connectorId)) {
return {
state: "connector_missing",
status: "missing_config",
title: "Connectorconfig ontbreekt",
description: "Vul de connectorconfiguratie aan en test daarna de read-only status voordat je acties gebruikt.",
badges: [
connectorId,
...badges,
secretBadge(secrets.length),
].filter(Boolean) as string[],
actions: [{ label: "Configureer connector", href: `/setup?step=connectors&connector=${encodeURIComponent(connectorId)}`, tone: "primary" }],
};
}
if (needsReview) {
return {
state: "needs_review",
status: "warning",
title: "Discovery review open",
description: "Controleer type, criticality, connector en policy-profiel in de setup review voordat deze app volledig managed is.",
badges,
actions: [{ label: "Open setup review", href: "/setup?step=review", tone: "primary" }],
};
}
return {
state: "complete",
status: "healthy",
title: "Review afgerond",
description: "Deze app is gekoppeld aan de inventory en heeft geen open setup-review actie.",
badges: [lifecycleState, connectorStatus].filter(Boolean),
actions: [],
};
};
+100
View File
@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { ApprovalRequest } from "@/types/appops";
import { buildApprovalEmptyState, buildApprovalFollowupView, filterApprovals } from "./approvalFollowupView.ts";
const approval = (overrides: Partial<ApprovalRequest> = {}): ApprovalRequest => ({
id: "appr-1",
action: "container.restart",
target: "sonarr",
requester: "manual",
source: "manual",
risk: "medium",
timestamp: "2026-07-08T08:00:00.000Z",
expiresAt: "2026-07-08T08:30:00.000Z",
context: "Restart Sonarr",
affectedApps: ["sonarr"],
reason: "Operator requested restart.",
plan: ["Request approval", "Execute after approval"],
status: "pending",
...overrides,
});
describe("buildApprovalFollowupView", () => {
it("guides pending approvals to audit context, command center and jobs without implying execution", () => {
const view = buildApprovalFollowupView(approval(), new Date("2026-07-08T08:10:00.000Z"));
assert.equal(view.statusLabel, "Pending");
assert.equal(view.expiryLabel, "Verloopt over 20m");
assert.equal(view.canDecide, true);
assert.equal(view.executionCopy, "Nog niet uitgevoerd. Goedkeuren of afwijzen schrijft audit en kan daarna een job opleveren als de actie een executor heeft.");
assert.deepEqual(view.links.map(link => [link.label, link.href]), [
["Open Audit", "/audit"],
["Command Center", "/command-center"],
["Jobs", "/jobs"],
]);
});
it("uses real audit references after a decision and never fabricates audit ids", () => {
const view = buildApprovalFollowupView(approval({
id: "appr-2",
status: "approved",
auditId: "aud-real",
}), new Date("2026-07-08T08:10:00.000Z"));
assert.equal(view.statusLabel, "Approved");
assert.equal(view.canDecide, false);
assert.equal(view.auditReference, "aud-real");
assert.deepEqual(view.links.map(link => [link.label, link.href]), [
["Open Audit", "/audit?query=aud-real"],
["Jobs", "/jobs"],
]);
});
it("turns expired pending approvals into non-decidable terminal guidance", () => {
const view = buildApprovalFollowupView(approval(), new Date("2026-07-08T08:45:00.000Z"));
assert.equal(view.status, "expired");
assert.equal(view.statusLabel, "Expired");
assert.equal(view.expiryLabel, "Verlopen 15m geleden");
assert.equal(view.canDecide, false);
assert.match(view.executionCopy, /geen actie uitgevoerd/i);
});
});
describe("filterApprovals", () => {
it("matches approval handoffs by id, action, target, audit and job references", () => {
const rows = [
approval({ id: "appr-sonarr", action: "container.restart", target: "sonarr", auditId: "aud-1", jobId: "job-1" }),
approval({ id: "appr-radarr", action: "movie.add", target: "radarr", auditId: "aud-2", jobId: "job-2" }),
];
assert.deepEqual(filterApprovals(rows, "job-2").map(row => row.id), ["appr-radarr"]);
assert.deepEqual(filterApprovals(rows, "aud-1").map(row => row.id), ["appr-sonarr"]);
assert.deepEqual(filterApprovals(rows, "movie.add").map(row => row.id), ["appr-radarr"]);
assert.deepEqual(filterApprovals(rows, "").map(row => row.id), ["appr-sonarr", "appr-radarr"]);
});
});
describe("buildApprovalEmptyState", () => {
it("turns an unmatched handoff query into reset and investigation actions", () => {
const state = buildApprovalEmptyState({ totalApprovals: 4, visibleApprovals: 0, query: "apr-missing" });
assert.equal(state.title, "Geen approval voor deze handoff");
assert.match(state.message, /apr-missing/);
assert.deepEqual(state.actions, [
{ label: "Toon alle approvals", href: "/approvals" },
{ label: "Zoek in Audit", href: "/audit?query=apr-missing" },
{ label: "Zoek in Jobs", href: "/jobs?query=apr-missing" },
{ label: "Command Center", href: "/command-center" },
]);
});
it("describes an empty approvals queue without fabricating references", () => {
const state = buildApprovalEmptyState({ totalApprovals: 0, visibleApprovals: 0, query: "" });
assert.equal(state.title, "Geen openstaande approvals");
assert.deepEqual(state.actions, [{ label: "Command Center", href: "/command-center" }]);
});
});
+142
View File
@@ -0,0 +1,142 @@
import type { ApprovalRequest, ApprovalStatus } from "@/types/appops";
export interface ApprovalFollowupLink {
label: string;
href: string;
}
export interface ApprovalFollowupView {
status: ApprovalStatus;
statusLabel: string;
expiryLabel: string;
auditReference: string | null;
jobReference: string | null;
executionCopy: string;
canDecide: boolean;
links: ApprovalFollowupLink[];
}
export interface ApprovalEmptyState {
title: string;
message: string;
actions: ApprovalFollowupLink[];
}
const statusLabels: Record<ApprovalStatus, string> = {
pending: "Pending",
approved: "Approved",
rejected: "Rejected",
expired: "Expired",
executed: "Executed",
};
const normalizeStatus = (status?: string): ApprovalStatus => (
status === "approved" ||
status === "rejected" ||
status === "expired" ||
status === "executed"
? status
: "pending"
);
const formatRelativeExpiry = (expiresAt?: string, now = new Date()): { label: string; expired: boolean } => {
if (!expiresAt) return { label: "Geen vervaldatum", expired: false };
const timestamp = Date.parse(expiresAt);
if (!Number.isFinite(timestamp)) return { label: "Vervaldatum onbekend", expired: false };
const diffMs = timestamp - now.getTime();
const absMinutes = Math.max(1, Math.round(Math.abs(diffMs) / 60_000));
const hours = Math.floor(absMinutes / 60);
const minutes = absMinutes % 60;
const duration = hours > 0 ? `${hours}u ${minutes}m` : `${absMinutes}m`;
return diffMs < 0
? { label: `Verlopen ${duration} geleden`, expired: true }
: { label: `Verloopt over ${duration}`, expired: false };
};
export const buildApprovalFollowupView = (approval: ApprovalRequest, now = new Date()): ApprovalFollowupView => {
const status = normalizeStatus(approval.status);
const expiry = formatRelativeExpiry(approval.expiresAt, now);
const effectiveStatus: ApprovalStatus = status === "pending" && expiry.expired ? "expired" : status;
const auditReference = approval.auditId || null;
const jobReference = approval.jobId || null;
const decided = effectiveStatus !== "pending";
const canDecide = effectiveStatus === "pending";
const links: ApprovalFollowupLink[] = decided
? [
{ label: "Open Audit", href: auditReference ? `/audit?query=${encodeURIComponent(auditReference)}` : "/audit" },
{ label: "Jobs", href: jobReference ? `/jobs?query=${encodeURIComponent(jobReference)}` : "/jobs" },
]
: [
{ label: "Open Audit", href: "/audit" },
{ label: "Command Center", href: "/command-center" },
{ label: "Jobs", href: "/jobs" },
];
return {
status: effectiveStatus,
statusLabel: statusLabels[effectiveStatus],
expiryLabel: effectiveStatus === "expired" ? expiry.label : expiry.label,
auditReference,
jobReference,
executionCopy: decided
? effectiveStatus === "expired"
? "Approval is verlopen. Er wordt geen actie uitgevoerd; maak indien nodig een nieuwe expliciete aanvraag."
: "Beslissing opgeslagen. Controleer Audit en Jobs voor echte vervolgstatussen wanneer die beschikbaar zijn."
: "Nog niet uitgevoerd. Goedkeuren of afwijzen schrijft audit en kan daarna een job opleveren als de actie een executor heeft.",
canDecide,
links,
};
};
export const filterApprovals = (approvals: ApprovalRequest[], query: string): ApprovalRequest[] => {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) return approvals;
return approvals.filter((approval) => [
approval.id,
approval.action,
approval.target,
approval.requester,
approval.context,
approval.reason || "",
approval.auditId || "",
approval.jobId || "",
].some((value) => value.toLowerCase().includes(normalizedQuery)));
};
export const buildApprovalEmptyState = (
options: { totalApprovals: number; visibleApprovals: number; query: string },
): ApprovalEmptyState => {
const query = options.query.trim();
if (options.totalApprovals === 0) {
return {
title: "Geen openstaande approvals",
message: "Er staan geen approvals klaar. Nieuwe approval-first acties verschijnen hier zodra Command Center, connectors of remediation flows er een aanvragen.",
actions: [{ label: "Command Center", href: "/command-center" }],
};
}
if (query && options.visibleApprovals === 0) {
return {
title: "Geen approval voor deze handoff",
message: `Er is geen approval gevonden voor "${query}". Controleer Audit of Jobs om te zien of de aanvraag al verwerkt, verlopen of als job doorgezet is.`,
actions: [
{ label: "Toon alle approvals", href: "/approvals" },
{ label: "Zoek in Audit", href: `/audit?query=${encodeURIComponent(query)}` },
{ label: "Zoek in Jobs", href: `/jobs?query=${encodeURIComponent(query)}` },
{ label: "Command Center", href: "/command-center" },
],
};
}
return {
title: "Geen approvals zichtbaar",
message: "De huidige selectie bevat geen approvals. Toon alles of start een nieuwe approval-first actie via Command Center.",
actions: [
{ label: "Toon alle approvals", href: "/approvals" },
{ label: "Command Center", href: "/command-center" },
],
};
};
+87
View File
@@ -0,0 +1,87 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { buildAuditEventDetailView } from "./auditEventDetailView.ts";
describe("auditEventDetailView", () => {
it("builds summary rows and sections from an audit event", () => {
const view = buildAuditEventDetailView({
id: "audit-1",
time: "2026-07-08T10:00:00Z",
source: "manual",
user: "operator",
action: "container.restart",
target: "sonarr",
connector: "portainer",
risk: "medium",
policy: "approval_required",
result: "success",
duration: "220ms",
details: {
reason: "Restart requested after health warning.",
plan: ["Check policy", "Restart container"],
postChecks: ["Container running", "Health healthy"],
beforeState: "unhealthy",
afterState: "running",
metadata: {
jobId: "job-1",
auditEventId: "audit-1",
},
},
});
assert.deepEqual(view.summaryRows.map(row => row.label), ["Action", "Target", "Result", "Policy", "Risk", "Source"]);
assert.equal(view.reason, "Restart requested after health warning.");
assert.deepEqual(view.plan, ["Check policy", "Restart container"]);
assert.deepEqual(view.postChecks, ["Container running", "Health healthy"]);
assert.equal(view.stateTransition?.before, "unhealthy");
assert.equal(view.stateTransition?.after, "running");
assert.equal(view.metadata.jobId, "job-1");
assert.equal(view.metadata.connector, "portainer");
});
it("omits empty optional sections without fabricating data", () => {
const view = buildAuditEventDetailView({
id: "audit-2",
time: "2026-07-08T10:00:00Z",
source: "system",
user: "appops",
action: "approval.rejected",
target: "appops-smoke-approval",
connector: "approval",
risk: "low",
policy: "blocked",
result: "blocked",
});
assert.equal(view.reason, "No reason provided.");
assert.equal(view.plan.length, 0);
assert.equal(view.postChecks.length, 0);
assert.equal(view.stateTransition, null);
assert.equal(view.metadata.auditEventId, "audit-2");
});
it("redacts sensitive metadata keys before display", () => {
const view = buildAuditEventDetailView({
id: "audit-3",
time: "2026-07-08T10:00:00Z",
source: "manual",
user: "operator",
action: "setup.test.connector",
target: "home_assistant",
connector: "home_assistant",
risk: "low",
policy: "allowed",
result: "failed",
details: {
metadata: {
token: "secret-token",
nested: { apiKey: "secret-key", url: "http://server.local" },
},
},
});
assert.equal(view.metadata.token, "[REDACTED]");
assert.deepEqual(view.metadata.nested, { apiKey: "[REDACTED]", url: "http://server.local" });
});
});
+64
View File
@@ -0,0 +1,64 @@
import type { AuditEvent } from "@/types/appops";
export interface AuditSummaryRow {
label: string;
value: string;
}
export interface AuditStateTransition {
before: string;
after: string;
}
export interface AuditEventDetailView {
summaryRows: AuditSummaryRow[];
reason: string;
plan: string[];
postChecks: string[];
stateTransition: AuditStateTransition | null;
metadata: Record<string, unknown>;
}
const sensitiveKeyPattern = /(api[-_]?key|token|secret|password|credential|authorization|bearer)/i;
const redactMetadata = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(redactMetadata);
if (!value || typeof value !== "object") return value;
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, entry]) => [
key,
sensitiveKeyPattern.test(key) ? "[REDACTED]" : redactMetadata(entry),
]));
};
export const buildAuditEventDetailView = (log: AuditEvent): AuditEventDetailView => {
const details = log.details || {};
const rawMetadata = {
...(details.metadata || {}),
connector: log.connector,
risk: log.risk,
source: log.source,
user: log.user,
timestamp: log.time,
auditEventId: log.id,
};
const before = details.beforeState;
const after = details.afterState;
return {
summaryRows: [
{ label: "Action", value: log.action },
{ label: "Target", value: log.target },
{ label: "Result", value: log.result },
{ label: "Policy", value: log.policy },
{ label: "Risk", value: log.risk },
{ label: "Source", value: log.source },
],
reason: details.reason || "No reason provided.",
plan: details.plan || [],
postChecks: details.postChecks || [],
stateTransition: before || after ? { before: before || "n/a", after: after || "n/a" } : null,
metadata: redactMetadata(rawMetadata) as Record<string, unknown>,
};
};
+78
View File
@@ -0,0 +1,78 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { AuditEvent } from "@/types/appops";
import { buildAuditEmptyState, DEFAULT_AUDIT_LIMIT, filterAuditLogs, getVisibleAuditLogs } from "./auditLogView.ts";
const event = (index: number): AuditEvent => ({
id: `audit-${index}`,
time: new Date(2026, 0, index + 1).toISOString(),
user: "setup",
source: "manual",
action: `setup.test.${index}`,
target: "portainer",
result: "success",
policy: "allowed",
risk: "low",
connector: "setup",
});
describe("getVisibleAuditLogs", () => {
it("limits unfiltered audit rows by default", () => {
const rows = Array.from({ length: DEFAULT_AUDIT_LIMIT + 25 }, (_, index) => event(index));
assert.equal(getVisibleAuditLogs(rows, { expanded: false }).length, DEFAULT_AUDIT_LIMIT);
});
it("keeps all rows available after explicit expansion", () => {
const rows = Array.from({ length: DEFAULT_AUDIT_LIMIT + 25 }, (_, index) => event(index));
assert.equal(getVisibleAuditLogs(rows, { expanded: true }).length, DEFAULT_AUDIT_LIMIT + 25);
});
});
describe("filterAuditLogs", () => {
it("matches event id, action, target, user and detail metadata", () => {
const rows = [
event(1),
{
...event(2),
id: "audit-real-id",
action: "approval.rejected",
target: "appops-smoke-approval",
details: { reason: "Operator rejected smoke approval.", metadata: { approvalId: "appr-real-id" } },
},
];
assert.deepEqual(filterAuditLogs(rows, { query: "audit-real-id", source: "all" }).map(row => row.id), ["audit-real-id"]);
assert.deepEqual(filterAuditLogs(rows, { query: "appr-real-id", source: "all" }).map(row => row.id), ["audit-real-id"]);
assert.deepEqual(filterAuditLogs(rows, { query: "setup.test.1", source: "manual" }).map(row => row.id), ["audit-1"]);
});
});
describe("buildAuditEmptyState", () => {
it("explains empty query results with reset and related jobs actions", () => {
const state = buildAuditEmptyState({ totalLogs: 25, filteredLogs: 0, query: "job-123", source: "all" });
assert.equal(state.title, "Geen audit-events voor deze filter");
assert.match(state.message, /job-123/);
assert.deepEqual(state.actions, [
{ label: "Wis filter", href: "/audit" },
{ label: "Zoek in Jobs", href: "/jobs?query=job-123" },
]);
});
it("does not offer a jobs link for source-only filters", () => {
const state = buildAuditEmptyState({ totalLogs: 25, filteredLogs: 0, query: "", source: "voice" });
assert.equal(state.title, "Geen audit-events voor deze bron");
assert.deepEqual(state.actions, [{ label: "Toon alle bronnen", href: "/audit" }]);
});
it("describes a completely empty audit log without fabricating follow-up links", () => {
const state = buildAuditEmptyState({ totalLogs: 0, filteredLogs: 0, query: "", source: "all" });
assert.equal(state.title, "Nog geen audit-events");
assert.deepEqual(state.actions, []);
});
});
+89
View File
@@ -0,0 +1,89 @@
import type { AuditEvent } from "@/types/appops";
export const DEFAULT_AUDIT_LIMIT = 100;
export interface AuditEmptyStateAction {
label: string;
href: string;
}
export interface AuditEmptyState {
title: string;
message: string;
actions: AuditEmptyStateAction[];
}
export const filterAuditLogs = (
logs: AuditEvent[],
options: { query: string; source: string },
): AuditEvent[] => {
const query = options.query.trim().toLowerCase();
return logs.filter(log => {
const matchesSource = options.source === "all" || log.source === options.source;
if (!matchesSource) return false;
if (!query) return true;
const metadata = log.details?.metadata ? JSON.stringify(log.details.metadata) : "";
return [
log.id,
log.action,
log.target,
log.user,
log.connector,
log.details?.reason || "",
metadata,
].some(value => value.toLowerCase().includes(query));
});
};
export const getVisibleAuditLogs = (
logs: AuditEvent[],
options: { expanded: boolean; limit?: number },
): AuditEvent[] => {
if (options.expanded) {
return logs;
}
return logs.slice(0, options.limit ?? DEFAULT_AUDIT_LIMIT);
};
export const buildAuditEmptyState = (
options: { totalLogs: number; filteredLogs: number; query: string; source: string },
): AuditEmptyState => {
const query = options.query.trim();
const sourceFiltered = options.source !== "all";
if (options.totalLogs === 0) {
return {
title: "Nog geen audit-events",
message: "Er zijn nog geen audit-events beschikbaar. Nieuwe policy-, setup- en actie-events verschijnen hier zodra ze plaatsvinden.",
actions: [],
};
}
if (query) {
return {
title: "Geen audit-events voor deze filter",
message: `Er zijn geen audit-events gevonden voor "${query}". Wis de filter of zoek dezelfde referentie in Jobs wanneer je een job-id volgt.`,
actions: [
{ label: "Wis filter", href: "/audit" },
{ label: "Zoek in Jobs", href: `/jobs?query=${encodeURIComponent(query)}` },
],
};
}
if (sourceFiltered && options.filteredLogs === 0) {
return {
title: "Geen audit-events voor deze bron",
message: `Er zijn audit-events aanwezig, maar niet voor bron "${options.source}". Toon alle bronnen om de volledige geschiedenis te zien.`,
actions: [{ label: "Toon alle bronnen", href: "/audit" }],
};
}
return {
title: "Geen audit-events zichtbaar",
message: "De huidige selectie bevat geen zichtbare audit-events. Pas de filter aan of toon alle events.",
actions: [{ label: "Wis filter", href: "/audit" }],
};
};
+14
View File
@@ -0,0 +1,14 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { toIsoString } from './backendDateTime';
describe('backend datetime normalization', () => {
it('treats timezone-less backend ISO timestamps as UTC', () => {
assert.equal(toIsoString('2026-08-28T03:38:03.703071'), '2026-08-28T03:38:03.703Z');
});
it('preserves timestamps that already carry an explicit offset', () => {
assert.equal(toIsoString('2026-08-28T05:38:03.703+02:00'), '2026-08-28T03:38:03.703Z');
});
});
+16
View File
@@ -0,0 +1,16 @@
const NAIVE_UTC_ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?$/;
export const toIsoString = (value: unknown, fallback = ''): string => {
if (!value) return fallback;
if (value instanceof Date) return value.toISOString();
if (typeof value === 'number') return new Date(value).toISOString();
if (typeof value === 'string') {
// FastAPI serializes the existing naive SQLite UTC columns without a zone.
// Append Z so browsers do not reinterpret them as local wall-clock time.
const parsed = new Date(NAIVE_UTC_ISO.test(value) ? `${value}Z` : value);
return Number.isNaN(parsed.getTime()) ? fallback : parsed.toISOString();
}
return fallback;
};
+18
View File
@@ -0,0 +1,18 @@
export const copyTextToClipboard = async (value: string): Promise<void> => {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value);
return;
}
const textarea = document.createElement("textarea");
textarea.value = value;
textarea.readOnly = true;
textarea.setAttribute("aria-hidden", "true");
textarea.style.position = "fixed";
textarea.style.inset = "0 auto auto -9999px";
document.body.appendChild(textarea);
textarea.select();
const copied = document.execCommand("copy");
textarea.remove();
if (!copied) throw new Error("clipboard_unavailable");
};
+58
View File
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { buildCommandActionFollowup } from "./commandActionFollowup.ts";
const plan = {
intent: "container.restart",
target: "widefrog",
};
describe("buildCommandActionFollowup", () => {
it("links approval-required results to approvals, audit and job follow-up when references exist", () => {
const view = buildCommandActionFollowup({
success: false,
decision: "approval_required",
result: "approval_required",
message: "Approval required.",
approvalId: "apr_123",
auditId: "aud_456",
jobId: "job_789",
}, plan);
assert.equal(view.tone, "approval");
assert.equal(view.title, "Approval handoff klaar");
assert.deepEqual(view.links, [
{ label: "Open approvals", href: "/approvals?query=apr_123" },
{ label: "Open job", href: "/jobs?query=job_789" },
{ label: "Open audit", href: "/audit?query=aud_456" },
]);
});
it("keeps missing configuration actionable through setup", () => {
const view = buildCommandActionFollowup({
success: false,
status: "missing_config",
message: "Missing connector config.",
}, plan);
assert.equal(view.tone, "error");
assert.equal(view.title, "Configuratie nodig");
assert.deepEqual(view.links, [
{ label: "Open setup", href: "/setup?step=connectors" },
]);
});
it("uses audit as the default follow-up for successful actions without specific references", () => {
const view = buildCommandActionFollowup({
success: true,
result: "success",
message: "Done.",
}, plan);
assert.equal(view.tone, "success");
assert.equal(view.title, "Actie verwerkt");
assert.deepEqual(view.links, [
{ label: "Open audit", href: "/audit" },
]);
});
});
+63
View File
@@ -0,0 +1,63 @@
import type { ActionPlan, ActionResult } from "../types/appops.ts";
import { buildActionResultFeedback, isApprovalRequiredActionResult, type ActionResultFeedbackTone } from "./actionResultStatus.ts";
export interface CommandActionFollowupLink {
label: string;
href: string;
}
export interface CommandActionFollowup {
tone: ActionResultFeedbackTone;
title: string;
message: string;
links: CommandActionFollowupLink[];
}
const queryHref = (base: string, query: string): string => `${base}?query=${encodeURIComponent(query)}`;
export const buildCommandActionFollowup = (
result: ActionResult,
plan: Pick<ActionPlan, "intent" | "target">,
): CommandActionFollowup => {
const feedback = buildActionResultFeedback(result, {
success: `${plan.intent} op ${plan.target} verwerkt.`,
approval: `${plan.intent} op ${plan.target} wacht op approval.`,
failure: `${plan.intent} op ${plan.target} kon niet worden verwerkt.`,
});
const approvalRequired = isApprovalRequiredActionResult(result) || result.result === "approval_requested" || Boolean(result.approvalId);
const blocked = result.decision === "blocked" || result.result === "blocked" || result.status === "blocked";
const missingConfig = result.status === "missing_config" || result.missing_config;
const links: CommandActionFollowupLink[] = [];
if (result.approvalId || approvalRequired) {
links.push({ label: "Open approvals", href: result.approvalId ? queryHref("/approvals", result.approvalId) : "/approvals" });
}
if (result.jobId) {
links.push({ label: "Open job", href: queryHref("/jobs", result.jobId) });
}
if (result.auditId) {
links.push({ label: "Open audit", href: queryHref("/audit", result.auditId) });
}
if (missingConfig) {
links.push({ label: "Open setup", href: "/setup?step=connectors" });
}
if (!links.length) {
links.push({ label: "Open audit", href: "/audit" });
}
return {
tone: blocked || missingConfig ? "error" : feedback.tone,
title: approvalRequired
? "Approval handoff klaar"
: blocked
? "Actie geblokkeerd"
: missingConfig
? "Configuratie nodig"
: result.success
? "Actie verwerkt"
: "Actie vraagt aandacht",
message: feedback.message,
links,
};
};
+38
View File
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { buildCommandActionView } from "./commandActionView.ts";
describe("buildCommandActionView", () => {
it("labels allowed command actions as policy-gated execution", () => {
const view = buildCommandActionView({ intent: "container.restart", policy: "allowed", risk: "medium" });
assert.equal(view.tone, "execute");
assert.equal(view.buttonLabel, "Uitvoeren: container.restart");
assert.match(view.copy, /unsupported\/failed/i);
});
it("keeps approval-required command actions explicit and non-executing", () => {
const view = buildCommandActionView({ intent: "stack.redeploy", policy: "approval_required", risk: "high" });
assert.equal(view.tone, "approval");
assert.equal(view.buttonLabel, "stack.redeploy");
assert.match(view.copy, /niet uitgevoerd/i);
});
it("marks blocked command actions as unavailable for approval or execution", () => {
const view = buildCommandActionView({ intent: "volume.delete", policy: "blocked", risk: "high" });
assert.equal(view.tone, "blocked");
assert.equal(view.buttonLabel, "Geblokkeerd: volume.delete");
assert.match(view.copy, /voert deze actie niet uit/i);
});
it("routes critical command actions through override guidance first", () => {
const view = buildCommandActionView({ intent: "container.restart", policy: "allowed", risk: "critical" });
assert.equal(view.tone, "critical");
assert.equal(view.buttonLabel, "container.restart");
assert.match(view.copy, /Directe uitvoering is geblokkeerd/i);
});
});
+50
View File
@@ -0,0 +1,50 @@
import type { ActionPlan } from "@/types/appops";
export type CommandActionViewTone = "execute" | "approval" | "blocked" | "critical";
export interface CommandActionView {
tone: CommandActionViewTone;
buttonLabel: string;
title: string;
copy: string;
}
export const buildCommandActionView = (
plan: Pick<ActionPlan, "intent" | "policy" | "risk">,
): CommandActionView => {
const intent = String(plan.intent);
if (plan.risk === "critical") {
return {
tone: "critical",
buttonLabel: intent,
title: "Kritieke actie",
copy: "Directe uitvoering is geblokkeerd zonder expliciete override. Gebruik eerst audit preview en werk via approval of handmatige operatorflow.",
};
}
if (plan.policy === "blocked") {
return {
tone: "blocked",
buttonLabel: `Geblokkeerd: ${intent}`,
title: "Policy blokkeert deze actie",
copy: "AppOps voert deze actie niet uit en vraagt er ook geen approval voor aan. Controleer de policyreden en gebruik alleen een apart ontworpen safe flow.",
};
}
if (plan.policy === "approval_required") {
return {
tone: "approval",
buttonLabel: intent,
title: "Approval-first handoff",
copy: "Deze knop maakt een approvalaanvraag en schrijft auditcontext. De externe connectoractie wordt niet uitgevoerd voordat de operatorflow dat toestaat.",
};
}
return {
tone: "execute",
buttonLabel: `Uitvoeren: ${intent}`,
title: "Policy-gated uitvoering",
copy: "Deze actie gaat door de backend executor, policy en audit. Als er geen veilige executor bestaat, toont AppOps een unsupported/failed handoff in plaats van fake success.",
};
};
+17
View File
@@ -0,0 +1,17 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { normalizeCommandCenterQuery } from "./commandCenterQuery.ts";
describe("normalizeCommandCenterQuery", () => {
it("turns deep-link query params into safe command center input text", () => {
assert.equal(normalizeCommandCenterQuery(" deluge stalled downloads "), "deluge stalled downloads");
assert.equal(normalizeCommandCenterQuery(null), "");
assert.equal(normalizeCommandCenterQuery(""), "");
});
it("caps very long handoff queries before they enter the command input", () => {
const value = "x".repeat(240);
assert.equal(normalizeCommandCenterQuery(value).length, 120);
});
});
+3
View File
@@ -0,0 +1,3 @@
export const normalizeCommandCenterQuery = (query: string | null): string => {
return (query || "").trim().slice(0, 120);
};
+25
View File
@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { buildCommandPaletteGroups } from "./commandPaletteNavigation.ts";
import { flattenNavigationGroups, navigationGroups } from "./navigationGroups.ts";
describe("buildCommandPaletteGroups", () => {
it("mirrors the sidebar navigation groups without dropping routes", () => {
const groups = buildCommandPaletteGroups(navigationGroups);
const routes = groups.flatMap((group) => group.items.map((item) => item.path));
assert.deepEqual(groups.map((group) => group.label), navigationGroups.map((group) => group.label));
assert.deepEqual([...routes].sort(), [...flattenNavigationGroups().map((item) => item.path)].sort());
});
it("uses search labels that keep route intent readable", () => {
const groups = buildCommandPaletteGroups(navigationGroups);
const itemByPath = Object.fromEntries(groups.flatMap((group) => group.items.map((item) => [item.path, item])));
assert.equal(itemByPath["/"].searchLabel, "Overzicht");
assert.equal(itemByPath["/apps"].searchLabel, "Apps");
assert.equal(itemByPath["/settings"].searchLabel, "Instellingen");
assert.equal(itemByPath["/ai/security-reviewer"].searchLabel, "AI Security Reviewer");
});
});
+19
View File
@@ -0,0 +1,19 @@
import type { NavigationGroup, NavigationItem } from "./navigationGroups";
export type CommandPaletteItem = NavigationItem & {
searchLabel: string;
};
export type CommandPaletteGroup = {
label: string;
items: CommandPaletteItem[];
};
export const buildCommandPaletteGroups = (groups: NavigationGroup[]): CommandPaletteGroup[] =>
groups.map((group) => ({
label: group.label,
items: group.items.map((item) => ({
...item,
searchLabel: item.label,
})),
}));
+16
View File
@@ -0,0 +1,16 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { isCommandPaletteShortcut } from "./commandPaletteShortcut.ts";
describe("isCommandPaletteShortcut", () => {
it("accepts Ctrl+K and Meta+K regardless of key casing", () => {
assert.equal(isCommandPaletteShortcut({ key: "k", ctrlKey: true, metaKey: false }), true);
assert.equal(isCommandPaletteShortcut({ key: "K", ctrlKey: false, metaKey: true }), true);
});
it("rejects plain K or unrelated shortcuts", () => {
assert.equal(isCommandPaletteShortcut({ key: "k", ctrlKey: false, metaKey: false }), false);
assert.equal(isCommandPaletteShortcut({ key: "j", ctrlKey: true, metaKey: false }), false);
});
});
+11
View File
@@ -0,0 +1,11 @@
export type CommandPaletteShortcutLike = {
ctrlKey?: boolean;
key: string;
metaKey?: boolean;
};
export const isCommandPaletteShortcut = (event: CommandPaletteShortcutLike): boolean =>
event.key.toLowerCase() === "k" && Boolean(event.metaKey || event.ctrlKey);
export const createCommandPaletteShortcutEvent = (): KeyboardEvent =>
new KeyboardEvent("keydown", { bubbles: true, key: "k", metaKey: true });
+26
View File
@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { ActionPlan } from "@/types/appops";
import { buildCommandPreflightRequest } from "./commandPreflight.ts";
const plan: ActionPlan = {
intent: "container.restart",
target: "widefrog",
source: "manual",
risk: "medium",
policy: "allowed",
reason: "Operator requested restart.",
plan: ["Check health", "Restart container"],
postChecks: ["Container healthy"],
};
describe("buildCommandPreflightRequest", () => {
it("uses the current command plan as the preflight source", () => {
assert.deepEqual(buildCommandPreflightRequest(plan), {
action: "container.restart",
target: "widefrog",
source: "manual",
});
});
});
+13
View File
@@ -0,0 +1,13 @@
import type { ActionPlan } from "@/types/appops";
export type CommandPreflightRequest = {
action: string;
target: string;
source: ActionPlan["source"];
};
export const buildCommandPreflightRequest = (plan: ActionPlan): CommandPreflightRequest => ({
action: plan.intent,
target: plan.target,
source: plan.source,
});
+130
View File
@@ -0,0 +1,130 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { buildConnectorActionFollowup } from "./connectorActionFollowup.ts";
import type { ActionResult } from "../types/appops.ts";
const result = (overrides: Partial<ActionResult>): ActionResult => ({
success: false,
decision: "approval_required",
status: "failed",
result: "approval_required",
message: "Action requires approval before execution.",
...overrides,
});
describe("buildConnectorActionFollowup", () => {
it("links approval-required Deluge actions to approvals, audit and Deluge detail", () => {
const view = buildConnectorActionFollowup(result({
approvalId: "apr-deluge",
auditId: "audit-deluge",
}), {
connectorId: "deluge",
connectorLabel: "Deluge",
success: "Deluge actie uitgevoerd.",
approval: "Deluge approval aangevraagd.",
failure: "Deluge actie vereist aandacht.",
});
assert.equal(view.tone, "approval");
assert.equal(view.title, "Approval handoff klaar");
assert.deepEqual(view.links, [
{ label: "Open approvals", href: "/approvals?query=apr-deluge" },
{ label: "Open audit", href: "/audit?query=audit-deluge" },
{ label: "Open Deluge", href: "/apps/deluge" },
]);
assert.match(view.safetyNote, /delete-with-data blijft geblokkeerd/i);
});
it("links successful Plex scans to jobs, audit and Plex detail", () => {
const view = buildConnectorActionFollowup(result({
success: true,
decision: "allowed",
policy_decision: "allowed",
status: "success",
result: "success",
jobId: "job-plex",
auditId: "audit-plex",
}), {
connectorId: "plex",
connectorLabel: "Plex",
success: "Plex scan gestart.",
approval: "Plex scan-approval aangevraagd.",
failure: "Plex scan vereist aandacht.",
});
assert.equal(view.tone, "success");
assert.deepEqual(view.links, [
{ label: "Open job", href: "/jobs?query=job-plex" },
{ label: "Open audit", href: "/audit?query=audit-plex" },
{ label: "Open Plex", href: "/apps/plex" },
]);
});
it("links missing Sonarr config to setup and Sonarr detail", () => {
const view = buildConnectorActionFollowup(result({
decision: "allowed",
policy_decision: "allowed",
result: "failed",
status: "missing_config",
missing_config: true,
}), {
connectorId: "sonarr",
connectorLabel: "Sonarr",
success: "Serie toegevoegd aan Sonarr.",
approval: "Sonarr approval aangevraagd.",
failure: "Sonarr actie mislukt.",
});
assert.equal(view.tone, "error");
assert.deepEqual(view.links, [
{ label: "Open setup", href: "/setup?step=connectors&connector=sonarr" },
{ label: "Open Sonarr", href: "/apps/sonarr" },
]);
assert.match(view.safetyNote, /series add.*policy/i);
});
it("links approval-required Widefrog retries to approval, audit and Widefrog detail", () => {
const view = buildConnectorActionFollowup(result({
approvalId: "apr-widefrog",
auditId: "audit-widefrog",
}), {
connectorId: "widefrog",
connectorLabel: "Widefrog",
success: "Widefrog actie gestart.",
approval: "Widefrog approval aangevraagd.",
failure: "Widefrog actie mislukt.",
});
assert.equal(view.tone, "approval");
assert.deepEqual(view.links, [
{ label: "Open approvals", href: "/approvals?query=apr-widefrog" },
{ label: "Open audit", href: "/audit?query=audit-widefrog" },
{ label: "Open Widefrog", href: "/apps/widefrog" },
]);
assert.match(view.safetyNote, /retry.*scan/i);
});
it("links blocked Radarr add attempts to audit and Radarr detail", () => {
const view = buildConnectorActionFollowup(result({
decision: "blocked",
policy_decision: "blocked",
result: "blocked",
status: "blocked",
auditId: "audit-radarr",
}), {
connectorId: "radarr",
connectorLabel: "Radarr",
success: "Film toegevoegd aan Radarr.",
approval: "Radarr approval aangevraagd.",
failure: "Radarr actie mislukt.",
});
assert.equal(view.tone, "error");
assert.deepEqual(view.links, [
{ label: "Open audit", href: "/audit?query=audit-radarr" },
{ label: "Open Radarr", href: "/apps/radarr" },
]);
assert.match(view.safetyNote, /movie add.*policy/i);
});
});
+90
View File
@@ -0,0 +1,90 @@
import type { ActionResult } from "../types/appops.ts";
import { buildActionResultFeedback, isApprovalRequiredActionResult, type ActionResultFeedbackTone } from "./actionResultStatus.ts";
export interface ConnectorActionFollowupCopy {
connectorId: string;
connectorLabel: string;
success: string;
approval: string;
failure: string;
}
export interface ConnectorActionFollowupLink {
label: string;
href: string;
}
export interface ConnectorActionFollowup {
tone: ActionResultFeedbackTone;
title: string;
message: string;
safetyNote: string;
links: ConnectorActionFollowupLink[];
}
const queryHref = (base: string, query: string): string => `${base}?query=${encodeURIComponent(query)}`;
const connectorSafetyNote = (connectorId: string): string => {
if (connectorId === "deluge") {
return "Deluge pause/resume blijft policy-aware; delete-with-data blijft geblokkeerd in AppOps V1.";
}
if (connectorId === "plex") {
return "Plex scan-acties zijn policy-aware en verwijderen geen media of libraries.";
}
if (connectorId === "widefrog") {
return "Widefrog retry en scan lopen via policy, audit en waar nodig approval; AppOps verwijdert geen queue- of librarydata.";
}
if (connectorId === "sonarr") {
return "Sonarr series add loopt via policy, audit en waar nodig approval; bestaande series en files worden niet verwijderd.";
}
if (connectorId === "radarr") {
return "Radarr movie add loopt via policy, audit en waar nodig approval; delete movie/delete files blijven geblokkeerd.";
}
return "Connectoractie loopt via policy, audit en waar nodig approval.";
};
export const buildConnectorActionFollowup = (
result: ActionResult,
copy: ConnectorActionFollowupCopy,
): ConnectorActionFollowup => {
const feedback = buildActionResultFeedback(result, {
success: copy.success,
approval: copy.approval,
failure: copy.failure,
});
const approvalRequired = isApprovalRequiredActionResult(result) || result.result === "approval_requested" || Boolean(result.approvalId);
const blocked = result.decision === "blocked" || result.result === "blocked" || result.status === "blocked";
const missingConfig = result.status === "missing_config" || result.missing_config;
const links: ConnectorActionFollowupLink[] = [];
if (approvalRequired || result.approvalId) {
links.push({ label: "Open approvals", href: result.approvalId ? queryHref("/approvals", result.approvalId) : "/approvals" });
}
if (result.jobId) {
links.push({ label: "Open job", href: queryHref("/jobs", result.jobId) });
}
if (result.auditId) {
links.push({ label: "Open audit", href: queryHref("/audit", result.auditId) });
}
if (missingConfig) {
links.push({ label: "Open setup", href: `/setup?step=connectors&connector=${encodeURIComponent(copy.connectorId)}` });
}
links.push({ label: `Open ${copy.connectorLabel}`, href: `/apps/${copy.connectorId}` });
return {
tone: blocked || missingConfig ? "error" : feedback.tone,
title: approvalRequired
? "Approval handoff klaar"
: blocked
? "Actie geblokkeerd"
: missingConfig
? "Configuratie nodig"
: result.success
? "Actie verwerkt"
: "Actie vraagt aandacht",
message: feedback.message,
safetyNote: connectorSafetyNote(copy.connectorId),
links,
};
};
+14
View File
@@ -0,0 +1,14 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { buildConnectorConfigLink } from "./connectorConfigLink.ts";
describe("buildConnectorConfigLink", () => {
it("deep-links to the editable connector setup form", () => {
assert.equal(buildConnectorConfigLink("nginx_proxy_manager"), "/setup?step=connectors&connector=nginx_proxy_manager");
});
it("encodes connector ids safely", () => {
assert.equal(buildConnectorConfigLink("custom connector"), "/setup?step=connectors&connector=custom%20connector");
});
});
+3
View File
@@ -0,0 +1,3 @@
export const buildConnectorConfigLink = (connectorId: string): string => (
`/setup?step=connectors&connector=${encodeURIComponent(connectorId)}`
);
+89
View File
@@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { Connector, ConnectorBinding, ConnectorCandidate } from "@/types/appops";
import { buildConnectorDrillView } from "./connectorDrillView.ts";
const connector = (overrides: Partial<Connector> = {}): Connector => ({
id: "sonarr",
name: "Sonarr",
status: "connected",
lastTest: new Date(0).toISOString(),
capabilities: ["health", "queue", "wanted"],
type: "media",
...overrides,
});
const candidate = (overrides: Partial<ConnectorCandidate> = {}): ConnectorCandidate => ({
connectorId: "sonarr",
discoveredContainerId: "dc-sonarr",
containerId: "sonarr",
containerName: "sonarr",
displayName: "Sonarr",
appType: "sonarr",
lifecycleState: "discovered",
suggestedAppId: "sonarr",
connectorStatus: "connected",
missingSecrets: [],
requiredSecrets: [],
confidence: 0.98,
reasons: ["name matched"],
ports: [],
...overrides,
});
const binding = (overrides: Partial<ConnectorBinding> = {}): ConnectorBinding => ({
connectorId: "sonarr",
appId: "sonarr",
appName: "Sonarr",
containerId: "sonarr",
containerName: "sonarr",
mappingId: "map-sonarr",
connectorStatus: "connected",
lifecycleState: "managed",
missingSecrets: [],
requiredSecrets: [],
needsReview: false,
...overrides,
});
describe("connectorDrillView", () => {
it("summarizes a connected connector with bindings", () => {
const view = buildConnectorDrillView(connector(), [], [binding()]);
assert.equal(view.healthLabel, "Live gekoppeld");
assert.equal(view.severity, "healthy");
assert.equal(view.configHref, "/setup?step=connectors&connector=sonarr");
assert.equal(view.primaryActionLabel, "Bekijk configuratie");
assert.equal(view.counts.bindings, 1);
assert.equal(view.counts.openCandidates, 0);
assert.match(view.operatorHint, /1 app/i);
});
it("prioritizes missing configuration and unique secret refs", () => {
const view = buildConnectorDrillView(
connector({ status: "missing_config", secretRef: "SONARR_API_KEY" }),
[candidate({ missingSecrets: ["SONARR_API_KEY", "SONARR_URL"] })],
[binding({ missingSecrets: ["SONARR_URL"] })],
);
assert.equal(view.healthLabel, "Configuratie mist");
assert.equal(view.severity, "critical");
assert.deepEqual(view.missingConfig, ["SONARR_API_KEY", "SONARR_URL"]);
assert.equal(view.primaryActionLabel, "Open setup");
assert.match(view.operatorHint, /ontbrekende configuratie/i);
});
it("surfaces bind work when candidates are not already bound", () => {
const view = buildConnectorDrillView(
connector({ status: "configured" }),
[candidate({ suggestedAppId: "radarr", displayName: "Radarr" })],
[],
);
assert.equal(view.severity, "warning");
assert.equal(view.counts.openCandidates, 1);
assert.equal(view.primaryActionLabel, "Review binding");
assert.match(view.operatorHint, /1 open candidate/i);
});
});
+104
View File
@@ -0,0 +1,104 @@
import type { Connector, ConnectorBinding, ConnectorCandidate } from "@/types/appops";
import { buildConnectorConfigLink } from "./connectorConfigLink.ts";
export type ConnectorDrillSeverity = "healthy" | "warning" | "critical" | "neutral";
export interface ConnectorDrillView {
healthLabel: string;
severity: ConnectorDrillSeverity;
operatorHint: string;
primaryActionLabel: string;
configHref: string;
missingConfig: string[];
counts: {
candidates: number;
openCandidates: number;
bindings: number;
capabilities: number;
};
}
const unique = (items: string[]) => Array.from(new Set(items.filter(Boolean)));
export const buildConnectorDrillView = (
connector: Connector,
candidates: ConnectorCandidate[],
bindings: ConnectorBinding[],
): ConnectorDrillView => {
const boundAppIds = new Set(bindings.map(binding => binding.appId));
const openCandidates = candidates.filter(candidate => (
!boundAppIds.has(candidate.suggestedAppId) &&
candidate.lifecycleState !== "managed"
));
const missingConfig = unique([
...(connector.status === "missing_config" && connector.secretRef ? [connector.secretRef] : []),
...candidates.flatMap(candidate => candidate.missingSecrets || []),
...bindings.flatMap(binding => binding.missingSecrets || []),
]);
if (missingConfig.length > 0 || connector.status === "missing_config") {
return {
healthLabel: "Configuratie mist",
severity: "critical",
operatorHint: `${missingConfig.length || 1} ontbrekende configuratievelden blokkeren volledige connectorcoverage.`,
primaryActionLabel: "Open setup",
configHref: buildConnectorConfigLink(connector.id),
missingConfig,
counts: {
candidates: candidates.length,
openCandidates: openCandidates.length,
bindings: bindings.length,
capabilities: connector.capabilities.length,
},
};
}
if (openCandidates.length > 0) {
return {
healthLabel: "Binding review",
severity: "warning",
operatorHint: `${openCandidates.length} open candidate${openCandidates.length === 1 ? "" : "s"} klaar voor review.`,
primaryActionLabel: "Review binding",
configHref: buildConnectorConfigLink(connector.id),
missingConfig,
counts: {
candidates: candidates.length,
openCandidates: openCandidates.length,
bindings: bindings.length,
capabilities: connector.capabilities.length,
},
};
}
if (connector.status === "connected" || connector.status === "configured") {
return {
healthLabel: "Live gekoppeld",
severity: "healthy",
operatorHint: `${bindings.length} app${bindings.length === 1 ? "" : "s"} gekoppeld; connector is klaar voor veilige reads.`,
primaryActionLabel: "Bekijk configuratie",
configHref: buildConnectorConfigLink(connector.id),
missingConfig,
counts: {
candidates: candidates.length,
openCandidates: openCandidates.length,
bindings: bindings.length,
capabilities: connector.capabilities.length,
},
};
}
return {
healthLabel: "Niet verbonden",
severity: "neutral",
operatorHint: "Geen actieve connectorcoverage bekend. Test of configureer deze connector.",
primaryActionLabel: "Configureer",
configHref: buildConnectorConfigLink(connector.id),
missingConfig,
counts: {
candidates: candidates.length,
openCandidates: openCandidates.length,
bindings: bindings.length,
capabilities: connector.capabilities.length,
},
};
};
+48
View File
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { buildConnectorMutationFeedback, buildConnectorMutationKey } from "./connectorMutationView.ts";
describe("connectorMutationView", () => {
it("builds stable mutation keys for bind and unbind operations", () => {
assert.equal(buildConnectorMutationKey("bind", "sonarr", "dc-sonarr"), "bind:sonarr:dc-sonarr");
assert.equal(buildConnectorMutationKey("unbind", "plex", "plex"), "unbind:plex:plex");
});
it("describes successful bind and unbind results without implying container mutation", () => {
const bind = buildConnectorMutationFeedback({
operation: "bind",
connectorId: "sonarr",
subjectName: "Sonarr",
success: true,
message: "ok",
});
const unbind = buildConnectorMutationFeedback({
operation: "unbind",
connectorId: "sonarr",
subjectName: "Sonarr",
success: true,
message: "ok",
});
assert.equal(bind.tone, "success");
assert.match(bind.title, /binding opgeslagen/i);
assert.match(bind.description, /metadata-only/i);
assert.equal(unbind.tone, "success");
assert.match(unbind.title, /binding verwijderd/i);
});
it("keeps failed mutation feedback actionable", () => {
const feedback = buildConnectorMutationFeedback({
operation: "bind",
connectorId: "deluge",
subjectName: "Deluge",
success: false,
message: "Missing DELUGE_URL",
});
assert.equal(feedback.tone, "error");
assert.match(feedback.title, /niet opgeslagen/i);
assert.match(feedback.description, /Missing DELUGE_URL/);
});
});
+46
View File
@@ -0,0 +1,46 @@
export type ConnectorMutationOperation = "bind" | "unbind";
export type ConnectorMutationTone = "success" | "error";
export interface ConnectorMutationFeedbackInput {
operation: ConnectorMutationOperation;
connectorId: string;
subjectName: string;
success: boolean;
message?: string;
}
export interface ConnectorMutationFeedback {
tone: ConnectorMutationTone;
title: string;
description: string;
}
export const buildConnectorMutationKey = (
operation: ConnectorMutationOperation,
connectorId: string,
subjectId: string,
): string => `${operation}:${connectorId}:${subjectId}`;
export const buildConnectorMutationFeedback = (input: ConnectorMutationFeedbackInput): ConnectorMutationFeedback => {
if (!input.success) {
return {
tone: "error",
title: `${input.subjectName} niet opgeslagen`,
description: input.message || "Connectorbinding kon niet worden bijgewerkt. Controleer setup en probeer opnieuw.",
};
}
if (input.operation === "bind") {
return {
tone: "success",
title: `${input.subjectName} binding opgeslagen`,
description: `Metadata-only koppeling aan ${input.connectorId}; er is geen container gemuteerd.`,
};
}
return {
tone: "success",
title: `${input.subjectName} binding verwijderd`,
description: `Metadata-only ontkoppeld van ${input.connectorId}; app en container blijven bestaan.`,
};
};
+21
View File
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { connectorPanelStatus } from "./connectorPanelStatus.ts";
describe("connectorPanelStatus", () => {
it("does not present unloaded connector health as missing config", () => {
assert.equal(connectorPanelStatus(undefined), "checking");
assert.equal(connectorPanelStatus(null), "checking");
});
it("maps connected health to the shared healthy chip state", () => {
assert.equal(connectorPanelStatus("connected"), "healthy");
});
it("keeps real connector failure states visible", () => {
assert.equal(connectorPanelStatus("missing_config"), "missing_config");
assert.equal(connectorPanelStatus("auth_failed"), "auth_failed");
assert.equal(connectorPanelStatus("unreachable"), "unreachable");
});
});
+4
View File
@@ -0,0 +1,4 @@
export const connectorPanelStatus = (status?: string | null): string => {
if (!status) return "checking";
return status === "connected" ? "healthy" : status;
};
+57
View File
@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { Container } from "../types/appops.ts";
import { buildContainerFocusSummary, filterContainersByFocus } from "./containerFocusView.ts";
const container = (overrides: Partial<Container> = {}): Container => ({
id: "container-1",
name: "widefrog",
appId: "widefrog",
state: "running",
health: "healthy",
image: "widefrog:latest",
ports: [],
cpu: "2%",
ram: "512 MiB",
restartCount: 0,
criticality: "medium",
voiceEnabled: false,
protected: false,
connectorType: "widefrog",
importStatus: "managed",
...overrides,
});
describe("containerFocusView", () => {
it("separates operational attention from missing telemetry coverage", () => {
const containers = [
container(),
container({ id: "unhealthy", name: "broken", health: "unhealthy" }),
container({ id: "unknown", name: "unknown", importStatus: "unmapped", connectorType: "none" }),
container({ id: "no-metrics", name: "no-metrics", cpu: "n/a", ram: "n/a" }),
container({ id: "stopped-no-metrics", name: "stopped", state: "exited", cpu: "n/a", ram: "n/a", metricsStatus: "not_running" }),
];
const summary = buildContainerFocusSummary(containers);
assert.equal(summary.total, 5);
assert.equal(summary.attention, 2);
assert.equal(summary.unhealthy, 1);
assert.equal(summary.unmapped, 1);
assert.equal(summary.missingMetrics, 1);
assert.equal(summary.measuredMetrics, 3);
});
it("keeps the default focus on unhealthy or unmapped containers", () => {
const containers = [
container({ id: "ok" }),
container({ id: "broken", health: "unhealthy" }),
container({ id: "unknown", importStatus: "unmapped" }),
container({ id: "metrics-only", cpu: "n/a", ram: "n/a" }),
];
assert.deepEqual(filterContainersByFocus(containers, "attention").map(item => item.id), ["broken", "unknown"]);
assert.deepEqual(filterContainersByFocus(containers, "missing_metrics").map(item => item.id), ["metrics-only"]);
});
});
+100
View File
@@ -0,0 +1,100 @@
import type { Container } from "../types/appops.ts";
export type ContainerFocusFilter =
| "all"
| "attention"
| "running"
| "unhealthy"
| "unmapped"
| "unknown"
| "protected"
| "managed"
| "connector_available"
| "no_connector"
| "missing_metrics";
export interface ContainerFocusSummary {
total: number;
attention: number;
unhealthy: number;
unmapped: number;
unknown: number;
protected: number;
missingMetrics: number;
measuredMetrics: number;
noConnector: number;
}
const statusValue = (value: unknown): string => String(value || "").toLowerCase();
export const hasMissingContainerMetrics = (container: Container): boolean => (
container.state === "running"
&& statusValue(container.metricsStatus) !== "measured"
&& (!container.cpu
|| !container.ram
|| statusValue(container.cpu) === "n/a"
|| statusValue(container.ram) === "n/a")
);
export const hasMeasuredContainerMetrics = (container: Container): boolean => (
statusValue(container.metricsStatus) === "measured"
|| (
Boolean(container.cpu)
&& Boolean(container.ram)
&& statusValue(container.cpu) !== "n/a"
&& statusValue(container.ram) !== "n/a"
)
);
export const isUnmappedContainer = (container: Container): boolean => {
const importStatus = statusValue(container.importStatus);
return ["unmapped", "changed", "missing"].includes(importStatus);
};
export const containerNeedsOperatorAttention = (container: Container): boolean => (
container.health === "unhealthy"
|| container.health === "critical"
|| isUnmappedContainer(container)
);
export const buildContainerFocusSummary = (containers: Container[]): ContainerFocusSummary => {
const summary: ContainerFocusSummary = {
total: containers.length,
attention: 0,
unhealthy: 0,
unmapped: 0,
unknown: 0,
protected: 0,
missingMetrics: 0,
measuredMetrics: 0,
noConnector: 0,
};
for (const container of containers) {
if (containerNeedsOperatorAttention(container)) summary.attention += 1;
if (container.health === "unhealthy" || container.health === "critical") summary.unhealthy += 1;
if (isUnmappedContainer(container)) summary.unmapped += 1;
if (statusValue(container.appType) === "unknown") summary.unknown += 1;
if (container.protected) summary.protected += 1;
if (hasMissingContainerMetrics(container)) summary.missingMetrics += 1;
if (hasMeasuredContainerMetrics(container)) summary.measuredMetrics += 1;
if (!container.connectorType || statusValue(container.connectorType) === "none") summary.noConnector += 1;
}
return summary;
};
export const filterContainersByFocus = (containers: Container[], focus: ContainerFocusFilter): Container[] => {
if (focus === "all") return containers;
if (focus === "attention") return containers.filter(containerNeedsOperatorAttention);
if (focus === "running") return containers.filter(container => container.state === "running");
if (focus === "unhealthy") return containers.filter(container => container.health === "unhealthy" || container.health === "critical");
if (focus === "unmapped") return containers.filter(isUnmappedContainer);
if (focus === "unknown") return containers.filter(container => statusValue(container.appType) === "unknown");
if (focus === "protected") return containers.filter(container => container.protected === true);
if (focus === "managed") return containers.filter(container => container.isManaged === true || container.importStatus === "managed");
if (focus === "connector_available") return containers.filter(container => Boolean(container.connectorType) && statusValue(container.connectorType) !== "none");
if (focus === "no_connector") return containers.filter(container => !container.connectorType || statusValue(container.connectorType) === "none");
if (focus === "missing_metrics") return containers.filter(hasMissingContainerMetrics);
return containers;
};
+106
View File
@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { Container, DiscoveredContainer } from "@/types/appops";
import { mergeInventoryContainersWithLive } from "./containerInventoryMerge.ts";
const liveContainer = (overrides: Partial<Container>): Container => ({
id: "live-1",
name: "radarr",
appId: "radarr",
state: "running",
health: "healthy",
image: "radarr:latest",
ports: ["7878:7878"],
cpu: "n/a",
ram: "n/a",
restartCount: 0,
criticality: "medium",
voiceEnabled: false,
protected: false,
...overrides,
});
const discoveredContainer = (overrides: Partial<DiscoveredContainer>): DiscoveredContainer => ({
id: "dc-1",
discoveryRunId: "run-1",
containerId: "live-1",
name: "radarr",
image: "radarr:latest",
state: "running",
status: "running",
ports: [7878],
mounts: [],
networks: [],
labels: {},
suggestedAppId: "radarr",
displayName: "Radarr",
suggestedAppType: "media",
suggestedCategory: "media",
suggestedCriticality: "medium",
suggestedConnector: "radarr",
suggestedPolicyProfile: "media",
protected: true,
confidence: 0.9,
reasons: [],
importStatus: "managed",
source: "portainer",
lifecycleState: "managed",
connectorRequiredSecrets: [],
actionsAvailable: [],
setupChecklist: [],
...overrides,
});
describe("mergeInventoryContainersWithLive", () => {
it("keeps live portainer URLs while adding inventory metadata", () => {
const merged = mergeInventoryContainersWithLive(
[liveContainer({ portainerUrl: "http://portainer/container/live-1" })],
[discoveredContainer({ matchedAppId: "radarr", isManaged: true })],
);
assert.equal(merged.length, 1);
assert.equal(merged[0].id, "live-1");
assert.equal(merged[0].portainerUrl, "http://portainer/container/live-1");
assert.equal(merged[0].protected, true);
assert.equal(merged[0].isManaged, true);
assert.equal(merged[0].connectorType, "radarr");
});
it("preserves live resource metrics when inventory metadata is merged later", () => {
const merged = mergeInventoryContainersWithLive(
[liveContainer({
cpu: "12.3%",
ram: "256 MiB / 1 GiB",
metricsStatus: "measured",
metricsSource: "portainer.docker.stats",
})],
[discoveredContainer({ matchedAppId: "radarr", isManaged: true })],
);
assert.equal(merged[0].cpu, "12.3%");
assert.equal(merged[0].ram, "256 MiB / 1 GiB");
assert.equal(merged[0].metricsStatus, "measured");
assert.equal(merged[0].metricsSource, "portainer.docker.stats");
});
it("does not append inventory-only containers when live containers are available", () => {
const merged = mergeInventoryContainersWithLive(
[liveContainer({ id: "live-1", name: "radarr" })],
[
discoveredContainer({ containerId: "live-1", name: "radarr", matchedAppId: "radarr" }),
discoveredContainer({ containerId: "historical-1", name: "old-pre-container", suggestedAppId: "old-pre-container" }),
],
);
assert.equal(merged.length, 1);
assert.equal(merged[0].id, "live-1");
assert.equal(merged[0].name, "radarr");
});
it("falls back to live containers when inventory is empty", () => {
const live = [liveContainer({ id: "live-only", name: "plex" })];
assert.deepEqual(mergeInventoryContainersWithLive(live, []), live);
});
});
+69
View File
@@ -0,0 +1,69 @@
import type { Container, DiscoveredContainer } from "@/types/appops";
export const discoveredToContainerView = (item: DiscoveredContainer): Container => ({
id: item.containerId,
name: item.name,
appId: item.matchedAppId || item.suggestedAppId || undefined,
state: item.state === "running" ? "running" : item.state === "exited" ? "exited" : item.state === "restarting" ? "restarting" : "paused",
health: item.status === "unhealthy" ? "unhealthy" : item.status === "running" ? "healthy" : item.state === "running" ? "healthy" : "warning",
image: item.image,
ports: (item.ports || []).map(String),
cpu: "n/a",
ram: "n/a",
restartCount: 0,
criticality: item.suggestedCriticality,
voiceEnabled: false,
protected: item.protected || item.isProtected,
appType: item.appType || item.suggestedAppType,
connectorType: item.connectorType || item.suggestedConnector,
policyProfile: item.policyProfile || item.suggestedPolicyProfile,
importStatus: item.importStatus,
isManaged: item.isManaged || item.importStatus === "managed",
portsRaw: item.ports,
mounts: item.mounts,
networks: item.networks,
labels: item.labels,
});
export const mergeInventoryContainersWithLive = (
liveContainers: Container[],
inventoryContainers: DiscoveredContainer[],
): Container[] => {
if (!inventoryContainers.length) {
return liveContainers;
}
const inventoryViews = inventoryContainers.map(discoveredToContainerView);
if (!liveContainers.length) {
return inventoryViews;
}
const inventoryById = new Map(inventoryViews.map((container) => [container.id, container]));
const inventoryByName = new Map(
inventoryViews.map((container) => [container.name.trim().toLowerCase(), container]),
);
return liveContainers.map((live) => {
const inventoryContainer = inventoryById.get(live.id)
?? inventoryByName.get(live.name.trim().toLowerCase());
if (!inventoryContainer) {
return live;
}
return {
...live,
...inventoryContainer,
portainerUrl: live?.portainerUrl ?? inventoryContainer.portainerUrl,
state: live?.state ?? inventoryContainer.state,
health: live?.health ?? inventoryContainer.health,
ports: live?.ports?.length ? live.ports : inventoryContainer.ports,
cpu: live?.cpu ?? inventoryContainer.cpu,
ram: live?.ram ?? inventoryContainer.ram,
metricsStatus: live?.metricsStatus ?? inventoryContainer.metricsStatus,
metricsSource: live?.metricsSource ?? inventoryContainer.metricsSource,
metricsError: live?.metricsError ?? inventoryContainer.metricsError,
};
});
};
+26
View File
@@ -0,0 +1,26 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { buildContainerLogExportText, safeContainerLogFilename } from "./containerLogExport.ts";
describe("containerLogExport", () => {
it("builds stable safe filenames", () => {
assert.equal(safeContainerLogFilename("Authentik Worker / Prod"), "appops-authentik-worker-prod-logs.txt");
assert.equal(safeContainerLogFilename(" "), "appops-container-logs.txt");
});
it("marks log exports as backend-redacted text", () => {
const text = buildContainerLogExportText({
containerName: "widefrog",
containerId: "abc123",
status: "ok",
exportedAt: "2026-07-04T10:00:00.000Z",
logs: ["INFO started", "WARN retry"],
});
assert.match(text, /container=widefrog/);
assert.match(text, /containerId=abc123/);
assert.match(text, /status=ok/);
assert.match(text, /redaction=backend-applied/);
assert.match(text, /INFO started/);
});
});
+30
View File
@@ -0,0 +1,30 @@
export type ContainerLogExportInput = {
containerName: string;
containerId: string;
status?: string;
logs: string[];
exportedAt?: string;
};
export const safeContainerLogFilename = (containerName: string): string => {
const safeName = containerName
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "");
return `appops-${safeName || "container"}-logs.txt`;
};
export const buildContainerLogExportText = (input: ContainerLogExportInput): string => {
const lines = [
"AppOps container logs export",
`container=${input.containerName}`,
`containerId=${input.containerId}`,
`status=${input.status || "unknown"}`,
`exportedAt=${input.exportedAt || new Date().toISOString()}`,
"redaction=backend-applied",
"",
...input.logs,
];
return lines.join("\n");
};
+38
View File
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { buildExposureWarningTriage } from "./exposureWarningTriage.ts";
describe("buildExposureWarningTriage", () => {
it("routes NPM SSL warnings to manual NPM review without implying mutation", () => {
const triage = buildExposureWarningTriage({
type: "ssl_not_forced",
severity: "warning",
title: "open.example.com does not force SSL",
evidence: ["open.example.com"],
suggestedAction: "review_ssl_policy",
}, "nginx_proxy_manager");
assert.equal(triage.tone, "warning");
assert.match(triage.description, /handmatige NPM review/i);
assert.match(triage.description, /geen automatische wijziging/i);
assert.deepEqual(triage.links[0], { label: "Open NPM app", href: "/apps/nginx_proxy_manager" });
});
it("routes Authentik coverage warnings to Authentik and planner follow-up", () => {
const triage = buildExposureWarningTriage({
type: "public_host_without_authentik",
severity: "high",
title: "open.example.com has no Authentik mapping",
evidence: ["open.example.com"],
suggestedAction: "map_host_to_authentik",
}, "authentik");
assert.equal(triage.tone, "critical");
assert.match(triage.description, /Authentik dekking/i);
assert.deepEqual(triage.links, [
{ label: "Open Authentik", href: "/apps/authentik" },
{ label: "Open Planner", href: "/ai/remediation-planner" },
]);
});
});
+114
View File
@@ -0,0 +1,114 @@
import type { AuthentikSecurityWarning, NpmSecurityWarning } from "@/types/appops";
export type ExposureWarningSource = "nginx_proxy_manager" | "authentik";
export type ExposureWarningTone = "info" | "warning" | "critical";
export interface ExposureWarningTriageLink {
label: string;
href: string;
}
export interface ExposureWarningTriage {
tone: ExposureWarningTone;
description: string;
evidence: string;
actionLabel: string;
links: ExposureWarningTriageLink[];
}
type ExposureWarning = NpmSecurityWarning | AuthentikSecurityWarning;
const toneFromSeverity = (severity: string): ExposureWarningTone => {
if (severity === "critical" || severity === "high") return "critical";
if (severity === "warning" || severity === "medium") return "warning";
return "info";
};
const previewEvidence = (evidence: string[]) => {
if (!evidence.length) return "Geen extra evidence meegegeven.";
return evidence.slice(0, 3).join(" | ");
};
export const buildExposureWarningTriage = (
warning: ExposureWarning,
source: ExposureWarningSource,
): ExposureWarningTriage => {
const evidence = previewEvidence(warning.evidence || []);
const tone = toneFromSeverity(warning.severity);
if (source === "authentik") {
if (warning.type === "public_host_without_authentik" || warning.type === "authentik_missing_config_with_public_hosts") {
return {
tone,
description: "Controleer Authentik dekking voor deze publieke host. AppOps maakt geen Authentik application, provider of NPM wijziging automatisch aan.",
evidence,
actionLabel: "Manual Authentik/NPM review",
links: [
{ label: "Open Authentik", href: "/apps/authentik" },
{ label: "Open Planner", href: "/ai/remediation-planner" },
],
};
}
return {
tone,
description: "Controleer Authentik configuratie en outpoststatus handmatig. Deze kaart is read-only triage en voert geen connectoractie uit.",
evidence,
actionLabel: "Review Authentik",
links: [
{ label: "Open Authentik", href: "/apps/authentik" },
{ label: "Open Audit", href: `/audit?query=${encodeURIComponent(warning.type)}` },
],
};
}
if (warning.type === "ssl_not_forced" || warning.type === "hsts_disabled") {
return {
tone,
description: "Voer een handmatige NPM review uit voor SSL/HSTS beleid. AppOps toont alleen de bevinding; er wordt geen automatische wijziging uitgevoerd.",
evidence,
actionLabel: "Manual NPM policy review",
links: [
{ label: "Open NPM app", href: "/apps/nginx_proxy_manager" },
{ label: "Open Exposure", href: "/network" },
],
};
}
if (warning.type === "host_without_certificate" || warning.type === "certificate_expired" || warning.type === "certificate_expiring_soon") {
return {
tone,
description: "Controleer certificaatstatus in NPM. Renew/delete certificate acties blijven geblokkeerd in AppOps V1.",
evidence,
actionLabel: "Manual certificate review",
links: [
{ label: "Open NPM app", href: "/apps/nginx_proxy_manager" },
{ label: "Open Planner", href: "/ai/remediation-planner" },
],
};
}
if (warning.type === "public_host_without_access_list") {
return {
tone,
description: "Controleer of deze publieke host via NPM access list of Authentik hoort beschermd te zijn. AppOps wijzigt access lists niet automatisch.",
evidence,
actionLabel: "Review access protection",
links: [
{ label: "Open NPM app", href: "/apps/nginx_proxy_manager" },
{ label: "Open Authentik", href: "/apps/authentik" },
],
};
}
return {
tone,
description: "Controleer deze exposure warning handmatig. De triagekaart is read-only en maakt geen wijzigingen aan NPM, Authentik of containers.",
evidence,
actionLabel: warning.suggestedAction || "Manual review",
links: [
{ label: "Open NPM app", href: "/apps/nginx_proxy_manager" },
{ label: "Open Audit", href: `/audit?query=${encodeURIComponent(warning.type)}` },
],
};
};
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { buildHomeAssistantNotificationActionState, buildNotificationEmptyState, filterNotificationEvents } from "./homeAssistantNotificationState.ts";
import type { NotificationChannel, NotificationEvent } from "../types/appops.ts";
const channel = (overrides: Partial<NotificationChannel> = {}): NotificationChannel => ({
id: "home_assistant",
type: "home_assistant",
name: "Home Assistant",
enabled: false,
config: {},
safeConfig: { dryRun: false },
secretRefs: ["HOME_ASSISTANT_TOKEN"],
status: "disabled",
hasSecret: true,
missing: [],
lastError: null,
lastSentAt: null,
lastTestAt: null,
createdAt: "2026-07-09T00:00:00Z",
updatedAt: "2026-07-09T00:00:00Z",
...overrides,
});
const event = (overrides: Partial<NotificationEvent> = {}): NotificationEvent => ({
id: "notif-1",
ruleId: "rule-1",
source: "safety",
eventType: "safety.finding",
severity: "high",
title: "Safety finding",
message: "NPM certificate warning",
category: "exposure",
target: "nginx_proxy_manager",
dedupeKey: "safety:npm",
channels: ["dashboard", "home_assistant"],
status: "failed",
reason: "home assistant unreachable",
payload: { findingId: "finding-1" },
channelResults: { home_assistant: { status: "unreachable", reason: "timeout" } },
read: false,
createdAt: "2026-07-09T00:00:00Z",
...overrides,
});
describe("homeAssistantNotificationState", () => {
it("blocks delivery tests while the saved channel is disabled", () => {
const state = buildHomeAssistantNotificationActionState(channel(), { enabled: false, dryRun: false });
assert.equal(state.deliveryReady, false);
assert.match(state.sendDisabledReason || "", /disabled/i);
assert.match(state.modeCopy, /verstuurt niets/i);
});
it("requires saving when the operator toggled enabled locally", () => {
const state = buildHomeAssistantNotificationActionState(channel(), { enabled: true, dryRun: false });
assert.equal(state.deliveryReady, false);
assert.match(state.sendDisabledReason || "", /sla/i);
});
it("allows connected saved delivery and labels dry-run mode explicitly", () => {
const state = buildHomeAssistantNotificationActionState(
channel({ enabled: true, status: "connected", safeConfig: { dryRun: true } }),
{ enabled: true, dryRun: true },
);
assert.equal(state.deliveryReady, true);
assert.equal(state.sendDisabledReason, null);
assert.equal(state.sendLabel, "Dry-run test notification");
assert.match(state.modeCopy, /geen externe notify-call/i);
});
});
describe("filterNotificationEvents", () => {
it("matches notification events by query, severity and status without inspecting secret values", () => {
const rows = [
event(),
event({ id: "notif-2", source: "media_pipeline", severity: "medium", target: "deluge", status: "sent", channels: ["dashboard"], title: "Download stalled", channelResults: undefined }),
];
assert.deepEqual(filterNotificationEvents(rows, { query: "home_assistant", severity: "all", status: "active" }).map(item => item.id), ["notif-1"]);
assert.deepEqual(filterNotificationEvents(rows, { query: "deluge", severity: "medium", status: "sent" }).map(item => item.id), ["notif-2"]);
assert.deepEqual(filterNotificationEvents(rows, { query: "", severity: "all", status: "active" }).map(item => item.id), ["notif-1", "notif-2"]);
});
});
describe("buildNotificationEmptyState", () => {
it("turns an unmatched notification query into safe follow-up links", () => {
const state = buildNotificationEmptyState({ totalEvents: 5, visibleEvents: 0, query: "home_assistant", severity: "all", status: "failed" });
assert.equal(state.title, "Geen notificaties voor deze filter");
assert.match(state.message, /home_assistant/);
assert.deepEqual(state.actions, [
{ label: "Toon alle notificaties", href: "/notifications" },
{ label: "Open setup", href: "/setup?step=voice" },
{ label: "Zoek in Audit", href: "/audit?query=home_assistant" },
]);
});
it("describes an empty notification inbox without fabricating events", () => {
const state = buildNotificationEmptyState({ totalEvents: 0, visibleEvents: 0, query: "", severity: "all", status: "active" });
assert.equal(state.title, "Geen notificaties");
assert.deepEqual(state.actions, [
{ label: "Evaluate all", href: "/notifications" },
{ label: "Open Safety Center", href: "/safety" },
]);
});
});
+165
View File
@@ -0,0 +1,165 @@
import type { NotificationChannel, NotificationEvent } from "@/types/appops";
type HomeAssistantFormState = {
enabled: boolean;
dryRun: boolean;
};
export interface HomeAssistantNotificationActionState {
deliveryReady: boolean;
sendDisabledReason: string | null;
sendLabel: string;
modeCopy: string;
}
export interface NotificationEventFilterOptions {
query: string;
severity: string;
status: string;
}
export interface NotificationEmptyStateAction {
label: string;
href: string;
}
export interface NotificationEmptyState {
title: string;
message: string;
actions: NotificationEmptyStateAction[];
}
export const buildHomeAssistantNotificationActionState = (
channel: NotificationChannel | undefined | null,
form: HomeAssistantFormState,
): HomeAssistantNotificationActionState => {
const missing = channel?.missing || [];
const savedEnabled = Boolean(channel?.enabled);
const connected = channel?.status === "connected";
const formHasUnsavedEnabledChange = form.enabled !== savedEnabled;
const formHasUnsavedDryRunChange = Boolean(channel?.safeConfig?.dryRun) !== form.dryRun;
if (missing.length > 0) {
return {
deliveryReady: false,
sendDisabledReason: `Vul eerst ${missing.join(", ")} in en sla de configuratie op.`,
sendLabel: "Send test notification",
modeCopy: "Home Assistant delivery is nog niet compleet.",
};
}
if (!savedEnabled) {
return {
deliveryReady: false,
sendDisabledReason: form.enabled
? "Sla de enabled Home Assistant-configuratie eerst op voordat je een testnotificatie stuurt."
: "Home Assistant delivery staat disabled; enable en save eerst om externe notificaties te testen.",
sendLabel: "Send test notification",
modeCopy: "Dashboardmeldingen blijven lokaal; Home Assistant verstuurt niets zolang het kanaal disabled is.",
};
}
if (!connected) {
return {
deliveryReady: false,
sendDisabledReason: "Test de Home Assistant-verbinding eerst totdat het kanaal connected is.",
sendLabel: "Send test notification",
modeCopy: "Home Assistant is enabled maar nog niet bevestigd als connected.",
};
}
if (formHasUnsavedEnabledChange || formHasUnsavedDryRunChange) {
return {
deliveryReady: false,
sendDisabledReason: "Sla je Home Assistant-wijzigingen eerst op voordat je de test uitvoert.",
sendLabel: form.dryRun ? "Dry-run test notification" : "Send test notification",
modeCopy: "Er staan lokale wijzigingen klaar; de test gebruikt alleen opgeslagen configuratie.",
};
}
return {
deliveryReady: true,
sendDisabledReason: null,
sendLabel: form.dryRun ? "Dry-run test notification" : "Send test notification",
modeCopy: form.dryRun
? "Dry run is actief; de test schrijft audit/status maar verstuurt geen externe notify-call."
: "Deze knop stuurt een expliciete Home Assistant testnotificatie en schrijft een audit-event.",
};
};
export const filterNotificationEvents = (
events: NotificationEvent[],
options: NotificationEventFilterOptions,
): NotificationEvent[] => {
const query = options.query.trim().toLowerCase();
return events.filter(event => {
if (options.severity !== "all" && event.severity !== options.severity) return false;
if (options.status === "active" && event.status === "dismissed") return false;
if (options.status !== "all" && options.status !== "active" && event.status !== options.status) return false;
if (!query) return true;
const channelResultText = event.channelResults
? Object.entries(event.channelResults)
.map(([channelId, result]) => [channelId, result.status, result.reason || "", result.detail || "", ...(result.missing || [])].join(" "))
.join(" ")
: "";
return [
event.id,
event.ruleId,
event.source,
event.eventType,
event.severity,
event.title,
event.message,
event.category || "",
event.target || "",
event.status,
event.reason || "",
event.dedupeKey,
...event.channels,
channelResultText,
].some(value => value.toLowerCase().includes(query));
});
};
export const buildNotificationEmptyState = (
options: { totalEvents: number; visibleEvents: number; query: string; severity: string; status: string },
): NotificationEmptyState => {
const query = options.query.trim();
if (options.totalEvents === 0) {
return {
title: "Geen notificaties",
message: "Er staan nog geen notificaties in de lokale inbox. Evalueer regels of open Safety Center om nieuwe read-only signalen te bekijken.",
actions: [
{ label: "Evaluate all", href: "/notifications" },
{ label: "Open Safety Center", href: "/safety" },
],
};
}
if (query || options.severity !== "all" || options.status !== "active") {
return {
title: "Geen notificaties voor deze filter",
message: query
? `Er zijn geen notificaties gevonden voor "${query}". Controleer setup, audit of toon alle notificaties om de lokale inbox te heropenen.`
: "Er zijn notificaties aanwezig, maar niet voor deze severity/status-combinatie.",
actions: [
{ label: "Toon alle notificaties", href: "/notifications" },
{ label: "Open setup", href: "/setup?step=voice" },
...(query ? [{ label: "Zoek in Audit", href: `/audit?query=${encodeURIComponent(query)}` }] : []),
],
};
}
return {
title: "Geen actieve notificaties",
message: "Alle notificaties zijn gelezen, dismissed of momenteel niet actief. Dashboardmeldingen blijven lokaal beschikbaar wanneer nieuwe signalen ontstaan.",
actions: [
{ label: "Toon alles", href: "/notifications" },
{ label: "Open Safety Center", href: "/safety" },
],
};
};
+15
View File
@@ -0,0 +1,15 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { isIgnorableLoadError } from "./ignoredLoadError.ts";
describe("isIgnorableLoadError", () => {
it("treats browser abort noise as ignorable", () => {
assert.equal(isIgnorableLoadError(new DOMException("The operation was aborted.", "AbortError")), true);
});
it("keeps real backend and network errors visible", () => {
assert.equal(isIgnorableLoadError(new Error("Backend 500: Request failed")), false);
assert.equal(isIgnorableLoadError(new Error("Failed to fetch")), false);
assert.equal(isIgnorableLoadError(new Error("signal is aborted without reason")), false);
});
});
+4
View File
@@ -0,0 +1,4 @@
export const isIgnorableLoadError = (error: unknown): boolean => {
if (error instanceof DOMException && error.name === "AbortError") return true;
return false;
};
+73
View File
@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { Incident } from "../types/appops.ts";
import { buildIncidentEmptyState, filterIncidents } from "./incidentView.ts";
const incident = (overrides: Partial<Incident> = {}): Incident => ({
id: "inc-1",
severity: "high",
status: "open",
title: "NPM certificate warning",
detectedTime: "2026-07-09T00:00:00Z",
evidence: {
source: "safety",
check: "certificate_expiry",
observed: "certificate expires in 5 days",
expected: "certificate valid for 14+ days",
timestamp: "2026-07-09T00:00:00Z",
},
suggestedActions: ["npm.expiring_certificates"],
...overrides,
});
describe("filterIncidents", () => {
it("matches incidents by query, status, severity, evidence and suggested actions", () => {
const rows = [
incident(),
incident({
id: "inc-2",
severity: "medium",
status: "acknowledged",
title: "Deluge queue stalled",
evidence: {
source: "media_pipeline",
check: "download_stalled",
observed: "3 stalled downloads",
expected: "downloads progressing",
timestamp: "2026-07-09T00:00:00Z",
},
suggestedActions: ["deluge.resume_all"],
}),
];
assert.deepEqual(filterIncidents(rows, { query: "certificate", status: "all", severity: "all" }).map(item => item.id), ["inc-1"]);
assert.deepEqual(filterIncidents(rows, { query: "deluge.resume_all", status: "acknowledged", severity: "medium" }).map(item => item.id), ["inc-2"]);
assert.deepEqual(filterIncidents(rows, { query: "", status: "active", severity: "all" }).map(item => item.id), ["inc-1", "inc-2"]);
});
});
describe("buildIncidentEmptyState", () => {
it("turns an unmatched focused incident query into safe investigation links", () => {
const state = buildIncidentEmptyState({ totalIncidents: 3, visibleIncidents: 0, query: "npm", status: "open", severity: "all" });
assert.equal(state.title, "Geen incidenten voor deze filter");
assert.match(state.message, /npm/);
assert.deepEqual(state.actions, [
{ label: "Toon alle incidenten", href: "/incidents" },
{ label: "Open Safety Center", href: "/safety" },
{ label: "Zoek in Audit", href: "/audit?query=npm" },
{ label: "Open Notifications", href: "/notifications?query=npm" },
]);
});
it("describes an empty incident list without fabricating incidents", () => {
const state = buildIncidentEmptyState({ totalIncidents: 0, visibleIncidents: 0, query: "", status: "active", severity: "all" });
assert.equal(state.title, "Geen actieve incidenten");
assert.deepEqual(state.actions, [
{ label: "Open Safety Center", href: "/safety" },
{ label: "Open Notifications", href: "/notifications" },
]);
});
});
+87
View File
@@ -0,0 +1,87 @@
import type { Incident } from "../types/appops.ts";
export interface IncidentFilterOptions {
query: string;
status: string;
severity: string;
}
export interface IncidentEmptyStateAction {
label: string;
href: string;
}
export interface IncidentEmptyState {
title: string;
message: string;
actions: IncidentEmptyStateAction[];
}
export const filterIncidents = (
incidents: Incident[],
options: IncidentFilterOptions,
): Incident[] => {
const query = options.query.trim().toLowerCase();
return incidents.filter(incident => {
if (options.status === "active" && incident.status === "resolved") return false;
if (options.status !== "all" && options.status !== "active" && incident.status !== options.status) return false;
if (options.severity !== "all" && incident.severity !== options.severity) return false;
if (!query) return true;
return [
incident.id,
incident.title,
incident.severity,
incident.status,
incident.evidence.source,
incident.evidence.check,
incident.evidence.observed,
incident.evidence.expected,
...incident.suggestedActions,
].some(value => value.toLowerCase().includes(query));
});
};
export const buildIncidentEmptyState = (
options: { totalIncidents: number; visibleIncidents: number; query: string; status: string; severity: string },
): IncidentEmptyState => {
const query = options.query.trim();
if (options.totalIncidents === 0) {
return {
title: "Geen actieve incidenten",
message: "Er zijn geen incidenten geregistreerd. Gebruik Safety Center en Notifications om nieuwe signalen read-only te evalueren.",
actions: [
{ label: "Open Safety Center", href: "/safety" },
{ label: "Open Notifications", href: "/notifications" },
],
};
}
if (query || options.status !== "active" || options.severity !== "all") {
return {
title: "Geen incidenten voor deze filter",
message: query
? `Er zijn geen incidenten gevonden voor "${query}". Controleer Safety, Audit of Notifications om hetzelfde signaal te volgen.`
: "Er zijn incidenten aanwezig, maar niet voor deze status/severity-combinatie.",
actions: [
{ label: "Toon alle incidenten", href: "/incidents" },
{ label: "Open Safety Center", href: "/safety" },
...(query ? [
{ label: "Zoek in Audit", href: `/audit?query=${encodeURIComponent(query)}` },
{ label: "Open Notifications", href: `/notifications?query=${encodeURIComponent(query)}` },
] : []),
],
};
}
return {
title: "Geen zichtbare incidenten",
message: "De huidige selectie bevat geen zichtbare incidenten. Toon alles of open Safety Center voor de bredere posture.",
actions: [
{ label: "Toon alle incidenten", href: "/incidents" },
{ label: "Open Safety Center", href: "/safety" },
],
};
};
+82
View File
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { IntegrationStatus } from "../types/appops.ts";
import { getIntegrationRuntimeStates } from "./integrationFlow.ts";
const verifiedStatus: IntegrationStatus = {
mcp: {
enabled: true,
authenticated: true,
transport: "streamable_http",
endpoint: "/mcp",
scope: "read_only",
state: "listening",
toolCount: 8,
},
rag: {
status: "ready",
engine: "local-hybrid",
chunks: 42,
sources: 8,
secretsIndexed: false,
secretScanStatus: "passed",
generationMode: "extractive",
},
n8n: {
enabled: true,
authenticated: true,
endpoint: "/api/integrations/n8n",
canExecute: false,
capabilities: ["event_feed", "preflight", "create_approval"],
state: "reachable",
},
};
describe("governed integration flow", () => {
it("only reports verified runtime states as healthy", () => {
assert.deepEqual(getIntegrationRuntimeStates(verifiedStatus, false, false), {
mcp: "healthy",
rag: "healthy",
n8n: "healthy",
executionBoundary: "healthy",
});
});
it("never paints stale data green while the status request is unavailable", () => {
assert.deepEqual(getIntegrationRuntimeStates(verifiedStatus, false, true), {
mcp: "unknown",
rag: "unknown",
n8n: "unknown",
executionBoundary: "unknown",
});
});
it("treats direct execution and indexed secrets as critical boundary failures", () => {
const unsafe: IntegrationStatus = {
...verifiedStatus,
rag: { ...verifiedStatus.rag, secretsIndexed: true },
n8n: { ...verifiedStatus.n8n, canExecute: true },
};
const states = getIntegrationRuntimeStates(unsafe, false, false);
assert.equal(states.rag, "critical");
assert.equal(states.n8n, "critical");
assert.equal(states.executionBoundary, "critical");
});
it("distinguishes configured services from verified live runtimes", () => {
const configured: IntegrationStatus = {
...verifiedStatus,
mcp: { ...verifiedStatus.mcp, state: "configured" },
rag: { ...verifiedStatus.rag, secretScanStatus: "unknown" },
n8n: { ...verifiedStatus.n8n, state: "configured" },
};
const states = getIntegrationRuntimeStates(configured, false, false);
assert.equal(states.mcp, "warning");
assert.equal(states.rag, "warning");
assert.equal(states.n8n, "warning");
assert.equal(states.executionBoundary, "healthy");
});
});
+60
View File
@@ -0,0 +1,60 @@
import type { IntegrationStatus } from "@/types/appops";
export type IntegrationFlowState = "healthy" | "warning" | "critical" | "stopped" | "loading" | "unknown";
export interface IntegrationRuntimeStates {
mcp: IntegrationFlowState;
rag: IntegrationFlowState;
n8n: IntegrationFlowState;
executionBoundary: IntegrationFlowState;
}
export const getIntegrationRuntimeStates = (
status: IntegrationStatus | undefined,
loading: boolean,
unavailable: boolean,
): IntegrationRuntimeStates => {
if (unavailable) {
return { mcp: "unknown", rag: "unknown", n8n: "unknown", executionBoundary: "unknown" };
}
if (loading || !status) {
return { mcp: "loading", rag: "loading", n8n: "loading", executionBoundary: "loading" };
}
const mcp: IntegrationFlowState = !status.mcp.enabled
? "stopped"
: status.mcp.authenticated && status.mcp.state === "listening"
? "healthy"
: "warning";
const rag: IntegrationFlowState = status.rag.secretsIndexed
? "critical"
: status.rag.status === "ready" && status.rag.secretScanStatus?.startsWith("passed")
? "healthy"
: "warning";
const n8n: IntegrationFlowState = status.n8n.canExecute
? "critical"
: !status.n8n.enabled
? "stopped"
: status.n8n.authenticated && status.n8n.state === "reachable"
? "healthy"
: "warning";
const executionBoundary: IntegrationFlowState = status.n8n.canExecute === true
? "critical"
: status.n8n.canExecute === false
? "healthy"
: "unknown";
return { mcp, rag, n8n, executionBoundary };
};
export const integrationFlowStateLabel: Record<IntegrationFlowState, string> = {
healthy: "geverifieerd",
warning: "aandacht",
critical: "kritiek",
stopped: "uitgeschakeld",
loading: "controleren",
unknown: "onbekend",
};
+51
View File
@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { buildInventoryActionFollowUp } from "./inventoryActionFollowup.ts";
describe("buildInventoryActionFollowUp", () => {
it("explains bulk import as AppOps metadata and links mapping follow-up", () => {
const followUp = buildInventoryActionFollowUp({ kind: "bulk_import", success: true, count: 3 });
assert.equal(followUp.tone, "success");
assert.match(followUp.message, /3 item/);
assert.match(followUp.message, /containers.*niet/i);
assert.deepEqual(followUp.actions, [
{ label: "Bekijk mappings", href: "/inventory/mapping" },
{ label: "Open Apps", href: "/apps" },
{ label: "Open Operations Map", href: "/operations-map" },
]);
});
it("deep-links mapping saves to the app and dependency map", () => {
const followUp = buildInventoryActionFollowUp({
kind: "mapping_saved",
success: true,
appId: "nginx_proxy_manager",
containerName: "NginxProxyManager",
});
assert.equal(followUp.title, "Mapping opgeslagen");
assert.match(followUp.message, /Voice-write acties blijven uit/i);
assert.deepEqual(followUp.actions.slice(0, 3), [
{ label: "Open app", href: "/apps/nginx_proxy_manager" },
{ label: "Bekijk mappings", href: "/inventory/mapping" },
{ label: "Open Operations Map", href: "/operations-map?query=nginx_proxy_manager" },
]);
});
it("routes drift incidents to incidents and audit without implying external mutation", () => {
const followUp = buildInventoryActionFollowUp({
kind: "drift_incident",
success: true,
driftId: "drift-1",
incidentId: "inc-1",
});
assert.match(followUp.message, /muteert geen container/i);
assert.deepEqual(followUp.actions, [
{ label: "Open incident", href: "/incidents?query=inc-1" },
{ label: "Bekijk drift", href: "/inventory/drift" },
{ label: "Zoek in Audit", href: "/audit?query=inventory.drift.create_incident" },
]);
});
});
+193
View File
@@ -0,0 +1,193 @@
export type InventoryActionFollowUpTone = "success" | "warning" | "error";
export type InventoryActionFollowUpKind =
| "baseline_update"
| "bulk_ignore"
| "bulk_import"
| "discovery_run"
| "drift_acknowledged"
| "drift_incident"
| "mapping_saved"
| "review_metadata"
| "single_ignore"
| "single_import";
export interface InventoryActionFollowUpAction {
label: string;
href: string;
}
export interface InventoryActionFollowUp {
tone: InventoryActionFollowUpTone;
title: string;
message: string;
details: string[];
actions: InventoryActionFollowUpAction[];
}
export interface InventoryActionFollowUpInput {
kind: InventoryActionFollowUpKind;
success: boolean;
count?: number;
appId?: string;
containerName?: string;
driftId?: string;
incidentId?: string;
message?: string;
}
const countLabel = (count?: number): string => `${count ?? 1} item(s)`;
const focusedHref = (path: string, query?: string): string => (
query ? `${path}?query=${encodeURIComponent(query)}` : path
);
const failedFollowUp = (input: InventoryActionFollowUpInput): InventoryActionFollowUp => ({
tone: "error",
title: "Actie niet afgerond",
message: input.message || "De Inventory actie is niet afgerond. Controleer de bronstatus en probeer opnieuw.",
details: ["Er is geen externe container- of connectorwijziging uitgevoerd door AppOps."],
actions: [
{ label: "Bekijk Inventory", href: "/inventory/discovery" },
{ label: "Zoek in Audit", href: "/audit?query=inventory" },
],
});
export const buildInventoryActionFollowUp = (input: InventoryActionFollowUpInput): InventoryActionFollowUp => {
if (!input.success) {
return failedFollowUp(input);
}
switch (input.kind) {
case "bulk_import":
return {
tone: "success",
title: "Bulk import afgerond",
message: `${countLabel(input.count)} geimporteerd met AppOps safe defaults. AppOps apps en mappings zijn bijgewerkt; containers of externe services zijn niet gemuteerd.`,
details: ["Controleer mappings en appdetail voordat je voice-write of connectoracties gebruikt."],
actions: [
{ label: "Bekijk mappings", href: "/inventory/mapping" },
{ label: "Open Apps", href: "/apps" },
{ label: "Open Operations Map", href: "/operations-map" },
],
};
case "bulk_ignore":
return {
tone: "success",
title: "Bulk ignore afgerond",
message: `${countLabel(input.count)} genegeerd in het AppOps reviewledger. Dit verwijdert, stopt of wijzigt geen containers.`,
details: ["Genegeerde discovery-items blijven traceerbaar via Inventory en Audit."],
actions: [
{ label: "Bekijk discovery", href: "/inventory/discovery" },
{ label: "Open Operations Map", href: "/operations-map" },
{ label: "Zoek in Audit", href: "/audit?query=inventory" },
],
};
case "single_import":
return {
tone: "success",
title: "Container geimporteerd",
message: `${input.containerName || "Container"} is als AppOps metadata geimporteerd. De container zelf is niet aangepast.`,
details: ["Controleer de app, mapping en connectorstatus voordat je vervolgacties uitvoert."],
actions: [
{ label: "Open app", href: input.appId ? `/apps/${input.appId}` : "/apps" },
{ label: "Bekijk mappings", href: "/inventory/mapping" },
{ label: "Zoek in Audit", href: "/audit?query=inventory.import" },
],
};
case "single_ignore":
return {
tone: "success",
title: "Discovery item genegeerd",
message: `${input.containerName || "Discovery item"} is genegeerd in AppOps metadata. De runtime-container blijft ongemoeid.`,
details: ["Gebruik Discovery opnieuw als je dit item later toch wil reviewen."],
actions: [
{ label: "Bekijk discovery", href: "/inventory/discovery" },
{ label: "Zoek in Audit", href: "/audit?query=inventory.ignore" },
],
};
case "mapping_saved":
return {
tone: "success",
title: "Mapping opgeslagen",
message: `${input.containerName || "Container"} is aan een bestaande AppOps app gekoppeld. Voice-write acties blijven uit voor review; de container zelf is niet aangepast.`,
details: ["Controleer de dependencykaart en appdetail om te zien of de relatie logisch staat."],
actions: [
{ label: "Open app", href: input.appId ? `/apps/${input.appId}` : "/apps" },
{ label: "Bekijk mappings", href: "/inventory/mapping" },
{ label: "Open Operations Map", href: focusedHref("/operations-map", input.appId) },
{ label: "Zoek in Audit", href: "/audit?query=inventory.review.map_existing" },
],
};
case "drift_incident": {
const focus = input.incidentId || input.driftId;
return {
tone: "warning",
title: "Drift incident aangemaakt",
message: "Drift is als AppOps incident geregistreerd. Dit muteert geen container of externe service.",
details: ["Gebruik het incident als werkqueue; eventuele uitvoering blijft via policy, approval en handmatige operatorcontrole lopen."],
actions: [
{ label: "Open incident", href: focusedHref("/incidents", focus) },
{ label: "Bekijk drift", href: "/inventory/drift" },
{ label: "Zoek in Audit", href: "/audit?query=inventory.drift.create_incident" },
],
};
}
case "drift_acknowledged":
return {
tone: "success",
title: "Drift acknowledged",
message: "Drift is alleen in AppOps metadata acknowledged. Er is geen container- of connectorwijziging uitgevoerd.",
details: ["Nieuwe drift met een ander fingerprint blijft opnieuw zichtbaar."],
actions: [
{ label: "Bekijk drift", href: "/inventory/drift" },
{ label: "Zoek in Audit", href: "/audit?query=inventory.drift.acknowledge" },
],
};
case "review_metadata":
return {
tone: "success",
title: "Review metadata opgeslagen",
message: `${input.containerName || "Discovery item"} is bijgewerkt in het Inventory reviewledger. Runtime-state en externe services zijn niet gewijzigd.`,
details: ["Gebruik import of mapping pas wanneer de reviewvelden kloppen."],
actions: [
{ label: "Bekijk discovery", href: "/inventory/discovery" },
{ label: "Zoek in Audit", href: "/audit?query=inventory.review.update_metadata" },
],
};
case "baseline_update":
return {
tone: "success",
title: "Baseline bijgewerkt",
message: "De drift-baseline is bijgewerkt vanuit het huidige Inventory ledger. Dit is een AppOps referentiepunt, geen runtime-mutatie.",
details: ["Controleer drift opnieuw om te zien welke verschillen nog relevant blijven."],
actions: [
{ label: "Bekijk drift", href: "/inventory/drift" },
{ label: "Zoek in Audit", href: "/audit?query=inventory.baseline" },
],
};
case "discovery_run":
return {
tone: "success",
title: "Discovery afgerond",
message: `${countLabel(input.count)} gevonden via read-only discovery. Import, ignore en mapping blijven aparte operatoracties.`,
details: ["Review nieuwe of gewijzigde items voordat ze onderdeel worden van de AppOps appcatalogus."],
actions: [
{ label: "Bekijk discovery", href: "/inventory/discovery" },
{ label: "Import mappings", href: "/inventory/mapping" },
{ label: "Open Containers", href: "/containers" },
],
};
default:
return {
tone: "success",
title: "Inventory actie afgerond",
message: input.message || "De Inventory actie is afgerond.",
details: ["Controleer de relevante Inventory-tab en Audit voor opvolging."],
actions: [
{ label: "Bekijk Inventory", href: "/inventory/discovery" },
{ label: "Zoek in Audit", href: "/audit?query=inventory" },
],
};
}
};
+50
View File
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { buildInventoryLedgerPosture } from "./inventoryLedgerView.ts";
describe("buildInventoryLedgerPosture", () => {
it("marks inventory as a stale review snapshot when live containers and review records differ", () => {
const posture = buildInventoryLedgerPosture({
liveContainerCount: 76,
reviewRecordCount: 184,
latestRunContainersFound: 184,
liveError: null,
});
assert.equal(posture.state, "stale_snapshot");
assert.equal(posture.tone, "warning");
assert.equal(posture.liveContainerCountLabel, "76 live");
assert.equal(posture.reviewRecordCountLabel, "184 review records");
assert.match(posture.message, /reviewledger/i);
assert.match(posture.message, /Run Discovery/i);
});
it("marks inventory aligned when the review ledger matches the live runtime count", () => {
const posture = buildInventoryLedgerPosture({
liveContainerCount: 76,
reviewRecordCount: 76,
latestRunContainersFound: 76,
liveError: null,
});
assert.equal(posture.state, "aligned");
assert.equal(posture.tone, "success");
assert.match(posture.message, /aligned/i);
});
it("keeps the review ledger usable when live runtime comparison cannot load", () => {
const posture = buildInventoryLedgerPosture({
liveContainerCount: null,
reviewRecordCount: 184,
latestRunContainersFound: 184,
liveError: "Backend 500",
});
assert.equal(posture.state, "partial");
assert.equal(posture.tone, "info");
assert.equal(posture.liveContainerCountLabel, "live onbekend");
assert.match(posture.message, /Containers/i);
assert.match(posture.message, /bruikbaar/i);
});
});
+68
View File
@@ -0,0 +1,68 @@
export type InventoryLedgerState = "aligned" | "stale_snapshot" | "partial";
export type InventoryLedgerTone = "success" | "warning" | "info";
export interface InventoryLedgerPosture {
state: InventoryLedgerState;
tone: InventoryLedgerTone;
title: string;
message: string;
liveContainerCountLabel: string;
reviewRecordCountLabel: string;
latestRunCountLabel: string;
}
export interface InventoryLedgerInput {
liveContainerCount: number | null;
reviewRecordCount: number;
latestRunContainersFound?: number | null;
liveError?: string | null;
}
const formatCount = (value: number | null | undefined, fallback: string): string => (
typeof value === "number" ? String(value) : fallback
);
export const buildInventoryLedgerPosture = (input: InventoryLedgerInput): InventoryLedgerPosture => {
const latestRunContainersFound = input.latestRunContainersFound ?? null;
const liveCountKnown = typeof input.liveContainerCount === "number";
const title = "Inventory reviewledger";
const liveContainerCountLabel = liveCountKnown ? `${input.liveContainerCount} live` : "live onbekend";
const reviewRecordCountLabel = `${input.reviewRecordCount} review records`;
const latestRunCountLabel = `${formatCount(latestRunContainersFound, "-")} laatste run`;
if (!liveCountKnown) {
return {
state: "partial",
tone: "info",
title,
liveContainerCountLabel,
reviewRecordCountLabel,
latestRunCountLabel,
message: input.liveError
? "Live runtime vergelijking kon niet laden. Inventory blijft bruikbaar als reviewledger; open Containers voor de actuele runtime zodra de read hersteld is."
: "Live runtime vergelijking wordt nog geladen. Inventory blijft bruikbaar als reviewledger; Containers is de actuele runtimebron.",
};
}
if (input.liveContainerCount !== input.reviewRecordCount) {
return {
state: "stale_snapshot",
tone: "warning",
title,
liveContainerCountLabel,
reviewRecordCountLabel,
latestRunCountLabel,
message: "Inventory is een reviewledger met discoveryhistoriek, geen live containerlijst. Run Discovery om de snapshot te verversen; gebruik Containers voor actuele runtime, CPU/RAM en state.",
};
}
return {
state: "aligned",
tone: "success",
title,
liveContainerCountLabel,
reviewRecordCountLabel,
latestRunCountLabel,
message: "Inventory en live Containers zijn aligned op aantallen. Gebruik Inventory voor review/import/drift en Containers voor actuele runtime-operaties.",
};
};
+147
View File
@@ -0,0 +1,147 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { DiscoveredContainer } from "@/types/appops";
import {
buildInventoryEmptyState,
buildInventoryReviewQueue,
setInventoryVisibleSelection,
type InventoryReviewFilter,
} from "./inventoryReviewQueue.ts";
const discovered = (overrides: Partial<DiscoveredContainer>): DiscoveredContainer => ({
id: "dc-sonarr",
discoveryRunId: "disc-1",
containerId: "sonarr-container",
name: "binhex-sonarr",
image: "binhex/arch-sonarr",
state: "running",
status: "running",
ports: ["8989:8989"],
mounts: [],
networks: [],
labels: {},
suggestedAppId: "sonarr",
displayName: "Sonarr",
suggestedAppType: "media",
suggestedCategory: "media",
suggestedCriticality: "medium",
suggestedConnector: "sonarr",
suggestedPolicyProfile: "media_manager",
protected: false,
confidence: 0.9,
reasons: ["Known Sonarr pattern."],
importStatus: "discovered",
...overrides,
});
describe("buildInventoryReviewQueue", () => {
it("summarizes actionable, unknown, protected and connector-ready work", () => {
const queue = buildInventoryReviewQueue([
discovered({ id: "dc-sonarr", suggestedConnector: "sonarr", importStatus: "discovered" }),
discovered({ id: "dc-unknown", name: "mystery", suggestedAppType: "unknown", suggestedConnector: "none", confidence: 0.2, importStatus: "unmapped" }),
discovered({ id: "dc-auth", name: "authentik", protected: true, suggestedCriticality: "critical", suggestedConnector: "authentik", importStatus: "changed" }),
discovered({ id: "dc-managed", name: "managed", importStatus: "managed" }),
discovered({ id: "dc-missing", name: "retired", importStatus: "missing" }),
], { filter: "all", query: "" });
assert.deepEqual(queue.summary, {
total: 5,
actionable: 3,
unknown: 1,
protected: 1,
connectorReady: 2,
changed: 1,
selectedActionable: 0,
});
assert.deepEqual(queue.lanes.map((lane) => [lane.id, lane.count]), [
["review_now", 3],
["connector_ready", 2],
["unknown_generic", 1],
["closed", 2],
]);
});
it("sorts protected, changed, connector-ready and unknown work before low-risk items", () => {
const queue = buildInventoryReviewQueue([
discovered({ id: "dc-low", name: "low", suggestedConnector: "none", suggestedAppType: "unknown", confidence: 0.2, importStatus: "unmapped" }),
discovered({ id: "dc-connector", name: "radarr", suggestedConnector: "radarr", suggestedCriticality: "medium", importStatus: "discovered" }),
discovered({ id: "dc-protected", name: "authentik", protected: true, suggestedCriticality: "critical", importStatus: "changed" }),
], { filter: "all", query: "" });
assert.deepEqual(queue.items.map((item) => item.id), ["dc-protected", "dc-connector", "dc-low"]);
assert.deepEqual(queue.items.map((item) => item.recommendedAction), ["review_protected", "import_connector", "review_generic"]);
});
it("filters by queue facets and search text", () => {
const cases: Array<[InventoryReviewFilter, string[]]> = [
["actionable", ["dc-auth", "dc-sonarr", "dc-unknown"]],
["connector_ready", ["dc-auth", "dc-sonarr"]],
["unknown", ["dc-unknown"]],
["protected", ["dc-auth"]],
["changed", ["dc-auth"]],
["closed", ["dc-managed", "dc-missing"]],
];
const containers = [
discovered({ id: "dc-sonarr", name: "binhex-sonarr", suggestedConnector: "sonarr" }),
discovered({ id: "dc-unknown", name: "mystery", displayName: "Mystery", image: "local/mystery", suggestedAppId: "mystery", suggestedAppType: "unknown", suggestedConnector: "none", confidence: 0.2, importStatus: "unmapped", reasons: ["No known pattern."] }),
discovered({ id: "dc-auth", name: "authentik", displayName: "Authentik", image: "ghcr.io/goauthentik/server", suggestedAppId: "authentik", suggestedConnector: "authentik", protected: true, suggestedCriticality: "critical", importStatus: "changed", reasons: ["Known auth app."] }),
discovered({ id: "dc-managed", name: "managed", displayName: "Managed", image: "local/managed", suggestedAppId: "managed", suggestedConnector: "none", importStatus: "managed", reasons: ["Managed app."] }),
discovered({ id: "dc-missing", name: "retired", displayName: "Retired", image: "local/retired", suggestedAppId: "retired", suggestedConnector: "none", importStatus: "missing", reasons: ["No longer present."] }),
];
for (const [filter, expected] of cases) {
assert.deepEqual(buildInventoryReviewQueue(containers, { filter, query: "" }).items.map((item) => item.id), expected);
}
assert.deepEqual(buildInventoryReviewQueue(containers, { filter: "all", query: "sonarr" }).items.map((item) => item.id), ["dc-sonarr"]);
});
it("selects and clears only visible actionable items", () => {
const queue = buildInventoryReviewQueue([
discovered({ id: "dc-actionable", importStatus: "discovered" }),
discovered({ id: "dc-managed", importStatus: "managed" }),
], { filter: "all", query: "" }, { "outside-filter": true });
assert.equal(queue.summary.selectedActionable, 0);
assert.deepEqual(setInventoryVisibleSelection(queue.items, { "outside-filter": true }, true), {
"outside-filter": true,
"dc-actionable": true,
});
assert.deepEqual(setInventoryVisibleSelection(queue.items, { "outside-filter": true, "dc-actionable": true }, false), {
"outside-filter": true,
});
});
it("builds safe empty-state handoff links for focused inventory queries", () => {
const state = buildInventoryEmptyState({
totalContainers: 4,
visibleContainers: 0,
query: "plex",
filter: "connector_ready",
});
assert.equal(state.title, "Geen inventory-items voor deze filter");
assert.deepEqual(state.actions, [
{ label: "Toon volledige inventory", href: "/inventory" },
{ label: "Open Apps", href: "/apps?query=plex" },
{ label: "Zoek in Audit", href: "/audit?query=plex" },
{ label: "Open Operations Map", href: "/operations-map?query=plex" },
]);
});
it("keeps an empty discovery inventory explicit", () => {
const state = buildInventoryEmptyState({
totalContainers: 0,
visibleContainers: 0,
query: "",
filter: "all",
});
assert.equal(state.title, "Geen discovery-items beschikbaar");
assert.deepEqual(state.actions, [
{ label: "Run Discovery", href: "/inventory" },
{ label: "Open Setup", href: "/setup" },
]);
});
});
+222
View File
@@ -0,0 +1,222 @@
import type { DiscoveredContainer } from "@/types/appops";
export type InventoryReviewFilter = "all" | "actionable" | "connector_ready" | "unknown" | "protected" | "changed" | "closed";
export type InventoryRecommendedAction = "import_connector" | "review_protected" | "review_generic" | "monitor_closed";
export type InventoryReviewOptions = {
filter: InventoryReviewFilter;
query: string;
};
export type InventoryReviewItem = DiscoveredContainer & {
actionable: boolean;
unknown: boolean;
connectorReady: boolean;
recommendedAction: InventoryRecommendedAction;
};
export type InventoryReviewSummary = {
total: number;
actionable: number;
unknown: number;
protected: number;
connectorReady: number;
changed: number;
selectedActionable: number;
};
export type InventoryReviewLane = {
id: "review_now" | "connector_ready" | "unknown_generic" | "closed";
label: string;
count: number;
filter: InventoryReviewFilter;
description: string;
};
export type InventoryReviewQueue = {
summary: InventoryReviewSummary;
lanes: InventoryReviewLane[];
items: InventoryReviewItem[];
visibleActionableIds: string[];
selectedVisibleActionable: number;
selectedTotal: number;
allVisibleActionableSelected: boolean;
};
export type InventoryEmptyState = {
title: string;
message: string;
actions: Array<{ label: string; href: string }>;
};
const actionableStates = new Set(["discovered", "unmapped", "changed"]);
const closedStates = new Set(["managed", "ignored", "missing"]);
const riskRank = { critical: 4, high: 3, medium: 2, low: 1 } as const;
const isActionable = (item: DiscoveredContainer): boolean => actionableStates.has(item.importStatus);
const isUnknown = (item: DiscoveredContainer): boolean => (
isActionable(item) &&
(
item.suggestedAppType === "unknown" ||
item.suggestedConnector === "none" ||
item.confidence < 0.5 ||
item.importStatus === "unmapped"
)
);
const isConnectorReady = (item: DiscoveredContainer): boolean => (
isActionable(item) &&
Boolean(item.suggestedConnector) &&
item.suggestedConnector !== "none"
);
const isProtected = (item: DiscoveredContainer): boolean => Boolean(item.protected || item.isProtected || item.privacySensitive);
const recommendedAction = (item: DiscoveredContainer): InventoryRecommendedAction => {
if (!isActionable(item)) return "monitor_closed";
if (isProtected(item)) return "review_protected";
if (isConnectorReady(item)) return "import_connector";
return "review_generic";
};
const priorityScore = (item: InventoryReviewItem): number => {
if (!item.actionable) return 0;
let score = 1_000;
score += (riskRank[item.suggestedCriticality] || 0) * 30;
if (item.protected || item.isProtected || item.privacySensitive) score += 80;
if (item.importStatus === "changed") score += 70;
if (item.connectorReady) score += 45;
if (item.unknown) score -= 10;
return score;
};
const searchableText = (item: DiscoveredContainer): string => [
item.name,
item.displayName,
item.image,
item.suggestedAppId,
item.suggestedAppType,
item.suggestedCategory,
item.suggestedConnector,
item.suggestedPolicyProfile,
item.importStatus,
].join(" ").toLowerCase();
const matchesFilter = (item: InventoryReviewItem, filter: InventoryReviewFilter): boolean => {
if (filter === "actionable") return item.actionable;
if (filter === "connector_ready") return item.connectorReady;
if (filter === "unknown") return item.unknown;
if (filter === "protected") return isProtected(item);
if (filter === "changed") return item.importStatus === "changed";
if (filter === "closed") return closedStates.has(item.importStatus);
return true;
};
export const buildInventoryReviewQueue = (
containers: DiscoveredContainer[],
options: InventoryReviewOptions,
selected: Record<string, boolean> = {},
): InventoryReviewQueue => {
const query = options.query.trim().toLowerCase();
const enriched: InventoryReviewItem[] = containers.map((item) => ({
...item,
actionable: isActionable(item),
unknown: isUnknown(item),
connectorReady: isConnectorReady(item),
recommendedAction: recommendedAction(item),
}));
const summary = enriched.reduce<InventoryReviewSummary>((acc, item) => {
acc.total += 1;
if (item.actionable) acc.actionable += 1;
if (item.unknown) acc.unknown += 1;
if (isProtected(item)) acc.protected += 1;
if (item.connectorReady) acc.connectorReady += 1;
if (item.importStatus === "changed") acc.changed += 1;
if (item.actionable && selected[item.id]) acc.selectedActionable += 1;
return acc;
}, { total: 0, actionable: 0, unknown: 0, protected: 0, connectorReady: 0, changed: 0, selectedActionable: 0 });
const items = enriched
.filter((item) => matchesFilter(item, options.filter))
.filter((item) => !query || searchableText(item).includes(query))
.sort((left, right) => {
const scoreDelta = priorityScore(right) - priorityScore(left);
if (scoreDelta !== 0) return scoreDelta;
return left.name.localeCompare(right.name);
});
const visibleActionableIds = items.filter((item) => item.actionable).map((item) => item.id);
const selectedVisibleActionable = visibleActionableIds.filter((id) => Boolean(selected[id])).length;
return {
summary,
lanes: [
{ id: "review_now", label: "Review now", count: summary.actionable, filter: "actionable", description: "Alles wat nog import, ignore of operatorbeoordeling nodig heeft." },
{ id: "connector_ready", label: "Connector ready", count: summary.connectorReady, filter: "connector_ready", description: "Bekende app-types met connectoradvies; veilig te importeren met defaults." },
{ id: "unknown_generic", label: "Generic/unknown", count: summary.unknown, filter: "unknown", description: "Lage confidence of geen connector; eerst naam/type/policy controleren." },
{ id: "closed", label: "Afgehandeld", count: summary.total - summary.actionable, filter: "closed", description: "Managed, ignored of historische missing-items die niet in bulkacties zitten." },
],
items,
visibleActionableIds,
selectedVisibleActionable,
selectedTotal: Object.values(selected).filter(Boolean).length,
allVisibleActionableSelected: visibleActionableIds.length > 0 && selectedVisibleActionable === visibleActionableIds.length,
};
};
export const setInventoryVisibleSelection = (
items: InventoryReviewItem[],
selected: Record<string, boolean>,
checked: boolean,
): Record<string, boolean> => {
const next = { ...selected };
for (const item of items) {
if (!item.actionable) continue;
if (checked) {
next[item.id] = true;
} else {
delete next[item.id];
}
}
return next;
};
export const buildInventoryEmptyState = (
options: { totalContainers: number; visibleContainers: number; query: string; filter: InventoryReviewFilter },
): InventoryEmptyState => {
const query = options.query.trim();
if (options.totalContainers === 0) {
return {
title: "Geen discovery-items beschikbaar",
message: "Er zijn nog geen containers in de inventory reviewqueue. Draai discovery of controleer de Portainer setup.",
actions: [
{ label: "Run Discovery", href: "/inventory" },
{ label: "Open Setup", href: "/setup" },
],
};
}
if (query || options.filter !== "all") {
return {
title: "Geen inventory-items voor deze filter",
message: query
? `Er is geen inventory-item gevonden voor "${query}". Gebruik Apps, Audit of Operations Map voor dezelfde operatorcontext.`
: "Er zijn inventory-items beschikbaar, maar niet voor deze reviewfilter.",
actions: [
{ label: "Toon volledige inventory", href: "/inventory" },
{ label: "Open Apps", href: query ? `/apps?query=${encodeURIComponent(query)}` : "/apps" },
...(query ? [{ label: "Zoek in Audit", href: `/audit?query=${encodeURIComponent(query)}` }] : []),
{ label: "Open Operations Map", href: query ? `/operations-map?query=${encodeURIComponent(query)}` : "/operations-map" },
],
};
}
return {
title: "Geen zichtbare inventory-items",
message: "De huidige selectie bevat geen discovery-items. Toon de volledige inventory of draai discovery opnieuw.",
actions: [
{ label: "Toon volledige inventory", href: "/inventory" },
{ label: "Run Discovery", href: "/inventory" },
],
};
};
+56
View File
@@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { buildJobCancelFollowup } from "./jobCancelFollowup.ts";
describe("jobCancelFollowup", () => {
it("builds a persistent success handoff with concrete job and audit links", () => {
const followup = buildJobCancelFollowup(
{
id: "job-1",
actionId: "container.restart",
target: "sonarr",
auditEventId: "audit-original",
},
{
success: true,
result: "success",
message: "Cancel requested.",
auditId: "audit-cancel",
jobId: "job-1",
},
);
assert.equal(followup.tone, "success");
assert.equal(followup.title, "Cancel aangevraagd");
assert.match(followup.message, /geen externe rollback/i);
assert.deepEqual(followup.links, [
{ label: "Open job", href: "/jobs?query=job-1" },
{ label: "Open audit", href: "/audit?query=audit-cancel" },
]);
});
it("falls back to existing job references when cancel fails without new refs", () => {
const followup = buildJobCancelFollowup(
{
id: "job-2",
actionId: "stack.redeploy",
target: "media",
auditEventId: "audit-original",
},
{
success: false,
result: "failed",
message: "Job is already terminal.",
},
);
assert.equal(followup.tone, "error");
assert.equal(followup.title, "Cancel niet uitgevoerd");
assert.match(followup.message, /Job is already terminal/);
assert.deepEqual(followup.links, [
{ label: "Open job", href: "/jobs?query=job-2" },
{ label: "Open audit", href: "/audit?query=audit-original" },
]);
});
});
+39
View File
@@ -0,0 +1,39 @@
import type { ActionResult, JobRun } from "../types/appops.ts";
export interface JobCancelFollowupLink {
label: string;
href: string;
}
export interface JobCancelFollowup {
tone: "success" | "error" | "warning";
title: string;
message: string;
links: JobCancelFollowupLink[];
}
const queryHref = (base: string, query: string): string => `${base}?query=${encodeURIComponent(query)}`;
export const buildJobCancelFollowup = (
job: Pick<JobRun, "id" | "actionId" | "target" | "auditEventId">,
result?: Pick<ActionResult, "success" | "result" | "message" | "auditId" | "jobId">,
): JobCancelFollowup => {
const succeeded = Boolean(result?.success || result?.result === "success");
const jobRef = result?.jobId || job.id;
const auditRef = result?.auditId || job.auditEventId || job.id;
const baseMessage = result?.message || (succeeded
? "Cancel request is verwerkt."
: "Cancel request kon niet worden verwerkt.");
return {
tone: succeeded ? "success" : "error",
title: succeeded ? "Cancel aangevraagd" : "Cancel niet uitgevoerd",
message: succeeded
? `${baseMessage} AppOps annuleert alleen de queued/running jobstate; er wordt geen externe rollback of connector-mutatie uitgevoerd.`
: `${baseMessage} Controleer de job en audit voordat je opnieuw probeert.`,
links: [
{ label: "Open job", href: queryHref("/jobs", jobRef) },
{ label: "Open audit", href: queryHref("/audit", auditRef) },
],
};
};
+68
View File
@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { buildJobRunView, filterJobRuns, formatJobDuration, parseJobSteps } from "./jobRunView.ts";
describe("jobRunView", () => {
it("parses job step json defensively", () => {
assert.deepEqual(parseJobSteps('["policy", "execute", 3]'), ["policy", "execute", "3"]);
assert.deepEqual(parseJobSteps("{not json"), []);
assert.deepEqual(parseJobSteps("{}"), []);
});
it("formats job duration for compact operator display", () => {
assert.equal(formatJobDuration(undefined), "n/a");
assert.equal(formatJobDuration(12_400), "12s");
assert.equal(formatJobDuration(125_000), "2m 5s");
});
it("filters jobs by id, action, target and audit reference", () => {
const jobs = [
{ id: "job-1", actionId: "container.restart", target: "sonarr", auditEventId: "audit-1" },
{ id: "job-2", actionId: "stack.redeploy", target: "auth-stack", auditEventId: "audit-2" },
];
assert.deepEqual(filterJobRuns(jobs, "sonarr").map(job => job.id), ["job-1"]);
assert.deepEqual(filterJobRuns(jobs, "audit-2").map(job => job.id), ["job-2"]);
assert.deepEqual(filterJobRuns(jobs, "").map(job => job.id), ["job-1", "job-2"]);
});
it("builds operator follow-up state for running jobs", () => {
const view = buildJobRunView({
id: "job-1",
status: "running",
actionId: "container.restart",
target: "sonarr",
auditEventId: "audit-1",
});
assert.equal(view.statusLabel, "Running");
assert.equal(view.canCancel, true);
assert.equal(view.terminal, false);
assert.equal(view.auditHref, "/audit?query=audit-1");
assert.match(view.operatorCopy, /loopt nog/i);
});
it("builds terminal follow-up state for failed and cancelled jobs", () => {
const failed = buildJobRunView({
id: "job-2",
status: "failed",
actionId: "container.restart",
target: "radarr",
});
const cancelled = buildJobRunView({
id: "job-3",
status: "cancelled",
actionId: "stack.redeploy",
target: "media",
});
assert.equal(failed.canCancel, false);
assert.equal(failed.terminal, true);
assert.equal(failed.auditHref, "/audit?query=job-2");
assert.match(failed.operatorCopy, /mislukt/i);
assert.equal(cancelled.statusLabel, "Cancelled");
assert.equal(cancelled.terminal, true);
assert.match(cancelled.operatorCopy, /geannuleerd/i);
});
});
+78
View File
@@ -0,0 +1,78 @@
import type { JobStatus } from "@/types/appops";
export const parseJobSteps = (stepsJson: string): string[] => {
try {
const value = JSON.parse(stepsJson || "[]");
return Array.isArray(value) ? value.map(String) : [];
} catch {
return [];
}
};
export const formatJobDuration = (durationMs?: number): string => {
if (!durationMs) return "n/a";
const seconds = Math.round(durationMs / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
return `${minutes}m ${seconds % 60}s`;
};
export interface JobRunFilterTarget {
id: string;
actionId: string;
target: string;
auditEventId?: string;
}
export interface JobRunViewTarget extends JobRunFilterTarget {
status: JobStatus;
}
export interface JobRunView {
statusLabel: string;
operatorCopy: string;
auditHref: string;
canCancel: boolean;
terminal: boolean;
}
const statusLabels: Record<JobStatus, string> = {
queued: "Queued",
running: "Running",
succeeded: "Succeeded",
failed: "Failed",
cancelled: "Cancelled",
};
const operatorCopyByStatus: Record<JobStatus, string> = {
queued: "Job staat klaar. Volg progress en audit zodra uitvoering start.",
running: "Job loopt nog. Volg progress en audit zodra post-checks beschikbaar zijn.",
succeeded: "Job afgerond. Controleer audit en post-checks voor het volledige spoor.",
failed: "Job mislukt. Gebruik audit en result summary voor triage.",
cancelled: "Job geannuleerd. Er is geen verdere uitvoering gestart.",
};
export const buildJobRunView = (job: JobRunViewTarget): JobRunView => {
const terminal = ["succeeded", "failed", "cancelled"].includes(job.status);
const queryRef = job.auditEventId || job.id;
return {
statusLabel: statusLabels[job.status] || job.status,
operatorCopy: operatorCopyByStatus[job.status] || "Jobstatus onbekend. Controleer audit voor details.",
auditHref: `/audit?query=${encodeURIComponent(queryRef)}`,
canCancel: !terminal,
terminal,
};
};
export const filterJobRuns = <T extends JobRunFilterTarget>(jobs: T[], query: string): T[] => {
const normalized = query.trim().toLowerCase();
if (!normalized) return jobs;
return jobs.filter(job => [
job.id,
job.actionId,
job.target,
job.auditEventId || "",
].some(value => value.toLowerCase().includes(normalized)));
};
+80
View File
@@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { AuditEvent, JobRun } from "@/types/appops";
import { buildLiveActivityFeedView } from "./liveActivityFeedView.ts";
const auditEvent = (overrides: Partial<AuditEvent>): AuditEvent => ({
id: "audit-1",
time: "2026-07-09T20:00:00.000Z",
source: "manual",
user: "operator",
action: "container.restart",
target: "widefrog",
connector: "portainer",
risk: "medium",
policy: "allowed",
result: "success",
...overrides,
});
const jobRun = (overrides: Partial<JobRun>): JobRun => ({
id: "job-1",
type: "action",
status: "running",
source: "manual",
actionId: "container.restart",
target: "widefrog",
risk: "medium",
policyDecision: "allowed",
stepsJson: "[]",
progress: 40,
startedAt: "2026-07-09T20:01:00.000Z",
...overrides,
});
describe("buildLiveActivityFeedView", () => {
it("combines audit events and jobs as a newest-first live feed", () => {
const view = buildLiveActivityFeedView({
audits: [
auditEvent({ id: "audit-old", time: "2026-07-09T19:59:00.000Z", action: "setup.reconcile", target: "setup" }),
],
jobs: [
jobRun({ id: "job-new", startedAt: "2026-07-09T20:02:00.000Z", actionId: "stack.redeploy", target: "media" }),
],
limit: 2,
});
assert.deepEqual(view.rows.map((row) => row.id), ["job-job-new", "audit-audit-old"]);
assert.equal(view.rows[0].title, "Job running");
assert.equal(view.rows[0].subtitle, "stack.redeploy on media");
assert.equal(view.rows[1].title, "Audit success");
assert.equal(view.rows[1].subtitle, "setup.reconcile on setup");
});
it("keeps empty and failed sources explicit without fabricating system output", () => {
const view = buildLiveActivityFeedView({
audits: [],
jobs: [],
auditError: "Backend 500",
jobsError: null,
});
assert.equal(view.rows.length, 0);
assert.match(view.emptyTitle, /Geen live activiteit/i);
assert.match(view.emptyMessage, /Audit laden mislukt: Backend 500/i);
assert.doesNotMatch(view.emptyMessage, /CPU 24|Health check|Cleaning up old logs/i);
});
it("does not expose raw HTML or JSON parser details as operator copy", () => {
const view = buildLiveActivityFeedView({
audits: [],
jobs: [],
auditError: "Unexpected token '<', \"<!doctype\" is not valid JSON",
jobsError: null,
});
assert.match(view.emptyMessage, /backendbron reageerde niet met geldige API-data/i);
assert.doesNotMatch(view.emptyMessage, /doctype|unexpected token/i);
});
});
+103
View File
@@ -0,0 +1,103 @@
import type { AuditEvent, JobRun, Status } from "@/types/appops";
import { formatOperatorError } from "./operatorError";
export type LiveActivityFeedTone = "success" | "warning" | "error" | "info";
export interface LiveActivityFeedRow {
id: string;
kind: "audit" | "job";
title: string;
subtitle: string;
timestamp: string;
tone: LiveActivityFeedTone;
href: string;
progress?: number;
}
export interface LiveActivityFeedView {
rows: LiveActivityFeedRow[];
emptyTitle: string;
emptyMessage: string;
partialWarning: string | null;
}
export interface LiveActivityFeedInput {
audits: AuditEvent[];
jobs: JobRun[];
auditError?: string | null;
jobsError?: string | null;
limit?: number;
}
const statusTone = (status: Status | JobRun["status"]): LiveActivityFeedTone => {
if (status === "success" || status === "succeeded") return "success";
if (status === "failed" || status === "blocked" || status === "critical" || status === "unhealthy") return "error";
if (status === "running" || status === "queued" || status === "warning" || status === "missing_config") return "warning";
return "info";
};
const rowTime = (value: string): number => {
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : 0;
};
const subject = (action: string, target: string): string => {
const cleanAction = action || "unknown";
const cleanTarget = target || "unknown";
return `${cleanAction} on ${cleanTarget}`;
};
const formatSourceError = (source: "Audit" | "Jobs", error: string): string => {
const formatted = formatOperatorError(error, `${source} laden mislukt.`);
if (formatted.includes("geen geldige API-respons")) {
return `${source} laden mislukt: backendbron reageerde niet met geldige API-data.`;
}
return `${source} laden mislukt: ${formatted}`;
};
export const buildLiveActivityFeedView = ({
audits,
jobs,
auditError,
jobsError,
limit = 8,
}: LiveActivityFeedInput): LiveActivityFeedView => {
const auditRows: LiveActivityFeedRow[] = audits.map((event) => ({
id: `audit-${event.id}`,
kind: "audit",
title: `Audit ${event.result}`,
subtitle: subject(event.action, event.target),
timestamp: event.time,
tone: statusTone(event.result),
href: `/audit?query=${encodeURIComponent(event.id)}`,
}));
const jobRows: LiveActivityFeedRow[] = jobs.map((job) => ({
id: `job-${job.id}`,
kind: "job",
title: `Job ${job.status}`,
subtitle: subject(job.actionId, job.target),
timestamp: job.startedAt || job.finishedAt || "",
tone: statusTone(job.status),
href: `/jobs?query=${encodeURIComponent(job.id)}`,
progress: job.progress,
}));
const rows = [...auditRows, ...jobRows]
.sort((left, right) => rowTime(right.timestamp) - rowTime(left.timestamp))
.slice(0, Math.max(1, limit));
const errors = [
auditError ? formatSourceError("Audit", auditError) : null,
jobsError ? formatSourceError("Jobs", jobsError) : null,
].filter((item): item is string => Boolean(item));
return {
rows,
emptyTitle: "Geen live activiteit",
emptyMessage: errors.length
? errors.join(" ")
: "Er zijn nog geen audit-events of jobs ontvangen. Nieuwe operatoracties verschijnen hier zodra ze live geregistreerd worden.",
partialWarning: rows.length > 0 && errors.length ? errors.join(" ") : null,
};
};
+37
View File
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { buildMediaDownloadTriage } from "./mediaDownloadTriage.ts";
describe("buildMediaDownloadTriage", () => {
it("describes stalled downloads as approval-aware and no-delete-with-data", () => {
const triage = buildMediaDownloadTriage({
stalledCount: 2,
activeCount: 1,
failedWidefrogJobs: 0,
warnings: ["Deluge has stalled downloads"],
});
assert.equal(triage.tone, "warning");
assert.match(triage.title, /2 stalled/i);
assert.match(triage.description, /approval/i);
assert.match(triage.description, /delete-with-data blijft geblokkeerd/i);
assert.deepEqual(triage.links, [
{ label: "Open Deluge", href: "/apps/deluge" },
{ label: "Open Planner", href: "/ai/remediation-planner" },
]);
});
it("keeps a healthy queue as read-only observation", () => {
const triage = buildMediaDownloadTriage({
stalledCount: 0,
activeCount: 3,
failedWidefrogJobs: 0,
warnings: [],
});
assert.equal(triage.tone, "healthy");
assert.match(triage.description, /read-only/i);
assert.deepEqual(triage.links, [{ label: "Open Deluge", href: "/apps/deluge" }]);
});
});
+64
View File
@@ -0,0 +1,64 @@
export type MediaDownloadTriageTone = "healthy" | "warning" | "critical";
export interface MediaDownloadTriageInput {
stalledCount: number;
activeCount: number;
failedWidefrogJobs: number;
warnings: string[];
}
export interface MediaDownloadTriageLink {
label: string;
href: string;
}
export interface MediaDownloadTriage {
tone: MediaDownloadTriageTone;
title: string;
description: string;
evidence: string[];
links: MediaDownloadTriageLink[];
}
export const buildMediaDownloadTriage = (input: MediaDownloadTriageInput): MediaDownloadTriage => {
const evidence = [
`${input.activeCount} active downloads`,
`${input.stalledCount} stalled downloads`,
`${input.failedWidefrogJobs} failed Widefrog jobs`,
...input.warnings.slice(0, 3),
];
if (input.stalledCount > 0) {
return {
tone: input.failedWidefrogJobs > 0 ? "critical" : "warning",
title: `${input.stalledCount} stalled download${input.stalledCount === 1 ? "" : "s"} require review`,
description: "Controleer Deluge stalled torrents eerst read-only. Resume of pause hoort via policy/approval te lopen; delete-with-data blijft geblokkeerd.",
evidence,
links: [
{ label: "Open Deluge", href: "/apps/deluge" },
{ label: "Open Planner", href: "/ai/remediation-planner" },
],
};
}
if (input.failedWidefrogJobs > 0) {
return {
tone: "warning",
title: `${input.failedWidefrogJobs} Widefrog job${input.failedWidefrogJobs === 1 ? "" : "s"} need review`,
description: "Controleer Widefrog jobs voordat je retry-acties aanvraagt. Retries kunnen downloads of verwerking starten en blijven operator-controlled.",
evidence,
links: [
{ label: "Open Widefrog", href: "/apps/widefrog" },
{ label: "Open Planner", href: "/ai/remediation-planner" },
],
};
}
return {
tone: "healthy",
title: "Download queue has no stalled items",
description: "Downloadstatus is read-only geobserveerd. Er zijn geen stalled Deluge downloads of failed Widefrog jobs in deze snapshot.",
evidence,
links: [{ label: "Open Deluge", href: "/apps/deluge" }],
};
};
+94
View File
@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { buildMediaPipelinePageLoadState, startMediaPipelinePageLoad } from "./mediaPipelinePageLoad.ts";
import type { MediaPipelineSummary } from "@/types/appops";
const summary = {
success: true,
status: "warning",
message: "summary available",
connectors: {},
counts: {
wantedSeries: 1,
missingEpisodes: 0,
activeDownloads: 0,
stalledDownloads: 0,
failedWidefrogJobs: 0,
plexLibraries: 0,
activeStreams: 0,
transcodes: 0,
indexers: 0,
failingIndexers: 0,
},
risk: { level: "medium", reasons: [] },
lastUpdated: "2026-07-09T19:00:00.000Z",
} satisfies MediaPipelineSummary;
const lastUpdated = "2026-07-09T19:00:00.000Z";
describe("buildMediaPipelinePageLoadState", () => {
it("keeps the route usable when secondary media panels fail", () => {
const state = buildMediaPipelinePageLoadState({
summary: { status: "fulfilled", value: summary },
flow: { status: "rejected", reason: new Error("signal is aborted without reason") },
issues: { status: "fulfilled", value: { success: true, issues: [], lastUpdated } },
downloads: { status: "rejected", reason: new Error("timeout") },
library: { status: "rejected", reason: new Error("Backend 404") },
activity: { status: "rejected", reason: new Error("timeout") },
recommendations: { status: "fulfilled", value: { success: true, recommendations: [], lastUpdated } },
});
assert.equal(state.blockingError, false);
assert.equal(state.summary, summary);
assert.equal(state.flow, null);
assert.deepEqual(state.issues, []);
assert.match(state.warningMessage || "", /flow.*downloads.*library.*activity/i);
assert.equal(state.errorMessage, null);
});
it("blocks the initial route only when the primary summary fails", () => {
const state = buildMediaPipelinePageLoadState({
summary: { status: "rejected", reason: new Error("summary timeout") },
flow: { status: "fulfilled", value: { success: true, nodes: [], edges: [], lastUpdated } },
issues: { status: "fulfilled", value: { success: true, issues: [], lastUpdated } },
downloads: { status: "rejected", reason: new Error("timeout") },
library: { status: "rejected", reason: new Error("timeout") },
activity: { status: "rejected", reason: new Error("timeout") },
recommendations: { status: "fulfilled", value: { success: true, recommendations: [], lastUpdated } },
});
assert.equal(state.blockingError, true);
assert.equal(state.summary, null);
assert.match(state.errorMessage || "", /summary timeout/i);
});
});
describe("startMediaPipelinePageLoad", () => {
it("returns the primary summary before slow secondary panels settle", async () => {
let resolvedPrimary = false;
const never = new Promise<never>(() => undefined);
const load = startMediaPipelinePageLoad({
getMediaPipelineSummary: async () => summary,
getMediaPipelineFlow: () => never,
getMediaPipelineIssues: () => never,
getMediaPipelineDownloads: () => never,
getMediaPipelineLibrary: () => never,
getMediaPipelineActivity: () => never,
getMediaPipelineRecommendations: () => never,
});
const timer = new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 25));
const result = await Promise.race([
load.then((value) => {
resolvedPrimary = true;
return value.summary;
}),
timer,
]);
assert.equal(resolvedPrimary, true);
assert.equal(result, summary);
});
});
+134
View File
@@ -0,0 +1,134 @@
import type {
MediaPipelineActivity,
MediaPipelineDownloads,
MediaPipelineFlow,
MediaPipelineIssues,
MediaPipelineLibrary,
MediaPipelineRecommendations,
MediaPipelineSummary,
} from "@/types/appops";
type Settled<T> = PromiseSettledResult<T>;
export interface MediaPipelinePageLoadResults {
summary: Settled<MediaPipelineSummary>;
flow: Settled<MediaPipelineFlow>;
issues: Settled<MediaPipelineIssues>;
downloads: Settled<MediaPipelineDownloads>;
library: Settled<MediaPipelineLibrary>;
activity: Settled<MediaPipelineActivity>;
recommendations: Settled<MediaPipelineRecommendations>;
}
export interface MediaPipelinePageLoadState {
blockingError: boolean;
errorMessage: string | null;
warningMessage: string | null;
summary: MediaPipelineSummary | null;
flow: MediaPipelineFlow | null;
issues: MediaPipelineIssues["issues"];
downloads: MediaPipelineDownloads | null;
library: MediaPipelineLibrary | null;
activity: MediaPipelineActivity | null;
recommendations: MediaPipelineRecommendations | null;
}
export interface MediaPipelinePageLoadClient {
getMediaPipelineSummary(refresh?: boolean): Promise<MediaPipelineSummary>;
getMediaPipelineFlow(): Promise<MediaPipelineFlow>;
getMediaPipelineIssues(): Promise<MediaPipelineIssues>;
getMediaPipelineDownloads(): Promise<MediaPipelineDownloads>;
getMediaPipelineLibrary(): Promise<MediaPipelineLibrary>;
getMediaPipelineActivity(): Promise<MediaPipelineActivity>;
getMediaPipelineRecommendations(): Promise<MediaPipelineRecommendations>;
}
export interface StartedMediaPipelinePageLoad {
summary: MediaPipelineSummary;
details: Promise<MediaPipelinePageLoadState>;
}
const reasonMessage = (reason: unknown): string => {
if (reason instanceof Error) return reason.message;
if (typeof reason === "string") return reason;
return "Onbekende fout";
};
const valueOrNull = <T>(result: Settled<T>): T | null => (
result.status === "fulfilled" ? result.value : null
);
export const buildMediaPipelinePageLoadState = (results: MediaPipelinePageLoadResults): MediaPipelinePageLoadState => {
if (results.summary.status === "rejected") {
return {
blockingError: true,
errorMessage: reasonMessage(results.summary.reason),
warningMessage: null,
summary: null,
flow: null,
issues: [],
downloads: null,
library: null,
activity: null,
recommendations: null,
};
}
const failedPanels = ([
["flow", results.flow],
["issues", results.issues],
["downloads", results.downloads],
["library", results.library],
["activity", results.activity],
["recommendations", results.recommendations],
] as const)
.filter(([, result]) => result.status === "rejected")
.map(([label]) => label);
return {
blockingError: false,
errorMessage: null,
warningMessage: failedPanels.length
? `Media Pipeline deels geladen. Ontbrekende panelen: ${failedPanels.join(", ")}. De summary blijft bruikbaar; probeer refresh voor trage connectorpanelen.`
: null,
summary: results.summary.value,
flow: valueOrNull(results.flow),
issues: valueOrNull(results.issues)?.issues ?? [],
downloads: valueOrNull(results.downloads),
library: valueOrNull(results.library),
activity: valueOrNull(results.activity),
recommendations: valueOrNull(results.recommendations),
};
};
export const startMediaPipelinePageLoad = async (
client: MediaPipelinePageLoadClient,
refreshSummary = false,
): Promise<StartedMediaPipelinePageLoad> => {
const summaryPromise = client.getMediaPipelineSummary(refreshSummary);
const detailResults = Promise.allSettled([
client.getMediaPipelineFlow(),
client.getMediaPipelineIssues(),
client.getMediaPipelineDownloads(),
client.getMediaPipelineLibrary(),
client.getMediaPipelineActivity(),
client.getMediaPipelineRecommendations(),
] as const);
const summary = await summaryPromise;
return {
summary,
details: detailResults.then(([flow, issues, downloads, library, activity, recommendations]) => (
buildMediaPipelinePageLoadState({
summary: { status: "fulfilled", value: summary },
flow,
issues,
downloads,
library,
activity,
recommendations,
})
)),
};
};
+82
View File
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
findActiveNavigationGroup,
flattenNavigationGroups,
isNavigationActive,
isNavigationItemActive,
navigationGroups,
} from "./navigationGroups.ts";
const expectedRoutes = [
"/",
"/command-center",
"/jobs",
"/apps",
"/containers",
"/stacks",
"/deployments",
"/runner-fleet",
"/inventory",
"/operations-map",
"/unraid",
"/vms",
"/storage",
"/network",
"/media-pipeline",
"/safety",
"/incidents",
"/policies",
"/approvals",
"/audit",
"/playbooks",
"/notifications",
"/ai",
"/ai/security-reviewer",
"/ai/remediation-planner",
"/integrations",
"/voice",
"/connectors",
"/secrets",
"/settings",
];
describe("navigationGroups", () => {
it("keeps all primary routes present exactly once", () => {
const routes = flattenNavigationGroups().map((item) => item.path);
assert.deepEqual([...routes].sort(), [...expectedRoutes].sort());
assert.equal(new Set(routes).size, routes.length);
});
it("keeps operational groups scan-friendly", () => {
assert.deepEqual(navigationGroups.map((group) => group.label), [
"Core",
"Infrastructure",
"Host & Storage",
"Exposure & Media",
"Safety & Governance",
"AI & Automation",
"Configuration",
]);
assert.ok(navigationGroups.every((group) => group.items.length <= 7));
});
it("activates nested route detail pages under the owning nav item", () => {
assert.equal(isNavigationItemActive("/apps/radarr", "/apps"), true);
assert.equal(isNavigationItemActive("/ai/security-reviewer", "/ai/security-reviewer"), true);
assert.equal(isNavigationItemActive("/ai/security-reviewer", "/ai"), true);
assert.equal(isNavigationItemActive("/settings", "/"), false);
const aiManager = navigationGroups.find((group) => group.label === "AI & Automation")?.items[0];
assert.ok(aiManager);
assert.equal(isNavigationActive("/ai/security-reviewer", { ...aiManager }), false);
assert.equal(isNavigationActive("/ai", { ...aiManager }), true);
});
it("opens the owning group for nested app and AI routes", () => {
assert.equal(findActiveNavigationGroup("/apps/radarr")?.label, "Infrastructure");
assert.equal(findActiveNavigationGroup("/ai/remediation-planner")?.label, "AI & Automation");
assert.equal(findActiveNavigationGroup("/")?.label, "Core");
});
});
+136
View File
@@ -0,0 +1,136 @@
import {
Activity,
AlertTriangle,
BellRing,
Box,
CheckCircle2,
ClipboardList,
Container,
Database,
FileSearch,
GitBranch,
Globe,
HardDrive,
History,
Key,
Layers,
LayoutDashboard,
Map,
Mic,
MonitorCog,
Network,
PlayCircle,
Radar,
Settings,
ShieldAlert,
ShieldCheck,
Sparkles,
Terminal,
Unplug,
type LucideIcon,
} from "lucide-react";
export type NavigationItem = {
icon: LucideIcon;
label: string;
path: string;
activeMatch?: "exact" | "prefix";
};
export type NavigationGroup = {
label: string;
items: NavigationItem[];
};
export const navigationGroups: NavigationGroup[] = [
{
label: "Core",
items: [
{ icon: LayoutDashboard, label: "Overzicht", path: "/" },
{ icon: Terminal, label: "Command Center", path: "/command-center" },
{ icon: Activity, label: "Jobs & Runs", path: "/jobs" },
],
},
{
label: "Infrastructure",
items: [
{ icon: Box, label: "Apps", path: "/apps" },
{ icon: Container, label: "Containers", path: "/containers" },
{ icon: Layers, label: "Stacks", path: "/stacks" },
{ icon: GitBranch, label: "Deployments", path: "/deployments" },
{ icon: Activity, label: "Runner Fleet", path: "/runner-fleet" },
{ icon: Radar, label: "Inventory", path: "/inventory" },
{ icon: Map, label: "Operations Map", path: "/operations-map" },
],
},
{
label: "Host & Storage",
items: [
{ icon: HardDrive, label: "Unraid", path: "/unraid" },
{ icon: MonitorCog, label: "Virtual Machines", path: "/vms" },
{ icon: Database, label: "Storage & Volumes", path: "/storage" },
],
},
{
label: "Exposure & Media",
items: [
{ icon: Globe, label: "Exposure Center", path: "/network" },
{ icon: GitBranch, label: "Media Pipeline", path: "/media-pipeline" },
],
},
{
label: "Safety & Governance",
items: [
{ icon: ShieldAlert, label: "Safety Center", path: "/safety" },
{ icon: AlertTriangle, label: "Incidenten", path: "/incidents" },
{ icon: ShieldCheck, label: "Policies", path: "/policies" },
{ icon: CheckCircle2, label: "Approvals", path: "/approvals" },
{ icon: History, label: "Audit Log", path: "/audit" },
{ icon: PlayCircle, label: "Playbooks", path: "/playbooks" },
{ icon: BellRing, label: "Notificaties", path: "/notifications" },
],
},
{
label: "AI & Automation",
items: [
{ icon: Sparkles, label: "AI Manager", path: "/ai", activeMatch: "exact" },
{ icon: FileSearch, label: "AI Security Reviewer", path: "/ai/security-reviewer" },
{ icon: ClipboardList, label: "AI Remediation Planner", path: "/ai/remediation-planner" },
{ icon: Mic, label: "Voice Bridge", path: "/voice" },
{ icon: Network, label: "MCP, RAG & n8n", path: "/integrations" },
],
},
{
label: "Configuration",
items: [
{ icon: Unplug, label: "Connectors", path: "/connectors" },
{ icon: Key, label: "Secrets Vault", path: "/secrets" },
{ icon: Settings, label: "Instellingen", path: "/settings" },
],
},
];
export const flattenNavigationGroups = (groups: NavigationGroup[] = navigationGroups): NavigationItem[] =>
groups.flatMap((group) => group.items);
export const isNavigationItemActive = (currentPath: string, itemPath: string): boolean => {
if (itemPath === "/") {
return currentPath === "/";
}
return currentPath === itemPath || currentPath.startsWith(`${itemPath}/`);
};
export const isNavigationActive = (currentPath: string, item: NavigationItem): boolean => {
if (item.path === "/" || item.activeMatch === "exact") {
return currentPath === item.path;
}
return isNavigationItemActive(currentPath, item.path);
};
export const findActiveNavigationGroup = (
currentPath: string,
groups: NavigationGroup[] = navigationGroups,
): NavigationGroup | undefined =>
groups.find((group) => group.items.some((item) => isNavigationActive(currentPath, item)));
+58
View File
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { loadNetworkSnapshot, startNetworkSnapshotLoad } from "./networkSnapshot.ts";
const exposureRows = [
{ source: "reverse_proxy" as const, host: "auth.example.com", port: 443, protocol: "https" as const, protected: false, risk: "high" as const },
{ source: "reverse_proxy" as const, host: "plex.example.com", port: 443, protocol: "https" as const, protected: true, risk: "low" as const },
];
describe("loadNetworkSnapshot", () => {
it("keeps canonical exposure rows when optional network detail calls fail", async () => {
const snapshot = await loadNetworkSnapshot({
getNpmProxyHosts: async () => {
throw new Error("npm hosts temporarily unavailable");
},
getNpmCertificates: async () => ({ success: true, status: "ok", items: [], total: 0 }),
getNpmAccessLists: async () => ({ success: true, status: "ok", items: [], total: 0 }),
getNpmExposureSummary: async () => {
throw new Error("summary temporarily unavailable");
},
getNpmSecurityWarnings: async () => ({ success: true, status: "ok", items: [], total: 0 }),
getAuthentikAuthCoverage: async () => {
throw new Error("auth coverage temporarily unavailable");
},
getAuthentikOutpostHealth: async () => ({ success: true, status: "warning", available: false, total: 0, embeddedExpected: true, items: [] }),
getAuthentikSecurityWarnings: async () => ({ success: true, status: "ok", items: [], total: 0 }),
getNetworkExposure: async () => exposureRows,
});
assert.equal(snapshot.partial, true);
assert.deepEqual(snapshot.exposureRows, exposureRows);
assert.equal(snapshot.hosts, null);
assert(snapshot.errors.some((item) => item.source === "npmHosts"));
});
it("returns canonical exposure before slow optional details settle", async () => {
const never = new Promise<never>(() => undefined);
const started = startNetworkSnapshotLoad({
getNpmProxyHosts: () => never,
getNpmCertificates: () => never,
getNpmAccessLists: () => never,
getNpmExposureSummary: () => never,
getNpmSecurityWarnings: () => never,
getAuthentikAuthCoverage: () => never,
getAuthentikOutpostHealth: () => never,
getAuthentikSecurityWarnings: () => never,
getNetworkExposure: async () => exposureRows,
});
const result = await Promise.race([
started.then((value) => value.exposureRows),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 25)),
]);
assert.deepEqual(result, exposureRows);
});
});
+158
View File
@@ -0,0 +1,158 @@
import type {
AuthentikAuthCoverage,
AuthentikOutpostHealth,
AuthentikSecurityWarnings,
NetworkExposure,
NpmAccessLists,
NpmCertificates,
NpmExposureSummary,
NpmProxyHosts,
NpmSecurityWarnings,
} from "@/types/appops";
export type NetworkSnapshotClient = {
getNpmProxyHosts: () => Promise<NpmProxyHosts>;
getNpmCertificates: () => Promise<NpmCertificates>;
getNpmAccessLists: () => Promise<NpmAccessLists>;
getNpmExposureSummary: () => Promise<NpmExposureSummary>;
getNpmSecurityWarnings: () => Promise<NpmSecurityWarnings>;
getAuthentikAuthCoverage: () => Promise<AuthentikAuthCoverage>;
getAuthentikOutpostHealth: () => Promise<AuthentikOutpostHealth>;
getAuthentikSecurityWarnings: () => Promise<AuthentikSecurityWarnings>;
getNetworkExposure: () => Promise<NetworkExposure[]>;
};
export type NetworkSnapshotError = {
source: string;
message: string;
};
export type NetworkSnapshot = {
hosts: NpmProxyHosts | null;
certificates: NpmCertificates | null;
accessLists: NpmAccessLists | null;
summary: NpmExposureSummary | null;
warnings: NpmSecurityWarnings | null;
authCoverage: AuthentikAuthCoverage | null;
outpostHealth: AuthentikOutpostHealth | null;
authWarnings: AuthentikSecurityWarnings | null;
exposureRows: NetworkExposure[];
partial: boolean;
errors: NetworkSnapshotError[];
};
export type StartedNetworkSnapshotLoad = {
exposureRows: NetworkExposure[];
details: Promise<NetworkSnapshot>;
};
const messageFrom = (error: unknown) => (
error instanceof Error ? error.message : "unknown error"
);
const settle = async <T,>(source: string, promise: Promise<T>) => {
try {
return { source, value: await promise, error: null as string | null };
} catch (error) {
return { source, value: null as T | null, error: messageFrom(error) };
}
};
type SettledNetworkPart<T> = {
source: string;
value: T | null;
error: string | null;
};
type NetworkSnapshotParts = {
hosts: SettledNetworkPart<NpmProxyHosts>;
certificates: SettledNetworkPart<NpmCertificates>;
accessLists: SettledNetworkPart<NpmAccessLists>;
summary: SettledNetworkPart<NpmExposureSummary>;
warnings: SettledNetworkPart<NpmSecurityWarnings>;
authCoverage: SettledNetworkPart<AuthentikAuthCoverage>;
outpostHealth: SettledNetworkPart<AuthentikOutpostHealth>;
authWarnings: SettledNetworkPart<AuthentikSecurityWarnings>;
exposureRows: SettledNetworkPart<NetworkExposure[]>;
};
const buildSnapshot = (parts: NetworkSnapshotParts): NetworkSnapshot => {
const allParts = Object.values(parts);
const errors = allParts
.filter((item) => item.error)
.map((item) => ({ source: item.source, message: item.error || "unknown error" }));
return {
hosts: parts.hosts.value,
certificates: parts.certificates.value,
accessLists: parts.accessLists.value,
summary: parts.summary.value,
warnings: parts.warnings.value,
authCoverage: parts.authCoverage.value,
outpostHealth: parts.outpostHealth.value,
authWarnings: parts.authWarnings.value,
exposureRows: parts.exposureRows.value || [],
partial: errors.length > 0,
errors,
};
};
const startOptionalDetails = (client: NetworkSnapshotClient) => Promise.all([
settle("npmHosts", client.getNpmProxyHosts()),
settle("certificates", client.getNpmCertificates()),
settle("accessLists", client.getNpmAccessLists()),
settle("summary", client.getNpmExposureSummary()),
settle("npmWarnings", client.getNpmSecurityWarnings()),
settle("authCoverage", client.getAuthentikAuthCoverage()),
settle("outpostHealth", client.getAuthentikOutpostHealth()),
settle("authWarnings", client.getAuthentikSecurityWarnings()),
] as const);
export const loadNetworkSnapshot = async (client: NetworkSnapshotClient): Promise<NetworkSnapshot> => {
const [
hosts,
certificates,
accessLists,
summary,
warnings,
authCoverage,
outpostHealth,
authWarnings,
exposureRows,
] = await Promise.all([
settle("npmHosts", client.getNpmProxyHosts()),
settle("certificates", client.getNpmCertificates()),
settle("accessLists", client.getNpmAccessLists()),
settle("summary", client.getNpmExposureSummary()),
settle("npmWarnings", client.getNpmSecurityWarnings()),
settle("authCoverage", client.getAuthentikAuthCoverage()),
settle("outpostHealth", client.getAuthentikOutpostHealth()),
settle("authWarnings", client.getAuthentikSecurityWarnings()),
settle("exposure", client.getNetworkExposure()),
]);
return buildSnapshot({ hosts, certificates, accessLists, summary, warnings, authCoverage, outpostHealth, authWarnings, exposureRows });
};
export const startNetworkSnapshotLoad = async (client: NetworkSnapshotClient): Promise<StartedNetworkSnapshotLoad> => {
const exposurePromise = client.getNetworkExposure();
const optionalDetails = startOptionalDetails(client);
const exposureRows = await exposurePromise;
return {
exposureRows,
details: optionalDetails.then(([hosts, certificates, accessLists, summary, warnings, authCoverage, outpostHealth, authWarnings]) => (
buildSnapshot({
hosts,
certificates,
accessLists,
summary,
warnings,
authCoverage,
outpostHealth,
authWarnings,
exposureRows: { source: "exposure", value: exposureRows, error: null },
})
)),
};
};
+60
View File
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { buildNpmAppDetailLoadState } from "./npmAppDetailLoadState.ts";
import type { NpmHealth, NpmProxyHosts } from "../types/appops.ts";
const health: NpmHealth = {
success: true,
status: "connected",
message: "connected",
url: "http://npm.local",
proxyHostCount: 1,
certificateCount: 0,
};
const proxyHosts: NpmProxyHosts = {
success: true,
status: "ok",
message: "proxy_hosts_ok",
total: 1,
items: [
{
id: 1,
domainNames: ["app.example.com"],
forwardHost: "198.18.1.10",
forwardPort: 8080,
forwardScheme: "http",
enabled: true,
sslForced: true,
http2Support: true,
hstsEnabled: false,
certificateId: null,
accessListId: null,
blockExploits: true,
cachingEnabled: false,
websocketSupport: true,
locationsCount: 0,
meta: {},
},
],
};
describe("npmAppDetailLoadState", () => {
it("keeps proxy hosts and health when optional NPM detail reads fail", () => {
const state = buildNpmAppDetailLoadState({
healthResult: { status: "fulfilled", value: health },
hostsResult: { status: "fulfilled", value: proxyHosts },
certificatesResult: { status: "rejected", reason: new Error("certificates failed") },
expiringResult: { status: "rejected", reason: new Error("expiring failed") },
accessListsResult: { status: "rejected", reason: new Error("access lists failed") },
exposureResult: { status: "rejected", reason: new Error("exposure failed") },
warningsResult: { status: "rejected", reason: new Error("warnings failed") },
});
assert.equal(state.health?.status, "connected");
assert.equal(state.proxyHosts?.items.length, 1);
assert.equal(state.exposure?.totalProxyHosts, 1);
assert.match(state.warningMessage || "", /deels geladen/i);
});
});
+82
View File
@@ -0,0 +1,82 @@
import type {
NpmAccessLists,
NpmCertificates,
NpmExposureSummary,
NpmHealth,
NpmProxyHosts,
NpmSecurityWarnings,
} from "../types/appops.ts";
type Settled<T> = PromiseSettledResult<T>;
export interface NpmAppDetailLoadInput {
healthResult: Settled<NpmHealth>;
hostsResult: Settled<NpmProxyHosts>;
certificatesResult: Settled<NpmCertificates>;
expiringResult: Settled<NpmCertificates>;
accessListsResult: Settled<NpmAccessLists>;
exposureResult: Settled<NpmExposureSummary>;
warningsResult: Settled<NpmSecurityWarnings>;
}
export interface NpmAppDetailLoadState {
health: NpmHealth | null;
proxyHosts: NpmProxyHosts;
certificates: NpmCertificates;
expiring: NpmCertificates;
accessLists: NpmAccessLists;
exposure: NpmExposureSummary;
warnings: NpmSecurityWarnings;
warningMessage: string | null;
}
const isRejected = <T>(result: Settled<T>): result is PromiseRejectedResult => result.status === "rejected";
const emptyHosts = (): NpmProxyHosts => ({ success: true, status: "ok", message: "proxy_hosts_empty", total: 0, items: [] });
const emptyCertificates = (message: string): NpmCertificates => ({ success: true, status: "ok", message, total: 0, items: [] });
const emptyAccessLists = (): NpmAccessLists => ({ success: true, status: "ok", message: "access_lists_empty", total: 0, items: [] });
const emptyWarnings = (): NpmSecurityWarnings => ({ success: true, status: "ok", message: "warnings_empty", total: 0, items: [] });
const exposureFromHosts = (hosts: NpmProxyHosts, warnings: NpmSecurityWarnings): NpmExposureSummary => {
const items = hosts.items || [];
return {
success: true,
status: "ok",
message: "exposure_derived_from_proxy_hosts",
totalProxyHosts: items.length,
enabledProxyHosts: items.filter((host) => host.enabled).length,
publicHosts: items.filter((host) => host.enabled).length,
hostsWithoutCertificate: items.filter((host) => !host.certificateId).length,
hostsWithExpiredCertificate: 0,
hostsWithExpiringCertificate: 0,
hostsWithoutAccessList: items.filter((host) => !host.accessListId).length,
hostsWithoutSslForce: items.filter((host) => !host.sslForced).length,
hostsWithWebsocket: items.filter((host) => host.websocketSupport).length,
warnings: warnings.items || [],
};
};
export const buildNpmAppDetailLoadState = ({
healthResult,
hostsResult,
certificatesResult,
expiringResult,
accessListsResult,
exposureResult,
warningsResult,
}: NpmAppDetailLoadInput): NpmAppDetailLoadState => {
const hosts = hostsResult.status === "fulfilled" ? hostsResult.value : emptyHosts();
const warnings = warningsResult.status === "fulfilled" ? warningsResult.value : emptyWarnings();
const failures = [healthResult, hostsResult, certificatesResult, expiringResult, accessListsResult, exposureResult, warningsResult].filter(isRejected).length;
return {
health: healthResult.status === "fulfilled" ? healthResult.value : null,
proxyHosts: hosts,
certificates: certificatesResult.status === "fulfilled" ? certificatesResult.value : emptyCertificates("certificates_unavailable"),
expiring: expiringResult.status === "fulfilled" ? expiringResult.value : emptyCertificates("expiring_certificates_unavailable"),
accessLists: accessListsResult.status === "fulfilled" ? accessListsResult.value : emptyAccessLists(),
exposure: exposureResult.status === "fulfilled" ? exposureResult.value : exposureFromHosts(hosts, warnings),
warnings,
warningMessage: failures > 0 ? "NPM AppDetail deels geladen. Een of meer optionele NPM-detailbronnen zijn tijdelijk niet beschikbaar." : null,
};
};
+111
View File
@@ -0,0 +1,111 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { buildOperationMapEmptyState, buildOperationMapSourceSummary, filterOperationMap, getOperationImpact } from "./operationMapView.ts";
const map = {
nodes: ["Nginx Proxy Manager", "Authentik", "Widefrog", "Media Shares"],
edges: [
{ from: "Nginx Proxy Manager", to: "Authentik" },
{ from: "Widefrog", to: "Media Shares" },
],
};
describe("filterOperationMap", () => {
it("filters nodes and edges by query and impact while keeping connected nodes visible", () => {
const focused = filterOperationMap(map, { query: "auth", impact: "all" });
assert.deepEqual(focused.nodes, ["Nginx Proxy Manager", "Authentik"]);
assert.deepEqual(focused.edges, [{ from: "Nginx Proxy Manager", to: "Authentik" }]);
const high = filterOperationMap(map, { query: "", impact: "high" });
assert.deepEqual(high.nodes, ["Nginx Proxy Manager", "Authentik"]);
assert.deepEqual(high.edges, [{ from: "Nginx Proxy Manager", to: "Authentik" }]);
});
it("classifies exposed/auth edges as high impact", () => {
assert.equal(getOperationImpact({ from: "Nginx Proxy Manager", to: "Authentik" }), "high");
assert.equal(getOperationImpact({ from: "Widefrog", to: "Media Shares" }), "low");
});
});
describe("buildOperationMapEmptyState", () => {
it("builds safe follow-up links for focused dependency handoffs", () => {
const state = buildOperationMapEmptyState({ totalEdges: 2, visibleEdges: 0, query: "plex", impact: "high" });
assert.equal(state.title, "Geen afhankelijkheden voor deze filter");
assert.match(state.message, /plex/);
assert.deepEqual(state.actions, [
{ label: "Toon volledige map", href: "/operations-map" },
{ label: "Open Apps", href: "/apps?query=plex" },
{ label: "Zoek in Audit", href: "/audit?query=plex" },
{ label: "Open Safety Center", href: "/safety" },
]);
});
it("keeps an empty dependency graph explicit", () => {
const state = buildOperationMapEmptyState({ totalEdges: 0, visibleEdges: 0, query: "", impact: "all" });
assert.equal(state.title, "Geen dependency edges beschikbaar");
assert.deepEqual(state.actions, [
{ label: "Open Inventory", href: "/inventory" },
{ label: "Open Connectors", href: "/connectors" },
]);
});
});
describe("buildOperationMapSourceSummary", () => {
it("describes the operations map as canonical dependency context instead of discovery history", () => {
const summary = buildOperationMapSourceSummary(map);
assert.equal(summary.title, "Canonical dependencykaart");
assert.deepEqual(summary.stats, [
{ label: "Nodes", value: "4" },
{ label: "Edges", value: "2" },
{ label: "Bron", value: "apps/connectors/policies" },
]);
assert.match(summary.message, /connectors/i);
assert.match(summary.message, /policies/i);
assert.match(summary.message, /Inventory/i);
assert.deepEqual(summary.actions, [
{ label: "Open Inventory", href: "/inventory" },
{ label: "Open Containers", href: "/containers" },
{ label: "Open Safety", href: "/safety" },
]);
});
it("uses backend source metadata when the operations map provides it", () => {
const summary = buildOperationMapSourceSummary({
...map,
generatedAt: "2026-07-10T00:00:00Z",
sources: {
apps: { status: "ok", count: 80 },
connectors: { status: "ok", count: 12 },
policies: { status: "ok", count: 42 },
secrets: { status: "metadata_only", count: 9 },
},
});
assert.deepEqual(summary.stats, [
{ label: "Nodes", value: "4" },
{ label: "Edges", value: "2" },
{ label: "Sources", value: "80 apps / 12 connectors / 42 policies" },
]);
assert.match(summary.message, /9 secret refs/i);
assert.match(summary.message, /10-7-2026/);
});
it("formats generatedAt in the operator timezone instead of raw UTC date", () => {
const summary = buildOperationMapSourceSummary({
...map,
generatedAt: "2026-07-09T23:12:12Z",
sources: {
apps: { status: "ok", count: 80 },
connectors: { status: "ok", count: 12 },
policies: { status: "ok", count: 42 },
secrets: { status: "metadata_only", count: 9 },
},
});
assert.match(summary.message, /10-7-2026/);
});
});
+157
View File
@@ -0,0 +1,157 @@
export interface OperationEdge {
from: string;
to: string;
}
export interface OperationMapData {
nodes: string[];
edges: OperationEdge[];
generatedAt?: string;
sources?: {
apps?: OperationMapSource;
connectors?: OperationMapSource;
policies?: OperationMapSource;
secrets?: OperationMapSource;
};
}
export type OperationImpact = "high" | "medium" | "low";
export type OperationImpactFilter = "all" | OperationImpact;
export type OperationMapSourceStatus = "ok" | "metadata_only" | "partial" | "error" | string;
export interface OperationMapSource {
status: OperationMapSourceStatus;
count: number;
}
export interface OperationMapFilterOptions {
query: string;
impact: OperationImpactFilter;
}
export interface OperationMapEmptyState {
title: string;
message: string;
actions: Array<{ label: string; href: string }>;
}
export interface OperationMapSourceSummary {
title: string;
message: string;
stats: Array<{ label: string; value: string }>;
actions: Array<{ label: string; href: string }>;
}
const highRiskTargets = new Set(["Authentik", "Home Assistant", "PostgreSQL", "Nginx Proxy Manager"]);
const formatGeneratedAt = (value?: string): string | null => {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
const parts = new Intl.DateTimeFormat("nl-BE", {
day: "numeric",
month: "numeric",
year: "numeric",
timeZone: "Europe/Brussels",
}).formatToParts(date);
const byType = Object.fromEntries(parts.map(part => [part.type, part.value]));
return `${byType.day}-${byType.month}-${byType.year}`;
};
export const getOperationImpact = (edge: OperationEdge): OperationImpact => {
if (highRiskTargets.has(edge.to)) return "high";
if (highRiskTargets.has(edge.from)) return "medium";
return "low";
};
export const filterOperationMap = (
mapData: OperationMapData,
options: OperationMapFilterOptions,
): OperationMapData => {
const query = options.query.trim().toLowerCase();
const edges = mapData.edges.filter(edge => {
if (options.impact !== "all" && getOperationImpact(edge) !== options.impact) return false;
if (!query) return true;
return [edge.from, edge.to].some(value => value.toLowerCase().includes(query));
});
const connectedNodes = new Set(edges.flatMap(edge => [edge.from, edge.to]));
const standaloneNodes = mapData.nodes.filter(node => {
if (connectedNodes.has(node)) return true;
return query && node.toLowerCase().includes(query) && options.impact === "all";
});
return {
nodes: standaloneNodes,
edges,
};
};
export const buildOperationMapEmptyState = (
options: { totalEdges: number; visibleEdges: number; query: string; impact: OperationImpactFilter },
): OperationMapEmptyState => {
const query = options.query.trim();
if (options.totalEdges === 0) {
return {
title: "Geen dependency edges beschikbaar",
message: "De Operations Map heeft nog geen dependency-relaties geladen. Gebruik Inventory en Connectors om discovery-context op te bouwen.",
actions: [
{ label: "Open Inventory", href: "/inventory" },
{ label: "Open Connectors", href: "/connectors" },
],
};
}
if (query || options.impact !== "all") {
return {
title: "Geen afhankelijkheden voor deze filter",
message: query
? `Er is geen dependency-relatie gevonden voor "${query}". Gebruik Apps, Audit of Safety voor dezelfde operatorcontext.`
: "Er zijn dependency-relaties beschikbaar, maar niet voor deze impactfilter.",
actions: [
{ label: "Toon volledige map", href: "/operations-map" },
{ label: "Open Apps", href: query ? `/apps?query=${encodeURIComponent(query)}` : "/apps" },
...(query ? [{ label: "Zoek in Audit", href: `/audit?query=${encodeURIComponent(query)}` }] : []),
{ label: "Open Safety Center", href: "/safety" },
],
};
}
return {
title: "Geen zichtbare afhankelijkheden",
message: "De huidige selectie bevat geen dependency-relaties. Toon de volledige map of open Inventory voor discovery-context.",
actions: [
{ label: "Toon volledige map", href: "/operations-map" },
{ label: "Open Inventory", href: "/inventory" },
],
};
};
export const buildOperationMapSourceSummary = (mapData: OperationMapData): OperationMapSourceSummary => {
const sources = mapData.sources;
const generatedLabel = formatGeneratedAt(mapData.generatedAt);
const hasSourceCounts = Boolean(sources?.apps && sources?.connectors && sources?.policies);
const sourceStats = hasSourceCounts
? `${sources?.apps?.count ?? 0} apps / ${sources?.connectors?.count ?? 0} connectors / ${sources?.policies?.count ?? 0} policies`
: "apps/connectors/policies";
const secretCount = sources?.secrets?.count;
const secretContext = typeof secretCount === "number"
? ` ${secretCount} secret refs worden alleen als metadata meegeteld.`
: " Secret-referenties blijven metadata-only.";
const generatedContext = generatedLabel ? ` Laatst opgebouwd: ${generatedLabel}.` : "";
return {
title: "Canonical dependencykaart",
message: `Gebaseerd op canonical apps, connectors, policies en secret-referenties.${secretContext}${generatedContext} Discoveryhistoriek hoort in Inventory; actuele runtime-state hoort in Containers.`,
stats: [
{ label: "Nodes", value: String(mapData.nodes.length) },
{ label: "Edges", value: String(mapData.edges.length) },
{ label: hasSourceCounts ? "Sources" : "Bron", value: sourceStats },
],
actions: [
{ label: "Open Inventory", href: "/inventory" },
{ label: "Open Containers", href: "/containers" },
{ label: "Open Safety", href: "/safety" },
],
};
};
+141
View File
@@ -0,0 +1,141 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { Incident, NotificationSummary, SafetyDashboard, SetupStatus } from "@/types/appops";
import { buildOperatorActionQueue } from "./operatorActionQueue.ts";
const setupStatus = (overrides: Partial<SetupStatus>): SetupStatus => ({
firstRunCompleted: true,
setupComplete: true,
liveReady: true,
coverageComplete: true,
securityCoveragePartial: false,
requiredStepsComplete: true,
optionalStepsComplete: true,
reviewBlockers: 0,
managedApps: 12,
needsReview: 0,
missingOptionalSources: [],
steps: [],
warnings: [],
nextActions: [],
blockers: [],
summary: {
containersDiscovered: 20,
appsManaged: 12,
appsNeedReview: 0,
connectorsMissingConfig: 0,
},
...overrides,
});
const safetyDashboard = (overrides: Partial<SafetyDashboard>): SafetyDashboard => ({
summary: {
status: "healthy",
score: 95,
generatedAt: "2026-07-08T12:00:00Z",
dataCompleteness: { percentage: 100, connectedSources: [], missingSources: [] },
coverageStatus: {},
criticalCount: 0,
highCount: 0,
mediumCount: 0,
warningCount: 0,
recommendationCount: 0,
connectedSources: [],
missingSources: [],
sections: [],
},
sections: [],
findings: [],
recommendations: [],
scoreBreakdown: {
baseScore: 100,
finalScore: 95,
penalties: [],
missingCoverageImpact: "none",
criticalEvidence: [],
explanation: "ok",
missingSources: [],
dataCompleteness: { percentage: 100, connectedSources: [], missingSources: [] },
coverageStatus: {},
},
coverageSources: [],
generatedAt: "2026-07-08T12:00:00Z",
cache: { hit: false, ttlSeconds: 300, expiresAt: "2026-07-08T12:05:00Z", source: "fresh", generationDurationMs: 10 },
...overrides,
});
const notifications = (overrides: Partial<NotificationSummary>): NotificationSummary => ({
unreadCount: 0,
activeRulesCount: 2,
suppressedCount: 0,
failedCount: 0,
channels: [],
...overrides,
});
const incident = (overrides: Partial<Incident>): Incident => ({
id: "inc-1",
severity: "high",
status: "open",
title: "Container restart loop",
detectedTime: "2026-07-08T12:00:00Z",
evidence: {
source: "Portainer",
check: "restart_count",
observed: "5 restarts",
expected: "0 restarts",
timestamp: "2026-07-08T12:00:00Z",
},
suggestedActions: ["container.restart"],
...overrides,
});
describe("buildOperatorActionQueue", () => {
it("prioritizes required setup blockers before safety and notifications", () => {
const queue = buildOperatorActionQueue({
setup: setupStatus({
setupComplete: false,
liveReady: false,
requiredStepsComplete: false,
blockers: ["portainer_missing"],
nextActions: [{ id: "connect_portainer", required: true }],
}),
safety: safetyDashboard({
findings: [{
id: "safety-1",
title: "Public host without Authentik",
description: "Host is public without identity coverage.",
severity: "critical",
category: "exposure",
affectedResources: ["public.example.com"],
evidence: [],
suggestedActions: [{ label: "Open Exposure", actionType: "open_page", href: "/network", executable: false }],
source: "Safety Center",
status: "open",
}],
}),
notifications: notifications({ unreadCount: 3 }),
incidents: [incident({ id: "inc-2" })],
});
assert.equal(queue.items[0].source, "setup");
assert.equal(queue.items[0].href, "/setup");
assert.equal(queue.items[1].source, "safety");
assert.equal(queue.summaryLabel, "4 acties");
});
it("keeps healthy systems actionable but calm", () => {
const queue = buildOperatorActionQueue({
setup: setupStatus({}),
safety: safetyDashboard({}),
notifications: notifications({}),
incidents: [],
});
assert.equal(queue.status, "clear");
assert.equal(queue.items.length, 1);
assert.equal(queue.items[0].title, "Geen directe operatoractie");
assert.equal(queue.items[0].href, "/safety");
});
});
+184
View File
@@ -0,0 +1,184 @@
import type { Incident, NotificationSummary, SafetyDashboard, SafetySeverity, SetupStatus } from "@/types/appops";
export type OperatorActionSource = "setup" | "safety" | "notifications" | "incidents" | "system";
export type OperatorActionTone = "critical" | "high" | "medium" | "low" | "clear";
export type OperatorQueueStatus = "critical" | "attention" | "clear";
export interface OperatorActionQueueItem {
id: string;
source: OperatorActionSource;
tone: OperatorActionTone;
title: string;
description: string;
href: string;
meta: string;
}
export interface OperatorActionQueue {
status: OperatorQueueStatus;
title: string;
summaryLabel: string;
items: OperatorActionQueueItem[];
}
interface BuildOperatorActionQueueInput {
setup: SetupStatus | null;
safety: SafetyDashboard | null;
notifications: NotificationSummary | null;
incidents: Incident[];
maxItems?: number;
}
const severityRank: Record<SafetySeverity | string, number> = {
critical: 5,
high: 4,
medium: 3,
low: 2,
info: 1,
};
const toneFromSeverity = (severity: SafetySeverity | string): OperatorActionTone => {
if (severity === "critical") return "critical";
if (severity === "high") return "high";
if (severity === "medium") return "medium";
return "low";
};
const humanizeActionId = (id: string): string => id
.replace(/^configure_optional_/, "Configureer ")
.replace(/^configure_/, "Configureer ")
.replace(/_/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase());
const setupActionHref = (id: string): string => {
const connector = id.replace(/^configure_optional_/, "").replace(/^configure_/, "");
if (connector && connector !== id && connector !== "setup") {
return `/setup?step=connectors&connector=${encodeURIComponent(connector)}`;
}
return "/setup";
};
const queueStatus = (items: OperatorActionQueueItem[]): OperatorQueueStatus => {
if (items.some((item) => item.tone === "critical")) return "critical";
if (items.some((item) => item.tone === "high" || item.tone === "medium")) return "attention";
return "clear";
};
export const buildOperatorActionQueue = ({
setup,
safety,
notifications,
incidents,
maxItems = 5,
}: BuildOperatorActionQueueInput): OperatorActionQueue => {
const items: OperatorActionQueueItem[] = [];
if (setup && (!setup.requiredStepsComplete || !setup.liveReady || setup.blockers.length > 0)) {
const action = setup.nextActions.find((item) => item.required) ?? setup.nextActions[0];
items.push({
id: `setup:${action?.id ?? "required"}`,
source: "setup",
tone: "critical",
title: action ? humanizeActionId(action.id) : "Required setup afronden",
description: setup.blockers[0] || "Required setup is nog niet live-ready.",
href: action ? setupActionHref(action.id) : "/setup",
meta: "required setup",
});
}
if (setup && setup.liveReady && setup.missingOptionalSources.length > 0) {
const source = setup.missingOptionalSources[0];
items.push({
id: `setup:optional:${source}`,
source: "setup",
tone: "low",
title: `Optionele coverage: ${humanizeActionId(source)}`,
description: "Live-ready blijft groen, maar connectorcoverage kan rijker.",
href: `/setup?step=connectors&connector=${encodeURIComponent(source)}`,
meta: `${setup.missingOptionalSources.length} optioneel ontbrekend`,
});
}
const openFindings = (safety?.findings ?? [])
.filter((finding) => finding.status === "open")
.sort((a, b) => (severityRank[b.severity] ?? 0) - (severityRank[a.severity] ?? 0));
for (const finding of openFindings.slice(0, 3)) {
items.push({
id: `safety:${finding.id}`,
source: "safety",
tone: toneFromSeverity(finding.severity),
title: finding.title,
description: finding.description,
href: finding.suggestedActions.find((action) => action.href)?.href ?? "/safety",
meta: `${finding.severity} / ${finding.category}`,
});
}
if (notifications && notifications.failedCount > 0) {
items.push({
id: "notifications:failed",
source: "notifications",
tone: "high",
title: "Notificatie delivery faalt",
description: `${notifications.failedCount} notificatie(s) hebben een failed status.`,
href: "/notifications",
meta: "delivery",
});
} else if (notifications && notifications.unreadCount > 0) {
items.push({
id: "notifications:unread",
source: "notifications",
tone: "medium",
title: "Ongelezen notificaties",
description: `${notifications.unreadCount} dashboardmelding(en) wachten op review.`,
href: "/notifications",
meta: "dashboard",
});
}
const openIncidents = incidents
.filter((incident) => incident.status === "open")
.sort((a, b) => (severityRank[b.severity] ?? 0) - (severityRank[a.severity] ?? 0));
for (const incident of openIncidents.slice(0, 2)) {
items.push({
id: `incident:${incident.id}`,
source: "incidents",
tone: toneFromSeverity(incident.severity),
title: incident.title,
description: `${incident.evidence.observed} / expected ${incident.evidence.expected}`,
href: "/incidents",
meta: `${incident.severity} incident`,
});
}
const sorted = items
.sort((a, b) => {
const rank = { critical: 5, high: 4, medium: 3, low: 2, clear: 1 };
return rank[b.tone] - rank[a.tone];
})
.slice(0, maxItems);
if (sorted.length === 0) {
sorted.push({
id: "system:clear",
source: "system",
tone: "clear",
title: "Geen directe operatoractie",
description: "Setup, safety, notificaties en incidenten vragen nu geen directe interventie.",
href: "/safety",
meta: "clear",
});
}
const status = queueStatus(sorted);
return {
status,
title: status === "critical" ? "Directe operatoractie vereist" : status === "attention" ? "Actiequeue" : "Geen directe actie",
summaryLabel: sorted.length === 1 && sorted[0].tone === "clear"
? "clear"
: `${sorted.length} ${sorted.length === 1 ? "actie" : "acties"}`,
items: sorted,
};
};
+20
View File
@@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { formatOperatorError } from "./operatorError.ts";
describe("formatOperatorError", () => {
it("turns an HTML or JSON parser response into operator copy", () => {
const result = formatOperatorError(
new Error("Unexpected token '<', \"<!doctype html>\" is not valid JSON"),
"Operations map laden mislukt.",
);
assert.equal(result, "Operations map laden mislukt. De backendbron gaf geen geldige API-respons.");
assert.doesNotMatch(result, /doctype|unexpected token/i);
});
it("keeps a concise backend message", () => {
assert.equal(formatOperatorError(new Error("Portainer is niet bereikbaar."), "Laden mislukt."), "Portainer is niet bereikbaar.");
});
});
+16
View File
@@ -0,0 +1,16 @@
const invalidApiResponse = /unexpected token|doctype|not valid json|json parse|syntaxerror/i;
const htmlResponse = /<html|<!doctype|<body/i;
export const formatOperatorError = (error: unknown, fallback: string): string => {
const raw = error instanceof Error ? error.message : String(error || "");
const normalized = raw.trim();
if (!normalized) return fallback;
if (invalidApiResponse.test(normalized) || htmlResponse.test(normalized)) {
return `${fallback} De backendbron gaf geen geldige API-respons.`;
}
if (normalized.length > 240) {
return `${normalized.slice(0, 237)}...`;
}
return normalized;
};
+49
View File
@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { SafetyFinding } from "@/types/appops";
import { selectTopSafetyIssue } from "./overviewTopIssue.ts";
const finding = (overrides: Partial<SafetyFinding> = {}): SafetyFinding => ({
id: "finding-medium",
title: "Medium issue",
description: "Medium issue description",
severity: "medium",
category: "connectors",
affectedResources: ["npm"],
evidence: [],
suggestedActions: [],
source: "Safety Center",
status: "open",
...overrides,
});
describe("selectTopSafetyIssue", () => {
it("selects the highest-severity open finding", () => {
const result = selectTopSafetyIssue([
finding({ id: "low", severity: "low", title: "Low issue" }),
finding({ id: "high", severity: "high", title: "High issue" }),
finding({ id: "medium", severity: "medium", title: "Medium issue" }),
]);
assert.equal(result?.id, "high");
});
it("ignores findings that are not open", () => {
const result = selectTopSafetyIssue([
finding({ id: "critical-dismissed", severity: "critical", status: "dismissed" }),
finding({ id: "critical-acknowledged", severity: "critical", status: "acknowledged" }),
finding({ id: "medium-open", severity: "medium", status: "open" }),
]);
assert.equal(result?.id, "medium-open");
});
it("returns null when there are no open findings", () => {
const result = selectTopSafetyIssue([
finding({ id: "resolved", severity: "critical", status: "resolved" }),
]);
assert.equal(result, null);
});
});
+16
View File
@@ -0,0 +1,16 @@
import type { SafetyFinding, SafetySeverity } from "@/types/appops";
const severityRank: Record<SafetySeverity, number> = {
critical: 5,
high: 4,
medium: 3,
low: 2,
info: 1,
};
export const selectTopSafetyIssue = (findings: SafetyFinding[]): SafetyFinding | null => {
const openFindings = findings.filter((finding) => finding.status === "open");
if (!openFindings.length) return null;
return [...openFindings].sort((left, right) => severityRank[right.severity] - severityRank[left.severity])[0];
};
+63
View File
@@ -0,0 +1,63 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { Playbook } from "../types/appops.ts";
import { buildPlaybookEmptyState, filterPlaybooks } from "./playbookView.ts";
const playbook = (overrides: Partial<Playbook> = {}): Playbook => ({
id: "pb-1",
name: "Restart unhealthy container",
description: "Policy-gated restart with audit checks.",
risk: "medium",
allowedSources: ["manual", "automation"],
requiredConnectors: ["portainer"],
steps: ["Review health", "Request approval", "Restart container"],
successRate: 92,
...overrides,
});
describe("filterPlaybooks", () => {
it("matches playbooks by query, risk and source", () => {
const rows = [
playbook(),
playbook({
id: "pb-2",
name: "Review media downloads",
description: "Inspect stalled Deluge downloads.",
risk: "low",
allowedSources: ["manual"],
requiredConnectors: ["deluge"],
steps: ["Open media pipeline", "Review stalled downloads"],
}),
];
assert.deepEqual(filterPlaybooks(rows, { query: "portainer", risk: "all", source: "all" }).map(item => item.id), ["pb-1"]);
assert.deepEqual(filterPlaybooks(rows, { query: "stalled", risk: "low", source: "manual" }).map(item => item.id), ["pb-2"]);
assert.deepEqual(filterPlaybooks(rows, { query: "", risk: "all", source: "automation" }).map(item => item.id), ["pb-1"]);
});
});
describe("buildPlaybookEmptyState", () => {
it("turns an unmatched focused playbook query into operator follow-up links", () => {
const state = buildPlaybookEmptyState({ totalPlaybooks: 3, visiblePlaybooks: 0, query: "deluge", risk: "high", source: "all" });
assert.equal(state.title, "Geen playbooks voor deze filter");
assert.match(state.message, /deluge/);
assert.deepEqual(state.actions, [
{ label: "Toon alle playbooks", href: "/playbooks" },
{ label: "Command Center", href: "/command-center?query=deluge" },
{ label: "Zoek in Audit", href: "/audit?query=deluge" },
{ label: "Open Safety Center", href: "/safety" },
]);
});
it("describes an empty catalog without fabricating executable playbooks", () => {
const state = buildPlaybookEmptyState({ totalPlaybooks: 0, visiblePlaybooks: 0, query: "", risk: "all", source: "all" });
assert.equal(state.title, "Geen playbooks beschikbaar");
assert.deepEqual(state.actions, [
{ label: "Command Center", href: "/command-center" },
{ label: "Open Policies", href: "/policies" },
]);
});
});
+82
View File
@@ -0,0 +1,82 @@
import type { Playbook, SourceType } from "../types/appops.ts";
export interface PlaybookFilterOptions {
query: string;
risk: string;
source: string;
}
export interface PlaybookEmptyStateAction {
label: string;
href: string;
}
export interface PlaybookEmptyState {
title: string;
message: string;
actions: PlaybookEmptyStateAction[];
}
export const filterPlaybooks = (
playbooks: Playbook[],
options: PlaybookFilterOptions,
): Playbook[] => {
const query = options.query.trim().toLowerCase();
return playbooks.filter(playbook => {
if (options.risk !== "all" && playbook.risk !== options.risk) return false;
if (options.source !== "all" && !playbook.allowedSources.includes(options.source as SourceType)) return false;
if (!query) return true;
return [
playbook.id,
playbook.name,
playbook.description,
playbook.risk,
...playbook.allowedSources,
...playbook.requiredConnectors,
...playbook.steps,
].some(value => String(value).toLowerCase().includes(query));
});
};
export const buildPlaybookEmptyState = (
options: { totalPlaybooks: number; visiblePlaybooks: number; query: string; risk: string; source: string },
): PlaybookEmptyState => {
const query = options.query.trim();
if (options.totalPlaybooks === 0) {
return {
title: "Geen playbooks beschikbaar",
message: "Er zijn geen playbooks geladen. Playbooks blijven catalogus-only totdat een policy/approval execution design beschikbaar is.",
actions: [
{ label: "Command Center", href: "/command-center" },
{ label: "Open Policies", href: "/policies" },
],
};
}
if (query || options.risk !== "all" || options.source !== "all") {
return {
title: "Geen playbooks voor deze filter",
message: query
? `Er is geen playbook gevonden voor "${query}". Gebruik Command Center, Audit of Safety om dezelfde operatorcontext verder te volgen.`
: "Er zijn playbooks beschikbaar, maar niet voor deze risk/source-combinatie.",
actions: [
{ label: "Toon alle playbooks", href: "/playbooks" },
{ label: "Command Center", href: query ? `/command-center?query=${encodeURIComponent(query)}` : "/command-center" },
...(query ? [{ label: "Zoek in Audit", href: `/audit?query=${encodeURIComponent(query)}` }] : []),
{ label: "Open Safety Center", href: "/safety" },
],
};
}
return {
title: "Geen zichtbare playbooks",
message: "De huidige selectie bevat geen playbooks. Toon alles of gebruik Command Center voor policy-aware intent preview.",
actions: [
{ label: "Toon alle playbooks", href: "/playbooks" },
{ label: "Command Center", href: "/command-center" },
],
};
};
Loaded 100 of 528 files, more files were not shown because too many files have changed in this diff. Show more