Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d
This commit is contained in:
commit
1dfbca08a1
528 files changed
+131054
No files matched your search
@@ -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"/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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")));
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
};
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: [],
|
||||
};
|
||||
};
|
||||
@@ -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" }]);
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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>,
|
||||
};
|
||||
};
|
||||
@@ -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, []);
|
||||
});
|
||||
});
|
||||
@@ -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" }],
|
||||
};
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export const normalizeCommandCenterQuery = (query: string | null): string => {
|
||||
return (query || "").trim().slice(0, 120);
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
})),
|
||||
}));
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export const buildConnectorConfigLink = (connectorId: string): string => (
|
||||
`/setup?step=connectors&connector=${encodeURIComponent(connectorId)}`
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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.`,
|
||||
};
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export const connectorPanelStatus = (status?: string | null): string => {
|
||||
if (!status) return "checking";
|
||||
return status === "connected" ? "healthy" : status;
|
||||
};
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export const isIgnorableLoadError = (error: unknown): boolean => {
|
||||
if (error instanceof DOMException && error.name === "AbortError") return true;
|
||||
return false;
|
||||
};
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
};
|
||||
};
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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) },
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)));
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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" }]);
|
||||
});
|
||||
});
|
||||
@@ -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" }],
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
})
|
||||
)),
|
||||
};
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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)));
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
})
|
||||
)),
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in new issue
Block a user